Správa používateľov + notifikačný systém

- Pridaná kompletná správa používateľov (CRUD, reset hesla, zmena roly) pre ROOT/ADMIN
- Backend: POST /users endpoint, createUser controller, validácia
- Frontend: UserManagement, UserForm, PasswordResetModal komponenty
- Settings prístupné pre ROOT aj ADMIN (AdminRoute)
- Notifikačný systém s snooze funkcionalitou
- Aktualizácia HELPDESK_INIT_V2.md dokumentácie

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 15:30:27 +01:00
parent cbdd952bc1
commit 2ca0c4f4d8
36 changed files with 3116 additions and 522 deletions

View File

@@ -28,7 +28,8 @@
### **Pridané:** ### **Pridané:**
- ✅ Configuration-driven architecture - ✅ Configuration-driven architecture
- ✅ ROOT Settings panel - ✅ ROOT/ADMIN Settings panel
- ✅ User Management (CRUD, reset hesla, zmena roly)
- ✅ External DB import pre zákazníkov - ✅ External DB import pre zákazníkov
- ✅ Dynamic workflow rules - ✅ Dynamic workflow rules
- ✅ Multi-entity tagging system - ✅ Multi-entity tagging system
@@ -173,6 +174,10 @@ model User {
reminders Reminder[] reminders Reminder[]
activityLogs ActivityLog[] activityLogs ActivityLog[]
// Comments & Notifications
comments Comment[]
notifications Notification[]
// Equipment // Equipment
createdEquipment Equipment[] @relation("EquipmentCreator") createdEquipment Equipment[] @relation("EquipmentCreator")
performedRevisions Revision[] performedRevisions Revision[]
@@ -511,10 +516,11 @@ model Task {
createdById String createdById String
createdBy User @relation("TaskCreator", fields: [createdById], references: [id]) createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
assignees TaskAssignee[] assignees TaskAssignee[]
reminders Reminder[] reminders Reminder[]
comments Comment[] comments Comment[]
tags TaskTag[] tags TaskTag[]
notifications Notification[]
@@index([projectId]) @@index([projectId])
@@index([parentId]) @@index([parentId])
@@ -574,6 +580,7 @@ model Comment {
userId String userId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade) task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
content String @db.Text content String @db.Text
@@ -584,6 +591,38 @@ model Comment {
@@index([createdAt]) @@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 ==================== // ==================== EQUIPMENT MANAGEMENT ====================
model Equipment { model Equipment {
@@ -760,6 +799,7 @@ model RMA {
statusHistory RMAStatusHistory[] statusHistory RMAStatusHistory[]
comments RMAComment[] comments RMAComment[]
tags RMATag[] tags RMATag[]
notifications Notification[]
@@index([rmaNumber]) @@index([rmaNumber])
@@index([customerId]) @@index([customerId])
@@ -910,15 +950,16 @@ POST /api/auth/logout
GET /api/auth/me GET /api/auth/me
``` ```
### Users ### Users (ROOT/ADMIN)
``` ```
GET /api/users // Stránkovaný zoznam (admin only) GET /api/users // Stránkovaný zoznam (admin only)
GET /api/users/simple // Jednoduchý zoznam pre selecty (server-side search: ?search=meno) 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 GET /api/users/:id
PUT /api/users/:id PUT /api/users/:id // Úprava + reset hesla
DELETE /api/users/:id DELETE /api/users/:id // Soft delete (deaktivácia)
PATCH /api/users/:id/role PATCH /api/users/:id/role // Zmena roly
``` ```
### Projects ### Projects
@@ -1004,7 +1045,7 @@ GET /api/rma/:id/pdf // Generate PDF
GET /api/rma/generate-number // Next RMA number GET /api/rma/generate-number // Next RMA number
``` ```
### **🆕 Settings (ROOT only)** ### **🆕 Settings (ROOT/ADMIN)**
``` ```
// Equipment Types // Equipment Types
@@ -1064,6 +1105,17 @@ PUT /api/settings/roles/:id
DELETE /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 ### Dashboard
``` ```
@@ -1100,6 +1152,9 @@ src/
│ │ ├── Sidebar.tsx │ │ ├── Sidebar.tsx
│ │ └── MainLayout.tsx │ │ └── MainLayout.tsx
│ │ │ │
│ ├── notifications/ # NEW (Fáza 2)
│ │ └── NotificationCenter.tsx # Zvonček s dropdown v header
│ │
│ ├── dashboard/ │ ├── dashboard/
│ │ ├── DashboardView.tsx │ │ ├── DashboardView.tsx
│ │ ├── TodaysTasks.tsx │ │ ├── TodaysTasks.tsx
@@ -1153,6 +1208,9 @@ src/
│ │ │ │
│ ├── settings/ # NEW │ ├── settings/ # NEW
│ │ ├── SettingsDashboard.tsx │ │ ├── SettingsDashboard.tsx
│ │ ├── UserManagement.tsx # Správa používateľov (ROOT/ADMIN)
│ │ ├── UserForm.tsx # Formulár vytvorenie/editácia
│ │ ├── PasswordResetModal.tsx # Reset hesla
│ │ ├── EquipmentTypesSettings.tsx │ │ ├── EquipmentTypesSettings.tsx
│ │ ├── RevisionTypesSettings.tsx │ │ ├── RevisionTypesSettings.tsx
│ │ ├── RMAStatusSettings.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:** **Backend:**
- [ ] **Revision system** - [ ] **Revision system**
@@ -1284,8 +1342,15 @@ cd backend && npx prisma db seed
- [ ] Dashboard aggregations - [ ] Dashboard aggregations
- [ ] Email service (Postfix self-hosted) - [ ] Email service (Postfix self-hosted)
- [ ] WebSocket (Socket.IO) - [ ] WebSocket (Socket.IO)
- [ ] File upload handling - [x] File upload handling
- [ ] **Task notifications** (databázové - viditeľné na všetkých zariadeniach) - [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:** **Frontend:**
- [ ] **Swimlanes Board** (dnd-kit) - [ ] **Swimlanes Board** (dnd-kit)
@@ -1300,7 +1365,7 @@ cd backend && npx prisma db seed
- [ ] **RMA Workflow** - [ ] **RMA Workflow**
- [ ] Status change UI - [ ] Status change UI
- [ ] Approval buttons (admin) - [ ] Approval buttons (admin)
- [ ] File attachments - [x] File attachments
- [ ] Comments - [ ] Comments
- [ ] PDF export - [ ] PDF export
- [ ] **Inline Quick Actions** - [ ] **Inline Quick Actions**
@@ -1308,16 +1373,24 @@ cd backend && npx prisma db seed
- [ ] Reminder management UI - [ ] Reminder management UI
- [ ] Filters & tags - [ ] Filters & tags
- [ ] Real-time updates (WebSocket) - [ ] 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:** **Deliverable:**
``` ```
✅ Všetko z Fázy 1 + ✅ Všetko z Fázy 1 +
Swimlanes board Swimlanes board
Revízny systém funguje Revízny systém funguje
RMA workflow s approval RMA workflow s approval
Email notifikácie Email notifikácie
Live updates (WebSocket) Live updates (WebSocket)
✅ File uploads ✅ File uploads
✅ Task notifikácie (databázové, všetky zariadenia) ✅ Task notifikácie (databázové, všetky zariadenia)
``` ```
@@ -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<string>();
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 ```typescript
// services/import.service.ts // services/import.service.ts
@@ -2266,6 +2452,7 @@ helpdesk-system/
│ │ │ ├── useTasks.ts │ │ │ ├── useTasks.ts
│ │ │ ├── useEquipment.ts # NEW │ │ │ ├── useEquipment.ts # NEW
│ │ │ ├── useRMA.ts # NEW │ │ │ ├── useRMA.ts # NEW
│ │ │ ├── useSnoozeOptions.ts # NEW (Fáza 2) - konfigurovateľné snooze možnosti
│ │ │ └── useKeyboard.ts │ │ │ └── useKeyboard.ts
│ │ │ │ │ │
│ │ ├── services/ │ │ ├── services/
@@ -2276,13 +2463,15 @@ helpdesk-system/
│ │ │ ├── customers.api.ts # NEW │ │ │ ├── customers.api.ts # NEW
│ │ │ ├── equipment.api.ts # NEW │ │ │ ├── equipment.api.ts # NEW
│ │ │ ├── rma.api.ts # NEW │ │ │ ├── rma.api.ts # NEW
│ │ │ ── settings.api.ts # NEW │ │ │ ── settings.api.ts # NEW
│ │ │ └── notification.api.ts # NEW (Fáza 2)
│ │ │ │ │ │
│ │ ├── store/ │ │ ├── store/
│ │ │ ├── authStore.ts │ │ │ ├── authStore.ts
│ │ │ ├── configStore.ts # NEW │ │ │ ├── configStore.ts # NEW
│ │ │ ├── projectsStore.ts │ │ │ ├── projectsStore.ts
│ │ │ ── tasksStore.ts │ │ │ ── tasksStore.ts
│ │ │ └── notificationStore.ts # NEW (Fáza 2)
│ │ │ │ │ │
│ │ ├── types/ │ │ ├── types/
│ │ ├── styles/ │ │ ├── styles/
@@ -2529,5 +2718,6 @@ CELKOM: ~€10-15/mesiac
--- ---
*Dokument vytvorený: 02.02.2026* *Dokument vytvorený: 02.02.2026*
*Verzia: 2.0.0* *Posledná aktualizácia: 19.02.2026*
*Verzia: 2.2.0*
*Autor: Claude (Anthropic) + Používateľ* *Autor: Claude (Anthropic) + Používateľ*

View File

@@ -67,6 +67,7 @@ model User {
taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader") taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader")
createdCustomers Customer[] createdCustomers Customer[]
notifications Notification[]
@@index([email]) @@index([email])
@@index([roleId]) @@index([roleId])
@@ -374,11 +375,12 @@ model Task {
createdById String createdById String
createdBy User @relation("TaskCreator", fields: [createdById], references: [id]) createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
assignees TaskAssignee[] assignees TaskAssignee[]
reminders Reminder[] reminders Reminder[]
comments Comment[] comments Comment[]
tags TaskTag[] tags TaskTag[]
attachments TaskAttachment[] attachments TaskAttachment[]
notifications Notification[]
@@index([projectId]) @@index([projectId])
@@index([parentId]) @@index([parentId])
@@ -633,6 +635,7 @@ model RMA {
statusHistory RMAStatusHistory[] statusHistory RMAStatusHistory[]
comments RMAComment[] comments RMAComment[]
tags RMATag[] tags RMATag[]
notifications Notification[]
@@index([rmaNumber]) @@index([rmaNumber])
@@index([customerId]) @@index([customerId])
@@ -706,6 +709,40 @@ model RMATag {
@@id([rmaId, tagId]) @@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 ==================== // ==================== ACTIVITY LOG ====================
model ActivityLog { model ActivityLog {

View File

@@ -203,6 +203,19 @@ async function seed() {
label: 'Zapnúť real-time aktualizácie (WebSocket)', label: 'Zapnúť real-time aktualizácie (WebSocket)',
dataType: 'boolean', 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',
},
], ],
}); });

View File

@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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);
}
};

View File

@@ -4,6 +4,7 @@ import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getPa
import { AuthRequest } from '../middleware/auth.middleware'; import { AuthRequest } from '../middleware/auth.middleware';
import { configService } from '../services/config.service'; import { configService } from '../services/config.service';
import { movePendingFilesToEntity } from './upload.controller'; import { movePendingFilesToEntity } from './upload.controller';
import { notificationService } from '../services/notification.service';
export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => { export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => {
try { try {
@@ -167,6 +168,13 @@ export const createTask = async (req: AuthRequest, res: Response): Promise<void>
userId, userId,
})), })),
}); });
// Notify assigned users
await notificationService.notifyTaskAssignment(
task.id,
req.body.assigneeIds,
req.user!.userId
);
} }
// Move pending files if tempId provided // Move pending files if tempId provided
@@ -189,13 +197,22 @@ export const updateTask = async (req: AuthRequest, res: Response): Promise<void>
try { try {
const id = getParam(req, 'id'); 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) { if (!existing) {
errorResponse(res, 'Úloha nebola nájdená.', 404); errorResponse(res, 'Úloha nebola nájdená.', 404);
return; return;
} }
const oldStatusName = existing.status.name;
const oldAssigneeIds = existing.assignees.map((a) => a.userId);
const updateData: Record<string, unknown> = {}; const updateData: Record<string, unknown> = {};
if (req.body.title) updateData.title = req.body.title; if (req.body.title) updateData.title = req.body.title;
if (req.body.description !== undefined) updateData.description = req.body.description; if (req.body.description !== undefined) updateData.description = req.body.description;
@@ -245,6 +262,26 @@ export const updateTask = async (req: AuthRequest, res: Response): Promise<void>
}, },
}); });
// 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) { if (req.logActivity) {
await req.logActivity('UPDATE', 'Task', id, updateData); 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 id = getParam(req, 'id');
const { statusId } = req.body; 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({ const task = await prisma.task.update({
where: { id }, where: { id },
data: { data: {
statusId, statusId,
completedAt: status?.isFinal ? new Date() : null, completedAt: newStatus?.isFinal ? new Date() : null,
}, },
include: { status: true }, 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) { if (req.logActivity) {
await req.logActivity('STATUS_CHANGE', 'Task', id, { statusId }); await req.logActivity('STATUS_CHANGE', 'Task', id, { statusId });
} }
@@ -398,6 +458,9 @@ export const addTaskComment = async (req: AuthRequest, res: Response): Promise<v
}, },
}); });
// Notify about new comment
await notificationService.notifyTaskComment(id, comment.id, userId, comment.user.name);
successResponse(res, comment, 'Komentár bol pridaný.', 201); successResponse(res, comment, 'Komentár bol pridaný.', 201);
} catch (error) { } catch (error) {
console.error('Error adding task comment:', error); console.error('Error adding task comment:', error);

View File

@@ -40,6 +40,54 @@ export const getUsersSimple = async (req: AuthRequest, res: Response): Promise<v
} }
}; };
export const createUser = async (req: AuthRequest, res: Response): Promise<void> => {
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<void> => { export const getUsers = async (req: AuthRequest, res: Response): Promise<void> => {
try { try {
const page = parseQueryInt(req.query.page, 1); const page = parseQueryInt(req.query.page, 1);

View File

@@ -20,6 +20,8 @@ export const getZakazky = async (req: AuthRequest, res: Response): Promise<void>
const rok = parseQueryInt(req.query.rok, new Date().getFullYear()); const rok = parseQueryInt(req.query.rok, new Date().getFullYear());
const search = getQueryString(req, 'search'); const search = getQueryString(req, 'search');
console.log(`[Zakazky] Fetching year=${rok}, search=${search || 'none'}`);
let zakazky; let zakazky;
if (search) { if (search) {
@@ -28,10 +30,16 @@ export const getZakazky = async (req: AuthRequest, res: Response): Promise<void>
zakazky = await externalDbService.getZakazkyByYear(rok); zakazky = await externalDbService.getZakazkyByYear(rok);
} }
console.log(`[Zakazky] Found ${zakazky.length} records`);
successResponse(res, zakazky); successResponse(res, zakazky);
} catch (error) { } catch (error) {
console.error('Error fetching zakazky:', error); const err = error as Error;
errorResponse(res, 'Chyba pri načítaní zákaziek z externej databázy.', 500); 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);
} }
}; };

View File

@@ -10,6 +10,7 @@ import settingsRoutes from './settings.routes';
import dashboardRoutes from './dashboard.routes'; import dashboardRoutes from './dashboard.routes';
import uploadRoutes from './upload.routes'; import uploadRoutes from './upload.routes';
import zakazkyRoutes from './zakazky.routes'; import zakazkyRoutes from './zakazky.routes';
import notificationRoutes from './notification.routes';
const router = Router(); const router = Router();
@@ -24,5 +25,6 @@ router.use('/settings', settingsRoutes);
router.use('/dashboard', dashboardRoutes); router.use('/dashboard', dashboardRoutes);
router.use('/files', uploadRoutes); router.use('/files', uploadRoutes);
router.use('/zakazky', zakazkyRoutes); router.use('/zakazky', zakazkyRoutes);
router.use('/notifications', notificationRoutes);
export default router; export default router;

View File

@@ -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;

View File

@@ -25,6 +25,10 @@ router.get('/rma-solutions', settingsController.getRMASolutions);
router.get('/tags', settingsController.getTags); router.get('/tags', settingsController.getTags);
// User Roles - čítanie // User Roles - čítanie
router.get('/roles', settingsController.getUserRoles); 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) === // === ROOT-ONLY ENDPOINTY (vytvorenie, úprava, mazanie) ===
router.use(isRoot); router.use(isRoot);
@@ -69,10 +73,7 @@ router.post('/roles', settingsController.createUserRole);
router.put('/roles/:id', settingsController.updateUserRole); router.put('/roles/:id', settingsController.updateUserRole);
router.delete('/roles/:id', settingsController.deleteUserRole); router.delete('/roles/:id', settingsController.deleteUserRole);
// System Settings - len ROOT // System Settings - úprava (len ROOT)
router.get('/system', settingsController.getSystemSettings);
router.get('/system/category/:category', settingsController.getSystemSettingsByCategory);
router.get('/system/:key', settingsController.getSystemSetting);
router.put('/system/:key', settingsController.updateSystemSetting); router.put('/system/:key', settingsController.updateSystemSetting);
export default router; export default router;

View File

@@ -3,6 +3,8 @@ import * as usersController from '../controllers/users.controller';
import { authenticate } from '../middleware/auth.middleware'; import { authenticate } from '../middleware/auth.middleware';
import { isAdmin } from '../middleware/rbac.middleware'; import { isAdmin } from '../middleware/rbac.middleware';
import { activityLogger } from '../middleware/activityLog.middleware'; import { activityLogger } from '../middleware/activityLog.middleware';
import { validate } from '../middleware/validate.middleware';
import { createUserSchema } from '../utils/validators';
const router = Router(); const router = Router();
@@ -13,6 +15,7 @@ router.use(activityLogger);
router.get('/simple', usersController.getUsersSimple); router.get('/simple', usersController.getUsersSimple);
router.get('/', isAdmin, usersController.getUsers); router.get('/', isAdmin, usersController.getUsers);
router.post('/', isAdmin, validate(createUserSchema), usersController.createUser);
router.get('/:id', isAdmin, usersController.getUser); router.get('/:id', isAdmin, usersController.getUser);
router.put('/:id', isAdmin, usersController.updateUser); router.put('/:id', isAdmin, usersController.updateUser);
router.delete('/:id', isAdmin, usersController.deleteUser); router.delete('/:id', isAdmin, usersController.deleteUser);

View File

@@ -144,15 +144,19 @@ export const getZakazkaById = async (rok: number, id: number): Promise<Zakazka |
return zakazky.find((z) => z.id === id) || null; return zakazky.find((z) => 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 // Search zakazky
export const searchZakazky = async (rok: number, search: string): Promise<Zakazka[]> => { export const searchZakazky = async (rok: number, search: string): Promise<Zakazka[]> => {
const zakazky = await getZakazkyByYear(rok); const zakazky = await getZakazkyByYear(rok);
const searchLower = search.toLowerCase(); const searchNormalized = normalizeText(search);
return zakazky.filter((z) => return zakazky.filter((z) =>
z.cislo.toLowerCase().includes(searchLower) || normalizeText(z.cislo).includes(searchNormalized) ||
z.nazov.toLowerCase().includes(searchLower) || normalizeText(z.nazov).includes(searchNormalized) ||
z.customer.toLowerCase().includes(searchLower) normalizeText(z.customer).includes(searchNormalized)
); );
}; };

View File

@@ -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<CreateNotificationData, 'userId'>) {
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<string>();
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 },
});
},
};

View File

@@ -71,3 +71,9 @@ export const parseBooleanQuery = (value: unknown): boolean | undefined => {
if (value === 'false') return false; if (value === 'false') return false;
return undefined; 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;
};

View File

@@ -18,6 +18,17 @@ export const registerSchema = z.object({
}); });
// User validators // 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({ export const updateUserSchema = z.object({
email: z.string().email('Neplatný email').optional(), email: z.string().email('Neplatný email').optional(),
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky').optional(), name: z.string().min(2, 'Meno musí mať aspoň 2 znaky').optional(),

View File

@@ -56,10 +56,10 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) {
return <>{children}</>; return <>{children}</>;
} }
function RootOnlyRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) {
const { user } = useAuthStore(); const { user } = useAuthStore();
if (user?.role.code !== 'ROOT') { if (user?.role.code !== 'ROOT' && user?.role.code !== 'ADMIN') {
return <Navigate to="/" replace />; return <Navigate to="/" replace />;
} }
@@ -91,9 +91,9 @@ function AppRoutes() {
<Route <Route
path="/settings" path="/settings"
element={ element={
<RootOnlyRoute> <AdminRoute>
<SettingsDashboard /> <SettingsDashboard />
</RootOnlyRoute> </AdminRoute>
} }
/> />
</Route> </Route>

View File

@@ -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<HTMLDivElement>(null);
const [snoozeOpenFor, setSnoozeOpenFor] = useState<string | null>(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 (
<div className="relative" ref={containerRef}>
{/* Bell Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'relative p-2 rounded-lg transition-colors',
'hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
isOpen && 'bg-accent'
)}
aria-label={`Notifikácie${unreadCount > 0 ? ` (${unreadCount} neprečítaných)` : ''}`}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute right-0 mt-2 w-96 max-h-[500px] bg-popover border border-border rounded-lg shadow-lg overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/50">
<h3 className="font-semibold">Notifikácie</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<CheckCheck className="h-3 w-3" />
Označiť všetky
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-accent rounded"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Notifications List */}
<div className="max-h-[400px] overflow-y-auto">
{isLoading ? (
<div className="py-8 text-center text-muted-foreground">
Načítavam...
</div>
) : notifications.length === 0 ? (
<div className="py-12 text-center">
<Bell className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">Žiadne notifikácie</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
onClick={() => 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'
)}
>
<div className="flex gap-3">
<span className="text-xl flex-shrink-0">
{getNotificationIcon(notification.type)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={cn(
'text-sm',
!notification.isRead && 'font-medium'
)}>
{notification.title}
</p>
{!notification.isRead && (
<span className="flex-shrink-0 h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
<div className="flex items-center gap-1">
{!notification.isRead && (
<button
onClick={(e) => {
e.stopPropagation();
markAsRead(notification.id);
}}
className="p-1 hover:bg-background rounded text-muted-foreground hover:text-foreground"
title="Označiť ako prečítané"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<div className="relative">
<button
onClick={(e) => toggleSnoozeMenu(e, notification.id)}
className="p-1 hover:bg-background rounded text-muted-foreground hover:text-foreground"
title="Odložiť"
>
<Clock className="h-3.5 w-3.5" />
</button>
{snoozeOpenFor === notification.id && (
<div className="absolute right-0 bottom-full mb-1 bg-popover border rounded-md shadow-lg z-20 min-w-[140px]">
{snoozeOptions.map((option) => (
<button
key={option.label}
onClick={(e) => handleSnooze(e, notification.id, option)}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent first:rounded-t-md last:rounded-b-md"
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,13 @@
import { Link } from 'react-router-dom'; 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 { useAuthStore } from '@/store/authStore';
import { useSidebarStore } from '@/store/sidebarStore';
import { Button } from '@/components/ui'; import { Button } from '@/components/ui';
import { NotificationCenter } from '@/components/NotificationCenter';
export function Header() { export function Header() {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { toggle } = useSidebarStore();
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
@@ -13,9 +16,19 @@ export function Header() {
return ( return (
<header className="sticky top-0 z-40 border-b bg-background"> <header className="sticky top-0 z-40 border-b bg-background">
<div className="flex h-14 items-center justify-between px-4"> <div className="flex h-14 items-center justify-between px-4">
<Link to="/" className="flex items-center gap-2 font-semibold"> <div className="flex items-center gap-2">
<span className="text-lg">Helpdesk</span> <Button
</Link> variant="ghost"
size="sm"
onClick={toggle}
className="md:hidden"
>
<Menu className="h-5 w-5" />
</Button>
<Link to="/" className="flex items-center gap-2 font-semibold">
<span className="text-lg">Helpdesk</span>
</Link>
</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{user && ( {user && (
@@ -26,7 +39,9 @@ export function Header() {
<span className="text-muted-foreground">({user.role.name})</span> <span className="text-muted-foreground">({user.role.name})</span>
</div> </div>
{user.role.code === 'ROOT' && ( <NotificationCenter />
{(user.role.code === 'ROOT' || user.role.code === 'ADMIN') && (
<Link to="/settings"> <Link to="/settings">
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Settings className="h-4 w-4" /> <Settings className="h-4 w-4" />

View File

@@ -7,7 +7,7 @@ export function MainLayout() {
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Header /> <Header />
<Sidebar /> <Sidebar />
<main className="ml-56 p-6"> <main className="p-4 md:ml-56 md:p-6">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@@ -6,8 +6,10 @@ import {
Users, Users,
Wrench, Wrench,
RotateCcw, RotateCcw,
X,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSidebarStore } from '@/store/sidebarStore';
const navItems = [ const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' }, { to: '/', icon: LayoutDashboard, label: 'Dashboard' },
@@ -19,28 +21,57 @@ const navItems = [
]; ];
export function Sidebar() { export function Sidebar() {
const { isOpen, close } = useSidebarStore();
return ( return (
<aside className="fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-56 border-r bg-background"> <>
<nav className="flex flex-col gap-1 p-4"> {/* Overlay pre mobile */}
{navItems.map((item) => ( {isOpen && (
<NavLink <div
key={item.to} className="fixed inset-0 z-40 bg-black/50 md:hidden"
to={item.to} onClick={close}
end={item.to === '/'} />
className={({ isActive }) => )}
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors', {/* Sidebar */}
isActive <aside
? 'bg-primary text-primary-foreground' className={cn(
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground' 'fixed left-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-56 border-r bg-background transition-transform duration-200',
) 'md:translate-x-0 md:z-30',
} isOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex items-center justify-between p-4 md:hidden">
<span className="font-semibold">Menu</span>
<button
onClick={close}
className="rounded-md p-1 hover:bg-accent"
> >
<item.icon className="h-4 w-4" /> <X className="h-5 w-5" />
{item.label} </button>
</NavLink> </div>
))} <nav className="flex flex-col gap-1 p-4 pt-0 md:pt-4">
</nav> {navItems.map((item) => (
</aside> <NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={close}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</nav>
</aside>
</>
); );
} }

View File

@@ -48,14 +48,18 @@ export function SearchableSelect({
return () => document.removeEventListener('mousedown', handleClickOutside); 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 // Filter options based on search
const filteredOptions = useMemo(() => { const filteredOptions = useMemo(() => {
if (!search) return options; if (!search) return options;
const searchLower = search.toLowerCase(); const searchNormalized = normalizeText(search);
return options.filter( return options.filter(
(opt) => (opt) =>
opt.label.toLowerCase().includes(searchLower) || normalizeText(opt.label).includes(searchNormalized) ||
opt.description?.toLowerCase().includes(searchLower) (opt.description && normalizeText(opt.description).includes(searchNormalized))
); );
}, [options, search]); }, [options, search]);

View File

@@ -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;
}

View File

@@ -27,11 +27,25 @@ export function formatRelativeTime(date: string | Date): string {
const now = new Date(); const now = new Date();
const target = new Date(date); const target = new Date(date);
const diffMs = now.getTime() - target.getTime(); 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)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Dnes'; const timeStr = target.toLocaleTimeString('sk-SK', { hour: '2-digit', minute: '2-digit' });
if (diffDays === 1) return 'Včera';
// 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 < 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); return formatDate(date);
} }

View File

@@ -1,117 +1,157 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
FolderKanban,
CheckSquare, CheckSquare,
Users,
Wrench,
RotateCcw,
AlertTriangle,
ArrowRight,
CalendarClock, CalendarClock,
User, User,
AlertCircle AlertCircle,
Bell,
Check,
Clock,
ChevronDown,
ChevronRight,
MessageSquare,
UserPlus,
RefreshCw,
Flag,
ListTodo,
AlertTriangle,
CheckCircle2,
Timer
} from 'lucide-react'; } from 'lucide-react';
import { get } from '@/services/api'; import { get } from '@/services/api';
import { settingsApi } from '@/services/settings.api';
import { Card, CardHeader, CardTitle, CardContent, Badge, LoadingOverlay } from '@/components/ui'; import { Card, CardHeader, CardTitle, CardContent, Badge, LoadingOverlay } from '@/components/ui';
import { TaskDetail } from '@/pages/tasks/TaskDetail'; import { TaskDetail } from '@/pages/tasks/TaskDetail';
import { formatDate } from '@/lib/utils'; import { formatDate, formatRelativeTime, cn } from '@/lib/utils';
import type { Task, Project } from '@/types'; import { useNotificationStore } from '@/store/notificationStore';
import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions';
interface DashboardStats { import type { Task } from '@/types';
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 };
}
interface DashboardToday { interface DashboardToday {
myTasks: Task[]; myTasks: Task[];
myProjects: Project[]; }
// Ikona podľa typu notifikácie
function getNotificationIcon(type: string) {
switch (type) {
case 'TASK_ASSIGNED':
return <UserPlus className="h-4 w-4 text-blue-500" />;
case 'TASK_UPDATED':
return <RefreshCw className="h-4 w-4 text-amber-500" />;
case 'TASK_COMMENT':
return <MessageSquare className="h-4 w-4 text-green-500" />;
case 'TASK_STATUS_CHANGED':
return <Flag className="h-4 w-4 text-purple-500" />;
case 'TASK_DEADLINE':
return <AlertTriangle className="h-4 w-4 text-red-500" />;
default:
return <Bell className="h-4 w-4 text-muted-foreground" />;
}
}
// 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() { export function Dashboard() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [detailTaskId, setDetailTaskId] = useState<string | null>(null); const [taskDetail, setTaskDetail] = useState<{ taskId: string; notificationId?: string } | null>(null);
const [collapsedStatuses, setCollapsedStatuses] = useState<Set<string>>(new Set());
const [snoozeOpenFor, setSnoozeOpenFor] = useState<string | null>(null);
const { data: statsData, isLoading: statsLoading } = useQuery({ // Notifikácie
queryKey: ['dashboard'], const {
queryFn: () => get<DashboardStats>('/dashboard'), 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({ const { data: todayData, isLoading: todayLoading } = useQuery({
queryKey: ['dashboard-today'], queryKey: ['dashboard-today'],
queryFn: () => get<DashboardToday>('/dashboard/today'), queryFn: () => get<DashboardToday>('/dashboard/today'),
}); });
if (statsLoading || todayLoading) { if (todayLoading) {
return <LoadingOverlay />; return <LoadingOverlay />;
} }
const stats = statsData?.data;
const today = todayData?.data; const today = todayData?.data;
const statuses = statusesData?.data || [];
const priorities = prioritiesData?.data || [];
const cards = [ // Zoskupiť úlohy podľa statusu
{ const tasksByStatus = statuses.reduce((acc, status) => {
title: 'Projekty', acc[status.id] = today?.myTasks?.filter(t => t.statusId === status.id) || [];
icon: FolderKanban, return acc;
value: stats?.projects.total ?? 0, }, {} as Record<string, Task[]>);
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',
},
];
// 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 => { const urgentTasks = today?.myTasks?.filter(t => {
if (!t.deadline) return false; if (!t.deadline) return false;
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); 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 => { // Úlohy podľa priority (len vysoká priorita)
if (!t.deadline) return true; const highPriorityTasks = today?.myTasks?.filter(t => {
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); const priority = priorities.find(p => p.id === t.priorityId);
return daysUntil > 2; return priority && priority.order <= 1; // Predpokladáme že nižšie číslo = vyššia priorita
}) || []; }) || [];
const isOverdue = (deadline: string) => { const isOverdue = (deadline: string) => {
@@ -120,84 +160,311 @@ export function Dashboard() {
const getDaysUntilDeadline = (deadline: string) => { const getDaysUntilDeadline = (deadline: string) => {
const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); 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 === 0) return 'Dnes';
if (days === 1) return 'Zajtra'; 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 ( return (
<div className="space-y-6"> <div className="space-y-4 md:space-y-6">
<div className="flex items-center justify-between"> {/* Header */}
<h1 className="text-2xl font-bold">Dashboard</h1> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<p className="text-sm text-muted-foreground"> <div>
{new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} <h1 className="text-xl md:text-2xl font-bold">Dashboard</h1>
</p> <p className="text-sm text-muted-foreground">
{new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
</div>
</div> </div>
{/* Štatistické karty */} {/* Quick Stats - responzívny grid */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
{cards.map((card) => ( <Card className="p-3 md:p-4">
<Link key={card.title} to={card.href}> <div className="flex items-center gap-3">
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${card.bgColor}`}> <div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <ListTodo className="h-5 w-5 text-blue-600 dark:text-blue-400" />
<CardTitle className="text-sm font-medium">{card.title}</CardTitle> </div>
<card.icon className={`h-5 w-5 ${card.color}`} /> <div>
</CardHeader> <p className="text-2xl font-bold">{totalTasks}</p>
<CardContent> <p className="text-xs text-muted-foreground">Celkom úloh</p>
<div className="text-3xl font-bold">{card.value}</div> </div>
<p className="text-xs text-muted-foreground mt-1">{card.subtitle}</p> </div>
</CardContent> </Card>
</Card>
</Link> <Card className={cn("p-3 md:p-4", overdueTasks.length > 0 && "border-red-200 dark:border-red-800")}>
))} <div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg", overdueTasks.length > 0 ? "bg-red-100 dark:bg-red-900/30" : "bg-muted")}>
<AlertCircle className={cn("h-5 w-5", overdueTasks.length > 0 ? "text-red-600 dark:text-red-400" : "text-muted-foreground")} />
</div>
<div>
<p className="text-2xl font-bold">{overdueTasks.length}</p>
<p className="text-xs text-muted-foreground">Po termíne</p>
</div>
</div>
</Card>
<Card className={cn("p-3 md:p-4", todayTasks.length > 0 && "border-amber-200 dark:border-amber-800")}>
<div className="flex items-center gap-3">
<div className={cn("p-2 rounded-lg", todayTasks.length > 0 ? "bg-amber-100 dark:bg-amber-900/30" : "bg-muted")}>
<Timer className={cn("h-5 w-5", todayTasks.length > 0 ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground")} />
</div>
<div>
<p className="text-2xl font-bold">{todayTasks.length}</p>
<p className="text-xs text-muted-foreground">Termín dnes</p>
</div>
</div>
</Card>
<Card className="p-3 md:p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-2xl font-bold">
{statuses.filter(s => s.isFinal).reduce((sum, s) => sum + (tasksByStatus[s.id]?.length || 0), 0)}
</p>
<p className="text-xs text-muted-foreground">Dokončených</p>
</div>
</div>
</Card>
</div> </div>
{/* Urgentné úlohy - zobrazí sa len ak existujú */} {/* Notifikácie - prepracované */}
{urgentTasks.length > 0 && ( {unreadNotifications.length > 0 && (
<Card className="border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-800"> <Card>
<CardHeader> <CardHeader className="pb-2 px-3 md:px-6">
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-400"> <div className="flex items-center justify-between">
<AlertCircle className="h-5 w-5" /> <CardTitle className="flex items-center gap-2 text-base md:text-lg">
Urgentné úlohy ({urgentTasks.length}) <Bell className="h-5 w-5 text-primary" />
</CardTitle> Nové upozornenia
<Badge variant="secondary" className="ml-1">{unreadCount}</Badge>
</CardTitle>
{unreadCount > 5 && (
<Link to="/notifications" className="text-xs text-primary hover:underline">
Zobraziť všetky
</Link>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="px-3 md:px-6 pt-0">
<div className="space-y-2"> <div className="divide-y">
{urgentTasks.map((task) => ( {unreadNotifications.map((notification) => {
<div const actorName = notification.data?.actorName as string | undefined;
key={task.id}
onClick={() => setDetailTaskId(task.id)} // Získať zmysluplný obsah správy
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" const getMessageContent = () => {
> const msg = notification.message;
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{task.title}</p> // Pre staré formáty zmeny stavu - extrahuj stavy
{task.description && ( if (notification.type === 'TASK_STATUS_CHANGED' && msg.includes('zmenila stav')) {
<p className="text-sm text-muted-foreground truncate mt-0.5">{task.description}</p> const match = msg.match(/z "(.+?)" na "(.+?)"/);
)} if (match) {
<div className="flex items-center gap-3 mt-2 text-xs"> return { message: `${match[1]}${match[2]}`, actor: actorName };
{task.project && ( }
<span className="flex items-center gap-1 text-muted-foreground"> }
<FolderKanban className="h-3 w-3" />
{task.project.name} return { message: msg, actor: actorName };
</span> };
)}
{task.createdBy && ( const { message: displayMessage, actor: displayActor } = getMessageContent();
<span className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" /> return (
{task.createdBy.name} <div
</span> key={notification.id}
)} className="py-3 first:pt-0 last:pb-0 hover:bg-muted/30 -mx-3 px-3 md:-mx-6 md:px-6 transition-colors group cursor-pointer"
onClick={() => {
if (notification.task) {
setTaskDetail({ taskId: notification.task.id, notificationId: notification.id });
}
}}
>
<div className="flex gap-3">
{/* Ikona */}
<div className="flex-shrink-0 mt-0.5">
<div className="p-1.5 rounded-full bg-muted">
{getNotificationIcon(notification.type)}
</div>
</div>
{/* Obsah */}
<div className="flex-1 min-w-0">
{/* Hlavička - typ + čas */}
<div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
{getNotificationTypeLabel(notification.type)}
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
</div>
{/* Názov úlohy + projekt */}
{notification.task && (
<div className="flex items-baseline gap-2 min-w-0">
<p className="font-semibold text-foreground truncate">
{notification.task.title}
</p>
{notification.task.project && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{notification.task.project.name}
</span>
)}
</div>
)}
{/* Detail zmeny + autor */}
{(displayMessage || displayActor) && (
<div className="flex items-baseline justify-between gap-2 mt-0.5">
{displayMessage && (
<p className="text-sm text-muted-foreground line-clamp-1 flex-1">
{displayMessage}
</p>
)}
{displayActor && (
<span className={`text-xs text-muted-foreground flex-shrink-0 ${displayMessage ? 'hidden sm:block' : ''}`}>
{displayActor}
</span>
)}
</div>
)}
</div>
{/* Akcie */}
<div
className="flex items-start gap-0.5 flex-shrink-0 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<button
onClick={() => markAsRead(notification.id)}
className="p-1.5 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
title="Označiť ako prečítané"
>
<Check className="h-4 w-4" />
</button>
<div className="relative">
<button
onClick={() => setSnoozeOpenFor(snoozeOpenFor === notification.id ? null : notification.id)}
className="p-1.5 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
title="Odložiť"
>
<Clock className="h-4 w-4" />
</button>
{snoozeOpenFor === notification.id && (
<div className="absolute right-0 top-full mt-1 bg-popover border rounded-md shadow-lg z-20 min-w-[140px]">
{snoozeOptions.map((option) => (
<button
key={option.label}
onClick={() => {
snooze(notification.id, calculateSnoozeMinutes(option));
setSnoozeOpenFor(null);
}}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent first:rounded-t-md last:rounded-b-md"
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 ml-4"> );
{task.deadline && ( })}
<span className={`text-xs font-medium px-2 py-1 rounded ${isOverdue(task.deadline) ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'}`}> </div>
{getDaysUntilDeadline(task.deadline)} </CardContent>
</Card>
)}
{/* Urgentné úlohy - po termíne + blížiaci sa termín */}
{(overdueTasks.length > 0 || urgentTasks.length > 0) && (
<Card className="border-red-200 dark:border-red-800">
<CardHeader className="pb-2 px-3 md:px-6">
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-400 text-base md:text-lg">
<AlertCircle className="h-5 w-5" />
Vyžaduje pozornosť
<Badge variant="destructive" className="ml-1">{overdueTasks.length + urgentTasks.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="px-3 md:px-6 pt-0">
<div className="space-y-2">
{/* Po termíne */}
{overdueTasks.map((task) => (
<div
key={task.id}
onClick={() => 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"
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<AlertCircle className="h-4 w-4 text-red-500 flex-shrink-0" />
<p className="font-medium truncate">{task.title}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs font-medium px-2 py-1 rounded bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">
{getDaysUntilDeadline(task.deadline!)}
</span> </span>
)} <Badge color={task.priority?.color} className="text-xs">{task.priority?.name}</Badge>
<Badge color={task.priority?.color}>{task.priority?.name}</Badge> </div>
</div> </div>
{task.description && (
<p className="text-sm text-muted-foreground line-clamp-2 ml-6 mb-1">
{task.description}
</p>
)}
{task.createdBy && (
<span className="text-xs text-muted-foreground ml-6 flex items-center gap-1">
<User className="h-3 w-3" />
Zadal: {task.createdBy.name}
</span>
)}
</div>
))}
{/* Blížiaci sa termín */}
{urgentTasks.filter(t => !overdueTasks.includes(t)).map((task) => (
<div
key={task.id}
onClick={() => 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"
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<Timer className="h-4 w-4 text-amber-500 flex-shrink-0" />
<p className="font-medium truncate">{task.title}</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className="text-xs font-medium px-2 py-1 rounded bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300">
{getDaysUntilDeadline(task.deadline!)}
</span>
<Badge color={task.priority?.color} className="text-xs">{task.priority?.name}</Badge>
</div>
</div>
{task.description && (
<p className="text-sm text-muted-foreground line-clamp-2 ml-6 mb-1">
{task.description}
</p>
)}
{task.createdBy && (
<span className="text-xs text-muted-foreground ml-6 flex items-center gap-1">
<User className="h-3 w-3" />
Zadal: {task.createdBy.name}
</span>
)}
</div> </div>
))} ))}
</div> </div>
@@ -205,176 +472,181 @@ export function Dashboard() {
</Card> </Card>
)} )}
<div className="grid gap-6 lg:grid-cols-2"> {/* Úlohy podľa stavov */}
{/* Moje úlohy */} {statuses.filter(s => !s.isFinal).map((status) => {
<Card> const tasks = tasksByStatus[status.id] || [];
<CardHeader className="flex flex-row items-center justify-between"> const isCollapsed = collapsedStatuses.has(status.id);
<CardTitle className="flex items-center gap-2">
<CheckSquare className="h-5 w-5 text-green-500" /> if (tasks.length === 0) return null;
Moje úlohy
{today?.myTasks && today.myTasks.length > 0 && ( return (
<span className="text-sm font-normal text-muted-foreground"> <Card key={status.id}>
({today.myTasks.length}) <CardHeader
</span> className="cursor-pointer hover:bg-muted/50 transition-colors py-3 px-3 md:px-6"
)} onClick={() => toggleStatusCollapse(status.id)}
</CardTitle> >
<Link to="/tasks" className="text-sm text-primary hover:underline flex items-center gap-1"> <CardTitle className="flex items-center justify-between text-base md:text-lg">
Všetky <ArrowRight className="h-4 w-4" /> <div className="flex items-center gap-2">
</Link> {isCollapsed ? (
</CardHeader> <ChevronRight className="h-5 w-5 text-muted-foreground" />
<CardContent> ) : (
{normalTasks.length > 0 ? ( <ChevronDown className="h-5 w-5 text-muted-foreground" />
<div className="space-y-3"> )}
{normalTasks.slice(0, 5).map((task) => ( <span
<div className="w-3 h-3 rounded-full flex-shrink-0"
key={task.id} style={{ backgroundColor: status.color || '#888' }}
onClick={() => setDetailTaskId(task.id)} />
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer" <span className="truncate">{status.name}</span>
> <Badge variant="secondary" className="ml-1">{tasks.length}</Badge>
<div className="flex items-start justify-between gap-2"> </div>
<div className="flex-1 min-w-0"> <Link
<p className="font-medium">{task.title}</p> to={`/tasks?statusId=${status.id}`}
{task.description && ( className="text-xs text-primary hover:underline font-normal hidden sm:block"
<p className="text-sm text-muted-foreground line-clamp-2 mt-1"> onClick={(e) => e.stopPropagation()}
{task.description} >
</p> Zobraziť všetky
</Link>
</CardTitle>
</CardHeader>
{!isCollapsed && (
<CardContent className="px-3 md:px-6 pt-0">
<div className="space-y-2">
{tasks.map((task) => (
<div
key={task.id}
onClick={() => setTaskDetail({ taskId: task.id })}
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer"
>
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex items-center gap-2 min-w-0">
<Badge color={task.priority?.color} className="text-xs flex-shrink-0">
{task.priority?.name}
</Badge>
<p className="font-medium truncate">{task.title}</p>
</div>
{task.deadline && (
<span className={cn(
"text-xs flex items-center gap-1 flex-shrink-0",
isOverdue(task.deadline) ? "text-red-500" : "text-muted-foreground"
)}>
<CalendarClock className="h-3 w-3" />
{formatDate(task.deadline)}
</span>
)} )}
</div> </div>
<div className="flex flex-col items-end gap-1"> {task.description && (
<Badge color={task.status?.color} className="text-xs">{task.status?.name}</Badge> <p className="text-sm text-muted-foreground line-clamp-2 ml-0 sm:ml-16 mb-1">
<Badge color={task.priority?.color} className="text-xs">{task.priority?.name}</Badge> {task.description}
</div> </p>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
{task.project && (
<span className="flex items-center gap-1">
<FolderKanban className="h-3 w-3" />
{task.project.name}
</span>
)} )}
{task.createdBy && ( {task.createdBy && (
<span className="flex items-center gap-1"> <span className="text-xs text-muted-foreground flex items-center gap-1 ml-0 sm:ml-16">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
Zadal: {task.createdBy.name} Zadal: {task.createdBy.name}
</span> </span>
)} )}
{task.deadline && (
<span className="flex items-center gap-1">
<CalendarClock className="h-3 w-3" />
{formatDate(task.deadline)}
</span>
)}
</div> </div>
</div> ))}
))} </div>
{normalTasks.length > 5 && ( </CardContent>
<p className="text-sm text-muted-foreground text-center">
+{normalTasks.length - 5} ďalších úloh
</p>
)}
</div>
) : today?.myTasks?.length === 0 ? (
<div className="text-center py-8">
<CheckSquare className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-muted-foreground">Nemáte žiadne priradené úlohy</p>
<Link to="/tasks" className="text-sm text-primary hover:underline mt-2 inline-block">
Zobraziť všetky úlohy
</Link>
</div>
) : null}
</CardContent>
</Card>
{/* Moje projekty */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<FolderKanban className="h-5 w-5 text-blue-500" />
Moje projekty
{today?.myProjects && today.myProjects.length > 0 && (
<span className="text-sm font-normal text-muted-foreground">
({today.myProjects.length})
</span>
)}
</CardTitle>
<Link to="/projects" className="text-sm text-primary hover:underline flex items-center gap-1">
Všetky <ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{today?.myProjects && today.myProjects.length > 0 ? (
<div className="space-y-3">
{today.myProjects.map((project) => (
<div
key={project.id}
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<p className="font-medium">{project.name}</p>
{project.description && (
<p className="text-sm text-muted-foreground line-clamp-1 mt-1">
{project.description}
</p>
)}
</div>
<Badge color={project.status?.color}>{project.status?.name}</Badge>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CheckSquare className="h-3 w-3" />
{project._count?.tasks ?? 0} úloh
</span>
{project.hardDeadline && (
<span className="flex items-center gap-1">
<CalendarClock className="h-3 w-3" />
Termín: {formatDate(project.hardDeadline)}
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<FolderKanban className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-muted-foreground">Nemáte žiadne aktívne projekty</p>
<Link to="/projects" className="text-sm text-primary hover:underline mt-2 inline-block">
Zobraziť všetky projekty
</Link>
</div>
)} )}
</CardContent> </Card>
</Card> );
</div> })}
{/* Upozornenie na revízie */} {/* Dokončené úlohy - defaultne zbalené */}
{(stats?.equipment.upcomingRevisions ?? 0) > 0 && ( {statuses.filter(s => s.isFinal).map((status) => {
<Card className="border-orange-200 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-800"> const tasks = tasksByStatus[status.id] || [];
<CardHeader>
<div className="flex items-center gap-2"> if (tasks.length === 0) return null;
<AlertTriangle className="h-5 w-5 text-orange-500" />
<CardTitle className="text-orange-700 dark:text-orange-400">Blížiace sa revízie</CardTitle> const isCollapsed = !collapsedStatuses.has(`done-${status.id}`);
return (
<Card key={status.id} className="opacity-70">
<CardHeader
className="cursor-pointer hover:bg-muted/50 transition-colors py-3 px-3 md:px-6"
onClick={() => {
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;
});
}}
>
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
{isCollapsed ? (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="truncate">{status.name}</span>
<Badge variant="secondary" className="ml-1">{tasks.length}</Badge>
</CardTitle>
</CardHeader>
{!isCollapsed && (
<CardContent className="px-3 md:px-6 pt-0">
<div className="space-y-2">
{tasks.slice(0, 5).map((task) => (
<div
key={task.id}
onClick={() => setTaskDetail({ taskId: task.id })}
className="flex items-center gap-4 p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate text-muted-foreground">{task.title}</p>
</div>
{task.completedAt && (
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatDate(task.completedAt)}
</span>
)}
</div>
))}
{tasks.length > 5 && (
<Link
to={`/tasks?statusId=${status.id}`}
className="block text-sm text-primary hover:underline text-center py-2"
>
Zobraziť všetkých {tasks.length} úloh
</Link>
)}
</div>
</CardContent>
)}
</Card>
);
})}
{/* Žiadne úlohy */}
{totalTasks === 0 && unreadNotifications.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="text-center">
<CheckSquare className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
<p className="text-lg font-medium">Všetko vybavené!</p>
<p className="text-muted-foreground mt-1">Nemáte žiadne priradené úlohy</p>
<Link to="/tasks" className="text-sm text-primary hover:underline mt-4 inline-block">
Zobraziť všetky úlohy
</Link>
</div> </div>
</CardHeader>
<CardContent>
<p className="text-orange-600 dark:text-orange-300">
Máte {stats?.equipment.upcomingRevisions} zariadení s blížiacou sa revíziou v nasledujúcich 30 dňoch.
</p>
<Link to="/equipment" className="text-sm text-orange-700 dark:text-orange-400 hover:underline mt-2 inline-block font-medium">
Skontrolovať zariadenia
</Link>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Detail úlohy */} {/* Detail úlohy */}
{detailTaskId && ( {taskDetail && (
<TaskDetail <TaskDetail
taskId={detailTaskId} taskId={taskDetail.taskId}
notificationId={taskDetail.notificationId}
onClose={() => { onClose={() => {
setDetailTaskId(null); setTaskDetail(null);
// Refresh dashboard data po zatvorení
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] }); queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
}} }}
/> />

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react'; import { ExternalLink, AlertTriangle } from 'lucide-react';
import { zakazkyApi } from '@/services/zakazky.api'; import { zakazkyApi } from '@/services/zakazky.api';
import { useAuthStore } from '@/store/authStore';
import { import {
Input, Input,
Card, Card,
@@ -23,9 +24,11 @@ import { formatDate } from '@/lib/utils';
export function ProjectsList() { export function ProjectsList() {
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear()); const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const { user } = useAuthStore();
const isAdmin = user?.role === 'ADMIN';
// Check if external DB is configured // Check if external DB is configured
const { data: zakazkyStatus, isLoading: statusLoading } = useQuery({ const { data: zakazkyStatus, isLoading: statusLoading, error: statusError } = useQuery({
queryKey: ['zakazky-status'], queryKey: ['zakazky-status'],
queryFn: () => zakazkyApi.checkStatus(), queryFn: () => zakazkyApi.checkStatus(),
}); });
@@ -38,12 +41,20 @@ export function ProjectsList() {
}); });
// Get zakazky for selected year // Get zakazky for selected year
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({ const { data: zakazkyData, isLoading: zakazkyLoading, error: zakazkyError } = useQuery({
queryKey: ['zakazky', selectedYear, search], queryKey: ['zakazky', selectedYear, search],
queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined), queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined),
enabled: !!zakazkyStatus?.data?.configured, 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 isExternalDbConfigured = zakazkyStatus?.data?.configured;
const yearOptions = (yearsData?.data || []).map((year) => ({ const yearOptions = (yearsData?.data || []).map((year) => ({
value: String(year), value: String(year),
@@ -107,8 +118,25 @@ export function ProjectsList() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Error display for admins */}
{isAdmin && (statusError || zakazkyError) && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 text-red-800 font-medium mb-2">
<AlertTriangle className="h-5 w-5" />
Chyba pripojenia k externej databáze
</div>
<pre className="text-sm text-red-700 whitespace-pre-wrap font-mono bg-red-100 p-2 rounded">
{getErrorMessage(statusError || zakazkyError)}
</pre>
</div>
)}
{zakazkyLoading ? ( {zakazkyLoading ? (
<LoadingOverlay /> <LoadingOverlay />
) : zakazkyError ? (
<div className="text-center py-8 text-muted-foreground">
Nepodarilo sa načítať zákazky. Skúste obnoviť stránku.
</div>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>

View File

@@ -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<typeof passwordResetSchema>;
interface PasswordResetModalProps {
userId: string;
userName: string;
onClose: () => void;
}
export function PasswordResetModal({ userId, userName, onClose }: PasswordResetModalProps) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PasswordResetFormData>({
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 (
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
<p className="text-sm text-muted-foreground">
Nastavenie nového hesla pre používateľa <strong>{userName}</strong>.
</p>
<Input
id="password"
type="password"
label="Nové heslo *"
error={errors.password?.message}
{...register('password')}
/>
<Input
id="confirmPassword"
type="password"
label="Potvrdenie hesla *"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<p className="text-xs text-muted-foreground">
Min. 8 znakov, jedno veľké písmeno, jedno číslo.
</p>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={mutation.isPending}>
Zmeniť heslo
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -21,8 +21,9 @@ import {
ModalFooter, ModalFooter,
} from '@/components/ui'; } from '@/components/ui';
import toast from 'react-hot-toast'; 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 // Spoločný interface pre konfiguračné entity
interface ConfigItem { interface ConfigItem {
@@ -33,7 +34,18 @@ interface ConfigItem {
order?: number; 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 }[] = [ const tabs: { key: ConfigTab; label: string }[] = [
{ key: 'users', label: 'Používatelia' },
{ key: 'taskStatuses', label: 'Stavy úloh' }, { key: 'taskStatuses', label: 'Stavy úloh' },
{ key: 'priorities', label: 'Priority' }, { key: 'priorities', label: 'Priority' },
{ key: 'equipmentTypes', label: 'Typy zariadení' }, { key: 'equipmentTypes', label: 'Typy zariadení' },
@@ -41,11 +53,12 @@ const tabs: { key: ConfigTab; label: string }[] = [
{ key: 'rmaStatuses', label: 'RMA stavy' }, { key: 'rmaStatuses', label: 'RMA stavy' },
{ key: 'rmaSolutions', label: 'RMA riešenia' }, { key: 'rmaSolutions', label: 'RMA riešenia' },
{ key: 'userRoles', label: 'Užívateľské role' }, { key: 'userRoles', label: 'Užívateľské role' },
{ key: 'systemSettings', label: 'Systémové nastavenia' },
]; ];
export function SettingsDashboard() { export function SettingsDashboard() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<ConfigTab>('taskStatuses'); const [activeTab, setActiveTab] = useState<ConfigTab>('users');
const [editItem, setEditItem] = useState<ConfigItem | null>(null); const [editItem, setEditItem] = useState<ConfigItem | null>(null);
const [deleteItem, setDeleteItem] = useState<{ id: string; name: string } | null>(null); const [deleteItem, setDeleteItem] = useState<{ id: string; name: string } | null>(null);
@@ -91,6 +104,12 @@ export function SettingsDashboard() {
enabled: activeTab === 'userRoles', enabled: activeTab === 'userRoles',
}); });
const { data: systemSettings, isLoading: loadingSystemSettings } = useQuery({
queryKey: ['system-settings'],
queryFn: () => settingsApi.getSystemSettings(),
enabled: activeTab === 'systemSettings',
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: async ({ tab, id }: { tab: ConfigTab; id: string }) => { mutationFn: async ({ tab, id }: { tab: ConfigTab; id: string }) => {
switch (tab) { switch (tab) {
@@ -114,7 +133,7 @@ export function SettingsDashboard() {
}); });
const isLoading = loadingStatuses || loadingPriorities || loadingEquipmentTypes || const isLoading = loadingStatuses || loadingPriorities || loadingEquipmentTypes ||
loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles; loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles || loadingSystemSettings;
const getCurrentData = (): ConfigItem[] => { const getCurrentData = (): ConfigItem[] => {
switch (activeTab) { switch (activeTab) {
@@ -125,10 +144,12 @@ export function SettingsDashboard() {
case 'rmaStatuses': return (rmaStatuses?.data || []) as ConfigItem[]; case 'rmaStatuses': return (rmaStatuses?.data || []) as ConfigItem[];
case 'rmaSolutions': return (rmaSolutions?.data || []) as ConfigItem[]; case 'rmaSolutions': return (rmaSolutions?.data || []) as ConfigItem[];
case 'userRoles': return (userRoles?.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 data: ConfigItem[] = getCurrentData();
const settings: SystemSetting[] = (systemSettings?.data || []) as SystemSetting[];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -147,65 +168,71 @@ export function SettingsDashboard() {
))} ))}
</div> </div>
<Card> {activeTab === 'users' ? (
<CardHeader className="flex flex-row items-center justify-between"> <UserManagement />
<CardTitle>{tabs.find(t => t.key === activeTab)?.label}</CardTitle> ) : activeTab === 'systemSettings' ? (
<Button size="sm" onClick={() => setEditItem({ id: '', code: '', name: '' })}> <SystemSettingsPanel settings={settings} isLoading={isLoading} />
<Plus className="mr-2 h-4 w-4" /> ) : (
Pridať <Card>
</Button> <CardHeader className="flex flex-row items-center justify-between">
</CardHeader> <CardTitle>{tabs.find(t => t.key === activeTab)?.label}</CardTitle>
<CardContent> <Button size="sm" onClick={() => setEditItem({ id: '', code: '', name: '' })}>
{isLoading ? ( <Plus className="mr-2 h-4 w-4" />
<LoadingOverlay /> Pridať
) : ( </Button>
<Table> </CardHeader>
<TableHeader> <CardContent>
<TableRow> {isLoading ? (
<TableHead>Kód</TableHead> <LoadingOverlay />
<TableHead>Názov</TableHead> ) : (
<TableHead>Farba</TableHead> <Table>
<TableHead>Poradie</TableHead> <TableHeader>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.id}>
<TableCell className="font-mono">{item.code}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.color && (
<Badge color={item.color}>{item.color}</Badge>
)}
</TableCell>
<TableCell>{item.order ?? 0}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => setEditItem(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteItem({ id: item.id, name: item.name })}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground"> <TableHead>Kód</TableHead>
Žiadne položky <TableHead>Názov</TableHead>
</TableCell> <TableHead>Farba</TableHead>
<TableHead>Poradie</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow> </TableRow>
)} </TableHeader>
</TableBody> <TableBody>
</Table> {data.map((item) => (
)} <TableRow key={item.id}>
</CardContent> <TableCell className="font-mono">{item.code}</TableCell>
</Card> <TableCell>{item.name}</TableCell>
<TableCell>
{item.color && (
<Badge color={item.color}>{item.color}</Badge>
)}
</TableCell>
<TableCell>{item.order ?? 0}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => setEditItem(item)}>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteItem({ id: item.id, name: item.name })}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Žiadne položky
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
<Modal <Modal
isOpen={!!editItem} isOpen={!!editItem}
@@ -330,3 +357,213 @@ function ConfigItemForm({ item, tab, onClose }: ConfigItemFormProps) {
</form> </form>
); );
} }
// Komponent pre systémové nastavenia
interface SystemSettingsPanelProps {
settings: SystemSetting[];
isLoading: boolean;
}
function SystemSettingsPanel({ settings, isLoading }: SystemSettingsPanelProps) {
const queryClient = useQueryClient();
const [editingSetting, setEditingSetting] = useState<SystemSetting | null>(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<string, SystemSetting[]>);
const categoryLabels: Record<string, string> = {
NOTIFICATIONS: 'Notifikácie',
GENERAL: 'Všeobecné',
EMAIL: 'Email',
OTHER: 'Ostatné',
};
if (isLoading) {
return <LoadingOverlay />;
}
return (
<div className="space-y-6">
{Object.entries(settingsByCategory).map(([category, categorySettings]) => (
<Card key={category}>
<CardHeader>
<CardTitle>{categoryLabels[category] || category}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{categorySettings.map((setting) => (
<div key={setting.id} className="p-4 border rounded-lg">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="font-medium">{setting.label}</div>
{setting.description && (
<p className="text-sm text-muted-foreground mt-1">{setting.description}</p>
)}
<div className="text-xs text-muted-foreground mt-2 font-mono">
Kľúč: {setting.key}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setEditingSetting(setting)}
>
<Pencil className="h-4 w-4 mr-1" />
Upraviť
</Button>
</div>
<div className="mt-3 p-3 bg-muted/50 rounded text-sm font-mono overflow-x-auto">
{setting.dataType === 'json' ? (
<pre>{JSON.stringify(setting.value, null, 2)}</pre>
) : (
<span>{String(setting.value)}</span>
)}
</div>
</div>
))}
</CardContent>
</Card>
))}
{settings.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
Žiadne systémové nastavenia
</CardContent>
</Card>
)}
{/* Modal pre editáciu */}
<Modal
isOpen={!!editingSetting}
onClose={() => setEditingSetting(null)}
title={`Upraviť: ${editingSetting?.label}`}
>
{editingSetting && (
<SystemSettingForm
setting={editingSetting}
onSave={(value) => updateMutation.mutate({ key: editingSetting.key, value })}
onClose={() => setEditingSetting(null)}
isLoading={updateMutation.isPending}
/>
)}
</Modal>
</div>
);
}
// 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<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-4">
{setting.description && (
<p className="text-sm text-muted-foreground">{setting.description}</p>
)}
{setting.dataType === 'json' ? (
<div>
<label className="text-sm font-medium">Hodnota (JSON)</label>
<textarea
className="w-full mt-1 p-3 border rounded-md font-mono text-sm min-h-[200px] bg-background"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
{error && <p className="text-sm text-destructive mt-1">{error}</p>}
{setting.key === 'NOTIFICATION_SNOOZE_OPTIONS' && (
<div className="text-xs text-muted-foreground mt-2 space-y-1">
<p>Formát: pole objektov. Každý objekt "label" a buď "minutes" alebo "type" + "hour".</p>
<ul className="list-disc list-inside pl-2">
<li><code>"minutes": 30</code> = relatívny čas (o 30 minút)</li>
<li><code>"type": "tomorrow", "hour": 9</code> = zajtra o 9:00</li>
<li><code>"type": "today", "hour": 14</code> = dnes o 14:00 (ak čas prešiel, možnosť sa skryje)</li>
</ul>
<p className="mt-1 text-amber-600 dark:text-amber-400"> Pre type "tomorrow" a "today" je "hour" povinné.</p>
</div>
)}
</div>
) : setting.dataType === 'boolean' ? (
<div>
<label className="text-sm font-medium">Hodnota</label>
<select
className="w-full mt-1 p-2 border rounded-md bg-background"
value={value}
onChange={(e) => setValue(e.target.value)}
>
<option value="true">Áno</option>
<option value="false">Nie</option>
</select>
</div>
) : (
<Input
label="Hodnota"
type={setting.dataType === 'number' ? 'number' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
/>
)}
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isLoading}>
Uložiť
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,176 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { usersApi, type CreateUserData, type UpdateUserData } from '@/services/users.api';
import { settingsApi } from '@/services/settings.api';
import type { User } from '@/types';
import { Button, Input, Select, ModalFooter, LoadingOverlay } from '@/components/ui';
import toast from 'react-hot-toast';
const userFormSchema = z.object({
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky'),
email: z.string().email('Neplatný email'),
password: z.string().optional(),
roleId: z.string().min(1, 'Rola je povinná'),
active: z.boolean(),
});
type UserFormData = z.infer<typeof userFormSchema>;
interface UserFormProps {
user: User | null;
onClose: () => void;
}
export function UserForm({ user, onClose }: UserFormProps) {
const queryClient = useQueryClient();
const isEditing = !!user?.id;
const { data: rolesData, isLoading: rolesLoading } = useQuery({
queryKey: ['user-roles'],
queryFn: () => settingsApi.getUserRoles(),
});
const roles = rolesData?.data || [];
const {
register,
handleSubmit,
formState: { errors },
} = useForm<UserFormData>({
resolver: zodResolver(
isEditing
? userFormSchema
: userFormSchema.extend({
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'),
})
),
defaultValues: {
name: user?.name || '',
email: user?.email || '',
password: '',
roleId: user?.role?.id || '',
active: user?.active ?? true,
},
});
const createMutation = useMutation({
mutationFn: (data: CreateUserData) => usersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('Používateľ bol vytvorený');
onClose();
},
onError: (error: unknown) => {
const msg = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Chyba pri vytváraní používateľa');
},
});
const updateMutation = useMutation({
mutationFn: async (data: UserFormData) => {
const updateData: UpdateUserData = {
name: data.name,
email: data.email,
active: data.active,
};
await usersApi.update(user!.id, updateData);
if (data.roleId !== user!.role.id) {
await usersApi.updateRole(user!.id, data.roleId);
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('Používateľ bol aktualizovaný');
onClose();
},
onError: (error: unknown) => {
const msg = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Chyba pri aktualizácii používateľa');
},
});
const onSubmit = handleSubmit((data) => {
if (isEditing) {
updateMutation.mutate(data);
} else {
createMutation.mutate({
name: data.name,
email: data.email,
password: data.password!,
roleId: data.roleId,
});
}
});
const isPending = createMutation.isPending || updateMutation.isPending;
const roleOptions = roles.map((role) => ({
value: role.id,
label: role.name,
}));
if (rolesLoading) {
return <LoadingOverlay />;
}
return (
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Input
id="name"
label="Meno *"
error={errors.name?.message}
{...register('name')}
/>
<Input
id="email"
type="email"
label="Email *"
error={errors.email?.message}
{...register('email')}
/>
</div>
{!isEditing && (
<Input
id="password"
type="password"
label="Heslo *"
error={errors.password?.message}
{...register('password')}
/>
)}
<Select
id="roleId"
label="Rola *"
error={errors.roleId?.message}
options={[{ value: '', label: '-- Vyberte rolu --' }, ...roleOptions]}
{...register('roleId')}
/>
{isEditing && (
<label className="flex items-center gap-2">
<input type="checkbox" {...register('active')} className="rounded" />
<span className="text-sm">Aktívny</span>
</label>
)}
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,249 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search, KeyRound } from 'lucide-react';
import { usersApi } from '@/services/users.api';
import { useConfigStore } from '@/store/configStore';
import { useAuthStore } from '@/store/authStore';
import type { User } from '@/types';
import {
Button,
Input,
Select,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import { UserForm } from './UserForm';
import { PasswordResetModal } from './PasswordResetModal';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export function UserManagement() {
const queryClient = useQueryClient();
const { userRoles } = useConfigStore();
const currentUser = useAuthStore((s) => s.user);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [activeFilter, setActiveFilter] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [passwordResetUser, setPasswordResetUser] = useState<{ id: string; name: string } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<User | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['users', search, roleFilter, activeFilter],
queryFn: () =>
usersApi.getAll({
search: search || undefined,
roleId: roleFilter || undefined,
active: activeFilter || undefined,
limit: 100,
}),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => usersApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success('Používateľ bol deaktivovaný');
setDeleteConfirm(null);
},
onError: (error: unknown) => {
const msg = (error as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Chyba pri deaktivácii používateľa');
},
});
const handleEdit = (user: User) => {
setEditingUser(user);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingUser(null);
};
const isSelf = (userId: string) => currentUser?.id === userId;
const roleOptions = [
{ value: '', label: 'Všetky role' },
...userRoles.map((role) => ({ value: role.id, label: role.name })),
];
const activeOptions = [
{ value: '', label: 'Všetci' },
{ value: 'true', label: 'Aktívni' },
{ value: 'false', label: 'Neaktívni' },
];
const users = data?.data || [];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Správa používateľov</h2>
<Button size="sm" onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nový používateľ
</Button>
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-4 flex-wrap">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Hľadať podľa mena alebo emailu..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Select
options={roleOptions}
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
className="w-40"
/>
<Select
options={activeOptions}
value={activeFilter}
onChange={(e) => setActiveFilter(e.target.value)}
className="w-36"
/>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Meno</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rola</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Vytvorený</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.name}
{isSelf(user.id) && (
<span className="ml-2 text-xs text-muted-foreground">(vy)</span>
)}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant="outline">{user.role.name}</Badge>
</TableCell>
<TableCell>
<Badge variant={user.active ? 'default' : 'secondary'}>
{user.active ? 'Aktívny' : 'Neaktívny'}
</Badge>
</TableCell>
<TableCell>{formatDate(user.createdAt)}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(user)} title="Upraviť">
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setPasswordResetUser({ id: user.id, name: user.name })}
title="Zmeniť heslo"
>
<KeyRound className="h-4 w-4" />
</Button>
{!isSelf(user.id) && (
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirm(user)}
title="Deaktivovať"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</TableCell>
</TableRow>
))}
{users.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Žiadni používatelia
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingUser ? 'Upraviť používateľa' : 'Nový používateľ'}
>
<UserForm user={editingUser} onClose={handleCloseForm} />
</Modal>
<Modal
isOpen={!!passwordResetUser}
onClose={() => setPasswordResetUser(null)}
title="Zmena hesla"
>
{passwordResetUser && (
<PasswordResetModal
userId={passwordResetUser.id}
userName={passwordResetUser.name}
onClose={() => setPasswordResetUser(null)}
/>
)}
</Modal>
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť deaktiváciu"
>
<p>
Naozaj chcete deaktivovať používateľa <strong>{deleteConfirm?.name}</strong>?
</p>
<p className="text-sm text-muted-foreground mt-2">
Používateľ sa nebude môcť prihlásiť, ale jeho dáta zostanú zachované.
</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
>
Deaktivovať
</Button>
</ModalFooter>
</Modal>
</div>
);
}

View File

@@ -1,9 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft } from 'lucide-react'; import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft, Check, Clock } from 'lucide-react';
import { tasksApi } from '@/services/tasks.api'; import { tasksApi } from '@/services/tasks.api';
import { settingsApi } from '@/services/settings.api'; import { settingsApi } from '@/services/settings.api';
import { useAuthStore } from '@/store/authStore'; import { useAuthStore } from '@/store/authStore';
import { useNotificationStore } from '@/store/notificationStore';
import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions';
import type { Task } from '@/types'; import type { Task } from '@/types';
import { Button, Badge, Textarea, Select } from '@/components/ui'; import { Button, Badge, Textarea, Select } from '@/components/ui';
import { TaskForm } from './TaskForm'; import { TaskForm } from './TaskForm';
@@ -22,13 +24,24 @@ interface TaskDetailProps {
taskId: string; taskId: string;
onClose: () => void; onClose: () => void;
onEdit?: (task: Task) => void; // Optional - ak nie je, použije sa interný edit mód onEdit?: (task: Task) => void; // Optional - ak nie je, použije sa interný edit mód
notificationId?: string; // Ak je detail otvorený z notifikácie
} }
export function TaskDetail({ taskId, onClose }: TaskDetailProps) { export function TaskDetail({ taskId, onClose, notificationId }: TaskDetailProps) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { user } = useAuthStore(); const { user } = useAuthStore();
const [newComment, setNewComment] = useState(''); const [newComment, setNewComment] = useState('');
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [snoozeOpen, setSnoozeOpen] = useState(false);
// Notifikácie - len ak bol detail otvorený z notifikácie
const { notifications, markAsRead, snooze } = useNotificationStore();
const snoozeOptions = useSnoozeOptions();
// Konkrétna notifikácia ak existuje
const notification = notificationId
? notifications.find((n) => n.id === notificationId && !n.isRead)
: null;
const { data: taskData, isLoading } = useQuery({ const { data: taskData, isLoading } = useQuery({
queryKey: ['task', taskId], queryKey: ['task', taskId],
@@ -56,6 +69,10 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] }); queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] });
setNewComment(''); setNewComment('');
toast.success('Komentár bol pridaný'); toast.success('Komentár bol pridaný');
// Označiť notifikáciu ako prečítanú ak existuje
if (notificationId) {
markAsRead(notificationId);
}
}, },
onError: (error: unknown) => { onError: (error: unknown) => {
const axiosError = error as { response?: { data?: { message?: string } } }; const axiosError = error as { response?: { data?: { message?: string } } };
@@ -70,6 +87,10 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
queryClient.invalidateQueries({ queryKey: ['tasks'] }); queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] }); queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
toast.success('Úloha bola aktualizovaná'); toast.success('Úloha bola aktualizovaná');
// Označiť notifikáciu ako prečítanú ak existuje
if (notificationId) {
markAsRead(notificationId);
}
}, },
onError: () => { onError: () => {
toast.error('Chyba pri aktualizácii úlohy'); toast.error('Chyba pri aktualizácii úlohy');
@@ -191,6 +212,47 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Tlačidlá pre notifikáciu */}
{notification && (
<>
<Button
variant="outline"
size="sm"
onClick={() => markAsRead(notification.id)}
title="Označiť ako prečítané"
>
<Check className="h-4 w-4 mr-1" />
Prečítané
</Button>
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setSnoozeOpen(!snoozeOpen)}
title="Odložiť notifikáciu"
>
<Clock className="h-4 w-4 mr-1" />
Odložiť
</Button>
{snoozeOpen && (
<div className="absolute right-0 top-full mt-1 bg-popover border rounded-md shadow-lg z-20 min-w-[140px]">
{snoozeOptions.map((option) => (
<button
key={option.label}
onClick={() => {
snooze(notification.id, calculateSnoozeMinutes(option));
setSnoozeOpen(false);
}}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent first:rounded-t-md last:rounded-b-md"
>
{option.label}
</button>
))}
</div>
)}
</div>
</>
)}
{canEdit && ( {canEdit && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}> <Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="h-4 w-4 mr-1" /> <Pencil className="h-4 w-4 mr-1" />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search, MessageSquare } from 'lucide-react'; import { Plus, Pencil, Trash2, Search, MessageSquare, Calendar, User as UserIcon } from 'lucide-react';
import { tasksApi } from '@/services/tasks.api'; import { tasksApi } from '@/services/tasks.api';
import type { Task } from '@/types'; import type { Task } from '@/types';
import { import {
@@ -9,12 +9,6 @@ import {
Card, Card,
CardHeader, CardHeader,
CardContent, CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge, Badge,
LoadingOverlay, LoadingOverlay,
Modal, Modal,
@@ -62,9 +56,9 @@ export function TasksList() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold">Úlohy</h1> <h1 className="text-2xl font-bold">Úlohy</h1>
<Button onClick={() => setIsFormOpen(true)}> <Button onClick={() => setIsFormOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Nová úloha Nová úloha
</Button> </Button>
@@ -72,80 +66,96 @@ export function TasksList() {
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center gap-4"> <div className="relative w-full sm:max-w-sm">
<div className="relative flex-1 max-w-sm"> <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> <Input
<Input placeholder="Hľadať úlohy..."
placeholder="Hľadať úlohy..." value={search}
value={search} onChange={(e) => setSearch(e.target.value)}
onChange={(e) => setSearch(e.target.value)} className="pl-9"
className="pl-9" />
/>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="p-0">
{isLoading ? ( {isLoading ? (
<LoadingOverlay /> <LoadingOverlay />
) : data?.data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">Žiadne úlohy</p>
) : ( ) : (
<Table> <div className="divide-y">
<TableHeader> {data?.data.map((task) => (
<TableRow> <div
<TableHead>Názov</TableHead> key={task.id}
<TableHead>Zadal</TableHead> className="p-4 hover:bg-accent/50 transition-colors"
<TableHead>Stav</TableHead> >
<TableHead>Priorita</TableHead> {/* Hlavný riadok */}
<TableHead>Termín</TableHead> <div className="flex items-center gap-3">
<TableHead>Priradení</TableHead> {/* Stav a priorita - kompaktne */}
<TableHead className="text-right">Akcie</TableHead> <div className="hidden sm:flex flex-col gap-1 shrink-0 w-24">
</TableRow> <Badge color={task.status.color} className="text-xs justify-center">{task.status.name}</Badge>
</TableHeader> <Badge color={task.priority.color} className="text-xs justify-center">{task.priority.name}</Badge>
<TableBody> </div>
{data?.data.map((task) => (
<TableRow key={task.id}> {/* Obsah */}
<TableCell className="font-medium"> <div className="flex-1 min-w-0">
<button <div className="flex items-center gap-2 mb-1">
onClick={() => setDetailTaskId(task.id)} {/* Mobile: badge inline */}
className="text-left hover:text-primary hover:underline" <div className="flex sm:hidden gap-1">
> <Badge color={task.status.color} className="text-xs">{task.status.name}</Badge>
{task.title} </div>
</button> <button
</TableCell> onClick={() => setDetailTaskId(task.id)}
<TableCell>{task.createdBy?.name || '-'}</TableCell> className="font-medium hover:text-primary hover:underline truncate"
<TableCell> >
<Badge color={task.status.color}>{task.status.name}</Badge> {task.title}
</TableCell> </button>
<TableCell> </div>
<Badge color={task.priority.color}>{task.priority.name}</Badge>
</TableCell> {/* Popis - skrátený na 1 riadok */}
<TableCell>{task.deadline ? formatDate(task.deadline) : '-'}</TableCell> {task.description && (
<TableCell> <p className="text-sm text-muted-foreground truncate mb-1">
{task.assignees.length > 0 {task.description}
? task.assignees.map((a) => a.user.name).join(', ') </p>
: '-'} )}
</TableCell>
<TableCell className="text-right"> {/* Metadáta */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{task.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(task.deadline)}
</span>
)}
{task.assignees.length > 0 && (
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{task.assignees.map((a) => a.user.name).join(', ')}
</span>
)}
{task.createdBy && (
<span className="hidden md:inline">
Zadal: {task.createdBy.name}
</span>
)}
</div>
</div>
{/* Akcie */}
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="sm" onClick={() => setDetailTaskId(task.id)} title="Detail"> <Button variant="ghost" size="sm" onClick={() => setDetailTaskId(task.id)} title="Detail">
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(task)} title="Upraviť"> <Button variant="ghost" size="sm" onClick={() => handleEdit(task)} title="Upraviť" className="hidden sm:inline-flex">
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať"> <Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať" className="hidden sm:inline-flex">
<Trash2 className="h-4 w-4 text-destructive" /> <Trash2 className="h-4 w-4 text-destructive" />
</Button> </Button>
</TableCell> </div>
</TableRow> </div>
))} </div>
{data?.data.length === 0 && ( ))}
<TableRow> </div>
<TableCell colSpan={7} className="text-center text-muted-foreground">
Žiadne úlohy
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

@@ -0,0 +1,63 @@
import { get, post, del } from './api';
export interface Notification {
id: string;
type: string;
title: string;
message: string;
isRead: boolean;
readAt: string | null;
snoozedUntil: string | null;
createdAt: string;
task?: {
id: string;
title: string;
status: { id: string; name: string; color: string };
project?: { id: string; name: string } | null;
};
rma?: {
id: string;
rmaNumber: string;
productName: string;
};
data?: Record<string, unknown>;
}
interface NotificationsResponse {
notifications: Notification[];
total: number;
limit: number;
offset: number;
}
interface UnreadCountResponse {
count: number;
}
export const notificationApi = {
// Get notifications for current user
getAll: (params?: { limit?: number; offset?: number; unreadOnly?: boolean }) => {
const query = new URLSearchParams();
if (params?.limit) query.append('limit', String(params.limit));
if (params?.offset) query.append('offset', String(params.offset));
if (params?.unreadOnly) query.append('unreadOnly', 'true');
const queryString = query.toString();
return get<NotificationsResponse>(`/notifications${queryString ? `?${queryString}` : ''}`);
},
// Get unread count
getUnreadCount: () => get<UnreadCountResponse>('/notifications/unread-count'),
// Mark notification as read
markAsRead: (id: string) => post<{ message: string }>(`/notifications/${id}/read`, {}),
// Mark all as read
markAllAsRead: () => post<{ message: string; count: number }>('/notifications/mark-all-read', {}),
// Snooze notification
snooze: (id: string, minutes: number) =>
post<{ message: string; snoozedUntil: string }>(`/notifications/${id}/snooze`, { minutes }),
// Delete notification
delete: (id: string) => del<{ message: string }>(`/notifications/${id}`),
};

View File

@@ -57,14 +57,14 @@ export const settingsApi = {
deleteTag: (id: string) => del<void>(`/settings/tags/${id}`), deleteTag: (id: string) => del<void>(`/settings/tags/${id}`),
// User Roles // User Roles
getUserRoles: () => get<UserRole[]>('/settings/user-roles'), getUserRoles: () => get<UserRole[]>('/settings/roles'),
createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/user-roles', data), createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/roles', data),
updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/user-roles/${id}`, data), updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/roles/${id}`, data),
deleteUserRole: (id: string) => del<void>(`/settings/user-roles/${id}`), deleteUserRole: (id: string) => del<void>(`/settings/roles/${id}`),
// System Settings // System Settings
getSystemSettings: () => get<SystemSetting[]>('/settings/system'), getSystemSettings: () => get<SystemSetting[]>('/settings/system'),
updateSystemSetting: (id: string, value: unknown) => put<SystemSetting>(`/settings/system/${id}`, { value }), updateSystemSetting: (key: string, value: unknown) => put<SystemSetting>(`/settings/system/${key}`, { value }),
// Users (admin) // Users (admin)
getUsers: () => getPaginated<User>('/users?limit=1000'), getUsers: () => getPaginated<User>('/users?limit=1000'),

View File

@@ -0,0 +1,54 @@
import { get, getPaginated, post, put, del, patch } from './api';
import type { User } from '@/types';
export interface UserFilters {
search?: string;
active?: string;
roleId?: string;
page?: number;
limit?: number;
}
export interface CreateUserData {
email: string;
password: string;
name: string;
roleId: string;
}
export interface UpdateUserData {
email?: string;
name?: string;
active?: boolean;
password?: string;
}
function buildQueryString(filters: UserFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.active !== undefined) params.append('active', filters.active);
if (filters.roleId) params.append('roleId', filters.roleId);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export const usersApi = {
getAll: (filters: UserFilters = {}) =>
getPaginated<User>(`/users?${buildQueryString(filters)}`),
getById: (id: string) =>
get<User>(`/users/${id}`),
create: (data: CreateUserData) =>
post<User>('/users', data),
update: (id: string, data: UpdateUserData) =>
put<User>(`/users/${id}`, data),
updateRole: (id: string, roleId: string) =>
patch<User>(`/users/${id}/role`, { roleId }),
delete: (id: string) =>
del<void>(`/users/${id}`),
};

View File

@@ -0,0 +1,114 @@
import { create } from 'zustand';
import { notificationApi, type Notification } from '@/services/notification.api';
interface NotificationState {
notifications: Notification[];
unreadCount: number;
isLoading: boolean;
isOpen: boolean;
// Actions
fetchNotifications: () => Promise<void>;
fetchUnreadCount: () => Promise<void>;
markAsRead: (id: string) => Promise<void>;
markAllAsRead: () => Promise<void>;
snooze: (id: string, minutes: number) => Promise<void>;
deleteNotification: (id: string) => Promise<void>;
setIsOpen: (isOpen: boolean) => void;
addNotification: (notification: Notification) => void;
}
export const useNotificationStore = create<NotificationState>((set, get) => ({
notifications: [],
unreadCount: 0,
isLoading: false,
isOpen: false,
fetchNotifications: async () => {
set({ isLoading: true });
try {
const response = await notificationApi.getAll({ limit: 50 });
set({ notifications: response.data.notifications });
} catch (error) {
console.error('Error fetching notifications:', error);
} finally {
set({ isLoading: false });
}
},
fetchUnreadCount: async () => {
try {
const response = await notificationApi.getUnreadCount();
set({ unreadCount: response.data.count });
} catch (error) {
console.error('Error fetching unread count:', error);
}
},
markAsRead: async (id: string) => {
try {
await notificationApi.markAsRead(id);
set((state) => ({
notifications: state.notifications.map((n) =>
n.id === id ? { ...n, isRead: true, readAt: new Date().toISOString() } : n
),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
} catch (error) {
console.error('Error marking notification as read:', error);
}
},
markAllAsRead: async () => {
try {
await notificationApi.markAllAsRead();
set((state) => ({
notifications: state.notifications.map((n) => ({
...n,
isRead: true,
readAt: new Date().toISOString(),
})),
unreadCount: 0,
}));
} catch (error) {
console.error('Error marking all as read:', error);
}
},
snooze: async (id: string, minutes: number) => {
try {
const response = await notificationApi.snooze(id, minutes);
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
unreadCount: Math.max(0, state.unreadCount - 1),
}));
return response;
} catch (error) {
console.error('Error snoozing notification:', error);
}
},
deleteNotification: async (id: string) => {
const notification = get().notifications.find((n) => n.id === id);
try {
await notificationApi.delete(id);
set((state) => ({
notifications: state.notifications.filter((n) => n.id !== id),
unreadCount: notification && !notification.isRead
? Math.max(0, state.unreadCount - 1)
: state.unreadCount,
}));
} catch (error) {
console.error('Error deleting notification:', error);
}
},
setIsOpen: (isOpen: boolean) => set({ isOpen }),
addNotification: (notification: Notification) => {
set((state) => ({
notifications: [notification, ...state.notifications],
unreadCount: notification.isRead ? state.unreadCount : state.unreadCount + 1,
}));
},
}));

View File

@@ -0,0 +1,15 @@
import { create } from 'zustand';
interface SidebarState {
isOpen: boolean;
toggle: () => void;
open: () => void;
close: () => void;
}
export const useSidebarStore = create<SidebarState>((set) => ({
isOpen: false,
toggle: () => set((state) => ({ isOpen: !state.isOpen })),
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));