diff --git a/HELPDESK_INIT_V2.md b/HELPDESK_INIT_V2.md index ef34470..df5de6f 100644 --- a/HELPDESK_INIT_V2.md +++ b/HELPDESK_INIT_V2.md @@ -28,7 +28,8 @@ ### **Pridané:** - ✅ Configuration-driven architecture -- ✅ ROOT Settings panel +- ✅ ROOT/ADMIN Settings panel +- ✅ User Management (CRUD, reset hesla, zmena roly) - ✅ External DB import pre zákazníkov - ✅ Dynamic workflow rules - ✅ Multi-entity tagging system @@ -173,6 +174,10 @@ model User { reminders Reminder[] activityLogs ActivityLog[] + // Comments & Notifications + comments Comment[] + notifications Notification[] + // Equipment createdEquipment Equipment[] @relation("EquipmentCreator") performedRevisions Revision[] @@ -511,11 +516,12 @@ model Task { createdById String createdBy User @relation("TaskCreator", fields: [createdById], references: [id]) - assignees TaskAssignee[] - reminders Reminder[] - comments Comment[] - tags TaskTag[] - + assignees TaskAssignee[] + reminders Reminder[] + comments Comment[] + tags TaskTag[] + notifications Notification[] + @@index([projectId]) @@index([parentId]) @@index([statusId]) @@ -572,18 +578,51 @@ model Comment { id String @id @default(cuid()) taskId String userId String - + task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) - + user User @relation(fields: [userId], references: [id]) + content String @db.Text - + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + @@index([taskId]) @@index([createdAt]) } +// ==================== NOTIFICATIONS ==================== + +model Notification { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type String // TASK_ASSIGNED, TASK_STATUS_CHANGED, TASK_COMMENT, etc. + title String + message String // Prázdne pre TASK_COMMENT - text sa načíta z Comment tabuľky + + // Odkazy na entity + taskId String? + task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade) + rmaId String? + rma RMA? @relation(fields: [rmaId], references: [id], onDelete: Cascade) + + // Dodatočné dáta (JSON) - napr. commentId, actorName, oldStatus, newStatus + data Json? + + isRead Boolean @default(false) + readAt DateTime? + snoozedUntil DateTime? // Odloženie notifikácie + + createdAt DateTime @default(now()) + + @@index([userId, isRead]) + @@index([userId, createdAt]) + @@index([taskId]) + @@index([rmaId]) +} + // ==================== EQUIPMENT MANAGEMENT ==================== model Equipment { @@ -760,7 +799,8 @@ model RMA { statusHistory RMAStatusHistory[] comments RMAComment[] tags RMATag[] - + notifications Notification[] + @@index([rmaNumber]) @@index([customerId]) @@index([statusId]) @@ -910,15 +950,16 @@ POST /api/auth/logout GET /api/auth/me ``` -### Users +### Users (ROOT/ADMIN) ``` GET /api/users // Stránkovaný zoznam (admin only) GET /api/users/simple // Jednoduchý zoznam pre selecty (server-side search: ?search=meno) +POST /api/users // Vytvorenie používateľa (admin only) GET /api/users/:id -PUT /api/users/:id -DELETE /api/users/:id -PATCH /api/users/:id/role +PUT /api/users/:id // Úprava + reset hesla +DELETE /api/users/:id // Soft delete (deaktivácia) +PATCH /api/users/:id/role // Zmena roly ``` ### Projects @@ -1004,7 +1045,7 @@ GET /api/rma/:id/pdf // Generate PDF GET /api/rma/generate-number // Next RMA number ``` -### **🆕 Settings (ROOT only)** +### **🆕 Settings (ROOT/ADMIN)** ``` // Equipment Types @@ -1064,6 +1105,17 @@ PUT /api/settings/roles/:id DELETE /api/settings/roles/:id ``` +### **🆕 Notifications** + +``` +GET /api/notifications // Zoznam notifikácií (limit, offset, unreadOnly) +GET /api/notifications/unread-count // Počet neprečítaných +POST /api/notifications/:id/read // Označiť ako prečítané +POST /api/notifications/mark-all-read // Označiť všetky ako prečítané +POST /api/notifications/:id/snooze // Odložiť notifikáciu (minutes) +DELETE /api/notifications/:id // Vymazať notifikáciu +``` + ### Dashboard ``` @@ -1100,6 +1152,9 @@ src/ │ │ ├── Sidebar.tsx │ │ └── MainLayout.tsx │ │ +│ ├── notifications/ # NEW (Fáza 2) +│ │ └── NotificationCenter.tsx # Zvonček s dropdown v header +│ │ │ ├── dashboard/ │ │ ├── DashboardView.tsx │ │ ├── TodaysTasks.tsx @@ -1153,6 +1208,9 @@ src/ │ │ │ ├── settings/ # NEW │ │ ├── SettingsDashboard.tsx +│ │ ├── UserManagement.tsx # Správa používateľov (ROOT/ADMIN) +│ │ ├── UserForm.tsx # Formulár vytvorenie/editácia +│ │ ├── PasswordResetModal.tsx # Reset hesla │ │ ├── EquipmentTypesSettings.tsx │ │ ├── RevisionTypesSettings.tsx │ │ ├── RMAStatusSettings.tsx @@ -1266,9 +1324,9 @@ cd backend && npx prisma db seed --- -### **FÁZA 2: Core Features + Workflow** 🔥 (4-5 týždňov) +### **FÁZA 2: Core Features + Workflow** 🔥 (4-5 týždňov) - *PREBIEHAJÚCA* -**Cieľ:** Swimlanes, revízie, RMA workflow, reminders +**Cieľ:** Swimlanes, revízie, RMA workflow, reminders, notifikácie **Backend:** - [ ] **Revision system** @@ -1284,8 +1342,15 @@ cd backend && npx prisma db seed - [ ] Dashboard aggregations - [ ] Email service (Postfix self-hosted) - [ ] WebSocket (Socket.IO) -- [ ] File upload handling -- [ ] **Task notifications** (databázové - viditeľné na všetkých zariadeniach) +- [x] File upload handling +- [x] **Notification system** ✅ + - [x] Notification model (Prisma) + - [x] notification.service.ts - CRUD, enrichment komentárov + - [x] notifyTaskComment - ukladá len commentId (žiadna duplicita) + - [x] notifyTaskStatusChange - ukladá oldStatus, newStatus, actorName + - [x] notifyTaskAssignment + - [x] Snooze funkcionalita s konfigurovateľnými možnosťami + - [x] SystemSetting NOTIFICATION_SNOOZE_OPTIONS **Frontend:** - [ ] **Swimlanes Board** (dnd-kit) @@ -1300,7 +1365,7 @@ cd backend && npx prisma db seed - [ ] **RMA Workflow** - [ ] Status change UI - [ ] Approval buttons (admin) - - [ ] File attachments + - [x] File attachments - [ ] Comments - [ ] PDF export - [ ] **Inline Quick Actions** @@ -1308,21 +1373,29 @@ cd backend && npx prisma db seed - [ ] Reminder management UI - [ ] Filters & tags - [ ] Real-time updates (WebSocket) -- [ ] **Notifikácie o nových komentároch/zmenách** (všetky zariadenia) +- [x] **Notification UI** ✅ + - [x] NotificationCenter komponent (zvonček v header) + - [x] Dashboard - prehľadné zobrazenie notifikácií + - [x] Typ notifikácie + relatívny čas + - [x] Názov úlohy + projekt + - [x] Detail zmeny/komentára + autor + - [x] markAsRead pri akcii (komentár/zmena stavu) + - [x] Snooze dropdown s konfigurovateľnými možnosťami + - [x] useSnoozeOptions hook (načíta z SystemSettings) **Deliverable:** ``` ✅ Všetko z Fázy 1 + -✅ Swimlanes board -✅ Revízny systém funguje -✅ RMA workflow s approval -✅ Email notifikácie -✅ Live updates (WebSocket) +⏳ Swimlanes board +⏳ Revízny systém funguje +⏳ RMA workflow s approval +⏳ Email notifikácie +⏳ Live updates (WebSocket) ✅ File uploads ✅ Task notifikácie (databázové, všetky zariadenia) ``` -**Čas:** 4-5 týždňov +**Čas:** 4-5 týždňov **Náklady:** €15-25/mesiac --- @@ -2059,7 +2132,120 @@ async function getUpcomingRevisions(days: number = 30) { --- -### **5. External DB Import (Customer)** +### **5. Notification Service (Fáza 2)** + +```typescript +// services/notification.service.ts + +export enum NotificationType { + TASK_ASSIGNED = 'TASK_ASSIGNED', + TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED', + TASK_COMMENT = 'TASK_COMMENT', + TASK_DEADLINE_APPROACHING = 'TASK_DEADLINE_APPROACHING', + TASK_UPDATED = 'TASK_UPDATED', + RMA_ASSIGNED = 'RMA_ASSIGNED', + RMA_STATUS_CHANGED = 'RMA_STATUS_CHANGED', + RMA_COMMENT = 'RMA_COMMENT', +} + +export const notificationService = { + // Vytvorenie notifikácie pre viacerých používateľov + async createForUsers(userIds: string[], data: { + type: NotificationType; + title: string; + message: string; + taskId?: string; + rmaId?: string; + data?: object; // Dodatočné dáta (commentId, actorName, ...) + }) { + if (userIds.length === 0) return []; + + return prisma.notification.createMany({ + data: userIds.map((userId) => ({ + userId, + type: data.type, + title: data.title, + message: data.message, + taskId: data.taskId, + rmaId: data.rmaId, + data: data.data || undefined, + })), + }); + }, + + // Notifikácia o novom komentári - NEUKLADÁ text, len commentId + async notifyTaskComment(taskId: string, commentId: string, commentByUserId: string, commentByUserName: string) { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + include: { + assignees: { select: { userId: true } }, + createdBy: { select: { id: true } }, + }, + }); + + if (!task) return; + + const userIds = new Set(); + task.assignees.forEach((a) => userIds.add(a.userId)); + userIds.add(task.createdById); + userIds.delete(commentByUserId); // Nenotifikovať autora + + if (userIds.size === 0) return; + + await this.createForUsers(Array.from(userIds), { + type: NotificationType.TASK_COMMENT, + title: 'Nový komentár', + message: '', // Text sa načíta z Comment tabuľky (žiadna duplicita) + taskId: task.id, + data: { commentId, actorName: commentByUserName }, + }); + }, + + // Pri načítaní notifikácií - enrichment TASK_COMMENT + async getForUser(userId: string, options?: { limit?: number; offset?: number }) { + const rawNotifications = await prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + take: options?.limit || 50, + skip: options?.offset || 0, + include: { + task: { select: { id: true, title: true, project: true } }, + }, + }); + + // Pre TASK_COMMENT načítaj text komentára z Comment tabuľky + const notifications = await Promise.all( + rawNotifications.map(async (notification) => { + if (notification.type === 'TASK_COMMENT' && notification.taskId) { + const data = notification.data as { commentId?: string } | null; + + if (data?.commentId) { + const comment = await prisma.comment.findUnique({ + where: { id: data.commentId }, + select: { content: true }, + }); + + if (comment) { + const shortComment = comment.content.length > 100 + ? comment.content.substring(0, 100) + '...' + : comment.content; + + return { ...notification, message: shortComment }; + } + } + } + return notification; + }) + ); + + return notifications; + }, +}; +``` + +--- + +### **6. External DB Import (Customer)** ```typescript // services/import.service.ts @@ -2266,6 +2452,7 @@ helpdesk-system/ │ │ │ ├── useTasks.ts │ │ │ ├── useEquipment.ts # NEW │ │ │ ├── useRMA.ts # NEW +│ │ │ ├── useSnoozeOptions.ts # NEW (Fáza 2) - konfigurovateľné snooze možnosti │ │ │ └── useKeyboard.ts │ │ │ │ │ ├── services/ @@ -2276,13 +2463,15 @@ helpdesk-system/ │ │ │ ├── customers.api.ts # NEW │ │ │ ├── equipment.api.ts # NEW │ │ │ ├── rma.api.ts # NEW -│ │ │ └── settings.api.ts # NEW +│ │ │ ├── settings.api.ts # NEW +│ │ │ └── notification.api.ts # NEW (Fáza 2) │ │ │ │ │ ├── store/ │ │ │ ├── authStore.ts │ │ │ ├── configStore.ts # NEW │ │ │ ├── projectsStore.ts -│ │ │ └── tasksStore.ts +│ │ │ ├── tasksStore.ts +│ │ │ └── notificationStore.ts # NEW (Fáza 2) │ │ │ │ │ ├── types/ │ │ ├── styles/ @@ -2528,6 +2717,7 @@ CELKOM: ~€10-15/mesiac --- -*Dokument vytvorený: 02.02.2026* -*Verzia: 2.0.0* +*Dokument vytvorený: 02.02.2026* +*Posledná aktualizácia: 19.02.2026* +*Verzia: 2.2.0* *Autor: Claude (Anthropic) + Používateľ* diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 3710b78..cde999a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -67,6 +67,7 @@ model User { taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader") createdCustomers Customer[] + notifications Notification[] @@index([email]) @@index([roleId]) @@ -374,11 +375,12 @@ model Task { createdById String createdBy User @relation("TaskCreator", fields: [createdById], references: [id]) - assignees TaskAssignee[] - reminders Reminder[] - comments Comment[] - tags TaskTag[] - attachments TaskAttachment[] + assignees TaskAssignee[] + reminders Reminder[] + comments Comment[] + tags TaskTag[] + attachments TaskAttachment[] + notifications Notification[] @@index([projectId]) @@index([parentId]) @@ -633,6 +635,7 @@ model RMA { statusHistory RMAStatusHistory[] comments RMAComment[] tags RMATag[] + notifications Notification[] @@index([rmaNumber]) @@index([customerId]) @@ -706,6 +709,40 @@ model RMATag { @@id([rmaId, tagId]) } +// ==================== NOTIFICATIONS ==================== + +model Notification { + id String @id @default(cuid()) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type String // TASK_ASSIGNED, TASK_STATUS_CHANGED, TASK_COMMENT, TASK_DEADLINE, RMA_ASSIGNED, etc. + + taskId String? + task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade) + + rmaId String? + rma RMA? @relation(fields: [rmaId], references: [id], onDelete: Cascade) + + title String + message String + data Json? // Extra data (oldStatus, newStatus, etc.) + + isRead Boolean @default(false) + readAt DateTime? + + snoozedUntil DateTime? + + createdAt DateTime @default(now()) + + @@index([userId]) + @@index([userId, isRead]) + @@index([taskId]) + @@index([rmaId]) + @@index([createdAt]) +} + // ==================== ACTIVITY LOG ==================== model ActivityLog { diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index b5342d9..5cd4ae0 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -203,6 +203,19 @@ async function seed() { label: 'Zapnúť real-time aktualizácie (WebSocket)', dataType: 'boolean', }, + { + key: 'NOTIFICATION_SNOOZE_OPTIONS', + value: [ + { label: '30 minút', minutes: 30 }, + { label: '1 hodina', minutes: 60 }, + { label: '3 hodiny', minutes: 180 }, + { label: 'Zajtra ráno', type: 'tomorrow', hour: 9 }, + ], + category: 'NOTIFICATIONS', + label: 'Možnosti odloženia notifikácií', + description: 'Pole objektov. Každý má "label" a buď "minutes" (relatívny čas) alebo "type" + "hour" (konkrétny čas). Type: "today" (ak čas prešiel, skryje sa), "tomorrow".', + dataType: 'json', + }, ], }); diff --git a/backend/src/controllers/notification.controller.ts b/backend/src/controllers/notification.controller.ts new file mode 100644 index 0000000..e430e85 --- /dev/null +++ b/backend/src/controllers/notification.controller.ts @@ -0,0 +1,100 @@ +import { Response } from 'express'; +import { AuthRequest } from '../middleware/auth.middleware'; +import { notificationService } from '../services/notification.service'; +import { successResponse, errorResponse, parseQueryInt, parseQueryBoolean, getParam } from '../utils/helpers'; + +// Get notifications for current user +export const getNotifications = async (req: AuthRequest, res: Response): Promise => { + try { + const userId = req.user!.userId; + const limit = parseQueryInt(req.query.limit, 50); + const offset = parseQueryInt(req.query.offset, 0); + const unreadOnly = parseQueryBoolean(req.query.unreadOnly, false); + + const { notifications, total } = await notificationService.getForUser(userId, { + limit, + offset, + unreadOnly, + }); + + successResponse(res, { notifications, total, limit, offset }); + } catch (error) { + console.error('[Notification] Error getting notifications:', error); + errorResponse(res, 'Chyba pri načítaní notifikácií', 500); + } +}; + +// Get unread count +export const getUnreadCount = async (req: AuthRequest, res: Response): Promise => { + try { + const userId = req.user!.userId; + const count = await notificationService.getUnreadCount(userId); + successResponse(res, { count }); + } catch (error) { + console.error('[Notification] Error getting unread count:', error); + errorResponse(res, 'Chyba pri načítaní počtu notifikácií', 500); + } +}; + +// Mark notification as read +export const markAsRead = async (req: AuthRequest, res: Response): Promise => { + try { + const userId = req.user!.userId; + const id = getParam(req, 'id'); + + await notificationService.markAsRead(id, userId); + successResponse(res, { message: 'Notifikácia označená ako prečítaná' }); + } catch (error) { + console.error('[Notification] Error marking as read:', error); + errorResponse(res, 'Chyba pri označovaní notifikácie', 500); + } +}; + +// Mark all notifications as read +export const markAllAsRead = async (req: AuthRequest, res: Response): Promise => { + try { + const userId = req.user!.userId; + const result = await notificationService.markAllAsRead(userId); + successResponse(res, { message: 'Všetky notifikácie označené ako prečítané', count: result.count }); + } catch (error) { + console.error('[Notification] Error marking all as read:', error); + errorResponse(res, 'Chyba pri označovaní notifikácií', 500); + } +}; + +// Snooze notification (remind later) +export const snoozeNotification = async (req: AuthRequest, res: Response): Promise => { + try { + const userId = req.user!.userId; + const id = getParam(req, 'id'); + const { minutes } = req.body; + + if (!minutes || typeof minutes !== 'number' || minutes <= 0) { + errorResponse(res, 'Neplatný čas odloženia', 400); + return; + } + + const until = new Date(); + until.setMinutes(until.getMinutes() + minutes); + + await notificationService.snooze(id, userId, until); + successResponse(res, { message: 'Notifikácia odložená', snoozedUntil: until }); + } catch (error) { + console.error('[Notification] Error snoozing:', error); + errorResponse(res, 'Chyba pri odkladaní notifikácie', 500); + } +}; + +// Delete notification +export const deleteNotification = async (req: AuthRequest, res: Response): Promise => { + try { + const userId = req.user!.userId; + const id = getParam(req, 'id'); + + await notificationService.delete(id, userId); + successResponse(res, { message: 'Notifikácia vymazaná' }); + } catch (error) { + console.error('[Notification] Error deleting:', error); + errorResponse(res, 'Chyba pri mazaní notifikácie', 500); + } +}; diff --git a/backend/src/controllers/tasks.controller.ts b/backend/src/controllers/tasks.controller.ts index caac958..dac0a70 100644 --- a/backend/src/controllers/tasks.controller.ts +++ b/backend/src/controllers/tasks.controller.ts @@ -4,6 +4,7 @@ import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getPa import { AuthRequest } from '../middleware/auth.middleware'; import { configService } from '../services/config.service'; import { movePendingFilesToEntity } from './upload.controller'; +import { notificationService } from '../services/notification.service'; export const getTasks = async (req: AuthRequest, res: Response): Promise => { try { @@ -167,6 +168,13 @@ export const createTask = async (req: AuthRequest, res: Response): Promise userId, })), }); + + // Notify assigned users + await notificationService.notifyTaskAssignment( + task.id, + req.body.assigneeIds, + req.user!.userId + ); } // Move pending files if tempId provided @@ -189,13 +197,22 @@ export const updateTask = async (req: AuthRequest, res: Response): Promise try { const id = getParam(req, 'id'); - const existing = await prisma.task.findUnique({ where: { id } }); + const existing = await prisma.task.findUnique({ + where: { id }, + include: { + status: { select: { name: true } }, + assignees: { select: { userId: true } }, + }, + }); if (!existing) { errorResponse(res, 'Úloha nebola nájdená.', 404); return; } + const oldStatusName = existing.status.name; + const oldAssigneeIds = existing.assignees.map((a) => a.userId); + const updateData: Record = {}; if (req.body.title) updateData.title = req.body.title; if (req.body.description !== undefined) updateData.description = req.body.description; @@ -245,6 +262,26 @@ export const updateTask = async (req: AuthRequest, res: Response): Promise }, }); + // Notify about status change + if (req.body.statusId && updatedTask && oldStatusName !== updatedTask.status.name) { + await notificationService.notifyTaskStatusChange( + id, + oldStatusName, + updatedTask.status.name, + req.user!.userId + ); + } + + // Notify new assignees + if (req.body.assigneeIds !== undefined) { + const newAssigneeIds = (req.body.assigneeIds || []).filter( + (userId: string) => !oldAssigneeIds.includes(userId) + ); + if (newAssigneeIds.length > 0) { + await notificationService.notifyTaskAssignment(id, newAssigneeIds, req.user!.userId); + } + } + if (req.logActivity) { await req.logActivity('UPDATE', 'Task', id, updateData); } @@ -285,17 +322,40 @@ export const updateTaskStatus = async (req: AuthRequest, res: Response): Promise const id = getParam(req, 'id'); const { statusId } = req.body; - const status = await prisma.taskStatus.findUnique({ where: { id: statusId } }); + // Get current task with old status + const currentTask = await prisma.task.findUnique({ + where: { id }, + include: { status: true }, + }); + + if (!currentTask) { + errorResponse(res, 'Úloha nebola nájdená.', 404); + return; + } + + const oldStatusName = currentTask.status.name; + + const newStatus = await prisma.taskStatus.findUnique({ where: { id: statusId } }); const task = await prisma.task.update({ where: { id }, data: { statusId, - completedAt: status?.isFinal ? new Date() : null, + completedAt: newStatus?.isFinal ? new Date() : null, }, include: { status: true }, }); + // Notify about status change + if (oldStatusName !== task.status.name) { + await notificationService.notifyTaskStatusChange( + id, + oldStatusName, + task.status.name, + req.user!.userId + ); + } + if (req.logActivity) { await req.logActivity('STATUS_CHANGE', 'Task', id, { statusId }); } @@ -398,6 +458,9 @@ export const addTaskComment = async (req: AuthRequest, res: Response): Promise => { + try { + const { email, password, name, roleId } = req.body; + + const existingUser = await prisma.user.findUnique({ where: { email } }); + if (existingUser) { + errorResponse(res, 'Email je už používaný.', 409); + return; + } + + const role = await prisma.userRole.findUnique({ where: { id: roleId } }); + if (!role || !role.active) { + errorResponse(res, 'Rola neexistuje alebo nie je aktívna.', 404); + return; + } + + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { email, password: hashedPassword, name, roleId }, + select: { + id: true, + email: true, + name: true, + active: true, + createdAt: true, + updatedAt: true, + role: { + select: { + id: true, + code: true, + name: true, + }, + }, + }, + }); + + if (req.logActivity) { + await req.logActivity('CREATE', 'User', user.id, { email, name, roleId }); + } + + successResponse(res, user, 'Používateľ bol vytvorený.', 201); + } catch (error) { + console.error('Error creating user:', error); + errorResponse(res, 'Chyba pri vytváraní používateľa.', 500); + } +}; + export const getUsers = async (req: AuthRequest, res: Response): Promise => { try { const page = parseQueryInt(req.query.page, 1); diff --git a/backend/src/controllers/zakazky.controller.ts b/backend/src/controllers/zakazky.controller.ts index c92ff39..6ef372b 100644 --- a/backend/src/controllers/zakazky.controller.ts +++ b/backend/src/controllers/zakazky.controller.ts @@ -20,6 +20,8 @@ export const getZakazky = async (req: AuthRequest, res: Response): Promise const rok = parseQueryInt(req.query.rok, new Date().getFullYear()); const search = getQueryString(req, 'search'); + console.log(`[Zakazky] Fetching year=${rok}, search=${search || 'none'}`); + let zakazky; if (search) { @@ -28,10 +30,16 @@ export const getZakazky = async (req: AuthRequest, res: Response): Promise zakazky = await externalDbService.getZakazkyByYear(rok); } + console.log(`[Zakazky] Found ${zakazky.length} records`); successResponse(res, zakazky); } catch (error) { - console.error('Error fetching zakazky:', error); - errorResponse(res, 'Chyba pri načítaní zákaziek z externej databázy.', 500); + const err = error as Error; + console.error('[Zakazky] Error:', err.message, err.stack); + // Return detailed error in development + const message = process.env.NODE_ENV === 'development' + ? `Chyba: ${err.message}` + : 'Chyba pri načítaní zákaziek z externej databázy.'; + errorResponse(res, message, 500); } }; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 8b4f3a4..cbd70d7 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -10,6 +10,7 @@ import settingsRoutes from './settings.routes'; import dashboardRoutes from './dashboard.routes'; import uploadRoutes from './upload.routes'; import zakazkyRoutes from './zakazky.routes'; +import notificationRoutes from './notification.routes'; const router = Router(); @@ -24,5 +25,6 @@ router.use('/settings', settingsRoutes); router.use('/dashboard', dashboardRoutes); router.use('/files', uploadRoutes); router.use('/zakazky', zakazkyRoutes); +router.use('/notifications', notificationRoutes); export default router; diff --git a/backend/src/routes/notification.routes.ts b/backend/src/routes/notification.routes.ts new file mode 100644 index 0000000..6d3ca7c --- /dev/null +++ b/backend/src/routes/notification.routes.ts @@ -0,0 +1,35 @@ +import { Router } from 'express'; +import { authenticate } from '../middleware/auth.middleware'; +import { + getNotifications, + getUnreadCount, + markAsRead, + markAllAsRead, + snoozeNotification, + deleteNotification, +} from '../controllers/notification.controller'; + +const router = Router(); + +// All routes require authentication +router.use(authenticate); + +// GET /api/notifications - Get user's notifications +router.get('/', getNotifications); + +// GET /api/notifications/unread-count - Get unread count +router.get('/unread-count', getUnreadCount); + +// POST /api/notifications/mark-all-read - Mark all as read +router.post('/mark-all-read', markAllAsRead); + +// POST /api/notifications/:id/read - Mark single notification as read +router.post('/:id/read', markAsRead); + +// POST /api/notifications/:id/snooze - Snooze notification +router.post('/:id/snooze', snoozeNotification); + +// DELETE /api/notifications/:id - Delete notification +router.delete('/:id', deleteNotification); + +export default router; diff --git a/backend/src/routes/settings.routes.ts b/backend/src/routes/settings.routes.ts index 5effba1..184757a 100644 --- a/backend/src/routes/settings.routes.ts +++ b/backend/src/routes/settings.routes.ts @@ -25,6 +25,10 @@ router.get('/rma-solutions', settingsController.getRMASolutions); router.get('/tags', settingsController.getTags); // User Roles - čítanie router.get('/roles', settingsController.getUserRoles); +// System Settings - čítanie (pre všetkých prihlásených) +router.get('/system', settingsController.getSystemSettings); +router.get('/system/category/:category', settingsController.getSystemSettingsByCategory); +router.get('/system/:key', settingsController.getSystemSetting); // === ROOT-ONLY ENDPOINTY (vytvorenie, úprava, mazanie) === router.use(isRoot); @@ -69,10 +73,7 @@ router.post('/roles', settingsController.createUserRole); router.put('/roles/:id', settingsController.updateUserRole); router.delete('/roles/:id', settingsController.deleteUserRole); -// System Settings - len ROOT -router.get('/system', settingsController.getSystemSettings); -router.get('/system/category/:category', settingsController.getSystemSettingsByCategory); -router.get('/system/:key', settingsController.getSystemSetting); +// System Settings - úprava (len ROOT) router.put('/system/:key', settingsController.updateSystemSetting); export default router; diff --git a/backend/src/routes/users.routes.ts b/backend/src/routes/users.routes.ts index 2da1838..2c82173 100644 --- a/backend/src/routes/users.routes.ts +++ b/backend/src/routes/users.routes.ts @@ -3,6 +3,8 @@ import * as usersController from '../controllers/users.controller'; import { authenticate } from '../middleware/auth.middleware'; import { isAdmin } from '../middleware/rbac.middleware'; import { activityLogger } from '../middleware/activityLog.middleware'; +import { validate } from '../middleware/validate.middleware'; +import { createUserSchema } from '../utils/validators'; const router = Router(); @@ -13,6 +15,7 @@ router.use(activityLogger); router.get('/simple', usersController.getUsersSimple); router.get('/', isAdmin, usersController.getUsers); +router.post('/', isAdmin, validate(createUserSchema), usersController.createUser); router.get('/:id', isAdmin, usersController.getUser); router.put('/:id', isAdmin, usersController.updateUser); router.delete('/:id', isAdmin, usersController.deleteUser); diff --git a/backend/src/services/externalDb.service.ts b/backend/src/services/externalDb.service.ts index 3cf7ba8..498eb1e 100644 --- a/backend/src/services/externalDb.service.ts +++ b/backend/src/services/externalDb.service.ts @@ -144,15 +144,19 @@ export const getZakazkaById = async (rok: number, id: number): Promise z.id === id) || null; }; +// Normalize text for search (remove diacritics, lowercase) +const normalizeText = (text: string): string => + text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + // Search zakazky export const searchZakazky = async (rok: number, search: string): Promise => { const zakazky = await getZakazkyByYear(rok); - const searchLower = search.toLowerCase(); + const searchNormalized = normalizeText(search); return zakazky.filter((z) => - z.cislo.toLowerCase().includes(searchLower) || - z.nazov.toLowerCase().includes(searchLower) || - z.customer.toLowerCase().includes(searchLower) + normalizeText(z.cislo).includes(searchNormalized) || + normalizeText(z.nazov).includes(searchNormalized) || + normalizeText(z.customer).includes(searchNormalized) ); }; diff --git a/backend/src/services/notification.service.ts b/backend/src/services/notification.service.ts new file mode 100644 index 0000000..755d301 --- /dev/null +++ b/backend/src/services/notification.service.ts @@ -0,0 +1,324 @@ +import prisma from '../config/database'; +import { Prisma } from '@prisma/client'; + +export enum NotificationType { + TASK_ASSIGNED = 'TASK_ASSIGNED', + TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED', + TASK_COMMENT = 'TASK_COMMENT', + TASK_DEADLINE_APPROACHING = 'TASK_DEADLINE_APPROACHING', + TASK_UPDATED = 'TASK_UPDATED', + RMA_ASSIGNED = 'RMA_ASSIGNED', + RMA_STATUS_CHANGED = 'RMA_STATUS_CHANGED', + RMA_COMMENT = 'RMA_COMMENT', +} + +interface CreateNotificationData { + userId: string; + type: NotificationType; + title: string; + message: string; + taskId?: string; + rmaId?: string; + data?: Prisma.InputJsonValue; +} + +export const notificationService = { + // Create a new notification + async create(data: CreateNotificationData) { + return prisma.notification.create({ + data: { + userId: data.userId, + type: data.type, + title: data.title, + message: data.message, + taskId: data.taskId, + rmaId: data.rmaId, + data: data.data || undefined, + }, + }); + }, + + // Create notifications for multiple users + async createForUsers(userIds: string[], data: Omit) { + if (userIds.length === 0) return []; + + return prisma.notification.createMany({ + data: userIds.map((userId) => ({ + userId, + type: data.type, + title: data.title, + message: data.message, + taskId: data.taskId, + rmaId: data.rmaId, + data: data.data || undefined, + })), + }); + }, + + // Get notifications for a user + async getForUser(userId: string, options?: { limit?: number; offset?: number; unreadOnly?: boolean }) { + const { limit = 50, offset = 0, unreadOnly = false } = options || {}; + + const where = { + userId, + ...(unreadOnly ? { isRead: false } : {}), + OR: [ + { snoozedUntil: null }, + { snoozedUntil: { lte: new Date() } }, + ], + }; + + const [rawNotifications, total] = await Promise.all([ + prisma.notification.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + include: { + task: { + select: { + id: true, + title: true, + status: { select: { id: true, name: true, color: true } }, + project: { select: { id: true, name: true } }, + }, + }, + rma: { + select: { + id: true, + rmaNumber: true, + productName: true, + }, + }, + }, + }), + prisma.notification.count({ where }), + ]); + + // Pre TASK_COMMENT notifikácie načítaj text komentára z Comment tabuľky + const notifications = await Promise.all( + rawNotifications.map(async (notification) => { + if (notification.type === 'TASK_COMMENT' && notification.taskId) { + const data = notification.data as { commentId?: string; actorName?: string } | null; + + let comment; + if (data?.commentId) { + // Nové notifikácie - načítaj podľa commentId + comment = await prisma.comment.findUnique({ + where: { id: data.commentId }, + select: { content: true, user: { select: { name: true } } }, + }); + } else { + // Staré notifikácie - nájdi podľa času (±5 sekúnd) + const notifTime = notification.createdAt.getTime(); + comment = await prisma.comment.findFirst({ + where: { + taskId: notification.taskId, + createdAt: { + gte: new Date(notifTime - 5000), + lte: new Date(notifTime + 5000), + }, + }, + select: { content: true, user: { select: { name: true } } }, + }); + } + + if (comment) { + const shortComment = comment.content.length > 100 + ? comment.content.substring(0, 100) + '...' + : comment.content; + + return { + ...notification, + message: shortComment, + data: { ...data, actorName: data?.actorName || comment.user?.name }, + }; + } + } + return notification; + }) + ); + + return { notifications, total }; + }, + + // Get unread count for a user + async getUnreadCount(userId: string) { + return prisma.notification.count({ + where: { + userId, + isRead: false, + OR: [ + { snoozedUntil: null }, + { snoozedUntil: { lte: new Date() } }, + ], + }, + }); + }, + + // Mark notification as read + async markAsRead(notificationId: string, userId: string) { + return prisma.notification.updateMany({ + where: { + id: notificationId, + userId, // Security: only owner can mark as read + }, + data: { + isRead: true, + readAt: new Date(), + }, + }); + }, + + // Mark all notifications as read for a user + async markAllAsRead(userId: string) { + return prisma.notification.updateMany({ + where: { + userId, + isRead: false, + }, + data: { + isRead: true, + readAt: new Date(), + }, + }); + }, + + // Snooze notification (remind later) + async snooze(notificationId: string, userId: string, until: Date) { + return prisma.notification.updateMany({ + where: { + id: notificationId, + userId, + }, + data: { + snoozedUntil: until, + }, + }); + }, + + // Delete notification + async delete(notificationId: string, userId: string) { + return prisma.notification.deleteMany({ + where: { + id: notificationId, + userId, + }, + }); + }, + + // Delete old notifications (cleanup job) + async deleteOld(olderThanDays: number = 30) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - olderThanDays); + + return prisma.notification.deleteMany({ + where: { + isRead: true, + createdAt: { lt: cutoffDate }, + }, + }); + }, + + // Helper: Notify task assignees about status change + async notifyTaskStatusChange( + taskId: string, + oldStatusName: string, + newStatusName: string, + changedByUserId: string + ) { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + include: { + assignees: { select: { userId: true } }, + createdBy: { select: { id: true, name: true } }, + }, + }); + + if (!task) return; + + // Get actor name + const actor = await prisma.user.findUnique({ + where: { id: changedByUserId }, + select: { name: true }, + }); + + // Notify assignees (except the one who made the change) + const userIds = task.assignees + .map((a: { userId: string }) => a.userId) + .filter((id: string) => id !== changedByUserId); + + // Also notify task creator if not already in list + if (task.createdById !== changedByUserId && !userIds.includes(task.createdById)) { + userIds.push(task.createdById); + } + + if (userIds.length === 0) return; + + await this.createForUsers(userIds, { + type: NotificationType.TASK_STATUS_CHANGED, + title: 'Zmena stavu úlohy', + message: `${oldStatusName} → ${newStatusName}`, + taskId: task.id, + data: { oldStatus: oldStatusName, newStatus: newStatusName, actorName: actor?.name }, + }); + }, + + // Helper: Notify user about task assignment + async notifyTaskAssignment(taskId: string, assignedUserIds: string[], assignedByUserId: string) { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + select: { id: true, title: true }, + }); + + if (!task) return; + + // Get actor name + const actor = await prisma.user.findUnique({ + where: { id: assignedByUserId }, + select: { name: true }, + }); + + // Don't notify the user who assigned (if they assigned themselves) + const userIds = assignedUserIds.filter((id) => id !== assignedByUserId); + + if (userIds.length === 0) return; + + await this.createForUsers(userIds, { + type: NotificationType.TASK_ASSIGNED, + title: 'Nová úloha', + message: 'Boli ste priradení k úlohe', + taskId: task.id, + data: { actorName: actor?.name }, + }); + }, + + // Helper: Notify about new comment on task + async notifyTaskComment(taskId: string, commentId: string, commentByUserId: string, commentByUserName: string) { + const task = await prisma.task.findUnique({ + where: { id: taskId }, + include: { + assignees: { select: { userId: true } }, + createdBy: { select: { id: true } }, + }, + }); + + if (!task) return; + + // Notify assignees and creator (except comment author) + const userIds = new Set(); + task.assignees.forEach((a: { userId: string }) => userIds.add(a.userId)); + userIds.add(task.createdById); + userIds.delete(commentByUserId); + + if (userIds.size === 0) return; + + await this.createForUsers(Array.from(userIds), { + type: NotificationType.TASK_COMMENT, + title: 'Nový komentár', + message: '', // Text sa načíta z Comment tabuľky + taskId: task.id, + data: { commentId, actorName: commentByUserName }, + }); + }, +}; diff --git a/backend/src/utils/helpers.ts b/backend/src/utils/helpers.ts index 0caf078..1a8ca93 100644 --- a/backend/src/utils/helpers.ts +++ b/backend/src/utils/helpers.ts @@ -71,3 +71,9 @@ export const parseBooleanQuery = (value: unknown): boolean | undefined => { if (value === 'false') return false; return undefined; }; + +export const parseQueryBoolean = (value: unknown, defaultValue: boolean): boolean => { + if (value === 'true' || value === '1') return true; + if (value === 'false' || value === '0') return false; + return defaultValue; +}; diff --git a/backend/src/utils/validators.ts b/backend/src/utils/validators.ts index 9fc4bc2..8916493 100644 --- a/backend/src/utils/validators.ts +++ b/backend/src/utils/validators.ts @@ -18,6 +18,17 @@ export const registerSchema = z.object({ }); // User validators +export const createUserSchema = z.object({ + email: z.string().email('Neplatný email'), + password: z + .string() + .min(8, 'Heslo musí mať aspoň 8 znakov') + .regex(/[A-Z]/, 'Heslo musí obsahovať veľké písmeno') + .regex(/[0-9]/, 'Heslo musí obsahovať číslo'), + name: z.string().min(2, 'Meno musí mať aspoň 2 znaky'), + roleId: z.string().min(1, 'Rola je povinná'), +}); + export const updateUserSchema = z.object({ email: z.string().email('Neplatný email').optional(), name: z.string().min(2, 'Meno musí mať aspoň 2 znaky').optional(), diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b14a18c..9e06ec6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -56,10 +56,10 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { return <>{children}; } -function RootOnlyRoute({ children }: { children: React.ReactNode }) { +function AdminRoute({ children }: { children: React.ReactNode }) { const { user } = useAuthStore(); - if (user?.role.code !== 'ROOT') { + if (user?.role.code !== 'ROOT' && user?.role.code !== 'ADMIN') { return ; } @@ -91,9 +91,9 @@ function AppRoutes() { + - + } /> diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx new file mode 100644 index 0000000..e29fff7 --- /dev/null +++ b/frontend/src/components/NotificationCenter.tsx @@ -0,0 +1,232 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Bell, Check, CheckCheck, Clock, X } from 'lucide-react'; +import { useNotificationStore } from '@/store/notificationStore'; +import { useSnoozeOptions, calculateSnoozeMinutes, type SnoozeOption } from '@/hooks/useSnoozeOptions'; +import { cn, formatRelativeTime } from '@/lib/utils'; + +export function NotificationCenter() { + const navigate = useNavigate(); + const containerRef = useRef(null); + const [snoozeOpenFor, setSnoozeOpenFor] = useState(null); + const snoozeOptions = useSnoozeOptions(); + + const { + notifications, + unreadCount, + isLoading, + isOpen, + fetchNotifications, + fetchUnreadCount, + markAsRead, + markAllAsRead, + snooze, + setIsOpen, + } = useNotificationStore(); + + // Fetch unread count on mount and periodically + useEffect(() => { + fetchUnreadCount(); + const interval = setInterval(fetchUnreadCount, 60000); // Every minute + return () => clearInterval(interval); + }, [fetchUnreadCount]); + + // Fetch full notifications when opening + useEffect(() => { + if (isOpen) { + fetchNotifications(); + } + }, [isOpen, fetchNotifications]); + + // Close when clicking outside + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [setIsOpen]); + + const handleNotificationClick = (notification: typeof notifications[0]) => { + // Kliknutie len zobrazí detail, neoznačí ako prečítané + // Používateľ musí explicitne kliknúť na "Označiť ako prečítané" + if (notification.task) { + setIsOpen(false); + navigate('/tasks'); + } else if (notification.rma) { + setIsOpen(false); + navigate('/rma'); + } + }; + + const handleSnooze = (e: React.MouseEvent, notificationId: string, option: SnoozeOption) => { + e.stopPropagation(); + const actualMinutes = calculateSnoozeMinutes(option); + snooze(notificationId, actualMinutes); + setSnoozeOpenFor(null); + }; + + const toggleSnoozeMenu = (e: React.MouseEvent, notificationId: string) => { + e.stopPropagation(); + setSnoozeOpenFor(snoozeOpenFor === notificationId ? null : notificationId); + }; + + const getNotificationIcon = (type: string) => { + switch (type) { + case 'TASK_ASSIGNED': + return '📋'; + case 'TASK_STATUS_CHANGED': + return '🔄'; + case 'TASK_COMMENT': + return '💬'; + case 'TASK_DEADLINE_APPROACHING': + return '⏰'; + case 'RMA_ASSIGNED': + return '📦'; + case 'RMA_STATUS_CHANGED': + return '🔄'; + default: + return '📌'; + } + }; + + return ( +
+ {/* Bell Button */} + + + {/* Dropdown */} + {isOpen && ( +
+ {/* Header */} +
+

Notifikácie

+
+ {unreadCount > 0 && ( + + )} + +
+
+ + {/* Notifications List */} +
+ {isLoading ? ( +
+ Načítavam... +
+ ) : notifications.length === 0 ? ( +
+ +

Žiadne notifikácie

+
+ ) : ( + notifications.map((notification) => ( +
handleNotificationClick(notification)} + className={cn( + 'px-4 py-3 border-b last:border-b-0 cursor-pointer transition-colors', + 'hover:bg-accent/50', + !notification.isRead && 'bg-primary/5' + )} + > +
+ + {getNotificationIcon(notification.type)} + +
+
+

+ {notification.title} +

+ {!notification.isRead && ( + + )} +
+

+ {notification.message} +

+
+ + {formatRelativeTime(notification.createdAt)} + +
+ {!notification.isRead && ( + + )} +
+ + {snoozeOpenFor === notification.id && ( +
+ {snoozeOptions.map((option) => ( + + ))} +
+ )} +
+
+
+
+
+
+ )) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index 0b9b46b..6469cfb 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -1,10 +1,13 @@ import { Link } from 'react-router-dom'; -import { LogOut, User, Settings } from 'lucide-react'; +import { LogOut, User, Settings, Menu } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; +import { useSidebarStore } from '@/store/sidebarStore'; import { Button } from '@/components/ui'; +import { NotificationCenter } from '@/components/NotificationCenter'; export function Header() { const { user, logout } = useAuthStore(); + const { toggle } = useSidebarStore(); const handleLogout = async () => { await logout(); @@ -13,9 +16,19 @@ export function Header() { return (
- - Helpdesk - +
+ + + Helpdesk + +
{user && ( @@ -26,7 +39,9 @@ export function Header() { ({user.role.name})
- {user.role.code === 'ROOT' && ( + + + {(user.role.code === 'ROOT' || user.role.code === 'ADMIN') && ( +
+ + + ); } diff --git a/frontend/src/components/ui/SearchableSelect.tsx b/frontend/src/components/ui/SearchableSelect.tsx index bdee7db..af817ba 100644 --- a/frontend/src/components/ui/SearchableSelect.tsx +++ b/frontend/src/components/ui/SearchableSelect.tsx @@ -48,14 +48,18 @@ export function SearchableSelect({ return () => document.removeEventListener('mousedown', handleClickOutside); }, []); + // Normalize text for search (remove diacritics, lowercase) + const normalizeText = (text: string) => + text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + // Filter options based on search const filteredOptions = useMemo(() => { if (!search) return options; - const searchLower = search.toLowerCase(); + const searchNormalized = normalizeText(search); return options.filter( (opt) => - opt.label.toLowerCase().includes(searchLower) || - opt.description?.toLowerCase().includes(searchLower) + normalizeText(opt.label).includes(searchNormalized) || + (opt.description && normalizeText(opt.description).includes(searchNormalized)) ); }, [options, search]); diff --git a/frontend/src/hooks/useSnoozeOptions.ts b/frontend/src/hooks/useSnoozeOptions.ts new file mode 100644 index 0000000..be48c54 --- /dev/null +++ b/frontend/src/hooks/useSnoozeOptions.ts @@ -0,0 +1,82 @@ +import { useQuery } from '@tanstack/react-query'; +import { settingsApi } from '@/services/settings.api'; + +export interface SnoozeOption { + label: string; + minutes?: number; // Relatívny čas v minútach (ak nie je type) + type?: 'tomorrow' | 'today'; // Typ špeciálneho odloženia + hour?: number; // Hodina dňa pre type 'tomorrow' alebo 'today' (0-23) +} + +const DEFAULT_SNOOZE_OPTIONS: SnoozeOption[] = [ + { label: '30 minút', minutes: 30 }, + { label: '1 hodina', minutes: 60 }, + { label: '3 hodiny', minutes: 180 }, + { label: 'Zajtra ráno', type: 'tomorrow', hour: 9 }, +]; + +export function useSnoozeOptions() { + const { data: settings } = useQuery({ + queryKey: ['system-settings'], + queryFn: () => settingsApi.getSystemSettings(), + staleTime: 1000 * 60, // Cache for 1 minute + refetchOnWindowFocus: true, // Obnoviť pri prepnutí okna/tabu + }); + + const snoozeSettings = settings?.data?.find( + (s) => s.key === 'NOTIFICATION_SNOOZE_OPTIONS' + ); + + const options: SnoozeOption[] = snoozeSettings?.value as SnoozeOption[] || DEFAULT_SNOOZE_OPTIONS; + + // Filtrovať neplatné možnosti + const filteredOptions = options.filter(option => { + // Špeciálne časové možnosti (type) vyžadujú definovanú hodinu + if (option.type && option.hour === undefined) { + return false; + } + + // "Dnes" možnosti - nezobrazovať ak čas už prešiel + if (option.type === 'today' && option.hour !== undefined) { + const now = new Date(); + return now.getHours() < option.hour; + } + + // Relatívne možnosti musia mať minutes > 0 + if (!option.type && (!option.minutes || option.minutes <= 0)) { + return false; + } + + return true; + }); + + return filteredOptions; +} + +/** + * Výpočet minút pre odloženie + * - Ak minutes: vráti priamo hodnotu minutes (relatívny čas) + * - Ak type === 'tomorrow': zajtra o zadanej hodine + * - Ak type === 'today': dnes o zadanej hodine + */ +export function calculateSnoozeMinutes(option: SnoozeOption): number { + const { minutes, type, hour = 9 } = option; + + if (type === 'tomorrow') { + // Zajtra o zadanej hodine + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(hour, 0, 0, 0); + return Math.ceil((tomorrow.getTime() - Date.now()) / 60000); + } + + if (type === 'today') { + // Dnes o zadanej hodine + const target = new Date(); + target.setHours(hour, 0, 0, 0); + return Math.ceil((target.getTime() - Date.now()) / 60000); + } + + // Relatívny čas v minútach + return minutes || 0; +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index dcaafb4..64c267f 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -27,11 +27,25 @@ export function formatRelativeTime(date: string | Date): string { const now = new Date(); const target = new Date(date); const diffMs = now.getTime() - target.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) return 'Dnes'; - if (diffDays === 1) return 'Včera'; + const timeStr = target.toLocaleTimeString('sk-SK', { hour: '2-digit', minute: '2-digit' }); + + // Dnes - zobraz len čas alebo "pred X min/hod" + if (diffDays === 0) { + if (diffMins < 1) return 'Práve teraz'; + if (diffMins < 60) return `pred ${diffMins} min`; + if (diffHours < 6) return `pred ${diffHours} hod`; + return timeStr; + } + + // Včera - zobraz "Včera HH:MM" + if (diffDays === 1) return `Včera ${timeStr}`; + + // Staršie if (diffDays < 7) return `Pred ${diffDays} dňami`; - if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} týždňami`; + if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} týž.`; return formatDate(date); } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e616ac5..6692e8b 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,117 +1,157 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Link } from 'react-router-dom'; import { - FolderKanban, CheckSquare, - Users, - Wrench, - RotateCcw, - AlertTriangle, - ArrowRight, CalendarClock, User, - AlertCircle + AlertCircle, + Bell, + Check, + Clock, + ChevronDown, + ChevronRight, + MessageSquare, + UserPlus, + RefreshCw, + Flag, + ListTodo, + AlertTriangle, + CheckCircle2, + Timer } from 'lucide-react'; import { get } from '@/services/api'; +import { settingsApi } from '@/services/settings.api'; import { Card, CardHeader, CardTitle, CardContent, Badge, LoadingOverlay } from '@/components/ui'; import { TaskDetail } from '@/pages/tasks/TaskDetail'; -import { formatDate } from '@/lib/utils'; -import type { Task, Project } from '@/types'; - -interface DashboardStats { - projects: { total: number; active: number }; - tasks: { total: number; pending: number; inProgress: number }; - customers: { total: number; active: number }; - equipment: { total: number; upcomingRevisions: number }; - rma: { total: number; pending: number }; -} +import { formatDate, formatRelativeTime, cn } from '@/lib/utils'; +import { useNotificationStore } from '@/store/notificationStore'; +import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions'; +import type { Task } from '@/types'; interface DashboardToday { myTasks: Task[]; - myProjects: Project[]; +} + +// Ikona podľa typu notifikácie +function getNotificationIcon(type: string) { + switch (type) { + case 'TASK_ASSIGNED': + return ; + case 'TASK_UPDATED': + return ; + case 'TASK_COMMENT': + return ; + case 'TASK_STATUS_CHANGED': + return ; + case 'TASK_DEADLINE': + return ; + default: + return ; + } +} + +// Krátky nadpis podľa typu notifikácie +function getNotificationTypeLabel(type: string) { + switch (type) { + case 'TASK_ASSIGNED': + return 'Nová úloha'; + case 'TASK_UPDATED': + return 'Úloha aktualizovaná'; + case 'TASK_COMMENT': + return 'Nový komentár'; + case 'TASK_STATUS_CHANGED': + return 'Zmena stavu'; + case 'TASK_DEADLINE': + return 'Blíži sa termín'; + case 'RMA_ASSIGNED': + return 'Nová RMA'; + case 'RMA_STATUS_CHANGED': + return 'Zmena stavu RMA'; + default: + return 'Upozornenie'; + } } export function Dashboard() { const queryClient = useQueryClient(); - const [detailTaskId, setDetailTaskId] = useState(null); + const [taskDetail, setTaskDetail] = useState<{ taskId: string; notificationId?: string } | null>(null); + const [collapsedStatuses, setCollapsedStatuses] = useState>(new Set()); + const [snoozeOpenFor, setSnoozeOpenFor] = useState(null); - const { data: statsData, isLoading: statsLoading } = useQuery({ - queryKey: ['dashboard'], - queryFn: () => get('/dashboard'), + // Notifikácie + const { + notifications, + unreadCount, + fetchNotifications, + fetchUnreadCount, + markAsRead, + snooze, + } = useNotificationStore(); + + // Načítať notifikácie pri prvom renderovaní + useEffect(() => { + fetchNotifications(); + fetchUnreadCount(); + }, [fetchNotifications, fetchUnreadCount]); + + // Snooze options z nastavení + const snoozeOptions = useSnoozeOptions(); + + // Neprečítané notifikácie pre banner + const unreadNotifications = notifications.filter((n) => !n.isRead).slice(0, 5); + + // Načítať task statusy + const { data: statusesData } = useQuery({ + queryKey: ['task-statuses'], + queryFn: () => settingsApi.getTaskStatuses(), }); + // Načítať priority + const { data: prioritiesData } = useQuery({ + queryKey: ['priorities'], + queryFn: () => settingsApi.getPriorities(), + }); + + // Načítať moje úlohy const { data: todayData, isLoading: todayLoading } = useQuery({ queryKey: ['dashboard-today'], queryFn: () => get('/dashboard/today'), }); - if (statsLoading || todayLoading) { + if (todayLoading) { return ; } - const stats = statsData?.data; const today = todayData?.data; + const statuses = statusesData?.data || []; + const priorities = prioritiesData?.data || []; - const cards = [ - { - title: 'Projekty', - icon: FolderKanban, - value: stats?.projects.total ?? 0, - subtitle: `${stats?.projects.active ?? 0} aktívnych`, - color: 'text-blue-500', - bgColor: 'bg-blue-50 dark:bg-blue-950/30', - href: '/projects', - }, - { - title: 'Úlohy', - icon: CheckSquare, - value: stats?.tasks.total ?? 0, - subtitle: `${stats?.tasks.inProgress ?? 0} v progrese`, - color: 'text-green-500', - bgColor: 'bg-green-50 dark:bg-green-950/30', - href: '/tasks', - }, - { - title: 'Zákazníci', - icon: Users, - value: stats?.customers.total ?? 0, - subtitle: `${stats?.customers.active ?? 0} aktívnych`, - color: 'text-purple-500', - bgColor: 'bg-purple-50 dark:bg-purple-950/30', - href: '/customers', - }, - { - title: 'Zariadenia', - icon: Wrench, - value: stats?.equipment.total ?? 0, - subtitle: `${stats?.equipment.upcomingRevisions ?? 0} revízií`, - color: 'text-orange-500', - bgColor: 'bg-orange-50 dark:bg-orange-950/30', - href: '/equipment', - }, - { - title: 'RMA', - icon: RotateCcw, - value: stats?.rma.total ?? 0, - subtitle: `${stats?.rma.pending ?? 0} otvorených`, - color: 'text-red-500', - bgColor: 'bg-red-50 dark:bg-red-950/30', - href: '/rma', - }, - ]; + // Zoskupiť úlohy podľa statusu + const tasksByStatus = statuses.reduce((acc, status) => { + acc[status.id] = today?.myTasks?.filter(t => t.statusId === status.id) || []; + return acc; + }, {} as Record); - // Rozdelenie úloh podľa urgentnosti + // Štatistiky + const totalTasks = today?.myTasks?.length || 0; + const overdueTasks = today?.myTasks?.filter(t => t.deadline && new Date(t.deadline) < new Date()) || []; + const todayTasks = today?.myTasks?.filter(t => { + if (!t.deadline) return false; + const deadline = new Date(t.deadline); + const now = new Date(); + return deadline.toDateString() === now.toDateString(); + }) || []; const urgentTasks = today?.myTasks?.filter(t => { if (!t.deadline) return false; const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); - return daysUntil <= 2; + return daysUntil <= 2 && daysUntil >= 0; }) || []; - const normalTasks = today?.myTasks?.filter(t => { - if (!t.deadline) return true; - const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); - return daysUntil > 2; + // Úlohy podľa priority (len vysoká priorita) + const highPriorityTasks = today?.myTasks?.filter(t => { + const priority = priorities.find(p => p.id === t.priorityId); + return priority && priority.order <= 1; // Predpokladáme že nižšie číslo = vyššia priorita }) || []; const isOverdue = (deadline: string) => { @@ -120,84 +160,311 @@ export function Dashboard() { const getDaysUntilDeadline = (deadline: string) => { const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); - if (days < 0) return `${Math.abs(days)} dní po termíne`; + if (days < 0) return `${Math.abs(days)}d po termíne`; if (days === 0) return 'Dnes'; if (days === 1) return 'Zajtra'; - return `${days} dní`; + return `${days}d`; + }; + + const toggleStatusCollapse = (statusId: string) => { + setCollapsedStatuses(prev => { + const next = new Set(prev); + if (next.has(statusId)) { + next.delete(statusId); + } else { + next.add(statusId); + } + return next; + }); }; return ( -
-
-

Dashboard

-

- {new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} -

+
+ {/* Header */} +
+
+

Dashboard

+

+ {new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long' })} +

+
- {/* Štatistické karty */} -
- {cards.map((card) => ( - - - - {card.title} - - - -
{card.value}
-

{card.subtitle}

-
-
- - ))} + {/* Quick Stats - responzívny grid */} +
+ +
+
+ +
+
+

{totalTasks}

+

Celkom úloh

+
+
+
+ + 0 && "border-red-200 dark:border-red-800")}> +
+
0 ? "bg-red-100 dark:bg-red-900/30" : "bg-muted")}> + 0 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} /> +
+
+

{overdueTasks.length}

+

Po termíne

+
+
+
+ + 0 && "border-amber-200 dark:border-amber-800")}> +
+
0 ? "bg-amber-100 dark:bg-amber-900/30" : "bg-muted")}> + 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")} /> +
+
+

{todayTasks.length}

+

Termín dnes

+
+
+
+ + +
+
+ +
+
+

+ {statuses.filter(s => s.isFinal).reduce((sum, s) => sum + (tasksByStatus[s.id]?.length || 0), 0)} +

+

Dokončených

+
+
+
- {/* Urgentné úlohy - zobrazí sa len ak existujú */} - {urgentTasks.length > 0 && ( - - - - - Urgentné úlohy ({urgentTasks.length}) - + {/* Notifikácie - prepracované */} + {unreadNotifications.length > 0 && ( + + +
+ + + Nové upozornenia + {unreadCount} + + {unreadCount > 5 && ( + + Zobraziť všetky + + )} +
- -
- {urgentTasks.map((task) => ( -
setDetailTaskId(task.id)} - className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-background border border-red-200 dark:border-red-800 cursor-pointer hover:border-red-400 transition-colors" - > -
-

{task.title}

- {task.description && ( -

{task.description}

- )} -
- {task.project && ( - - - {task.project.name} - - )} - {task.createdBy && ( - - - {task.createdBy.name} - - )} + +
+ {unreadNotifications.map((notification) => { + const actorName = notification.data?.actorName as string | undefined; + + // Získať zmysluplný obsah správy + const getMessageContent = () => { + const msg = notification.message; + + // Pre staré formáty zmeny stavu - extrahuj stavy + if (notification.type === 'TASK_STATUS_CHANGED' && msg.includes('zmenila stav')) { + const match = msg.match(/z "(.+?)" na "(.+?)"/); + if (match) { + return { message: `${match[1]} → ${match[2]}`, actor: actorName }; + } + } + + return { message: msg, actor: actorName }; + }; + + const { message: displayMessage, actor: displayActor } = getMessageContent(); + + return ( +
{ + if (notification.task) { + setTaskDetail({ taskId: notification.task.id, notificationId: notification.id }); + } + }} + > +
+ {/* Ikona */} +
+
+ {getNotificationIcon(notification.type)} +
+
+ + {/* Obsah */} +
+ {/* Hlavička - typ + čas */} +
+ + {getNotificationTypeLabel(notification.type)} + + · + + {formatRelativeTime(notification.createdAt)} + +
+ + {/* Názov úlohy + projekt */} + {notification.task && ( +
+

+ {notification.task.title} +

+ {notification.task.project && ( + + • {notification.task.project.name} + + )} +
+ )} + + {/* Detail zmeny + autor */} + {(displayMessage || displayActor) && ( +
+ {displayMessage && ( +

+ {displayMessage} +

+ )} + {displayActor && ( + + {displayActor} + + )} +
+ )} +
+ + {/* Akcie */} +
e.stopPropagation()} + > + +
+ + {snoozeOpenFor === notification.id && ( +
+ {snoozeOptions.map((option) => ( + + ))} +
+ )} +
+
-
- {task.deadline && ( - - {getDaysUntilDeadline(task.deadline)} + ); + })} +
+ + + )} + + {/* Urgentné úlohy - po termíne + blížiaci sa termín */} + {(overdueTasks.length > 0 || urgentTasks.length > 0) && ( + + + + + Vyžaduje pozornosť + {overdueTasks.length + urgentTasks.length} + + + +
+ {/* Po termíne */} + {overdueTasks.map((task) => ( +
setTaskDetail({ taskId: task.id })} + className="p-3 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 cursor-pointer hover:border-red-400 transition-colors" + > +
+
+ +

{task.title}

+
+
+ + {getDaysUntilDeadline(task.deadline!)} - )} - {task.priority?.name} + {task.priority?.name} +
+ {task.description && ( +

+ {task.description} +

+ )} + {task.createdBy && ( + + + Zadal: {task.createdBy.name} + + )} +
+ ))} + {/* Blížiaci sa termín */} + {urgentTasks.filter(t => !overdueTasks.includes(t)).map((task) => ( +
setTaskDetail({ taskId: task.id })} + className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 cursor-pointer hover:border-amber-400 transition-colors" + > +
+
+ +

{task.title}

+
+
+ + {getDaysUntilDeadline(task.deadline!)} + + {task.priority?.name} +
+
+ {task.description && ( +

+ {task.description} +

+ )} + {task.createdBy && ( + + + Zadal: {task.createdBy.name} + + )}
))}
@@ -205,176 +472,181 @@ export function Dashboard() {
)} -
- {/* Moje úlohy */} - - - - - Moje úlohy - {today?.myTasks && today.myTasks.length > 0 && ( - - ({today.myTasks.length}) - - )} - - - Všetky - - - - {normalTasks.length > 0 ? ( -
- {normalTasks.slice(0, 5).map((task) => ( -
setDetailTaskId(task.id)} - className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer" - > -
-
-

{task.title}

- {task.description && ( -

- {task.description} -

+ {/* Úlohy podľa stavov */} + {statuses.filter(s => !s.isFinal).map((status) => { + const tasks = tasksByStatus[status.id] || []; + const isCollapsed = collapsedStatuses.has(status.id); + + if (tasks.length === 0) return null; + + return ( + + toggleStatusCollapse(status.id)} + > + +
+ {isCollapsed ? ( + + ) : ( + + )} + + {status.name} + {tasks.length} +
+ e.stopPropagation()} + > + Zobraziť všetky + +
+
+ {!isCollapsed && ( + +
+ {tasks.map((task) => ( +
setTaskDetail({ taskId: task.id })} + className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer" + > +
+
+ + {task.priority?.name} + +

{task.title}

+
+ {task.deadline && ( + + + {formatDate(task.deadline)} + )}
-
- {task.status?.name} - {task.priority?.name} -
-
-
- {task.project && ( - - - {task.project.name} - + {task.description && ( +

+ {task.description} +

)} {task.createdBy && ( - + Zadal: {task.createdBy.name} )} - {task.deadline && ( - - - {formatDate(task.deadline)} - - )}
-
- ))} - {normalTasks.length > 5 && ( -

- +{normalTasks.length - 5} ďalších úloh -

- )} -
- ) : today?.myTasks?.length === 0 ? ( -
- -

Nemáte žiadne priradené úlohy

- - Zobraziť všetky úlohy → - -
- ) : null} - - - - {/* Moje projekty */} - - - - - Moje projekty - {today?.myProjects && today.myProjects.length > 0 && ( - - ({today.myProjects.length}) - - )} - - - Všetky - - - - {today?.myProjects && today.myProjects.length > 0 ? ( -
- {today.myProjects.map((project) => ( -
-
-
-

{project.name}

- {project.description && ( -

- {project.description} -

- )} -
- {project.status?.name} -
-
- - - {project._count?.tasks ?? 0} úloh - - {project.hardDeadline && ( - - - Termín: {formatDate(project.hardDeadline)} - - )} -
-
- ))} -
- ) : ( -
- -

Nemáte žiadne aktívne projekty

- - Zobraziť všetky projekty → - -
+ ))} +
+ )} - - -
+ + ); + })} - {/* Upozornenie na revízie */} - {(stats?.equipment.upcomingRevisions ?? 0) > 0 && ( - - -
- - Blížiace sa revízie + {/* Dokončené úlohy - defaultne zbalené */} + {statuses.filter(s => s.isFinal).map((status) => { + const tasks = tasksByStatus[status.id] || []; + + if (tasks.length === 0) return null; + + const isCollapsed = !collapsedStatuses.has(`done-${status.id}`); + + return ( + + { + setCollapsedStatuses(prev => { + const next = new Set(prev); + const key = `done-${status.id}`; + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }} + > + + {isCollapsed ? ( + + ) : ( + + )} + + {status.name} + {tasks.length} + + + {!isCollapsed && ( + +
+ {tasks.slice(0, 5).map((task) => ( +
setTaskDetail({ taskId: task.id })} + className="flex items-center gap-4 p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer" + > +
+

{task.title}

+
+ {task.completedAt && ( + + {formatDate(task.completedAt)} + + )} +
+ ))} + {tasks.length > 5 && ( + + Zobraziť všetkých {tasks.length} úloh + + )} +
+
+ )} +
+ ); + })} + + {/* Žiadne úlohy */} + {totalTasks === 0 && unreadNotifications.length === 0 && ( + + +
+ +

Všetko vybavené!

+

Nemáte žiadne priradené úlohy

+ + Zobraziť všetky úlohy +
- - -

- Máte {stats?.equipment.upcomingRevisions} zariadení s blížiacou sa revíziou v nasledujúcich 30 dňoch. -

- - Skontrolovať zariadenia → -
)} {/* Detail úlohy */} - {detailTaskId && ( + {taskDetail && ( { - setDetailTaskId(null); - // Refresh dashboard data po zatvorení + setTaskDetail(null); queryClient.invalidateQueries({ queryKey: ['dashboard-today'] }); }} /> diff --git a/frontend/src/pages/projects/ProjectsList.tsx b/frontend/src/pages/projects/ProjectsList.tsx index 9be1fde..a777131 100644 --- a/frontend/src/pages/projects/ProjectsList.tsx +++ b/frontend/src/pages/projects/ProjectsList.tsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { ExternalLink } from 'lucide-react'; +import { ExternalLink, AlertTriangle } from 'lucide-react'; import { zakazkyApi } from '@/services/zakazky.api'; +import { useAuthStore } from '@/store/authStore'; import { Input, Card, @@ -23,9 +24,11 @@ import { formatDate } from '@/lib/utils'; export function ProjectsList() { const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); const [search, setSearch] = useState(''); + const { user } = useAuthStore(); + const isAdmin = user?.role === 'ADMIN'; // Check if external DB is configured - const { data: zakazkyStatus, isLoading: statusLoading } = useQuery({ + const { data: zakazkyStatus, isLoading: statusLoading, error: statusError } = useQuery({ queryKey: ['zakazky-status'], queryFn: () => zakazkyApi.checkStatus(), }); @@ -38,12 +41,20 @@ export function ProjectsList() { }); // Get zakazky for selected year - const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({ + const { data: zakazkyData, isLoading: zakazkyLoading, error: zakazkyError } = useQuery({ queryKey: ['zakazky', selectedYear, search], queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined), enabled: !!zakazkyStatus?.data?.configured, + retry: 1, }); + // Extract error message + const getErrorMessage = (error: unknown): string => { + if (!error) return ''; + const axiosError = error as { response?: { data?: { message?: string } }; message?: string }; + return axiosError.response?.data?.message || axiosError.message || 'Neznáma chyba'; + }; + const isExternalDbConfigured = zakazkyStatus?.data?.configured; const yearOptions = (yearsData?.data || []).map((year) => ({ value: String(year), @@ -107,8 +118,25 @@ export function ProjectsList() {
+ {/* Error display for admins */} + {isAdmin && (statusError || zakazkyError) && ( +
+
+ + Chyba pripojenia k externej databáze +
+
+                {getErrorMessage(statusError || zakazkyError)}
+              
+
+ )} + {zakazkyLoading ? ( + ) : zakazkyError ? ( +
+ Nepodarilo sa načítať zákazky. Skúste obnoviť stránku. +
) : ( diff --git a/frontend/src/pages/settings/PasswordResetModal.tsx b/frontend/src/pages/settings/PasswordResetModal.tsx new file mode 100644 index 0000000..732ffd4 --- /dev/null +++ b/frontend/src/pages/settings/PasswordResetModal.tsx @@ -0,0 +1,91 @@ +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { usersApi } from '@/services/users.api'; +import { Button, Input, ModalFooter } from '@/components/ui'; +import toast from 'react-hot-toast'; + +const passwordResetSchema = z + .object({ + password: z + .string() + .min(8, 'Heslo musí mať aspoň 8 znakov') + .regex(/[A-Z]/, 'Heslo musí obsahovať veľké písmeno') + .regex(/[0-9]/, 'Heslo musí obsahovať číslo'), + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Heslá sa nezhodujú', + path: ['confirmPassword'], + }); + +type PasswordResetFormData = z.infer; + +interface PasswordResetModalProps { + userId: string; + userName: string; + onClose: () => void; +} + +export function PasswordResetModal({ userId, userName, onClose }: PasswordResetModalProps) { + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(passwordResetSchema), + defaultValues: { + password: '', + confirmPassword: '', + }, + }); + + const mutation = useMutation({ + mutationFn: (data: PasswordResetFormData) => + usersApi.update(userId, { password: data.password }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }); + toast.success(`Heslo pre ${userName} bolo zmenené`); + onClose(); + }, + onError: () => { + toast.error('Chyba pri zmene hesla'); + }, + }); + + return ( +
mutation.mutate(data))} className="space-y-4"> +

+ Nastavenie nového hesla pre používateľa {userName}. +

+ + +

+ Min. 8 znakov, jedno veľké písmeno, jedno číslo. +

+ + + + + + ); +} diff --git a/frontend/src/pages/settings/SettingsDashboard.tsx b/frontend/src/pages/settings/SettingsDashboard.tsx index dfe47c0..cf1eba6 100644 --- a/frontend/src/pages/settings/SettingsDashboard.tsx +++ b/frontend/src/pages/settings/SettingsDashboard.tsx @@ -21,8 +21,9 @@ import { ModalFooter, } from '@/components/ui'; import toast from 'react-hot-toast'; +import { UserManagement } from './UserManagement'; -type ConfigTab = 'taskStatuses' | 'priorities' | 'equipmentTypes' | 'revisionTypes' | 'rmaStatuses' | 'rmaSolutions' | 'userRoles'; +type ConfigTab = 'users' | 'taskStatuses' | 'priorities' | 'equipmentTypes' | 'revisionTypes' | 'rmaStatuses' | 'rmaSolutions' | 'userRoles' | 'systemSettings'; // Spoločný interface pre konfiguračné entity interface ConfigItem { @@ -33,7 +34,18 @@ interface ConfigItem { order?: number; } +interface SystemSetting { + id: string; + key: string; + value: unknown; + category: string; + label: string; + description?: string | null; + dataType: string; +} + const tabs: { key: ConfigTab; label: string }[] = [ + { key: 'users', label: 'Používatelia' }, { key: 'taskStatuses', label: 'Stavy úloh' }, { key: 'priorities', label: 'Priority' }, { key: 'equipmentTypes', label: 'Typy zariadení' }, @@ -41,11 +53,12 @@ const tabs: { key: ConfigTab; label: string }[] = [ { key: 'rmaStatuses', label: 'RMA stavy' }, { key: 'rmaSolutions', label: 'RMA riešenia' }, { key: 'userRoles', label: 'Užívateľské role' }, + { key: 'systemSettings', label: 'Systémové nastavenia' }, ]; export function SettingsDashboard() { const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState('taskStatuses'); + const [activeTab, setActiveTab] = useState('users'); const [editItem, setEditItem] = useState(null); const [deleteItem, setDeleteItem] = useState<{ id: string; name: string } | null>(null); @@ -91,6 +104,12 @@ export function SettingsDashboard() { enabled: activeTab === 'userRoles', }); + const { data: systemSettings, isLoading: loadingSystemSettings } = useQuery({ + queryKey: ['system-settings'], + queryFn: () => settingsApi.getSystemSettings(), + enabled: activeTab === 'systemSettings', + }); + const deleteMutation = useMutation({ mutationFn: async ({ tab, id }: { tab: ConfigTab; id: string }) => { switch (tab) { @@ -114,7 +133,7 @@ export function SettingsDashboard() { }); const isLoading = loadingStatuses || loadingPriorities || loadingEquipmentTypes || - loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles; + loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles || loadingSystemSettings; const getCurrentData = (): ConfigItem[] => { switch (activeTab) { @@ -125,10 +144,12 @@ export function SettingsDashboard() { case 'rmaStatuses': return (rmaStatuses?.data || []) as ConfigItem[]; case 'rmaSolutions': return (rmaSolutions?.data || []) as ConfigItem[]; case 'userRoles': return (userRoles?.data || []) as ConfigItem[]; + case 'systemSettings': return []; // Systémové nastavenia majú iný formát } }; const data: ConfigItem[] = getCurrentData(); + const settings: SystemSetting[] = (systemSettings?.data || []) as SystemSetting[]; return (
@@ -147,65 +168,71 @@ export function SettingsDashboard() { ))}
- - - {tabs.find(t => t.key === activeTab)?.label} - - - - {isLoading ? ( - - ) : ( -
- - - Kód - Názov - Farba - Poradie - Akcie - - - - {data.map((item) => ( - - {item.code} - {item.name} - - {item.color && ( - {item.color} - )} - - {item.order ?? 0} - - - - - - ))} - {data.length === 0 && ( + {activeTab === 'users' ? ( + + ) : activeTab === 'systemSettings' ? ( + + ) : ( + + + {tabs.find(t => t.key === activeTab)?.label} + + + + {isLoading ? ( + + ) : ( +
+ - - Žiadne položky - + Kód + Názov + Farba + Poradie + Akcie - )} - -
- )} -
-
+ + + {data.map((item) => ( + + {item.code} + {item.name} + + {item.color && ( + {item.color} + )} + + {item.order ?? 0} + + + + + + ))} + {data.length === 0 && ( + + + Žiadne položky + + + )} + + + )} + + + )} ); } + +// Komponent pre systémové nastavenia +interface SystemSettingsPanelProps { + settings: SystemSetting[]; + isLoading: boolean; +} + +function SystemSettingsPanel({ settings, isLoading }: SystemSettingsPanelProps) { + const queryClient = useQueryClient(); + const [editingSetting, setEditingSetting] = useState(null); + + const updateMutation = useMutation({ + mutationFn: async ({ key, value }: { key: string; value: unknown }) => { + return settingsApi.updateSystemSetting(key, value); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['system-settings'] }); + toast.success('Nastavenie bolo aktualizované'); + setEditingSetting(null); + }, + onError: () => { + toast.error('Chyba pri aktualizácii nastavenia'); + }, + }); + + // Zoskupiť nastavenia podľa kategórie + const settingsByCategory = settings.reduce((acc, setting) => { + const category = setting.category || 'OTHER'; + if (!acc[category]) acc[category] = []; + acc[category].push(setting); + return acc; + }, {} as Record); + + const categoryLabels: Record = { + NOTIFICATIONS: 'Notifikácie', + GENERAL: 'Všeobecné', + EMAIL: 'Email', + OTHER: 'Ostatné', + }; + + if (isLoading) { + return ; + } + + return ( +
+ {Object.entries(settingsByCategory).map(([category, categorySettings]) => ( + + + {categoryLabels[category] || category} + + + {categorySettings.map((setting) => ( +
+
+
+
{setting.label}
+ {setting.description && ( +

{setting.description}

+ )} +
+ Kľúč: {setting.key} +
+
+ +
+
+ {setting.dataType === 'json' ? ( +
{JSON.stringify(setting.value, null, 2)}
+ ) : ( + {String(setting.value)} + )} +
+
+ ))} +
+
+ ))} + + {settings.length === 0 && ( + + + Žiadne systémové nastavenia + + + )} + + {/* Modal pre editáciu */} + setEditingSetting(null)} + title={`Upraviť: ${editingSetting?.label}`} + > + {editingSetting && ( + updateMutation.mutate({ key: editingSetting.key, value })} + onClose={() => setEditingSetting(null)} + isLoading={updateMutation.isPending} + /> + )} + +
+ ); +} + +// Formulár pre úpravu systémového nastavenia +interface SystemSettingFormProps { + setting: SystemSetting; + onSave: (value: unknown) => void; + onClose: () => void; + isLoading: boolean; +} + +function SystemSettingForm({ setting, onSave, onClose, isLoading }: SystemSettingFormProps) { + const [value, setValue] = useState(() => { + if (setting.dataType === 'json') { + return JSON.stringify(setting.value, null, 2); + } + return String(setting.value); + }); + const [error, setError] = useState(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + + try { + let parsedValue: unknown; + if (setting.dataType === 'json') { + parsedValue = JSON.parse(value); + } else if (setting.dataType === 'number') { + parsedValue = Number(value); + } else if (setting.dataType === 'boolean') { + parsedValue = value === 'true'; + } else { + parsedValue = value; + } + onSave(parsedValue); + } catch { + setError('Neplatný formát hodnoty. Skontrolujte syntax JSON.'); + } + }; + + return ( +
+ {setting.description && ( +

{setting.description}

+ )} + + {setting.dataType === 'json' ? ( +
+ +