Files
helpdesk-texnet/HELPDESK_INIT_V2.md
pettrop da265ff097 Revízny systém - kompletná implementácia
- Backend: CRUD revízií, schedule endpoint (agregovaný plán), skip revízia, stats
- Shared utility revisionSchedule.ts - centralizovaná logika výpočtu cyklov
- Equipment detail s revíznym plánom, históriou a prílohami
- Frontend: RevisionsList s tabmi (nadchádzajúce/po termíne/vykonané/preskočené)
- Pozičné labeling cyklov (eliminuje drift 4×90≠365)
- EquipmentRevisionSchedule model (many-to-many typy revízií)
- Aktualizovaná dokumentácia HELPDESK_INIT_V2.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 21:59:23 +01:00

80 KiB
Raw Permalink Blame History

🎯 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: 23.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é)

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

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[]
  equipmentSchedules  EquipmentRevisionSchedule[]

  @@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?
  revisionCycleStart DateTime?  // Anchor pre výpočet revíznych cyklov (default = installDate)
  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[]
  revisionSchedules  EquipmentRevisionSchedule[]  // Priradené typy revízií
  attachments        EquipmentAttachment[]
  tags               EquipmentTag[]
  
  @@index([typeId])
  @@index([customerId])
  @@index([warrantyEnd])
  @@index([active])
  @@index([createdById])
}

// Priradenie revíznych typov k zariadeniu (many-to-many)
model EquipmentRevisionSchedule {
  id              String       @id @default(cuid())

  equipmentId     String
  equipment       Equipment    @relation(fields: [equipmentId], references: [id], onDelete: Cascade)

  revisionTypeId  String
  revisionType    RevisionType @relation(fields: [revisionTypeId], references: [id])

  createdAt       DateTime     @default(now())

  @@unique([equipmentId, revisionTypeId])
  @@index([equipmentId])
  @@index([revisionTypeId])
}

model Revision {
  id            String       @id @default(cuid())

  equipmentId   String
  equipment     Equipment    @relation(fields: [equipmentId], references: [id], onDelete: Cascade)

  typeId        String
  type          RevisionType @relation(fields: [typeId], references: [id])

  status        String       @default("performed")  // "performed" | "skipped"

  performedDate DateTime
  nextDueDate   DateTime?    // Auto-calculated z cyklového anchoru

  performedById String
  performedBy   User         @relation(fields: [performedById], references: [id])

  findings      String?      @db.Text
  result        String?      // "OK", "MINOR_ISSUES", "CRITICAL"
  notes         String?      @db.Text
  skipReason    String?      // Dôvod preskočenia (ak status = "skipped")

  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])
  @@index([status])
}

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)

// 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                     // Zoznam zariadení (stránkovaný, search, filtre)
POST   /api/equipment                     // Vytvorenie zariadenia + revisionSchedules
GET    /api/equipment/reminders           // Upcoming revisions (PRED /:id!)
GET    /api/equipment/:id                 // Detail zariadenia
PUT    /api/equipment/:id                 // Úprava zariadenia + revisionSchedules
DELETE /api/equipment/:id                 // Soft delete (deaktivácia)
GET    /api/equipment/:id/schedule        // Revízny plán zariadenia (nadchádzajúce dátumy)
GET    /api/equipment/:id/revisions       // História revízií zariadenia
POST   /api/equipment/:id/revisions       // Pridanie revízie cez equipment detail
GET    /api/equipment/:id/files           // Zoznam príloh
POST   /api/equipment/:id/files           // Upload príloh (max 10)
DELETE /api/equipment/:id/files/:fileId   // Zmazanie prílohy

🆕 Revisions

GET    /api/revisions                     // Zoznam revízií (stránkovaný, status filter, search)
GET    /api/revisions/stats               // Štatistiky: { upcoming, overdue, performed, skipped }
GET    /api/revisions/schedule             // Agregovaný plán zo VŠETKÝCH zariadení (view=upcoming|overdue)
POST   /api/revisions/skip                // Preskočenie plánovanej revízie (s dôvodom)
GET    /api/revisions/:id                 // Detail revízie
POST   /api/revisions                     // Vytvorenie revízie
PUT    /api/revisions/:id                 // Úprava revízie
DELETE /api/revisions/:id                 // Zmazanie revízie

Query parametre pre /schedule: view (upcoming|overdue), typeId, customerId, search, page, limit

Body pre /skip:

{
  "equipmentId": "string",
  "typeId": "string",
  "scheduledDate": "ISO date string",
  "skipReason": "string (optional)"
}

🆕 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

🔧 Revízny Systém - Architektúra

Princípy

  1. Cyklový anchor - Revízne termíny sú ukotvené na revisionCycleStart (alebo installDate). Cyklus sa NEposúva keď sa revízia vykoná po termíne.
  2. Pozičné labeling - Cyklové body sa generujú LEN z najkratšieho intervalu (napr. štvrťročný = 90 dní). Pozícia v ročnom cykle určuje label: 1., 2., 3. Štvrťročná, alebo Ročná pri 4. pozícii. Toto eliminuje drift problém (4×90≠365).
  3. Pokrývajúce typy - Dlhší interval pokrýva kratší (ročná pokrýva štvrťročnú). Po ročnej revízii sa nextDueDate počíta z najkratšieho intervalu.
  4. Skip mechanizmus - Plánovaná revízia sa dá preskočiť s dôvodom. Vytvorí sa záznam so status: "skipped".

Seed dáta - Typy revízií

MONTHLY      - Mesačná revízia     (30 dní, reminder 7 dní)
QUARTERLY    - Štvrťročná revízia  (90 dní, reminder 14 dní)
BIANNUAL     - Polročná revízia    (180 dní, reminder 21 dní)
ANNUAL       - Ročná revízia       (365 dní, reminder 30 dní)
BIENNIAL     - Dvojročná revízia   (730 dní, reminder 60 dní)
TRIENNIAL    - Trojročná revízia   (1095 dní, reminder 90 dní)
QUADRENNIAL  - Štvorročná revízia  (1460 dní, reminder 90 dní)
QUINQUENNIAL - Päťročná revízia    (1825 dní, reminder 90 dní)
EMERGENCY    - Mimoriadna revízia  (0 dní, bez opakovania)

Seed dáta - Typy zariadení

EPS          - Elektrická požiarna signalizácia
HSP          - Hlasová signalizácia požiaru
EZS          - Elektronický zabezpečovací systém
BLESKOZVOD   - Bleskozvod

Shared utility: backend/src/utils/revisionSchedule.ts

Centralizovaná logika zdieľaná medzi equipment.controller.ts a revisions.controller.ts:

Funkcia Popis
calculateNextDueDateFromInstall() Výpočet nasledujúceho termínu z anchor dátumu a intervalu
detectSkippedCycles() Detekcia preskočených cyklových bodov
computeFirstUpcomingCycleDate() Prvý nadchádzajúci cyklový bod po dátume
getCoveringTypeIds() Nájde typy s dlhším intervalom (pokrývajúce)
calculateReminderDate() Dátum pripomienky = nextDueDate - reminderDays
getShortestIntervalForEquipment() Najkratší interval zo všetkých plánov zariadenia
computeNextDueAndReminder() Kompletný výpočet nextDueDate + reminderDate
mergeOverlappingItems() Zlúčenie prekrývajúcich sa termínov rôznych typov

Equipment Schedule (GET /api/equipment/:id/schedule)

Vracia:

  • schedules - Stav každého priradeného revízneho typu (posledná revízia, nasledujúci termín)
  • upcomingDates - Nadchádzajúce dátumy s pozičným labelom (lookAhead = 365 dní)
  • revisionHistory - Posledných N vykonaných/preskočených revízií

Revisions Schedule (GET /api/revisions/schedule)

Agregovaný plán zo VŠETKÝCH zariadení. Pre každý equipment+revisionType pár:

  • Ak existuje posledná revízia → dueDate = latestRevision.nextDueDate
  • Ak neexistuje → vypočíta sa prvý cyklový bod po dnešku z anchoru

Filtrovateľné podľa: view (upcoming/overdue), typeId, customerId, search


🎨 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         # Detail s revíznym plánom, históriou, prílohami
│   │   ├── EquipmentForm.tsx           # Formulár s revisionSchedules (typ revízií)
│   │   └── RevisionForm.tsx
│   │
│   ├── revisions/                      # NEW
│   │   ├── RevisionsList.tsx           # Hlavný zoznam s tabmi (upcoming/overdue/performed/skipped)
│   │   └── RevisionForm.tsx            # Formulár pre pridanie/editáciu revízie
│   │
│   ├── 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)
│
├── hooks/
│   ├── useRevisionStatus.ts            # Hook pre stav revízie (schedule/performed/skipped)
│   └── useSnoozeOptions.ts
│
├── services/
│   ├── equipment.api.ts
│   ├── revisions.api.ts                # API pre revízie (CRUD, schedule, skip, stats)
│   ├── settings.api.ts
│   └── ...
│
└── types/
    └── index.ts                        # Obsahuje Revision, RevisionScheduleItem, RevisionStats

🚀 Development Fázy (Aktualizované)

FÁZA 1: MVP + Configuration Foundation DOKONČENÁ

Cieľ: Základná funkcionalita + dynamická konfigurácia

Backend:

  • Setup projektu (Express, Prisma, PostgreSQL)
  • Databázová schéma V2 (všetky configuration tables)
  • JWT Auth (access + refresh tokens)
  • RBAC middleware (dynamic permissions)
  • User CRUD + /users/simple endpoint (server-side search)
  • User Roles CRUD (dynamické role)
  • Project CRUD (s fallback pre default status)
  • Task CRUD (s fallback pre default status/priority)
  • Task Comments (s kontrolou oprávnení - len autor/priradený)
  • Customer CRUD
  • Equipment CRUD (s revíznymi plánmi a prílohami)
  • RMA CRUD (basic, bez workflow)
  • Settings API (CRUD pre všetky config tables)
  • Dashboard API (/dashboard, /dashboard/today, /dashboard/stats)
  • Activity logging
  • Seed data (default config)

Frontend:

  • Setup (React 18, Vite, TypeScript, Tailwind CSS v4)
  • Auth pages (login s redirect po prihlásení)
  • Layout (header, sidebar, MainLayout)
  • Dashboard (štatistické karty + moje úlohy + moje projekty)
  • Project list/create/edit (s error handling)
  • Task list/create/edit (so stĺpcom "Zadal")
  • 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)
  • UserSelect - server-side vyhľadávanie používateľov (debounce 300ms)
  • Customer list/create/edit
  • Equipment list/create (basic form)
  • RMA list/create (basic form)
  • Settings Dashboard (ROOT only)
    • Equipment Types
    • Revision Types
    • RMA Statuses/Solutions
    • Task Statuses
    • Priorities
    • User Roles
  • Basic forms (React Hook Form + Zod) s lepším error handling
  • Toast notifications (react-hot-toast)
  • 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 (s revíznymi plánmi)
✅ 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:

# 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
    • CRUD endpoints (create, read, update, delete)
    • Auto-calculate nextDueDate (cyklový anchor, pozičné labeling)
    • Skip revision endpoint (preskočenie s dôvodom)
    • Agregovaný schedule endpoint (zo všetkých zariadení)
    • Stats endpoint (upcoming, overdue, performed, skipped)
    • Shared utility revisionSchedule.ts
    • Equipment schedule endpoint (nadchádzajúce dátumy s labelmi)
    • Reminder scheduler (cron job na posielanie pripomienok)
  • 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)
  • File upload handling
  • Notification system
    • Notification model (Prisma)
    • notification.service.ts - CRUD, enrichment komentárov
    • notifyTaskComment - ukladá len commentId (žiadna duplicita)
    • notifyTaskStatusChange - ukladá oldStatus, newStatus, actorName
    • notifyTaskAssignment
    • Snooze funkcionalita s konfigurovateľnými možnosťami
    • SystemSetting NOTIFICATION_SNOOZE_OPTIONS

Frontend:

  • Swimlanes Board (dnd-kit)
    • Project swimlanes
    • Drag & Drop
    • Collapse/expand
    • Progress indicators
  • Equipment Management
    • EquipmentList - zoznam zariadení s filtrami
    • EquipmentForm - formulár s revisionSchedules
    • EquipmentDetail - detail s revíznym plánom, históriou, prílohami
    • RevisionForm - formulár pre pridanie/editáciu revízie
    • Revízny plán s pozičným labelingom
    • File attachments
  • Revisions Management
    • RevisionsList - tabmi (nadchádzajúce, po termíne, vykonané, preskočené)
    • Filtrovacie tlačidlá podľa typu revízie
    • Preskočenie revízie s dôvodom
    • Vykonanie revízie z plánu (predvyplnený formulár)
    • useRevisionStatus hook
  • RMA Workflow
    • Status change UI
    • Approval buttons (admin)
    • File attachments
    • Comments
    • PDF export
  • Inline Quick Actions
  • Quick Search (Ctrl+K)
  • Reminder management UI
  • Filters & tags
  • Real-time updates (WebSocket)
  • Notification UI
    • NotificationCenter komponent (zvonček v header)
    • Dashboard - prehľadné zobrazenie notifikácií
    • Typ notifikácie + relatívny čas
    • Názov úlohy + projekt
    • Detail zmeny/komentára + autor
    • markAsRead pri akcii (komentár/zmena stavu)
    • Snooze dropdown s konfigurovateľnými možnosťami
    • useSnoozeOptions hook (načíta z SystemSettings)

Deliverable:

✅ Všetko z Fázy 1 +
⏳ Swimlanes board
✅ Revízny systém (CRUD, plán, skip, schedule, stats)
✅ Equipment management (detail, plán, história, prílohy)
⏳ 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)

# 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)

// 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)

// 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

// 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

// 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

// 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)

// 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)

// 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)

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ľ