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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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