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:
@@ -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,11 +516,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[]
|
||||||
|
notifications Notification[]
|
||||||
|
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
@@index([statusId])
|
@@index([statusId])
|
||||||
@@ -572,18 +578,51 @@ model Comment {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
taskId String
|
taskId String
|
||||||
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
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([taskId])
|
@@index([taskId])
|
||||||
@@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,7 +799,8 @@ model RMA {
|
|||||||
statusHistory RMAStatusHistory[]
|
statusHistory RMAStatusHistory[]
|
||||||
comments RMAComment[]
|
comments RMAComment[]
|
||||||
tags RMATag[]
|
tags RMATag[]
|
||||||
|
notifications Notification[]
|
||||||
|
|
||||||
@@index([rmaNumber])
|
@@index([rmaNumber])
|
||||||
@@index([customerId])
|
@@index([customerId])
|
||||||
@@index([statusId])
|
@@index([statusId])
|
||||||
@@ -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,21 +1373,29 @@ 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)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Čas:** 4-5 týždňov
|
**Čas:** 4-5 týždňov
|
||||||
**Náklady:** €15-25/mesiac
|
**Náklady:** €15-25/mesiac
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2059,7 +2132,120 @@ async function getUpcomingRevisions(days: number = 30) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **5. External DB Import (Customer)**
|
### **5. Notification Service (Fáza 2)**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// services/notification.service.ts
|
||||||
|
|
||||||
|
export enum NotificationType {
|
||||||
|
TASK_ASSIGNED = 'TASK_ASSIGNED',
|
||||||
|
TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED',
|
||||||
|
TASK_COMMENT = 'TASK_COMMENT',
|
||||||
|
TASK_DEADLINE_APPROACHING = 'TASK_DEADLINE_APPROACHING',
|
||||||
|
TASK_UPDATED = 'TASK_UPDATED',
|
||||||
|
RMA_ASSIGNED = 'RMA_ASSIGNED',
|
||||||
|
RMA_STATUS_CHANGED = 'RMA_STATUS_CHANGED',
|
||||||
|
RMA_COMMENT = 'RMA_COMMENT',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationService = {
|
||||||
|
// Vytvorenie notifikácie pre viacerých používateľov
|
||||||
|
async createForUsers(userIds: string[], data: {
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
taskId?: string;
|
||||||
|
rmaId?: string;
|
||||||
|
data?: object; // Dodatočné dáta (commentId, actorName, ...)
|
||||||
|
}) {
|
||||||
|
if (userIds.length === 0) return [];
|
||||||
|
|
||||||
|
return prisma.notification.createMany({
|
||||||
|
data: userIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
type: data.type,
|
||||||
|
title: data.title,
|
||||||
|
message: data.message,
|
||||||
|
taskId: data.taskId,
|
||||||
|
rmaId: data.rmaId,
|
||||||
|
data: data.data || undefined,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notifikácia o novom komentári - NEUKLADÁ text, len commentId
|
||||||
|
async notifyTaskComment(taskId: string, commentId: string, commentByUserId: string, commentByUserName: string) {
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id: taskId },
|
||||||
|
include: {
|
||||||
|
assignees: { select: { userId: true } },
|
||||||
|
createdBy: { select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) return;
|
||||||
|
|
||||||
|
const userIds = new Set<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/
|
||||||
@@ -2528,6 +2717,7 @@ 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ľ*
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
100
backend/src/controllers/notification.controller.ts
Normal file
100
backend/src/controllers/notification.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
35
backend/src/routes/notification.routes.ts
Normal file
35
backend/src/routes/notification.routes.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
324
backend/src/services/notification.service.ts
Normal file
324
backend/src/services/notification.service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
232
frontend/src/components/NotificationCenter.tsx
Normal file
232
frontend/src/components/NotificationCenter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
82
frontend/src/hooks/useSnoozeOptions.ts
Normal file
82
frontend/src/hooks/useSnoozeOptions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'] });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
91
frontend/src/pages/settings/PasswordResetModal.tsx
Normal file
91
frontend/src/pages/settings/PasswordResetModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 má "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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
176
frontend/src/pages/settings/UserForm.tsx
Normal file
176
frontend/src/pages/settings/UserForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
249
frontend/src/pages/settings/UserManagement.tsx
Normal file
249
frontend/src/pages/settings/UserManagement.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
63
frontend/src/services/notification.api.ts
Normal file
63
frontend/src/services/notification.api.ts
Normal 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}`),
|
||||||
|
};
|
||||||
@@ -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'),
|
||||||
|
|||||||
54
frontend/src/services/users.api.ts
Normal file
54
frontend/src/services/users.api.ts
Normal 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}`),
|
||||||
|
};
|
||||||
114
frontend/src/store/notificationStore.ts
Normal file
114
frontend/src/store/notificationStore.ts
Normal 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,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
}));
|
||||||
15
frontend/src/store/sidebarStore.ts
Normal file
15
frontend/src/store/sidebarStore.ts
Normal 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 }),
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user