- 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>
2724 lines
73 KiB
Markdown
2724 lines
73 KiB
Markdown
# 🎯 Helpdesk/Task Manager System V2 - Inicializačná Špecifikácia
|
|
|
|
## 📊 Prehľad Projektu
|
|
|
|
**Názov:** Helpdesk & Task Management System (Extended)
|
|
**Verzia:** 2.0.0
|
|
**Účel:** Komplexný systém pre správu zákaziek, úloh, projektov, revíznych kontrol a reklamácií s dôrazom na flexibilitu a konfigurovateľnosť
|
|
**Jazyk UI:** Slovenčina
|
|
**Dátum:** 02.02.2026
|
|
|
|
---
|
|
|
|
## 🆕 Novinky vo V2
|
|
|
|
### **Hlavné Rozšírenia:**
|
|
|
|
1. **🔧 Equipment Management** - Evidencia zariadení s revíznymi kontrolami
|
|
2. **📝 RMA System** - Kompletný reklamačný proces
|
|
3. **👥 Customer Database** - Centralizovaná zákaznícka databáza
|
|
4. **⚙️ Dynamic Configuration** - Všetky typy, stavy a nastavenia konfigurovateľné cez GUI
|
|
5. **🔄 Workflow Engine** - Flexibilné workflow pravidlá
|
|
6. **🏠 Self-Hosted First** - Kompletne zadarmo, bez platených služieb
|
|
|
|
### **Odstránené:**
|
|
- ❌ Pevné ENUMs v databáze
|
|
- ❌ Hardcoded business logika
|
|
- ❌ Platené závislosti
|
|
|
|
### **Pridané:**
|
|
- ✅ Configuration-driven architecture
|
|
- ✅ 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
|
|
|
|
---
|
|
|
|
## 🎨 Primárny UI Koncept
|
|
|
|
### SWIMLANES BY PROJECT (Hlavný View - Nezmenené)
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────┐
|
|
│ Filtre: [👤 Moje] [📅 Dnes+Zajtra] [🔴 Vysoká priorita] │
|
|
│ Zobrazenie: [🏊 Swimlanes] [📋 List] [📅 Timeline] │
|
|
└─────────────────────────────────────────────────────────┘
|
|
|
|
╔═══════════════════════════════════════════════════════════╗
|
|
║ 📊 PROJEKT: Renovácia kancelárie XY [▼] ║
|
|
║ Zákazník: Firma ABC | Deadline: 15.02.2026 | Zodp: Martin║
|
|
╠════════════╦═════════════╦═════════════╦═════════════════╣
|
|
║ 🆕 NEW (2) ║ 🔄 DOING(3) ║ ✅ DONE (1) ║ 📊 STATS ║
|
|
╠════════════╬═════════════╬═════════════╬═════════════════╣
|
|
║ [Task 1]🔴 ║ [Task 3]🟡 ║ [Task 6] ║ Progress: 60% ║
|
|
║ [Task 2]🟡 ║ [Task 4]🟡 ║ ║ 3 dni do DDL ║
|
|
║ ║ [Task 5]🟢 ║ ║ 🏷️ #ponuka ║
|
|
║ + Nový task║ ║ ║ [⚙️] [📈] [💬] ║
|
|
╚════════════╩═════════════╩═════════════╩═════════════════╝
|
|
```
|
|
|
|
---
|
|
|
|
## 🏗️ Technická Architektúra
|
|
|
|
### Tech Stack (Nezmenené)
|
|
|
|
```yaml
|
|
Backend:
|
|
- Runtime: Node.js (v20+)
|
|
- Framework: Express.js
|
|
- Database: PostgreSQL (v16+)
|
|
- ORM: Prisma
|
|
- Auth: JWT + bcrypt
|
|
- Validation: Zod
|
|
- Testing: Jest + Supertest
|
|
|
|
Frontend:
|
|
- Framework: React 18+ (TypeScript)
|
|
- State: Zustand
|
|
- Routing: React Router v6
|
|
- UI Library: shadcn/ui + Tailwind CSS
|
|
- Drag & Drop: dnd-kit
|
|
- Forms: React Hook Form + Zod
|
|
- Notifications: react-hot-toast
|
|
- Testing: Vitest + React Testing Library
|
|
|
|
DevOps (Self-Hosted):
|
|
- Containerization: Docker + Docker Compose
|
|
- Reverse Proxy: Nginx
|
|
- SSL: Let's Encrypt (free)
|
|
- Monitoring: Prometheus + Grafana (self-hosted)
|
|
- Logging: Loki + Promtail (self-hosted)
|
|
- CI/CD: GitHub Actions (free tier)
|
|
- Email: Postfix (self-hosted) alebo Gmail SMTP (free)
|
|
- Backups: Cron + rsync (self-hosted)
|
|
```
|
|
|
|
---
|
|
|
|
## 📐 Databázová Schéma (Prisma) - KOMPLETNÁ V2
|
|
|
|
### 🎯 Architektúra Princípy
|
|
|
|
```
|
|
1. DYNAMICKÉ TYPY - Žiadne ENUMs, všetko v tabuľkách
|
|
2. KONFIGUROVATEĽNOSŤ - ROOT môže meniť všetko cez GUI
|
|
3. AUDIT TRAIL - Každá zmena je zalogovaná
|
|
4. SOFT DELETE - Záznamy sa označujú ako neaktívne, nemažú sa
|
|
5. SELF-REFERENCING - Hierarchie (parent-child)
|
|
```
|
|
|
|
---
|
|
|
|
### Kompletný Prisma Schema
|
|
|
|
```prisma
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
// ==================== USER ROLES (dynamické) ====================
|
|
|
|
model UserRole {
|
|
id String @id @default(cuid())
|
|
code String @unique // "ROOT", "ADMIN", "USER", "CUSTOMER"
|
|
name String // "Root správca"
|
|
description String?
|
|
|
|
// Oprávnenia (JSON)
|
|
permissions Json // { "projects": ["create", "read", "update", "delete", "all"], ... }
|
|
|
|
level Int // Hierarchia: 1=ROOT, 2=ADMIN, 3=USER, 4=CUSTOMER
|
|
order Int @default(0)
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Relations
|
|
users User[]
|
|
|
|
@@index([active])
|
|
@@index([level])
|
|
}
|
|
|
|
// ==================== USERS ====================
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String @unique
|
|
password String // bcrypt hashed
|
|
name String
|
|
|
|
// Role relation (namiesto enum)
|
|
roleId String
|
|
role UserRole @relation(fields: [roleId], references: [id])
|
|
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Relations
|
|
ownedProjects Project[] @relation("ProjectOwner")
|
|
assignedProjects ProjectMember[]
|
|
createdTasks Task[] @relation("TaskCreator")
|
|
assignedTasks TaskAssignee[]
|
|
reminders Reminder[]
|
|
activityLogs ActivityLog[]
|
|
|
|
// Comments & Notifications
|
|
comments Comment[]
|
|
notifications Notification[]
|
|
|
|
// Equipment
|
|
createdEquipment Equipment[] @relation("EquipmentCreator")
|
|
performedRevisions Revision[]
|
|
uploadedEquipmentFiles EquipmentAttachment[]
|
|
|
|
// RMA
|
|
assignedRMAs RMA[] @relation("RMAAssignee")
|
|
createdRMAs RMA[] @relation("RMACreator")
|
|
approvedRMAs RMA[] @relation("RMAApprover")
|
|
rmaAttachments RMAAttachment[]
|
|
rmaStatusChanges RMAStatusHistory[]
|
|
rmaComments RMAComment[]
|
|
|
|
// Customers
|
|
createdCustomers Customer[]
|
|
|
|
@@index([email])
|
|
@@index([roleId])
|
|
@@index([active])
|
|
}
|
|
|
|
// ==================== CONFIGURATION TABLES ====================
|
|
|
|
// Equipment Types (dynamické, namiesto enum)
|
|
model EquipmentType {
|
|
id String @id @default(cuid())
|
|
code String @unique // "EPS", "HSP", "CAMERA"
|
|
name String // "Elektrická požiarna signalizácia"
|
|
description String?
|
|
color String? // Hex farba (#FF5733)
|
|
icon String? // Lucide icon name
|
|
order Int @default(0)
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
equipment Equipment[]
|
|
|
|
@@index([active])
|
|
@@index([order])
|
|
}
|
|
|
|
// Revision Types (dynamické, namiesto enum)
|
|
model RevisionType {
|
|
id String @id @default(cuid())
|
|
code String @unique // "QUARTERLY", "ANNUAL"
|
|
name String // "Štvrťročná revízia"
|
|
intervalDays Int // Interval (90, 365...)
|
|
reminderDays Int @default(14) // Pripomenúť X dní dopredu
|
|
color String?
|
|
description String?
|
|
order Int @default(0)
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
revisions Revision[]
|
|
|
|
@@index([active])
|
|
@@index([order])
|
|
}
|
|
|
|
// RMA Statuses (dynamické, namiesto enum)
|
|
model RMAStatus {
|
|
id String @id @default(cuid())
|
|
code String @unique // "NEW", "IN_ASSESSMENT"
|
|
name String // "Nová reklamácia"
|
|
description String?
|
|
color String?
|
|
icon String?
|
|
order Int @default(0)
|
|
|
|
// Workflow
|
|
isInitial Boolean @default(false) // Štartovací stav
|
|
isFinal Boolean @default(false) // Konečný stav
|
|
canTransitionTo Json? // Array: ["IN_ASSESSMENT", "REJECTED"]
|
|
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
rmas RMA[]
|
|
|
|
@@index([active])
|
|
@@index([order])
|
|
@@index([isInitial])
|
|
@@index([isFinal])
|
|
}
|
|
|
|
// RMA Solutions (dynamické, namiesto enum)
|
|
model RMASolution {
|
|
id String @id @default(cuid())
|
|
code String @unique // "REPAIR", "REPLACEMENT"
|
|
name String // "Oprava"
|
|
description String?
|
|
color String?
|
|
order Int @default(0)
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
rmas RMA[]
|
|
|
|
@@index([active])
|
|
@@index([order])
|
|
}
|
|
|
|
// Task Statuses (dynamické, namiesto enum)
|
|
model TaskStatus {
|
|
id String @id @default(cuid())
|
|
code String @unique // "NEW", "IN_PROGRESS", "DONE"
|
|
name String // "Nová úloha"
|
|
description String?
|
|
color String?
|
|
icon String?
|
|
order Int @default(0)
|
|
|
|
// Swimlane mapping
|
|
swimlaneColumn String? // "NEW", "DOING", "DONE"
|
|
|
|
isInitial Boolean @default(false)
|
|
isFinal Boolean @default(false)
|
|
canTransitionTo Json?
|
|
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
tasks Task[]
|
|
|
|
@@index([active])
|
|
@@index([swimlaneColumn])
|
|
@@index([order])
|
|
}
|
|
|
|
// Priorities (dynamické, namiesto enum)
|
|
model Priority {
|
|
id String @id @default(cuid())
|
|
code String @unique // "LOW", "MEDIUM", "HIGH"
|
|
name String // "Vysoká priorita"
|
|
description String?
|
|
color String?
|
|
icon String?
|
|
level Int // 1=lowest, 10=highest
|
|
order Int @default(0)
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
tasks Task[]
|
|
|
|
@@index([active])
|
|
@@index([level])
|
|
@@index([order])
|
|
}
|
|
|
|
// Tags (univerzálne pre všetky entity)
|
|
model Tag {
|
|
id String @id @default(cuid())
|
|
code String @unique
|
|
name String
|
|
description String?
|
|
color String?
|
|
|
|
entityType String // "PROJECT", "TASK", "EQUIPMENT", "RMA"
|
|
|
|
order Int @default(0)
|
|
active Boolean @default(true)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
projectTags ProjectTag[]
|
|
taskTags TaskTag[]
|
|
equipmentTags EquipmentTag[]
|
|
rmaTags RMATag[]
|
|
|
|
@@index([entityType])
|
|
@@index([active])
|
|
}
|
|
|
|
// System Settings (key-value configuration)
|
|
model SystemSetting {
|
|
id String @id @default(cuid())
|
|
|
|
key String @unique // "REVISION_REMINDER_DAYS"
|
|
value Json // Flexible value (string, number, boolean, object)
|
|
|
|
category String // "NOTIFICATIONS", "RMA", "EQUIPMENT"
|
|
label String // Human-readable
|
|
description String?
|
|
dataType String // "string", "number", "boolean", "json"
|
|
|
|
validation Json? // { min: 1, max: 365, required: true }
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([category])
|
|
}
|
|
|
|
// ==================== CUSTOMERS ====================
|
|
|
|
model Customer {
|
|
id String @id @default(cuid())
|
|
|
|
// Základné údaje
|
|
name String // Názov firmy alebo meno
|
|
address String?
|
|
email String?
|
|
phone String?
|
|
ico String? // IČO
|
|
dic String? // DIČ
|
|
icdph String? // IČ DPH
|
|
|
|
// Kontaktná osoba
|
|
contactPerson String?
|
|
contactEmail String?
|
|
contactPhone String?
|
|
|
|
// Import z externej DB
|
|
externalId String? @unique // ID z externej databázy
|
|
externalSource String? // Názov zdroja ("SAP", "Legacy")
|
|
|
|
notes String? @db.Text
|
|
active Boolean @default(true)
|
|
|
|
createdById String
|
|
createdBy User @relation(fields: [createdById], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Relations
|
|
projects Project[]
|
|
equipment Equipment[]
|
|
rmas RMA[]
|
|
|
|
@@index([name])
|
|
@@index([ico])
|
|
@@index([externalId])
|
|
@@index([active])
|
|
}
|
|
|
|
// ==================== PROJECTS ====================
|
|
|
|
model Project {
|
|
id String @id @default(cuid())
|
|
name String
|
|
description String?
|
|
|
|
customerId String?
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
|
|
ownerId String
|
|
owner User @relation("ProjectOwner", fields: [ownerId], references: [id])
|
|
|
|
// Status relation (namiesto enum)
|
|
statusId String
|
|
status TaskStatus @relation(fields: [statusId], references: [id])
|
|
|
|
softDeadline DateTime? // Makký deadline (warning)
|
|
hardDeadline DateTime? // Finálny deadline (critical)
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
completedAt DateTime?
|
|
|
|
tasks Task[]
|
|
members ProjectMember[]
|
|
tags ProjectTag[]
|
|
|
|
@@index([ownerId])
|
|
@@index([statusId])
|
|
@@index([customerId])
|
|
@@index([hardDeadline])
|
|
}
|
|
|
|
model ProjectMember {
|
|
id String @id @default(cuid())
|
|
projectId String
|
|
userId String
|
|
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
addedAt DateTime @default(now())
|
|
|
|
@@unique([projectId, userId])
|
|
@@index([projectId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model ProjectTag {
|
|
projectId String
|
|
tagId String
|
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([projectId, tagId])
|
|
}
|
|
|
|
// ==================== TASKS ====================
|
|
|
|
model Task {
|
|
id String @id @default(cuid())
|
|
title String
|
|
description String?
|
|
|
|
projectId String?
|
|
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
|
|
|
parentId String?
|
|
parent Task? @relation("SubTasks", fields: [parentId], references: [id], onDelete: Cascade)
|
|
subTasks Task[] @relation("SubTasks")
|
|
|
|
// Status relation (namiesto enum)
|
|
statusId String
|
|
status TaskStatus @relation(fields: [statusId], references: [id])
|
|
|
|
// Priority relation (namiesto enum)
|
|
priorityId String
|
|
priority Priority @relation(fields: [priorityId], references: [id])
|
|
|
|
deadline DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
completedAt DateTime?
|
|
|
|
createdById String
|
|
createdBy User @relation("TaskCreator", fields: [createdById], references: [id])
|
|
|
|
assignees TaskAssignee[]
|
|
reminders Reminder[]
|
|
comments Comment[]
|
|
tags TaskTag[]
|
|
notifications Notification[]
|
|
|
|
@@index([projectId])
|
|
@@index([parentId])
|
|
@@index([statusId])
|
|
@@index([priorityId])
|
|
@@index([deadline])
|
|
@@index([createdById])
|
|
}
|
|
|
|
model TaskAssignee {
|
|
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], onDelete: Cascade)
|
|
|
|
assignedAt DateTime @default(now())
|
|
|
|
@@unique([taskId, userId])
|
|
@@index([taskId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model TaskTag {
|
|
taskId String
|
|
tagId String
|
|
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([taskId, tagId])
|
|
}
|
|
|
|
model Reminder {
|
|
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], onDelete: Cascade)
|
|
|
|
remindAt DateTime
|
|
snoozedUntil DateTime?
|
|
dismissed Boolean @default(false)
|
|
|
|
message String?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId, remindAt])
|
|
@@index([taskId])
|
|
}
|
|
|
|
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 {
|
|
id String @id @default(cuid())
|
|
|
|
name String
|
|
|
|
// Type relation (namiesto enum)
|
|
typeId String
|
|
type EquipmentType @relation(fields: [typeId], references: [id])
|
|
|
|
brand String?
|
|
model String?
|
|
|
|
customerId String?
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
|
|
address String
|
|
location String? // Presné umiestnenie
|
|
|
|
partNumber String? // PN
|
|
serialNumber String? // SN
|
|
|
|
installDate DateTime?
|
|
warrantyEnd DateTime?
|
|
warrantyStatus String? // "ACTIVE", "EXPIRED", "EXTENDED"
|
|
|
|
description String? @db.Text
|
|
notes String? @db.Text
|
|
|
|
active Boolean @default(true)
|
|
|
|
createdById String
|
|
createdBy User @relation("EquipmentCreator", fields: [createdById], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
revisions Revision[]
|
|
attachments EquipmentAttachment[]
|
|
tags EquipmentTag[]
|
|
|
|
@@index([typeId])
|
|
@@index([customerId])
|
|
@@index([warrantyEnd])
|
|
@@index([active])
|
|
@@index([createdById])
|
|
}
|
|
|
|
model Revision {
|
|
id String @id @default(cuid())
|
|
|
|
equipmentId String
|
|
equipment Equipment @relation(fields: [equipmentId], references: [id], onDelete: Cascade)
|
|
|
|
// Type relation (namiesto enum)
|
|
typeId String
|
|
type RevisionType @relation(fields: [typeId], references: [id])
|
|
|
|
performedDate DateTime
|
|
nextDueDate DateTime? // Auto-calculated: performedDate + type.intervalDays
|
|
|
|
performedById String
|
|
performedBy User @relation(fields: [performedById], references: [id])
|
|
|
|
findings String? @db.Text
|
|
result String? // "OK", "MINOR_ISSUES", "CRITICAL"
|
|
notes String? @db.Text
|
|
|
|
reminderSent Boolean @default(false)
|
|
reminderDate DateTime? // Auto-calculated: nextDueDate - type.reminderDays
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([equipmentId])
|
|
@@index([typeId])
|
|
@@index([performedById])
|
|
@@index([nextDueDate])
|
|
@@index([reminderDate])
|
|
}
|
|
|
|
model EquipmentAttachment {
|
|
id String @id @default(cuid())
|
|
equipmentId String
|
|
equipment Equipment @relation(fields: [equipmentId], references: [id], onDelete: Cascade)
|
|
|
|
filename String
|
|
filepath String
|
|
mimetype String
|
|
size Int
|
|
|
|
uploadedById String
|
|
uploadedBy User @relation(fields: [uploadedById], references: [id])
|
|
|
|
uploadedAt DateTime @default(now())
|
|
|
|
@@index([equipmentId])
|
|
}
|
|
|
|
model EquipmentTag {
|
|
equipmentId String
|
|
tagId String
|
|
equipment Equipment @relation(fields: [equipmentId], references: [id], onDelete: Cascade)
|
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([equipmentId, tagId])
|
|
}
|
|
|
|
// ==================== RMA (REKLAMÁCIE) ====================
|
|
|
|
model RMA {
|
|
id String @id @default(cuid())
|
|
|
|
// Auto-generated RMA number
|
|
rmaNumber String @unique // Format: RMA-YYYYMMDDXXX
|
|
|
|
// ===== ZÁKAZNÍK =====
|
|
customerId String?
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
|
|
// Manual entry (ak nie je v DB)
|
|
customerName String?
|
|
customerAddress String?
|
|
customerEmail String?
|
|
customerPhone String?
|
|
customerICO String?
|
|
submittedBy String // Meno osoby
|
|
|
|
// ===== VÝROBOK =====
|
|
productName String
|
|
invoiceNumber String?
|
|
purchaseDate DateTime?
|
|
productNumber String?
|
|
serialNumber String?
|
|
accessories String? @db.Text
|
|
|
|
// ===== REKLAMÁCIA =====
|
|
issueDescription String @db.Text
|
|
|
|
// Status relation (namiesto enum)
|
|
statusId String
|
|
status RMAStatus @relation(fields: [statusId], references: [id])
|
|
|
|
// Solution relation (namiesto enum)
|
|
proposedSolutionId String?
|
|
proposedSolution RMASolution? @relation(fields: [proposedSolutionId], references: [id])
|
|
|
|
// ===== WORKFLOW =====
|
|
requiresApproval Boolean @default(false) // Dynamicky z workflow rules
|
|
approvedById String?
|
|
approvedBy User? @relation("RMAApprover", fields: [approvedById], references: [id])
|
|
approvedAt DateTime?
|
|
|
|
// ===== SPRACOVANIE =====
|
|
receivedDate DateTime?
|
|
receivedLocation String?
|
|
internalNotes String? @db.Text
|
|
|
|
resolutionDate DateTime?
|
|
resolutionNotes String? @db.Text
|
|
|
|
assignedToId String?
|
|
assignedTo User? @relation("RMAAssignee", fields: [assignedToId], references: [id])
|
|
|
|
createdById String
|
|
createdBy User @relation("RMACreator", fields: [createdById], references: [id])
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
closedAt DateTime?
|
|
|
|
attachments RMAAttachment[]
|
|
statusHistory RMAStatusHistory[]
|
|
comments RMAComment[]
|
|
tags RMATag[]
|
|
notifications Notification[]
|
|
|
|
@@index([rmaNumber])
|
|
@@index([customerId])
|
|
@@index([statusId])
|
|
@@index([proposedSolutionId])
|
|
@@index([assignedToId])
|
|
@@index([createdById])
|
|
@@index([purchaseDate])
|
|
@@index([receivedDate])
|
|
}
|
|
|
|
model RMAAttachment {
|
|
id String @id @default(cuid())
|
|
rmaId String
|
|
rma RMA @relation(fields: [rmaId], references: [id], onDelete: Cascade)
|
|
|
|
filename String
|
|
filepath String
|
|
mimetype String
|
|
size Int
|
|
|
|
uploadedById String
|
|
uploadedBy User @relation(fields: [uploadedById], references: [id])
|
|
|
|
uploadedAt DateTime @default(now())
|
|
|
|
@@index([rmaId])
|
|
}
|
|
|
|
model RMAStatusHistory {
|
|
id String @id @default(cuid())
|
|
rmaId String
|
|
rma RMA @relation(fields: [rmaId], references: [id], onDelete: Cascade)
|
|
|
|
fromStatusId String?
|
|
toStatusId String
|
|
|
|
changedById String
|
|
changedBy User @relation(fields: [changedById], references: [id])
|
|
|
|
notes String? @db.Text
|
|
changedAt DateTime @default(now())
|
|
|
|
@@index([rmaId])
|
|
@@index([changedAt])
|
|
}
|
|
|
|
model RMAComment {
|
|
id String @id @default(cuid())
|
|
rmaId String
|
|
rma RMA @relation(fields: [rmaId], references: [id], onDelete: Cascade)
|
|
|
|
content String @db.Text
|
|
|
|
userId String
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([rmaId])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
model RMATag {
|
|
rmaId String
|
|
tagId String
|
|
rma RMA @relation(fields: [rmaId], references: [id], onDelete: Cascade)
|
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([rmaId, tagId])
|
|
}
|
|
|
|
// ==================== ACTIVITY LOG ====================
|
|
|
|
model ActivityLog {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
|
|
action String // "CREATE", "UPDATE", "DELETE", "STATUS_CHANGE"
|
|
entity String // "Project", "Task", "RMA", "Equipment"
|
|
entityId String
|
|
|
|
changes Json? // Snapshot of changes
|
|
ipAddress String?
|
|
userAgent String?
|
|
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([entity, entityId])
|
|
@@index([createdAt])
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🔐 Bezpečnosť & Autentifikácia (Nezmenené)
|
|
|
|
### JWT Auth Flow
|
|
|
|
```
|
|
1. Login:
|
|
POST /api/auth/login
|
|
→ Validate credentials
|
|
→ Generate accessToken (15min)
|
|
→ Generate refreshToken (7 days)
|
|
|
|
2. Protected Routes:
|
|
Header: Authorization: Bearer <token>
|
|
→ Middleware verifies JWT
|
|
→ Middleware checks user.role permissions
|
|
|
|
3. Refresh:
|
|
POST /api/auth/refresh
|
|
|
|
4. Logout:
|
|
POST /api/auth/logout
|
|
```
|
|
|
|
### RBAC (Dynamic from Database)
|
|
|
|
```typescript
|
|
// Permissions načítané z UserRole tabuľky
|
|
const userRole = await prisma.userRole.findUnique({
|
|
where: { id: user.roleId },
|
|
});
|
|
|
|
const permissions = userRole.permissions; // JSON object
|
|
|
|
// Middleware check
|
|
if (!hasPermission(permissions, 'projects', 'create')) {
|
|
return res.status(403).json({ error: 'Forbidden' });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 🎯 API Endpoints - KOMPLETNÉ
|
|
|
|
### Authentication (Nezmenené)
|
|
|
|
```
|
|
POST /api/auth/register
|
|
POST /api/auth/login
|
|
POST /api/auth/refresh
|
|
POST /api/auth/logout
|
|
GET /api/auth/me
|
|
```
|
|
|
|
### 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 // Úprava + reset hesla
|
|
DELETE /api/users/:id // Soft delete (deaktivácia)
|
|
PATCH /api/users/:id/role // Zmena roly
|
|
```
|
|
|
|
### Projects
|
|
|
|
```
|
|
GET /api/projects
|
|
POST /api/projects
|
|
GET /api/projects/:id
|
|
PUT /api/projects/:id
|
|
DELETE /api/projects/:id
|
|
PATCH /api/projects/:id/status
|
|
GET /api/projects/:id/tasks
|
|
POST /api/projects/:id/members
|
|
DELETE /api/projects/:id/members/:userId
|
|
```
|
|
|
|
### Tasks
|
|
|
|
```
|
|
GET /api/tasks
|
|
POST /api/tasks
|
|
GET /api/tasks/:id
|
|
PUT /api/tasks/:id
|
|
DELETE /api/tasks/:id
|
|
PATCH /api/tasks/:id/status
|
|
POST /api/tasks/:id/assignees
|
|
DELETE /api/tasks/:id/assignees/:userId
|
|
GET /api/tasks/:id/comments
|
|
POST /api/tasks/:id/comments
|
|
```
|
|
|
|
### **🆕 Customers**
|
|
|
|
```
|
|
GET /api/customers
|
|
POST /api/customers
|
|
GET /api/customers/:id
|
|
PUT /api/customers/:id
|
|
DELETE /api/customers/:id
|
|
GET /api/customers/:id/projects
|
|
GET /api/customers/:id/equipment
|
|
GET /api/customers/:id/rmas
|
|
POST /api/customers/import // Import z externej DB
|
|
```
|
|
|
|
### **🆕 Equipment**
|
|
|
|
```
|
|
GET /api/equipment
|
|
POST /api/equipment
|
|
GET /api/equipment/:id
|
|
PUT /api/equipment/:id
|
|
DELETE /api/equipment/:id
|
|
GET /api/equipment/:id/revisions
|
|
POST /api/equipment/:id/revisions
|
|
POST /api/equipment/:id/attachments
|
|
GET /api/equipment/reminders // Upcoming revisions
|
|
```
|
|
|
|
### **🆕 Revisions**
|
|
|
|
```
|
|
GET /api/revisions
|
|
GET /api/revisions/upcoming
|
|
GET /api/revisions/overdue
|
|
PUT /api/revisions/:id
|
|
DELETE /api/revisions/:id
|
|
```
|
|
|
|
### **🆕 RMA**
|
|
|
|
```
|
|
GET /api/rma
|
|
POST /api/rma
|
|
GET /api/rma/:id
|
|
PUT /api/rma/:id
|
|
DELETE /api/rma/:id
|
|
PATCH /api/rma/:id/status
|
|
PATCH /api/rma/:id/approve // Admin approval
|
|
POST /api/rma/:id/attachments
|
|
POST /api/rma/:id/comments
|
|
GET /api/rma/:id/pdf // Generate PDF
|
|
GET /api/rma/generate-number // Next RMA number
|
|
```
|
|
|
|
### **🆕 Settings (ROOT/ADMIN)**
|
|
|
|
```
|
|
// Equipment Types
|
|
GET /api/settings/equipment-types
|
|
POST /api/settings/equipment-types
|
|
PUT /api/settings/equipment-types/:id
|
|
DELETE /api/settings/equipment-types/:id
|
|
PATCH /api/settings/equipment-types/:id/reorder
|
|
|
|
// Revision Types
|
|
GET /api/settings/revision-types
|
|
POST /api/settings/revision-types
|
|
PUT /api/settings/revision-types/:id
|
|
DELETE /api/settings/revision-types/:id
|
|
|
|
// RMA Statuses
|
|
GET /api/settings/rma-statuses
|
|
POST /api/settings/rma-statuses
|
|
PUT /api/settings/rma-statuses/:id
|
|
DELETE /api/settings/rma-statuses/:id
|
|
GET /api/settings/rma-statuses/:id/transitions
|
|
|
|
// RMA Solutions
|
|
GET /api/settings/rma-solutions
|
|
POST /api/settings/rma-solutions
|
|
PUT /api/settings/rma-solutions/:id
|
|
DELETE /api/settings/rma-solutions/:id
|
|
|
|
// Task Statuses
|
|
GET /api/settings/task-statuses
|
|
POST /api/settings/task-statuses
|
|
PUT /api/settings/task-statuses/:id
|
|
DELETE /api/settings/task-statuses/:id
|
|
|
|
// Priorities
|
|
GET /api/settings/priorities
|
|
POST /api/settings/priorities
|
|
PUT /api/settings/priorities/:id
|
|
DELETE /api/settings/priorities/:id
|
|
|
|
// Tags
|
|
GET /api/settings/tags?entityType=PROJECT
|
|
POST /api/settings/tags
|
|
PUT /api/settings/tags/:id
|
|
DELETE /api/settings/tags/:id
|
|
|
|
// System Settings
|
|
GET /api/settings/system
|
|
GET /api/settings/system/:key
|
|
PUT /api/settings/system/:key
|
|
GET /api/settings/system/category/:category
|
|
|
|
// User Roles
|
|
GET /api/settings/roles
|
|
POST /api/settings/roles
|
|
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
|
|
|
|
```
|
|
GET /api/dashboard // Hlavné štatistiky (projects, tasks, customers, equipment, rma)
|
|
GET /api/dashboard/today // Moje úlohy + moje projekty
|
|
GET /api/dashboard/week // Úlohy s termínom tento týždeň
|
|
GET /api/dashboard/stats // Detailné štatistiky
|
|
GET /api/dashboard/reminders // Tasks + Equipment revisions
|
|
```
|
|
|
|
### Activity Logs (ROOT only)
|
|
|
|
```
|
|
GET /api/logs
|
|
GET /api/logs/:entityType/:entityId
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Frontend Komponenty
|
|
|
|
### Rozšírená Štruktúra
|
|
|
|
```
|
|
src/
|
|
├── components/
|
|
│ ├── auth/
|
|
│ │ ├── LoginForm.tsx
|
|
│ │ ├── ProtectedRoute.tsx
|
|
│ │ └── RoleGuard.tsx
|
|
│ │
|
|
│ ├── layout/
|
|
│ │ ├── Header.tsx
|
|
│ │ ├── Sidebar.tsx
|
|
│ │ └── MainLayout.tsx
|
|
│ │
|
|
│ ├── notifications/ # NEW (Fáza 2)
|
|
│ │ └── NotificationCenter.tsx # Zvonček s dropdown v header
|
|
│ │
|
|
│ ├── dashboard/
|
|
│ │ ├── DashboardView.tsx
|
|
│ │ ├── TodaysTasks.tsx
|
|
│ │ ├── RemindersWidget.tsx
|
|
│ │ ├── EquipmentReminders.tsx # NEW
|
|
│ │ ├── RMAWidget.tsx # NEW
|
|
│ │ └── StatsCard.tsx
|
|
│ │
|
|
│ ├── swimlanes/
|
|
│ │ ├── SwimlanesBoard.tsx
|
|
│ │ ├── ProjectSwimlane.tsx
|
|
│ │ ├── TaskCard.tsx
|
|
│ │ ├── ColumnContainer.tsx
|
|
│ │ └── QuickActions.tsx
|
|
│ │
|
|
│ ├── tasks/
|
|
│ │ ├── TaskList.tsx
|
|
│ │ ├── TaskDetail.tsx
|
|
│ │ ├── TaskForm.tsx
|
|
│ │ ├── InlineCommentEditor.tsx
|
|
│ │ ├── AssigneeSelector.tsx
|
|
│ │ └── PriorityBadge.tsx
|
|
│ │
|
|
│ ├── projects/
|
|
│ │ ├── ProjectList.tsx
|
|
│ │ ├── ProjectDetail.tsx
|
|
│ │ ├── ProjectForm.tsx
|
|
│ │ └── ProjectProgress.tsx
|
|
│ │
|
|
│ ├── customers/ # NEW
|
|
│ │ ├── CustomerList.tsx
|
|
│ │ ├── CustomerDetail.tsx
|
|
│ │ ├── CustomerForm.tsx
|
|
│ │ └── CustomerImport.tsx
|
|
│ │
|
|
│ ├── equipment/ # NEW
|
|
│ │ ├── EquipmentList.tsx
|
|
│ │ ├── EquipmentDetail.tsx
|
|
│ │ ├── EquipmentForm.tsx
|
|
│ │ ├── RevisionForm.tsx
|
|
│ │ ├── RevisionCalendar.tsx
|
|
│ │ └── EquipmentCard.tsx
|
|
│ │
|
|
│ ├── rma/ # NEW
|
|
│ │ ├── RMAList.tsx
|
|
│ │ ├── RMADetail.tsx
|
|
│ │ ├── RMAForm.tsx
|
|
│ │ ├── RMAStatusBadge.tsx
|
|
│ │ ├── RMAWorkflow.tsx
|
|
│ │ └── RMAPDFExport.tsx
|
|
│ │
|
|
│ ├── 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
|
|
│ │ ├── RMASolutionSettings.tsx
|
|
│ │ ├── TaskStatusSettings.tsx
|
|
│ │ ├── PrioritySettings.tsx
|
|
│ │ ├── TagSettings.tsx
|
|
│ │ ├── SystemSettings.tsx
|
|
│ │ └── RoleSettings.tsx
|
|
│ │
|
|
│ ├── shared/
|
|
│ │ ├── Button.tsx
|
|
│ │ ├── Modal.tsx
|
|
│ │ ├── Dropdown.tsx
|
|
│ │ ├── DatePicker.tsx
|
|
│ │ ├── SearchBar.tsx
|
|
│ │ ├── FileUpload.tsx # NEW
|
|
│ │ ├── ColorPicker.tsx # NEW
|
|
│ │ ├── IconPicker.tsx # NEW
|
|
│ │ └── Toast.tsx
|
|
│ │
|
|
│ └── ui/ (shadcn/ui)
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 Development Fázy (Aktualizované)
|
|
|
|
### **FÁZA 1: MVP + Configuration Foundation** ✅ DOKONČENÁ
|
|
|
|
**Cieľ:** Základná funkcionalita + dynamická konfigurácia
|
|
|
|
**Backend:**
|
|
- [x] Setup projektu (Express, Prisma, PostgreSQL)
|
|
- [x] **Databázová schéma V2** (všetky configuration tables)
|
|
- [x] JWT Auth (access + refresh tokens)
|
|
- [x] RBAC middleware (dynamic permissions)
|
|
- [x] User CRUD + `/users/simple` endpoint (server-side search)
|
|
- [x] **User Roles CRUD** (dynamické role)
|
|
- [x] Project CRUD (s fallback pre default status)
|
|
- [x] Task CRUD (s fallback pre default status/priority)
|
|
- [x] Task Comments (s kontrolou oprávnení - len autor/priradený)
|
|
- [x] **Customer CRUD**
|
|
- [x] **Equipment CRUD** (bez revízií zatiaľ)
|
|
- [x] **RMA CRUD** (basic, bez workflow)
|
|
- [x] **Settings API** (CRUD pre všetky config tables)
|
|
- [x] **Dashboard API** (`/dashboard`, `/dashboard/today`, `/dashboard/stats`)
|
|
- [x] Activity logging
|
|
- [x] Seed data (default config)
|
|
|
|
**Frontend:**
|
|
- [x] Setup (React 18, Vite, TypeScript, Tailwind CSS v4)
|
|
- [x] Auth pages (login s redirect po prihlásení)
|
|
- [x] Layout (header, sidebar, MainLayout)
|
|
- [x] **Dashboard** (štatistické karty + moje úlohy + moje projekty)
|
|
- [x] Project list/create/edit (s error handling)
|
|
- [x] Task list/create/edit (so stĺpcom "Zadal")
|
|
- [x] **TaskDetail** - detail úlohy s komentármi
|
|
- Kontrola oprávnení (komentovať môže len autor alebo priradený)
|
|
- Zobrazenie info o úlohe (zadal, priradení, projekt, termín)
|
|
- [x] **UserSelect** - server-side vyhľadávanie používateľov (debounce 300ms)
|
|
- [x] **Customer list/create/edit**
|
|
- [x] **Equipment list/create (basic form)**
|
|
- [x] **RMA list/create (basic form)**
|
|
- [x] **Settings Dashboard** (ROOT only)
|
|
- [x] Equipment Types
|
|
- [x] Revision Types
|
|
- [x] RMA Statuses/Solutions
|
|
- [x] Task Statuses
|
|
- [x] Priorities
|
|
- [x] User Roles
|
|
- [x] Basic forms (React Hook Form + Zod) s lepším error handling
|
|
- [x] Toast notifications (react-hot-toast)
|
|
- [x] React Query pre data fetching
|
|
|
|
**Deliverable:**
|
|
```
|
|
✅ Prihlásenie/odhlásenie (JWT + refresh tokens)
|
|
✅ Dynamické role a oprávnenia
|
|
✅ Projekty a úlohy (vytvorenie, editácia, mazanie)
|
|
✅ Detail úlohy s komentármi a zmenou statusu/priority
|
|
✅ Priradenie používateľov na úlohy (multi-select)
|
|
✅ Dashboard s mojimi úlohami a projektmi + urgentné úlohy
|
|
✅ Zákazníci
|
|
✅ Zariadenia (bez revízií)
|
|
✅ RMA (bez workflow)
|
|
✅ ROOT môže konfigurovať všetko cez Settings
|
|
✅ Žiadne hardcoded ENUMs
|
|
✅ Server-side vyhľadávanie používateľov (škálovateľné)
|
|
```
|
|
|
|
**Prihlasovacie údaje:**
|
|
- Root: `root@helpdesk.sk` / `root123`
|
|
- Admin: `admin@helpdesk.sk` / `admin123`
|
|
- User: `user@helpdesk.sk` / `user123`
|
|
|
|
**Spustenie:**
|
|
```bash
|
|
# Backend (terminal 1)
|
|
cd backend && npm run dev
|
|
|
|
# Frontend (terminal 2)
|
|
cd frontend && npm run dev
|
|
|
|
# Seed databázy (ak treba)
|
|
cd backend && npx prisma db seed
|
|
```
|
|
|
|
**Čas:** 3-4 týždne
|
|
**Náklady:** €5-10/mesiac (VPS)
|
|
|
|
---
|
|
|
|
### **FÁZA 2: Core Features + Workflow** 🔥 (4-5 týždňov) - *PREBIEHAJÚCA*
|
|
|
|
**Cieľ:** Swimlanes, revízie, RMA workflow, reminders, notifikácie
|
|
|
|
**Backend:**
|
|
- [ ] **Revision system**
|
|
- [ ] Create revision endpoint
|
|
- [ ] Auto-calculate nextDueDate
|
|
- [ ] Reminder scheduler
|
|
- [ ] **RMA workflow**
|
|
- [ ] Status transitions validation
|
|
- [ ] Approval flow (customer RMAs)
|
|
- [ ] Auto-assign logic
|
|
- [ ] Task hierarchy (parent-child)
|
|
- [ ] Bulk operations
|
|
- [ ] Dashboard aggregations
|
|
- [ ] Email service (Postfix self-hosted)
|
|
- [ ] WebSocket (Socket.IO)
|
|
- [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)
|
|
- [ ] Project swimlanes
|
|
- [ ] Drag & Drop
|
|
- [ ] Collapse/expand
|
|
- [ ] Progress indicators
|
|
- [ ] **Equipment Management**
|
|
- [ ] Revision form
|
|
- [ ] Revision calendar view
|
|
- [ ] Reminder notifications
|
|
- [ ] **RMA Workflow**
|
|
- [ ] Status change UI
|
|
- [ ] Approval buttons (admin)
|
|
- [x] File attachments
|
|
- [ ] Comments
|
|
- [ ] PDF export
|
|
- [ ] **Inline Quick Actions**
|
|
- [ ] **Quick Search (Ctrl+K)**
|
|
- [ ] Reminder management UI
|
|
- [ ] Filters & tags
|
|
- [ ] Real-time updates (WebSocket)
|
|
- [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)
|
|
✅ File uploads
|
|
✅ Task notifikácie (databázové, všetky zariadenia)
|
|
```
|
|
|
|
**Čas:** 4-5 týždňov
|
|
**Náklady:** €15-25/mesiac
|
|
|
|
---
|
|
|
|
### **FÁZA 3: Advanced Features** 🚀 (4-6 týždňov)
|
|
|
|
**Cieľ:** Import, export, analytics, advanced UI
|
|
|
|
**Backend:**
|
|
- [ ] **External DB connector** (import zákazníkov)
|
|
- [ ] Advanced filtering & search
|
|
- [ ] Export dát (PDF, Excel)
|
|
- [ ] Template system
|
|
- [ ] Performance optimizations
|
|
- [ ] Caching (Redis)
|
|
- [ ] Rate limiting
|
|
|
|
**Frontend:**
|
|
- [ ] **Customer import UI**
|
|
- [ ] Timeline/Calendar view
|
|
- [ ] Gantt-style visualization
|
|
- [ ] Bulk actions UI
|
|
- [ ] Task templates
|
|
- [ ] Statistics & reports dashboard
|
|
- [ ] Advanced keyboard shortcuts
|
|
- [ ] Activity stream sidebar
|
|
- [ ] Role-based dashboards
|
|
- [ ] Mobile responsive
|
|
|
|
**Testing:**
|
|
- [ ] Performance testing
|
|
- [ ] Security audit
|
|
- [ ] Full E2E coverage
|
|
|
|
**Deliverable:**
|
|
```
|
|
✅ Production-ready systém
|
|
✅ Import zákazníkov z externých DB
|
|
✅ Komplexné reporty
|
|
✅ Optimalizovaný performance
|
|
✅ Full monitoring stack
|
|
```
|
|
|
|
**Čas:** 4-6 týždňov
|
|
**Náklady:** €20-40/mesiac
|
|
|
|
---
|
|
|
|
## 🔧 Environment Variables
|
|
|
|
### Backend (.env)
|
|
|
|
```bash
|
|
# Database
|
|
DATABASE_URL="postgresql://user:password@localhost:5432/helpdesk_db"
|
|
|
|
# JWT
|
|
JWT_SECRET="your-super-secret-key"
|
|
JWT_REFRESH_SECRET="your-refresh-secret"
|
|
JWT_EXPIRES_IN="15m"
|
|
JWT_REFRESH_EXPIRES_IN="7d"
|
|
|
|
# Server
|
|
PORT=3001
|
|
NODE_ENV="development"
|
|
|
|
# Email (Fáza 2)
|
|
SMTP_HOST="localhost" # Postfix self-hosted
|
|
SMTP_PORT=25
|
|
SMTP_USER=""
|
|
SMTP_PASSWORD=""
|
|
EMAIL_FROM="noreply@vasadomena.sk"
|
|
|
|
# Frontend URL
|
|
FRONTEND_URL="http://localhost:5173"
|
|
|
|
# File Upload
|
|
UPLOAD_DIR="/app/uploads"
|
|
MAX_FILE_SIZE=10485760 # 10MB
|
|
|
|
# Redis (Fáza 3)
|
|
REDIS_URL="redis://localhost:6379"
|
|
|
|
# External DB (Fáza 3)
|
|
EXTERNAL_DB_TYPE="mysql" # mysql, mssql, oracle
|
|
EXTERNAL_DB_HOST="external-server.com"
|
|
EXTERNAL_DB_PORT=3306
|
|
EXTERNAL_DB_USER="readonly"
|
|
EXTERNAL_DB_PASSWORD="password"
|
|
EXTERNAL_DB_NAME="customers"
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Seed Data (Default Configuration)
|
|
|
|
```typescript
|
|
// prisma/seed.ts
|
|
|
|
async function seed() {
|
|
console.log('🌱 Seeding database...');
|
|
|
|
// ===== USER ROLES =====
|
|
const roles = await Promise.all([
|
|
prisma.userRole.create({
|
|
data: {
|
|
code: 'ROOT',
|
|
name: 'Root Správca',
|
|
level: 1,
|
|
permissions: {
|
|
projects: ['*'],
|
|
tasks: ['*'],
|
|
equipment: ['*'],
|
|
rma: ['*'],
|
|
customers: ['*'],
|
|
settings: ['*'],
|
|
users: ['*'],
|
|
logs: ['*'],
|
|
},
|
|
},
|
|
}),
|
|
prisma.userRole.create({
|
|
data: {
|
|
code: 'ADMIN',
|
|
name: 'Administrátor',
|
|
level: 2,
|
|
permissions: {
|
|
projects: ['create', 'read', 'update', 'delete', 'all'],
|
|
tasks: ['create', 'read', 'update', 'delete', 'all'],
|
|
equipment: ['create', 'read', 'update', 'delete', 'all'],
|
|
rma: ['create', 'read', 'update', 'delete', 'approve'],
|
|
customers: ['create', 'read', 'update', 'delete'],
|
|
users: ['read'],
|
|
},
|
|
},
|
|
}),
|
|
prisma.userRole.create({
|
|
data: {
|
|
code: 'USER',
|
|
name: 'Používateľ',
|
|
level: 3,
|
|
permissions: {
|
|
projects: ['read:own', 'update:own'],
|
|
tasks: ['create', 'read:assigned', 'update:assigned'],
|
|
equipment: ['read', 'update:assigned'],
|
|
rma: ['create', 'read:assigned', 'update:assigned'],
|
|
customers: ['read'],
|
|
},
|
|
},
|
|
}),
|
|
prisma.userRole.create({
|
|
data: {
|
|
code: 'CUSTOMER',
|
|
name: 'Zákazník',
|
|
level: 4,
|
|
permissions: {
|
|
projects: ['read:own'],
|
|
tasks: ['read:own'],
|
|
equipment: ['read:own'],
|
|
rma: ['create:own', 'read:own'],
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
// ===== EQUIPMENT TYPES =====
|
|
await prisma.equipmentType.createMany({
|
|
data: [
|
|
{ code: 'EPS', name: 'Elektrická požiarna signalizácia', color: '#3B82F6', order: 1 },
|
|
{ code: 'HSP', name: 'Hasiaci systém', color: '#EF4444', order: 2 },
|
|
{ code: 'CAMERA', name: 'Kamerový systém', color: '#10B981', order: 3 },
|
|
{ code: 'ACCESS', name: 'Prístupový systém', color: '#F59E0B', order: 4 },
|
|
{ code: 'OTHER', name: 'Iné zariadenie', color: '#6B7280', order: 5 },
|
|
],
|
|
});
|
|
|
|
// ===== REVISION TYPES =====
|
|
await prisma.revisionType.createMany({
|
|
data: [
|
|
{ code: 'QUARTERLY', name: 'Štvrťročná revízia', intervalDays: 90, reminderDays: 14, color: '#FFA500', order: 1 },
|
|
{ code: 'BIANNUAL', name: 'Polročná revízia', intervalDays: 180, reminderDays: 21, color: '#FBBF24', order: 2 },
|
|
{ code: 'ANNUAL', name: 'Ročná revízia', intervalDays: 365, reminderDays: 30, color: '#DC2626', order: 3 },
|
|
{ code: 'EMERGENCY', name: 'Mimoriadna revízia', intervalDays: 0, reminderDays: 0, color: '#DC2626', order: 4 },
|
|
],
|
|
});
|
|
|
|
// ===== RMA STATUSES =====
|
|
await prisma.rmaStatus.createMany({
|
|
data: [
|
|
{
|
|
code: 'NEW',
|
|
name: 'Nová reklamácia',
|
|
color: '#10B981',
|
|
isInitial: true,
|
|
canTransitionTo: JSON.stringify(['IN_ASSESSMENT', 'REJECTED']),
|
|
order: 1
|
|
},
|
|
{
|
|
code: 'IN_ASSESSMENT',
|
|
name: 'V posúdzovaní',
|
|
color: '#F59E0B',
|
|
canTransitionTo: JSON.stringify(['APPROVED', 'REJECTED']),
|
|
order: 2
|
|
},
|
|
{
|
|
code: 'APPROVED',
|
|
name: 'Schválená',
|
|
color: '#3B82F6',
|
|
canTransitionTo: JSON.stringify(['IN_REPAIR', 'REPLACED', 'REFUNDED']),
|
|
order: 3
|
|
},
|
|
{
|
|
code: 'REJECTED',
|
|
name: 'Zamietnutá',
|
|
color: '#EF4444',
|
|
isFinal: true,
|
|
order: 4
|
|
},
|
|
{
|
|
code: 'IN_REPAIR',
|
|
name: 'V oprave',
|
|
color: '#8B5CF6',
|
|
canTransitionTo: JSON.stringify(['REPAIRED', 'COMPLETED']),
|
|
order: 5
|
|
},
|
|
{
|
|
code: 'REPAIRED',
|
|
name: 'Opravené',
|
|
color: '#059669',
|
|
canTransitionTo: JSON.stringify(['COMPLETED']),
|
|
order: 6
|
|
},
|
|
{
|
|
code: 'REPLACED',
|
|
name: 'Vymenené',
|
|
color: '#059669',
|
|
canTransitionTo: JSON.stringify(['COMPLETED']),
|
|
order: 7
|
|
},
|
|
{
|
|
code: 'REFUNDED',
|
|
name: 'Vrátené peniaze',
|
|
color: '#059669',
|
|
canTransitionTo: JSON.stringify(['COMPLETED']),
|
|
order: 8
|
|
},
|
|
{
|
|
code: 'COMPLETED',
|
|
name: 'Uzatvorená',
|
|
color: '#059669',
|
|
isFinal: true,
|
|
order: 9
|
|
},
|
|
],
|
|
});
|
|
|
|
// ===== RMA SOLUTIONS =====
|
|
await prisma.rmaSolution.createMany({
|
|
data: [
|
|
{ code: 'ASSESSMENT', name: 'Posúdzovanie', color: '#F59E0B', order: 1 },
|
|
{ code: 'REPAIR', name: 'Oprava', color: '#3B82F6', order: 2 },
|
|
{ code: 'REPLACEMENT', name: 'Výmena', color: '#10B981', order: 3 },
|
|
{ code: 'REFUND', name: 'Vrátenie peňazí', color: '#8B5CF6', order: 4 },
|
|
{ code: 'REJECTED', name: 'Zamietnutie', color: '#EF4444', order: 5 },
|
|
{ code: 'OTHER', name: 'Iné riešenie', color: '#6B7280', order: 6 },
|
|
],
|
|
});
|
|
|
|
// ===== TASK STATUSES =====
|
|
await prisma.taskStatus.createMany({
|
|
data: [
|
|
{
|
|
code: 'NEW',
|
|
name: 'Nová úloha',
|
|
swimlaneColumn: 'NEW',
|
|
color: '#10B981',
|
|
isInitial: true,
|
|
order: 1
|
|
},
|
|
{
|
|
code: 'IN_PROGRESS',
|
|
name: 'V riešení',
|
|
swimlaneColumn: 'DOING',
|
|
color: '#F59E0B',
|
|
order: 2
|
|
},
|
|
{
|
|
code: 'COMPLETED',
|
|
name: 'Dokončená',
|
|
swimlaneColumn: 'DONE',
|
|
color: '#059669',
|
|
isFinal: true,
|
|
order: 3
|
|
},
|
|
],
|
|
});
|
|
|
|
// ===== PRIORITIES =====
|
|
await prisma.priority.createMany({
|
|
data: [
|
|
{ code: 'LOW', name: 'Nízka priorita', color: '#10B981', level: 1, order: 1 },
|
|
{ code: 'MEDIUM', name: 'Stredná priorita', color: '#F59E0B', level: 5, order: 2 },
|
|
{ code: 'HIGH', name: 'Vysoká priorita', color: '#EF4444', level: 8, order: 3 },
|
|
{ code: 'URGENT', name: 'Urgentná', color: '#DC2626', level: 10, order: 4 },
|
|
],
|
|
});
|
|
|
|
// ===== SYSTEM SETTINGS =====
|
|
await prisma.systemSetting.createMany({
|
|
data: [
|
|
{
|
|
key: 'REVISION_REMINDER_DAYS',
|
|
value: JSON.stringify(14),
|
|
category: 'NOTIFICATIONS',
|
|
label: 'Pripomenúť revíziu X dní dopredu',
|
|
dataType: 'number',
|
|
validation: JSON.stringify({ min: 1, max: 365 }),
|
|
},
|
|
{
|
|
key: 'RMA_NUMBER_FORMAT',
|
|
value: JSON.stringify('RMA-{YYYY}{MM}{DD}{XXX}'),
|
|
category: 'RMA',
|
|
label: 'Formát RMA čísla',
|
|
dataType: 'string',
|
|
},
|
|
{
|
|
key: 'RMA_CUSTOMER_REQUIRES_APPROVAL',
|
|
value: JSON.stringify(true),
|
|
category: 'RMA',
|
|
label: 'Reklamácie od zákazníkov vyžadujú schválenie',
|
|
dataType: 'boolean',
|
|
},
|
|
{
|
|
key: 'ADMIN_NOTIFICATION_EMAILS',
|
|
value: JSON.stringify(['admin@firma.sk']),
|
|
category: 'NOTIFICATIONS',
|
|
label: 'Email adresy pre admin notifikácie',
|
|
dataType: 'json',
|
|
},
|
|
{
|
|
key: 'ENABLE_WEBSOCKET',
|
|
value: JSON.stringify(false),
|
|
category: 'GENERAL',
|
|
label: 'Zapnúť real-time aktualizácie (WebSocket)',
|
|
dataType: 'boolean',
|
|
},
|
|
],
|
|
});
|
|
|
|
// ===== DEMO USERS =====
|
|
const rootRole = roles.find(r => r.code === 'ROOT');
|
|
const adminRole = roles.find(r => r.code === 'ADMIN');
|
|
const userRole = roles.find(r => r.code === 'USER');
|
|
|
|
await prisma.user.createMany({
|
|
data: [
|
|
{
|
|
email: 'root@helpdesk.sk',
|
|
password: await bcrypt.hash('Root1234!', 10),
|
|
name: 'Root Admin',
|
|
roleId: rootRole.id,
|
|
},
|
|
{
|
|
email: 'admin@helpdesk.sk',
|
|
password: await bcrypt.hash('Admin1234!', 10),
|
|
name: 'Peter Admin',
|
|
roleId: adminRole.id,
|
|
},
|
|
{
|
|
email: 'user@helpdesk.sk',
|
|
password: await bcrypt.hash('User1234!', 10),
|
|
name: 'Martin Používateľ',
|
|
roleId: userRole.id,
|
|
},
|
|
],
|
|
});
|
|
|
|
console.log('✅ Seeding completed!');
|
|
}
|
|
|
|
seed()
|
|
.catch(console.error)
|
|
.finally(() => prisma.$disconnect());
|
|
```
|
|
|
|
---
|
|
|
|
## 🔌 Backend Logic Examples
|
|
|
|
### **1. Config Service (Caching)**
|
|
|
|
```typescript
|
|
// services/config.service.ts
|
|
|
|
class ConfigService {
|
|
private cache = new Map<string, any>();
|
|
private cacheExpiry = 60000; // 1 minute
|
|
|
|
async getEquipmentTypes(activeOnly = true) {
|
|
const cacheKey = `equipment_types_${activeOnly}`;
|
|
|
|
if (this.cache.has(cacheKey)) {
|
|
return this.cache.get(cacheKey);
|
|
}
|
|
|
|
const types = await prisma.equipmentType.findMany({
|
|
where: activeOnly ? { active: true } : {},
|
|
orderBy: { order: 'asc' },
|
|
});
|
|
|
|
this.cache.set(cacheKey, types);
|
|
setTimeout(() => this.cache.delete(cacheKey), this.cacheExpiry);
|
|
|
|
return types;
|
|
}
|
|
|
|
async getSetting<T = any>(key: string): Promise<T | null> {
|
|
const cacheKey = `setting_${key}`;
|
|
|
|
if (this.cache.has(cacheKey)) {
|
|
return this.cache.get(cacheKey);
|
|
}
|
|
|
|
const setting = await prisma.systemSetting.findUnique({
|
|
where: { key },
|
|
});
|
|
|
|
if (setting) {
|
|
this.cache.set(cacheKey, setting.value);
|
|
setTimeout(() => this.cache.delete(cacheKey), this.cacheExpiry);
|
|
return setting.value as T;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
clearCache() {
|
|
this.cache.clear();
|
|
}
|
|
}
|
|
|
|
export const configService = new ConfigService();
|
|
```
|
|
|
|
---
|
|
|
|
### **2. RMA Number Generator**
|
|
|
|
```typescript
|
|
// services/rma.service.ts
|
|
|
|
async function generateRMANumber(): Promise<string> {
|
|
const format = await configService.getSetting<string>('RMA_NUMBER_FORMAT');
|
|
|
|
if (!format) {
|
|
throw new Error('RMA_NUMBER_FORMAT not configured');
|
|
}
|
|
|
|
const today = new Date();
|
|
const year = today.getFullYear();
|
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
|
const day = String(today.getDate()).padStart(2, '0');
|
|
|
|
// Count today's RMAs
|
|
const count = await prisma.rma.count({
|
|
where: {
|
|
createdAt: {
|
|
gte: new Date(today.setHours(0, 0, 0, 0)),
|
|
lte: new Date(today.setHours(23, 59, 59, 999)),
|
|
},
|
|
},
|
|
});
|
|
|
|
const sequence = String(count + 1).padStart(2, '0');
|
|
|
|
// Replace placeholders
|
|
let rmaNumber = format
|
|
.replace('{YYYY}', String(year))
|
|
.replace('{YY}', String(year).slice(-2))
|
|
.replace('{MM}', month)
|
|
.replace('{DD}', day)
|
|
.replace('{XXX}', sequence.padStart(3, '0'))
|
|
.replace('{XX}', sequence);
|
|
|
|
return rmaNumber;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### **3. RMA Workflow Logic**
|
|
|
|
```typescript
|
|
// services/rma.service.ts
|
|
|
|
async function createRMA(data: CreateRMAInput, userId: string) {
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
include: { role: true }
|
|
});
|
|
|
|
// Check if requires approval (from settings)
|
|
let requiresApproval = false;
|
|
|
|
if (user.role.code === 'CUSTOMER') {
|
|
const setting = await configService.getSetting<boolean>(
|
|
'RMA_CUSTOMER_REQUIRES_APPROVAL'
|
|
);
|
|
requiresApproval = setting === true;
|
|
}
|
|
|
|
// Get initial status
|
|
const initialStatus = await prisma.rmaStatus.findFirst({
|
|
where: { isInitial: true, active: true },
|
|
});
|
|
|
|
if (!initialStatus) {
|
|
throw new Error('No initial RMA status configured');
|
|
}
|
|
|
|
// Get default solution
|
|
const defaultSolution = await prisma.rmaSolution.findFirst({
|
|
where: { code: 'ASSESSMENT', active: true },
|
|
});
|
|
|
|
// Generate RMA number
|
|
const rmaNumber = await generateRMANumber();
|
|
|
|
// Create RMA
|
|
const rma = await prisma.rma.create({
|
|
data: {
|
|
rmaNumber,
|
|
statusId: initialStatus.id,
|
|
proposedSolutionId: defaultSolution?.id,
|
|
requiresApproval,
|
|
createdById: userId,
|
|
...data,
|
|
},
|
|
include: {
|
|
status: true,
|
|
customer: true,
|
|
createdBy: true,
|
|
},
|
|
});
|
|
|
|
// Log activity
|
|
await prisma.activityLog.create({
|
|
data: {
|
|
userId,
|
|
action: 'CREATE',
|
|
entity: 'RMA',
|
|
entityId: rma.id,
|
|
changes: { rmaNumber, status: initialStatus.code },
|
|
},
|
|
});
|
|
|
|
// Create initial status history
|
|
await prisma.rmaStatusHistory.create({
|
|
data: {
|
|
rmaId: rma.id,
|
|
toStatusId: initialStatus.id,
|
|
changedById: userId,
|
|
notes: 'Reklamácia vytvorená',
|
|
},
|
|
});
|
|
|
|
// Notify admins if requires approval
|
|
if (requiresApproval) {
|
|
await notifyAdminsAboutNewRMA(rma);
|
|
}
|
|
|
|
return rma;
|
|
}
|
|
|
|
async function changeRMAStatus(
|
|
rmaId: string,
|
|
newStatusId: string,
|
|
userId: string,
|
|
notes?: string
|
|
) {
|
|
const rma = await prisma.rma.findUnique({
|
|
where: { id: rmaId },
|
|
include: { status: true },
|
|
});
|
|
|
|
if (!rma) {
|
|
throw new Error('RMA not found');
|
|
}
|
|
|
|
const newStatus = await prisma.rmaStatus.findUnique({
|
|
where: { id: newStatusId },
|
|
});
|
|
|
|
if (!newStatus) {
|
|
throw new Error('Status not found');
|
|
}
|
|
|
|
// Validate transition
|
|
const currentStatus = rma.status;
|
|
const allowedTransitions = currentStatus.canTransitionTo as string[];
|
|
|
|
if (allowedTransitions && !allowedTransitions.includes(newStatus.code)) {
|
|
throw new Error(
|
|
`Cannot transition from ${currentStatus.name} to ${newStatus.name}`
|
|
);
|
|
}
|
|
|
|
// Update RMA
|
|
const updatedRMA = await prisma.rma.update({
|
|
where: { id: rmaId },
|
|
data: {
|
|
statusId: newStatusId,
|
|
closedAt: newStatus.isFinal ? new Date() : null,
|
|
},
|
|
});
|
|
|
|
// Log status change
|
|
await prisma.rmaStatusHistory.create({
|
|
data: {
|
|
rmaId,
|
|
fromStatusId: currentStatus.id,
|
|
toStatusId: newStatusId,
|
|
changedById: userId,
|
|
notes,
|
|
},
|
|
});
|
|
|
|
// Activity log
|
|
await prisma.activityLog.create({
|
|
data: {
|
|
userId,
|
|
action: 'STATUS_CHANGE',
|
|
entity: 'RMA',
|
|
entityId: rmaId,
|
|
changes: {
|
|
from: currentStatus.code,
|
|
to: newStatus.code,
|
|
},
|
|
},
|
|
});
|
|
|
|
return updatedRMA;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### **4. Revision Auto-Calculation**
|
|
|
|
```typescript
|
|
// services/revision.service.ts
|
|
|
|
async function createRevision(data: CreateRevisionInput) {
|
|
const revisionType = await prisma.revisionType.findUnique({
|
|
where: { id: data.typeId },
|
|
});
|
|
|
|
if (!revisionType) {
|
|
throw new Error('Revision type not found');
|
|
}
|
|
|
|
// Auto-calculate nextDueDate
|
|
const nextDueDate = addDays(
|
|
data.performedDate,
|
|
revisionType.intervalDays
|
|
);
|
|
|
|
// Auto-calculate reminderDate
|
|
const reminderDate = subDays(
|
|
nextDueDate,
|
|
revisionType.reminderDays
|
|
);
|
|
|
|
// Create revision
|
|
const revision = await prisma.revision.create({
|
|
data: {
|
|
...data,
|
|
nextDueDate,
|
|
reminderDate,
|
|
},
|
|
include: {
|
|
equipment: true,
|
|
type: true,
|
|
},
|
|
});
|
|
|
|
// Schedule reminder job
|
|
await scheduleRevisionReminder(revision.id, reminderDate);
|
|
|
|
// Log activity
|
|
await prisma.activityLog.create({
|
|
data: {
|
|
userId: data.performedById,
|
|
action: 'CREATE',
|
|
entity: 'Revision',
|
|
entityId: revision.id,
|
|
changes: {
|
|
equipmentId: data.equipmentId,
|
|
type: revisionType.code,
|
|
nextDue: nextDueDate,
|
|
},
|
|
},
|
|
});
|
|
|
|
return revision;
|
|
}
|
|
|
|
async function getUpcomingRevisions(days: number = 30) {
|
|
const futureDate = addDays(new Date(), days);
|
|
|
|
return prisma.revision.findMany({
|
|
where: {
|
|
nextDueDate: {
|
|
gte: new Date(),
|
|
lte: futureDate,
|
|
},
|
|
reminderSent: false,
|
|
},
|
|
include: {
|
|
equipment: {
|
|
include: {
|
|
customer: true,
|
|
type: true,
|
|
},
|
|
},
|
|
type: true,
|
|
},
|
|
orderBy: {
|
|
nextDueDate: 'asc',
|
|
},
|
|
});
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### **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
|
|
|
|
interface ExternalCustomer {
|
|
id: string;
|
|
name: string;
|
|
address?: string;
|
|
email?: string;
|
|
phone?: string;
|
|
ico?: string;
|
|
}
|
|
|
|
async function importCustomersFromExternalDB() {
|
|
const dbType = process.env.EXTERNAL_DB_TYPE;
|
|
|
|
if (!dbType) {
|
|
throw new Error('External DB not configured');
|
|
}
|
|
|
|
// Connect to external DB (example: MySQL)
|
|
const externalDB = await createExternalConnection(dbType);
|
|
|
|
try {
|
|
// Query external DB
|
|
const externalCustomers: ExternalCustomer[] = await externalDB.query(
|
|
'SELECT id, name, address, email, phone, ico FROM customers'
|
|
);
|
|
|
|
console.log(`Found ${externalCustomers.length} customers in external DB`);
|
|
|
|
// Import to our DB
|
|
const imported = [];
|
|
|
|
for (const extCustomer of externalCustomers) {
|
|
// Check if already exists
|
|
const existing = await prisma.customer.findUnique({
|
|
where: { externalId: extCustomer.id },
|
|
});
|
|
|
|
if (existing) {
|
|
// Update existing
|
|
await prisma.customer.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
name: extCustomer.name,
|
|
address: extCustomer.address,
|
|
email: extCustomer.email,
|
|
phone: extCustomer.phone,
|
|
ico: extCustomer.ico,
|
|
},
|
|
});
|
|
} else {
|
|
// Create new
|
|
const customer = await prisma.customer.create({
|
|
data: {
|
|
name: extCustomer.name,
|
|
address: extCustomer.address,
|
|
email: extCustomer.email,
|
|
phone: extCustomer.phone,
|
|
ico: extCustomer.ico,
|
|
externalId: extCustomer.id,
|
|
externalSource: dbType.toUpperCase(),
|
|
createdById: 'system', // System user ID
|
|
},
|
|
});
|
|
|
|
imported.push(customer);
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: externalCustomers.length,
|
|
imported: imported.length,
|
|
updated: externalCustomers.length - imported.length,
|
|
};
|
|
|
|
} finally {
|
|
await externalDB.close();
|
|
}
|
|
}
|
|
|
|
// Cron job (Fáza 3)
|
|
cron.schedule('0 2 * * *', async () => {
|
|
// Daily at 2 AM
|
|
console.log('🔄 Starting customer import...');
|
|
const result = await importCustomersFromExternalDB();
|
|
console.log('✅ Import completed:', result);
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 📦 Projektová Štruktúra (Rozšírená)
|
|
|
|
```
|
|
helpdesk-system/
|
|
│
|
|
├── backend/
|
|
│ ├── src/
|
|
│ │ ├── config/
|
|
│ │ │ ├── database.ts
|
|
│ │ │ ├── jwt.ts
|
|
│ │ │ ├── email.ts # NEW
|
|
│ │ │ ├── external-db.ts # NEW
|
|
│ │ │ └── env.ts
|
|
│ │ │
|
|
│ │ ├── controllers/
|
|
│ │ │ ├── auth.controller.ts
|
|
│ │ │ ├── users.controller.ts
|
|
│ │ │ ├── projects.controller.ts
|
|
│ │ │ ├── tasks.controller.ts
|
|
│ │ │ ├── customers.controller.ts # NEW
|
|
│ │ │ ├── equipment.controller.ts # NEW
|
|
│ │ │ ├── revisions.controller.ts # NEW
|
|
│ │ │ ├── rma.controller.ts # NEW
|
|
│ │ │ ├── settings.controller.ts # NEW
|
|
│ │ │ └── dashboard.controller.ts
|
|
│ │ │
|
|
│ │ ├── services/
|
|
│ │ │ ├── auth.service.ts
|
|
│ │ │ ├── config.service.ts # NEW
|
|
│ │ │ ├── email.service.ts # NEW
|
|
│ │ │ ├── import.service.ts # NEW
|
|
│ │ │ ├── rma.service.ts # NEW
|
|
│ │ │ ├── revision.service.ts # NEW
|
|
│ │ │ ├── notification.service.ts
|
|
│ │ │ └── logger.service.ts
|
|
│ │ │
|
|
│ │ ├── middleware/
|
|
│ │ │ ├── auth.middleware.ts
|
|
│ │ │ ├── rbac.middleware.ts # Dynamic permissions
|
|
│ │ │ ├── validate.middleware.ts
|
|
│ │ │ ├── upload.middleware.ts # NEW
|
|
│ │ │ ├── errorHandler.ts
|
|
│ │ │ └── activityLog.middleware.ts
|
|
│ │ │
|
|
│ │ ├── routes/
|
|
│ │ │ ├── auth.routes.ts
|
|
│ │ │ ├── users.routes.ts
|
|
│ │ │ ├── projects.routes.ts
|
|
│ │ │ ├── tasks.routes.ts
|
|
│ │ │ ├── customers.routes.ts # NEW
|
|
│ │ │ ├── equipment.routes.ts # NEW
|
|
│ │ │ ├── revisions.routes.ts # NEW
|
|
│ │ │ ├── rma.routes.ts # NEW
|
|
│ │ │ ├── settings.routes.ts # NEW
|
|
│ │ │ ├── dashboard.routes.ts
|
|
│ │ │ └── index.ts
|
|
│ │ │
|
|
│ │ ├── utils/
|
|
│ │ │ ├── validators.ts
|
|
│ │ │ ├── helpers.ts
|
|
│ │ │ ├── pdf-generator.ts # NEW
|
|
│ │ │ └── constants.ts
|
|
│ │ │
|
|
│ │ ├── jobs/ # NEW (Fáza 2)
|
|
│ │ │ ├── reminder.job.ts
|
|
│ │ │ └── import.job.ts
|
|
│ │ │
|
|
│ │ └── index.ts
|
|
│ │
|
|
│ ├── prisma/
|
|
│ │ ├── schema.prisma
|
|
│ │ ├── seed.ts
|
|
│ │ └── migrations/
|
|
│ │
|
|
│ ├── tests/
|
|
│ ├── uploads/ # NEW
|
|
│ ├── .env.example
|
|
│ └── package.json
|
|
│
|
|
├── frontend/
|
|
│ ├── src/
|
|
│ │ ├── components/
|
|
│ │ │ ├── auth/
|
|
│ │ │ ├── layout/
|
|
│ │ │ ├── dashboard/
|
|
│ │ │ ├── swimlanes/
|
|
│ │ │ ├── tasks/
|
|
│ │ │ ├── projects/
|
|
│ │ │ ├── customers/ # NEW
|
|
│ │ │ ├── equipment/ # NEW
|
|
│ │ │ ├── rma/ # NEW
|
|
│ │ │ ├── settings/ # NEW
|
|
│ │ │ ├── shared/
|
|
│ │ │ └── ui/
|
|
│ │ │
|
|
│ │ ├── pages/
|
|
│ │ │ ├── LoginPage.tsx
|
|
│ │ │ ├── DashboardPage.tsx
|
|
│ │ │ ├── ProjectsPage.tsx
|
|
│ │ │ ├── TasksPage.tsx
|
|
│ │ │ ├── CustomersPage.tsx # NEW
|
|
│ │ │ ├── EquipmentPage.tsx # NEW
|
|
│ │ │ ├── RMAPage.tsx # NEW
|
|
│ │ │ ├── SettingsPage.tsx # NEW
|
|
│ │ │ └── NotFoundPage.tsx
|
|
│ │ │
|
|
│ │ ├── hooks/
|
|
│ │ │ ├── useAuth.ts
|
|
│ │ │ ├── useConfig.ts # NEW
|
|
│ │ │ ├── useProjects.ts
|
|
│ │ │ ├── useTasks.ts
|
|
│ │ │ ├── useEquipment.ts # NEW
|
|
│ │ │ ├── useRMA.ts # NEW
|
|
│ │ │ ├── useSnoozeOptions.ts # NEW (Fáza 2) - konfigurovateľné snooze možnosti
|
|
│ │ │ └── useKeyboard.ts
|
|
│ │ │
|
|
│ │ ├── services/
|
|
│ │ │ ├── api.ts
|
|
│ │ │ ├── auth.api.ts
|
|
│ │ │ ├── projects.api.ts
|
|
│ │ │ ├── tasks.api.ts
|
|
│ │ │ ├── customers.api.ts # NEW
|
|
│ │ │ ├── equipment.api.ts # NEW
|
|
│ │ │ ├── rma.api.ts # NEW
|
|
│ │ │ ├── settings.api.ts # NEW
|
|
│ │ │ └── notification.api.ts # NEW (Fáza 2)
|
|
│ │ │
|
|
│ │ ├── store/
|
|
│ │ │ ├── authStore.ts
|
|
│ │ │ ├── configStore.ts # NEW
|
|
│ │ │ ├── projectsStore.ts
|
|
│ │ │ ├── tasksStore.ts
|
|
│ │ │ └── notificationStore.ts # NEW (Fáza 2)
|
|
│ │ │
|
|
│ │ ├── types/
|
|
│ │ ├── styles/
|
|
│ │ ├── App.tsx
|
|
│ │ └── main.tsx
|
|
│ │
|
|
│ ├── tests/
|
|
│ └── package.json
|
|
│
|
|
├── docker/
|
|
│ ├── Dockerfile.backend
|
|
│ ├── Dockerfile.frontend
|
|
│ └── docker-compose.yml
|
|
│
|
|
├── monitoring/ # NEW (Self-hosted)
|
|
│ ├── prometheus.yml
|
|
│ ├── grafana-dashboards/
|
|
│ ├── loki-config.yml
|
|
│ └── promtail-config.yml
|
|
│
|
|
├── docs/
|
|
│ ├── API.md
|
|
│ ├── DATABASE.md
|
|
│ ├── SETTINGS.md # NEW
|
|
│ ├── DEPLOYMENT.md
|
|
│ └── USER_GUIDE.md
|
|
│
|
|
├── .github/
|
|
│ └── workflows/
|
|
│ ├── ci.yml
|
|
│ └── cd.yml
|
|
│
|
|
├── README.md
|
|
└── LICENSE
|
|
```
|
|
|
|
---
|
|
|
|
## 🐳 Docker Compose (Self-Hosted All-in-One)
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
# ===== DATABASE =====
|
|
postgres:
|
|
image: postgres:16-alpine
|
|
restart: always
|
|
environment:
|
|
POSTGRES_DB: helpdesk
|
|
POSTGRES_USER: helpdesk
|
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
- ./backups:/backups
|
|
ports:
|
|
- "5432:5432"
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U helpdesk"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
# ===== CACHE =====
|
|
redis:
|
|
image: redis:7-alpine
|
|
restart: always
|
|
command: redis-server --appendonly yes
|
|
volumes:
|
|
- redis_data:/data
|
|
ports:
|
|
- "6379:6379"
|
|
|
|
# ===== BACKEND =====
|
|
backend:
|
|
build: ./backend
|
|
restart: always
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_started
|
|
environment:
|
|
DATABASE_URL: postgresql://helpdesk:${DB_PASSWORD}@postgres:5432/helpdesk
|
|
REDIS_URL: redis://redis:6379
|
|
JWT_SECRET: ${JWT_SECRET}
|
|
NODE_ENV: production
|
|
volumes:
|
|
- ./uploads:/app/uploads
|
|
ports:
|
|
- "3001:3001"
|
|
|
|
# ===== FRONTEND =====
|
|
frontend:
|
|
image: nginx:alpine
|
|
restart: always
|
|
volumes:
|
|
- ./frontend/dist:/usr/share/nginx/html
|
|
- ./nginx.conf:/etc/nginx/nginx.conf
|
|
- ./ssl:/etc/nginx/ssl
|
|
ports:
|
|
- "80:80"
|
|
- "443:443"
|
|
|
|
# ===== EMAIL (Self-hosted) =====
|
|
postfix:
|
|
image: boky/postfix:latest
|
|
restart: always
|
|
environment:
|
|
ALLOWED_SENDER_DOMAINS: vasadomena.sk
|
|
ports:
|
|
- "25:25"
|
|
|
|
# ===== MONITORING =====
|
|
prometheus:
|
|
image: prom/prometheus:latest
|
|
restart: always
|
|
volumes:
|
|
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
|
|
- prometheus_data:/prometheus
|
|
ports:
|
|
- "9090:9090"
|
|
|
|
grafana:
|
|
image: grafana/grafana:latest
|
|
restart: always
|
|
volumes:
|
|
- grafana_data:/var/lib/grafana
|
|
- ./monitoring/grafana-dashboards:/etc/grafana/provisioning/dashboards
|
|
environment:
|
|
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_PASSWORD}
|
|
ports:
|
|
- "3000:3000"
|
|
|
|
loki:
|
|
image: grafana/loki:latest
|
|
restart: always
|
|
volumes:
|
|
- loki_data:/loki
|
|
- ./monitoring/loki-config.yml:/etc/loki/local-config.yaml
|
|
ports:
|
|
- "3100:3100"
|
|
|
|
promtail:
|
|
image: grafana/promtail:latest
|
|
restart: always
|
|
volumes:
|
|
- /var/log:/var/log:ro
|
|
- ./monitoring/promtail-config.yml:/etc/promtail/config.yml
|
|
|
|
node-exporter:
|
|
image: prom/node-exporter:latest
|
|
restart: always
|
|
volumes:
|
|
- /proc:/host/proc:ro
|
|
- /sys:/host/sys:ro
|
|
- /:/rootfs:ro
|
|
command:
|
|
- '--path.procfs=/host/proc'
|
|
- '--path.sysfs=/host/sys'
|
|
|
|
cadvisor:
|
|
image: gcr.io/cadvisor/cadvisor:latest
|
|
restart: always
|
|
volumes:
|
|
- /:/rootfs:ro
|
|
- /var/run:/var/run:ro
|
|
- /sys:/sys:ro
|
|
- /var/lib/docker/:/var/lib/docker:ro
|
|
|
|
volumes:
|
|
postgres_data:
|
|
redis_data:
|
|
prometheus_data:
|
|
grafana_data:
|
|
loki_data:
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Metriky Úspechu
|
|
|
|
### **MVP (Fáza 1):**
|
|
- [ ] Používateľ sa vie prihlásiť/odhlásiť
|
|
- [ ] ROOT vie konfigurovať všetky typy cez Settings
|
|
- [ ] Vytvoriť projekt, úlohu, zákazníka
|
|
- [ ] Vytvoriť zariadenie (basic)
|
|
- [ ] Vytvoriť RMA (basic)
|
|
- [ ] Activity log funguje
|
|
- [ ] RBAC s dynamickými rolami
|
|
|
|
### **Fáza 2:**
|
|
- [ ] Swimlanes board funguje
|
|
- [ ] Revízny systém s auto-kalkuláciou
|
|
- [ ] RMA workflow s approval
|
|
- [ ] Email notifikácie
|
|
- [ ] File uploads
|
|
- [ ] WebSocket updates
|
|
|
|
### **Fáza 3:**
|
|
- [ ] Import zákazníkov z externej DB
|
|
- [ ] Export PDF/Excel
|
|
- [ ] Komplexné reporty
|
|
- [ ] Performance optimalizovaný
|
|
|
|
---
|
|
|
|
## 🔧 Náklady (Self-Hosted)
|
|
|
|
```
|
|
VPS/Vlastný server: €0 (vlastný hardware)
|
|
Elektrina: ~€10/mesiac
|
|
Doména: €10/rok
|
|
SSL: €0 (Let's Encrypt)
|
|
Monitoring: €0 (self-hosted)
|
|
Email: €0 (Postfix)
|
|
Backup: €0 (cron + rsync)
|
|
|
|
CELKOM: ~€10-15/mesiac
|
|
```
|
|
|
|
---
|
|
|
|
## ✅ Záver
|
|
|
|
**Helpdesk System V2** je:
|
|
|
|
✅ **Plne konfigurovateľný** - žiadne hardcoded hodnoty
|
|
✅ **Dynamický** - zmeny cez GUI, žiadne migrácie
|
|
✅ **Škálovateľný** - pripravený na rast
|
|
✅ **Self-hosted** - 100% kontrola, žiadne závislosti
|
|
✅ **Bezpečný** - RBAC, audit log, JWT
|
|
✅ **Moderný** - TypeScript, React, Prisma
|
|
|
|
**Next Steps:**
|
|
1. Vytvorte repozitár
|
|
2. Inicializujte backend (Fáza 1)
|
|
3. Inicializujte frontend (Fáza 1)
|
|
4. Deploy s Docker Compose
|
|
5. Testujte a iterujte
|
|
|
|
**Good luck! 🚀**
|
|
|
|
---
|
|
|
|
*Dokument vytvorený: 02.02.2026*
|
|
*Posledná aktualizácia: 19.02.2026*
|
|
*Verzia: 2.2.0*
|
|
*Autor: Claude (Anthropic) + Používateľ*
|