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:
@@ -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>
|
||||
|
||||
232
frontend/src/components/NotificationCenter.tsx
Normal file
232
frontend/src/components/NotificationCenter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
82
frontend/src/hooks/useSnoozeOptions.ts
Normal file
82
frontend/src/hooks/useSnoozeOptions.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
91
frontend/src/pages/settings/PasswordResetModal.tsx
Normal file
91
frontend/src/pages/settings/PasswordResetModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 má "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>
|
||||
);
|
||||
}
|
||||
|
||||
176
frontend/src/pages/settings/UserForm.tsx
Normal file
176
frontend/src/pages/settings/UserForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
249
frontend/src/pages/settings/UserManagement.tsx
Normal file
249
frontend/src/pages/settings/UserManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
63
frontend/src/services/notification.api.ts
Normal file
63
frontend/src/services/notification.api.ts
Normal 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}`),
|
||||
};
|
||||
@@ -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'),
|
||||
|
||||
54
frontend/src/services/users.api.ts
Normal file
54
frontend/src/services/users.api.ts
Normal 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}`),
|
||||
};
|
||||
114
frontend/src/store/notificationStore.ts
Normal file
114
frontend/src/store/notificationStore.ts
Normal 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,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
15
frontend/src/store/sidebarStore.ts
Normal file
15
frontend/src/store/sidebarStore.ts
Normal 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 }),
|
||||
}));
|
||||
Reference in New Issue
Block a user