Initial commit: Helpdesk application setup

- Backend: Node.js/TypeScript with Prisma ORM
- Frontend: Vite + TypeScript
- Project configuration and documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 08:53:22 +01:00
commit e4f63a135e
103 changed files with 19913 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,15 @@
import { Outlet } from 'react-router-dom';
import { Header } from './Header';
import { Sidebar } from './Sidebar';
export function MainLayout() {
return (
<div className="min-h-screen bg-background">
<Header />
<Sidebar />
<main className="ml-56 p-6">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { NavLink } from 'react-router-dom';
import {
LayoutDashboard,
FolderKanban,
CheckSquare,
Users,
Wrench,
RotateCcw,
} from 'lucide-react';
import { cn } from '@/lib/utils';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/projects', icon: FolderKanban, label: 'Projekty' },
{ to: '/tasks', icon: CheckSquare, label: 'Úlohy' },
{ to: '/customers', icon: Users, label: 'Zákazníci' },
{ to: '/equipment', icon: Wrench, label: 'Zariadenia' },
{ to: '/rma', icon: RotateCcw, label: 'RMA' },
];
export function Sidebar() {
return (
<aside className="fixed left-0 top-14 z-30 h-[calc(100vh-3.5rem)] w-56 border-r bg-background">
<nav className="flex flex-col gap-1 p-4">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)
}
>
<item.icon className="h-4 w-4" />
{item.label}
</NavLink>
))}
</nav>
</aside>
);
}

View File

@@ -0,0 +1,3 @@
export { Header } from './Header';
export { Sidebar } from './Sidebar';
export { MainLayout } from './MainLayout';

View File

@@ -0,0 +1,31 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
variant?: 'default' | 'secondary' | 'destructive' | 'outline';
color?: string;
children: ReactNode;
}
export function Badge({ className, variant = 'default', color, children, style, ...props }: BadgeProps) {
const variants = {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
};
const customStyle = color
? { backgroundColor: color, borderColor: color, color: '#fff', ...style }
: style;
return (
<span
className={cn('badge', !color && variants[variant], className)}
style={customStyle}
{...props}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,41 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'destructive' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = 'primary', size = 'md', isLoading, children, disabled, ...props }, ref) => {
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
destructive: 'btn-destructive',
outline: 'btn-outline',
ghost: 'btn-ghost',
};
const sizes = {
sm: 'btn-sm',
md: 'btn-md',
lg: 'btn-lg',
};
return (
<button
ref={ref}
className={cn('btn', variants[variant], sizes[size], className)}
disabled={disabled || isLoading}
{...props}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{children}
</button>
);
}
);
Button.displayName = 'Button';

View File

@@ -0,0 +1,74 @@
import type { HTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/utils';
interface CardProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function Card({ className, children, ...props }: CardProps) {
return (
<div className={cn('card', className)} {...props}>
{children}
</div>
);
}
interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function CardHeader({ className, children, ...props }: CardHeaderProps) {
return (
<div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props}>
{children}
</div>
);
}
interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {
children: ReactNode;
}
export function CardTitle({ className, children, ...props }: CardTitleProps) {
return (
<h3 className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props}>
{children}
</h3>
);
}
interface CardDescriptionProps extends HTMLAttributes<HTMLParagraphElement> {
children: ReactNode;
}
export function CardDescription({ className, children, ...props }: CardDescriptionProps) {
return (
<p className={cn('text-sm text-muted-foreground', className)} {...props}>
{children}
</p>
);
}
interface CardContentProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function CardContent({ className, children, ...props }: CardContentProps) {
return (
<div className={cn('p-6 pt-0', className)} {...props}>
{children}
</div>
);
}
interface CardFooterProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
}
export function CardFooter({ className, children, ...props }: CardFooterProps) {
return (
<div className={cn('flex items-center p-6 pt-0', className)} {...props}>
{children}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
error?: string;
label?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, error, label, id, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
)}
<input
ref={ref}
id={id}
className={cn(
'input',
error && 'border-destructive focus-visible:ring-destructive',
className
)}
{...props}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
);
Input.displayName = 'Input';

View File

@@ -0,0 +1,78 @@
import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
import { X } from 'lucide-react';
import { Button } from './Button';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: ReactNode;
className?: string;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export function Modal({ isOpen, onClose, title, children, className, size = 'md' }: ModalProps) {
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) {
document.addEventListener('keydown', handleEscape);
document.body.style.overflow = 'hidden';
}
return () => {
document.removeEventListener('keydown', handleEscape);
document.body.style.overflow = 'unset';
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const sizes = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
};
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div
className={cn(
'relative z-50 w-full rounded-lg bg-background p-6 shadow-lg',
sizes[size],
className
)}
>
{title && (
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold">{title}</h2>
<Button variant="ghost" size="sm" onClick={onClose} className="h-8 w-8 p-0">
<X className="h-4 w-4" />
</Button>
</div>
)}
{children}
</div>
</div>,
document.body
);
}
interface ModalFooterProps {
children: ReactNode;
className?: string;
}
export function ModalFooter({ children, className }: ModalFooterProps) {
return (
<div className={cn('mt-6 flex justify-end gap-2', className)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { forwardRef, type SelectHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
error?: string;
label?: string;
options: SelectOption[];
placeholder?: string;
}
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, error, label, id, options, placeholder, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
)}
<select
ref={ref}
id={id}
className={cn(
'input appearance-none bg-background',
error && 'border-destructive focus-visible:ring-destructive',
className
)}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
);
Select.displayName = 'Select';

View File

@@ -0,0 +1,32 @@
import { cn } from '@/lib/utils';
import { Loader2 } from 'lucide-react';
interface SpinnerProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
}
export function Spinner({ className, size = 'md' }: SpinnerProps) {
const sizes = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
};
return <Loader2 className={cn('animate-spin text-primary', sizes[size], className)} />;
}
interface LoadingOverlayProps {
message?: string;
}
export function LoadingOverlay({ message = 'Načítavam...' }: LoadingOverlayProps) {
return (
<div className="flex h-full min-h-[200px] items-center justify-center">
<div className="flex flex-col items-center gap-2">
<Spinner size="lg" />
<p className="text-sm text-muted-foreground">{message}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,85 @@
import type { HTMLAttributes, ReactNode, ThHTMLAttributes, TdHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
interface TableProps extends HTMLAttributes<HTMLTableElement> {
children: ReactNode;
}
export function Table({ className, children, ...props }: TableProps) {
return (
<div className="w-full overflow-auto">
<table className={cn('w-full caption-bottom text-sm', className)} {...props}>
{children}
</table>
</div>
);
}
interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode;
}
export function TableHeader({ className, children, ...props }: TableHeaderProps) {
return (
<thead className={cn('[&_tr]:border-b', className)} {...props}>
{children}
</thead>
);
}
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
children: ReactNode;
}
export function TableBody({ className, children, ...props }: TableBodyProps) {
return (
<tbody className={cn('[&_tr:last-child]:border-0', className)} {...props}>
{children}
</tbody>
);
}
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
children: ReactNode;
}
export function TableRow({ className, children, ...props }: TableRowProps) {
return (
<tr
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
{...props}
>
{children}
</tr>
);
}
interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {
children?: ReactNode;
}
export function TableHead({ className, children, ...props }: TableHeadProps) {
return (
<th
className={cn(
'h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
>
{children}
</th>
);
}
interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
children?: ReactNode;
}
export function TableCell({ className, children, ...props }: TableCellProps) {
return (
<td className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props}>
{children}
</td>
);
}

View File

@@ -0,0 +1,34 @@
import { forwardRef, type TextareaHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
error?: string;
label?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, error, label, id, ...props }, ref) => {
return (
<div className="space-y-1">
{label && (
<label htmlFor={id} className="text-sm font-medium text-foreground">
{label}
</label>
)}
<textarea
ref={ref}
id={id}
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
error && 'border-destructive focus-visible:ring-destructive',
className
)}
{...props}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
}
);
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,195 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { X, Search, ChevronDown, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { settingsApi } from '@/services/settings.api';
interface User {
id: string;
name: string;
email: string;
}
interface UserSelectProps {
selectedIds: string[];
onChange: (ids: string[]) => void;
label?: string;
placeholder?: string;
initialUsers?: User[]; // Pre-loaded users (napr. pri editácii)
}
export function UserSelect({
selectedIds,
onChange,
label,
placeholder = 'Vyhľadať používateľa...',
initialUsers = [],
}: UserSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [users, setUsers] = useState<User[]>(initialUsers);
const [selectedUsers, setSelectedUsers] = useState<User[]>(initialUsers.filter(u => selectedIds.includes(u.id)));
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Zatvoriť dropdown pri kliknutí mimo
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Načítať používateľov pri otvorení alebo zmene vyhľadávania
const fetchUsers = useCallback(async (searchQuery: string) => {
setIsLoading(true);
try {
const response = await settingsApi.getUsersSimple(searchQuery || undefined);
setUsers(response.data);
} catch (error) {
console.error('Error fetching users:', error);
} finally {
setIsLoading(false);
}
}, []);
// Debounced search
useEffect(() => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
if (isOpen) {
debounceRef.current = setTimeout(() => {
fetchUsers(search);
}, 300);
}
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [search, isOpen, fetchUsers]);
// Načítať počiatočných používateľov pri otvorení
useEffect(() => {
if (isOpen && users.length === 0) {
fetchUsers('');
}
}, [isOpen, users.length, fetchUsers]);
const filteredUsers = users.filter((u) => !selectedIds.includes(u.id));
const handleSelect = (user: User) => {
onChange([...selectedIds, user.id]);
setSelectedUsers([...selectedUsers, user]);
setSearch('');
inputRef.current?.focus();
};
const handleRemove = (userId: string) => {
onChange(selectedIds.filter((id) => id !== userId));
setSelectedUsers(selectedUsers.filter((u) => u.id !== userId));
};
return (
<div className="space-y-1" ref={containerRef}>
{label && (
<label className="text-sm font-medium text-foreground">{label}</label>
)}
<div
className={cn(
'min-h-10 w-full rounded-md border border-input bg-background px-3 py-2',
'focus-within:ring-2 focus-within:ring-ring',
isOpen && 'ring-2 ring-ring'
)}
onClick={() => {
setIsOpen(true);
inputRef.current?.focus();
}}
>
{/* Vybraní používatelia ako tagy */}
<div className="flex flex-wrap gap-1 mb-1">
{selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex items-center gap-1 bg-primary/10 text-primary rounded-md px-2 py-0.5 text-sm"
>
{user.name}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemove(user.id);
}}
className="hover:bg-primary/20 rounded-full p-0.5"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
{/* Vyhľadávací input */}
<div className="flex items-center gap-2">
<Search className="h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={selectedUsers.length === 0 ? placeholder : 'Pridať ďalšieho...'}
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
/>
{isLoading ? (
<Loader2 className="h-4 w-4 text-muted-foreground animate-spin" />
) : (
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
)}
</div>
</div>
{/* Dropdown so zoznamom */}
{isOpen && (
<div className="relative">
<div className="absolute z-50 w-full mt-1 bg-popover border border-input rounded-md shadow-lg max-h-48 overflow-y-auto">
{isLoading ? (
<div className="px-3 py-2 text-sm text-muted-foreground flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Načítavam...
</div>
) : filteredUsers.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{search ? 'Žiadne výsledky' : 'Všetci používatelia sú vybraní'}
</div>
) : (
filteredUsers.map((user) => (
<button
key={user.id}
type="button"
onClick={() => handleSelect(user)}
className="w-full px-3 py-2 text-left hover:bg-accent flex flex-col"
>
<span className="text-sm font-medium">{user.name}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
</button>
))
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
export { Button, type ButtonProps } from './Button';
export { Input, type InputProps } from './Input';
export { Textarea, type TextareaProps } from './Textarea';
export { Select, type SelectProps, type SelectOption } from './Select';
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './Card';
export { Badge } from './Badge';
export { Modal, ModalFooter } from './Modal';
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
export { Spinner, LoadingOverlay } from './Spinner';
export { UserSelect } from './UserSelect';