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

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

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

View File

@@ -28,7 +28,8 @@
### **Pridané:**
- ✅ Configuration-driven architecture
- ✅ ROOT Settings panel
- ✅ ROOT/ADMIN Settings panel
- ✅ User Management (CRUD, reset hesla, zmena roly)
- ✅ External DB import pre zákazníkov
- ✅ Dynamic workflow rules
- ✅ Multi-entity tagging system
@@ -173,6 +174,10 @@ model User {
reminders Reminder[]
activityLogs ActivityLog[]
// Comments & Notifications
comments Comment[]
notifications Notification[]
// Equipment
createdEquipment Equipment[] @relation("EquipmentCreator")
performedRevisions Revision[]
@@ -511,11 +516,12 @@ model Task {
createdById String
createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
assignees TaskAssignee[]
reminders Reminder[]
comments Comment[]
tags TaskTag[]
assignees TaskAssignee[]
reminders Reminder[]
comments Comment[]
tags TaskTag[]
notifications Notification[]
@@index([projectId])
@@index([parentId])
@@index([statusId])
@@ -572,18 +578,51 @@ model Comment {
id String @id @default(cuid())
taskId String
userId String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id])
content String @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([taskId])
@@index([createdAt])
}
// ==================== NOTIFICATIONS ====================
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // TASK_ASSIGNED, TASK_STATUS_CHANGED, TASK_COMMENT, etc.
title String
message String // Prázdne pre TASK_COMMENT - text sa načíta z Comment tabuľky
// Odkazy na entity
taskId String?
task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade)
rmaId String?
rma RMA? @relation(fields: [rmaId], references: [id], onDelete: Cascade)
// Dodatočné dáta (JSON) - napr. commentId, actorName, oldStatus, newStatus
data Json?
isRead Boolean @default(false)
readAt DateTime?
snoozedUntil DateTime? // Odloženie notifikácie
createdAt DateTime @default(now())
@@index([userId, isRead])
@@index([userId, createdAt])
@@index([taskId])
@@index([rmaId])
}
// ==================== EQUIPMENT MANAGEMENT ====================
model Equipment {
@@ -760,7 +799,8 @@ model RMA {
statusHistory RMAStatusHistory[]
comments RMAComment[]
tags RMATag[]
notifications Notification[]
@@index([rmaNumber])
@@index([customerId])
@@index([statusId])
@@ -910,15 +950,16 @@ POST /api/auth/logout
GET /api/auth/me
```
### Users
### Users (ROOT/ADMIN)
```
GET /api/users // Stránkovaný zoznam (admin only)
GET /api/users/simple // Jednoduchý zoznam pre selecty (server-side search: ?search=meno)
POST /api/users // Vytvorenie používateľa (admin only)
GET /api/users/:id
PUT /api/users/:id
DELETE /api/users/:id
PATCH /api/users/:id/role
PUT /api/users/:id // Úprava + reset hesla
DELETE /api/users/:id // Soft delete (deaktivácia)
PATCH /api/users/:id/role // Zmena roly
```
### Projects
@@ -1004,7 +1045,7 @@ GET /api/rma/:id/pdf // Generate PDF
GET /api/rma/generate-number // Next RMA number
```
### **🆕 Settings (ROOT only)**
### **🆕 Settings (ROOT/ADMIN)**
```
// Equipment Types
@@ -1064,6 +1105,17 @@ PUT /api/settings/roles/:id
DELETE /api/settings/roles/:id
```
### **🆕 Notifications**
```
GET /api/notifications // Zoznam notifikácií (limit, offset, unreadOnly)
GET /api/notifications/unread-count // Počet neprečítaných
POST /api/notifications/:id/read // Označiť ako prečítané
POST /api/notifications/mark-all-read // Označiť všetky ako prečítané
POST /api/notifications/:id/snooze // Odložiť notifikáciu (minutes)
DELETE /api/notifications/:id // Vymazať notifikáciu
```
### Dashboard
```
@@ -1100,6 +1152,9 @@ src/
│ │ ├── Sidebar.tsx
│ │ └── MainLayout.tsx
│ │
│ ├── notifications/ # NEW (Fáza 2)
│ │ └── NotificationCenter.tsx # Zvonček s dropdown v header
│ │
│ ├── dashboard/
│ │ ├── DashboardView.tsx
│ │ ├── TodaysTasks.tsx
@@ -1153,6 +1208,9 @@ src/
│ │
│ ├── settings/ # NEW
│ │ ├── SettingsDashboard.tsx
│ │ ├── UserManagement.tsx # Správa používateľov (ROOT/ADMIN)
│ │ ├── UserForm.tsx # Formulár vytvorenie/editácia
│ │ ├── PasswordResetModal.tsx # Reset hesla
│ │ ├── EquipmentTypesSettings.tsx
│ │ ├── RevisionTypesSettings.tsx
│ │ ├── RMAStatusSettings.tsx
@@ -1266,9 +1324,9 @@ cd backend && npx prisma db seed
---
### **FÁZA 2: Core Features + Workflow** 🔥 (4-5 týždňov)
### **FÁZA 2: Core Features + Workflow** 🔥 (4-5 týždňov) - *PREBIEHAJÚCA*
**Cieľ:** Swimlanes, revízie, RMA workflow, reminders
**Cieľ:** Swimlanes, revízie, RMA workflow, reminders, notifikácie
**Backend:**
- [ ] **Revision system**
@@ -1284,8 +1342,15 @@ cd backend && npx prisma db seed
- [ ] Dashboard aggregations
- [ ] Email service (Postfix self-hosted)
- [ ] WebSocket (Socket.IO)
- [ ] File upload handling
- [ ] **Task notifications** (databázové - viditeľné na všetkých zariadeniach)
- [x] File upload handling
- [x] **Notification system**
- [x] Notification model (Prisma)
- [x] notification.service.ts - CRUD, enrichment komentárov
- [x] notifyTaskComment - ukladá len commentId (žiadna duplicita)
- [x] notifyTaskStatusChange - ukladá oldStatus, newStatus, actorName
- [x] notifyTaskAssignment
- [x] Snooze funkcionalita s konfigurovateľnými možnosťami
- [x] SystemSetting NOTIFICATION_SNOOZE_OPTIONS
**Frontend:**
- [ ] **Swimlanes Board** (dnd-kit)
@@ -1300,7 +1365,7 @@ cd backend && npx prisma db seed
- [ ] **RMA Workflow**
- [ ] Status change UI
- [ ] Approval buttons (admin)
- [ ] File attachments
- [x] File attachments
- [ ] Comments
- [ ] PDF export
- [ ] **Inline Quick Actions**
@@ -1308,21 +1373,29 @@ cd backend && npx prisma db seed
- [ ] Reminder management UI
- [ ] Filters & tags
- [ ] Real-time updates (WebSocket)
- [ ] **Notifikácie o nových komentároch/zmenách** (všetky zariadenia)
- [x] **Notification UI**
- [x] NotificationCenter komponent (zvonček v header)
- [x] Dashboard - prehľadné zobrazenie notifikácií
- [x] Typ notifikácie + relatívny čas
- [x] Názov úlohy + projekt
- [x] Detail zmeny/komentára + autor
- [x] markAsRead pri akcii (komentár/zmena stavu)
- [x] Snooze dropdown s konfigurovateľnými možnosťami
- [x] useSnoozeOptions hook (načíta z SystemSettings)
**Deliverable:**
```
✅ Všetko z Fázy 1 +
Swimlanes board
Revízny systém funguje
RMA workflow s approval
Email notifikácie
Live updates (WebSocket)
Swimlanes board
Revízny systém funguje
RMA workflow s approval
Email notifikácie
Live updates (WebSocket)
✅ File uploads
✅ Task notifikácie (databázové, všetky zariadenia)
```
**Čas:** 4-5 týždňov
**Čas:** 4-5 týždňov
**Náklady:** €15-25/mesiac
---
@@ -2059,7 +2132,120 @@ async function getUpcomingRevisions(days: number = 30) {
---
### **5. External DB Import (Customer)**
### **5. Notification Service (Fáza 2)**
```typescript
// services/notification.service.ts
export enum NotificationType {
TASK_ASSIGNED = 'TASK_ASSIGNED',
TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED',
TASK_COMMENT = 'TASK_COMMENT',
TASK_DEADLINE_APPROACHING = 'TASK_DEADLINE_APPROACHING',
TASK_UPDATED = 'TASK_UPDATED',
RMA_ASSIGNED = 'RMA_ASSIGNED',
RMA_STATUS_CHANGED = 'RMA_STATUS_CHANGED',
RMA_COMMENT = 'RMA_COMMENT',
}
export const notificationService = {
// Vytvorenie notifikácie pre viacerých používateľov
async createForUsers(userIds: string[], data: {
type: NotificationType;
title: string;
message: string;
taskId?: string;
rmaId?: string;
data?: object; // Dodatočné dáta (commentId, actorName, ...)
}) {
if (userIds.length === 0) return [];
return prisma.notification.createMany({
data: userIds.map((userId) => ({
userId,
type: data.type,
title: data.title,
message: data.message,
taskId: data.taskId,
rmaId: data.rmaId,
data: data.data || undefined,
})),
});
},
// Notifikácia o novom komentári - NEUKLADÁ text, len commentId
async notifyTaskComment(taskId: string, commentId: string, commentByUserId: string, commentByUserName: string) {
const task = await prisma.task.findUnique({
where: { id: taskId },
include: {
assignees: { select: { userId: true } },
createdBy: { select: { id: true } },
},
});
if (!task) return;
const userIds = new Set<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
// services/import.service.ts
@@ -2266,6 +2452,7 @@ helpdesk-system/
│ │ │ ├── useTasks.ts
│ │ │ ├── useEquipment.ts # NEW
│ │ │ ├── useRMA.ts # NEW
│ │ │ ├── useSnoozeOptions.ts # NEW (Fáza 2) - konfigurovateľné snooze možnosti
│ │ │ └── useKeyboard.ts
│ │ │
│ │ ├── services/
@@ -2276,13 +2463,15 @@ helpdesk-system/
│ │ │ ├── customers.api.ts # NEW
│ │ │ ├── equipment.api.ts # NEW
│ │ │ ├── rma.api.ts # NEW
│ │ │ ── settings.api.ts # NEW
│ │ │ ── settings.api.ts # NEW
│ │ │ └── notification.api.ts # NEW (Fáza 2)
│ │ │
│ │ ├── store/
│ │ │ ├── authStore.ts
│ │ │ ├── configStore.ts # NEW
│ │ │ ├── projectsStore.ts
│ │ │ ── tasksStore.ts
│ │ │ ── tasksStore.ts
│ │ │ └── notificationStore.ts # NEW (Fáza 2)
│ │ │
│ │ ├── types/
│ │ ├── styles/
@@ -2528,6 +2717,7 @@ CELKOM: ~€10-15/mesiac
---
*Dokument vytvorený: 02.02.2026*
*Verzia: 2.0.0*
*Dokument vytvorený: 02.02.2026*
*Posledná aktualizácia: 19.02.2026*
*Verzia: 2.2.0*
*Autor: Claude (Anthropic) + Používateľ*

View File

@@ -67,6 +67,7 @@ model User {
taskAttachments TaskAttachment[] @relation("TaskAttachmentUploader")
createdCustomers Customer[]
notifications Notification[]
@@index([email])
@@index([roleId])
@@ -374,11 +375,12 @@ model Task {
createdById String
createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
assignees TaskAssignee[]
reminders Reminder[]
comments Comment[]
tags TaskTag[]
attachments TaskAttachment[]
assignees TaskAssignee[]
reminders Reminder[]
comments Comment[]
tags TaskTag[]
attachments TaskAttachment[]
notifications Notification[]
@@index([projectId])
@@index([parentId])
@@ -633,6 +635,7 @@ model RMA {
statusHistory RMAStatusHistory[]
comments RMAComment[]
tags RMATag[]
notifications Notification[]
@@index([rmaNumber])
@@index([customerId])
@@ -706,6 +709,40 @@ model RMATag {
@@id([rmaId, tagId])
}
// ==================== NOTIFICATIONS ====================
model Notification {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // TASK_ASSIGNED, TASK_STATUS_CHANGED, TASK_COMMENT, TASK_DEADLINE, RMA_ASSIGNED, etc.
taskId String?
task Task? @relation(fields: [taskId], references: [id], onDelete: Cascade)
rmaId String?
rma RMA? @relation(fields: [rmaId], references: [id], onDelete: Cascade)
title String
message String
data Json? // Extra data (oldStatus, newStatus, etc.)
isRead Boolean @default(false)
readAt DateTime?
snoozedUntil DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([userId, isRead])
@@index([taskId])
@@index([rmaId])
@@index([createdAt])
}
// ==================== ACTIVITY LOG ====================
model ActivityLog {

View File

@@ -203,6 +203,19 @@ async function seed() {
label: 'Zapnúť real-time aktualizácie (WebSocket)',
dataType: 'boolean',
},
{
key: 'NOTIFICATION_SNOOZE_OPTIONS',
value: [
{ label: '30 minút', minutes: 30 },
{ label: '1 hodina', minutes: 60 },
{ label: '3 hodiny', minutes: 180 },
{ label: 'Zajtra ráno', type: 'tomorrow', hour: 9 },
],
category: 'NOTIFICATIONS',
label: 'Možnosti odloženia notifikácií',
description: 'Pole objektov. Každý má "label" a buď "minutes" (relatívny čas) alebo "type" + "hour" (konkrétny čas). Type: "today" (ak čas prešiel, skryje sa), "tomorrow".',
dataType: 'json',
},
],
});

View File

@@ -0,0 +1,100 @@
import { Response } from 'express';
import { AuthRequest } from '../middleware/auth.middleware';
import { notificationService } from '../services/notification.service';
import { successResponse, errorResponse, parseQueryInt, parseQueryBoolean, getParam } from '../utils/helpers';
// Get notifications for current user
export const getNotifications = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user!.userId;
const limit = parseQueryInt(req.query.limit, 50);
const offset = parseQueryInt(req.query.offset, 0);
const unreadOnly = parseQueryBoolean(req.query.unreadOnly, false);
const { notifications, total } = await notificationService.getForUser(userId, {
limit,
offset,
unreadOnly,
});
successResponse(res, { notifications, total, limit, offset });
} catch (error) {
console.error('[Notification] Error getting notifications:', error);
errorResponse(res, 'Chyba pri načítaní notifikácií', 500);
}
};
// Get unread count
export const getUnreadCount = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user!.userId;
const count = await notificationService.getUnreadCount(userId);
successResponse(res, { count });
} catch (error) {
console.error('[Notification] Error getting unread count:', error);
errorResponse(res, 'Chyba pri načítaní počtu notifikácií', 500);
}
};
// Mark notification as read
export const markAsRead = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user!.userId;
const id = getParam(req, 'id');
await notificationService.markAsRead(id, userId);
successResponse(res, { message: 'Notifikácia označená ako prečítaná' });
} catch (error) {
console.error('[Notification] Error marking as read:', error);
errorResponse(res, 'Chyba pri označovaní notifikácie', 500);
}
};
// Mark all notifications as read
export const markAllAsRead = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user!.userId;
const result = await notificationService.markAllAsRead(userId);
successResponse(res, { message: 'Všetky notifikácie označené ako prečítané', count: result.count });
} catch (error) {
console.error('[Notification] Error marking all as read:', error);
errorResponse(res, 'Chyba pri označovaní notifikácií', 500);
}
};
// Snooze notification (remind later)
export const snoozeNotification = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user!.userId;
const id = getParam(req, 'id');
const { minutes } = req.body;
if (!minutes || typeof minutes !== 'number' || minutes <= 0) {
errorResponse(res, 'Neplatný čas odloženia', 400);
return;
}
const until = new Date();
until.setMinutes(until.getMinutes() + minutes);
await notificationService.snooze(id, userId, until);
successResponse(res, { message: 'Notifikácia odložená', snoozedUntil: until });
} catch (error) {
console.error('[Notification] Error snoozing:', error);
errorResponse(res, 'Chyba pri odkladaní notifikácie', 500);
}
};
// Delete notification
export const deleteNotification = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const userId = req.user!.userId;
const id = getParam(req, 'id');
await notificationService.delete(id, userId);
successResponse(res, { message: 'Notifikácia vymazaná' });
} catch (error) {
console.error('[Notification] Error deleting:', error);
errorResponse(res, 'Chyba pri mazaní notifikácie', 500);
}
};

View File

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

View File

@@ -40,6 +40,54 @@ export const getUsersSimple = async (req: AuthRequest, res: Response): Promise<v
}
};
export const createUser = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const { email, password, name, roleId } = req.body;
const existingUser = await prisma.user.findUnique({ where: { email } });
if (existingUser) {
errorResponse(res, 'Email je už používaný.', 409);
return;
}
const role = await prisma.userRole.findUnique({ where: { id: roleId } });
if (!role || !role.active) {
errorResponse(res, 'Rola neexistuje alebo nie je aktívna.', 404);
return;
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = await prisma.user.create({
data: { email, password: hashedPassword, name, roleId },
select: {
id: true,
email: true,
name: true,
active: true,
createdAt: true,
updatedAt: true,
role: {
select: {
id: true,
code: true,
name: true,
},
},
},
});
if (req.logActivity) {
await req.logActivity('CREATE', 'User', user.id, { email, name, roleId });
}
successResponse(res, user, 'Používateľ bol vytvorený.', 201);
} catch (error) {
console.error('Error creating user:', error);
errorResponse(res, 'Chyba pri vytváraní používateľa.', 500);
}
};
export const getUsers = async (req: AuthRequest, res: Response): Promise<void> => {
try {
const page = parseQueryInt(req.query.page, 1);

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
import { Router } from 'express';
import { authenticate } from '../middleware/auth.middleware';
import {
getNotifications,
getUnreadCount,
markAsRead,
markAllAsRead,
snoozeNotification,
deleteNotification,
} from '../controllers/notification.controller';
const router = Router();
// All routes require authentication
router.use(authenticate);
// GET /api/notifications - Get user's notifications
router.get('/', getNotifications);
// GET /api/notifications/unread-count - Get unread count
router.get('/unread-count', getUnreadCount);
// POST /api/notifications/mark-all-read - Mark all as read
router.post('/mark-all-read', markAllAsRead);
// POST /api/notifications/:id/read - Mark single notification as read
router.post('/:id/read', markAsRead);
// POST /api/notifications/:id/snooze - Snooze notification
router.post('/:id/snooze', snoozeNotification);
// DELETE /api/notifications/:id - Delete notification
router.delete('/:id', deleteNotification);
export default router;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,324 @@
import prisma from '../config/database';
import { Prisma } from '@prisma/client';
export enum NotificationType {
TASK_ASSIGNED = 'TASK_ASSIGNED',
TASK_STATUS_CHANGED = 'TASK_STATUS_CHANGED',
TASK_COMMENT = 'TASK_COMMENT',
TASK_DEADLINE_APPROACHING = 'TASK_DEADLINE_APPROACHING',
TASK_UPDATED = 'TASK_UPDATED',
RMA_ASSIGNED = 'RMA_ASSIGNED',
RMA_STATUS_CHANGED = 'RMA_STATUS_CHANGED',
RMA_COMMENT = 'RMA_COMMENT',
}
interface CreateNotificationData {
userId: string;
type: NotificationType;
title: string;
message: string;
taskId?: string;
rmaId?: string;
data?: Prisma.InputJsonValue;
}
export const notificationService = {
// Create a new notification
async create(data: CreateNotificationData) {
return prisma.notification.create({
data: {
userId: data.userId,
type: data.type,
title: data.title,
message: data.message,
taskId: data.taskId,
rmaId: data.rmaId,
data: data.data || undefined,
},
});
},
// Create notifications for multiple users
async createForUsers(userIds: string[], data: Omit<CreateNotificationData, 'userId'>) {
if (userIds.length === 0) return [];
return prisma.notification.createMany({
data: userIds.map((userId) => ({
userId,
type: data.type,
title: data.title,
message: data.message,
taskId: data.taskId,
rmaId: data.rmaId,
data: data.data || undefined,
})),
});
},
// Get notifications for a user
async getForUser(userId: string, options?: { limit?: number; offset?: number; unreadOnly?: boolean }) {
const { limit = 50, offset = 0, unreadOnly = false } = options || {};
const where = {
userId,
...(unreadOnly ? { isRead: false } : {}),
OR: [
{ snoozedUntil: null },
{ snoozedUntil: { lte: new Date() } },
],
};
const [rawNotifications, total] = await Promise.all([
prisma.notification.findMany({
where,
orderBy: { createdAt: 'desc' },
take: limit,
skip: offset,
include: {
task: {
select: {
id: true,
title: true,
status: { select: { id: true, name: true, color: true } },
project: { select: { id: true, name: true } },
},
},
rma: {
select: {
id: true,
rmaNumber: true,
productName: true,
},
},
},
}),
prisma.notification.count({ where }),
]);
// Pre TASK_COMMENT notifikácie načítaj text komentára z Comment tabuľky
const notifications = await Promise.all(
rawNotifications.map(async (notification) => {
if (notification.type === 'TASK_COMMENT' && notification.taskId) {
const data = notification.data as { commentId?: string; actorName?: string } | null;
let comment;
if (data?.commentId) {
// Nové notifikácie - načítaj podľa commentId
comment = await prisma.comment.findUnique({
where: { id: data.commentId },
select: { content: true, user: { select: { name: true } } },
});
} else {
// Staré notifikácie - nájdi podľa času (±5 sekúnd)
const notifTime = notification.createdAt.getTime();
comment = await prisma.comment.findFirst({
where: {
taskId: notification.taskId,
createdAt: {
gte: new Date(notifTime - 5000),
lte: new Date(notifTime + 5000),
},
},
select: { content: true, user: { select: { name: true } } },
});
}
if (comment) {
const shortComment = comment.content.length > 100
? comment.content.substring(0, 100) + '...'
: comment.content;
return {
...notification,
message: shortComment,
data: { ...data, actorName: data?.actorName || comment.user?.name },
};
}
}
return notification;
})
);
return { notifications, total };
},
// Get unread count for a user
async getUnreadCount(userId: string) {
return prisma.notification.count({
where: {
userId,
isRead: false,
OR: [
{ snoozedUntil: null },
{ snoozedUntil: { lte: new Date() } },
],
},
});
},
// Mark notification as read
async markAsRead(notificationId: string, userId: string) {
return prisma.notification.updateMany({
where: {
id: notificationId,
userId, // Security: only owner can mark as read
},
data: {
isRead: true,
readAt: new Date(),
},
});
},
// Mark all notifications as read for a user
async markAllAsRead(userId: string) {
return prisma.notification.updateMany({
where: {
userId,
isRead: false,
},
data: {
isRead: true,
readAt: new Date(),
},
});
},
// Snooze notification (remind later)
async snooze(notificationId: string, userId: string, until: Date) {
return prisma.notification.updateMany({
where: {
id: notificationId,
userId,
},
data: {
snoozedUntil: until,
},
});
},
// Delete notification
async delete(notificationId: string, userId: string) {
return prisma.notification.deleteMany({
where: {
id: notificationId,
userId,
},
});
},
// Delete old notifications (cleanup job)
async deleteOld(olderThanDays: number = 30) {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - olderThanDays);
return prisma.notification.deleteMany({
where: {
isRead: true,
createdAt: { lt: cutoffDate },
},
});
},
// Helper: Notify task assignees about status change
async notifyTaskStatusChange(
taskId: string,
oldStatusName: string,
newStatusName: string,
changedByUserId: string
) {
const task = await prisma.task.findUnique({
where: { id: taskId },
include: {
assignees: { select: { userId: true } },
createdBy: { select: { id: true, name: true } },
},
});
if (!task) return;
// Get actor name
const actor = await prisma.user.findUnique({
where: { id: changedByUserId },
select: { name: true },
});
// Notify assignees (except the one who made the change)
const userIds = task.assignees
.map((a: { userId: string }) => a.userId)
.filter((id: string) => id !== changedByUserId);
// Also notify task creator if not already in list
if (task.createdById !== changedByUserId && !userIds.includes(task.createdById)) {
userIds.push(task.createdById);
}
if (userIds.length === 0) return;
await this.createForUsers(userIds, {
type: NotificationType.TASK_STATUS_CHANGED,
title: 'Zmena stavu úlohy',
message: `${oldStatusName}${newStatusName}`,
taskId: task.id,
data: { oldStatus: oldStatusName, newStatus: newStatusName, actorName: actor?.name },
});
},
// Helper: Notify user about task assignment
async notifyTaskAssignment(taskId: string, assignedUserIds: string[], assignedByUserId: string) {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { id: true, title: true },
});
if (!task) return;
// Get actor name
const actor = await prisma.user.findUnique({
where: { id: assignedByUserId },
select: { name: true },
});
// Don't notify the user who assigned (if they assigned themselves)
const userIds = assignedUserIds.filter((id) => id !== assignedByUserId);
if (userIds.length === 0) return;
await this.createForUsers(userIds, {
type: NotificationType.TASK_ASSIGNED,
title: 'Nová úloha',
message: 'Boli ste priradení k úlohe',
taskId: task.id,
data: { actorName: actor?.name },
});
},
// Helper: Notify about new comment on task
async notifyTaskComment(taskId: string, commentId: string, commentByUserId: string, commentByUserName: string) {
const task = await prisma.task.findUnique({
where: { id: taskId },
include: {
assignees: { select: { userId: true } },
createdBy: { select: { id: true } },
},
});
if (!task) return;
// Notify assignees and creator (except comment author)
const userIds = new Set<string>();
task.assignees.forEach((a: { userId: string }) => userIds.add(a.userId));
userIds.add(task.createdById);
userIds.delete(commentByUserId);
if (userIds.size === 0) return;
await this.createForUsers(Array.from(userIds), {
type: NotificationType.TASK_COMMENT,
title: 'Nový komentár',
message: '', // Text sa načíta z Comment tabuľky
taskId: task.id,
data: { commentId, actorName: commentByUserName },
});
},
};

View File

@@ -71,3 +71,9 @@ export const parseBooleanQuery = (value: unknown): boolean | undefined => {
if (value === 'false') return false;
return undefined;
};
export const parseQueryBoolean = (value: unknown, defaultValue: boolean): boolean => {
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
return defaultValue;
};

View File

@@ -18,6 +18,17 @@ export const registerSchema = z.object({
});
// User validators
export const createUserSchema = z.object({
email: z.string().email('Neplatný email'),
password: z
.string()
.min(8, 'Heslo musí mať aspoň 8 znakov')
.regex(/[A-Z]/, 'Heslo musí obsahovať veľké písmeno')
.regex(/[0-9]/, 'Heslo musí obsahovať číslo'),
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky'),
roleId: z.string().min(1, 'Rola je povinná'),
});
export const updateUserSchema = z.object({
email: z.string().email('Neplatný email').optional(),
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky').optional(),

View File

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

View File

@@ -0,0 +1,232 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Bell, Check, CheckCheck, Clock, X } from 'lucide-react';
import { useNotificationStore } from '@/store/notificationStore';
import { useSnoozeOptions, calculateSnoozeMinutes, type SnoozeOption } from '@/hooks/useSnoozeOptions';
import { cn, formatRelativeTime } from '@/lib/utils';
export function NotificationCenter() {
const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null);
const [snoozeOpenFor, setSnoozeOpenFor] = useState<string | null>(null);
const snoozeOptions = useSnoozeOptions();
const {
notifications,
unreadCount,
isLoading,
isOpen,
fetchNotifications,
fetchUnreadCount,
markAsRead,
markAllAsRead,
snooze,
setIsOpen,
} = useNotificationStore();
// Fetch unread count on mount and periodically
useEffect(() => {
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 60000); // Every minute
return () => clearInterval(interval);
}, [fetchUnreadCount]);
// Fetch full notifications when opening
useEffect(() => {
if (isOpen) {
fetchNotifications();
}
}, [isOpen, fetchNotifications]);
// Close when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [setIsOpen]);
const handleNotificationClick = (notification: typeof notifications[0]) => {
// Kliknutie len zobrazí detail, neoznačí ako prečítané
// Používateľ musí explicitne kliknúť na "Označiť ako prečítané"
if (notification.task) {
setIsOpen(false);
navigate('/tasks');
} else if (notification.rma) {
setIsOpen(false);
navigate('/rma');
}
};
const handleSnooze = (e: React.MouseEvent, notificationId: string, option: SnoozeOption) => {
e.stopPropagation();
const actualMinutes = calculateSnoozeMinutes(option);
snooze(notificationId, actualMinutes);
setSnoozeOpenFor(null);
};
const toggleSnoozeMenu = (e: React.MouseEvent, notificationId: string) => {
e.stopPropagation();
setSnoozeOpenFor(snoozeOpenFor === notificationId ? null : notificationId);
};
const getNotificationIcon = (type: string) => {
switch (type) {
case 'TASK_ASSIGNED':
return '📋';
case 'TASK_STATUS_CHANGED':
return '🔄';
case 'TASK_COMMENT':
return '💬';
case 'TASK_DEADLINE_APPROACHING':
return '⏰';
case 'RMA_ASSIGNED':
return '📦';
case 'RMA_STATUS_CHANGED':
return '🔄';
default:
return '📌';
}
};
return (
<div className="relative" ref={containerRef}>
{/* Bell Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className={cn(
'relative p-2 rounded-lg transition-colors',
'hover:bg-accent focus:outline-none focus:ring-2 focus:ring-ring',
isOpen && 'bg-accent'
)}
aria-label={`Notifikácie${unreadCount > 0 ? ` (${unreadCount} neprečítaných)` : ''}`}
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute right-0 mt-2 w-96 max-h-[500px] bg-popover border border-border rounded-lg shadow-lg overflow-hidden z-50">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-muted/50">
<h3 className="font-semibold">Notifikácie</h3>
<div className="flex items-center gap-2">
{unreadCount > 0 && (
<button
onClick={markAllAsRead}
className="text-xs text-primary hover:underline flex items-center gap-1"
>
<CheckCheck className="h-3 w-3" />
Označiť všetky
</button>
)}
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-accent rounded"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Notifications List */}
<div className="max-h-[400px] overflow-y-auto">
{isLoading ? (
<div className="py-8 text-center text-muted-foreground">
Načítavam...
</div>
) : notifications.length === 0 ? (
<div className="py-12 text-center">
<Bell className="h-12 w-12 mx-auto text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground">Žiadne notifikácie</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
onClick={() => handleNotificationClick(notification)}
className={cn(
'px-4 py-3 border-b last:border-b-0 cursor-pointer transition-colors',
'hover:bg-accent/50',
!notification.isRead && 'bg-primary/5'
)}
>
<div className="flex gap-3">
<span className="text-xl flex-shrink-0">
{getNotificationIcon(notification.type)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className={cn(
'text-sm',
!notification.isRead && 'font-medium'
)}>
{notification.title}
</p>
{!notification.isRead && (
<span className="flex-shrink-0 h-2 w-2 rounded-full bg-primary" />
)}
</div>
<p className="text-sm text-muted-foreground line-clamp-2 mt-0.5">
{notification.message}
</p>
<div className="flex items-center justify-between mt-2">
<span className="text-xs text-muted-foreground">
{formatRelativeTime(notification.createdAt)}
</span>
<div className="flex items-center gap-1">
{!notification.isRead && (
<button
onClick={(e) => {
e.stopPropagation();
markAsRead(notification.id);
}}
className="p-1 hover:bg-background rounded text-muted-foreground hover:text-foreground"
title="Označiť ako prečítané"
>
<Check className="h-3.5 w-3.5" />
</button>
)}
<div className="relative">
<button
onClick={(e) => toggleSnoozeMenu(e, notification.id)}
className="p-1 hover:bg-background rounded text-muted-foreground hover:text-foreground"
title="Odložiť"
>
<Clock className="h-3.5 w-3.5" />
</button>
{snoozeOpenFor === notification.id && (
<div className="absolute right-0 bottom-full mb-1 bg-popover border rounded-md shadow-lg z-20 min-w-[140px]">
{snoozeOptions.map((option) => (
<button
key={option.label}
onClick={(e) => handleSnooze(e, notification.id, option)}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent first:rounded-t-md last:rounded-b-md"
>
{option.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -6,8 +6,10 @@ import {
Users,
Wrench,
RotateCcw,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSidebarStore } from '@/store/sidebarStore';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
@@ -19,28 +21,57 @@ const navItems = [
];
export function Sidebar() {
const { isOpen, close } = useSidebarStore();
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">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
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'
)
}
<>
{/* Overlay pre mobile */}
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={close}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'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" />
{item.label}
</NavLink>
))}
</nav>
</aside>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex flex-col gap-1 p-4 pt-0 md:pt-4">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={close}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</nav>
</aside>
</>
);
}

View File

@@ -48,14 +48,18 @@ export function SearchableSelect({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Normalize text for search (remove diacritics, lowercase)
const normalizeText = (text: string) =>
text.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
// Filter options based on search
const filteredOptions = useMemo(() => {
if (!search) return options;
const searchLower = search.toLowerCase();
const searchNormalized = normalizeText(search);
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(searchLower) ||
opt.description?.toLowerCase().includes(searchLower)
normalizeText(opt.label).includes(searchNormalized) ||
(opt.description && normalizeText(opt.description).includes(searchNormalized))
);
}, [options, search]);

View File

@@ -0,0 +1,82 @@
import { useQuery } from '@tanstack/react-query';
import { settingsApi } from '@/services/settings.api';
export interface SnoozeOption {
label: string;
minutes?: number; // Relatívny čas v minútach (ak nie je type)
type?: 'tomorrow' | 'today'; // Typ špeciálneho odloženia
hour?: number; // Hodina dňa pre type 'tomorrow' alebo 'today' (0-23)
}
const DEFAULT_SNOOZE_OPTIONS: SnoozeOption[] = [
{ label: '30 minút', minutes: 30 },
{ label: '1 hodina', minutes: 60 },
{ label: '3 hodiny', minutes: 180 },
{ label: 'Zajtra ráno', type: 'tomorrow', hour: 9 },
];
export function useSnoozeOptions() {
const { data: settings } = useQuery({
queryKey: ['system-settings'],
queryFn: () => settingsApi.getSystemSettings(),
staleTime: 1000 * 60, // Cache for 1 minute
refetchOnWindowFocus: true, // Obnoviť pri prepnutí okna/tabu
});
const snoozeSettings = settings?.data?.find(
(s) => s.key === 'NOTIFICATION_SNOOZE_OPTIONS'
);
const options: SnoozeOption[] = snoozeSettings?.value as SnoozeOption[] || DEFAULT_SNOOZE_OPTIONS;
// Filtrovať neplatné možnosti
const filteredOptions = options.filter(option => {
// Špeciálne časové možnosti (type) vyžadujú definovanú hodinu
if (option.type && option.hour === undefined) {
return false;
}
// "Dnes" možnosti - nezobrazovať ak čas už prešiel
if (option.type === 'today' && option.hour !== undefined) {
const now = new Date();
return now.getHours() < option.hour;
}
// Relatívne možnosti musia mať minutes > 0
if (!option.type && (!option.minutes || option.minutes <= 0)) {
return false;
}
return true;
});
return filteredOptions;
}
/**
* Výpočet minút pre odloženie
* - Ak minutes: vráti priamo hodnotu minutes (relatívny čas)
* - Ak type === 'tomorrow': zajtra o zadanej hodine
* - Ak type === 'today': dnes o zadanej hodine
*/
export function calculateSnoozeMinutes(option: SnoozeOption): number {
const { minutes, type, hour = 9 } = option;
if (type === 'tomorrow') {
// Zajtra o zadanej hodine
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(hour, 0, 0, 0);
return Math.ceil((tomorrow.getTime() - Date.now()) / 60000);
}
if (type === 'today') {
// Dnes o zadanej hodine
const target = new Date();
target.setHours(hour, 0, 0, 0);
return Math.ceil((target.getTime() - Date.now()) / 60000);
}
// Relatívny čas v minútach
return minutes || 0;
}

View File

@@ -27,11 +27,25 @@ export function formatRelativeTime(date: string | Date): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Dnes';
if (diffDays === 1) return 'Včera';
const timeStr = target.toLocaleTimeString('sk-SK', { hour: '2-digit', minute: '2-digit' });
// Dnes - zobraz len čas alebo "pred X min/hod"
if (diffDays === 0) {
if (diffMins < 1) return 'Práve teraz';
if (diffMins < 60) return `pred ${diffMins} min`;
if (diffHours < 6) return `pred ${diffHours} hod`;
return timeStr;
}
// Včera - zobraz "Včera HH:MM"
if (diffDays === 1) return `Včera ${timeStr}`;
// Staršie
if (diffDays < 7) return `Pred ${diffDays} dňami`;
if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} týždňami`;
if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} týž.`;
return formatDate(date);
}

View File

@@ -1,117 +1,157 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
FolderKanban,
CheckSquare,
Users,
Wrench,
RotateCcw,
AlertTriangle,
ArrowRight,
CalendarClock,
User,
AlertCircle
AlertCircle,
Bell,
Check,
Clock,
ChevronDown,
ChevronRight,
MessageSquare,
UserPlus,
RefreshCw,
Flag,
ListTodo,
AlertTriangle,
CheckCircle2,
Timer
} from 'lucide-react';
import { get } from '@/services/api';
import { settingsApi } from '@/services/settings.api';
import { Card, CardHeader, CardTitle, CardContent, Badge, LoadingOverlay } from '@/components/ui';
import { TaskDetail } from '@/pages/tasks/TaskDetail';
import { formatDate } from '@/lib/utils';
import type { Task, Project } from '@/types';
interface DashboardStats {
projects: { total: number; active: number };
tasks: { total: number; pending: number; inProgress: number };
customers: { total: number; active: number };
equipment: { total: number; upcomingRevisions: number };
rma: { total: number; pending: number };
}
import { formatDate, formatRelativeTime, cn } from '@/lib/utils';
import { useNotificationStore } from '@/store/notificationStore';
import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions';
import type { Task } from '@/types';
interface DashboardToday {
myTasks: Task[];
myProjects: Project[];
}
// Ikona podľa typu notifikácie
function getNotificationIcon(type: string) {
switch (type) {
case 'TASK_ASSIGNED':
return <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() {
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({
queryKey: ['dashboard'],
queryFn: () => get<DashboardStats>('/dashboard'),
// Notifikácie
const {
notifications,
unreadCount,
fetchNotifications,
fetchUnreadCount,
markAsRead,
snooze,
} = useNotificationStore();
// Načítať notifikácie pri prvom renderovaní
useEffect(() => {
fetchNotifications();
fetchUnreadCount();
}, [fetchNotifications, fetchUnreadCount]);
// Snooze options z nastavení
const snoozeOptions = useSnoozeOptions();
// Neprečítané notifikácie pre banner
const unreadNotifications = notifications.filter((n) => !n.isRead).slice(0, 5);
// Načítať task statusy
const { data: statusesData } = useQuery({
queryKey: ['task-statuses'],
queryFn: () => settingsApi.getTaskStatuses(),
});
// Načítať priority
const { data: prioritiesData } = useQuery({
queryKey: ['priorities'],
queryFn: () => settingsApi.getPriorities(),
});
// Načítať moje úlohy
const { data: todayData, isLoading: todayLoading } = useQuery({
queryKey: ['dashboard-today'],
queryFn: () => get<DashboardToday>('/dashboard/today'),
});
if (statsLoading || todayLoading) {
if (todayLoading) {
return <LoadingOverlay />;
}
const stats = statsData?.data;
const today = todayData?.data;
const statuses = statusesData?.data || [];
const priorities = prioritiesData?.data || [];
const cards = [
{
title: 'Projekty',
icon: FolderKanban,
value: stats?.projects.total ?? 0,
subtitle: `${stats?.projects.active ?? 0} aktívnych`,
color: 'text-blue-500',
bgColor: 'bg-blue-50 dark:bg-blue-950/30',
href: '/projects',
},
{
title: 'Úlohy',
icon: CheckSquare,
value: stats?.tasks.total ?? 0,
subtitle: `${stats?.tasks.inProgress ?? 0} v progrese`,
color: 'text-green-500',
bgColor: 'bg-green-50 dark:bg-green-950/30',
href: '/tasks',
},
{
title: 'Zákazníci',
icon: Users,
value: stats?.customers.total ?? 0,
subtitle: `${stats?.customers.active ?? 0} aktívnych`,
color: 'text-purple-500',
bgColor: 'bg-purple-50 dark:bg-purple-950/30',
href: '/customers',
},
{
title: 'Zariadenia',
icon: Wrench,
value: stats?.equipment.total ?? 0,
subtitle: `${stats?.equipment.upcomingRevisions ?? 0} revízií`,
color: 'text-orange-500',
bgColor: 'bg-orange-50 dark:bg-orange-950/30',
href: '/equipment',
},
{
title: 'RMA',
icon: RotateCcw,
value: stats?.rma.total ?? 0,
subtitle: `${stats?.rma.pending ?? 0} otvorených`,
color: 'text-red-500',
bgColor: 'bg-red-50 dark:bg-red-950/30',
href: '/rma',
},
];
// Zoskupiť úlohy podľa statusu
const tasksByStatus = statuses.reduce((acc, status) => {
acc[status.id] = today?.myTasks?.filter(t => t.statusId === status.id) || [];
return acc;
}, {} as Record<string, Task[]>);
// Rozdelenie úloh podľa urgentnosti
// Štatistiky
const totalTasks = today?.myTasks?.length || 0;
const overdueTasks = today?.myTasks?.filter(t => t.deadline && new Date(t.deadline) < new Date()) || [];
const todayTasks = today?.myTasks?.filter(t => {
if (!t.deadline) return false;
const deadline = new Date(t.deadline);
const now = new Date();
return deadline.toDateString() === now.toDateString();
}) || [];
const urgentTasks = today?.myTasks?.filter(t => {
if (!t.deadline) return false;
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return daysUntil <= 2;
return daysUntil <= 2 && daysUntil >= 0;
}) || [];
const normalTasks = today?.myTasks?.filter(t => {
if (!t.deadline) return true;
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return daysUntil > 2;
// Úlohy podľa priority (len vysoká priorita)
const highPriorityTasks = today?.myTasks?.filter(t => {
const priority = priorities.find(p => p.id === t.priorityId);
return priority && priority.order <= 1; // Predpokladáme že nižšie číslo = vyššia priorita
}) || [];
const isOverdue = (deadline: string) => {
@@ -120,84 +160,311 @@ export function Dashboard() {
const getDaysUntilDeadline = (deadline: string) => {
const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
if (days < 0) return `${Math.abs(days)} dní po termíne`;
if (days < 0) return `${Math.abs(days)}d po termíne`;
if (days === 0) return 'Dnes';
if (days === 1) return 'Zajtra';
return `${days} dní`;
return `${days}d`;
};
const toggleStatusCollapse = (statusId: string) => {
setCollapsedStatuses(prev => {
const next = new Set(prev);
if (next.has(statusId)) {
next.delete(statusId);
} else {
next.add(statusId);
}
return next;
});
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-muted-foreground">
{new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div>
<h1 className="text-xl md:text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-muted-foreground">
{new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
</div>
</div>
{/* Štatistické karty */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{cards.map((card) => (
<Link key={card.title} to={card.href}>
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${card.bgColor}`}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<card.icon className={`h-5 w-5 ${card.color}`} />
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{card.value}</div>
<p className="text-xs text-muted-foreground mt-1">{card.subtitle}</p>
</CardContent>
</Card>
</Link>
))}
{/* Quick Stats - responzívny grid */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 md:gap-4">
<Card className="p-3 md:p-4">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
<ListTodo className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-2xl font-bold">{totalTasks}</p>
<p className="text-xs text-muted-foreground">Celkom úloh</p>
</div>
</div>
</Card>
<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>
{/* Urgentné úlohy - zobrazí sa len ak existujú */}
{urgentTasks.length > 0 && (
<Card className="border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-800">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-400">
<AlertCircle className="h-5 w-5" />
Urgentné úlohy ({urgentTasks.length})
</CardTitle>
{/* Notifikácie - prepracované */}
{unreadNotifications.length > 0 && (
<Card>
<CardHeader className="pb-2 px-3 md:px-6">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base md:text-lg">
<Bell className="h-5 w-5 text-primary" />
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>
<CardContent>
<div className="space-y-2">
{urgentTasks.map((task) => (
<div
key={task.id}
onClick={() => setDetailTaskId(task.id)}
className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-background border border-red-200 dark:border-red-800 cursor-pointer hover:border-red-400 transition-colors"
>
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{task.title}</p>
{task.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">{task.description}</p>
)}
<div className="flex items-center gap-3 mt-2 text-xs">
{task.project && (
<span className="flex items-center gap-1 text-muted-foreground">
<FolderKanban className="h-3 w-3" />
{task.project.name}
</span>
)}
{task.createdBy && (
<span className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
{task.createdBy.name}
</span>
)}
<CardContent className="px-3 md:px-6 pt-0">
<div className="divide-y">
{unreadNotifications.map((notification) => {
const actorName = notification.data?.actorName as string | undefined;
// Získať zmysluplný obsah správy
const getMessageContent = () => {
const msg = notification.message;
// Pre staré formáty zmeny stavu - extrahuj stavy
if (notification.type === 'TASK_STATUS_CHANGED' && msg.includes('zmenila stav')) {
const match = msg.match(/z "(.+?)" na "(.+?)"/);
if (match) {
return { message: `${match[1]}${match[2]}`, actor: actorName };
}
}
return { message: msg, actor: actorName };
};
const { message: displayMessage, actor: displayActor } = getMessageContent();
return (
<div
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 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'}`}>
{getDaysUntilDeadline(task.deadline)}
);
})}
</div>
</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>
)}
<Badge color={task.priority?.color}>{task.priority?.name}</Badge>
<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>
))}
{/* 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>
@@ -205,176 +472,181 @@ export function Dashboard() {
</Card>
)}
<div className="grid gap-6 lg:grid-cols-2">
{/* Moje úlohy */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<CheckSquare className="h-5 w-5 text-green-500" />
Moje úlohy
{today?.myTasks && today.myTasks.length > 0 && (
<span className="text-sm font-normal text-muted-foreground">
({today.myTasks.length})
</span>
)}
</CardTitle>
<Link to="/tasks" className="text-sm text-primary hover:underline flex items-center gap-1">
Všetky <ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{normalTasks.length > 0 ? (
<div className="space-y-3">
{normalTasks.slice(0, 5).map((task) => (
<div
key={task.id}
onClick={() => setDetailTaskId(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">
<div className="flex-1 min-w-0">
<p className="font-medium">{task.title}</p>
{task.description && (
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
{task.description}
</p>
{/* Úlohy podľa stavov */}
{statuses.filter(s => !s.isFinal).map((status) => {
const tasks = tasksByStatus[status.id] || [];
const isCollapsed = collapsedStatuses.has(status.id);
if (tasks.length === 0) return null;
return (
<Card key={status.id}>
<CardHeader
className="cursor-pointer hover:bg-muted/50 transition-colors py-3 px-3 md:px-6"
onClick={() => toggleStatusCollapse(status.id)}
>
<CardTitle className="flex items-center justify-between text-base md:text-lg">
<div className="flex items-center gap-2">
{isCollapsed ? (
<ChevronRight className="h-5 w-5 text-muted-foreground" />
) : (
<ChevronDown className="h-5 w-5 text-muted-foreground" />
)}
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: status.color || '#888' }}
/>
<span className="truncate">{status.name}</span>
<Badge variant="secondary" className="ml-1">{tasks.length}</Badge>
</div>
<Link
to={`/tasks?statusId=${status.id}`}
className="text-xs text-primary hover:underline font-normal hidden sm:block"
onClick={(e) => e.stopPropagation()}
>
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 className="flex flex-col items-end gap-1">
<Badge color={task.status?.color} className="text-xs">{task.status?.name}</Badge>
<Badge color={task.priority?.color} className="text-xs">{task.priority?.name}</Badge>
</div>
</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.description && (
<p className="text-sm text-muted-foreground line-clamp-2 ml-0 sm:ml-16 mb-1">
{task.description}
</p>
)}
{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" />
Zadal: {task.createdBy.name}
</span>
)}
{task.deadline && (
<span className="flex items-center gap-1">
<CalendarClock className="h-3 w-3" />
{formatDate(task.deadline)}
</span>
)}
</div>
</div>
))}
{normalTasks.length > 5 && (
<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>
))}
</div>
</CardContent>
)}
</CardContent>
</Card>
</div>
</Card>
);
})}
{/* Upozornenie na revízie */}
{(stats?.equipment.upcomingRevisions ?? 0) > 0 && (
<Card className="border-orange-200 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-800">
<CardHeader>
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-orange-500" />
<CardTitle className="text-orange-700 dark:text-orange-400">Blížiace sa revízie</CardTitle>
{/* Dokončené úlohy - defaultne zbalené */}
{statuses.filter(s => s.isFinal).map((status) => {
const tasks = tasksByStatus[status.id] || [];
if (tasks.length === 0) return null;
const isCollapsed = !collapsedStatuses.has(`done-${status.id}`);
return (
<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>
</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>
</Card>
)}
{/* Detail úlohy */}
{detailTaskId && (
{taskDetail && (
<TaskDetail
taskId={detailTaskId}
taskId={taskDetail.taskId}
notificationId={taskDetail.notificationId}
onClose={() => {
setDetailTaskId(null);
// Refresh dashboard data po zatvorení
setTaskDetail(null);
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
}}
/>

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, AlertTriangle } from 'lucide-react';
import { zakazkyApi } from '@/services/zakazky.api';
import { useAuthStore } from '@/store/authStore';
import {
Input,
Card,
@@ -23,9 +24,11 @@ import { formatDate } from '@/lib/utils';
export function ProjectsList() {
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [search, setSearch] = useState('');
const { user } = useAuthStore();
const isAdmin = user?.role === 'ADMIN';
// Check if external DB is configured
const { data: zakazkyStatus, isLoading: statusLoading } = useQuery({
const { data: zakazkyStatus, isLoading: statusLoading, error: statusError } = useQuery({
queryKey: ['zakazky-status'],
queryFn: () => zakazkyApi.checkStatus(),
});
@@ -38,12 +41,20 @@ export function ProjectsList() {
});
// Get zakazky for selected year
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({
const { data: zakazkyData, isLoading: zakazkyLoading, error: zakazkyError } = useQuery({
queryKey: ['zakazky', selectedYear, search],
queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined),
enabled: !!zakazkyStatus?.data?.configured,
retry: 1,
});
// Extract error message
const getErrorMessage = (error: unknown): string => {
if (!error) return '';
const axiosError = error as { response?: { data?: { message?: string } }; message?: string };
return axiosError.response?.data?.message || axiosError.message || 'Neznáma chyba';
};
const isExternalDbConfigured = zakazkyStatus?.data?.configured;
const yearOptions = (yearsData?.data || []).map((year) => ({
value: String(year),
@@ -107,8 +118,25 @@ export function ProjectsList() {
</div>
</CardHeader>
<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 ? (
<LoadingOverlay />
) : zakazkyError ? (
<div className="text-center py-8 text-muted-foreground">
Nepodarilo sa načítať zákazky. Skúste obnoviť stránku.
</div>
) : (
<Table>
<TableHeader>

View File

@@ -0,0 +1,91 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '@/services/users.api';
import { Button, Input, ModalFooter } from '@/components/ui';
import toast from 'react-hot-toast';
const passwordResetSchema = z
.object({
password: z
.string()
.min(8, 'Heslo musí mať aspoň 8 znakov')
.regex(/[A-Z]/, 'Heslo musí obsahovať veľké písmeno')
.regex(/[0-9]/, 'Heslo musí obsahovať číslo'),
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Heslá sa nezhodujú',
path: ['confirmPassword'],
});
type PasswordResetFormData = z.infer<typeof passwordResetSchema>;
interface PasswordResetModalProps {
userId: string;
userName: string;
onClose: () => void;
}
export function PasswordResetModal({ userId, userName, onClose }: PasswordResetModalProps) {
const queryClient = useQueryClient();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<PasswordResetFormData>({
resolver: zodResolver(passwordResetSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
});
const mutation = useMutation({
mutationFn: (data: PasswordResetFormData) =>
usersApi.update(userId, { password: data.password }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
toast.success(`Heslo pre ${userName} bolo zmenené`);
onClose();
},
onError: () => {
toast.error('Chyba pri zmene hesla');
},
});
return (
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-4">
<p className="text-sm text-muted-foreground">
Nastavenie nového hesla pre používateľa <strong>{userName}</strong>.
</p>
<Input
id="password"
type="password"
label="Nové heslo *"
error={errors.password?.message}
{...register('password')}
/>
<Input
id="confirmPassword"
type="password"
label="Potvrdenie hesla *"
error={errors.confirmPassword?.message}
{...register('confirmPassword')}
/>
<p className="text-xs text-muted-foreground">
Min. 8 znakov, jedno veľké písmeno, jedno číslo.
</p>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={mutation.isPending}>
Zmeniť heslo
</Button>
</ModalFooter>
</form>
);
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { useState } from 'react';
import { 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 { settingsApi } from '@/services/settings.api';
import { useAuthStore } from '@/store/authStore';
import { useNotificationStore } from '@/store/notificationStore';
import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions';
import type { Task } from '@/types';
import { Button, Badge, Textarea, Select } from '@/components/ui';
import { TaskForm } from './TaskForm';
@@ -22,13 +24,24 @@ interface TaskDetailProps {
taskId: string;
onClose: () => void;
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 { user } = useAuthStore();
const [newComment, setNewComment] = useState('');
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({
queryKey: ['task', taskId],
@@ -56,6 +69,10 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] });
setNewComment('');
toast.success('Komentár bol pridaný');
// Označiť notifikáciu ako prečítanú ak existuje
if (notificationId) {
markAsRead(notificationId);
}
},
onError: (error: unknown) => {
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: ['dashboard-today'] });
toast.success('Úloha bola aktualizovaná');
// Označiť notifikáciu ako prečítanú ak existuje
if (notificationId) {
markAsRead(notificationId);
}
},
onError: () => {
toast.error('Chyba pri aktualizácii úlohy');
@@ -191,6 +212,47 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
</div>
</div>
<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 && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="h-4 w-4 mr-1" />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
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 type { Task } from '@/types';
import {
@@ -9,12 +9,6 @@ import {
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
@@ -62,9 +56,9 @@ export function TasksList() {
return (
<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>
<Button onClick={() => setIsFormOpen(true)}>
<Button onClick={() => setIsFormOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
Nová úloha
</Button>
@@ -72,80 +66,96 @@ export function TasksList() {
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<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" />
<Input
placeholder="Hľadať úlohy..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="relative w-full sm: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ť úlohy..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{isLoading ? (
<LoadingOverlay />
) : data?.data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">Žiadne úlohy</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Zadal</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Priorita</TableHead>
<TableHead>Termín</TableHead>
<TableHead>Priradení</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">
<button
onClick={() => setDetailTaskId(task.id)}
className="text-left hover:text-primary hover:underline"
>
{task.title}
</button>
</TableCell>
<TableCell>{task.createdBy?.name || '-'}</TableCell>
<TableCell>
<Badge color={task.status.color}>{task.status.name}</Badge>
</TableCell>
<TableCell>
<Badge color={task.priority.color}>{task.priority.name}</Badge>
</TableCell>
<TableCell>{task.deadline ? formatDate(task.deadline) : '-'}</TableCell>
<TableCell>
{task.assignees.length > 0
? task.assignees.map((a) => a.user.name).join(', ')
: '-'}
</TableCell>
<TableCell className="text-right">
<div className="divide-y">
{data?.data.map((task) => (
<div
key={task.id}
className="p-4 hover:bg-accent/50 transition-colors"
>
{/* Hlavný riadok */}
<div className="flex items-center gap-3">
{/* Stav a priorita - kompaktne */}
<div className="hidden sm:flex flex-col gap-1 shrink-0 w-24">
<Badge color={task.status.color} className="text-xs justify-center">{task.status.name}</Badge>
<Badge color={task.priority.color} className="text-xs justify-center">{task.priority.name}</Badge>
</div>
{/* Obsah */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{/* Mobile: badge inline */}
<div className="flex sm:hidden gap-1">
<Badge color={task.status.color} className="text-xs">{task.status.name}</Badge>
</div>
<button
onClick={() => setDetailTaskId(task.id)}
className="font-medium hover:text-primary hover:underline truncate"
>
{task.title}
</button>
</div>
{/* Popis - skrátený na 1 riadok */}
{task.description && (
<p className="text-sm text-muted-foreground truncate mb-1">
{task.description}
</p>
)}
{/* 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">
<MessageSquare className="h-4 w-4" />
</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" />
</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" />
</Button>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
Žiadne úlohy
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>

View File

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

View File

@@ -57,14 +57,14 @@ export const settingsApi = {
deleteTag: (id: string) => del<void>(`/settings/tags/${id}`),
// User Roles
getUserRoles: () => get<UserRole[]>('/settings/user-roles'),
createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/user-roles', data),
updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/user-roles/${id}`, data),
deleteUserRole: (id: string) => del<void>(`/settings/user-roles/${id}`),
getUserRoles: () => get<UserRole[]>('/settings/roles'),
createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/roles', data),
updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/roles/${id}`, data),
deleteUserRole: (id: string) => del<void>(`/settings/roles/${id}`),
// System Settings
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)
getUsers: () => getPaginated<User>('/users?limit=1000'),

View File

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

View File

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

View File

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