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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,8 +6,10 @@ import {
Users,
Wrench,
RotateCcw,
X,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSidebarStore } from '@/store/sidebarStore';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
@@ -19,28 +21,57 @@ const navItems = [
];
export function Sidebar() {
const { isOpen, close } = useSidebarStore();
return (
<aside className="fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-56 border-r bg-background">
<nav className="flex flex-col gap-1 p-4">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
<>
{/* Overlay pre mobile */}
{isOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 md:hidden"
onClick={close}
/>
)}
{/* Sidebar */}
<aside
className={cn(
'fixed left-0 top-14 z-50 h-[calc(100vh-3.5rem)] w-56 border-r bg-background transition-transform duration-200',
'md:translate-x-0 md:z-30',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex items-center justify-between p-4 md:hidden">
<span className="font-semibold">Menu</span>
<button
onClick={close}
className="rounded-md p-1 hover:bg-accent"
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</nav>
</aside>
<X className="h-5 w-5" />
</button>
</div>
<nav className="flex flex-col gap-1 p-4 pt-0 md:pt-4">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={close}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</nav>
</aside>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { ExternalLink, AlertTriangle } from 'lucide-react';
import { zakazkyApi } from '@/services/zakazky.api';
import { useAuthStore } from '@/store/authStore';
import {
Input,
Card,
@@ -23,9 +24,11 @@ import { formatDate } from '@/lib/utils';
export function ProjectsList() {
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [search, setSearch] = useState('');
const { user } = useAuthStore();
const isAdmin = user?.role === 'ADMIN';
// Check if external DB is configured
const { data: zakazkyStatus, isLoading: statusLoading } = useQuery({
const { data: zakazkyStatus, isLoading: statusLoading, error: statusError } = useQuery({
queryKey: ['zakazky-status'],
queryFn: () => zakazkyApi.checkStatus(),
});
@@ -38,12 +41,20 @@ export function ProjectsList() {
});
// Get zakazky for selected year
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({
const { data: zakazkyData, isLoading: zakazkyLoading, error: zakazkyError } = useQuery({
queryKey: ['zakazky', selectedYear, search],
queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined),
enabled: !!zakazkyStatus?.data?.configured,
retry: 1,
});
// Extract error message
const getErrorMessage = (error: unknown): string => {
if (!error) return '';
const axiosError = error as { response?: { data?: { message?: string } }; message?: string };
return axiosError.response?.data?.message || axiosError.message || 'Neznáma chyba';
};
const isExternalDbConfigured = zakazkyStatus?.data?.configured;
const yearOptions = (yearsData?.data || []).map((year) => ({
value: String(year),
@@ -107,8 +118,25 @@ export function ProjectsList() {
</div>
</CardHeader>
<CardContent>
{/* Error display for admins */}
{isAdmin && (statusError || zakazkyError) && (
<div className="mb-4 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-center gap-2 text-red-800 font-medium mb-2">
<AlertTriangle className="h-5 w-5" />
Chyba pripojenia k externej databáze
</div>
<pre className="text-sm text-red-700 whitespace-pre-wrap font-mono bg-red-100 p-2 rounded">
{getErrorMessage(statusError || zakazkyError)}
</pre>
</div>
)}
{zakazkyLoading ? (
<LoadingOverlay />
) : zakazkyError ? (
<div className="text-center py-8 text-muted-foreground">
Nepodarilo sa načítať zákazky. Skúste obnoviť stránku.
</div>
) : (
<Table>
<TableHeader>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft } from 'lucide-react';
import { X, Send, Pencil, Calendar, User as UserIcon, Users, FolderOpen, CheckCircle2, ArrowLeft, Check, Clock } from 'lucide-react';
import { tasksApi } from '@/services/tasks.api';
import { settingsApi } from '@/services/settings.api';
import { useAuthStore } from '@/store/authStore';
import { useNotificationStore } from '@/store/notificationStore';
import { useSnoozeOptions, calculateSnoozeMinutes } from '@/hooks/useSnoozeOptions';
import type { Task } from '@/types';
import { Button, Badge, Textarea, Select } from '@/components/ui';
import { TaskForm } from './TaskForm';
@@ -22,13 +24,24 @@ interface TaskDetailProps {
taskId: string;
onClose: () => void;
onEdit?: (task: Task) => void; // Optional - ak nie je, použije sa interný edit mód
notificationId?: string; // Ak je detail otvorený z notifikácie
}
export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
export function TaskDetail({ taskId, onClose, notificationId }: TaskDetailProps) {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const [newComment, setNewComment] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [snoozeOpen, setSnoozeOpen] = useState(false);
// Notifikácie - len ak bol detail otvorený z notifikácie
const { notifications, markAsRead, snooze } = useNotificationStore();
const snoozeOptions = useSnoozeOptions();
// Konkrétna notifikácia ak existuje
const notification = notificationId
? notifications.find((n) => n.id === notificationId && !n.isRead)
: null;
const { data: taskData, isLoading } = useQuery({
queryKey: ['task', taskId],
@@ -56,6 +69,10 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] });
setNewComment('');
toast.success('Komentár bol pridaný');
// Označiť notifikáciu ako prečítanú ak existuje
if (notificationId) {
markAsRead(notificationId);
}
},
onError: (error: unknown) => {
const axiosError = error as { response?: { data?: { message?: string } } };
@@ -70,6 +87,10 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
toast.success('Úloha bola aktualizovaná');
// Označiť notifikáciu ako prečítanú ak existuje
if (notificationId) {
markAsRead(notificationId);
}
},
onError: () => {
toast.error('Chyba pri aktualizácii úlohy');
@@ -191,6 +212,47 @@ export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
</div>
</div>
<div className="flex items-center gap-2">
{/* Tlačidlá pre notifikáciu */}
{notification && (
<>
<Button
variant="outline"
size="sm"
onClick={() => markAsRead(notification.id)}
title="Označiť ako prečítané"
>
<Check className="h-4 w-4 mr-1" />
Prečítané
</Button>
<div className="relative">
<Button
variant="outline"
size="sm"
onClick={() => setSnoozeOpen(!snoozeOpen)}
title="Odložiť notifikáciu"
>
<Clock className="h-4 w-4 mr-1" />
Odložiť
</Button>
{snoozeOpen && (
<div className="absolute right-0 top-full mt-1 bg-popover border rounded-md shadow-lg z-20 min-w-[140px]">
{snoozeOptions.map((option) => (
<button
key={option.label}
onClick={() => {
snooze(notification.id, calculateSnoozeMinutes(option));
setSnoozeOpen(false);
}}
className="w-full px-3 py-1.5 text-left text-sm hover:bg-accent first:rounded-t-md last:rounded-b-md"
>
{option.label}
</button>
))}
</div>
)}
</div>
</>
)}
{canEdit && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="h-4 w-4 mr-1" />

View File

@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search, MessageSquare } from 'lucide-react';
import { Plus, Pencil, Trash2, Search, MessageSquare, Calendar, User as UserIcon } from 'lucide-react';
import { tasksApi } from '@/services/tasks.api';
import type { Task } from '@/types';
import {
@@ -9,12 +9,6 @@ import {
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
@@ -62,9 +56,9 @@ export function TasksList() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold">Úlohy</h1>
<Button onClick={() => setIsFormOpen(true)}>
<Button onClick={() => setIsFormOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
Nová úloha
</Button>
@@ -72,80 +66,96 @@ export function TasksList() {
<Card>
<CardHeader>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Hľadať úlohy..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="relative w-full sm:max-w-sm">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Hľadať úlohy..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</CardHeader>
<CardContent>
<CardContent className="p-0">
{isLoading ? (
<LoadingOverlay />
) : data?.data.length === 0 ? (
<p className="text-center text-muted-foreground py-8">Žiadne úlohy</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Zadal</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Priorita</TableHead>
<TableHead>Termín</TableHead>
<TableHead>Priradení</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-medium">
<button
onClick={() => setDetailTaskId(task.id)}
className="text-left hover:text-primary hover:underline"
>
{task.title}
</button>
</TableCell>
<TableCell>{task.createdBy?.name || '-'}</TableCell>
<TableCell>
<Badge color={task.status.color}>{task.status.name}</Badge>
</TableCell>
<TableCell>
<Badge color={task.priority.color}>{task.priority.name}</Badge>
</TableCell>
<TableCell>{task.deadline ? formatDate(task.deadline) : '-'}</TableCell>
<TableCell>
{task.assignees.length > 0
? task.assignees.map((a) => a.user.name).join(', ')
: '-'}
</TableCell>
<TableCell className="text-right">
<div className="divide-y">
{data?.data.map((task) => (
<div
key={task.id}
className="p-4 hover:bg-accent/50 transition-colors"
>
{/* Hlavný riadok */}
<div className="flex items-center gap-3">
{/* Stav a priorita - kompaktne */}
<div className="hidden sm:flex flex-col gap-1 shrink-0 w-24">
<Badge color={task.status.color} className="text-xs justify-center">{task.status.name}</Badge>
<Badge color={task.priority.color} className="text-xs justify-center">{task.priority.name}</Badge>
</div>
{/* Obsah */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{/* Mobile: badge inline */}
<div className="flex sm:hidden gap-1">
<Badge color={task.status.color} className="text-xs">{task.status.name}</Badge>
</div>
<button
onClick={() => setDetailTaskId(task.id)}
className="font-medium hover:text-primary hover:underline truncate"
>
{task.title}
</button>
</div>
{/* Popis - skrátený na 1 riadok */}
{task.description && (
<p className="text-sm text-muted-foreground truncate mb-1">
{task.description}
</p>
)}
{/* Metadáta */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
{task.deadline && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
{formatDate(task.deadline)}
</span>
)}
{task.assignees.length > 0 && (
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{task.assignees.map((a) => a.user.name).join(', ')}
</span>
)}
{task.createdBy && (
<span className="hidden md:inline">
Zadal: {task.createdBy.name}
</span>
)}
</div>
</div>
{/* Akcie */}
<div className="flex shrink-0 gap-1">
<Button variant="ghost" size="sm" onClick={() => setDetailTaskId(task.id)} title="Detail">
<MessageSquare className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(task)} title="Upraviť">
<Button variant="ghost" size="sm" onClick={() => handleEdit(task)} title="Upraviť" className="hidden sm:inline-flex">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať">
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať" className="hidden sm:inline-flex">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground">
Žiadne úlohy
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>

View File

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

View File

@@ -57,14 +57,14 @@ export const settingsApi = {
deleteTag: (id: string) => del<void>(`/settings/tags/${id}`),
// User Roles
getUserRoles: () => get<UserRole[]>('/settings/user-roles'),
createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/user-roles', data),
updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/user-roles/${id}`, data),
deleteUserRole: (id: string) => del<void>(`/settings/user-roles/${id}`),
getUserRoles: () => get<UserRole[]>('/settings/roles'),
createUserRole: (data: Omit<UserRole, 'id'>) => post<UserRole>('/settings/roles', data),
updateUserRole: (id: string, data: Partial<UserRole>) => put<UserRole>(`/settings/roles/${id}`, data),
deleteUserRole: (id: string) => del<void>(`/settings/roles/${id}`),
// System Settings
getSystemSettings: () => get<SystemSetting[]>('/settings/system'),
updateSystemSetting: (id: string, value: unknown) => put<SystemSetting>(`/settings/system/${id}`, { value }),
updateSystemSetting: (key: string, value: unknown) => put<SystemSetting>(`/settings/system/${key}`, { value }),
// Users (admin)
getUsers: () => getPaginated<User>('/users?limit=1000'),

View File

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

View File

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

View File

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