Initial commit: Helpdesk application setup
- Backend: Node.js/TypeScript with Prisma ORM - Frontend: Vite + TypeScript - Project configuration and documentation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.output/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# TypeScript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
.tmp/
|
||||||
|
|
||||||
|
# Uploads (keep structure, ignore content)
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.sqlite
|
||||||
|
*.db
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/*.db
|
||||||
|
prisma/*.db-journal
|
||||||
2533
HELPDESK_INIT_V2.md
Normal file
2533
HELPDESK_INIT_V2.md
Normal file
File diff suppressed because it is too large
Load Diff
19
backend/.env.example
Normal file
19
backend/.env.example
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="postgresql://user:password@localhost:5432/helpdesk_db"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET="your-super-secret-key-change-this"
|
||||||
|
JWT_REFRESH_SECRET="your-refresh-secret-change-this"
|
||||||
|
JWT_EXPIRES_IN="15m"
|
||||||
|
JWT_REFRESH_EXPIRES_IN="7d"
|
||||||
|
|
||||||
|
# Server
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
# Frontend URL (for CORS)
|
||||||
|
FRONTEND_URL="http://localhost:5173"
|
||||||
|
|
||||||
|
# File Upload
|
||||||
|
UPLOAD_DIR="./uploads"
|
||||||
|
MAX_FILE_SIZE=10485760
|
||||||
34
backend/.gitignore
vendored
Normal file
34
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Prisma
|
||||||
|
prisma/migrations/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Uploads (keep folder structure)
|
||||||
|
uploads/*
|
||||||
|
!uploads/.gitkeep
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
coverage/
|
||||||
23
backend/docker-compose.yml
Normal file
23
backend/docker-compose.yml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: helpdesk-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: helpdesk_db
|
||||||
|
POSTGRES_USER: helpdesk
|
||||||
|
POSTGRES_PASSWORD: helpdesk123
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5433:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U helpdesk -d helpdesk_db"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
2092
backend/package-lock.json
generated
Normal file
2092
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
backend/package.json
Normal file
53
backend/package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "helpdesk-backend",
|
||||||
|
"version": "2.0.0",
|
||||||
|
"description": "Helpdesk & Task Management System Backend",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"prisma": {
|
||||||
|
"seed": "ts-node prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:generate": "prisma generate",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"helpdesk",
|
||||||
|
"task-management",
|
||||||
|
"typescript",
|
||||||
|
"express",
|
||||||
|
"prisma"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^5.22.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"helmet": "^8.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
|
"uuid": "^13.0.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^5.0.6",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/morgan": "^1.9.10",
|
||||||
|
"@types/node": "^25.2.0",
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
709
backend/prisma/schema.prisma
Normal file
709
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USER ROLES (dynamicke) ====================
|
||||||
|
|
||||||
|
model UserRole {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
|
||||||
|
permissions Json
|
||||||
|
|
||||||
|
level Int
|
||||||
|
order Int @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
users User[]
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
@@index([level])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== USERS ====================
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
password String
|
||||||
|
name String
|
||||||
|
|
||||||
|
roleId String
|
||||||
|
role UserRole @relation(fields: [roleId], references: [id])
|
||||||
|
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
ownedProjects Project[] @relation("ProjectOwner")
|
||||||
|
assignedProjects ProjectMember[]
|
||||||
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
|
assignedTasks TaskAssignee[]
|
||||||
|
reminders Reminder[]
|
||||||
|
activityLogs ActivityLog[]
|
||||||
|
|
||||||
|
createdEquipment Equipment[] @relation("EquipmentCreator")
|
||||||
|
performedRevisions Revision[]
|
||||||
|
uploadedEquipmentFiles EquipmentAttachment[]
|
||||||
|
|
||||||
|
assignedRMAs RMA[] @relation("RMAAssignee")
|
||||||
|
createdRMAs RMA[] @relation("RMACreator")
|
||||||
|
approvedRMAs RMA[] @relation("RMAApprover")
|
||||||
|
rmaAttachments RMAAttachment[]
|
||||||
|
rmaStatusChanges RMAStatusHistory[]
|
||||||
|
rmaComments RMAComment[]
|
||||||
|
taskComments Comment[]
|
||||||
|
|
||||||
|
createdCustomers Customer[]
|
||||||
|
|
||||||
|
@@index([email])
|
||||||
|
@@index([roleId])
|
||||||
|
@@index([active])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CONFIGURATION TABLES ====================
|
||||||
|
|
||||||
|
model EquipmentType {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
color String?
|
||||||
|
icon String?
|
||||||
|
order Int @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
equipment Equipment[]
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
@@index([order])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RevisionType {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
intervalDays Int
|
||||||
|
reminderDays Int @default(14)
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RMAStatus {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
color String?
|
||||||
|
icon String?
|
||||||
|
order Int @default(0)
|
||||||
|
|
||||||
|
isInitial Boolean @default(false)
|
||||||
|
isFinal Boolean @default(false)
|
||||||
|
canTransitionTo Json?
|
||||||
|
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
rmas RMA[]
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
@@index([order])
|
||||||
|
@@index([isInitial])
|
||||||
|
@@index([isFinal])
|
||||||
|
}
|
||||||
|
|
||||||
|
model RMASolution {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TaskStatus {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
color String?
|
||||||
|
icon String?
|
||||||
|
order Int @default(0)
|
||||||
|
|
||||||
|
swimlaneColumn String?
|
||||||
|
|
||||||
|
isInitial Boolean @default(false)
|
||||||
|
isFinal Boolean @default(false)
|
||||||
|
canTransitionTo Json?
|
||||||
|
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tasks Task[]
|
||||||
|
projects Project[]
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
@@index([swimlaneColumn])
|
||||||
|
@@index([order])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Priority {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
color String?
|
||||||
|
icon String?
|
||||||
|
level Int
|
||||||
|
order Int @default(0)
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
tasks Task[]
|
||||||
|
|
||||||
|
@@index([active])
|
||||||
|
@@index([level])
|
||||||
|
@@index([order])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
color String?
|
||||||
|
|
||||||
|
entityType String
|
||||||
|
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
model SystemSetting {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
key String @unique
|
||||||
|
value Json
|
||||||
|
|
||||||
|
category String
|
||||||
|
label String
|
||||||
|
description String?
|
||||||
|
dataType String
|
||||||
|
|
||||||
|
validation Json?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([category])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== CUSTOMERS ====================
|
||||||
|
|
||||||
|
model Customer {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
name String
|
||||||
|
address String?
|
||||||
|
email String?
|
||||||
|
phone String?
|
||||||
|
ico String?
|
||||||
|
dic String?
|
||||||
|
icdph String?
|
||||||
|
|
||||||
|
contactPerson String?
|
||||||
|
contactEmail String?
|
||||||
|
contactPhone String?
|
||||||
|
|
||||||
|
externalId String? @unique
|
||||||
|
externalSource String?
|
||||||
|
|
||||||
|
notes String?
|
||||||
|
active Boolean @default(true)
|
||||||
|
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation(fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
statusId String
|
||||||
|
status TaskStatus @relation(fields: [statusId], references: [id])
|
||||||
|
|
||||||
|
softDeadline DateTime?
|
||||||
|
hardDeadline DateTime?
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
statusId String
|
||||||
|
status TaskStatus @relation(fields: [statusId], references: [id])
|
||||||
|
|
||||||
|
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[]
|
||||||
|
|
||||||
|
@@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
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([taskId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== EQUIPMENT MANAGEMENT ====================
|
||||||
|
|
||||||
|
model Equipment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
name String
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
partNumber String?
|
||||||
|
serialNumber String?
|
||||||
|
|
||||||
|
installDate DateTime?
|
||||||
|
warrantyEnd DateTime?
|
||||||
|
warrantyStatus String?
|
||||||
|
|
||||||
|
description String?
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
typeId String
|
||||||
|
type RevisionType @relation(fields: [typeId], references: [id])
|
||||||
|
|
||||||
|
performedDate DateTime
|
||||||
|
nextDueDate DateTime?
|
||||||
|
|
||||||
|
performedById String
|
||||||
|
performedBy User @relation(fields: [performedById], references: [id])
|
||||||
|
|
||||||
|
findings String?
|
||||||
|
result String?
|
||||||
|
notes String?
|
||||||
|
|
||||||
|
reminderSent Boolean @default(false)
|
||||||
|
reminderDate DateTime?
|
||||||
|
|
||||||
|
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 (REKLAMACIE) ====================
|
||||||
|
|
||||||
|
model RMA {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
rmaNumber String @unique
|
||||||
|
|
||||||
|
customerId String?
|
||||||
|
customer Customer? @relation(fields: [customerId], references: [id])
|
||||||
|
|
||||||
|
customerName String?
|
||||||
|
customerAddress String?
|
||||||
|
customerEmail String?
|
||||||
|
customerPhone String?
|
||||||
|
customerICO String?
|
||||||
|
submittedBy String
|
||||||
|
|
||||||
|
productName String
|
||||||
|
invoiceNumber String?
|
||||||
|
purchaseDate DateTime?
|
||||||
|
productNumber String?
|
||||||
|
serialNumber String?
|
||||||
|
accessories String?
|
||||||
|
|
||||||
|
issueDescription String
|
||||||
|
|
||||||
|
statusId String
|
||||||
|
status RMAStatus @relation(fields: [statusId], references: [id])
|
||||||
|
|
||||||
|
proposedSolutionId String?
|
||||||
|
proposedSolution RMASolution? @relation(fields: [proposedSolutionId], references: [id])
|
||||||
|
|
||||||
|
requiresApproval Boolean @default(false)
|
||||||
|
approvedById String?
|
||||||
|
approvedBy User? @relation("RMAApprover", fields: [approvedById], references: [id])
|
||||||
|
approvedAt DateTime?
|
||||||
|
|
||||||
|
receivedDate DateTime?
|
||||||
|
receivedLocation String?
|
||||||
|
internalNotes String?
|
||||||
|
|
||||||
|
resolutionDate DateTime?
|
||||||
|
resolutionNotes String?
|
||||||
|
|
||||||
|
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[]
|
||||||
|
|
||||||
|
@@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?
|
||||||
|
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
|
||||||
|
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
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
|
||||||
|
entity String
|
||||||
|
entityId String
|
||||||
|
|
||||||
|
changes Json?
|
||||||
|
ipAddress String?
|
||||||
|
userAgent String?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
@@index([entity, entityId])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
260
backend/prisma/seed.ts
Normal file
260
backend/prisma/seed.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
// ===== USER ROLES =====
|
||||||
|
console.log('Creating user roles...');
|
||||||
|
const roles = await Promise.all([
|
||||||
|
prisma.userRole.upsert({
|
||||||
|
where: { code: 'ROOT' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'ROOT',
|
||||||
|
name: 'Root Správca',
|
||||||
|
level: 1,
|
||||||
|
order: 1,
|
||||||
|
permissions: {
|
||||||
|
projects: ['*'],
|
||||||
|
tasks: ['*'],
|
||||||
|
equipment: ['*'],
|
||||||
|
rma: ['*'],
|
||||||
|
customers: ['*'],
|
||||||
|
settings: ['*'],
|
||||||
|
users: ['*'],
|
||||||
|
logs: ['*'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.userRole.upsert({
|
||||||
|
where: { code: 'ADMIN' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'ADMIN',
|
||||||
|
name: 'Administrátor',
|
||||||
|
level: 2,
|
||||||
|
order: 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.upsert({
|
||||||
|
where: { code: 'USER' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'USER',
|
||||||
|
name: 'Používateľ',
|
||||||
|
level: 3,
|
||||||
|
order: 3,
|
||||||
|
permissions: {
|
||||||
|
projects: ['read', 'update'],
|
||||||
|
tasks: ['create', 'read', 'update'],
|
||||||
|
equipment: ['read', 'update'],
|
||||||
|
rma: ['create', 'read', 'update'],
|
||||||
|
customers: ['read'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.userRole.upsert({
|
||||||
|
where: { code: 'CUSTOMER' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
code: 'CUSTOMER',
|
||||||
|
name: 'Zákazník',
|
||||||
|
level: 4,
|
||||||
|
order: 4,
|
||||||
|
permissions: {
|
||||||
|
projects: ['read'],
|
||||||
|
tasks: ['read'],
|
||||||
|
equipment: ['read'],
|
||||||
|
rma: ['create', 'read'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ===== EQUIPMENT TYPES =====
|
||||||
|
console.log('Creating equipment types...');
|
||||||
|
await prisma.equipmentType.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
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 =====
|
||||||
|
console.log('Creating revision types...');
|
||||||
|
await prisma.revisionType.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
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 =====
|
||||||
|
console.log('Creating RMA statuses...');
|
||||||
|
await prisma.rMAStatus.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
data: [
|
||||||
|
{ code: 'NEW', name: 'Nová reklamácia', color: '#10B981', isInitial: true, canTransitionTo: ['IN_ASSESSMENT', 'REJECTED'], order: 1 },
|
||||||
|
{ code: 'IN_ASSESSMENT', name: 'V posúdzovaní', color: '#F59E0B', canTransitionTo: ['APPROVED', 'REJECTED'], order: 2 },
|
||||||
|
{ code: 'APPROVED', name: 'Schválená', color: '#3B82F6', canTransitionTo: ['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: ['REPAIRED', 'COMPLETED'], order: 5 },
|
||||||
|
{ code: 'REPAIRED', name: 'Opravené', color: '#059669', canTransitionTo: ['COMPLETED'], order: 6 },
|
||||||
|
{ code: 'REPLACED', name: 'Vymenené', color: '#059669', canTransitionTo: ['COMPLETED'], order: 7 },
|
||||||
|
{ code: 'REFUNDED', name: 'Vrátené peniaze', color: '#059669', canTransitionTo: ['COMPLETED'], order: 8 },
|
||||||
|
{ code: 'COMPLETED', name: 'Uzatvorená', color: '#059669', isFinal: true, order: 9 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== RMA SOLUTIONS =====
|
||||||
|
console.log('Creating RMA solutions...');
|
||||||
|
await prisma.rMASolution.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
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 =====
|
||||||
|
console.log('Creating task statuses...');
|
||||||
|
await prisma.taskStatus.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
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: 'REVIEW', name: 'Na kontrolu', swimlaneColumn: 'DOING', color: '#8B5CF6', order: 3 },
|
||||||
|
{ code: 'COMPLETED', name: 'Dokončená', swimlaneColumn: 'DONE', color: '#059669', isFinal: true, order: 4 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== PRIORITIES =====
|
||||||
|
console.log('Creating priorities...');
|
||||||
|
await prisma.priority.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
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 =====
|
||||||
|
console.log('Creating system settings...');
|
||||||
|
await prisma.systemSetting.createMany({
|
||||||
|
skipDuplicates: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
key: 'REVISION_REMINDER_DAYS',
|
||||||
|
value: 14,
|
||||||
|
category: 'NOTIFICATIONS',
|
||||||
|
label: 'Pripomenúť revíziu X dní dopredu',
|
||||||
|
dataType: 'number',
|
||||||
|
validation: { min: 1, max: 365 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'RMA_NUMBER_FORMAT',
|
||||||
|
value: 'RMA-{YYYY}{MM}{DD}{XXX}',
|
||||||
|
category: 'RMA',
|
||||||
|
label: 'Formát RMA čísla',
|
||||||
|
dataType: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'RMA_CUSTOMER_REQUIRES_APPROVAL',
|
||||||
|
value: true,
|
||||||
|
category: 'RMA',
|
||||||
|
label: 'Reklamácie od zákazníkov vyžadujú schválenie',
|
||||||
|
dataType: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ADMIN_NOTIFICATION_EMAILS',
|
||||||
|
value: ['admin@firma.sk'],
|
||||||
|
category: 'NOTIFICATIONS',
|
||||||
|
label: 'Email adresy pre admin notifikácie',
|
||||||
|
dataType: 'json',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ENABLE_WEBSOCKET',
|
||||||
|
value: false,
|
||||||
|
category: 'GENERAL',
|
||||||
|
label: 'Zapnúť real-time aktualizácie (WebSocket)',
|
||||||
|
dataType: 'boolean',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== DEMO USERS =====
|
||||||
|
console.log('Creating 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');
|
||||||
|
|
||||||
|
if (rootRole && adminRole && userRole) {
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'root@helpdesk.sk' },
|
||||||
|
update: { password: await bcrypt.hash('root123', 10) },
|
||||||
|
create: {
|
||||||
|
email: 'root@helpdesk.sk',
|
||||||
|
password: await bcrypt.hash('root123', 10),
|
||||||
|
name: 'Root Admin',
|
||||||
|
roleId: rootRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@helpdesk.sk' },
|
||||||
|
update: { password: await bcrypt.hash('admin123', 10) },
|
||||||
|
create: {
|
||||||
|
email: 'admin@helpdesk.sk',
|
||||||
|
password: await bcrypt.hash('admin123', 10),
|
||||||
|
name: 'Peter Admin',
|
||||||
|
roleId: adminRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: { email: 'user@helpdesk.sk' },
|
||||||
|
update: { password: await bcrypt.hash('user123', 10) },
|
||||||
|
create: {
|
||||||
|
email: 'user@helpdesk.sk',
|
||||||
|
password: await bcrypt.hash('user123', 10),
|
||||||
|
name: 'Martin Používateľ',
|
||||||
|
roleId: userRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Seeding completed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
seed()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('Seeding failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
17
backend/src/config/database.ts
Normal file
17
backend/src/config/database.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPrisma.prisma = prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
29
backend/src/config/env.ts
Normal file
29
backend/src/config/env.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
// Server
|
||||||
|
PORT: parseInt(process.env.PORT || '3001', 10),
|
||||||
|
NODE_ENV: process.env.NODE_ENV || 'development',
|
||||||
|
|
||||||
|
// Database
|
||||||
|
DATABASE_URL: process.env.DATABASE_URL || '',
|
||||||
|
|
||||||
|
// JWT
|
||||||
|
JWT_SECRET: process.env.JWT_SECRET || 'default-secret',
|
||||||
|
JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || 'default-refresh-secret',
|
||||||
|
JWT_EXPIRES_IN: process.env.JWT_EXPIRES_IN || '15m',
|
||||||
|
JWT_REFRESH_EXPIRES_IN: process.env.JWT_REFRESH_EXPIRES_IN || '7d',
|
||||||
|
|
||||||
|
// Frontend
|
||||||
|
FRONTEND_URL: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
|
// Upload
|
||||||
|
UPLOAD_DIR: process.env.UPLOAD_DIR || './uploads',
|
||||||
|
MAX_FILE_SIZE: parseInt(process.env.MAX_FILE_SIZE || '10485760', 10),
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
isDev: process.env.NODE_ENV === 'development',
|
||||||
|
isProd: process.env.NODE_ENV === 'production',
|
||||||
|
};
|
||||||
29
backend/src/config/jwt.ts
Normal file
29
backend/src/config/jwt.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { env } from './env';
|
||||||
|
|
||||||
|
export interface TokenPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
roleId: string;
|
||||||
|
roleCode: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const generateAccessToken = (payload: TokenPayload): string => {
|
||||||
|
return jwt.sign(payload, env.JWT_SECRET, {
|
||||||
|
expiresIn: '15m',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateRefreshToken = (payload: TokenPayload): string => {
|
||||||
|
return jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
||||||
|
expiresIn: '7d',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyAccessToken = (token: string): TokenPayload => {
|
||||||
|
return jwt.verify(token, env.JWT_SECRET) as TokenPayload;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyRefreshToken = (token: string): TokenPayload => {
|
||||||
|
return jwt.verify(token, env.JWT_REFRESH_SECRET) as TokenPayload;
|
||||||
|
};
|
||||||
131
backend/src/controllers/auth.controller.ts
Normal file
131
backend/src/controllers/auth.controller.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { authService } from '../services/auth.service';
|
||||||
|
import { successResponse, errorResponse } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { logActivity } from '../middleware/activityLog.middleware';
|
||||||
|
|
||||||
|
export const register = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const user = await authService.register(req.body);
|
||||||
|
|
||||||
|
await logActivity(
|
||||||
|
user.id,
|
||||||
|
'CREATE',
|
||||||
|
'User',
|
||||||
|
user.id,
|
||||||
|
{ email: user.email, name: user.name },
|
||||||
|
req.ip,
|
||||||
|
req.get('User-Agent')
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
}, 'Registrácia úspešná.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorResponse(res, error.message, 400);
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Chyba pri registrácii.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const login = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { user, tokens } = await authService.login(req.body);
|
||||||
|
|
||||||
|
await logActivity(
|
||||||
|
user.id,
|
||||||
|
'LOGIN',
|
||||||
|
'User',
|
||||||
|
user.id,
|
||||||
|
{ email: user.email },
|
||||||
|
req.ip,
|
||||||
|
req.get('User-Agent')
|
||||||
|
);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
}, 'Prihlásenie úspešné.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorResponse(res, error.message, 401);
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Chyba pri prihlásení.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const refresh = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { refreshToken } = req.body;
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
errorResponse(res, 'Refresh token je povinný.', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = await authService.refreshTokens(refreshToken);
|
||||||
|
|
||||||
|
successResponse(res, tokens, 'Tokeny obnovené.');
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorResponse(res, error.message, 401);
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Chyba pri obnove tokenov.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const logout = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (req.user) {
|
||||||
|
await logActivity(
|
||||||
|
req.user.userId,
|
||||||
|
'LOGOUT',
|
||||||
|
'User',
|
||||||
|
req.user.userId,
|
||||||
|
{ email: req.user.email },
|
||||||
|
req.ip,
|
||||||
|
req.get('User-Agent')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Odhlásenie úspešné.');
|
||||||
|
} catch {
|
||||||
|
errorResponse(res, 'Chyba pri odhlásení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMe = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
errorResponse(res, 'Nie ste prihlásený.', 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await authService.getMe(req.user.userId);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
errorResponse(res, error.message, 404);
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Chyba pri získaní údajov.', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
216
backend/src/controllers/customers.controller.ts
Normal file
216
backend/src/controllers/customers.controller.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
export const getCustomers = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const page = parseQueryInt(req.query.page, 1);
|
||||||
|
const limit = parseQueryInt(req.query.limit, 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const active = getQueryString(req, 'active');
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(active !== undefined && { active: active === 'true' }),
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ email: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ ico: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [customers, total] = await Promise.all([
|
||||||
|
prisma.customer.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
projects: true,
|
||||||
|
equipment: true,
|
||||||
|
rmas: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.customer.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResponse(res, customers, total, page, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customers:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní zákazníkov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCustomer = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const customer = await prisma.customer.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
createdBy: {
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
projects: true,
|
||||||
|
equipment: true,
|
||||||
|
rmas: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
errorResponse(res, 'Zákazník nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, customer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní zákazníka.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createCustomer = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const customer = await prisma.customer.create({
|
||||||
|
data: {
|
||||||
|
...req.body,
|
||||||
|
createdById: req.user!.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('CREATE', 'Customer', customer.id, { name: customer.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, customer, 'Zákazník bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating customer:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní zákazníka.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateCustomer = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const existing = await prisma.customer.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
errorResponse(res, 'Zákazník nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'Customer', id, req.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, customer, 'Zákazník bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating customer:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii zákazníka.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteCustomer = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const customer = await prisma.customer.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!customer) {
|
||||||
|
errorResponse(res, 'Zákazník nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await prisma.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('DELETE', 'Customer', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Zákazník bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting customer:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní zákazníka.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCustomerProjects = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const projects = await prisma.project.findMany({
|
||||||
|
where: { customerId: id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
owner: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { tasks: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, projects);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer projects:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní projektov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCustomerEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findMany({
|
||||||
|
where: { customerId: id, active: true },
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, equipment);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer equipment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní zariadení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCustomerRMAs = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const rmas = await prisma.rMA.findMany({
|
||||||
|
where: { customerId: id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
proposedSolution: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, rmas);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching customer RMAs:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní reklamácií.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
333
backend/src/controllers/dashboard.controller.ts
Normal file
333
backend/src/controllers/dashboard.controller.ts
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
// Hlavný dashboard endpoint - štatistiky pre karty
|
||||||
|
export const getDashboard = async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const nextMonth = new Date(today);
|
||||||
|
nextMonth.setDate(nextMonth.getDate() + 30);
|
||||||
|
|
||||||
|
const [
|
||||||
|
totalProjects,
|
||||||
|
activeProjects,
|
||||||
|
totalTasks,
|
||||||
|
pendingTasks,
|
||||||
|
inProgressTasks,
|
||||||
|
totalCustomers,
|
||||||
|
activeCustomers,
|
||||||
|
totalEquipment,
|
||||||
|
upcomingRevisions,
|
||||||
|
totalRMAs,
|
||||||
|
pendingRMAs,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.project.count(),
|
||||||
|
prisma.project.count({ where: { status: { isFinal: false } } }),
|
||||||
|
prisma.task.count(),
|
||||||
|
prisma.task.count({ where: { status: { code: 'NEW' } } }),
|
||||||
|
prisma.task.count({ where: { status: { code: 'IN_PROGRESS' } } }),
|
||||||
|
prisma.customer.count(),
|
||||||
|
prisma.customer.count({ where: { active: true } }),
|
||||||
|
prisma.equipment.count({ where: { active: true } }),
|
||||||
|
prisma.revision.count({
|
||||||
|
where: {
|
||||||
|
nextDueDate: {
|
||||||
|
gte: today,
|
||||||
|
lte: nextMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rMA.count(),
|
||||||
|
prisma.rMA.count({ where: { status: { isFinal: false } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
projects: {
|
||||||
|
total: totalProjects,
|
||||||
|
active: activeProjects,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
total: totalTasks,
|
||||||
|
pending: pendingTasks,
|
||||||
|
inProgress: inProgressTasks,
|
||||||
|
},
|
||||||
|
customers: {
|
||||||
|
total: totalCustomers,
|
||||||
|
active: activeCustomers,
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
total: totalEquipment,
|
||||||
|
upcomingRevisions,
|
||||||
|
},
|
||||||
|
rma: {
|
||||||
|
total: totalRMAs,
|
||||||
|
pending: pendingRMAs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní dashboardu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDashboardToday = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const [myTasks, myProjects, recentRMAs] = await Promise.all([
|
||||||
|
// Tasks assigned to me that are not completed
|
||||||
|
prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
assignees: { some: { userId } },
|
||||||
|
status: { isFinal: false },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: { level: 'desc' } }, { deadline: 'asc' }],
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// My active projects
|
||||||
|
prisma.project.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ ownerId: userId },
|
||||||
|
{ members: { some: { userId } } },
|
||||||
|
],
|
||||||
|
status: { isFinal: false },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
_count: { select: { tasks: true } },
|
||||||
|
},
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Recent RMAs
|
||||||
|
prisma.rMA.findMany({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ assignedToId: userId },
|
||||||
|
{ createdById: userId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
myTasks,
|
||||||
|
myProjects,
|
||||||
|
recentRMAs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard today:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní dashboardu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDashboardWeek = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const nextWeek = new Date(today);
|
||||||
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const [tasksDeadlineThisWeek, upcomingRevisions] = await Promise.all([
|
||||||
|
// Tasks with deadline this week
|
||||||
|
prisma.task.findMany({
|
||||||
|
where: {
|
||||||
|
deadline: {
|
||||||
|
gte: today,
|
||||||
|
lte: nextWeek,
|
||||||
|
},
|
||||||
|
status: { isFinal: false },
|
||||||
|
OR: [
|
||||||
|
{ createdById: userId },
|
||||||
|
{ assignees: { some: { userId } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { deadline: 'asc' },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Upcoming equipment revisions
|
||||||
|
prisma.revision.findMany({
|
||||||
|
where: {
|
||||||
|
nextDueDate: {
|
||||||
|
gte: today,
|
||||||
|
lte: nextWeek,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
equipment: {
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
orderBy: { nextDueDate: 'asc' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
tasksDeadlineThisWeek,
|
||||||
|
upcomingRevisions,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard week:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní týždenného prehľadu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDashboardStats = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
totalProjects,
|
||||||
|
activeProjects,
|
||||||
|
totalTasks,
|
||||||
|
completedTasks,
|
||||||
|
totalCustomers,
|
||||||
|
totalEquipment,
|
||||||
|
totalRMAs,
|
||||||
|
openRMAs,
|
||||||
|
] = await Promise.all([
|
||||||
|
prisma.project.count(),
|
||||||
|
prisma.project.count({ where: { status: { isFinal: false } } }),
|
||||||
|
prisma.task.count(),
|
||||||
|
prisma.task.count({ where: { status: { isFinal: true } } }),
|
||||||
|
prisma.customer.count({ where: { active: true } }),
|
||||||
|
prisma.equipment.count({ where: { active: true } }),
|
||||||
|
prisma.rMA.count(),
|
||||||
|
prisma.rMA.count({ where: { status: { isFinal: false } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
projects: {
|
||||||
|
total: totalProjects,
|
||||||
|
active: activeProjects,
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
total: totalTasks,
|
||||||
|
completed: completedTasks,
|
||||||
|
completionRate: totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0,
|
||||||
|
},
|
||||||
|
customers: {
|
||||||
|
total: totalCustomers,
|
||||||
|
},
|
||||||
|
equipment: {
|
||||||
|
total: totalEquipment,
|
||||||
|
},
|
||||||
|
rmas: {
|
||||||
|
total: totalRMAs,
|
||||||
|
open: openRMAs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní štatistík.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDashboardReminders = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const today = new Date();
|
||||||
|
const nextMonth = new Date(today);
|
||||||
|
nextMonth.setDate(nextMonth.getDate() + 30);
|
||||||
|
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
const [taskReminders, equipmentRevisions, overdueRMAs] = await Promise.all([
|
||||||
|
// Task reminders
|
||||||
|
prisma.reminder.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
dismissed: false,
|
||||||
|
remindAt: {
|
||||||
|
lte: nextMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
task: {
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { remindAt: 'asc' },
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Equipment revision reminders
|
||||||
|
prisma.revision.findMany({
|
||||||
|
where: {
|
||||||
|
nextDueDate: {
|
||||||
|
gte: today,
|
||||||
|
lte: nextMonth,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
equipment: {
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
orderBy: { nextDueDate: 'asc' },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Overdue or old RMAs
|
||||||
|
prisma.rMA.findMany({
|
||||||
|
where: {
|
||||||
|
status: { isFinal: false },
|
||||||
|
createdAt: {
|
||||||
|
lte: new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000), // Older than 7 days
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
take: 10,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
successResponse(res, {
|
||||||
|
taskReminders,
|
||||||
|
equipmentRevisions,
|
||||||
|
overdueRMAs,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard reminders:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní upomienok.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
313
backend/src/controllers/equipment.controller.ts
Normal file
313
backend/src/controllers/equipment.controller.ts
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
export const getEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const page = parseQueryInt(req.query.page, 1);
|
||||||
|
const limit = parseQueryInt(req.query.limit, 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const active = getQueryString(req, 'active');
|
||||||
|
const typeId = getQueryString(req, 'typeId');
|
||||||
|
const customerId = getQueryString(req, 'customerId');
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(active !== undefined && { active: active === 'true' }),
|
||||||
|
...(typeId && { typeId }),
|
||||||
|
...(customerId && { customerId }),
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ serialNumber: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ address: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [equipment, total] = await Promise.all([
|
||||||
|
prisma.equipment.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { revisions: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.equipment.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResponse(res, equipment, total, page, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching equipment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní zariadení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEquipmentById = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: true,
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
revisions: {
|
||||||
|
orderBy: { performedDate: 'desc' },
|
||||||
|
take: 5,
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
performedBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
attachments: {
|
||||||
|
orderBy: { uploadedAt: 'desc' },
|
||||||
|
},
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, equipment);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching equipment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const equipment = await prisma.equipment.create({
|
||||||
|
data: {
|
||||||
|
name: req.body.name,
|
||||||
|
typeId: req.body.typeId,
|
||||||
|
brand: req.body.brand,
|
||||||
|
model: req.body.model,
|
||||||
|
customerId: req.body.customerId || null,
|
||||||
|
address: req.body.address,
|
||||||
|
location: req.body.location,
|
||||||
|
partNumber: req.body.partNumber,
|
||||||
|
serialNumber: req.body.serialNumber,
|
||||||
|
installDate: req.body.installDate ? new Date(req.body.installDate) : null,
|
||||||
|
warrantyEnd: req.body.warrantyEnd ? new Date(req.body.warrantyEnd) : null,
|
||||||
|
warrantyStatus: req.body.warrantyStatus,
|
||||||
|
description: req.body.description,
|
||||||
|
notes: req.body.notes,
|
||||||
|
createdById: req.user!.userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('CREATE', 'Equipment', equipment.id, { name: equipment.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, equipment, 'Zariadenie bolo vytvorené.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating equipment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const existing = await prisma.equipment.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
const fields = [
|
||||||
|
'name', 'typeId', 'brand', 'model', 'customerId', 'address',
|
||||||
|
'location', 'partNumber', 'serialNumber', 'warrantyStatus',
|
||||||
|
'description', 'notes', 'active'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (req.body[field] !== undefined) {
|
||||||
|
updateData[field] = req.body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.installDate !== undefined) {
|
||||||
|
updateData.installDate = req.body.installDate ? new Date(req.body.installDate) : null;
|
||||||
|
}
|
||||||
|
if (req.body.warrantyEnd !== undefined) {
|
||||||
|
updateData.warrantyEnd = req.body.warrantyEnd ? new Date(req.body.warrantyEnd) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'Equipment', id, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, equipment, 'Zariadenie bolo aktualizované.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating equipment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEquipment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!equipment) {
|
||||||
|
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await prisma.equipment.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('DELETE', 'Equipment', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Zariadenie bolo deaktivované.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting equipment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEquipmentRevisions = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const revisions = await prisma.revision.findMany({
|
||||||
|
where: { equipmentId: id },
|
||||||
|
orderBy: { performedDate: 'desc' },
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
performedBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, revisions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching equipment revisions:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní revízií.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEquipmentRevision = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const equipment = await prisma.equipment.findUnique({ where: { id } });
|
||||||
|
if (!equipment) {
|
||||||
|
errorResponse(res, 'Zariadenie nebolo nájdené.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const revisionType = await prisma.revisionType.findUnique({
|
||||||
|
where: { id: req.body.typeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!revisionType) {
|
||||||
|
errorResponse(res, 'Typ revízie nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const performedDate = new Date(req.body.performedDate);
|
||||||
|
const nextDueDate = new Date(performedDate);
|
||||||
|
nextDueDate.setDate(nextDueDate.getDate() + revisionType.intervalDays);
|
||||||
|
|
||||||
|
const reminderDate = new Date(nextDueDate);
|
||||||
|
reminderDate.setDate(reminderDate.getDate() - revisionType.reminderDays);
|
||||||
|
|
||||||
|
const revision = await prisma.revision.create({
|
||||||
|
data: {
|
||||||
|
equipmentId: id,
|
||||||
|
typeId: req.body.typeId,
|
||||||
|
performedDate,
|
||||||
|
nextDueDate,
|
||||||
|
reminderDate,
|
||||||
|
performedById: req.user!.userId,
|
||||||
|
findings: req.body.findings,
|
||||||
|
result: req.body.result,
|
||||||
|
notes: req.body.notes,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
performedBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('CREATE', 'Revision', revision.id, {
|
||||||
|
equipmentId: id,
|
||||||
|
type: revisionType.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, revision, 'Revízia bola vytvorená.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating revision:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní revízie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEquipmentReminders = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const days = parseQueryInt(req.query.days, 30);
|
||||||
|
const futureDate = new Date();
|
||||||
|
futureDate.setDate(futureDate.getDate() + days);
|
||||||
|
|
||||||
|
const revisions = await prisma.revision.findMany({
|
||||||
|
where: {
|
||||||
|
nextDueDate: {
|
||||||
|
gte: new Date(),
|
||||||
|
lte: futureDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
equipment: {
|
||||||
|
include: {
|
||||||
|
type: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
type: true,
|
||||||
|
},
|
||||||
|
orderBy: { nextDueDate: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, revisions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching equipment reminders:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní upomienok.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
288
backend/src/controllers/projects.controller.ts
Normal file
288
backend/src/controllers/projects.controller.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { configService } from '../services/config.service';
|
||||||
|
|
||||||
|
export const getProjects = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const page = parseQueryInt(req.query.page, 1);
|
||||||
|
const limit = parseQueryInt(req.query.limit, 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const statusId = getQueryString(req, 'statusId');
|
||||||
|
const customerId = getQueryString(req, 'customerId');
|
||||||
|
const ownerId = getQueryString(req, 'ownerId');
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(statusId && { statusId }),
|
||||||
|
...(customerId && { customerId }),
|
||||||
|
...(ownerId && { ownerId }),
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [projects, total] = await Promise.all([
|
||||||
|
prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
owner: { select: { id: true, name: true, email: true } },
|
||||||
|
_count: { select: { tasks: true, members: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.project.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResponse(res, projects, total, page, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching projects:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní projektov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProject = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
customer: true,
|
||||||
|
owner: { select: { id: true, name: true, email: true } },
|
||||||
|
members: {
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
_count: { select: { tasks: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
errorResponse(res, 'Projekt nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, project);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching project:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní projektu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createProject = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
let { statusId } = req.body;
|
||||||
|
|
||||||
|
// Get initial status if not provided
|
||||||
|
if (!statusId) {
|
||||||
|
const initialStatus = await configService.getInitialTaskStatus();
|
||||||
|
if (initialStatus) {
|
||||||
|
statusId = initialStatus.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: vezmi prvý aktívny status
|
||||||
|
const allStatuses = await configService.getTaskStatuses();
|
||||||
|
const statuses = allStatuses as { id: string }[];
|
||||||
|
if (statuses.length > 0) {
|
||||||
|
statusId = statuses[0].id;
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Žiadny status nie je nakonfigurovaný. Spustite seed.', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
name: req.body.name,
|
||||||
|
description: req.body.description,
|
||||||
|
customerId: req.body.customerId || null,
|
||||||
|
ownerId: req.user!.userId,
|
||||||
|
statusId,
|
||||||
|
softDeadline: req.body.softDeadline ? new Date(req.body.softDeadline) : null,
|
||||||
|
hardDeadline: req.body.hardDeadline ? new Date(req.body.hardDeadline) : null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
owner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('CREATE', 'Project', project.id, { name: project.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, project, 'Projekt bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating project:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní projektu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProject = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const existing = await prisma.project.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
errorResponse(res, 'Projekt nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (req.body.name) updateData.name = req.body.name;
|
||||||
|
if (req.body.description !== undefined) updateData.description = req.body.description;
|
||||||
|
if (req.body.customerId !== undefined) updateData.customerId = req.body.customerId || null;
|
||||||
|
if (req.body.statusId) updateData.statusId = req.body.statusId;
|
||||||
|
if (req.body.softDeadline !== undefined) {
|
||||||
|
updateData.softDeadline = req.body.softDeadline ? new Date(req.body.softDeadline) : null;
|
||||||
|
}
|
||||||
|
if (req.body.hardDeadline !== undefined) {
|
||||||
|
updateData.hardDeadline = req.body.hardDeadline ? new Date(req.body.hardDeadline) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const project = await prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
owner: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'Project', id, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, project, 'Projekt bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating project:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii projektu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteProject = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const project = await prisma.project.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
errorResponse(res, 'Projekt nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.project.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('DELETE', 'Project', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Projekt bol vymazaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting project:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní projektu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateProjectStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { statusId } = req.body;
|
||||||
|
|
||||||
|
const project = await prisma.project.update({
|
||||||
|
where: { id },
|
||||||
|
data: { statusId },
|
||||||
|
include: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('STATUS_CHANGE', 'Project', id, { statusId });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, project, 'Status projektu bol zmenený.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating project status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri zmene statusu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectTasks = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const tasks = await prisma.task.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
assignees: {
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ priority: { level: 'desc' } }, { createdAt: 'desc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, tasks);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching project tasks:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní úloh.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addProjectMember = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const member = await prisma.projectMember.create({
|
||||||
|
data: {
|
||||||
|
projectId: id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, member, 'Člen bol pridaný.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding project member:', error);
|
||||||
|
errorResponse(res, 'Chyba pri pridávaní člena.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeProjectMember = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const userId = getParam(req, 'userId');
|
||||||
|
|
||||||
|
await prisma.projectMember.delete({
|
||||||
|
where: {
|
||||||
|
projectId_userId: {
|
||||||
|
projectId: id,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, null, 'Člen bol odstránený.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing project member:', error);
|
||||||
|
errorResponse(res, 'Chyba pri odstraňovaní člena.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
414
backend/src/controllers/rma.controller.ts
Normal file
414
backend/src/controllers/rma.controller.ts
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { configService } from '../services/config.service';
|
||||||
|
|
||||||
|
async function generateRMANumber(): Promise<string> {
|
||||||
|
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');
|
||||||
|
|
||||||
|
const startOfDay = new Date(today.setHours(0, 0, 0, 0));
|
||||||
|
const endOfDay = new Date(today.setHours(23, 59, 59, 999));
|
||||||
|
|
||||||
|
const count = await prisma.rMA.count({
|
||||||
|
where: {
|
||||||
|
createdAt: {
|
||||||
|
gte: startOfDay,
|
||||||
|
lte: endOfDay,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sequence = String(count + 1).padStart(3, '0');
|
||||||
|
return `RMA-${year}${month}${day}${sequence}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getRMAs = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const page = parseQueryInt(req.query.page, 1);
|
||||||
|
const limit = parseQueryInt(req.query.limit, 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const statusId = getQueryString(req, 'statusId');
|
||||||
|
const customerId = getQueryString(req, 'customerId');
|
||||||
|
const assignedToId = getQueryString(req, 'assignedToId');
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(statusId && { statusId }),
|
||||||
|
...(customerId && { customerId }),
|
||||||
|
...(assignedToId && { assignedToId }),
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ rmaNumber: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ productName: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ customerName: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [rmas, total] = await Promise.all([
|
||||||
|
prisma.rMA.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
proposedSolution: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
assignedTo: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.rMA.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResponse(res, rmas, total, page, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RMAs:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní reklamácií.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRMA = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const rma = await prisma.rMA.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
proposedSolution: true,
|
||||||
|
customer: true,
|
||||||
|
assignedTo: { select: { id: true, name: true, email: true } },
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
approvedBy: { select: { id: true, name: true } },
|
||||||
|
attachments: {
|
||||||
|
orderBy: { uploadedAt: 'desc' },
|
||||||
|
},
|
||||||
|
statusHistory: {
|
||||||
|
orderBy: { changedAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
changedBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rma) {
|
||||||
|
errorResponse(res, 'Reklamácia nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, rma);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RMA:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRMA = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
let { statusId, proposedSolutionId } = req.body;
|
||||||
|
|
||||||
|
// Get initial status if not provided
|
||||||
|
if (!statusId) {
|
||||||
|
const initialStatus = await configService.getInitialRMAStatus();
|
||||||
|
if (initialStatus) {
|
||||||
|
statusId = initialStatus.id;
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Predvolený status reklamácie nie je nakonfigurovaný.', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default solution (Assessment) if not provided
|
||||||
|
if (!proposedSolutionId) {
|
||||||
|
const solutions = await configService.getRMASolutions();
|
||||||
|
const defaultSolution = (solutions as { id: string; code: string }[]).find(
|
||||||
|
(s) => s.code === 'ASSESSMENT'
|
||||||
|
);
|
||||||
|
if (defaultSolution) {
|
||||||
|
proposedSolutionId = defaultSolution.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rmaNumber = await generateRMANumber();
|
||||||
|
|
||||||
|
// Check if customer role requires approval
|
||||||
|
let requiresApproval = false;
|
||||||
|
if (req.user?.roleCode === 'CUSTOMER') {
|
||||||
|
const setting = await configService.getSetting<boolean>('RMA_CUSTOMER_REQUIRES_APPROVAL');
|
||||||
|
requiresApproval = setting === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rma = await prisma.rMA.create({
|
||||||
|
data: {
|
||||||
|
rmaNumber,
|
||||||
|
customerId: req.body.customerId || null,
|
||||||
|
customerName: req.body.customerName,
|
||||||
|
customerAddress: req.body.customerAddress,
|
||||||
|
customerEmail: req.body.customerEmail,
|
||||||
|
customerPhone: req.body.customerPhone,
|
||||||
|
customerICO: req.body.customerICO,
|
||||||
|
submittedBy: req.body.submittedBy,
|
||||||
|
productName: req.body.productName,
|
||||||
|
invoiceNumber: req.body.invoiceNumber,
|
||||||
|
purchaseDate: req.body.purchaseDate ? new Date(req.body.purchaseDate) : null,
|
||||||
|
productNumber: req.body.productNumber,
|
||||||
|
serialNumber: req.body.serialNumber,
|
||||||
|
accessories: req.body.accessories,
|
||||||
|
issueDescription: req.body.issueDescription,
|
||||||
|
statusId,
|
||||||
|
proposedSolutionId,
|
||||||
|
requiresApproval,
|
||||||
|
createdById: req.user!.userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
proposedSolution: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create initial status history
|
||||||
|
await prisma.rMAStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
rmaId: rma.id,
|
||||||
|
toStatusId: statusId,
|
||||||
|
changedById: req.user!.userId,
|
||||||
|
notes: 'Reklamácia vytvorená',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('CREATE', 'RMA', rma.id, { rmaNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, rma, 'Reklamácia bola vytvorená.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating RMA:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRMA = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const existing = await prisma.rMA.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
errorResponse(res, 'Reklamácia nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
const fields = [
|
||||||
|
'customerId', 'customerName', 'customerAddress', 'customerEmail',
|
||||||
|
'customerPhone', 'customerICO', 'submittedBy', 'productName',
|
||||||
|
'invoiceNumber', 'productNumber', 'serialNumber', 'accessories',
|
||||||
|
'issueDescription', 'proposedSolutionId', 'receivedLocation',
|
||||||
|
'internalNotes', 'resolutionNotes', 'assignedToId'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of fields) {
|
||||||
|
if (req.body[field] !== undefined) {
|
||||||
|
updateData[field] = req.body[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.body.purchaseDate !== undefined) {
|
||||||
|
updateData.purchaseDate = req.body.purchaseDate ? new Date(req.body.purchaseDate) : null;
|
||||||
|
}
|
||||||
|
if (req.body.receivedDate !== undefined) {
|
||||||
|
updateData.receivedDate = req.body.receivedDate ? new Date(req.body.receivedDate) : null;
|
||||||
|
}
|
||||||
|
if (req.body.resolutionDate !== undefined) {
|
||||||
|
updateData.resolutionDate = req.body.resolutionDate ? new Date(req.body.resolutionDate) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rma = await prisma.rMA.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
proposedSolution: true,
|
||||||
|
customer: { select: { id: true, name: true } },
|
||||||
|
assignedTo: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'RMA', id, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, rma, 'Reklamácia bola aktualizovaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating RMA:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRMA = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const rma = await prisma.rMA.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!rma) {
|
||||||
|
errorResponse(res, 'Reklamácia nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.rMA.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('DELETE', 'RMA', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Reklamácia bola vymazaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting RMA:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRMAStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { statusId, notes } = req.body;
|
||||||
|
|
||||||
|
const rma = await prisma.rMA.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rma) {
|
||||||
|
errorResponse(res, 'Reklamácia nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = await prisma.rMAStatus.findUnique({ where: { id: statusId } });
|
||||||
|
|
||||||
|
if (!newStatus) {
|
||||||
|
errorResponse(res, 'Status nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate transition (basic - can be enhanced with workflow rules)
|
||||||
|
const currentStatus = rma.status;
|
||||||
|
if (currentStatus.canTransitionTo) {
|
||||||
|
const allowedTransitions = currentStatus.canTransitionTo as string[];
|
||||||
|
if (!allowedTransitions.includes(newStatus.code)) {
|
||||||
|
errorResponse(
|
||||||
|
res,
|
||||||
|
`Prechod zo statusu "${currentStatus.name}" na "${newStatus.name}" nie je povolený.`,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedRMA = await prisma.rMA.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
statusId,
|
||||||
|
closedAt: newStatus.isFinal ? new Date() : null,
|
||||||
|
},
|
||||||
|
include: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create status history
|
||||||
|
await prisma.rMAStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
rmaId: id,
|
||||||
|
fromStatusId: rma.statusId,
|
||||||
|
toStatusId: statusId,
|
||||||
|
changedById: req.user!.userId,
|
||||||
|
notes,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('STATUS_CHANGE', 'RMA', id, {
|
||||||
|
from: currentStatus.code,
|
||||||
|
to: newStatus.code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, updatedRMA, 'Status reklamácie bol zmenený.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating RMA status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri zmene statusu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const approveRMA = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const rma = await prisma.rMA.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
requiresApproval: false,
|
||||||
|
approvedById: req.user!.userId,
|
||||||
|
approvedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'RMA', id, { approved: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, rma, 'Reklamácia bola schválená.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error approving RMA:', error);
|
||||||
|
errorResponse(res, 'Chyba pri schvaľovaní reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addRMAComment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { content } = req.body;
|
||||||
|
|
||||||
|
const comment = await prisma.rMAComment.create({
|
||||||
|
data: {
|
||||||
|
rmaId: id,
|
||||||
|
userId: req.user!.userId,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, comment, 'Komentár bol pridaný.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding RMA comment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri pridávaní komentára.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const generateRMANumberEndpoint = async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const rmaNumber = await generateRMANumber();
|
||||||
|
successResponse(res, { rmaNumber });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating RMA number:', error);
|
||||||
|
errorResponse(res, 'Chyba pri generovaní RMA čísla.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
450
backend/src/controllers/settings.controller.ts
Normal file
450
backend/src/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { configService } from '../services/config.service';
|
||||||
|
|
||||||
|
// ==================== EQUIPMENT TYPES ====================
|
||||||
|
|
||||||
|
export const getEquipmentTypes = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const types = await configService.getEquipmentTypes(activeOnly);
|
||||||
|
successResponse(res, types);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching equipment types:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní typov zariadení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createEquipmentType = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const type = await prisma.equipmentType.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('equipment_types');
|
||||||
|
successResponse(res, type, 'Typ zariadenia bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating equipment type:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní typu zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateEquipmentType = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const type = await prisma.equipmentType.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('equipment_types');
|
||||||
|
successResponse(res, type, 'Typ zariadenia bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating equipment type:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii typu zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteEquipmentType = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.equipmentType.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('equipment_types');
|
||||||
|
successResponse(res, null, 'Typ zariadenia bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting equipment type:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní typu zariadenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== REVISION TYPES ====================
|
||||||
|
|
||||||
|
export const getRevisionTypes = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const types = await configService.getRevisionTypes(activeOnly);
|
||||||
|
successResponse(res, types);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching revision types:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní typov revízií.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRevisionType = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const type = await prisma.revisionType.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('revision_types');
|
||||||
|
successResponse(res, type, 'Typ revízie bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating revision type:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní typu revízie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRevisionType = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const type = await prisma.revisionType.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('revision_types');
|
||||||
|
successResponse(res, type, 'Typ revízie bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating revision type:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii typu revízie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRevisionType = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.revisionType.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('revision_types');
|
||||||
|
successResponse(res, null, 'Typ revízie bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting revision type:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní typu revízie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== RMA STATUSES ====================
|
||||||
|
|
||||||
|
export const getRMAStatuses = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const statuses = await configService.getRMAStatuses(activeOnly);
|
||||||
|
successResponse(res, statuses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RMA statuses:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní statusov reklamácií.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRMAStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const status = await prisma.rMAStatus.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('rma_statuses');
|
||||||
|
successResponse(res, status, 'Status reklamácie bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating RMA status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní statusu reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRMAStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const status = await prisma.rMAStatus.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('rma_statuses');
|
||||||
|
successResponse(res, status, 'Status reklamácie bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating RMA status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii statusu reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRMAStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.rMAStatus.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('rma_statuses');
|
||||||
|
successResponse(res, null, 'Status reklamácie bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting RMA status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní statusu reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== RMA SOLUTIONS ====================
|
||||||
|
|
||||||
|
export const getRMASolutions = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const solutions = await configService.getRMASolutions(activeOnly);
|
||||||
|
successResponse(res, solutions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching RMA solutions:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní riešení reklamácií.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRMASolution = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const solution = await prisma.rMASolution.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('rma_solutions');
|
||||||
|
successResponse(res, solution, 'Riešenie reklamácie bolo vytvorené.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating RMA solution:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní riešenia reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRMASolution = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const solution = await prisma.rMASolution.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('rma_solutions');
|
||||||
|
successResponse(res, solution, 'Riešenie reklamácie bolo aktualizované.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating RMA solution:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii riešenia reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteRMASolution = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.rMASolution.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('rma_solutions');
|
||||||
|
successResponse(res, null, 'Riešenie reklamácie bolo deaktivované.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting RMA solution:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní riešenia reklamácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== TASK STATUSES ====================
|
||||||
|
|
||||||
|
export const getTaskStatuses = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const statuses = await configService.getTaskStatuses(activeOnly);
|
||||||
|
successResponse(res, statuses);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching task statuses:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní statusov úloh.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTaskStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const status = await prisma.taskStatus.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('task_statuses');
|
||||||
|
successResponse(res, status, 'Status úlohy bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating task status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní statusu úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTaskStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const status = await prisma.taskStatus.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('task_statuses');
|
||||||
|
successResponse(res, status, 'Status úlohy bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating task status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii statusu úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTaskStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.taskStatus.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('task_statuses');
|
||||||
|
successResponse(res, null, 'Status úlohy bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting task status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní statusu úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== PRIORITIES ====================
|
||||||
|
|
||||||
|
export const getPriorities = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const priorities = await configService.getPriorities(activeOnly);
|
||||||
|
successResponse(res, priorities);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching priorities:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní priorít.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createPriority = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const priority = await prisma.priority.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('priorities');
|
||||||
|
successResponse(res, priority, 'Priorita bola vytvorená.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating priority:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní priority.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updatePriority = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const priority = await prisma.priority.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('priorities');
|
||||||
|
successResponse(res, priority, 'Priorita bola aktualizovaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating priority:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii priority.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deletePriority = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.priority.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('priorities');
|
||||||
|
successResponse(res, null, 'Priorita bola deaktivovaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting priority:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní priority.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== TAGS ====================
|
||||||
|
|
||||||
|
export const getTags = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const entityType = getQueryString(req, 'entityType');
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const tags = await configService.getTags(entityType, activeOnly);
|
||||||
|
successResponse(res, tags);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tags:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní tagov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTag = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const tag = await prisma.tag.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('tags');
|
||||||
|
successResponse(res, tag, 'Tag bol vytvorený.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating tag:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní tagu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTag = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const tag = await prisma.tag.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('tags');
|
||||||
|
successResponse(res, tag, 'Tag bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating tag:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii tagu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTag = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.tag.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('tags');
|
||||||
|
successResponse(res, null, 'Tag bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting tag:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní tagu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== USER ROLES ====================
|
||||||
|
|
||||||
|
export const getUserRoles = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const activeOnly = getQueryString(req, 'activeOnly') !== 'false';
|
||||||
|
const roles = await configService.getUserRoles(activeOnly);
|
||||||
|
successResponse(res, roles);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user roles:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní rolí.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUserRole = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const role = await prisma.userRole.create({ data: req.body });
|
||||||
|
configService.clearCacheKey('user_roles');
|
||||||
|
successResponse(res, role, 'Rola bola vytvorená.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user role:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní roly.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserRole = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const role = await prisma.userRole.update({ where: { id }, data: req.body });
|
||||||
|
configService.clearCacheKey('user_roles');
|
||||||
|
successResponse(res, role, 'Rola bola aktualizovaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user role:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii roly.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUserRole = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
await prisma.userRole.update({ where: { id }, data: { active: false } });
|
||||||
|
configService.clearCacheKey('user_roles');
|
||||||
|
successResponse(res, null, 'Rola bola deaktivovaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user role:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní roly.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== SYSTEM SETTINGS ====================
|
||||||
|
|
||||||
|
export const getSystemSettings = async (_req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.systemSetting.findMany({
|
||||||
|
orderBy: [{ category: 'asc' }, { key: 'asc' }],
|
||||||
|
});
|
||||||
|
successResponse(res, settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching system settings:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní nastavení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemSetting = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = getParam(req, 'key');
|
||||||
|
const setting = await prisma.systemSetting.findUnique({ where: { key } });
|
||||||
|
|
||||||
|
if (!setting) {
|
||||||
|
errorResponse(res, 'Nastavenie nebolo nájdené.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, setting);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching system setting:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní nastavenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateSystemSetting = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const key = getParam(req, 'key');
|
||||||
|
const { value } = req.body;
|
||||||
|
|
||||||
|
const setting = await prisma.systemSetting.update({
|
||||||
|
where: { key },
|
||||||
|
data: { value },
|
||||||
|
});
|
||||||
|
|
||||||
|
configService.clearCacheKey(`setting_${key}`);
|
||||||
|
successResponse(res, setting, 'Nastavenie bolo aktualizované.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating system setting:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii nastavenia.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getSystemSettingsByCategory = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const category = getParam(req, 'category');
|
||||||
|
const settings = await configService.getSettingsByCategory(category);
|
||||||
|
successResponse(res, settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching system settings by category:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní nastavení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
400
backend/src/controllers/tasks.controller.ts
Normal file
400
backend/src/controllers/tasks.controller.ts
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
import { configService } from '../services/config.service';
|
||||||
|
|
||||||
|
export const getTasks = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const page = parseQueryInt(req.query.page, 1);
|
||||||
|
const limit = parseQueryInt(req.query.limit, 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const projectId = getQueryString(req, 'projectId');
|
||||||
|
const statusId = getQueryString(req, 'statusId');
|
||||||
|
const priorityId = getQueryString(req, 'priorityId');
|
||||||
|
const createdById = getQueryString(req, 'createdById');
|
||||||
|
const assigneeId = getQueryString(req, 'assigneeId');
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(projectId && { projectId }),
|
||||||
|
...(statusId && { statusId }),
|
||||||
|
...(priorityId && { priorityId }),
|
||||||
|
...(createdById && { createdById }),
|
||||||
|
...(assigneeId && {
|
||||||
|
assignees: { some: { userId: assigneeId } },
|
||||||
|
}),
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ title: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ description: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [tasks, total] = await Promise.all([
|
||||||
|
prisma.task.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: [{ priority: { level: 'desc' } }, { createdAt: 'desc' }],
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
assignees: {
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
_count: { select: { subTasks: true, comments: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.task.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResponse(res, tasks, total, page, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tasks:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní úloh.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTask = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
parent: { select: { id: true, title: true } },
|
||||||
|
subTasks: {
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createdBy: { select: { id: true, name: true, email: true } },
|
||||||
|
assignees: {
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
},
|
||||||
|
comments: {
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
},
|
||||||
|
tags: { include: { tag: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, task);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching task:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createTask = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
let { statusId, priorityId } = req.body;
|
||||||
|
|
||||||
|
// Get defaults if not provided
|
||||||
|
if (!statusId) {
|
||||||
|
const initialStatus = await configService.getInitialTaskStatus();
|
||||||
|
if (initialStatus) {
|
||||||
|
statusId = initialStatus.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: vezmi prvý aktívny status
|
||||||
|
const allStatuses = await configService.getTaskStatuses();
|
||||||
|
const statuses = allStatuses as { id: string }[];
|
||||||
|
if (statuses.length > 0) {
|
||||||
|
statusId = statuses[0].id;
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Žiadny status nie je nakonfigurovaný. Spustite seed.', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!priorityId) {
|
||||||
|
const defaultPriority = await configService.getDefaultPriority();
|
||||||
|
if (defaultPriority) {
|
||||||
|
priorityId = defaultPriority.id;
|
||||||
|
} else {
|
||||||
|
// Fallback: vezmi prvú aktívnu prioritu
|
||||||
|
const allPriorities = await configService.getPriorities();
|
||||||
|
const priorities = allPriorities as { id: string }[];
|
||||||
|
if (priorities.length > 0) {
|
||||||
|
priorityId = priorities[0].id;
|
||||||
|
} else {
|
||||||
|
errorResponse(res, 'Žiadna priorita nie je nakonfigurovaná. Spustite seed.', 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
title: req.body.title,
|
||||||
|
description: req.body.description,
|
||||||
|
projectId: req.body.projectId || null,
|
||||||
|
parentId: req.body.parentId || null,
|
||||||
|
statusId,
|
||||||
|
priorityId,
|
||||||
|
deadline: req.body.deadline ? new Date(req.body.deadline) : null,
|
||||||
|
createdById: req.user!.userId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
createdBy: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add assignees if provided
|
||||||
|
if (req.body.assigneeIds && req.body.assigneeIds.length > 0) {
|
||||||
|
await prisma.taskAssignee.createMany({
|
||||||
|
data: req.body.assigneeIds.map((userId: string) => ({
|
||||||
|
taskId: task.id,
|
||||||
|
userId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('CREATE', 'Task', task.id, { title: task.title });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, task, 'Úloha bola vytvorená.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating task:', error);
|
||||||
|
errorResponse(res, 'Chyba pri vytváraní úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTask = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const existing = await prisma.task.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (req.body.title) updateData.title = req.body.title;
|
||||||
|
if (req.body.description !== undefined) updateData.description = req.body.description;
|
||||||
|
if (req.body.projectId !== undefined) updateData.projectId = req.body.projectId || null;
|
||||||
|
if (req.body.parentId !== undefined) updateData.parentId = req.body.parentId || null;
|
||||||
|
if (req.body.statusId) updateData.statusId = req.body.statusId;
|
||||||
|
if (req.body.priorityId) updateData.priorityId = req.body.priorityId;
|
||||||
|
if (req.body.deadline !== undefined) {
|
||||||
|
updateData.deadline = req.body.deadline ? new Date(req.body.deadline) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = await prisma.task.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update assignees if provided
|
||||||
|
if (req.body.assigneeIds !== undefined) {
|
||||||
|
// Remove all current assignees
|
||||||
|
await prisma.taskAssignee.deleteMany({ where: { taskId: id } });
|
||||||
|
|
||||||
|
// Add new assignees
|
||||||
|
if (req.body.assigneeIds.length > 0) {
|
||||||
|
await prisma.taskAssignee.createMany({
|
||||||
|
data: req.body.assigneeIds.map((userId: string) => ({
|
||||||
|
taskId: id,
|
||||||
|
userId,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-fetch task with updated assignees
|
||||||
|
const updatedTask = await prisma.task.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
project: { select: { id: true, name: true } },
|
||||||
|
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'Task', id, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, updatedTask, 'Úloha bola aktualizovaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating task:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteTask = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const task = await prisma.task.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.task.delete({ where: { id } });
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('DELETE', 'Task', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Úloha bola vymazaná.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting task:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní úlohy.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTaskStatus = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { statusId } = req.body;
|
||||||
|
|
||||||
|
const status = await prisma.taskStatus.findUnique({ where: { id: statusId } });
|
||||||
|
|
||||||
|
const task = await prisma.task.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
statusId,
|
||||||
|
completedAt: status?.isFinal ? new Date() : null,
|
||||||
|
},
|
||||||
|
include: { status: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('STATUS_CHANGE', 'Task', id, { statusId });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, task, 'Status úlohy bol zmenený.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating task status:', error);
|
||||||
|
errorResponse(res, 'Chyba pri zmene statusu.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addTaskAssignee = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { userId } = req.body;
|
||||||
|
|
||||||
|
const assignee = await prisma.taskAssignee.create({
|
||||||
|
data: { taskId: id, userId },
|
||||||
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, assignee, 'Priraďovateľ bol pridaný.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding task assignee:', error);
|
||||||
|
errorResponse(res, 'Chyba pri pridávaní priraďovateľa.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeTaskAssignee = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const userId = getParam(req, 'userId');
|
||||||
|
|
||||||
|
await prisma.taskAssignee.delete({
|
||||||
|
where: { taskId_userId: { taskId: id, userId } },
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, null, 'Priraďovateľ bol odstránený.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing task assignee:', error);
|
||||||
|
errorResponse(res, 'Chyba pri odstraňovaní priraďovateľa.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getTaskComments = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const comments = await prisma.comment.findMany({
|
||||||
|
where: { taskId: id },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, comments);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching task comments:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní komentárov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addTaskComment = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { content } = req.body;
|
||||||
|
const userId = req.user!.userId;
|
||||||
|
|
||||||
|
// Načítať úlohu s assignees pre kontrolu oprávnení
|
||||||
|
const task = await prisma.task.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
assignees: { select: { userId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
errorResponse(res, 'Úloha nebola nájdená.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kontrola oprávnení - len autor alebo priradený môže komentovať
|
||||||
|
const isCreator = task.createdById === userId;
|
||||||
|
const isAssignee = task.assignees.some(a => a.userId === userId);
|
||||||
|
|
||||||
|
if (!isCreator && !isAssignee) {
|
||||||
|
errorResponse(res, 'Nemáte oprávnenie komentovať túto úlohu.', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const comment = await prisma.comment.create({
|
||||||
|
data: {
|
||||||
|
taskId: id,
|
||||||
|
userId,
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, comment, 'Komentár bol pridaný.', 201);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error adding task comment:', error);
|
||||||
|
errorResponse(res, 'Chyba pri pridávaní komentára.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
272
backend/src/controllers/users.controller.ts
Normal file
272
backend/src/controllers/users.controller.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import { Response } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { successResponse, errorResponse, paginatedResponse, parseQueryInt, getParam, getQueryString } from '../utils/helpers';
|
||||||
|
import { AuthRequest } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
// Jednoduchý zoznam aktívnych používateľov (pre select/dropdown)
|
||||||
|
// Podporuje server-side vyhľadávanie pre lepší výkon pri veľkom počte používateľov
|
||||||
|
export const getUsersSimple = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const limit = parseQueryInt(req.query.limit, 50); // Default 50, max 100
|
||||||
|
const actualLimit = Math.min(limit, 100);
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
active: true,
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ email: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
take: actualLimit,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
successResponse(res, users);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users simple:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní používateľov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUsers = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const page = parseQueryInt(req.query.page, 1);
|
||||||
|
const limit = parseQueryInt(req.query.limit, 20);
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
const search = getQueryString(req, 'search');
|
||||||
|
const active = getQueryString(req, 'active');
|
||||||
|
const roleId = getQueryString(req, 'roleId');
|
||||||
|
|
||||||
|
const where = {
|
||||||
|
...(active !== undefined && { active: active === 'true' }),
|
||||||
|
...(roleId && { roleId }),
|
||||||
|
...(search && {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
{ email: { contains: search, mode: 'insensitive' as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.user.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
paginatedResponse(res, users, total, page, limit);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní používateľov.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUser = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
errorResponse(res, 'Používateľ nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user:', error);
|
||||||
|
errorResponse(res, 'Chyba pri načítaní používateľa.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUser = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { email, name, active, password } = req.body;
|
||||||
|
|
||||||
|
const existingUser = await prisma.user.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
errorResponse(res, 'Používateľ nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email uniqueness if changing
|
||||||
|
if (email && email !== existingUser.email) {
|
||||||
|
const emailExists = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (emailExists) {
|
||||||
|
errorResponse(res, 'Email je už používaný.', 409);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {};
|
||||||
|
if (email) updateData.email = email;
|
||||||
|
if (name) updateData.name = name;
|
||||||
|
if (active !== undefined) updateData.active = active;
|
||||||
|
if (password) updateData.password = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
active: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'User', id, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, user, 'Používateľ bol aktualizovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user:', error);
|
||||||
|
errorResponse(res, 'Chyba pri aktualizácii používateľa.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const deleteUser = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
|
||||||
|
// Prevent self-deletion
|
||||||
|
if (req.user?.userId === id) {
|
||||||
|
errorResponse(res, 'Nemôžete vymazať vlastný účet.', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
errorResponse(res, 'Používateľ nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete - just deactivate
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('DELETE', 'User', id);
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, null, 'Používateľ bol deaktivovaný.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
errorResponse(res, 'Chyba pri mazaní používateľa.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateUserRole = async (req: AuthRequest, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const id = getParam(req, 'id');
|
||||||
|
const { roleId } = req.body;
|
||||||
|
|
||||||
|
// Prevent changing own role
|
||||||
|
if (req.user?.userId === id) {
|
||||||
|
errorResponse(res, 'Nemôžete zmeniť vlastnú rolu.', 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
errorResponse(res, 'Používateľ nebol nájdený.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await prisma.userRole.findUnique({ where: { id: roleId } });
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
errorResponse(res, 'Rola neexistuje.', 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { roleId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (req.logActivity) {
|
||||||
|
await req.logActivity('UPDATE', 'User', id, { roleId, roleName: role.name });
|
||||||
|
}
|
||||||
|
|
||||||
|
successResponse(res, updatedUser, 'Rola používateľa bola zmenená.');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user role:', error);
|
||||||
|
errorResponse(res, 'Chyba pri zmene roly.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
72
backend/src/index.ts
Normal file
72
backend/src/index.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import { env } from './config/env';
|
||||||
|
import routes from './routes';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||||
|
import prisma from './config/database';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({
|
||||||
|
origin: env.FRONTEND_URL,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Request parsing
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Logging
|
||||||
|
if (env.isDev) {
|
||||||
|
app.use(morgan('dev'));
|
||||||
|
} else {
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api', routes);
|
||||||
|
|
||||||
|
// Error handlers
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const startServer = async () => {
|
||||||
|
try {
|
||||||
|
// Test database connection
|
||||||
|
await prisma.$connect();
|
||||||
|
console.log('Database connected successfully');
|
||||||
|
|
||||||
|
app.listen(env.PORT, () => {
|
||||||
|
console.log(`Server running on http://localhost:${env.PORT}`);
|
||||||
|
console.log(`Environment: ${env.NODE_ENV}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
console.log('SIGTERM received, shutting down...');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
console.log('SIGINT received, shutting down...');
|
||||||
|
await prisma.$disconnect();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
startServer();
|
||||||
73
backend/src/middleware/activityLog.middleware.ts
Normal file
73
backend/src/middleware/activityLog.middleware.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { AuthRequest } from './auth.middleware';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
type ActionType = 'CREATE' | 'UPDATE' | 'DELETE' | 'STATUS_CHANGE' | 'LOGIN' | 'LOGOUT';
|
||||||
|
type EntityType = 'User' | 'Project' | 'Task' | 'Customer' | 'Equipment' | 'Revision' | 'RMA';
|
||||||
|
|
||||||
|
export const logActivity = async (
|
||||||
|
userId: string,
|
||||||
|
action: ActionType,
|
||||||
|
entity: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
changes?: Record<string, unknown>,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await prisma.activityLog.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
entity,
|
||||||
|
entityId,
|
||||||
|
changes: changes as Prisma.InputJsonValue,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log activity:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware to attach logging helper to request
|
||||||
|
export const activityLogger = (
|
||||||
|
req: AuthRequest,
|
||||||
|
_res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): void => {
|
||||||
|
req.logActivity = async (
|
||||||
|
action: ActionType,
|
||||||
|
entity: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
changes?: Record<string, unknown>
|
||||||
|
) => {
|
||||||
|
if (req.user) {
|
||||||
|
await logActivity(
|
||||||
|
req.user.userId,
|
||||||
|
action,
|
||||||
|
entity,
|
||||||
|
entityId,
|
||||||
|
changes,
|
||||||
|
req.ip,
|
||||||
|
req.get('User-Agent')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extend Express Request type
|
||||||
|
declare module 'express' {
|
||||||
|
interface Request {
|
||||||
|
logActivity?: (
|
||||||
|
action: ActionType,
|
||||||
|
entity: EntityType,
|
||||||
|
entityId: string,
|
||||||
|
changes?: Record<string, unknown>
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/src/middleware/auth.middleware.ts
Normal file
100
backend/src/middleware/auth.middleware.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { verifyAccessToken, TokenPayload } from '../config/jwt';
|
||||||
|
import { errorResponse } from '../utils/helpers';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
|
||||||
|
export interface AuthRequest extends Request {
|
||||||
|
user?: TokenPayload & {
|
||||||
|
permissions: Record<string, string[]>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authenticate = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
errorResponse(res, 'Prístup zamietnutý. Token nebol poskytnutý.', 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
|
||||||
|
// Get user role with permissions
|
||||||
|
const userRole = await prisma.userRole.findUnique({
|
||||||
|
where: { id: decoded.roleId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!userRole || !userRole.active) {
|
||||||
|
errorResponse(res, 'Rola používateľa nie je aktívna.', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user still exists and is active
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.active) {
|
||||||
|
errorResponse(res, 'Používateľ neexistuje alebo je neaktívny.', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.user = {
|
||||||
|
...decoded,
|
||||||
|
permissions: userRole.permissions as Record<string, string[]>,
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
errorResponse(res, 'Neplatný alebo expirovaný token.', 401);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth middleware error:', error);
|
||||||
|
errorResponse(res, 'Chyba autentifikácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const optionalAuth = async (
|
||||||
|
req: AuthRequest,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = verifyAccessToken(token);
|
||||||
|
const userRole = await prisma.userRole.findUnique({
|
||||||
|
where: { id: decoded.roleId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (userRole && userRole.active) {
|
||||||
|
req.user = {
|
||||||
|
...decoded,
|
||||||
|
permissions: userRole.permissions as Record<string, string[]>,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Token invalid, continue without user
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
};
|
||||||
49
backend/src/middleware/errorHandler.ts
Normal file
49
backend/src/middleware/errorHandler.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { env } from '../config/env';
|
||||||
|
|
||||||
|
export interface AppError extends Error {
|
||||||
|
statusCode?: number;
|
||||||
|
isOperational?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorHandler = (
|
||||||
|
err: AppError,
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
_next: NextFunction
|
||||||
|
): void => {
|
||||||
|
const statusCode = err.statusCode || 500;
|
||||||
|
const message = err.message || 'Interná chyba servera';
|
||||||
|
|
||||||
|
console.error(`[ERROR] ${statusCode} - ${message}`);
|
||||||
|
console.error(err.stack);
|
||||||
|
|
||||||
|
res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
...(env.isDev && {
|
||||||
|
stack: err.stack,
|
||||||
|
error: err,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notFoundHandler = (req: Request, res: Response): void => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `Route ${req.method} ${req.path} nenájdená`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
statusCode: number;
|
||||||
|
isOperational: boolean;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode = 400) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.isOperational = true;
|
||||||
|
|
||||||
|
Error.captureStackTrace(this, this.constructor);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
backend/src/middleware/rbac.middleware.ts
Normal file
115
backend/src/middleware/rbac.middleware.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Response, NextFunction } from 'express';
|
||||||
|
import { AuthRequest } from './auth.middleware';
|
||||||
|
import { errorResponse } from '../utils/helpers';
|
||||||
|
|
||||||
|
type Resource = 'projects' | 'tasks' | 'equipment' | 'rma' | 'customers' | 'settings' | 'users' | 'logs';
|
||||||
|
type Action = 'create' | 'read' | 'update' | 'delete' | 'all' | 'approve' | '*';
|
||||||
|
|
||||||
|
export const hasPermission = (
|
||||||
|
permissions: Record<string, string[]>,
|
||||||
|
resource: Resource,
|
||||||
|
action: Action
|
||||||
|
): boolean => {
|
||||||
|
const resourcePermissions = permissions[resource];
|
||||||
|
|
||||||
|
if (!resourcePermissions) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard permission
|
||||||
|
if (resourcePermissions.includes('*')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for 'all' permission (grants read/create/update/delete)
|
||||||
|
if (resourcePermissions.includes('all') && ['create', 'read', 'update', 'delete'].includes(action)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for specific permission
|
||||||
|
return resourcePermissions.includes(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkPermission = (resource: Resource, action: Action) => {
|
||||||
|
return (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
errorResponse(res, 'Nie ste prihlásený.', 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { permissions } = req.user;
|
||||||
|
|
||||||
|
if (!hasPermission(permissions, resource, action)) {
|
||||||
|
errorResponse(res, 'Nemáte oprávnenie na túto akciu.', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Shorthand middleware factories
|
||||||
|
export const canCreate = (resource: Resource) => checkPermission(resource, 'create');
|
||||||
|
export const canRead = (resource: Resource) => checkPermission(resource, 'read');
|
||||||
|
export const canUpdate = (resource: Resource) => checkPermission(resource, 'update');
|
||||||
|
export const canDelete = (resource: Resource) => checkPermission(resource, 'delete');
|
||||||
|
export const canManage = (resource: Resource) => checkPermission(resource, 'all');
|
||||||
|
|
||||||
|
// Check if user has ROOT role
|
||||||
|
export const isRoot = (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
errorResponse(res, 'Nie ste prihlásený.', 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.user.roleCode !== 'ROOT') {
|
||||||
|
errorResponse(res, 'Táto akcia vyžaduje ROOT oprávnenia.', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if user has ADMIN or ROOT role
|
||||||
|
export const isAdmin = (req: AuthRequest, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
errorResponse(res, 'Nie ste prihlásený.', 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!['ROOT', 'ADMIN'].includes(req.user.roleCode)) {
|
||||||
|
errorResponse(res, 'Táto akcia vyžaduje ADMIN oprávnenia.', 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check ownership or admin permission
|
||||||
|
export const isOwnerOrAdmin = (getOwnerId: (req: AuthRequest) => Promise<string | null>) => {
|
||||||
|
return async (req: AuthRequest, res: Response, next: NextFunction): Promise<void> => {
|
||||||
|
if (!req.user) {
|
||||||
|
errorResponse(res, 'Nie ste prihlásený.', 401);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins and ROOT can access anything
|
||||||
|
if (['ROOT', 'ADMIN'].includes(req.user.roleCode)) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ownerId = await getOwnerId(req);
|
||||||
|
|
||||||
|
if (ownerId === req.user.userId) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorResponse(res, 'Nemáte oprávnenie na túto akciu.', 403);
|
||||||
|
} catch {
|
||||||
|
errorResponse(res, 'Chyba pri overovaní oprávnení.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
71
backend/src/middleware/validate.middleware.ts
Normal file
71
backend/src/middleware/validate.middleware.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import { ZodSchema } from 'zod';
|
||||||
|
import { errorResponse } from '../utils/helpers';
|
||||||
|
|
||||||
|
export const validate = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
try {
|
||||||
|
schema.parse(req.body);
|
||||||
|
next();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'issues' in error) {
|
||||||
|
const zodError = error as { issues: Array<{ path: (string | number)[]; message: string }> };
|
||||||
|
const formattedErrors = zodError.issues.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
errorResponse(res, 'Validácia zlyhala.', 400, formattedErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorResponse(res, 'Neočakávaná chyba validácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateQuery = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
try {
|
||||||
|
const result = schema.parse(req.query);
|
||||||
|
req.query = result as typeof req.query;
|
||||||
|
next();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'issues' in error) {
|
||||||
|
const zodError = error as { issues: Array<{ path: (string | number)[]; message: string }> };
|
||||||
|
const formattedErrors = zodError.issues.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
errorResponse(res, 'Neplatné query parametre.', 400, formattedErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorResponse(res, 'Neočakávaná chyba validácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateParams = (schema: ZodSchema) => {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
try {
|
||||||
|
const result = schema.parse(req.params);
|
||||||
|
req.params = result as typeof req.params;
|
||||||
|
next();
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error && typeof error === 'object' && 'issues' in error) {
|
||||||
|
const zodError = error as { issues: Array<{ path: (string | number)[]; message: string }> };
|
||||||
|
const formattedErrors = zodError.issues.map((err) => ({
|
||||||
|
field: err.path.join('.'),
|
||||||
|
message: err.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
errorResponse(res, 'Neplatné URL parametre.', 400, formattedErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errorResponse(res, 'Neočakávaná chyba validácie.', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
15
backend/src/routes/auth.routes.ts
Normal file
15
backend/src/routes/auth.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as authController from '../controllers/auth.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { loginSchema, registerSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.post('/register', validate(registerSchema), authController.register);
|
||||||
|
router.post('/login', validate(loginSchema), authController.login);
|
||||||
|
router.post('/refresh', authController.refresh);
|
||||||
|
router.post('/logout', authenticate, authController.logout);
|
||||||
|
router.get('/me', authenticate, authController.getMe);
|
||||||
|
|
||||||
|
export default router;
|
||||||
25
backend/src/routes/customers.routes.ts
Normal file
25
backend/src/routes/customers.routes.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as customersController from '../controllers/customers.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
|
||||||
|
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { customerSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(activityLogger);
|
||||||
|
|
||||||
|
router.get('/', canRead('customers'), customersController.getCustomers);
|
||||||
|
router.post('/', canCreate('customers'), validate(customerSchema), customersController.createCustomer);
|
||||||
|
router.get('/:id', canRead('customers'), customersController.getCustomer);
|
||||||
|
router.put('/:id', canUpdate('customers'), validate(customerSchema), customersController.updateCustomer);
|
||||||
|
router.delete('/:id', canDelete('customers'), customersController.deleteCustomer);
|
||||||
|
|
||||||
|
// Customer relations
|
||||||
|
router.get('/:id/projects', canRead('customers'), customersController.getCustomerProjects);
|
||||||
|
router.get('/:id/equipment', canRead('customers'), customersController.getCustomerEquipment);
|
||||||
|
router.get('/:id/rmas', canRead('customers'), customersController.getCustomerRMAs);
|
||||||
|
|
||||||
|
export default router;
|
||||||
15
backend/src/routes/dashboard.routes.ts
Normal file
15
backend/src/routes/dashboard.routes.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as dashboardController from '../controllers/dashboard.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
router.get('/', dashboardController.getDashboard);
|
||||||
|
router.get('/today', dashboardController.getDashboardToday);
|
||||||
|
router.get('/week', dashboardController.getDashboardWeek);
|
||||||
|
router.get('/stats', dashboardController.getDashboardStats);
|
||||||
|
router.get('/reminders', dashboardController.getDashboardReminders);
|
||||||
|
|
||||||
|
export default router;
|
||||||
25
backend/src/routes/equipment.routes.ts
Normal file
25
backend/src/routes/equipment.routes.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as equipmentController from '../controllers/equipment.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
|
||||||
|
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { equipmentSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(activityLogger);
|
||||||
|
|
||||||
|
router.get('/', canRead('equipment'), equipmentController.getEquipment);
|
||||||
|
router.post('/', canCreate('equipment'), validate(equipmentSchema), equipmentController.createEquipment);
|
||||||
|
router.get('/reminders', canRead('equipment'), equipmentController.getEquipmentReminders);
|
||||||
|
router.get('/:id', canRead('equipment'), equipmentController.getEquipmentById);
|
||||||
|
router.put('/:id', canUpdate('equipment'), equipmentController.updateEquipment);
|
||||||
|
router.delete('/:id', canDelete('equipment'), equipmentController.deleteEquipment);
|
||||||
|
|
||||||
|
// Revisions
|
||||||
|
router.get('/:id/revisions', canRead('equipment'), equipmentController.getEquipmentRevisions);
|
||||||
|
router.post('/:id/revisions', canCreate('equipment'), equipmentController.createEquipmentRevision);
|
||||||
|
|
||||||
|
export default router;
|
||||||
24
backend/src/routes/index.ts
Normal file
24
backend/src/routes/index.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import authRoutes from './auth.routes';
|
||||||
|
import usersRoutes from './users.routes';
|
||||||
|
import customersRoutes from './customers.routes';
|
||||||
|
import projectsRoutes from './projects.routes';
|
||||||
|
import tasksRoutes from './tasks.routes';
|
||||||
|
import equipmentRoutes from './equipment.routes';
|
||||||
|
import rmaRoutes from './rma.routes';
|
||||||
|
import settingsRoutes from './settings.routes';
|
||||||
|
import dashboardRoutes from './dashboard.routes';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use('/auth', authRoutes);
|
||||||
|
router.use('/users', usersRoutes);
|
||||||
|
router.use('/customers', customersRoutes);
|
||||||
|
router.use('/projects', projectsRoutes);
|
||||||
|
router.use('/tasks', tasksRoutes);
|
||||||
|
router.use('/equipment', equipmentRoutes);
|
||||||
|
router.use('/rma', rmaRoutes);
|
||||||
|
router.use('/settings', settingsRoutes);
|
||||||
|
router.use('/dashboard', dashboardRoutes);
|
||||||
|
|
||||||
|
export default router;
|
||||||
30
backend/src/routes/projects.routes.ts
Normal file
30
backend/src/routes/projects.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as projectsController from '../controllers/projects.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
|
||||||
|
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { projectSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(activityLogger);
|
||||||
|
|
||||||
|
router.get('/', canRead('projects'), projectsController.getProjects);
|
||||||
|
router.post('/', canCreate('projects'), validate(projectSchema), projectsController.createProject);
|
||||||
|
router.get('/:id', canRead('projects'), projectsController.getProject);
|
||||||
|
router.put('/:id', canUpdate('projects'), projectsController.updateProject);
|
||||||
|
router.delete('/:id', canDelete('projects'), projectsController.deleteProject);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
router.patch('/:id/status', canUpdate('projects'), projectsController.updateProjectStatus);
|
||||||
|
|
||||||
|
// Tasks
|
||||||
|
router.get('/:id/tasks', canRead('projects'), projectsController.getProjectTasks);
|
||||||
|
|
||||||
|
// Members
|
||||||
|
router.post('/:id/members', canUpdate('projects'), projectsController.addProjectMember);
|
||||||
|
router.delete('/:id/members/:userId', canUpdate('projects'), projectsController.removeProjectMember);
|
||||||
|
|
||||||
|
export default router;
|
||||||
30
backend/src/routes/rma.routes.ts
Normal file
30
backend/src/routes/rma.routes.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as rmaController from '../controllers/rma.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { canRead, canCreate, canUpdate, canDelete, isAdmin } from '../middleware/rbac.middleware';
|
||||||
|
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { rmaSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(activityLogger);
|
||||||
|
|
||||||
|
router.get('/', canRead('rma'), rmaController.getRMAs);
|
||||||
|
router.post('/', canCreate('rma'), validate(rmaSchema), rmaController.createRMA);
|
||||||
|
router.get('/generate-number', canCreate('rma'), rmaController.generateRMANumberEndpoint);
|
||||||
|
router.get('/:id', canRead('rma'), rmaController.getRMA);
|
||||||
|
router.put('/:id', canUpdate('rma'), rmaController.updateRMA);
|
||||||
|
router.delete('/:id', canDelete('rma'), rmaController.deleteRMA);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
router.patch('/:id/status', canUpdate('rma'), rmaController.updateRMAStatus);
|
||||||
|
|
||||||
|
// Approval (Admin only)
|
||||||
|
router.patch('/:id/approve', isAdmin, rmaController.approveRMA);
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
router.post('/:id/comments', canRead('rma'), rmaController.addRMAComment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
78
backend/src/routes/settings.routes.ts
Normal file
78
backend/src/routes/settings.routes.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as settingsController from '../controllers/settings.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { isRoot } from '../middleware/rbac.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Všetky endpointy vyžadujú autentifikáciu
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// === VEREJNÉ ENDPOINTY (pre všetkých prihlásených) ===
|
||||||
|
// Task Statuses - čítanie
|
||||||
|
router.get('/task-statuses', settingsController.getTaskStatuses);
|
||||||
|
// Priorities - čítanie
|
||||||
|
router.get('/priorities', settingsController.getPriorities);
|
||||||
|
// Equipment Types - čítanie
|
||||||
|
router.get('/equipment-types', settingsController.getEquipmentTypes);
|
||||||
|
// Revision Types - čítanie
|
||||||
|
router.get('/revision-types', settingsController.getRevisionTypes);
|
||||||
|
// RMA Statuses - čítanie
|
||||||
|
router.get('/rma-statuses', settingsController.getRMAStatuses);
|
||||||
|
// RMA Solutions - čítanie
|
||||||
|
router.get('/rma-solutions', settingsController.getRMASolutions);
|
||||||
|
// Tags - čítanie
|
||||||
|
router.get('/tags', settingsController.getTags);
|
||||||
|
// User Roles - čítanie
|
||||||
|
router.get('/roles', settingsController.getUserRoles);
|
||||||
|
|
||||||
|
// === ROOT-ONLY ENDPOINTY (vytvorenie, úprava, mazanie) ===
|
||||||
|
router.use(isRoot);
|
||||||
|
|
||||||
|
// Equipment Types - správa
|
||||||
|
router.post('/equipment-types', settingsController.createEquipmentType);
|
||||||
|
router.put('/equipment-types/:id', settingsController.updateEquipmentType);
|
||||||
|
router.delete('/equipment-types/:id', settingsController.deleteEquipmentType);
|
||||||
|
|
||||||
|
// Revision Types - správa
|
||||||
|
router.post('/revision-types', settingsController.createRevisionType);
|
||||||
|
router.put('/revision-types/:id', settingsController.updateRevisionType);
|
||||||
|
router.delete('/revision-types/:id', settingsController.deleteRevisionType);
|
||||||
|
|
||||||
|
// RMA Statuses - správa
|
||||||
|
router.post('/rma-statuses', settingsController.createRMAStatus);
|
||||||
|
router.put('/rma-statuses/:id', settingsController.updateRMAStatus);
|
||||||
|
router.delete('/rma-statuses/:id', settingsController.deleteRMAStatus);
|
||||||
|
|
||||||
|
// RMA Solutions - správa
|
||||||
|
router.post('/rma-solutions', settingsController.createRMASolution);
|
||||||
|
router.put('/rma-solutions/:id', settingsController.updateRMASolution);
|
||||||
|
router.delete('/rma-solutions/:id', settingsController.deleteRMASolution);
|
||||||
|
|
||||||
|
// Task Statuses - správa
|
||||||
|
router.post('/task-statuses', settingsController.createTaskStatus);
|
||||||
|
router.put('/task-statuses/:id', settingsController.updateTaskStatus);
|
||||||
|
router.delete('/task-statuses/:id', settingsController.deleteTaskStatus);
|
||||||
|
|
||||||
|
// Priorities - správa
|
||||||
|
router.post('/priorities', settingsController.createPriority);
|
||||||
|
router.put('/priorities/:id', settingsController.updatePriority);
|
||||||
|
router.delete('/priorities/:id', settingsController.deletePriority);
|
||||||
|
|
||||||
|
// Tags - správa
|
||||||
|
router.post('/tags', settingsController.createTag);
|
||||||
|
router.put('/tags/:id', settingsController.updateTag);
|
||||||
|
router.delete('/tags/:id', settingsController.deleteTag);
|
||||||
|
|
||||||
|
// User Roles - správa
|
||||||
|
router.post('/roles', settingsController.createUserRole);
|
||||||
|
router.put('/roles/:id', settingsController.updateUserRole);
|
||||||
|
router.delete('/roles/:id', settingsController.deleteUserRole);
|
||||||
|
|
||||||
|
// System Settings - len ROOT
|
||||||
|
router.get('/system', settingsController.getSystemSettings);
|
||||||
|
router.get('/system/category/:category', settingsController.getSystemSettingsByCategory);
|
||||||
|
router.get('/system/:key', settingsController.getSystemSetting);
|
||||||
|
router.put('/system/:key', settingsController.updateSystemSetting);
|
||||||
|
|
||||||
|
export default router;
|
||||||
31
backend/src/routes/tasks.routes.ts
Normal file
31
backend/src/routes/tasks.routes.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as tasksController from '../controllers/tasks.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { canRead, canCreate, canUpdate, canDelete } from '../middleware/rbac.middleware';
|
||||||
|
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||||
|
import { validate } from '../middleware/validate.middleware';
|
||||||
|
import { taskSchema } from '../utils/validators';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(activityLogger);
|
||||||
|
|
||||||
|
router.get('/', canRead('tasks'), tasksController.getTasks);
|
||||||
|
router.post('/', canCreate('tasks'), validate(taskSchema), tasksController.createTask);
|
||||||
|
router.get('/:id', canRead('tasks'), tasksController.getTask);
|
||||||
|
router.put('/:id', canUpdate('tasks'), tasksController.updateTask);
|
||||||
|
router.delete('/:id', canDelete('tasks'), tasksController.deleteTask);
|
||||||
|
|
||||||
|
// Status
|
||||||
|
router.patch('/:id/status', canUpdate('tasks'), tasksController.updateTaskStatus);
|
||||||
|
|
||||||
|
// Assignees
|
||||||
|
router.post('/:id/assignees', canUpdate('tasks'), tasksController.addTaskAssignee);
|
||||||
|
router.delete('/:id/assignees/:userId', canUpdate('tasks'), tasksController.removeTaskAssignee);
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
router.get('/:id/comments', canRead('tasks'), tasksController.getTaskComments);
|
||||||
|
router.post('/:id/comments', canRead('tasks'), tasksController.addTaskComment);
|
||||||
|
|
||||||
|
export default router;
|
||||||
21
backend/src/routes/users.routes.ts
Normal file
21
backend/src/routes/users.routes.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import * as usersController from '../controllers/users.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
import { isAdmin } from '../middleware/rbac.middleware';
|
||||||
|
import { activityLogger } from '../middleware/activityLog.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
router.use(activityLogger);
|
||||||
|
|
||||||
|
// Jednoduchý zoznam pre select/dropdown - prístupné všetkým autentifikovaným
|
||||||
|
router.get('/simple', usersController.getUsersSimple);
|
||||||
|
|
||||||
|
router.get('/', isAdmin, usersController.getUsers);
|
||||||
|
router.get('/:id', isAdmin, usersController.getUser);
|
||||||
|
router.put('/:id', isAdmin, usersController.updateUser);
|
||||||
|
router.delete('/:id', isAdmin, usersController.deleteUser);
|
||||||
|
router.patch('/:id/role', isAdmin, usersController.updateUserRole);
|
||||||
|
|
||||||
|
export default router;
|
||||||
195
backend/src/services/auth.service.ts
Normal file
195
backend/src/services/auth.service.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import prisma from '../config/database';
|
||||||
|
import { generateAccessToken, generateRefreshToken, verifyRefreshToken, TokenPayload } from '../config/jwt';
|
||||||
|
import { ApiError } from '../middleware/errorHandler';
|
||||||
|
|
||||||
|
interface RegisterInput {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
roleId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginInput {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserWithRole {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
role: {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
permissions: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuthService {
|
||||||
|
async register(data: RegisterInput): Promise<UserWithRole> {
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await prisma.user.findUnique({
|
||||||
|
where: { email: data.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
throw new ApiError('Používateľ s týmto emailom už existuje.', 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get default role (USER) if not specified
|
||||||
|
let roleId = data.roleId;
|
||||||
|
if (!roleId) {
|
||||||
|
const defaultRole = await prisma.userRole.findFirst({
|
||||||
|
where: { code: 'USER', active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!defaultRole) {
|
||||||
|
throw new ApiError('Predvolená rola nie je nakonfigurovaná.', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
roleId = defaultRole.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(data.password, 10);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: data.name,
|
||||||
|
roleId,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(data: LoginInput): Promise<{ user: UserWithRole; tokens: AuthTokens }> {
|
||||||
|
// Find user
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: data.email },
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError('Neplatný email alebo heslo.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.active) {
|
||||||
|
throw new ApiError('Váš účet je deaktivovaný.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isPasswordValid = await bcrypt.compare(data.password, user.password);
|
||||||
|
|
||||||
|
if (!isPasswordValid) {
|
||||||
|
throw new ApiError('Neplatný email alebo heslo.', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate tokens
|
||||||
|
const tokenPayload: TokenPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
roleId: user.role.id,
|
||||||
|
roleCode: user.role.code,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tokens: AuthTokens = {
|
||||||
|
accessToken: generateAccessToken(tokenPayload),
|
||||||
|
refreshToken: generateRefreshToken(tokenPayload),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { user, tokens };
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshTokens(refreshToken: string): Promise<AuthTokens> {
|
||||||
|
try {
|
||||||
|
const decoded = verifyRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
// Verify user still exists and is active
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.userId },
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.active) {
|
||||||
|
throw new ApiError('Používateľ neexistuje alebo je neaktívny.', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new tokens
|
||||||
|
const tokenPayload: TokenPayload = {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
roleId: user.role.id,
|
||||||
|
roleCode: user.role.code,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken: generateAccessToken(tokenPayload),
|
||||||
|
refreshToken: generateRefreshToken(tokenPayload),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
throw new ApiError('Neplatný refresh token.', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMe(userId: string): Promise<UserWithRole> {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
role: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
code: true,
|
||||||
|
name: true,
|
||||||
|
permissions: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new ApiError('Používateľ nebol nájdený.', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authService = new AuthService();
|
||||||
211
backend/src/services/config.service.ts
Normal file
211
backend/src/services/config.service.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import prisma from '../config/database';
|
||||||
|
|
||||||
|
type CacheValue = {
|
||||||
|
data: unknown;
|
||||||
|
expiry: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ConfigService {
|
||||||
|
private cache = new Map<string, CacheValue>();
|
||||||
|
private cacheExpiry = 60000; // 1 minute
|
||||||
|
|
||||||
|
private getCached<T>(key: string): T | null {
|
||||||
|
const cached = this.cache.get(key);
|
||||||
|
if (cached && cached.expiry > Date.now()) {
|
||||||
|
return cached.data as T;
|
||||||
|
}
|
||||||
|
this.cache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setCache(key: string, data: unknown): void {
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
expiry: Date.now() + this.cacheExpiry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEquipmentTypes(activeOnly = true) {
|
||||||
|
const cacheKey = `equipment_types_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const types = await prisma.equipmentType.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, types);
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRevisionTypes(activeOnly = true) {
|
||||||
|
const cacheKey = `revision_types_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const types = await prisma.revisionType.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, types);
|
||||||
|
return types;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRMAStatuses(activeOnly = true) {
|
||||||
|
const cacheKey = `rma_statuses_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const statuses = await prisma.rMAStatus.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, statuses);
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRMASolutions(activeOnly = true) {
|
||||||
|
const cacheKey = `rma_solutions_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const solutions = await prisma.rMASolution.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, solutions);
|
||||||
|
return solutions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTaskStatuses(activeOnly = true) {
|
||||||
|
const cacheKey = `task_statuses_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const statuses = await prisma.taskStatus.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, statuses);
|
||||||
|
return statuses;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPriorities(activeOnly = true) {
|
||||||
|
const cacheKey = `priorities_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const priorities = await prisma.priority.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, priorities);
|
||||||
|
return priorities;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserRoles(activeOnly = true) {
|
||||||
|
const cacheKey = `user_roles_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const roles = await prisma.userRole.findMany({
|
||||||
|
where: activeOnly ? { active: true } : {},
|
||||||
|
orderBy: { level: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, roles);
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTags(entityType?: string, activeOnly = true) {
|
||||||
|
const cacheKey = `tags_${entityType || 'all'}_${activeOnly}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: {
|
||||||
|
...(activeOnly && { active: true }),
|
||||||
|
...(entityType && { entityType }),
|
||||||
|
},
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, tags);
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSetting<T = unknown>(key: string): Promise<T | null> {
|
||||||
|
const cacheKey = `setting_${key}`;
|
||||||
|
|
||||||
|
const cached = this.getCached<T>(cacheKey);
|
||||||
|
if (cached !== null) return cached;
|
||||||
|
|
||||||
|
const setting = await prisma.systemSetting.findUnique({
|
||||||
|
where: { key },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (setting) {
|
||||||
|
this.setCache(cacheKey, setting.value);
|
||||||
|
return setting.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSettingsByCategory(category: string) {
|
||||||
|
const cacheKey = `settings_category_${category}`;
|
||||||
|
|
||||||
|
const cached = this.getCached(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const settings = await prisma.systemSetting.findMany({
|
||||||
|
where: { category },
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setCache(cacheKey, settings);
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInitialTaskStatus() {
|
||||||
|
const statuses = await this.getTaskStatuses();
|
||||||
|
return (statuses as { id: string; isInitial: boolean }[]).find((s) => s.isInitial);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInitialRMAStatus() {
|
||||||
|
const statuses = await this.getRMAStatuses();
|
||||||
|
return (statuses as { id: string; isInitial: boolean }[]).find((s) => s.isInitial);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDefaultPriority() {
|
||||||
|
const priorities = await this.getPriorities();
|
||||||
|
return (priorities as { id: string; code: string }[]).find((p) => p.code === 'MEDIUM');
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCacheKey(keyPrefix: string): void {
|
||||||
|
for (const key of this.cache.keys()) {
|
||||||
|
if (key.startsWith(keyPrefix)) {
|
||||||
|
this.cache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const configService = new ConfigService();
|
||||||
73
backend/src/utils/helpers.ts
Normal file
73
backend/src/utils/helpers.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
|
||||||
|
export const getParam = (req: Request, name: string): string => {
|
||||||
|
const value = req.params[name];
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getQueryString = (req: Request, name: string): string | undefined => {
|
||||||
|
const value = req.query[name];
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
return Array.isArray(value) ? String(value[0]) : String(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const successResponse = <T>(
|
||||||
|
res: Response,
|
||||||
|
data: T,
|
||||||
|
message = 'Success',
|
||||||
|
statusCode = 200
|
||||||
|
) => {
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
success: true,
|
||||||
|
message,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const errorResponse = (
|
||||||
|
res: Response,
|
||||||
|
message: string,
|
||||||
|
statusCode = 400,
|
||||||
|
errors?: unknown
|
||||||
|
) => {
|
||||||
|
return res.status(statusCode).json({
|
||||||
|
success: false,
|
||||||
|
message,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const paginatedResponse = <T>(
|
||||||
|
res: Response,
|
||||||
|
data: T[],
|
||||||
|
total: number,
|
||||||
|
page: number,
|
||||||
|
limit: number
|
||||||
|
) => {
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
hasNext: page * limit < total,
|
||||||
|
hasPrev: page > 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseQueryInt = (value: unknown, defaultValue: number): number => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = parseInt(value, 10);
|
||||||
|
return isNaN(parsed) ? defaultValue : parsed;
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseBooleanQuery = (value: unknown): boolean | undefined => {
|
||||||
|
if (value === 'true') return true;
|
||||||
|
if (value === 'false') return false;
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
183
backend/src/utils/validators.ts
Normal file
183
backend/src/utils/validators.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Auth validators
|
||||||
|
export const loginSchema = z.object({
|
||||||
|
email: z.string().email('Neplatný email'),
|
||||||
|
password: z.string().min(1, 'Heslo je povinné'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const registerSchema = z.object({
|
||||||
|
email: z.string().email('Neplatný email'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(8, 'Heslo musí mať aspoň 8 znakov')
|
||||||
|
.regex(/[A-Z]/, 'Heslo musí obsahovať veľké písmeno')
|
||||||
|
.regex(/[0-9]/, 'Heslo musí obsahovať číslo'),
|
||||||
|
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky'),
|
||||||
|
roleId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// User validators
|
||||||
|
export const updateUserSchema = z.object({
|
||||||
|
email: z.string().email('Neplatný email').optional(),
|
||||||
|
name: z.string().min(2, 'Meno musí mať aspoň 2 znaky').optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Customer validators
|
||||||
|
export const customerSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
address: z.string().optional(),
|
||||||
|
email: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
ico: z.string().optional(),
|
||||||
|
dic: z.string().optional(),
|
||||||
|
icdph: z.string().optional(),
|
||||||
|
contactPerson: z.string().optional(),
|
||||||
|
contactEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||||
|
contactPhone: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Project validators
|
||||||
|
export const projectSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
statusId: z.string().optional(),
|
||||||
|
softDeadline: z.string().datetime().optional().or(z.literal('')),
|
||||||
|
hardDeadline: z.string().datetime().optional().or(z.literal('')),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task validators
|
||||||
|
export const taskSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
parentId: z.string().optional(),
|
||||||
|
statusId: z.string().optional(),
|
||||||
|
priorityId: z.string().optional(),
|
||||||
|
deadline: z.string().datetime().optional().or(z.literal('')),
|
||||||
|
assigneeIds: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Equipment validators
|
||||||
|
export const equipmentSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
typeId: z.string().min(1, 'Typ je povinný'),
|
||||||
|
brand: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
address: z.string().min(1, 'Adresa je povinná'),
|
||||||
|
location: z.string().optional(),
|
||||||
|
partNumber: z.string().optional(),
|
||||||
|
serialNumber: z.string().optional(),
|
||||||
|
installDate: z.string().datetime().optional().or(z.literal('')),
|
||||||
|
warrantyEnd: z.string().datetime().optional().or(z.literal('')),
|
||||||
|
warrantyStatus: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// RMA validators
|
||||||
|
export const rmaSchema = z.object({
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
customerName: z.string().optional(),
|
||||||
|
customerAddress: z.string().optional(),
|
||||||
|
customerEmail: z.string().email().optional().or(z.literal('')),
|
||||||
|
customerPhone: z.string().optional(),
|
||||||
|
customerICO: z.string().optional(),
|
||||||
|
submittedBy: z.string().min(1, 'Meno odosielateľa je povinné'),
|
||||||
|
productName: z.string().min(1, 'Názov produktu je povinný'),
|
||||||
|
invoiceNumber: z.string().optional(),
|
||||||
|
purchaseDate: z.string().datetime().optional().or(z.literal('')),
|
||||||
|
productNumber: z.string().optional(),
|
||||||
|
serialNumber: z.string().optional(),
|
||||||
|
accessories: z.string().optional(),
|
||||||
|
issueDescription: z.string().min(1, 'Popis problému je povinný'),
|
||||||
|
statusId: z.string().optional(),
|
||||||
|
proposedSolutionId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings validators
|
||||||
|
export const equipmentTypeSchema = z.object({
|
||||||
|
code: z.string().min(1, 'Kód je povinný'),
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
order: z.number().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const revisionTypeSchema = z.object({
|
||||||
|
code: z.string().min(1, 'Kód je povinný'),
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
intervalDays: z.number().min(0, 'Interval musí byť kladné číslo'),
|
||||||
|
reminderDays: z.number().min(0).optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
order: z.number().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskStatusSchema = z.object({
|
||||||
|
code: z.string().min(1, 'Kód je povinný'),
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
order: z.number().optional(),
|
||||||
|
swimlaneColumn: z.string().optional(),
|
||||||
|
isInitial: z.boolean().optional(),
|
||||||
|
isFinal: z.boolean().optional(),
|
||||||
|
canTransitionTo: z.array(z.string()).optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const prioritySchema = z.object({
|
||||||
|
code: z.string().min(1, 'Kód je povinný'),
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
level: z.number().min(1).max(10),
|
||||||
|
order: z.number().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userRoleSchema = z.object({
|
||||||
|
code: z.string().min(1, 'Kód je povinný'),
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
permissions: z.record(z.string(), z.array(z.string())),
|
||||||
|
level: z.number().min(1),
|
||||||
|
order: z.number().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const systemSettingSchema = z.object({
|
||||||
|
key: z.string().min(1, 'Kľúč je povinný'),
|
||||||
|
value: z.unknown(),
|
||||||
|
category: z.string().min(1, 'Kategória je povinná'),
|
||||||
|
label: z.string().min(1, 'Label je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
dataType: z.enum(['string', 'number', 'boolean', 'json']),
|
||||||
|
validation: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const tagSchema = z.object({
|
||||||
|
code: z.string().min(1, 'Kód je povinný'),
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
color: z.string().optional(),
|
||||||
|
entityType: z.enum(['PROJECT', 'TASK', 'EQUIPMENT', 'RMA']),
|
||||||
|
order: z.number().optional(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
export const paginationSchema = z.object({
|
||||||
|
page: z.string().optional().transform((val) => (val ? parseInt(val, 10) : 1)),
|
||||||
|
limit: z.string().optional().transform((val) => (val ? parseInt(val, 10) : 20)),
|
||||||
|
});
|
||||||
24
backend/tsconfig.json
Normal file
24
backend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
0
backend/uploads/.gitkeep
Normal file
0
backend/uploads/.gitkeep
Normal file
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="sk">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Helpdesk</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4363
frontend/package-lock.json
generated
Normal file
4363
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
frontend/package.json
Normal file
44
frontend/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@tanstack/react-query": "^5.90.20",
|
||||||
|
"axios": "^1.13.4",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^0.563.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.71.1",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"tailwind-merge": "^3.4.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.5",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.46.4",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
124
frontend/src/App.tsx
Normal file
124
frontend/src/App.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { useConfigStore } from '@/store/configStore';
|
||||||
|
import { MainLayout } from '@/components/layout';
|
||||||
|
import { LoadingOverlay } from '@/components/ui';
|
||||||
|
|
||||||
|
import { Login } from '@/pages/Login';
|
||||||
|
import { Dashboard } from '@/pages/Dashboard';
|
||||||
|
import { CustomersList } from '@/pages/customers';
|
||||||
|
import { ProjectsList } from '@/pages/projects';
|
||||||
|
import { TasksList } from '@/pages/tasks';
|
||||||
|
import { EquipmentList } from '@/pages/equipment';
|
||||||
|
import { RMAList } from '@/pages/rma';
|
||||||
|
import { SettingsDashboard } from '@/pages/settings';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isAuthenticated, user, isLoading, fetchProfile } = useAuthStore();
|
||||||
|
const { fetchConfig, isLoaded: configLoaded } = useConfigStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch profile if we have a token but no user data
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token && !user) {
|
||||||
|
fetchProfile();
|
||||||
|
}
|
||||||
|
}, [user, fetchProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated && !configLoaded) {
|
||||||
|
fetchConfig();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, configLoaded, fetchConfig]);
|
||||||
|
|
||||||
|
// Show loading only when we're actively fetching
|
||||||
|
if (isLoading && !user) {
|
||||||
|
return <LoadingOverlay message="Načítavam..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RootOnlyRoute({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
|
if (user?.role.code !== 'ROOT') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppRoutes() {
|
||||||
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/login"
|
||||||
|
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<MainLayout />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/customers" element={<CustomersList />} />
|
||||||
|
<Route path="/projects" element={<ProjectsList />} />
|
||||||
|
<Route path="/tasks" element={<TasksList />} />
|
||||||
|
<Route path="/equipment" element={<EquipmentList />} />
|
||||||
|
<Route path="/rma" element={<RMAList />} />
|
||||||
|
<Route
|
||||||
|
path="/settings"
|
||||||
|
element={
|
||||||
|
<RootOnlyRoute>
|
||||||
|
<SettingsDashboard />
|
||||||
|
</RootOnlyRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppRoutes />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 3000,
|
||||||
|
style: {
|
||||||
|
background: '#fff',
|
||||||
|
color: '#0f172a',
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/layout/Header.tsx
Normal file
46
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { LogOut, User, Settings } from 'lucide-react';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { user, logout } = useAuthStore();
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-40 border-b bg-background">
|
||||||
|
<div className="flex h-14 items-center justify-between px-4">
|
||||||
|
<Link to="/" className="flex items-center gap-2 font-semibold">
|
||||||
|
<span className="text-lg">Helpdesk</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
<span>{user.name}</span>
|
||||||
|
<span className="text-muted-foreground">({user.role.name})</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.role.code === 'ROOT' && (
|
||||||
|
<Link to="/settings">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
frontend/src/components/layout/MainLayout.tsx
Normal file
15
frontend/src/components/layout/MainLayout.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { Header } from './Header';
|
||||||
|
import { Sidebar } from './Sidebar';
|
||||||
|
|
||||||
|
export function MainLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background">
|
||||||
|
<Header />
|
||||||
|
<Sidebar />
|
||||||
|
<main className="ml-56 p-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/layout/Sidebar.tsx
Normal file
46
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NavLink } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
FolderKanban,
|
||||||
|
CheckSquare,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
RotateCcw,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ to: '/projects', icon: FolderKanban, label: 'Projekty' },
|
||||||
|
{ to: '/tasks', icon: CheckSquare, label: 'Úlohy' },
|
||||||
|
{ to: '/customers', icon: Users, label: 'Zákazníci' },
|
||||||
|
{ to: '/equipment', icon: Wrench, label: 'Zariadenia' },
|
||||||
|
{ to: '/rma', icon: RotateCcw, label: 'RMA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
return (
|
||||||
|
<aside className="fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-56 border-r bg-background">
|
||||||
|
<nav className="flex flex-col gap-1 p-4">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||||
|
isActive
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { Header } from './Header';
|
||||||
|
export { Sidebar } from './Sidebar';
|
||||||
|
export { MainLayout } from './MainLayout';
|
||||||
31
frontend/src/components/ui/Badge.tsx
Normal file
31
frontend/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||||
|
variant?: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
color?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ className, variant = 'default', color, children, style, ...props }: BadgeProps) {
|
||||||
|
const variants = {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
destructive: 'border-transparent bg-destructive text-destructive-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
const customStyle = color
|
||||||
|
? { backgroundColor: color, borderColor: color, color: '#fff', ...style }
|
||||||
|
: style;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('badge', !color && variants[variant], className)}
|
||||||
|
style={customStyle}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/components/ui/Button.tsx
Normal file
41
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { forwardRef, type ButtonHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
|
||||||
|
const variants = {
|
||||||
|
primary: 'btn-primary',
|
||||||
|
secondary: 'btn-secondary',
|
||||||
|
destructive: 'btn-destructive',
|
||||||
|
outline: 'btn-outline',
|
||||||
|
ghost: 'btn-ghost',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'btn-sm',
|
||||||
|
md: 'btn-md',
|
||||||
|
lg: 'btn-lg',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
className={cn('btn', variants[variant], sizes[size], className)}
|
||||||
|
disabled={disabled || isLoading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Button.displayName = 'Button';
|
||||||
74
frontend/src/components/ui/Card.tsx
Normal file
74
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ className, children, ...props }: CardProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('card', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ className, children, ...props }: CardHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardTitle({ className, children, ...props }: CardTitleProps) {
|
||||||
|
return (
|
||||||
|
<h3 className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</h3>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardDescription({ className, children, ...props }: CardDescriptionProps) {
|
||||||
|
return (
|
||||||
|
<p className={cn('text-sm text-muted-foreground', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardContent({ className, children, ...props }: CardContentProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('p-6 pt-0', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ className, children, ...props }: CardFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center p-6 pt-0', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/ui/Input.tsx
Normal file
34
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, error, label, id, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
'input',
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
78
frontend/src/components/ui/Modal.tsx
Normal file
78
frontend/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ isOpen, onClose, title, children, className, size = 'md' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 w-full rounded-lg bg-background p-6 shadow-lg',
|
||||||
|
sizes[size],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title && (
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">{title}</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalFooterProps {
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModalFooter({ children, className }: ModalFooterProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn('mt-6 flex justify-end gap-2', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
frontend/src/components/ui/Select.tsx
Normal file
52
frontend/src/components/ui/Select.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { forwardRef, type SelectHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
options: SelectOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ className, error, label, id, options, placeholder, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
'input appearance-none bg-background',
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{placeholder && (
|
||||||
|
<option value="" disabled>
|
||||||
|
{placeholder}
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
|
{options.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = 'Select';
|
||||||
32
frontend/src/components/ui/Spinner.tsx
Normal file
32
frontend/src/components/ui/Spinner.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SpinnerProps {
|
||||||
|
className?: string;
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ className, size = 'md' }: SpinnerProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Loader2 className={cn('animate-spin text-primary', sizes[size], className)} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoadingOverlayProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingOverlay({ message = 'Načítavam...' }: LoadingOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full min-h-[200px] items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Spinner size="lg" />
|
||||||
|
<p className="text-sm text-muted-foreground">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
frontend/src/components/ui/Table.tsx
Normal file
85
frontend/src/components/ui/Table.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type { HTMLAttributes, ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TableProps extends HTMLAttributes<HTMLTableElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Table({ className, children, ...props }: TableProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-full overflow-auto">
|
||||||
|
<table className={cn('w-full caption-bottom text-sm', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHeader({ className, children, ...props }: TableHeaderProps) {
|
||||||
|
return (
|
||||||
|
<thead className={cn('[&_tr]:border-b', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableBody({ className, children, ...props }: TableBodyProps) {
|
||||||
|
return (
|
||||||
|
<tbody className={cn('[&_tr:last-child]:border-0', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableRow({ className, children, ...props }: TableRowProps) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHead({ className, children, ...props }: TableHeadProps) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
className={cn(
|
||||||
|
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableCell({ className, children, ...props }: TableCellProps) {
|
||||||
|
return (
|
||||||
|
<td className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/ui/Textarea.tsx
Normal file
34
frontend/src/components/ui/Textarea.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
error?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, error, label, id, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{label && (
|
||||||
|
<label htmlFor={id} className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
error && 'border-destructive focus-visible:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
195
frontend/src/components/ui/UserSelect.tsx
Normal file
195
frontend/src/components/ui/UserSelect.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { X, Search, ChevronDown, Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSelectProps {
|
||||||
|
selectedIds: string[];
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
initialUsers?: User[]; // Pre-loaded users (napr. pri editácii)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSelect({
|
||||||
|
selectedIds,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
placeholder = 'Vyhľadať používateľa...',
|
||||||
|
initialUsers = [],
|
||||||
|
}: UserSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [users, setUsers] = useState<User[]>(initialUsers);
|
||||||
|
const [selectedUsers, setSelectedUsers] = useState<User[]>(initialUsers.filter(u => selectedIds.includes(u.id)));
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// Zatvoriť dropdown pri kliknutí mimo
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Načítať používateľov pri otvorení alebo zmene vyhľadávania
|
||||||
|
const fetchUsers = useCallback(async (searchQuery: string) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.getUsersSimple(searchQuery || undefined);
|
||||||
|
setUsers(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounced search
|
||||||
|
useEffect(() => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
fetchUsers(search);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) {
|
||||||
|
clearTimeout(debounceRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [search, isOpen, fetchUsers]);
|
||||||
|
|
||||||
|
// Načítať počiatočných používateľov pri otvorení
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && users.length === 0) {
|
||||||
|
fetchUsers('');
|
||||||
|
}
|
||||||
|
}, [isOpen, users.length, fetchUsers]);
|
||||||
|
|
||||||
|
const filteredUsers = users.filter((u) => !selectedIds.includes(u.id));
|
||||||
|
|
||||||
|
const handleSelect = (user: User) => {
|
||||||
|
onChange([...selectedIds, user.id]);
|
||||||
|
setSelectedUsers([...selectedUsers, user]);
|
||||||
|
setSearch('');
|
||||||
|
inputRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemove = (userId: string) => {
|
||||||
|
onChange(selectedIds.filter((id) => id !== userId));
|
||||||
|
setSelectedUsers(selectedUsers.filter((u) => u.id !== userId));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1" ref={containerRef}>
|
||||||
|
{label && (
|
||||||
|
<label className="text-sm font-medium text-foreground">{label}</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'min-h-10 w-full rounded-md border border-input bg-background px-3 py-2',
|
||||||
|
'focus-within:ring-2 focus-within:ring-ring',
|
||||||
|
isOpen && 'ring-2 ring-ring'
|
||||||
|
)}
|
||||||
|
onClick={() => {
|
||||||
|
setIsOpen(true);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Vybraní používatelia ako tagy */}
|
||||||
|
<div className="flex flex-wrap gap-1 mb-1">
|
||||||
|
{selectedUsers.map((user) => (
|
||||||
|
<span
|
||||||
|
key={user.id}
|
||||||
|
className="inline-flex items-center gap-1 bg-primary/10 text-primary rounded-md px-2 py-0.5 text-sm"
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemove(user.id);
|
||||||
|
}}
|
||||||
|
className="hover:bg-primary/20 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vyhľadávací input */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Search className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
placeholder={selectedUsers.length === 0 ? placeholder : 'Pridať ďalšieho...'}
|
||||||
|
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
|
||||||
|
/>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className="h-4 w-4 text-muted-foreground animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown
|
||||||
|
className={cn(
|
||||||
|
'h-4 w-4 text-muted-foreground transition-transform',
|
||||||
|
isOpen && 'rotate-180'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dropdown so zoznamom */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="absolute z-50 w-full mt-1 bg-popover border border-input rounded-md shadow-lg max-h-48 overflow-y-auto">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Načítavam...
|
||||||
|
</div>
|
||||||
|
) : filteredUsers.length === 0 ? (
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
{search ? 'Žiadne výsledky' : 'Všetci používatelia sú vybraní'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredUsers.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSelect(user)}
|
||||||
|
className="w-full px-3 py-2 text-left hover:bg-accent flex flex-col"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium">{user.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
frontend/src/components/ui/index.ts
Normal file
10
frontend/src/components/ui/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export { Button, type ButtonProps } from './Button';
|
||||||
|
export { Input, type InputProps } from './Input';
|
||||||
|
export { Textarea, type TextareaProps } from './Textarea';
|
||||||
|
export { Select, type SelectProps, type SelectOption } from './Select';
|
||||||
|
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
|
||||||
|
export { Badge } from './Badge';
|
||||||
|
export { Modal, ModalFooter } from './Modal';
|
||||||
|
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
|
||||||
|
export { Spinner, LoadingOverlay } from './Spinner';
|
||||||
|
export { UserSelect } from './UserSelect';
|
||||||
88
frontend/src/index.css
Normal file
88
frontend/src/index.css
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-foreground: #ffffff;
|
||||||
|
--color-secondary: #f1f5f9;
|
||||||
|
--color-secondary-foreground: #0f172a;
|
||||||
|
--color-destructive: #ef4444;
|
||||||
|
--color-destructive-foreground: #ffffff;
|
||||||
|
--color-muted: #f1f5f9;
|
||||||
|
--color-muted-foreground: #64748b;
|
||||||
|
--color-accent: #f1f5f9;
|
||||||
|
--color-accent-foreground: #0f172a;
|
||||||
|
--color-background: #ffffff;
|
||||||
|
--color-foreground: #0f172a;
|
||||||
|
--color-card: #ffffff;
|
||||||
|
--color-card-foreground: #0f172a;
|
||||||
|
--color-popover: #ffffff;
|
||||||
|
--color-popover-foreground: #0f172a;
|
||||||
|
--color-border: #e2e8f0;
|
||||||
|
--color-input: #e2e8f0;
|
||||||
|
--color-ring: #3b82f6;
|
||||||
|
--radius-sm: 0.25rem;
|
||||||
|
--radius-md: 0.375rem;
|
||||||
|
--radius-lg: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply bg-primary text-primary-foreground hover:bg-primary/90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-destructive {
|
||||||
|
@apply bg-destructive text-destructive-foreground hover:bg-destructive/90;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline {
|
||||||
|
@apply border border-input bg-background hover:bg-accent hover:text-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply hover:bg-accent hover:text-accent-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-sm {
|
||||||
|
@apply h-8 px-3 text-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-md {
|
||||||
|
@apply h-10 px-4 py-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-lg {
|
||||||
|
@apply h-12 px-6 text-base;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
frontend/src/lib/utils.ts
Normal file
37
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
return new Date(date).toLocaleDateString('sk-SK', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: string | Date): string {
|
||||||
|
return new Date(date).toLocaleString('sk-SK', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(date: string | Date): string {
|
||||||
|
const now = new Date();
|
||||||
|
const target = new Date(date);
|
||||||
|
const diffMs = now.getTime() - target.getTime();
|
||||||
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (diffDays === 0) return 'Dnes';
|
||||||
|
if (diffDays === 1) return 'Včera';
|
||||||
|
if (diffDays < 7) return `Pred ${diffDays} dňami`;
|
||||||
|
if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} týždňami`;
|
||||||
|
return formatDate(date);
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
384
frontend/src/pages/Dashboard.tsx
Normal file
384
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FolderKanban,
|
||||||
|
CheckSquare,
|
||||||
|
Users,
|
||||||
|
Wrench,
|
||||||
|
RotateCcw,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
|
User,
|
||||||
|
AlertCircle
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { get } from '@/services/api';
|
||||||
|
import { Card, CardHeader, CardTitle, CardContent, Badge, LoadingOverlay } from '@/components/ui';
|
||||||
|
import { TaskDetail } from '@/pages/tasks/TaskDetail';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import type { Task, Project } from '@/types';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
projects: { total: number; active: number };
|
||||||
|
tasks: { total: number; pending: number; inProgress: number };
|
||||||
|
customers: { total: number; active: number };
|
||||||
|
equipment: { total: number; upcomingRevisions: number };
|
||||||
|
rma: { total: number; pending: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardToday {
|
||||||
|
myTasks: Task[];
|
||||||
|
myProjects: Project[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: statsData, isLoading: statsLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: () => get<DashboardStats>('/dashboard'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: todayData, isLoading: todayLoading } = useQuery({
|
||||||
|
queryKey: ['dashboard-today'],
|
||||||
|
queryFn: () => get<DashboardToday>('/dashboard/today'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statsLoading || todayLoading) {
|
||||||
|
return <LoadingOverlay />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = statsData?.data;
|
||||||
|
const today = todayData?.data;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: 'Projekty',
|
||||||
|
icon: FolderKanban,
|
||||||
|
value: stats?.projects.total ?? 0,
|
||||||
|
subtitle: `${stats?.projects.active ?? 0} aktívnych`,
|
||||||
|
color: 'text-blue-500',
|
||||||
|
bgColor: 'bg-blue-50 dark:bg-blue-950/30',
|
||||||
|
href: '/projects',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Úlohy',
|
||||||
|
icon: CheckSquare,
|
||||||
|
value: stats?.tasks.total ?? 0,
|
||||||
|
subtitle: `${stats?.tasks.inProgress ?? 0} v progrese`,
|
||||||
|
color: 'text-green-500',
|
||||||
|
bgColor: 'bg-green-50 dark:bg-green-950/30',
|
||||||
|
href: '/tasks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zákazníci',
|
||||||
|
icon: Users,
|
||||||
|
value: stats?.customers.total ?? 0,
|
||||||
|
subtitle: `${stats?.customers.active ?? 0} aktívnych`,
|
||||||
|
color: 'text-purple-500',
|
||||||
|
bgColor: 'bg-purple-50 dark:bg-purple-950/30',
|
||||||
|
href: '/customers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Zariadenia',
|
||||||
|
icon: Wrench,
|
||||||
|
value: stats?.equipment.total ?? 0,
|
||||||
|
subtitle: `${stats?.equipment.upcomingRevisions ?? 0} revízií`,
|
||||||
|
color: 'text-orange-500',
|
||||||
|
bgColor: 'bg-orange-50 dark:bg-orange-950/30',
|
||||||
|
href: '/equipment',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'RMA',
|
||||||
|
icon: RotateCcw,
|
||||||
|
value: stats?.rma.total ?? 0,
|
||||||
|
subtitle: `${stats?.rma.pending ?? 0} otvorených`,
|
||||||
|
color: 'text-red-500',
|
||||||
|
bgColor: 'bg-red-50 dark:bg-red-950/30',
|
||||||
|
href: '/rma',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rozdelenie úloh podľa urgentnosti
|
||||||
|
const urgentTasks = today?.myTasks?.filter(t => {
|
||||||
|
if (!t.deadline) return false;
|
||||||
|
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
return daysUntil <= 2;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const normalTasks = today?.myTasks?.filter(t => {
|
||||||
|
if (!t.deadline) return true;
|
||||||
|
const daysUntil = Math.ceil((new Date(t.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
return daysUntil > 2;
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
const isOverdue = (deadline: string) => {
|
||||||
|
return new Date(deadline) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDaysUntilDeadline = (deadline: string) => {
|
||||||
|
const days = Math.ceil((new Date(deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (days < 0) return `${Math.abs(days)} dní po termíne`;
|
||||||
|
if (days === 0) return 'Dnes';
|
||||||
|
if (days === 1) return 'Zajtra';
|
||||||
|
return `${days} dní`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{new Date().toLocaleDateString('sk-SK', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Štatistické karty */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link key={card.title} to={card.href}>
|
||||||
|
<Card className={`hover:border-primary/50 transition-colors cursor-pointer ${card.bgColor}`}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
|
||||||
|
<card.icon className={`h-5 w-5 ${card.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-3xl font-bold">{card.value}</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{card.subtitle}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Urgentné úlohy - zobrazí sa len ak existujú */}
|
||||||
|
{urgentTasks.length > 0 && (
|
||||||
|
<Card className="border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-800">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||||
|
<AlertCircle className="h-5 w-5" />
|
||||||
|
Urgentné úlohy ({urgentTasks.length})
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{urgentTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => setDetailTaskId(task.id)}
|
||||||
|
className="flex items-center justify-between p-3 rounded-lg bg-white dark:bg-background border border-red-200 dark:border-red-800 cursor-pointer hover:border-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium truncate">{task.title}</p>
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-sm text-muted-foreground truncate mt-0.5">{task.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3 mt-2 text-xs">
|
||||||
|
{task.project && (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<FolderKanban className="h-3 w-3" />
|
||||||
|
{task.project.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.createdBy && (
|
||||||
|
<span className="flex items-center gap-1 text-muted-foreground">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
{task.createdBy.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-4">
|
||||||
|
{task.deadline && (
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded ${isOverdue(task.deadline) ? 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' : 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300'}`}>
|
||||||
|
{getDaysUntilDeadline(task.deadline)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge color={task.priority?.color}>{task.priority?.name}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
{/* Moje úlohy */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<CheckSquare className="h-5 w-5 text-green-500" />
|
||||||
|
Moje úlohy
|
||||||
|
{today?.myTasks && today.myTasks.length > 0 && (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
({today.myTasks.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<Link to="/tasks" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||||
|
Všetky <ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{normalTasks.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{normalTasks.slice(0, 5).map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => setDetailTaskId(task.id)}
|
||||||
|
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{task.title}</p>
|
||||||
|
{task.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-2 mt-1">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1">
|
||||||
|
<Badge color={task.status?.color} className="text-xs">{task.status?.name}</Badge>
|
||||||
|
<Badge color={task.priority?.color} className="text-xs">{task.priority?.name}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||||
|
{task.project && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FolderKanban className="h-3 w-3" />
|
||||||
|
{task.project.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.createdBy && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<User className="h-3 w-3" />
|
||||||
|
Zadal: {task.createdBy.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.deadline && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarClock className="h-3 w-3" />
|
||||||
|
{formatDate(task.deadline)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{normalTasks.length > 5 && (
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
+{normalTasks.length - 5} ďalších úloh
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : today?.myTasks?.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<CheckSquare className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">Nemáte žiadne priradené úlohy</p>
|
||||||
|
<Link to="/tasks" className="text-sm text-primary hover:underline mt-2 inline-block">
|
||||||
|
Zobraziť všetky úlohy →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Moje projekty */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<FolderKanban className="h-5 w-5 text-blue-500" />
|
||||||
|
Moje projekty
|
||||||
|
{today?.myProjects && today.myProjects.length > 0 && (
|
||||||
|
<span className="text-sm font-normal text-muted-foreground">
|
||||||
|
({today.myProjects.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</CardTitle>
|
||||||
|
<Link to="/projects" className="text-sm text-primary hover:underline flex items-center gap-1">
|
||||||
|
Všetky <ArrowRight className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{today?.myProjects && today.myProjects.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{today.myProjects.map((project) => (
|
||||||
|
<div
|
||||||
|
key={project.id}
|
||||||
|
className="p-3 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium">{project.name}</p>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-muted-foreground line-clamp-1 mt-1">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge color={project.status?.color}>{project.status?.name}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckSquare className="h-3 w-3" />
|
||||||
|
{project._count?.tasks ?? 0} úloh
|
||||||
|
</span>
|
||||||
|
{project.hardDeadline && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CalendarClock className="h-3 w-3" />
|
||||||
|
Termín: {formatDate(project.hardDeadline)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<FolderKanban className="h-12 w-12 text-muted-foreground/30 mx-auto mb-3" />
|
||||||
|
<p className="text-muted-foreground">Nemáte žiadne aktívne projekty</p>
|
||||||
|
<Link to="/projects" className="text-sm text-primary hover:underline mt-2 inline-block">
|
||||||
|
Zobraziť všetky projekty →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upozornenie na revízie */}
|
||||||
|
{(stats?.equipment.upcomingRevisions ?? 0) > 0 && (
|
||||||
|
<Card className="border-orange-200 bg-orange-50 dark:bg-orange-950/20 dark:border-orange-800">
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-orange-500" />
|
||||||
|
<CardTitle className="text-orange-700 dark:text-orange-400">Blížiace sa revízie</CardTitle>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-orange-600 dark:text-orange-300">
|
||||||
|
Máte {stats?.equipment.upcomingRevisions} zariadení s blížiacou sa revíziou v nasledujúcich 30 dňoch.
|
||||||
|
</p>
|
||||||
|
<Link to="/equipment" className="text-sm text-orange-700 dark:text-orange-400 hover:underline mt-2 inline-block font-medium">
|
||||||
|
Skontrolovať zariadenia →
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Detail úlohy */}
|
||||||
|
{detailTaskId && (
|
||||||
|
<TaskDetail
|
||||||
|
taskId={detailTaskId}
|
||||||
|
onClose={() => {
|
||||||
|
setDetailTaskId(null);
|
||||||
|
// Refresh dashboard data po zatvorení
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
frontend/src/pages/Login.tsx
Normal file
82
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Neplatný email'),
|
||||||
|
password: z.string().min(1, 'Heslo je povinné'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginFormData = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { login, isAuthenticated } = useAuthStore();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// Redirect when authenticated
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/', { replace: true });
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<LoginFormData>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginFormData) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
await login(data);
|
||||||
|
toast.success('Úspešne prihlásený');
|
||||||
|
} catch {
|
||||||
|
toast.error('Neplatné prihlasovacie údaje');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-muted/50">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="text-center">
|
||||||
|
<CardTitle className="text-2xl">Helpdesk</CardTitle>
|
||||||
|
<p className="text-muted-foreground">Prihláste sa do svojho účtu</p>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="vas@email.sk"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
label="Heslo"
|
||||||
|
placeholder="••••••••"
|
||||||
|
error={errors.password?.message}
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
|
<Button type="submit" className="w-full" isLoading={isLoading}>
|
||||||
|
Prihlásiť sa
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
188
frontend/src/pages/customers/CustomerForm.tsx
Normal file
188
frontend/src/pages/customers/CustomerForm.tsx
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { customersApi, type CreateCustomerData } from '@/services/customers.api';
|
||||||
|
import type { Customer } from '@/types';
|
||||||
|
import { Button, Input, Textarea, ModalFooter } from '@/components/ui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const customerSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
address: z.string().optional(),
|
||||||
|
email: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||||
|
phone: z.string().optional(),
|
||||||
|
ico: z.string().optional(),
|
||||||
|
dic: z.string().optional(),
|
||||||
|
icdph: z.string().optional(),
|
||||||
|
contactPerson: z.string().optional(),
|
||||||
|
contactEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||||
|
contactPhone: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
active: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
type CustomerFormData = z.input<typeof customerSchema>;
|
||||||
|
|
||||||
|
interface CustomerFormProps {
|
||||||
|
customer: Customer | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerForm({ customer, onClose }: CustomerFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = !!customer;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CustomerFormData>({
|
||||||
|
resolver: zodResolver(customerSchema),
|
||||||
|
defaultValues: customer
|
||||||
|
? {
|
||||||
|
name: customer.name,
|
||||||
|
address: customer.address || '',
|
||||||
|
email: customer.email || '',
|
||||||
|
phone: customer.phone || '',
|
||||||
|
ico: customer.ico || '',
|
||||||
|
dic: customer.dic || '',
|
||||||
|
icdph: customer.icdph || '',
|
||||||
|
contactPerson: customer.contactPerson || '',
|
||||||
|
contactEmail: customer.contactEmail || '',
|
||||||
|
contactPhone: customer.contactPhone || '',
|
||||||
|
notes: customer.notes || '',
|
||||||
|
active: customer.active,
|
||||||
|
}
|
||||||
|
: { active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateCustomerData) => customersApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
toast.success('Zákazník bol vytvorený');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri vytváraní zákazníka');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateCustomerData) => customersApi.update(customer!.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
toast.success('Zákazník bol aktualizovaný');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri aktualizácii zákazníka');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: CustomerFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
email: data.email || undefined,
|
||||||
|
contactEmail: data.contactEmail || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateMutation.mutate(cleanData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(cleanData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label="Názov *"
|
||||||
|
error={errors.name?.message}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
error={errors.email?.message}
|
||||||
|
{...register('email')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="phone"
|
||||||
|
label="Telefón"
|
||||||
|
{...register('phone')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
label="Adresa"
|
||||||
|
{...register('address')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="ico"
|
||||||
|
label="IČO"
|
||||||
|
{...register('ico')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="dic"
|
||||||
|
label="DIČ"
|
||||||
|
{...register('dic')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="icdph"
|
||||||
|
label="IČ DPH"
|
||||||
|
{...register('icdph')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
<h3 className="font-medium">Kontaktná osoba</h3>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Input
|
||||||
|
id="contactPerson"
|
||||||
|
label="Meno"
|
||||||
|
{...register('contactPerson')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="contactEmail"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
error={errors.contactEmail?.message}
|
||||||
|
{...register('contactEmail')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="contactPhone"
|
||||||
|
label="Telefón"
|
||||||
|
{...register('contactPhone')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
label="Poznámky"
|
||||||
|
rows={3}
|
||||||
|
{...register('notes')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" {...register('active')} className="rounded" />
|
||||||
|
<span className="text-sm">Aktívny</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
frontend/src/pages/customers/CustomersList.tsx
Normal file
164
frontend/src/pages/customers/CustomersList.tsx
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
|
import { customersApi } from '@/services/customers.api';
|
||||||
|
import type { Customer } from '@/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { CustomerForm } from './CustomerForm';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function CustomersList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<Customer | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['customers', search],
|
||||||
|
queryFn: () => customersApi.getAll({ search, limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => customersApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['customers'] });
|
||||||
|
toast.success('Zákazník bol vymazaný');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri mazaní zákazníka');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (customer: Customer) => {
|
||||||
|
setEditingCustomer(customer);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingCustomer(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Zákazníci</h1>
|
||||||
|
<Button onClick={() => setIsFormOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nový zákazník
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Hľadať zákazníkov..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingOverlay />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Názov</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Telefón</TableHead>
|
||||||
|
<TableHead>IČO</TableHead>
|
||||||
|
<TableHead>Stav</TableHead>
|
||||||
|
<TableHead className="text-right">Akcie</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((customer) => (
|
||||||
|
<TableRow key={customer.id}>
|
||||||
|
<TableCell className="font-medium">{customer.name}</TableCell>
|
||||||
|
<TableCell>{customer.email || '-'}</TableCell>
|
||||||
|
<TableCell>{customer.phone || '-'}</TableCell>
|
||||||
|
<TableCell>{customer.ico || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={customer.active ? 'default' : 'secondary'}>
|
||||||
|
{customer.active ? 'Aktívny' : 'Neaktívny'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(customer)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(customer)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.data.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
Žiadni zákazníci
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
title={editingCustomer ? 'Upraviť zákazníka' : 'Nový zákazník'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<CustomerForm customer={editingCustomer} onClose={handleCloseForm} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
title="Potvrdiť vymazanie"
|
||||||
|
>
|
||||||
|
<p>Naozaj chcete vymazať zákazníka "{deleteConfirm?.name}"?</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Vymazať
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/pages/customers/index.ts
Normal file
1
frontend/src/pages/customers/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { CustomersList } from './CustomersList';
|
||||||
229
frontend/src/pages/equipment/EquipmentForm.tsx
Normal file
229
frontend/src/pages/equipment/EquipmentForm.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { equipmentApi, type CreateEquipmentData } from '@/services/equipment.api';
|
||||||
|
import { customersApi } from '@/services/customers.api';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
import type { Equipment } from '@/types';
|
||||||
|
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const equipmentSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
typeId: z.string().min(1, 'Typ je povinný'),
|
||||||
|
brand: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
address: z.string().min(1, 'Adresa je povinná'),
|
||||||
|
location: z.string().optional(),
|
||||||
|
partNumber: z.string().optional(),
|
||||||
|
serialNumber: z.string().optional(),
|
||||||
|
installDate: z.string().optional(),
|
||||||
|
warrantyEnd: z.string().optional(),
|
||||||
|
warrantyStatus: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
notes: z.string().optional(),
|
||||||
|
active: z.boolean().optional().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
type EquipmentFormData = z.input<typeof equipmentSchema>;
|
||||||
|
|
||||||
|
interface EquipmentFormProps {
|
||||||
|
equipment: Equipment | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = !!equipment;
|
||||||
|
|
||||||
|
const { data: customersData } = useQuery({
|
||||||
|
queryKey: ['customers-select'],
|
||||||
|
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: typesData } = useQuery({
|
||||||
|
queryKey: ['equipment-types'],
|
||||||
|
queryFn: () => settingsApi.getEquipmentTypes(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<EquipmentFormData>({
|
||||||
|
resolver: zodResolver(equipmentSchema),
|
||||||
|
defaultValues: equipment
|
||||||
|
? {
|
||||||
|
name: equipment.name,
|
||||||
|
typeId: equipment.typeId,
|
||||||
|
brand: equipment.brand || '',
|
||||||
|
model: equipment.model || '',
|
||||||
|
customerId: equipment.customerId || '',
|
||||||
|
address: equipment.address,
|
||||||
|
location: equipment.location || '',
|
||||||
|
partNumber: equipment.partNumber || '',
|
||||||
|
serialNumber: equipment.serialNumber || '',
|
||||||
|
installDate: equipment.installDate?.split('T')[0] || '',
|
||||||
|
warrantyEnd: equipment.warrantyEnd?.split('T')[0] || '',
|
||||||
|
warrantyStatus: equipment.warrantyStatus || '',
|
||||||
|
description: equipment.description || '',
|
||||||
|
notes: equipment.notes || '',
|
||||||
|
active: equipment.active,
|
||||||
|
}
|
||||||
|
: { active: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateEquipmentData) => equipmentApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['equipment'] });
|
||||||
|
toast.success('Zariadenie bolo vytvorené');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri vytváraní zariadenia');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateEquipmentData) => equipmentApi.update(equipment!.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['equipment'] });
|
||||||
|
toast.success('Zariadenie bolo aktualizované');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri aktualizácii zariadenia');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: EquipmentFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
customerId: data.customerId || undefined,
|
||||||
|
installDate: data.installDate || undefined,
|
||||||
|
warrantyEnd: data.warrantyEnd || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateMutation.mutate(cleanData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(cleanData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
|
||||||
|
const typeOptions = typesData?.data.map((t) => ({ value: t.id, label: t.name })) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label="Názov *"
|
||||||
|
error={errors.name?.message}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="typeId"
|
||||||
|
label="Typ *"
|
||||||
|
options={typeOptions}
|
||||||
|
error={errors.typeId?.message}
|
||||||
|
{...register('typeId')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="brand"
|
||||||
|
label="Značka"
|
||||||
|
{...register('brand')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="model"
|
||||||
|
label="Model"
|
||||||
|
{...register('model')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="customerId"
|
||||||
|
label="Zákazník"
|
||||||
|
options={[{ value: '', label: '-- Bez zákazníka --' }, ...customerOptions]}
|
||||||
|
{...register('customerId')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="partNumber"
|
||||||
|
label="Part Number"
|
||||||
|
{...register('partNumber')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="serialNumber"
|
||||||
|
label="Sériové číslo"
|
||||||
|
{...register('serialNumber')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="address"
|
||||||
|
label="Adresa *"
|
||||||
|
error={errors.address?.message}
|
||||||
|
{...register('address')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="location"
|
||||||
|
label="Umiestnenie"
|
||||||
|
placeholder="napr. 2. poschodie, serverovňa"
|
||||||
|
{...register('location')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<Input
|
||||||
|
id="installDate"
|
||||||
|
type="date"
|
||||||
|
label="Dátum inštalácie"
|
||||||
|
{...register('installDate')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="warrantyEnd"
|
||||||
|
type="date"
|
||||||
|
label="Záruka do"
|
||||||
|
{...register('warrantyEnd')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="warrantyStatus"
|
||||||
|
label="Stav záruky"
|
||||||
|
{...register('warrantyStatus')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
label="Popis"
|
||||||
|
rows={2}
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="notes"
|
||||||
|
label="Poznámky"
|
||||||
|
rows={2}
|
||||||
|
{...register('notes')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" {...register('active')} className="rounded" />
|
||||||
|
<span className="text-sm">Aktívne</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
173
frontend/src/pages/equipment/EquipmentList.tsx
Normal file
173
frontend/src/pages/equipment/EquipmentList.tsx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
|
import { equipmentApi } from '@/services/equipment.api';
|
||||||
|
import type { Equipment } from '@/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { EquipmentForm } from './EquipmentForm';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function EquipmentList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingEquipment, setEditingEquipment] = useState<Equipment | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<Equipment | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['equipment', search],
|
||||||
|
queryFn: () => equipmentApi.getAll({ search, limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => equipmentApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['equipment'] });
|
||||||
|
toast.success('Zariadenie bolo vymazané');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri mazaní zariadenia');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (equipment: Equipment) => {
|
||||||
|
setEditingEquipment(equipment);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingEquipment(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Zariadenia</h1>
|
||||||
|
<Button onClick={() => setIsFormOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nové zariadenie
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Hľadať zariadenia..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingOverlay />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Názov</TableHead>
|
||||||
|
<TableHead>Typ</TableHead>
|
||||||
|
<TableHead>Zákazník</TableHead>
|
||||||
|
<TableHead>Adresa</TableHead>
|
||||||
|
<TableHead>Sériové číslo</TableHead>
|
||||||
|
<TableHead>Záruka do</TableHead>
|
||||||
|
<TableHead>Stav</TableHead>
|
||||||
|
<TableHead className="text-right">Akcie</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((equipment) => (
|
||||||
|
<TableRow key={equipment.id}>
|
||||||
|
<TableCell className="font-medium">{equipment.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge color={equipment.type.color}>{equipment.type.name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{equipment.customer?.name || '-'}</TableCell>
|
||||||
|
<TableCell className="max-w-[200px] truncate">{equipment.address}</TableCell>
|
||||||
|
<TableCell>{equipment.serialNumber || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{equipment.warrantyEnd ? formatDate(equipment.warrantyEnd) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={equipment.active ? 'default' : 'secondary'}>
|
||||||
|
{equipment.active ? 'Aktívne' : 'Neaktívne'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(equipment)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(equipment)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.data.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
|
Žiadne zariadenia
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
title={editingEquipment ? 'Upraviť zariadenie' : 'Nové zariadenie'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<EquipmentForm equipment={editingEquipment} onClose={handleCloseForm} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
title="Potvrdiť vymazanie"
|
||||||
|
>
|
||||||
|
<p>Naozaj chcete vymazať zariadenie "{deleteConfirm?.name}"?</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Vymazať
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/pages/equipment/index.ts
Normal file
1
frontend/src/pages/equipment/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { EquipmentList } from './EquipmentList';
|
||||||
191
frontend/src/pages/projects/ProjectForm.tsx
Normal file
191
frontend/src/pages/projects/ProjectForm.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { projectsApi, type CreateProjectData } from '@/services/projects.api';
|
||||||
|
import { customersApi } from '@/services/customers.api';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const projectSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
ownerId: z.string().optional(), // Backend nastaví aktuálneho používateľa ak prázdne
|
||||||
|
statusId: z.string().optional(), // Backend nastaví predvolený ak prázdne
|
||||||
|
softDeadline: z.string().optional(),
|
||||||
|
hardDeadline: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type ProjectFormData = z.infer<typeof projectSchema>;
|
||||||
|
|
||||||
|
interface ProjectFormProps {
|
||||||
|
project: Project | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectForm({ project, onClose }: ProjectFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const isEditing = !!project;
|
||||||
|
|
||||||
|
const { data: customersData } = useQuery({
|
||||||
|
queryKey: ['customers-select'],
|
||||||
|
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statusesData } = useQuery({
|
||||||
|
queryKey: ['task-statuses'],
|
||||||
|
queryFn: () => settingsApi.getTaskStatuses(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: usersData } = useQuery({
|
||||||
|
queryKey: ['users-select'],
|
||||||
|
queryFn: () => settingsApi.getUsers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<ProjectFormData>({
|
||||||
|
resolver: zodResolver(projectSchema),
|
||||||
|
defaultValues: project
|
||||||
|
? {
|
||||||
|
name: project.name,
|
||||||
|
description: project.description || '',
|
||||||
|
customerId: project.customerId || '',
|
||||||
|
ownerId: project.ownerId,
|
||||||
|
statusId: project.statusId,
|
||||||
|
softDeadline: project.softDeadline?.split('T')[0] || '',
|
||||||
|
hardDeadline: project.hardDeadline?.split('T')[0] || '',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
ownerId: user?.id || '',
|
||||||
|
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateProjectData) => projectsApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
toast.success('Projekt bol vytvorený');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
console.error('Create project error:', error);
|
||||||
|
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||||
|
const message = axiosError.response?.data?.message || 'Chyba pri vytváraní projektu';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateProjectData) => projectsApi.update(project!.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
toast.success('Projekt bol aktualizovaný');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
console.error('Update project error:', error);
|
||||||
|
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||||
|
const message = axiosError.response?.data?.message || 'Chyba pri aktualizácii projektu';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: ProjectFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
customerId: data.customerId || undefined,
|
||||||
|
ownerId: data.ownerId || undefined,
|
||||||
|
statusId: data.statusId || undefined,
|
||||||
|
softDeadline: data.softDeadline || undefined,
|
||||||
|
hardDeadline: data.hardDeadline || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submitting project data:', cleanData);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateMutation.mutate(cleanData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(cleanData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
|
||||||
|
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||||
|
const userOptions = usersData?.data.map((u) => ({ value: u.id, label: u.name })) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
label="Názov *"
|
||||||
|
error={errors.name?.message}
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
label="Popis"
|
||||||
|
rows={3}
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
id="customerId"
|
||||||
|
label="Zákazník"
|
||||||
|
options={[{ value: '', label: '-- Bez zákazníka --' }, ...customerOptions]}
|
||||||
|
{...register('customerId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="ownerId"
|
||||||
|
label="Vlastník *"
|
||||||
|
options={userOptions}
|
||||||
|
error={errors.ownerId?.message}
|
||||||
|
{...register('ownerId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="statusId"
|
||||||
|
label="Stav *"
|
||||||
|
options={statusOptions}
|
||||||
|
error={errors.statusId?.message}
|
||||||
|
{...register('statusId')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
id="softDeadline"
|
||||||
|
type="date"
|
||||||
|
label="Mäkký termín"
|
||||||
|
{...register('softDeadline')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="hardDeadline"
|
||||||
|
type="date"
|
||||||
|
label="Tvrdý termín"
|
||||||
|
{...register('hardDeadline')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
frontend/src/pages/projects/ProjectsList.tsx
Normal file
165
frontend/src/pages/projects/ProjectsList.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
|
import { projectsApi } from '@/services/projects.api';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { ProjectForm } from './ProjectForm';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function ProjectsList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<Project | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['projects', search],
|
||||||
|
queryFn: () => projectsApi.getAll({ search, limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => projectsApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['projects'] });
|
||||||
|
toast.success('Projekt bol vymazaný');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri mazaní projektu');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (project: Project) => {
|
||||||
|
setEditingProject(project);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingProject(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Projekty</h1>
|
||||||
|
<Button onClick={() => setIsFormOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nový projekt
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Hľadať projekty..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingOverlay />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Názov</TableHead>
|
||||||
|
<TableHead>Zákazník</TableHead>
|
||||||
|
<TableHead>Vlastník</TableHead>
|
||||||
|
<TableHead>Stav</TableHead>
|
||||||
|
<TableHead>Termín</TableHead>
|
||||||
|
<TableHead className="text-right">Akcie</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((project) => (
|
||||||
|
<TableRow key={project.id}>
|
||||||
|
<TableCell className="font-medium">{project.name}</TableCell>
|
||||||
|
<TableCell>{project.customer?.name || '-'}</TableCell>
|
||||||
|
<TableCell>{project.owner.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge color={project.status.color}>{project.status.name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{project.hardDeadline ? formatDate(project.hardDeadline) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(project)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(project)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.data.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
Žiadne projekty
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
title={editingProject ? 'Upraviť projekt' : 'Nový projekt'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<ProjectForm project={editingProject} onClose={handleCloseForm} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
title="Potvrdiť vymazanie"
|
||||||
|
>
|
||||||
|
<p>Naozaj chcete vymazať projekt "{deleteConfirm?.name}"?</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Vymazať
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/pages/projects/index.ts
Normal file
1
frontend/src/pages/projects/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectsList } from './ProjectsList';
|
||||||
309
frontend/src/pages/rma/RMAForm.tsx
Normal file
309
frontend/src/pages/rma/RMAForm.tsx
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { rmaApi, type CreateRMAData, type UpdateRMAData } from '@/services/rma.api';
|
||||||
|
import { customersApi } from '@/services/customers.api';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
import type { RMA } from '@/types';
|
||||||
|
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const rmaSchema = z.object({
|
||||||
|
customerId: z.string().optional(),
|
||||||
|
customerName: z.string().optional(),
|
||||||
|
customerAddress: z.string().optional(),
|
||||||
|
customerEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
|
||||||
|
customerPhone: z.string().optional(),
|
||||||
|
customerICO: z.string().optional(),
|
||||||
|
submittedBy: z.string().min(1, 'Podávajúci je povinný'),
|
||||||
|
productName: z.string().min(1, 'Názov produktu je povinný'),
|
||||||
|
invoiceNumber: z.string().optional(),
|
||||||
|
purchaseDate: z.string().optional(),
|
||||||
|
productNumber: z.string().optional(),
|
||||||
|
serialNumber: z.string().optional(),
|
||||||
|
accessories: z.string().optional(),
|
||||||
|
issueDescription: z.string().min(1, 'Popis problému je povinný'),
|
||||||
|
statusId: z.string().min(1, 'Stav je povinný'),
|
||||||
|
proposedSolutionId: z.string().optional(),
|
||||||
|
requiresApproval: z.boolean().optional().default(false),
|
||||||
|
receivedDate: z.string().optional(),
|
||||||
|
receivedLocation: z.string().optional(),
|
||||||
|
internalNotes: z.string().optional(),
|
||||||
|
resolutionNotes: z.string().optional(),
|
||||||
|
assignedToId: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type RMAFormData = z.input<typeof rmaSchema>;
|
||||||
|
|
||||||
|
interface RMAFormProps {
|
||||||
|
rma: RMA | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RMAForm({ rma, onClose }: RMAFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = !!rma;
|
||||||
|
|
||||||
|
const { data: customersData } = useQuery({
|
||||||
|
queryKey: ['customers-select'],
|
||||||
|
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statusesData } = useQuery({
|
||||||
|
queryKey: ['rma-statuses'],
|
||||||
|
queryFn: () => settingsApi.getRMAStatuses(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: solutionsData } = useQuery({
|
||||||
|
queryKey: ['rma-solutions'],
|
||||||
|
queryFn: () => settingsApi.getRMASolutions(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: usersData } = useQuery({
|
||||||
|
queryKey: ['users-select'],
|
||||||
|
queryFn: () => settingsApi.getUsers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<RMAFormData>({
|
||||||
|
resolver: zodResolver(rmaSchema),
|
||||||
|
defaultValues: rma
|
||||||
|
? {
|
||||||
|
customerId: rma.customerId || '',
|
||||||
|
customerName: rma.customerName || '',
|
||||||
|
customerAddress: rma.customerAddress || '',
|
||||||
|
customerEmail: rma.customerEmail || '',
|
||||||
|
customerPhone: rma.customerPhone || '',
|
||||||
|
customerICO: rma.customerICO || '',
|
||||||
|
submittedBy: rma.submittedBy,
|
||||||
|
productName: rma.productName,
|
||||||
|
invoiceNumber: rma.invoiceNumber || '',
|
||||||
|
purchaseDate: rma.purchaseDate?.split('T')[0] || '',
|
||||||
|
productNumber: rma.productNumber || '',
|
||||||
|
serialNumber: rma.serialNumber || '',
|
||||||
|
accessories: rma.accessories || '',
|
||||||
|
issueDescription: rma.issueDescription,
|
||||||
|
statusId: rma.statusId,
|
||||||
|
proposedSolutionId: rma.proposedSolutionId || '',
|
||||||
|
requiresApproval: rma.requiresApproval,
|
||||||
|
receivedDate: rma.receivedDate?.split('T')[0] || '',
|
||||||
|
receivedLocation: rma.receivedLocation || '',
|
||||||
|
internalNotes: rma.internalNotes || '',
|
||||||
|
resolutionNotes: rma.resolutionNotes || '',
|
||||||
|
assignedToId: rma.assignedToId || '',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
|
||||||
|
requiresApproval: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateRMAData) => rmaApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rma'] });
|
||||||
|
toast.success('RMA bolo vytvorené');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri vytváraní RMA');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: UpdateRMAData) => rmaApi.update(rma!.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rma'] });
|
||||||
|
toast.success('RMA bolo aktualizované');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri aktualizácii RMA');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: RMAFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
customerId: data.customerId || undefined,
|
||||||
|
customerEmail: data.customerEmail || undefined,
|
||||||
|
purchaseDate: data.purchaseDate || undefined,
|
||||||
|
receivedDate: data.receivedDate || undefined,
|
||||||
|
proposedSolutionId: data.proposedSolutionId || undefined,
|
||||||
|
assignedToId: data.assignedToId || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateMutation.mutate(cleanData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(cleanData as CreateRMAData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
|
||||||
|
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||||
|
const solutionOptions = solutionsData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||||
|
const userOptions = usersData?.data.map((u) => ({ value: u.id, label: u.name })) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
|
||||||
|
<h3 className="font-medium">Zákazník</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
id="customerId"
|
||||||
|
label="Existujúci zákazník"
|
||||||
|
options={[{ value: '', label: '-- Alebo zadajte manuálne --' }, ...customerOptions]}
|
||||||
|
{...register('customerId')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="customerName"
|
||||||
|
label="Názov zákazníka"
|
||||||
|
{...register('customerName')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="customerEmail"
|
||||||
|
type="email"
|
||||||
|
label="Email"
|
||||||
|
error={errors.customerEmail?.message}
|
||||||
|
{...register('customerEmail')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="customerPhone"
|
||||||
|
label="Telefón"
|
||||||
|
{...register('customerPhone')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="customerAddress"
|
||||||
|
label="Adresa"
|
||||||
|
{...register('customerAddress')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="customerICO"
|
||||||
|
label="IČO"
|
||||||
|
{...register('customerICO')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
<h3 className="font-medium">Produkt</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
id="submittedBy"
|
||||||
|
label="Podávajúci *"
|
||||||
|
error={errors.submittedBy?.message}
|
||||||
|
{...register('submittedBy')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="productName"
|
||||||
|
label="Názov produktu *"
|
||||||
|
error={errors.productName?.message}
|
||||||
|
{...register('productName')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="invoiceNumber"
|
||||||
|
label="Číslo faktúry"
|
||||||
|
{...register('invoiceNumber')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="purchaseDate"
|
||||||
|
type="date"
|
||||||
|
label="Dátum nákupu"
|
||||||
|
{...register('purchaseDate')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="productNumber"
|
||||||
|
label="Číslo produktu"
|
||||||
|
{...register('productNumber')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="serialNumber"
|
||||||
|
label="Sériové číslo"
|
||||||
|
{...register('serialNumber')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="accessories"
|
||||||
|
label="Príslušenstvo"
|
||||||
|
{...register('accessories')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="issueDescription"
|
||||||
|
label="Popis problému *"
|
||||||
|
rows={3}
|
||||||
|
error={errors.issueDescription?.message}
|
||||||
|
{...register('issueDescription')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
<h3 className="font-medium">Stav a spracovanie</h3>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
id="statusId"
|
||||||
|
label="Stav *"
|
||||||
|
options={statusOptions}
|
||||||
|
error={errors.statusId?.message}
|
||||||
|
{...register('statusId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="proposedSolutionId"
|
||||||
|
label="Navrhované riešenie"
|
||||||
|
options={[{ value: '', label: '-- Žiadne --' }, ...solutionOptions]}
|
||||||
|
{...register('proposedSolutionId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="assignedToId"
|
||||||
|
label="Priradené"
|
||||||
|
options={[{ value: '', label: '-- Nepriradené --' }, ...userOptions]}
|
||||||
|
{...register('assignedToId')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="receivedDate"
|
||||||
|
type="date"
|
||||||
|
label="Dátum prijatia"
|
||||||
|
{...register('receivedDate')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="receivedLocation"
|
||||||
|
label="Miesto prijatia"
|
||||||
|
{...register('receivedLocation')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" {...register('requiresApproval')} className="rounded" />
|
||||||
|
<span className="text-sm">Vyžaduje schválenie</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="internalNotes"
|
||||||
|
label="Interné poznámky"
|
||||||
|
rows={2}
|
||||||
|
{...register('internalNotes')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="resolutionNotes"
|
||||||
|
label="Poznámky k riešeniu"
|
||||||
|
rows={2}
|
||||||
|
{...register('resolutionNotes')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
167
frontend/src/pages/rma/RMAList.tsx
Normal file
167
frontend/src/pages/rma/RMAList.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
|
import { rmaApi } from '@/services/rma.api';
|
||||||
|
import type { RMA } from '@/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { RMAForm } from './RMAForm';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function RMAList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingRMA, setEditingRMA] = useState<RMA | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<RMA | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['rma', search],
|
||||||
|
queryFn: () => rmaApi.getAll({ search, limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => rmaApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['rma'] });
|
||||||
|
toast.success('RMA bolo vymazané');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri mazaní RMA');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (rma: RMA) => {
|
||||||
|
setEditingRMA(rma);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingRMA(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">RMA</h1>
|
||||||
|
<Button onClick={() => setIsFormOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nové RMA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Hľadať RMA..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingOverlay />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Číslo RMA</TableHead>
|
||||||
|
<TableHead>Zákazník</TableHead>
|
||||||
|
<TableHead>Produkt</TableHead>
|
||||||
|
<TableHead>Stav</TableHead>
|
||||||
|
<TableHead>Prijaté</TableHead>
|
||||||
|
<TableHead>Priradené</TableHead>
|
||||||
|
<TableHead className="text-right">Akcie</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((rma) => (
|
||||||
|
<TableRow key={rma.id}>
|
||||||
|
<TableCell className="font-medium">{rma.rmaNumber}</TableCell>
|
||||||
|
<TableCell>{rma.customer?.name || rma.customerName || '-'}</TableCell>
|
||||||
|
<TableCell>{rma.productName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge color={rma.status.color}>{rma.status.name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{rma.receivedDate ? formatDate(rma.receivedDate) : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{rma.assignedTo?.name || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(rma)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(rma)}>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.data.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
|
Žiadne RMA
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
title={editingRMA ? 'Upraviť RMA' : 'Nové RMA'}
|
||||||
|
size="xl"
|
||||||
|
>
|
||||||
|
<RMAForm rma={editingRMA} onClose={handleCloseForm} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
title="Potvrdiť vymazanie"
|
||||||
|
>
|
||||||
|
<p>Naozaj chcete vymazať RMA "{deleteConfirm?.rmaNumber}"?</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Vymazať
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/pages/rma/index.ts
Normal file
1
frontend/src/pages/rma/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { RMAList } from './RMAList';
|
||||||
332
frontend/src/pages/settings/SettingsDashboard.tsx
Normal file
332
frontend/src/pages/settings/SettingsDashboard.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
type ConfigTab = 'taskStatuses' | 'priorities' | 'equipmentTypes' | 'revisionTypes' | 'rmaStatuses' | 'rmaSolutions' | 'userRoles';
|
||||||
|
|
||||||
|
// Spoločný interface pre konfiguračné entity
|
||||||
|
interface ConfigItem {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string | null;
|
||||||
|
order?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs: { key: ConfigTab; label: string }[] = [
|
||||||
|
{ key: 'taskStatuses', label: 'Stavy úloh' },
|
||||||
|
{ key: 'priorities', label: 'Priority' },
|
||||||
|
{ key: 'equipmentTypes', label: 'Typy zariadení' },
|
||||||
|
{ key: 'revisionTypes', label: 'Typy revízií' },
|
||||||
|
{ key: 'rmaStatuses', label: 'RMA stavy' },
|
||||||
|
{ key: 'rmaSolutions', label: 'RMA riešenia' },
|
||||||
|
{ key: 'userRoles', label: 'Užívateľské role' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SettingsDashboard() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [activeTab, setActiveTab] = useState<ConfigTab>('taskStatuses');
|
||||||
|
const [editItem, setEditItem] = useState<ConfigItem | null>(null);
|
||||||
|
const [deleteItem, setDeleteItem] = useState<{ id: string; name: string } | null>(null);
|
||||||
|
|
||||||
|
const { data: taskStatuses, isLoading: loadingStatuses } = useQuery({
|
||||||
|
queryKey: ['task-statuses'],
|
||||||
|
queryFn: () => settingsApi.getTaskStatuses(),
|
||||||
|
enabled: activeTab === 'taskStatuses',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: priorities, isLoading: loadingPriorities } = useQuery({
|
||||||
|
queryKey: ['priorities'],
|
||||||
|
queryFn: () => settingsApi.getPriorities(),
|
||||||
|
enabled: activeTab === 'priorities',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: equipmentTypes, isLoading: loadingEquipmentTypes } = useQuery({
|
||||||
|
queryKey: ['equipment-types'],
|
||||||
|
queryFn: () => settingsApi.getEquipmentTypes(),
|
||||||
|
enabled: activeTab === 'equipmentTypes',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: revisionTypes, isLoading: loadingRevisionTypes } = useQuery({
|
||||||
|
queryKey: ['revision-types'],
|
||||||
|
queryFn: () => settingsApi.getRevisionTypes(),
|
||||||
|
enabled: activeTab === 'revisionTypes',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: rmaStatuses, isLoading: loadingRmaStatuses } = useQuery({
|
||||||
|
queryKey: ['rma-statuses'],
|
||||||
|
queryFn: () => settingsApi.getRMAStatuses(),
|
||||||
|
enabled: activeTab === 'rmaStatuses',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: rmaSolutions, isLoading: loadingRmaSolutions } = useQuery({
|
||||||
|
queryKey: ['rma-solutions'],
|
||||||
|
queryFn: () => settingsApi.getRMASolutions(),
|
||||||
|
enabled: activeTab === 'rmaSolutions',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: userRoles, isLoading: loadingUserRoles } = useQuery({
|
||||||
|
queryKey: ['user-roles'],
|
||||||
|
queryFn: () => settingsApi.getUserRoles(),
|
||||||
|
enabled: activeTab === 'userRoles',
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async ({ tab, id }: { tab: ConfigTab; id: string }) => {
|
||||||
|
switch (tab) {
|
||||||
|
case 'taskStatuses': return settingsApi.deleteTaskStatus(id);
|
||||||
|
case 'priorities': return settingsApi.deletePriority(id);
|
||||||
|
case 'equipmentTypes': return settingsApi.deleteEquipmentType(id);
|
||||||
|
case 'revisionTypes': return settingsApi.deleteRevisionType(id);
|
||||||
|
case 'rmaStatuses': return settingsApi.deleteRMAStatus(id);
|
||||||
|
case 'rmaSolutions': return settingsApi.deleteRMASolution(id);
|
||||||
|
case 'userRoles': return settingsApi.deleteUserRole(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
toast.success('Položka bola vymazaná');
|
||||||
|
setDeleteItem(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri mazaní položky');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLoading = loadingStatuses || loadingPriorities || loadingEquipmentTypes ||
|
||||||
|
loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles;
|
||||||
|
|
||||||
|
const getCurrentData = (): ConfigItem[] => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'taskStatuses': return (taskStatuses?.data || []) as ConfigItem[];
|
||||||
|
case 'priorities': return (priorities?.data || []) as ConfigItem[];
|
||||||
|
case 'equipmentTypes': return (equipmentTypes?.data || []) as ConfigItem[];
|
||||||
|
case 'revisionTypes': return (revisionTypes?.data || []) as ConfigItem[];
|
||||||
|
case 'rmaStatuses': return (rmaStatuses?.data || []) as ConfigItem[];
|
||||||
|
case 'rmaSolutions': return (rmaSolutions?.data || []) as ConfigItem[];
|
||||||
|
case 'userRoles': return (userRoles?.data || []) as ConfigItem[];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const data: ConfigItem[] = getCurrentData();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Nastavenia</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Button
|
||||||
|
key={tab.key}
|
||||||
|
variant={activeTab === tab.key ? 'primary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setActiveTab(tab.key)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>{tabs.find(t => t.key === activeTab)?.label}</CardTitle>
|
||||||
|
<Button size="sm" onClick={() => setEditItem({ id: '', code: '', name: '' })}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Pridať
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingOverlay />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Kód</TableHead>
|
||||||
|
<TableHead>Názov</TableHead>
|
||||||
|
<TableHead>Farba</TableHead>
|
||||||
|
<TableHead>Poradie</TableHead>
|
||||||
|
<TableHead className="text-right">Akcie</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell className="font-mono">{item.code}</TableCell>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.color && (
|
||||||
|
<Badge color={item.color}>{item.color}</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.order ?? 0}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditItem(item)}>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteItem({ id: item.id, name: item.name })}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
|
Žiadne položky
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!editItem}
|
||||||
|
onClose={() => setEditItem(null)}
|
||||||
|
title={editItem?.id ? 'Upraviť položku' : 'Nová položka'}
|
||||||
|
>
|
||||||
|
<ConfigItemForm
|
||||||
|
item={editItem}
|
||||||
|
tab={activeTab}
|
||||||
|
onClose={() => setEditItem(null)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteItem}
|
||||||
|
onClose={() => setDeleteItem(null)}
|
||||||
|
title="Potvrdiť vymazanie"
|
||||||
|
>
|
||||||
|
<p>Naozaj chcete vymazať položku "{deleteItem?.name}"?</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteItem(null)}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteItem && deleteMutation.mutate({ tab: activeTab, id: deleteItem.id })}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Vymazať
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfigItemFormProps {
|
||||||
|
item: ConfigItem | null;
|
||||||
|
tab: ConfigTab;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfigItemForm({ item, tab, onClose }: ConfigItemFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = item?.id ? true : false;
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
code: item?.code || '',
|
||||||
|
name: item?.name || '',
|
||||||
|
color: item?.color || '',
|
||||||
|
order: item?.order || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const data = { ...formData, order: Number(formData.order) };
|
||||||
|
if (isEditing && item?.id) {
|
||||||
|
switch (tab) {
|
||||||
|
case 'taskStatuses': return settingsApi.updateTaskStatus(item.id, data);
|
||||||
|
case 'priorities': return settingsApi.updatePriority(item.id, data);
|
||||||
|
case 'equipmentTypes': return settingsApi.updateEquipmentType(item.id, data);
|
||||||
|
case 'revisionTypes': return settingsApi.updateRevisionType(item.id, { ...data, intervalDays: 365, reminderDays: 30 });
|
||||||
|
case 'rmaStatuses': return settingsApi.updateRMAStatus(item.id, data);
|
||||||
|
case 'rmaSolutions': return settingsApi.updateRMASolution(item.id, data);
|
||||||
|
case 'userRoles': return settingsApi.updateUserRole(item.id, { ...data, permissions: {}, level: 0 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
switch (tab) {
|
||||||
|
case 'taskStatuses': return settingsApi.createTaskStatus({ ...data, isInitial: false, isFinal: false });
|
||||||
|
case 'priorities': return settingsApi.createPriority({ ...data, level: 0 });
|
||||||
|
case 'equipmentTypes': return settingsApi.createEquipmentType(data);
|
||||||
|
case 'revisionTypes': return settingsApi.createRevisionType({ ...data, intervalDays: 365, reminderDays: 30 });
|
||||||
|
case 'rmaStatuses': return settingsApi.createRMAStatus({ ...data, isInitial: false, isFinal: false });
|
||||||
|
case 'rmaSolutions': return settingsApi.createRMASolution(data);
|
||||||
|
case 'userRoles': return settingsApi.createUserRole({ ...data, permissions: {}, level: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
toast.success(isEditing ? 'Položka bola aktualizovaná' : 'Položka bola vytvorená');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri ukladaní položky');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
label="Kód *"
|
||||||
|
value={formData.code}
|
||||||
|
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Názov *"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Farba"
|
||||||
|
type="color"
|
||||||
|
value={formData.color || '#3b82f6'}
|
||||||
|
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Poradie"
|
||||||
|
type="number"
|
||||||
|
value={formData.order}
|
||||||
|
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={mutation.isPending}>
|
||||||
|
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/pages/settings/index.ts
Normal file
1
frontend/src/pages/settings/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { SettingsDashboard } from './SettingsDashboard';
|
||||||
356
frontend/src/pages/tasks/TaskDetail.tsx
Normal file
356
frontend/src/pages/tasks/TaskDetail.tsx
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft } from 'lucide-react';
|
||||||
|
import { tasksApi } from '@/services/tasks.api';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
import type { Task } from '@/types';
|
||||||
|
import { Button, Badge, Textarea, Select } from '@/components/ui';
|
||||||
|
import { TaskForm } from './TaskForm';
|
||||||
|
import { formatDate, formatDateTime } from '@/lib/utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface Comment {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
userId: string;
|
||||||
|
user?: { id: string; name: string };
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskDetailProps {
|
||||||
|
taskId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit?: (task: Task) => void; // Optional - ak nie je, použije sa interný edit mód
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const [newComment, setNewComment] = useState('');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const { data: taskData, isLoading } = useQuery({
|
||||||
|
queryKey: ['task', taskId],
|
||||||
|
queryFn: () => tasksApi.getById(taskId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: commentsData, isLoading: commentsLoading } = useQuery({
|
||||||
|
queryKey: ['task-comments', taskId],
|
||||||
|
queryFn: () => tasksApi.getComments(taskId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statusesData } = useQuery({
|
||||||
|
queryKey: ['task-statuses'],
|
||||||
|
queryFn: () => settingsApi.getTaskStatuses(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: prioritiesData } = useQuery({
|
||||||
|
queryKey: ['priorities'],
|
||||||
|
queryFn: () => settingsApi.getPriorities(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addCommentMutation = useMutation({
|
||||||
|
mutationFn: (content: string) => tasksApi.addComment(taskId, content),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] });
|
||||||
|
setNewComment('');
|
||||||
|
toast.success('Komentár bol pridaný');
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||||
|
toast.error(axiosError.response?.data?.message || 'Chyba pri pridávaní komentára');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTaskMutation = useMutation({
|
||||||
|
mutationFn: (data: { statusId?: string; priorityId?: string }) => tasksApi.update(taskId, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
|
||||||
|
toast.success('Úloha bola aktualizovaná');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri aktualizácii úlohy');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = taskData?.data;
|
||||||
|
const comments = (commentsData?.data || []) as Comment[];
|
||||||
|
const statuses = statusesData?.data || [];
|
||||||
|
const priorities = prioritiesData?.data || [];
|
||||||
|
|
||||||
|
// Kontrola oprávnení - môže komentovať/meniť autor alebo priradený
|
||||||
|
const isCreator = user && task && (
|
||||||
|
task.createdById === user.id || task.createdBy?.id === user.id
|
||||||
|
);
|
||||||
|
const isAssignee = user && task && task.assignees?.some(a =>
|
||||||
|
a.userId === user.id || a.user?.id === user.id
|
||||||
|
);
|
||||||
|
|
||||||
|
const canComment = isCreator || isAssignee;
|
||||||
|
const canChangeStatus = isCreator || isAssignee; // Stav môže meniť autor + priradený
|
||||||
|
const canChangePriority = isCreator; // Prioritu môže meniť len zadávateľ
|
||||||
|
const canEdit = isCreator; // Len zadávateľ môže editovať úlohu
|
||||||
|
|
||||||
|
const handleSubmitComment = () => {
|
||||||
|
if (!newComment.trim()) return;
|
||||||
|
addCommentMutation.mutate(newComment.trim());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
||||||
|
handleSubmitComment();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStatusChange = (statusId: string) => {
|
||||||
|
if (statusId && statusId !== task?.statusId) {
|
||||||
|
updateTaskMutation.mutate({ statusId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePriorityChange = (priorityId: string) => {
|
||||||
|
if (priorityId && priorityId !== task?.priorityId) {
|
||||||
|
updateTaskMutation.mutate({ priorityId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditComplete = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-background rounded-lg p-8">
|
||||||
|
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions = statuses.map(s => ({ value: s.id, label: s.name }));
|
||||||
|
const priorityOptions = priorities.map(p => ({ value: p.id, label: p.name }));
|
||||||
|
|
||||||
|
// Edit mód - zobrazí formulár
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4" onClick={onClose}>
|
||||||
|
<div className="bg-background rounded-lg shadow-xl w-full max-w-3xl my-8" onClick={e => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-6 border-b">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setIsEditing(false)}>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<h2 className="text-xl font-semibold">Upraviť úlohu</h2>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form */}
|
||||||
|
<div className="p-6">
|
||||||
|
<TaskForm
|
||||||
|
task={task}
|
||||||
|
onClose={handleEditComplete}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// View mód - zobrazí detail
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4" onClick={onClose}>
|
||||||
|
<div className="bg-background rounded-lg shadow-xl w-full max-w-3xl my-8" onClick={e => e.stopPropagation()}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between p-6 border-b">
|
||||||
|
<div className="flex-1 pr-4">
|
||||||
|
<h2 className="text-xl font-semibold">{task.title}</h2>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Badge color={task.status.color}>{task.status.name}</Badge>
|
||||||
|
<Badge color={task.priority.color}>{task.priority.name}</Badge>
|
||||||
|
{task.status.isFinal && (
|
||||||
|
<span className="flex items-center gap-1 text-xs text-green-600">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Dokončená
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" />
|
||||||
|
Upraviť
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Quick actions - zmena statusu a priority */}
|
||||||
|
{(canChangeStatus || canChangePriority) && (
|
||||||
|
<div className="flex flex-wrap gap-4 p-4 bg-muted/30 rounded-lg">
|
||||||
|
{canChangeStatus && (
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">Zmeniť stav</label>
|
||||||
|
<Select
|
||||||
|
id="status"
|
||||||
|
options={statusOptions}
|
||||||
|
value={task.statusId}
|
||||||
|
onChange={(e) => handleStatusChange(e.target.value)}
|
||||||
|
disabled={updateTaskMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canChangePriority && (
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground mb-1 block">Zmeniť prioritu</label>
|
||||||
|
<Select
|
||||||
|
id="priority"
|
||||||
|
options={priorityOptions}
|
||||||
|
value={task.priorityId}
|
||||||
|
onChange={(e) => handlePriorityChange(e.target.value)}
|
||||||
|
disabled={updateTaskMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Info grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<UserIcon className="h-4 w-4" />
|
||||||
|
<span>Zadal:</span>
|
||||||
|
<span className="text-foreground font-medium">{task.createdBy?.name || '-'}</span>
|
||||||
|
</div>
|
||||||
|
{task.project && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<FolderOpen className="h-4 w-4" />
|
||||||
|
<span>Projekt:</span>
|
||||||
|
<span className="text-foreground font-medium">{task.project.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.deadline && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Calendar className="h-4 w-4" />
|
||||||
|
<span>Termín:</span>
|
||||||
|
<span className={`font-medium ${new Date(task.deadline) < new Date() ? 'text-red-500' : 'text-foreground'}`}>
|
||||||
|
{formatDate(task.deadline)}
|
||||||
|
{new Date(task.deadline) < new Date() && ' (po termíne!)'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{task.assignees && task.assignees.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground">
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<span>Priradení:</span>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{task.assignees.map(a => a.user?.name || a.userId).join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{task.description ? (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-2">Popis</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap bg-muted/50 rounded-md p-3">
|
||||||
|
{task.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted-foreground italic">
|
||||||
|
Bez popisu
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comments */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium text-muted-foreground mb-3">
|
||||||
|
Komentáre ({comments.length})
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Add comment - na vrchu */}
|
||||||
|
{canComment && (
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Napíšte komentár... (Ctrl+Enter pre odoslanie)"
|
||||||
|
value={newComment}
|
||||||
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSubmitComment}
|
||||||
|
disabled={!newComment.trim() || addCommentMutation.isPending}
|
||||||
|
isLoading={addCommentMutation.isPending}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4 mr-2" />
|
||||||
|
Odoslať
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Comment list */}
|
||||||
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||||
|
{commentsLoading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Načítavam...</p>
|
||||||
|
) : comments.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
Zatiaľ žiadne komentáre
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => (
|
||||||
|
<div key={comment.id} className="bg-muted/50 rounded-md p-3">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="text-sm font-medium">{comment.user?.name || 'Neznámy'}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(comment.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canComment && (
|
||||||
|
<p className="text-sm text-muted-foreground italic mt-4">
|
||||||
|
Komentovať môže len autor úlohy alebo priradený používateľ.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-between items-center p-4 border-t text-xs text-muted-foreground">
|
||||||
|
<span>Vytvorené: {formatDateTime(task.createdAt)}</span>
|
||||||
|
<span>Aktualizované: {formatDateTime(task.updatedAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
frontend/src/pages/tasks/TaskForm.tsx
Normal file
204
frontend/src/pages/tasks/TaskForm.tsx
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
|
||||||
|
import { tasksApi, type CreateTaskData } from '@/services/tasks.api';
|
||||||
|
import { projectsApi } from '@/services/projects.api';
|
||||||
|
import { settingsApi } from '@/services/settings.api'; // Pre statusy a priority
|
||||||
|
import type { Task } from '@/types';
|
||||||
|
import { Button, Input, Textarea, Select, ModalFooter, UserSelect } from '@/components/ui';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
const taskSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Názov je povinný'),
|
||||||
|
description: z.string().optional(),
|
||||||
|
projectId: z.string().optional(),
|
||||||
|
statusId: z.string().optional(),
|
||||||
|
priorityId: z.string().optional(),
|
||||||
|
deadline: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type TaskFormData = z.infer<typeof taskSchema>;
|
||||||
|
|
||||||
|
interface TaskFormProps {
|
||||||
|
task: Task | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaskForm({ task, onClose }: TaskFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const isEditing = !!task;
|
||||||
|
|
||||||
|
// State pre vybraných používateľov
|
||||||
|
const [selectedAssignees, setSelectedAssignees] = useState<string[]>(
|
||||||
|
task?.assignees?.map((a) => a.userId) || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: projectsData } = useQuery({
|
||||||
|
queryKey: ['projects-select'],
|
||||||
|
queryFn: () => projectsApi.getAll({ limit: 1000 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: statusesData } = useQuery({
|
||||||
|
queryKey: ['task-statuses'],
|
||||||
|
queryFn: () => settingsApi.getTaskStatuses(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: prioritiesData } = useQuery({
|
||||||
|
queryKey: ['priorities'],
|
||||||
|
queryFn: () => settingsApi.getPriorities(),
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<TaskFormData>({
|
||||||
|
resolver: zodResolver(taskSchema),
|
||||||
|
defaultValues: task
|
||||||
|
? {
|
||||||
|
title: task.title,
|
||||||
|
description: task.description || '',
|
||||||
|
projectId: task.projectId || '',
|
||||||
|
statusId: task.statusId,
|
||||||
|
priorityId: task.priorityId,
|
||||||
|
deadline: task.deadline?.split('T')[0] || '',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
projectId: '',
|
||||||
|
statusId: '',
|
||||||
|
priorityId: '',
|
||||||
|
deadline: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateTaskData) => tasksApi.create(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
toast.success('Úloha bola vytvorená');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
console.error('Create task error:', error);
|
||||||
|
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||||
|
const message = axiosError.response?.data?.message || 'Chyba pri vytváraní úlohy';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: CreateTaskData) => tasksApi.update(task!.id, data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
toast.success('Úloha bola aktualizovaná');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (error: unknown) => {
|
||||||
|
console.error('Update task error:', error);
|
||||||
|
const axiosError = error as { response?: { data?: { message?: string } } };
|
||||||
|
const message = axiosError.response?.data?.message || 'Chyba pri aktualizácii úlohy';
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = (data: TaskFormData) => {
|
||||||
|
const cleanData = {
|
||||||
|
...data,
|
||||||
|
projectId: data.projectId || undefined,
|
||||||
|
statusId: data.statusId || undefined,
|
||||||
|
priorityId: data.priorityId || undefined,
|
||||||
|
deadline: data.deadline || undefined,
|
||||||
|
// Pre create: undefined ak prázdne (backend priradí default)
|
||||||
|
// Pre update: vždy poslať pole (aj prázdne) aby sa aktualizovali assignees
|
||||||
|
assigneeIds: isEditing ? selectedAssignees : (selectedAssignees.length > 0 ? selectedAssignees : undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submitting task data:', cleanData);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
updateMutation.mutate(cleanData);
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(cleanData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||||
|
|
||||||
|
const projectOptions = projectsData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
|
||||||
|
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
|
||||||
|
const priorityOptions = prioritiesData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
|
||||||
|
|
||||||
|
// Pripraviť počiatočných používateľov pre editáciu (už priradení)
|
||||||
|
const initialAssignees = task?.assignees?.map((a) => ({
|
||||||
|
id: a.userId,
|
||||||
|
name: a.user?.name || '',
|
||||||
|
email: a.user?.email || '',
|
||||||
|
})) || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<Input
|
||||||
|
id="title"
|
||||||
|
label="Názov *"
|
||||||
|
error={errors.title?.message}
|
||||||
|
{...register('title')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
label="Popis"
|
||||||
|
rows={3}
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
id="projectId"
|
||||||
|
label="Projekt"
|
||||||
|
options={[{ value: '', label: '-- Bez projektu --' }, ...projectOptions]}
|
||||||
|
{...register('projectId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="statusId"
|
||||||
|
label="Stav"
|
||||||
|
options={[{ value: '', label: '-- Predvolený --' }, ...statusOptions]}
|
||||||
|
{...register('statusId')}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="priorityId"
|
||||||
|
label="Priorita"
|
||||||
|
options={[{ value: '', label: '-- Predvolená --' }, ...priorityOptions]}
|
||||||
|
{...register('priorityId')}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
id="deadline"
|
||||||
|
type="date"
|
||||||
|
label="Termín"
|
||||||
|
{...register('deadline')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UserSelect
|
||||||
|
label="Priradiť na"
|
||||||
|
selectedIds={selectedAssignees}
|
||||||
|
onChange={setSelectedAssignees}
|
||||||
|
initialUsers={initialAssignees}
|
||||||
|
placeholder="Vyhľadať používateľa..."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" isLoading={isPending}>
|
||||||
|
{isEditing ? 'Uložiť' : 'Vytvoriť'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
196
frontend/src/pages/tasks/TasksList.tsx
Normal file
196
frontend/src/pages/tasks/TasksList.tsx
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Plus, Pencil, Trash2, Search, MessageSquare } from 'lucide-react';
|
||||||
|
import { tasksApi } from '@/services/tasks.api';
|
||||||
|
import type { Task } from '@/types';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardContent,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Badge,
|
||||||
|
LoadingOverlay,
|
||||||
|
Modal,
|
||||||
|
ModalFooter,
|
||||||
|
} from '@/components/ui';
|
||||||
|
import { TaskForm } from './TaskForm';
|
||||||
|
import { TaskDetail } from './TaskDetail';
|
||||||
|
import { formatDate } from '@/lib/utils';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
export function TasksList() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [editingTask, setEditingTask] = useState<Task | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<Task | null>(null);
|
||||||
|
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['tasks', search],
|
||||||
|
queryFn: () => tasksApi.getAll({ search, limit: 100 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: string) => tasksApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] });
|
||||||
|
toast.success('Úloha bola vymazaná');
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Chyba pri mazaní úlohy');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleEdit = (task: Task) => {
|
||||||
|
setEditingTask(task);
|
||||||
|
setIsFormOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseForm = () => {
|
||||||
|
setIsFormOpen(false);
|
||||||
|
setEditingTask(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">Úlohy</h1>
|
||||||
|
<Button onClick={() => setIsFormOpen(true)}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
Nová úloha
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Hľadať úlohy..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<LoadingOverlay />
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Názov</TableHead>
|
||||||
|
<TableHead>Projekt</TableHead>
|
||||||
|
<TableHead>Zadal</TableHead>
|
||||||
|
<TableHead>Stav</TableHead>
|
||||||
|
<TableHead>Priorita</TableHead>
|
||||||
|
<TableHead>Termín</TableHead>
|
||||||
|
<TableHead>Priradení</TableHead>
|
||||||
|
<TableHead className="text-right">Akcie</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.data.map((task) => (
|
||||||
|
<TableRow key={task.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailTaskId(task.id)}
|
||||||
|
className="text-left hover:text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{task.project?.name || '-'}</TableCell>
|
||||||
|
<TableCell>{task.createdBy?.name || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge color={task.status.color}>{task.status.name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge color={task.priority.color}>{task.priority.name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{task.deadline ? formatDate(task.deadline) : '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{task.assignees.length > 0
|
||||||
|
? task.assignees.map((a) => a.user.name).join(', ')
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetailTaskId(task.id)} title="Detail">
|
||||||
|
<MessageSquare className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => handleEdit(task)} title="Upraviť">
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať">
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{data?.data.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
|
Žiadne úlohy
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isFormOpen}
|
||||||
|
onClose={handleCloseForm}
|
||||||
|
title={editingTask ? 'Upraviť úlohu' : 'Nová úloha'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<TaskForm task={editingTask} onClose={handleCloseForm} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={!!deleteConfirm}
|
||||||
|
onClose={() => setDeleteConfirm(null)}
|
||||||
|
title="Potvrdiť vymazanie"
|
||||||
|
>
|
||||||
|
<p>Naozaj chcete vymazať úlohu "{deleteConfirm?.title}"?</p>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
||||||
|
Zrušiť
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
|
||||||
|
isLoading={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
Vymazať
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{detailTaskId && (
|
||||||
|
<TaskDetail
|
||||||
|
taskId={detailTaskId}
|
||||||
|
onClose={() => setDetailTaskId(null)}
|
||||||
|
onEdit={(task) => {
|
||||||
|
setDetailTaskId(null);
|
||||||
|
handleEdit(task);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
frontend/src/pages/tasks/index.ts
Normal file
1
frontend/src/pages/tasks/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { TasksList } from './TasksList';
|
||||||
88
frontend/src/services/api.ts
Normal file
88
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import type { ApiResponse, PaginatedResponse } from '@/types';
|
||||||
|
|
||||||
|
const API_URL = '/api';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: API_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
api.interceptors.request.use(
|
||||||
|
(config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle token refresh
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (refreshToken) {
|
||||||
|
const response = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
|
||||||
|
const { accessToken, refreshToken: newRefreshToken } = response.data.data;
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
localStorage.setItem('refreshToken', newRefreshToken);
|
||||||
|
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
return api(originalRequest);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generic API helpers
|
||||||
|
export async function get<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
const response = await api.get<ApiResponse<T>>(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPaginated<T>(url: string): Promise<PaginatedResponse<T>> {
|
||||||
|
const response = await api.get<PaginatedResponse<T>>(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function post<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
|
const response = await api.post<ApiResponse<T>>(url, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function put<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
|
const response = await api.put<ApiResponse<T>>(url, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patch<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||||
|
const response = await api.patch<ApiResponse<T>>(url, data);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function del<T>(url: string): Promise<ApiResponse<T>> {
|
||||||
|
const response = await api.delete<ApiResponse<T>>(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
33
frontend/src/services/auth.api.ts
Normal file
33
frontend/src/services/auth.api.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { post, get } from './api';
|
||||||
|
import type { User, LoginResponse } from '@/types';
|
||||||
|
|
||||||
|
export interface LoginCredentials {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: (credentials: LoginCredentials) =>
|
||||||
|
post<LoginResponse>('/auth/login', credentials),
|
||||||
|
|
||||||
|
register: (data: RegisterData) =>
|
||||||
|
post<LoginResponse>('/auth/register', data),
|
||||||
|
|
||||||
|
logout: () =>
|
||||||
|
post<void>('/auth/logout'),
|
||||||
|
|
||||||
|
refreshToken: (refreshToken: string) =>
|
||||||
|
post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { refreshToken }),
|
||||||
|
|
||||||
|
getProfile: () =>
|
||||||
|
get<User>('/auth/me'),
|
||||||
|
|
||||||
|
changePassword: (data: { currentPassword: string; newPassword: string }) =>
|
||||||
|
post<void>('/auth/change-password', data),
|
||||||
|
};
|
||||||
52
frontend/src/services/customers.api.ts
Normal file
52
frontend/src/services/customers.api.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { get, getPaginated, post, put, del } from './api';
|
||||||
|
import type { Customer } from '@/types';
|
||||||
|
|
||||||
|
export interface CustomerFilters {
|
||||||
|
search?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomerData {
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
ico?: string;
|
||||||
|
dic?: string;
|
||||||
|
icdph?: string;
|
||||||
|
contactPerson?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
notes?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateCustomerData = Partial<CreateCustomerData>;
|
||||||
|
|
||||||
|
function buildQueryString(filters: CustomerFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.active !== undefined) params.append('active', String(filters.active));
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const customersApi = {
|
||||||
|
getAll: (filters: CustomerFilters = {}) =>
|
||||||
|
getPaginated<Customer>(`/customers?${buildQueryString(filters)}`),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
get<Customer>(`/customers/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateCustomerData) =>
|
||||||
|
post<Customer>('/customers', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateCustomerData) =>
|
||||||
|
put<Customer>(`/customers/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
del<void>(`/customers/${id}`),
|
||||||
|
};
|
||||||
75
frontend/src/services/equipment.api.ts
Normal file
75
frontend/src/services/equipment.api.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { get, getPaginated, post, put, del } from './api';
|
||||||
|
import type { Equipment, Revision } from '@/types';
|
||||||
|
|
||||||
|
export interface EquipmentFilters {
|
||||||
|
search?: string;
|
||||||
|
customerId?: string;
|
||||||
|
typeId?: string;
|
||||||
|
active?: boolean;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateEquipmentData {
|
||||||
|
name: string;
|
||||||
|
typeId: string;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
customerId?: string;
|
||||||
|
address: string;
|
||||||
|
location?: string;
|
||||||
|
partNumber?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
installDate?: string;
|
||||||
|
warrantyEnd?: string;
|
||||||
|
warrantyStatus?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
active?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateEquipmentData = Partial<CreateEquipmentData>;
|
||||||
|
|
||||||
|
export interface CreateRevisionData {
|
||||||
|
typeId: string;
|
||||||
|
performedDate: string;
|
||||||
|
nextDueDate?: string;
|
||||||
|
findings?: string;
|
||||||
|
result?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(filters: EquipmentFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.customerId) params.append('customerId', filters.customerId);
|
||||||
|
if (filters.typeId) params.append('typeId', filters.typeId);
|
||||||
|
if (filters.active !== undefined) params.append('active', String(filters.active));
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const equipmentApi = {
|
||||||
|
getAll: (filters: EquipmentFilters = {}) =>
|
||||||
|
getPaginated<Equipment>(`/equipment?${buildQueryString(filters)}`),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
get<Equipment>(`/equipment/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateEquipmentData) =>
|
||||||
|
post<Equipment>('/equipment', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateEquipmentData) =>
|
||||||
|
put<Equipment>(`/equipment/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
del<void>(`/equipment/${id}`),
|
||||||
|
|
||||||
|
// Revisions
|
||||||
|
getRevisions: (equipmentId: string) =>
|
||||||
|
get<Revision[]>(`/equipment/${equipmentId}/revisions`),
|
||||||
|
|
||||||
|
createRevision: (equipmentId: string, data: CreateRevisionData) =>
|
||||||
|
post<Revision>(`/equipment/${equipmentId}/revisions`, data),
|
||||||
|
};
|
||||||
51
frontend/src/services/projects.api.ts
Normal file
51
frontend/src/services/projects.api.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { get, getPaginated, post, put, del } from './api';
|
||||||
|
import type { Project } from '@/types';
|
||||||
|
|
||||||
|
export interface ProjectFilters {
|
||||||
|
search?: string;
|
||||||
|
customerId?: string;
|
||||||
|
ownerId?: string;
|
||||||
|
statusId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateProjectData {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
customerId?: string;
|
||||||
|
ownerId?: string; // Backend nastaví aktuálneho používateľa ak prázdne
|
||||||
|
statusId?: string; // Backend nastaví predvolený ak prázdne
|
||||||
|
softDeadline?: string;
|
||||||
|
hardDeadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateProjectData = Partial<CreateProjectData>;
|
||||||
|
|
||||||
|
function buildQueryString(filters: ProjectFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.customerId) params.append('customerId', filters.customerId);
|
||||||
|
if (filters.ownerId) params.append('ownerId', filters.ownerId);
|
||||||
|
if (filters.statusId) params.append('statusId', filters.statusId);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectsApi = {
|
||||||
|
getAll: (filters: ProjectFilters = {}) =>
|
||||||
|
getPaginated<Project>(`/projects?${buildQueryString(filters)}`),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
get<Project>(`/projects/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateProjectData) =>
|
||||||
|
post<Project>('/projects', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateProjectData) =>
|
||||||
|
put<Project>(`/projects/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
del<void>(`/projects/${id}`),
|
||||||
|
};
|
||||||
75
frontend/src/services/rma.api.ts
Normal file
75
frontend/src/services/rma.api.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { get, getPaginated, post, put, del } from './api';
|
||||||
|
import type { RMA } from '@/types';
|
||||||
|
|
||||||
|
export interface RMAFilters {
|
||||||
|
search?: string;
|
||||||
|
customerId?: string;
|
||||||
|
statusId?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRMAData {
|
||||||
|
customerId?: string;
|
||||||
|
customerName?: string;
|
||||||
|
customerAddress?: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
customerICO?: string;
|
||||||
|
submittedBy: string;
|
||||||
|
productName: string;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
purchaseDate?: string;
|
||||||
|
productNumber?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
accessories?: string;
|
||||||
|
issueDescription: string;
|
||||||
|
statusId: string;
|
||||||
|
requiresApproval?: boolean;
|
||||||
|
assignedToId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRMAData {
|
||||||
|
statusId?: string;
|
||||||
|
proposedSolutionId?: string;
|
||||||
|
requiresApproval?: boolean;
|
||||||
|
approvedById?: string;
|
||||||
|
receivedDate?: string;
|
||||||
|
receivedLocation?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
resolutionDate?: string;
|
||||||
|
resolutionNotes?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(filters: RMAFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.customerId) params.append('customerId', filters.customerId);
|
||||||
|
if (filters.statusId) params.append('statusId', filters.statusId);
|
||||||
|
if (filters.assignedToId) params.append('assignedToId', filters.assignedToId);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rmaApi = {
|
||||||
|
getAll: (filters: RMAFilters = {}) =>
|
||||||
|
getPaginated<RMA>(`/rma?${buildQueryString(filters)}`),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
get<RMA>(`/rma/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateRMAData) =>
|
||||||
|
post<RMA>('/rma', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateRMAData) =>
|
||||||
|
put<RMA>(`/rma/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
del<void>(`/rma/${id}`),
|
||||||
|
|
||||||
|
approve: (id: string) =>
|
||||||
|
post<RMA>(`/rma/${id}/approve`),
|
||||||
|
};
|
||||||
76
frontend/src/services/settings.api.ts
Normal file
76
frontend/src/services/settings.api.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { get, getPaginated, post, put, del } from './api';
|
||||||
|
import type {
|
||||||
|
UserRole,
|
||||||
|
User,
|
||||||
|
TaskStatus,
|
||||||
|
Priority,
|
||||||
|
EquipmentType,
|
||||||
|
RevisionType,
|
||||||
|
RMAStatus,
|
||||||
|
RMASolution,
|
||||||
|
Tag,
|
||||||
|
SystemSetting,
|
||||||
|
} from '@/types';
|
||||||
|
|
||||||
|
// Config endpoints
|
||||||
|
export const settingsApi = {
|
||||||
|
// Task Statuses
|
||||||
|
getTaskStatuses: () => get<TaskStatus[]>('/settings/task-statuses'),
|
||||||
|
createTaskStatus: (data: Omit<TaskStatus, 'id'>) => post<TaskStatus>('/settings/task-statuses', data),
|
||||||
|
updateTaskStatus: (id: string, data: Partial<TaskStatus>) => put<TaskStatus>(`/settings/task-statuses/${id}`, data),
|
||||||
|
deleteTaskStatus: (id: string) => del<void>(`/settings/task-statuses/${id}`),
|
||||||
|
|
||||||
|
// Priorities
|
||||||
|
getPriorities: () => get<Priority[]>('/settings/priorities'),
|
||||||
|
createPriority: (data: Omit<Priority, 'id'>) => post<Priority>('/settings/priorities', data),
|
||||||
|
updatePriority: (id: string, data: Partial<Priority>) => put<Priority>(`/settings/priorities/${id}`, data),
|
||||||
|
deletePriority: (id: string) => del<void>(`/settings/priorities/${id}`),
|
||||||
|
|
||||||
|
// Equipment Types
|
||||||
|
getEquipmentTypes: () => get<EquipmentType[]>('/settings/equipment-types'),
|
||||||
|
createEquipmentType: (data: Omit<EquipmentType, 'id'>) => post<EquipmentType>('/settings/equipment-types', data),
|
||||||
|
updateEquipmentType: (id: string, data: Partial<EquipmentType>) => put<EquipmentType>(`/settings/equipment-types/${id}`, data),
|
||||||
|
deleteEquipmentType: (id: string) => del<void>(`/settings/equipment-types/${id}`),
|
||||||
|
|
||||||
|
// Revision Types
|
||||||
|
getRevisionTypes: () => get<RevisionType[]>('/settings/revision-types'),
|
||||||
|
createRevisionType: (data: Omit<RevisionType, 'id'>) => post<RevisionType>('/settings/revision-types', data),
|
||||||
|
updateRevisionType: (id: string, data: Partial<RevisionType>) => put<RevisionType>(`/settings/revision-types/${id}`, data),
|
||||||
|
deleteRevisionType: (id: string) => del<void>(`/settings/revision-types/${id}`),
|
||||||
|
|
||||||
|
// RMA Statuses
|
||||||
|
getRMAStatuses: () => get<RMAStatus[]>('/settings/rma-statuses'),
|
||||||
|
createRMAStatus: (data: Omit<RMAStatus, 'id'>) => post<RMAStatus>('/settings/rma-statuses', data),
|
||||||
|
updateRMAStatus: (id: string, data: Partial<RMAStatus>) => put<RMAStatus>(`/settings/rma-statuses/${id}`, data),
|
||||||
|
deleteRMAStatus: (id: string) => del<void>(`/settings/rma-statuses/${id}`),
|
||||||
|
|
||||||
|
// RMA Solutions
|
||||||
|
getRMASolutions: () => get<RMASolution[]>('/settings/rma-solutions'),
|
||||||
|
createRMASolution: (data: Omit<RMASolution, 'id'>) => post<RMASolution>('/settings/rma-solutions', data),
|
||||||
|
updateRMASolution: (id: string, data: Partial<RMASolution>) => put<RMASolution>(`/settings/rma-solutions/${id}`, data),
|
||||||
|
deleteRMASolution: (id: string) => del<void>(`/settings/rma-solutions/${id}`),
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
getTags: () => get<Tag[]>('/settings/tags'),
|
||||||
|
createTag: (data: Omit<Tag, 'id'>) => post<Tag>('/settings/tags', data),
|
||||||
|
updateTag: (id: string, data: Partial<Tag>) => put<Tag>(`/settings/tags/${id}`, data),
|
||||||
|
deleteTag: (id: string) => del<void>(`/settings/tags/${id}`),
|
||||||
|
|
||||||
|
// User Roles
|
||||||
|
getUserRoles: () => get<UserRole[]>('/settings/user-roles'),
|
||||||
|
createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/user-roles', data),
|
||||||
|
updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/user-roles/${id}`, data),
|
||||||
|
deleteUserRole: (id: string) => del<void>(`/settings/user-roles/${id}`),
|
||||||
|
|
||||||
|
// System Settings
|
||||||
|
getSystemSettings: () => get<SystemSetting[]>('/settings/system'),
|
||||||
|
updateSystemSetting: (id: string, value: unknown) => put<SystemSetting>(`/settings/system/${id}`, { value }),
|
||||||
|
|
||||||
|
// Users (admin)
|
||||||
|
getUsers: () => getPaginated<User>('/users?limit=1000'),
|
||||||
|
// Jednoduchý zoznam pre selecty (server-side vyhľadávanie)
|
||||||
|
getUsersSimple: (search?: string) => get<{ id: string; name: string; email: string }[]>(`/users/simple${search ? `?search=${encodeURIComponent(search)}` : ''}`),
|
||||||
|
createUser: (data: { email: string; password: string; name: string; roleId: string }) => post<User>('/users', data),
|
||||||
|
updateUser: (id: string, data: Partial<User & { password?: string }>) => put<User>(`/users/${id}`, data),
|
||||||
|
deleteUser: (id: string) => del<void>(`/users/${id}`),
|
||||||
|
};
|
||||||
67
frontend/src/services/tasks.api.ts
Normal file
67
frontend/src/services/tasks.api.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { get, getPaginated, post, put, del } from './api';
|
||||||
|
import type { Task } from '@/types';
|
||||||
|
|
||||||
|
export interface TaskFilters {
|
||||||
|
search?: string;
|
||||||
|
projectId?: string;
|
||||||
|
statusId?: string;
|
||||||
|
priorityId?: string;
|
||||||
|
assigneeId?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTaskData {
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
parentId?: string;
|
||||||
|
statusId?: string;
|
||||||
|
priorityId?: string;
|
||||||
|
deadline?: string;
|
||||||
|
assigneeIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateTaskData = Partial<CreateTaskData>;
|
||||||
|
|
||||||
|
function buildQueryString(filters: TaskFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filters.search) params.append('search', filters.search);
|
||||||
|
if (filters.projectId) params.append('projectId', filters.projectId);
|
||||||
|
if (filters.statusId) params.append('statusId', filters.statusId);
|
||||||
|
if (filters.priorityId) params.append('priorityId', filters.priorityId);
|
||||||
|
if (filters.assigneeId) params.append('assigneeId', filters.assigneeId);
|
||||||
|
if (filters.page) params.append('page', String(filters.page));
|
||||||
|
if (filters.limit) params.append('limit', String(filters.limit));
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tasksApi = {
|
||||||
|
getAll: (filters: TaskFilters = {}) =>
|
||||||
|
getPaginated<Task>(`/tasks?${buildQueryString(filters)}`),
|
||||||
|
|
||||||
|
getById: (id: string) =>
|
||||||
|
get<Task>(`/tasks/${id}`),
|
||||||
|
|
||||||
|
create: (data: CreateTaskData) =>
|
||||||
|
post<Task>('/tasks', data),
|
||||||
|
|
||||||
|
update: (id: string, data: UpdateTaskData) =>
|
||||||
|
put<Task>(`/tasks/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: string) =>
|
||||||
|
del<void>(`/tasks/${id}`),
|
||||||
|
|
||||||
|
assignUser: (taskId: string, userId: string) =>
|
||||||
|
post<void>(`/tasks/${taskId}/assign`, { userId }),
|
||||||
|
|
||||||
|
unassignUser: (taskId: string, userId: string) =>
|
||||||
|
del<void>(`/tasks/${taskId}/assign/${userId}`),
|
||||||
|
|
||||||
|
// Comments
|
||||||
|
getComments: (taskId: string) =>
|
||||||
|
get<{ id: string; content: string; userId: string; user?: { id: string; name: string }; createdAt: string }[]>(`/tasks/${taskId}/comments`),
|
||||||
|
|
||||||
|
addComment: (taskId: string, content: string) =>
|
||||||
|
post<void>(`/tasks/${taskId}/comments`, { content }),
|
||||||
|
};
|
||||||
80
frontend/src/store/authStore.ts
Normal file
80
frontend/src/store/authStore.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import type { User } from '@/types';
|
||||||
|
import { authApi, type LoginCredentials } from '@/services/auth.api';
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
login: (credentials: LoginCredentials) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
fetchProfile: () => Promise<void>;
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
login: async (credentials) => {
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
try {
|
||||||
|
const response = await authApi.login(credentials);
|
||||||
|
const { user, accessToken, refreshToken } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('accessToken', accessToken);
|
||||||
|
localStorage.setItem('refreshToken', refreshToken);
|
||||||
|
|
||||||
|
set({ user, isAuthenticated: true, isLoading: false });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Prihlásenie zlyhalo';
|
||||||
|
set({ error: message, isLoading: false });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch {
|
||||||
|
// Ignore logout errors
|
||||||
|
} finally {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchProfile: async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
set({ user: null, isAuthenticated: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const response = await authApi.getProfile();
|
||||||
|
set({ user: response.data, isAuthenticated: true, isLoading: false });
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
set({ user: null, isAuthenticated: false, isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearError: () => set({ error: null }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'auth-storage',
|
||||||
|
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
91
frontend/src/store/configStore.ts
Normal file
91
frontend/src/store/configStore.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type {
|
||||||
|
TaskStatus,
|
||||||
|
Priority,
|
||||||
|
EquipmentType,
|
||||||
|
RevisionType,
|
||||||
|
RMAStatus,
|
||||||
|
RMASolution,
|
||||||
|
UserRole,
|
||||||
|
} from '@/types';
|
||||||
|
import { settingsApi } from '@/services/settings.api';
|
||||||
|
|
||||||
|
interface ConfigState {
|
||||||
|
taskStatuses: TaskStatus[];
|
||||||
|
priorities: Priority[];
|
||||||
|
equipmentTypes: EquipmentType[];
|
||||||
|
revisionTypes: RevisionType[];
|
||||||
|
rmaStatuses: RMAStatus[];
|
||||||
|
rmaSolutions: RMASolution[];
|
||||||
|
userRoles: UserRole[];
|
||||||
|
isLoading: boolean;
|
||||||
|
isLoaded: boolean;
|
||||||
|
|
||||||
|
fetchConfig: () => Promise<void>;
|
||||||
|
getTaskStatusById: (id: string) => TaskStatus | undefined;
|
||||||
|
getPriorityById: (id: string) => Priority | undefined;
|
||||||
|
getEquipmentTypeById: (id: string) => EquipmentType | undefined;
|
||||||
|
getRevisionTypeById: (id: string) => RevisionType | undefined;
|
||||||
|
getRMAStatusById: (id: string) => RMAStatus | undefined;
|
||||||
|
getRMASolutionById: (id: string) => RMASolution | undefined;
|
||||||
|
getUserRoleById: (id: string) => UserRole | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfigStore = create<ConfigState>((set, get) => ({
|
||||||
|
taskStatuses: [],
|
||||||
|
priorities: [],
|
||||||
|
equipmentTypes: [],
|
||||||
|
revisionTypes: [],
|
||||||
|
rmaStatuses: [],
|
||||||
|
rmaSolutions: [],
|
||||||
|
userRoles: [],
|
||||||
|
isLoading: false,
|
||||||
|
isLoaded: false,
|
||||||
|
|
||||||
|
fetchConfig: async () => {
|
||||||
|
if (get().isLoaded) return;
|
||||||
|
|
||||||
|
set({ isLoading: true });
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
taskStatusesRes,
|
||||||
|
prioritiesRes,
|
||||||
|
equipmentTypesRes,
|
||||||
|
revisionTypesRes,
|
||||||
|
rmaStatusesRes,
|
||||||
|
rmaSolutionsRes,
|
||||||
|
userRolesRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
settingsApi.getTaskStatuses(),
|
||||||
|
settingsApi.getPriorities(),
|
||||||
|
settingsApi.getEquipmentTypes(),
|
||||||
|
settingsApi.getRevisionTypes(),
|
||||||
|
settingsApi.getRMAStatuses(),
|
||||||
|
settingsApi.getRMASolutions(),
|
||||||
|
settingsApi.getUserRoles(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
set({
|
||||||
|
taskStatuses: taskStatusesRes.data,
|
||||||
|
priorities: prioritiesRes.data,
|
||||||
|
equipmentTypes: equipmentTypesRes.data,
|
||||||
|
revisionTypes: revisionTypesRes.data,
|
||||||
|
rmaStatuses: rmaStatusesRes.data,
|
||||||
|
rmaSolutions: rmaSolutionsRes.data,
|
||||||
|
userRoles: userRolesRes.data,
|
||||||
|
isLoading: false,
|
||||||
|
isLoaded: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getTaskStatusById: (id) => get().taskStatuses.find((s) => s.id === id),
|
||||||
|
getPriorityById: (id) => get().priorities.find((p) => p.id === id),
|
||||||
|
getEquipmentTypeById: (id) => get().equipmentTypes.find((t) => t.id === id),
|
||||||
|
getRevisionTypeById: (id) => get().revisionTypes.find((t) => t.id === id),
|
||||||
|
getRMAStatusById: (id) => get().rmaStatuses.find((s) => s.id === id),
|
||||||
|
getRMASolutionById: (id) => get().rmaSolutions.find((s) => s.id === id),
|
||||||
|
getUserRoleById: (id) => get().userRoles.find((r) => r.id === id),
|
||||||
|
}));
|
||||||
290
frontend/src/types/index.ts
Normal file
290
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
// User & Auth
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
active: boolean;
|
||||||
|
role: UserRole;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRole {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
permissions: Record<string, string[]>;
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthTokens {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
user: User;
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Customer
|
||||||
|
export interface Customer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
ico?: string;
|
||||||
|
dic?: string;
|
||||||
|
icdph?: string;
|
||||||
|
contactPerson?: string;
|
||||||
|
contactEmail?: string;
|
||||||
|
contactPhone?: string;
|
||||||
|
notes?: string;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_count?: {
|
||||||
|
projects: number;
|
||||||
|
equipment: number;
|
||||||
|
rmas: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
customerId?: string;
|
||||||
|
customer?: Pick<Customer, 'id' | 'name'>;
|
||||||
|
ownerId: string;
|
||||||
|
owner: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
statusId: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
softDeadline?: string;
|
||||||
|
hardDeadline?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
_count?: {
|
||||||
|
tasks: number;
|
||||||
|
members: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task
|
||||||
|
export interface Task {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
projectId?: string;
|
||||||
|
project?: Pick<Project, 'id' | 'name'>;
|
||||||
|
parentId?: string;
|
||||||
|
statusId: string;
|
||||||
|
status: TaskStatus;
|
||||||
|
priorityId: string;
|
||||||
|
priority: Priority;
|
||||||
|
deadline?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
createdById: string;
|
||||||
|
createdBy: Pick<User, 'id' | 'name'>;
|
||||||
|
assignees: TaskAssignee[];
|
||||||
|
_count?: {
|
||||||
|
subTasks: number;
|
||||||
|
comments: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskAssignee {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
user: Pick<User, 'id' | 'name' | 'email'>;
|
||||||
|
assignedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
export interface Equipment {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
typeId: string;
|
||||||
|
type: EquipmentType;
|
||||||
|
brand?: string;
|
||||||
|
model?: string;
|
||||||
|
customerId?: string;
|
||||||
|
customer?: Pick<Customer, 'id' | 'name'>;
|
||||||
|
address: string;
|
||||||
|
location?: string;
|
||||||
|
partNumber?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
installDate?: string;
|
||||||
|
warrantyEnd?: string;
|
||||||
|
warrantyStatus?: string;
|
||||||
|
description?: string;
|
||||||
|
notes?: string;
|
||||||
|
active: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_count?: {
|
||||||
|
revisions: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Revision {
|
||||||
|
id: string;
|
||||||
|
equipmentId: string;
|
||||||
|
typeId: string;
|
||||||
|
type: RevisionType;
|
||||||
|
performedDate: string;
|
||||||
|
nextDueDate?: string;
|
||||||
|
performedById: string;
|
||||||
|
performedBy: Pick<User, 'id' | 'name'>;
|
||||||
|
findings?: string;
|
||||||
|
result?: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RMA
|
||||||
|
export interface RMA {
|
||||||
|
id: string;
|
||||||
|
rmaNumber: string;
|
||||||
|
customerId?: string;
|
||||||
|
customer?: Pick<Customer, 'id' | 'name'>;
|
||||||
|
customerName?: string;
|
||||||
|
customerAddress?: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerPhone?: string;
|
||||||
|
customerICO?: string;
|
||||||
|
submittedBy: string;
|
||||||
|
productName: string;
|
||||||
|
invoiceNumber?: string;
|
||||||
|
purchaseDate?: string;
|
||||||
|
productNumber?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
accessories?: string;
|
||||||
|
issueDescription: string;
|
||||||
|
statusId: string;
|
||||||
|
status: RMAStatus;
|
||||||
|
proposedSolutionId?: string;
|
||||||
|
proposedSolution?: RMASolution;
|
||||||
|
requiresApproval: boolean;
|
||||||
|
approvedById?: string;
|
||||||
|
approvedAt?: string;
|
||||||
|
receivedDate?: string;
|
||||||
|
receivedLocation?: string;
|
||||||
|
internalNotes?: string;
|
||||||
|
resolutionDate?: string;
|
||||||
|
resolutionNotes?: string;
|
||||||
|
assignedToId?: string;
|
||||||
|
assignedTo?: Pick<User, 'id' | 'name'>;
|
||||||
|
createdById: string;
|
||||||
|
createdBy: Pick<User, 'id' | 'name'>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
closedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config Types
|
||||||
|
export interface TaskStatus {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
swimlaneColumn?: string;
|
||||||
|
isInitial: boolean;
|
||||||
|
isFinal: boolean;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Priority {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
level: number;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EquipmentType {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RevisionType {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
intervalDays: number;
|
||||||
|
reminderDays: number;
|
||||||
|
color?: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RMAStatus {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
isInitial: boolean;
|
||||||
|
isFinal: boolean;
|
||||||
|
canTransitionTo?: string[];
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RMASolution {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
entityType: 'PROJECT' | 'TASK' | 'EQUIPMENT' | 'RMA';
|
||||||
|
order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemSetting {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
value: unknown;
|
||||||
|
category: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
dataType: 'string' | 'number' | 'boolean' | 'json';
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Response
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
hasPrev: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
32
frontend/tsconfig.app.json
Normal file
32
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Path aliases */
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user