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:
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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user