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

124
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,124 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { useEffect } from 'react';
import { useAuthStore } from '@/store/authStore';
import { useConfigStore } from '@/store/configStore';
import { MainLayout } from '@/components/layout';
import { LoadingOverlay } from '@/components/ui';
import { Login } from '@/pages/Login';
import { Dashboard } from '@/pages/Dashboard';
import { CustomersList } from '@/pages/customers';
import { ProjectsList } from '@/pages/projects';
import { TasksList } from '@/pages/tasks';
import { EquipmentList } from '@/pages/equipment';
import { RMAList } from '@/pages/rma';
import { SettingsDashboard } from '@/pages/settings';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, user, isLoading, fetchProfile } = useAuthStore();
const { fetchConfig, isLoaded: configLoaded } = useConfigStore();
useEffect(() => {
// Only fetch profile if we have a token but no user data
const token = localStorage.getItem('accessToken');
if (token && !user) {
fetchProfile();
}
}, [user, fetchProfile]);
useEffect(() => {
if (isAuthenticated && !configLoaded) {
fetchConfig();
}
}, [isAuthenticated, configLoaded, fetchConfig]);
// Show loading only when we're actively fetching
if (isLoading && !user) {
return <LoadingOverlay message="Načítavam..." />;
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function RootOnlyRoute({ children }: { children: React.ReactNode }) {
const { user } = useAuthStore();
if (user?.role.code !== 'ROOT') {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
function AppRoutes() {
const { isAuthenticated } = useAuthStore();
return (
<Routes>
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
<Route
element={
<ProtectedRoute>
<MainLayout />
</ProtectedRoute>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/customers" element={<CustomersList />} />
<Route path="/projects" element={<ProjectsList />} />
<Route path="/tasks" element={<TasksList />} />
<Route path="/equipment" element={<EquipmentList />} />
<Route path="/rma" element={<RMAList />} />
<Route
path="/settings"
element={
<RootOnlyRoute>
<SettingsDashboard />
</RootOnlyRoute>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
export function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
<Toaster
position="top-right"
toastOptions={{
duration: 3000,
style: {
background: '#fff',
color: '#0f172a',
border: '1px solid #e2e8f0',
},
}}
/>
</BrowserRouter>
</QueryClientProvider>
);
}

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';

88
frontend/src/index.css Normal file
View File

@@ -0,0 +1,88 @@
@import "tailwindcss";
@theme {
--color-primary: #3b82f6;
--color-primary-foreground: #ffffff;
--color-secondary: #f1f5f9;
--color-secondary-foreground: #0f172a;
--color-destructive: #ef4444;
--color-destructive-foreground: #ffffff;
--color-muted: #f1f5f9;
--color-muted-foreground: #64748b;
--color-accent: #f1f5f9;
--color-accent-foreground: #0f172a;
--color-background: #ffffff;
--color-foreground: #0f172a;
--color-card: #ffffff;
--color-card-foreground: #0f172a;
--color-popover: #ffffff;
--color-popover-foreground: #0f172a;
--color-border: #e2e8f0;
--color-input: #e2e8f0;
--color-ring: #3b82f6;
--radius-sm: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50;
}
.btn-primary {
@apply bg-primary text-primary-foreground hover:bg-primary/90;
}
.btn-secondary {
@apply bg-secondary text-secondary-foreground hover:bg-secondary/80;
}
.btn-destructive {
@apply bg-destructive text-destructive-foreground hover:bg-destructive/90;
}
.btn-outline {
@apply border border-input bg-background hover:bg-accent hover:text-accent-foreground;
}
.btn-ghost {
@apply hover:bg-accent hover:text-accent-foreground;
}
.btn-sm {
@apply h-8 px-3 text-xs;
}
.btn-md {
@apply h-10 px-4 py-2;
}
.btn-lg {
@apply h-12 px-6 text-base;
}
.input {
@apply flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50;
}
.card {
@apply rounded-lg border bg-card text-card-foreground shadow-sm;
}
.badge {
@apply inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors;
}
}

37
frontend/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,37 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDate(date: string | Date): string {
return new Date(date).toLocaleDateString('sk-SK', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
export function formatDateTime(date: string | Date): string {
return new Date(date).toLocaleString('sk-SK', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatRelativeTime(date: string | Date): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Dnes';
if (diffDays === 1) return 'Včera';
if (diffDays < 7) return `Pred ${diffDays} dňami`;
if (diffDays < 30) return `Pred ${Math.floor(diffDays / 7)} týždňami`;
return formatDate(date);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

View File

@@ -0,0 +1,384 @@
import { useState } 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
} from 'lucide-react';
import { get } from '@/services/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 };
}
interface DashboardToday {
myTasks: Task[];
myProjects: Project[];
}
export function Dashboard() {
const queryClient = useQueryClient();
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const { data: statsData, isLoading: statsLoading } = useQuery({
queryKey: ['dashboard'],
queryFn: () => get<DashboardStats>('/dashboard'),
});
const { data: todayData, isLoading: todayLoading } = useQuery({
queryKey: ['dashboard-today'],
queryFn: () => get<DashboardToday>('/dashboard/today'),
});
if (statsLoading || todayLoading) {
return <LoadingOverlay />;
}
const stats = statsData?.data;
const today = todayData?.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',
},
];
// Rozdelenie úloh podľa urgentnosti
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;
}) || [];
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;
}) || [];
const isOverdue = (deadline: string) => {
return new Date(deadline) < new Date();
};
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 'Dnes';
if (days === 1) return 'Zajtra';
return `${days} dní`;
};
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>
{/* Š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>
))}
</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>
</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>
)}
</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)}
</span>
)}
<Badge color={task.priority?.color}>{task.priority?.name}</Badge>
</div>
</div>
))}
</div>
</CardContent>
</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>
)}
</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.createdBy && (
<span className="flex items-center gap-1">
<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>
)}
</CardContent>
</Card>
</div>
{/* 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>
</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
taskId={detailTaskId}
onClose={() => {
setDetailTaskId(null);
// Refresh dashboard data po zatvorení
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useAuthStore } from '@/store/authStore';
import { Button, Input, Card, CardHeader, CardTitle, CardContent } from '@/components/ui';
import toast from 'react-hot-toast';
const loginSchema = z.object({
email: z.string().email('Neplatný email'),
password: z.string().min(1, 'Heslo je povinné'),
});
type LoginFormData = z.infer<typeof loginSchema>;
export function Login() {
const navigate = useNavigate();
const { login, isAuthenticated } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
// Redirect when authenticated
useEffect(() => {
if (isAuthenticated) {
navigate('/', { replace: true });
}
}, [isAuthenticated, navigate]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
setIsLoading(true);
try {
await login(data);
toast.success('Úspešne prihlásený');
} catch {
toast.error('Neplatné prihlasovacie údaje');
} finally {
setIsLoading(false);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-muted/50">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Helpdesk</CardTitle>
<p className="text-muted-foreground">Prihláste sa do svojho účtu</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
id="email"
type="email"
label="Email"
placeholder="vas@email.sk"
error={errors.email?.message}
{...register('email')}
/>
<Input
id="password"
type="password"
label="Heslo"
placeholder="••••••••"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Prihlásiť sa
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { customersApi, type CreateCustomerData } from '@/services/customers.api';
import type { Customer } from '@/types';
import { Button, Input, Textarea, ModalFooter } from '@/components/ui';
import toast from 'react-hot-toast';
const customerSchema = z.object({
name: z.string().min(1, 'Názov je povinný'),
address: z.string().optional(),
email: z.string().email('Neplatný email').optional().or(z.literal('')),
phone: z.string().optional(),
ico: z.string().optional(),
dic: z.string().optional(),
icdph: z.string().optional(),
contactPerson: z.string().optional(),
contactEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
contactPhone: z.string().optional(),
notes: z.string().optional(),
active: z.boolean().optional().default(true),
});
type CustomerFormData = z.input<typeof customerSchema>;
interface CustomerFormProps {
customer: Customer | null;
onClose: () => void;
}
export function CustomerForm({ customer, onClose }: CustomerFormProps) {
const queryClient = useQueryClient();
const isEditing = !!customer;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CustomerFormData>({
resolver: zodResolver(customerSchema),
defaultValues: customer
? {
name: customer.name,
address: customer.address || '',
email: customer.email || '',
phone: customer.phone || '',
ico: customer.ico || '',
dic: customer.dic || '',
icdph: customer.icdph || '',
contactPerson: customer.contactPerson || '',
contactEmail: customer.contactEmail || '',
contactPhone: customer.contactPhone || '',
notes: customer.notes || '',
active: customer.active,
}
: { active: true },
});
const createMutation = useMutation({
mutationFn: (data: CreateCustomerData) => customersApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
toast.success('Zákazník bol vytvorený');
onClose();
},
onError: () => {
toast.error('Chyba pri vytváraní zákazníka');
},
});
const updateMutation = useMutation({
mutationFn: (data: CreateCustomerData) => customersApi.update(customer!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
toast.success('Zákazník bol aktualizovaný');
onClose();
},
onError: () => {
toast.error('Chyba pri aktualizácii zákazníka');
},
});
const onSubmit = (data: CustomerFormData) => {
const cleanData = {
...data,
email: data.email || undefined,
contactEmail: data.contactEmail || undefined,
};
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Input
id="name"
label="Názov *"
error={errors.name?.message}
{...register('name')}
/>
<Input
id="email"
type="email"
label="Email"
error={errors.email?.message}
{...register('email')}
/>
<Input
id="phone"
label="Telefón"
{...register('phone')}
/>
<Input
id="address"
label="Adresa"
{...register('address')}
/>
<Input
id="ico"
label="IČO"
{...register('ico')}
/>
<Input
id="dic"
label="DIČ"
{...register('dic')}
/>
<Input
id="icdph"
label="IČ DPH"
{...register('icdph')}
/>
</div>
<hr className="my-4" />
<h3 className="font-medium">Kontaktná osoba</h3>
<div className="grid gap-4 md:grid-cols-3">
<Input
id="contactPerson"
label="Meno"
{...register('contactPerson')}
/>
<Input
id="contactEmail"
type="email"
label="Email"
error={errors.contactEmail?.message}
{...register('contactEmail')}
/>
<Input
id="contactPhone"
label="Telefón"
{...register('contactPhone')}
/>
</div>
<Textarea
id="notes"
label="Poznámky"
rows={3}
{...register('notes')}
/>
<label className="flex items-center gap-2">
<input type="checkbox" {...register('active')} className="rounded" />
<span className="text-sm">Aktívny</span>
</label>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,164 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { customersApi } from '@/services/customers.api';
import type { Customer } from '@/types';
import {
Button,
Input,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import { CustomerForm } from './CustomerForm';
import toast from 'react-hot-toast';
export function CustomersList() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Customer | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['customers', search],
queryFn: () => customersApi.getAll({ search, limit: 100 }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => customersApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] });
toast.success('Zákazník bol vymazaný');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní zákazníka');
},
});
const handleEdit = (customer: Customer) => {
setEditingCustomer(customer);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingCustomer(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Zákazníci</h1>
<Button onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nový zákazník
</Button>
</div>
<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ť zákazníkov..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Email</TableHead>
<TableHead>Telefón</TableHead>
<TableHead>IČO</TableHead>
<TableHead>Stav</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((customer) => (
<TableRow key={customer.id}>
<TableCell className="font-medium">{customer.name}</TableCell>
<TableCell>{customer.email || '-'}</TableCell>
<TableCell>{customer.phone || '-'}</TableCell>
<TableCell>{customer.ico || '-'}</TableCell>
<TableCell>
<Badge variant={customer.active ? 'default' : 'secondary'}>
{customer.active ? 'Aktívny' : 'Neaktívny'}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(customer)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(customer)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Žiadni zákazníci
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingCustomer ? 'Upraviť zákazníka' : 'Nový zákazník'}
size="lg"
>
<CustomerForm customer={editingCustomer} onClose={handleCloseForm} />
</Modal>
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
>
<p>Naozaj chcete vymazať zákazníka "{deleteConfirm?.name}"?</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
>
Vymazať
</Button>
</ModalFooter>
</Modal>
</div>
);
}

View File

@@ -0,0 +1 @@
export { CustomersList } from './CustomersList';

View File

@@ -0,0 +1,229 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { equipmentApi, type CreateEquipmentData } from '@/services/equipment.api';
import { customersApi } from '@/services/customers.api';
import { settingsApi } from '@/services/settings.api';
import type { Equipment } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
import toast from 'react-hot-toast';
const equipmentSchema = z.object({
name: z.string().min(1, 'Názov je povinný'),
typeId: z.string().min(1, 'Typ je povinný'),
brand: z.string().optional(),
model: z.string().optional(),
customerId: z.string().optional(),
address: z.string().min(1, 'Adresa je povinná'),
location: z.string().optional(),
partNumber: z.string().optional(),
serialNumber: z.string().optional(),
installDate: z.string().optional(),
warrantyEnd: z.string().optional(),
warrantyStatus: z.string().optional(),
description: z.string().optional(),
notes: z.string().optional(),
active: z.boolean().optional().default(true),
});
type EquipmentFormData = z.input<typeof equipmentSchema>;
interface EquipmentFormProps {
equipment: Equipment | null;
onClose: () => void;
}
export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
const queryClient = useQueryClient();
const isEditing = !!equipment;
const { data: customersData } = useQuery({
queryKey: ['customers-select'],
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
});
const { data: typesData } = useQuery({
queryKey: ['equipment-types'],
queryFn: () => settingsApi.getEquipmentTypes(),
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<EquipmentFormData>({
resolver: zodResolver(equipmentSchema),
defaultValues: equipment
? {
name: equipment.name,
typeId: equipment.typeId,
brand: equipment.brand || '',
model: equipment.model || '',
customerId: equipment.customerId || '',
address: equipment.address,
location: equipment.location || '',
partNumber: equipment.partNumber || '',
serialNumber: equipment.serialNumber || '',
installDate: equipment.installDate?.split('T')[0] || '',
warrantyEnd: equipment.warrantyEnd?.split('T')[0] || '',
warrantyStatus: equipment.warrantyStatus || '',
description: equipment.description || '',
notes: equipment.notes || '',
active: equipment.active,
}
: { active: true },
});
const createMutation = useMutation({
mutationFn: (data: CreateEquipmentData) => equipmentApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
toast.success('Zariadenie bolo vytvorené');
onClose();
},
onError: () => {
toast.error('Chyba pri vytváraní zariadenia');
},
});
const updateMutation = useMutation({
mutationFn: (data: CreateEquipmentData) => equipmentApi.update(equipment!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
toast.success('Zariadenie bolo aktualizované');
onClose();
},
onError: () => {
toast.error('Chyba pri aktualizácii zariadenia');
},
});
const onSubmit = (data: EquipmentFormData) => {
const cleanData = {
...data,
customerId: data.customerId || undefined,
installDate: data.installDate || undefined,
warrantyEnd: data.warrantyEnd || undefined,
};
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
const typeOptions = typesData?.data.map((t) => ({ value: t.id, label: t.name })) || [];
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Input
id="name"
label="Názov *"
error={errors.name?.message}
{...register('name')}
/>
<Select
id="typeId"
label="Typ *"
options={typeOptions}
error={errors.typeId?.message}
{...register('typeId')}
/>
<Input
id="brand"
label="Značka"
{...register('brand')}
/>
<Input
id="model"
label="Model"
{...register('model')}
/>
<Select
id="customerId"
label="Zákazník"
options={[{ value: '', label: '-- Bez zákazníka --' }, ...customerOptions]}
{...register('customerId')}
/>
<Input
id="partNumber"
label="Part Number"
{...register('partNumber')}
/>
<Input
id="serialNumber"
label="Sériové číslo"
{...register('serialNumber')}
/>
</div>
<Input
id="address"
label="Adresa *"
error={errors.address?.message}
{...register('address')}
/>
<Input
id="location"
label="Umiestnenie"
placeholder="napr. 2. poschodie, serverovňa"
{...register('location')}
/>
<div className="grid gap-4 md:grid-cols-3">
<Input
id="installDate"
type="date"
label="Dátum inštalácie"
{...register('installDate')}
/>
<Input
id="warrantyEnd"
type="date"
label="Záruka do"
{...register('warrantyEnd')}
/>
<Input
id="warrantyStatus"
label="Stav záruky"
{...register('warrantyStatus')}
/>
</div>
<Textarea
id="description"
label="Popis"
rows={2}
{...register('description')}
/>
<Textarea
id="notes"
label="Poznámky"
rows={2}
{...register('notes')}
/>
<label className="flex items-center gap-2">
<input type="checkbox" {...register('active')} className="rounded" />
<span className="text-sm">Aktívne</span>
</label>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,173 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { equipmentApi } from '@/services/equipment.api';
import type { Equipment } from '@/types';
import {
Button,
Input,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import { EquipmentForm } from './EquipmentForm';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export function EquipmentList() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingEquipment, setEditingEquipment] = useState<Equipment | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Equipment | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['equipment', search],
queryFn: () => equipmentApi.getAll({ search, limit: 100 }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => equipmentApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['equipment'] });
toast.success('Zariadenie bolo vymazané');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní zariadenia');
},
});
const handleEdit = (equipment: Equipment) => {
setEditingEquipment(equipment);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingEquipment(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Zariadenia</h1>
<Button onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nové zariadenie
</Button>
</div>
<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ť zariadenia..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Typ</TableHead>
<TableHead>Zákazník</TableHead>
<TableHead>Adresa</TableHead>
<TableHead>Sériové číslo</TableHead>
<TableHead>Záruka do</TableHead>
<TableHead>Stav</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((equipment) => (
<TableRow key={equipment.id}>
<TableCell className="font-medium">{equipment.name}</TableCell>
<TableCell>
<Badge color={equipment.type.color}>{equipment.type.name}</Badge>
</TableCell>
<TableCell>{equipment.customer?.name || '-'}</TableCell>
<TableCell className="max-w-[200px] truncate">{equipment.address}</TableCell>
<TableCell>{equipment.serialNumber || '-'}</TableCell>
<TableCell>
{equipment.warrantyEnd ? formatDate(equipment.warrantyEnd) : '-'}
</TableCell>
<TableCell>
<Badge variant={equipment.active ? 'default' : 'secondary'}>
{equipment.active ? 'Aktívne' : 'Neaktívne'}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(equipment)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(equipment)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
Žiadne zariadenia
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingEquipment ? 'Upraviť zariadenie' : 'Nové zariadenie'}
size="lg"
>
<EquipmentForm equipment={editingEquipment} onClose={handleCloseForm} />
</Modal>
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
>
<p>Naozaj chcete vymazať zariadenie "{deleteConfirm?.name}"?</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
>
Vymazať
</Button>
</ModalFooter>
</Modal>
</div>
);
}

View File

@@ -0,0 +1 @@
export { EquipmentList } from './EquipmentList';

View File

@@ -0,0 +1,191 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { projectsApi, type CreateProjectData } from '@/services/projects.api';
import { customersApi } from '@/services/customers.api';
import { settingsApi } from '@/services/settings.api';
import type { Project } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
import { useAuthStore } from '@/store/authStore';
import toast from 'react-hot-toast';
const projectSchema = z.object({
name: z.string().min(1, 'Názov je povinný'),
description: z.string().optional(),
customerId: z.string().optional(),
ownerId: z.string().optional(), // Backend nastaví aktuálneho používateľa ak prázdne
statusId: z.string().optional(), // Backend nastaví predvolený ak prázdne
softDeadline: z.string().optional(),
hardDeadline: z.string().optional(),
});
type ProjectFormData = z.infer<typeof projectSchema>;
interface ProjectFormProps {
project: Project | null;
onClose: () => void;
}
export function ProjectForm({ project, onClose }: ProjectFormProps) {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const isEditing = !!project;
const { data: customersData } = useQuery({
queryKey: ['customers-select'],
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
});
const { data: statusesData } = useQuery({
queryKey: ['task-statuses'],
queryFn: () => settingsApi.getTaskStatuses(),
});
const { data: usersData } = useQuery({
queryKey: ['users-select'],
queryFn: () => settingsApi.getUsers(),
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<ProjectFormData>({
resolver: zodResolver(projectSchema),
defaultValues: project
? {
name: project.name,
description: project.description || '',
customerId: project.customerId || '',
ownerId: project.ownerId,
statusId: project.statusId,
softDeadline: project.softDeadline?.split('T')[0] || '',
hardDeadline: project.hardDeadline?.split('T')[0] || '',
}
: {
ownerId: user?.id || '',
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
},
});
const createMutation = useMutation({
mutationFn: (data: CreateProjectData) => projectsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Projekt bol vytvorený');
onClose();
},
onError: (error: unknown) => {
console.error('Create project error:', error);
const axiosError = error as { response?: { data?: { message?: string } } };
const message = axiosError.response?.data?.message || 'Chyba pri vytváraní projektu';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: CreateProjectData) => projectsApi.update(project!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Projekt bol aktualizovaný');
onClose();
},
onError: (error: unknown) => {
console.error('Update project error:', error);
const axiosError = error as { response?: { data?: { message?: string } } };
const message = axiosError.response?.data?.message || 'Chyba pri aktualizácii projektu';
toast.error(message);
},
});
const onSubmit = (data: ProjectFormData) => {
const cleanData = {
...data,
customerId: data.customerId || undefined,
ownerId: data.ownerId || undefined,
statusId: data.statusId || undefined,
softDeadline: data.softDeadline || undefined,
hardDeadline: data.hardDeadline || undefined,
};
console.log('Submitting project data:', cleanData);
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
const userOptions = usersData?.data.map((u) => ({ value: u.id, label: u.name })) || [];
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
id="name"
label="Názov *"
error={errors.name?.message}
{...register('name')}
/>
<Textarea
id="description"
label="Popis"
rows={3}
{...register('description')}
/>
<div className="grid gap-4 md:grid-cols-2">
<Select
id="customerId"
label="Zákazník"
options={[{ value: '', label: '-- Bez zákazníka --' }, ...customerOptions]}
{...register('customerId')}
/>
<Select
id="ownerId"
label="Vlastník *"
options={userOptions}
error={errors.ownerId?.message}
{...register('ownerId')}
/>
<Select
id="statusId"
label="Stav *"
options={statusOptions}
error={errors.statusId?.message}
{...register('statusId')}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Input
id="softDeadline"
type="date"
label="Mäkký termín"
{...register('softDeadline')}
/>
<Input
id="hardDeadline"
type="date"
label="Tvrdý termín"
{...register('hardDeadline')}
/>
</div>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,165 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { projectsApi } from '@/services/projects.api';
import type { Project } from '@/types';
import {
Button,
Input,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import { ProjectForm } from './ProjectForm';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export function ProjectsList() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Project | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['projects', search],
queryFn: () => projectsApi.getAll({ search, limit: 100 }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => projectsApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Projekt bol vymazaný');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní projektu');
},
});
const handleEdit = (project: Project) => {
setEditingProject(project);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingProject(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Projekty</h1>
<Button onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nový projekt
</Button>
</div>
<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ť projekty..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Zákazník</TableHead>
<TableHead>Vlastník</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Termín</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">{project.name}</TableCell>
<TableCell>{project.customer?.name || '-'}</TableCell>
<TableCell>{project.owner.name}</TableCell>
<TableCell>
<Badge color={project.status.color}>{project.status.name}</Badge>
</TableCell>
<TableCell>
{project.hardDeadline ? formatDate(project.hardDeadline) : '-'}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(project)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(project)}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Žiadne projekty
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingProject ? 'Upraviť projekt' : 'Nový projekt'}
size="lg"
>
<ProjectForm project={editingProject} onClose={handleCloseForm} />
</Modal>
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
>
<p>Naozaj chcete vymazať projekt "{deleteConfirm?.name}"?</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
>
Vymazať
</Button>
</ModalFooter>
</Modal>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ProjectsList } from './ProjectsList';

View File

@@ -0,0 +1,309 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { rmaApi, type CreateRMAData, type UpdateRMAData } from '@/services/rma.api';
import { customersApi } from '@/services/customers.api';
import { settingsApi } from '@/services/settings.api';
import type { RMA } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
import toast from 'react-hot-toast';
const rmaSchema = z.object({
customerId: z.string().optional(),
customerName: z.string().optional(),
customerAddress: z.string().optional(),
customerEmail: z.string().email('Neplatný email').optional().or(z.literal('')),
customerPhone: z.string().optional(),
customerICO: z.string().optional(),
submittedBy: z.string().min(1, 'Podávajúci je povinný'),
productName: z.string().min(1, 'Názov produktu je povinný'),
invoiceNumber: z.string().optional(),
purchaseDate: z.string().optional(),
productNumber: z.string().optional(),
serialNumber: z.string().optional(),
accessories: z.string().optional(),
issueDescription: z.string().min(1, 'Popis problému je povinný'),
statusId: z.string().min(1, 'Stav je povinný'),
proposedSolutionId: z.string().optional(),
requiresApproval: z.boolean().optional().default(false),
receivedDate: z.string().optional(),
receivedLocation: z.string().optional(),
internalNotes: z.string().optional(),
resolutionNotes: z.string().optional(),
assignedToId: z.string().optional(),
});
type RMAFormData = z.input<typeof rmaSchema>;
interface RMAFormProps {
rma: RMA | null;
onClose: () => void;
}
export function RMAForm({ rma, onClose }: RMAFormProps) {
const queryClient = useQueryClient();
const isEditing = !!rma;
const { data: customersData } = useQuery({
queryKey: ['customers-select'],
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
});
const { data: statusesData } = useQuery({
queryKey: ['rma-statuses'],
queryFn: () => settingsApi.getRMAStatuses(),
});
const { data: solutionsData } = useQuery({
queryKey: ['rma-solutions'],
queryFn: () => settingsApi.getRMASolutions(),
});
const { data: usersData } = useQuery({
queryKey: ['users-select'],
queryFn: () => settingsApi.getUsers(),
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RMAFormData>({
resolver: zodResolver(rmaSchema),
defaultValues: rma
? {
customerId: rma.customerId || '',
customerName: rma.customerName || '',
customerAddress: rma.customerAddress || '',
customerEmail: rma.customerEmail || '',
customerPhone: rma.customerPhone || '',
customerICO: rma.customerICO || '',
submittedBy: rma.submittedBy,
productName: rma.productName,
invoiceNumber: rma.invoiceNumber || '',
purchaseDate: rma.purchaseDate?.split('T')[0] || '',
productNumber: rma.productNumber || '',
serialNumber: rma.serialNumber || '',
accessories: rma.accessories || '',
issueDescription: rma.issueDescription,
statusId: rma.statusId,
proposedSolutionId: rma.proposedSolutionId || '',
requiresApproval: rma.requiresApproval,
receivedDate: rma.receivedDate?.split('T')[0] || '',
receivedLocation: rma.receivedLocation || '',
internalNotes: rma.internalNotes || '',
resolutionNotes: rma.resolutionNotes || '',
assignedToId: rma.assignedToId || '',
}
: {
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
requiresApproval: false,
},
});
const createMutation = useMutation({
mutationFn: (data: CreateRMAData) => rmaApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rma'] });
toast.success('RMA bolo vytvorené');
onClose();
},
onError: () => {
toast.error('Chyba pri vytváraní RMA');
},
});
const updateMutation = useMutation({
mutationFn: (data: UpdateRMAData) => rmaApi.update(rma!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rma'] });
toast.success('RMA bolo aktualizované');
onClose();
},
onError: () => {
toast.error('Chyba pri aktualizácii RMA');
},
});
const onSubmit = (data: RMAFormData) => {
const cleanData = {
...data,
customerId: data.customerId || undefined,
customerEmail: data.customerEmail || undefined,
purchaseDate: data.purchaseDate || undefined,
receivedDate: data.receivedDate || undefined,
proposedSolutionId: data.proposedSolutionId || undefined,
assignedToId: data.assignedToId || undefined,
};
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData as CreateRMAData);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const customerOptions = customersData?.data.map((c) => ({ value: c.id, label: c.name })) || [];
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
const solutionOptions = solutionsData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
const userOptions = usersData?.data.map((u) => ({ value: u.id, label: u.name })) || [];
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-h-[70vh] overflow-y-auto pr-2">
<h3 className="font-medium">Zákazník</h3>
<div className="grid gap-4 md:grid-cols-2">
<Select
id="customerId"
label="Existujúci zákazník"
options={[{ value: '', label: '-- Alebo zadajte manuálne --' }, ...customerOptions]}
{...register('customerId')}
/>
<Input
id="customerName"
label="Názov zákazníka"
{...register('customerName')}
/>
<Input
id="customerEmail"
type="email"
label="Email"
error={errors.customerEmail?.message}
{...register('customerEmail')}
/>
<Input
id="customerPhone"
label="Telefón"
{...register('customerPhone')}
/>
<Input
id="customerAddress"
label="Adresa"
{...register('customerAddress')}
/>
<Input
id="customerICO"
label="IČO"
{...register('customerICO')}
/>
</div>
<hr className="my-4" />
<h3 className="font-medium">Produkt</h3>
<div className="grid gap-4 md:grid-cols-2">
<Input
id="submittedBy"
label="Podávajúci *"
error={errors.submittedBy?.message}
{...register('submittedBy')}
/>
<Input
id="productName"
label="Názov produktu *"
error={errors.productName?.message}
{...register('productName')}
/>
<Input
id="invoiceNumber"
label="Číslo faktúry"
{...register('invoiceNumber')}
/>
<Input
id="purchaseDate"
type="date"
label="Dátum nákupu"
{...register('purchaseDate')}
/>
<Input
id="productNumber"
label="Číslo produktu"
{...register('productNumber')}
/>
<Input
id="serialNumber"
label="Sériové číslo"
{...register('serialNumber')}
/>
</div>
<Input
id="accessories"
label="Príslušenstvo"
{...register('accessories')}
/>
<Textarea
id="issueDescription"
label="Popis problému *"
rows={3}
error={errors.issueDescription?.message}
{...register('issueDescription')}
/>
<hr className="my-4" />
<h3 className="font-medium">Stav a spracovanie</h3>
<div className="grid gap-4 md:grid-cols-2">
<Select
id="statusId"
label="Stav *"
options={statusOptions}
error={errors.statusId?.message}
{...register('statusId')}
/>
<Select
id="proposedSolutionId"
label="Navrhované riešenie"
options={[{ value: '', label: '-- Žiadne --' }, ...solutionOptions]}
{...register('proposedSolutionId')}
/>
<Select
id="assignedToId"
label="Priradené"
options={[{ value: '', label: '-- Nepriradené --' }, ...userOptions]}
{...register('assignedToId')}
/>
<Input
id="receivedDate"
type="date"
label="Dátum prijatia"
{...register('receivedDate')}
/>
<Input
id="receivedLocation"
label="Miesto prijatia"
{...register('receivedLocation')}
/>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" {...register('requiresApproval')} className="rounded" />
<span className="text-sm">Vyžaduje schválenie</span>
</label>
<Textarea
id="internalNotes"
label="Interné poznámky"
rows={2}
{...register('internalNotes')}
/>
<Textarea
id="resolutionNotes"
label="Poznámky k riešeniu"
rows={2}
{...register('resolutionNotes')}
/>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,167 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
import { rmaApi } from '@/services/rma.api';
import type { RMA } from '@/types';
import {
Button,
Input,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import { RMAForm } from './RMAForm';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export function RMAList() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingRMA, setEditingRMA] = useState<RMA | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<RMA | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['rma', search],
queryFn: () => rmaApi.getAll({ search, limit: 100 }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => rmaApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['rma'] });
toast.success('RMA bolo vymazané');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní RMA');
},
});
const handleEdit = (rma: RMA) => {
setEditingRMA(rma);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingRMA(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">RMA</h1>
<Button onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nové RMA
</Button>
</div>
<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ť RMA..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Číslo RMA</TableHead>
<TableHead>Zákazník</TableHead>
<TableHead>Produkt</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Prijaté</TableHead>
<TableHead>Priradené</TableHead>
<TableHead className="text-right">Akcie</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.data.map((rma) => (
<TableRow key={rma.id}>
<TableCell className="font-medium">{rma.rmaNumber}</TableCell>
<TableCell>{rma.customer?.name || rma.customerName || '-'}</TableCell>
<TableCell>{rma.productName}</TableCell>
<TableCell>
<Badge color={rma.status.color}>{rma.status.name}</Badge>
</TableCell>
<TableCell>
{rma.receivedDate ? formatDate(rma.receivedDate) : '-'}
</TableCell>
<TableCell>{rma.assignedTo?.name || '-'}</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" onClick={() => handleEdit(rma)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(rma)}>
<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 RMA
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingRMA ? 'Upraviť RMA' : 'Nové RMA'}
size="xl"
>
<RMAForm rma={editingRMA} onClose={handleCloseForm} />
</Modal>
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
>
<p>Naozaj chcete vymazať RMA "{deleteConfirm?.rmaNumber}"?</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
>
Vymazať
</Button>
</ModalFooter>
</Modal>
</div>
);
}

View File

@@ -0,0 +1 @@
export { RMAList } from './RMAList';

View File

@@ -0,0 +1,332 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2 } from 'lucide-react';
import { settingsApi } from '@/services/settings.api';
import {
Button,
Input,
Card,
CardHeader,
CardTitle,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import toast from 'react-hot-toast';
type ConfigTab = 'taskStatuses' | 'priorities' | 'equipmentTypes' | 'revisionTypes' | 'rmaStatuses' | 'rmaSolutions' | 'userRoles';
// Spoločný interface pre konfiguračné entity
interface ConfigItem {
id: string;
code: string;
name: string;
color?: string | null;
order?: number;
}
const tabs: { key: ConfigTab; label: string }[] = [
{ key: 'taskStatuses', label: 'Stavy úloh' },
{ key: 'priorities', label: 'Priority' },
{ key: 'equipmentTypes', label: 'Typy zariadení' },
{ key: 'revisionTypes', label: 'Typy revízií' },
{ key: 'rmaStatuses', label: 'RMA stavy' },
{ key: 'rmaSolutions', label: 'RMA riešenia' },
{ key: 'userRoles', label: 'Užívateľské role' },
];
export function SettingsDashboard() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<ConfigTab>('taskStatuses');
const [editItem, setEditItem] = useState<ConfigItem | null>(null);
const [deleteItem, setDeleteItem] = useState<{ id: string; name: string } | null>(null);
const { data: taskStatuses, isLoading: loadingStatuses } = useQuery({
queryKey: ['task-statuses'],
queryFn: () => settingsApi.getTaskStatuses(),
enabled: activeTab === 'taskStatuses',
});
const { data: priorities, isLoading: loadingPriorities } = useQuery({
queryKey: ['priorities'],
queryFn: () => settingsApi.getPriorities(),
enabled: activeTab === 'priorities',
});
const { data: equipmentTypes, isLoading: loadingEquipmentTypes } = useQuery({
queryKey: ['equipment-types'],
queryFn: () => settingsApi.getEquipmentTypes(),
enabled: activeTab === 'equipmentTypes',
});
const { data: revisionTypes, isLoading: loadingRevisionTypes } = useQuery({
queryKey: ['revision-types'],
queryFn: () => settingsApi.getRevisionTypes(),
enabled: activeTab === 'revisionTypes',
});
const { data: rmaStatuses, isLoading: loadingRmaStatuses } = useQuery({
queryKey: ['rma-statuses'],
queryFn: () => settingsApi.getRMAStatuses(),
enabled: activeTab === 'rmaStatuses',
});
const { data: rmaSolutions, isLoading: loadingRmaSolutions } = useQuery({
queryKey: ['rma-solutions'],
queryFn: () => settingsApi.getRMASolutions(),
enabled: activeTab === 'rmaSolutions',
});
const { data: userRoles, isLoading: loadingUserRoles } = useQuery({
queryKey: ['user-roles'],
queryFn: () => settingsApi.getUserRoles(),
enabled: activeTab === 'userRoles',
});
const deleteMutation = useMutation({
mutationFn: async ({ tab, id }: { tab: ConfigTab; id: string }) => {
switch (tab) {
case 'taskStatuses': return settingsApi.deleteTaskStatus(id);
case 'priorities': return settingsApi.deletePriority(id);
case 'equipmentTypes': return settingsApi.deleteEquipmentType(id);
case 'revisionTypes': return settingsApi.deleteRevisionType(id);
case 'rmaStatuses': return settingsApi.deleteRMAStatus(id);
case 'rmaSolutions': return settingsApi.deleteRMASolution(id);
case 'userRoles': return settingsApi.deleteUserRole(id);
}
},
onSuccess: () => {
queryClient.invalidateQueries();
toast.success('Položka bola vymazaná');
setDeleteItem(null);
},
onError: () => {
toast.error('Chyba pri mazaní položky');
},
});
const isLoading = loadingStatuses || loadingPriorities || loadingEquipmentTypes ||
loadingRevisionTypes || loadingRmaStatuses || loadingRmaSolutions || loadingUserRoles;
const getCurrentData = (): ConfigItem[] => {
switch (activeTab) {
case 'taskStatuses': return (taskStatuses?.data || []) as ConfigItem[];
case 'priorities': return (priorities?.data || []) as ConfigItem[];
case 'equipmentTypes': return (equipmentTypes?.data || []) as ConfigItem[];
case 'revisionTypes': return (revisionTypes?.data || []) as ConfigItem[];
case 'rmaStatuses': return (rmaStatuses?.data || []) as ConfigItem[];
case 'rmaSolutions': return (rmaSolutions?.data || []) as ConfigItem[];
case 'userRoles': return (userRoles?.data || []) as ConfigItem[];
}
};
const data: ConfigItem[] = getCurrentData();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Nastavenia</h1>
<div className="flex gap-2 flex-wrap">
{tabs.map((tab) => (
<Button
key={tab.key}
variant={activeTab === tab.key ? 'primary' : 'outline'}
size="sm"
onClick={() => setActiveTab(tab.key)}
>
{tab.label}
</Button>
))}
</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 && (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
Žiadne položky
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={!!editItem}
onClose={() => setEditItem(null)}
title={editItem?.id ? 'Upraviť položku' : 'Nová položka'}
>
<ConfigItemForm
item={editItem}
tab={activeTab}
onClose={() => setEditItem(null)}
/>
</Modal>
<Modal
isOpen={!!deleteItem}
onClose={() => setDeleteItem(null)}
title="Potvrdiť vymazanie"
>
<p>Naozaj chcete vymazať položku "{deleteItem?.name}"?</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteItem(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteItem && deleteMutation.mutate({ tab: activeTab, id: deleteItem.id })}
isLoading={deleteMutation.isPending}
>
Vymazať
</Button>
</ModalFooter>
</Modal>
</div>
);
}
interface ConfigItemFormProps {
item: ConfigItem | null;
tab: ConfigTab;
onClose: () => void;
}
function ConfigItemForm({ item, tab, onClose }: ConfigItemFormProps) {
const queryClient = useQueryClient();
const isEditing = item?.id ? true : false;
const [formData, setFormData] = useState({
code: item?.code || '',
name: item?.name || '',
color: item?.color || '',
order: item?.order || 0,
});
const mutation = useMutation({
mutationFn: async () => {
const data = { ...formData, order: Number(formData.order) };
if (isEditing && item?.id) {
switch (tab) {
case 'taskStatuses': return settingsApi.updateTaskStatus(item.id, data);
case 'priorities': return settingsApi.updatePriority(item.id, data);
case 'equipmentTypes': return settingsApi.updateEquipmentType(item.id, data);
case 'revisionTypes': return settingsApi.updateRevisionType(item.id, { ...data, intervalDays: 365, reminderDays: 30 });
case 'rmaStatuses': return settingsApi.updateRMAStatus(item.id, data);
case 'rmaSolutions': return settingsApi.updateRMASolution(item.id, data);
case 'userRoles': return settingsApi.updateUserRole(item.id, { ...data, permissions: {}, level: 0 });
}
} else {
switch (tab) {
case 'taskStatuses': return settingsApi.createTaskStatus({ ...data, isInitial: false, isFinal: false });
case 'priorities': return settingsApi.createPriority({ ...data, level: 0 });
case 'equipmentTypes': return settingsApi.createEquipmentType(data);
case 'revisionTypes': return settingsApi.createRevisionType({ ...data, intervalDays: 365, reminderDays: 30 });
case 'rmaStatuses': return settingsApi.createRMAStatus({ ...data, isInitial: false, isFinal: false });
case 'rmaSolutions': return settingsApi.createRMASolution(data);
case 'userRoles': return settingsApi.createUserRole({ ...data, permissions: {}, level: 0 });
}
}
},
onSuccess: () => {
queryClient.invalidateQueries();
toast.success(isEditing ? 'Položka bola aktualizovaná' : 'Položka bola vytvorená');
onClose();
},
onError: () => {
toast.error('Chyba pri ukladaní položky');
},
});
return (
<form onSubmit={(e) => { e.preventDefault(); mutation.mutate(); }} className="space-y-4">
<Input
label="Kód *"
value={formData.code}
onChange={(e) => setFormData({ ...formData, code: e.target.value })}
required
/>
<Input
label="Názov *"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
/>
<Input
label="Farba"
type="color"
value={formData.color || '#3b82f6'}
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
/>
<Input
label="Poradie"
type="number"
value={formData.order}
onChange={(e) => setFormData({ ...formData, order: Number(e.target.value) })}
/>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={mutation.isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1 @@
export { SettingsDashboard } from './SettingsDashboard';

View File

@@ -0,0 +1,356 @@
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 { tasksApi } from '@/services/tasks.api';
import { settingsApi } from '@/services/settings.api';
import { useAuthStore } from '@/store/authStore';
import type { Task } from '@/types';
import { Button, Badge, Textarea, Select } from '@/components/ui';
import { TaskForm } from './TaskForm';
import { formatDate, formatDateTime } from '@/lib/utils';
import toast from 'react-hot-toast';
interface Comment {
id: string;
content: string;
userId: string;
user?: { id: string; name: string };
createdAt: string;
}
interface TaskDetailProps {
taskId: string;
onClose: () => void;
onEdit?: (task: Task) => void; // Optional - ak nie je, použije sa interný edit mód
}
export function TaskDetail({ taskId, onClose }: TaskDetailProps) {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const [newComment, setNewComment] = useState('');
const [isEditing, setIsEditing] = useState(false);
const { data: taskData, isLoading } = useQuery({
queryKey: ['task', taskId],
queryFn: () => tasksApi.getById(taskId),
});
const { data: commentsData, isLoading: commentsLoading } = useQuery({
queryKey: ['task-comments', taskId],
queryFn: () => tasksApi.getComments(taskId),
});
const { data: statusesData } = useQuery({
queryKey: ['task-statuses'],
queryFn: () => settingsApi.getTaskStatuses(),
});
const { data: prioritiesData } = useQuery({
queryKey: ['priorities'],
queryFn: () => settingsApi.getPriorities(),
});
const addCommentMutation = useMutation({
mutationFn: (content: string) => tasksApi.addComment(taskId, content),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['task-comments', taskId] });
setNewComment('');
toast.success('Komentár bol pridaný');
},
onError: (error: unknown) => {
const axiosError = error as { response?: { data?: { message?: string } } };
toast.error(axiosError.response?.data?.message || 'Chyba pri pridávaní komentára');
},
});
const updateTaskMutation = useMutation({
mutationFn: (data: { statusId?: string; priorityId?: string }) => tasksApi.update(taskId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
toast.success('Úloha bola aktualizovaná');
},
onError: () => {
toast.error('Chyba pri aktualizácii úlohy');
},
});
const task = taskData?.data;
const comments = (commentsData?.data || []) as Comment[];
const statuses = statusesData?.data || [];
const priorities = prioritiesData?.data || [];
// Kontrola oprávnení - môže komentovať/meniť autor alebo priradený
const isCreator = user && task && (
task.createdById === user.id || task.createdBy?.id === user.id
);
const isAssignee = user && task && task.assignees?.some(a =>
a.userId === user.id || a.user?.id === user.id
);
const canComment = isCreator || isAssignee;
const canChangeStatus = isCreator || isAssignee; // Stav môže meniť autor + priradený
const canChangePriority = isCreator; // Prioritu môže meniť len zadávateľ
const canEdit = isCreator; // Len zadávateľ môže editovať úlohu
const handleSubmitComment = () => {
if (!newComment.trim()) return;
addCommentMutation.mutate(newComment.trim());
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
handleSubmitComment();
}
};
const handleStatusChange = (statusId: string) => {
if (statusId && statusId !== task?.statusId) {
updateTaskMutation.mutate({ statusId });
}
};
const handlePriorityChange = (priorityId: string) => {
if (priorityId && priorityId !== task?.priorityId) {
updateTaskMutation.mutate({ priorityId });
}
};
const handleEditComplete = () => {
setIsEditing(false);
queryClient.invalidateQueries({ queryKey: ['task', taskId] });
queryClient.invalidateQueries({ queryKey: ['tasks'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-today'] });
};
if (isLoading) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-lg p-8">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
</div>
);
}
if (!task) {
return null;
}
const statusOptions = statuses.map(s => ({ value: s.id, label: s.name }));
const priorityOptions = priorities.map(p => ({ value: p.id, label: p.name }));
// Edit mód - zobrazí formulár
if (isEditing) {
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4" onClick={onClose}>
<div className="bg-background rounded-lg shadow-xl w-full max-w-3xl my-8" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => setIsEditing(false)}>
<ArrowLeft className="h-4 w-4" />
</Button>
<h2 className="text-xl font-semibold">Upraviť úlohu</h2>
</div>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
{/* Form */}
<div className="p-6">
<TaskForm
task={task}
onClose={handleEditComplete}
/>
</div>
</div>
</div>
);
}
// View mód - zobrazí detail
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4" onClick={onClose}>
<div className="bg-background rounded-lg shadow-xl w-full max-w-3xl my-8" onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-start justify-between p-6 border-b">
<div className="flex-1 pr-4">
<h2 className="text-xl font-semibold">{task.title}</h2>
<div className="flex items-center gap-2 mt-2">
<Badge color={task.status.color}>{task.status.name}</Badge>
<Badge color={task.priority.color}>{task.priority.name}</Badge>
{task.status.isFinal && (
<span className="flex items-center gap-1 text-xs text-green-600">
<CheckCircle2 className="h-3 w-3" />
Dokončená
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{canEdit && (
<Button variant="outline" size="sm" onClick={() => setIsEditing(true)}>
<Pencil className="h-4 w-4 mr-1" />
Upraviť
</Button>
)}
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-5 w-5" />
</Button>
</div>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Quick actions - zmena statusu a priority */}
{(canChangeStatus || canChangePriority) && (
<div className="flex flex-wrap gap-4 p-4 bg-muted/30 rounded-lg">
{canChangeStatus && (
<div className="flex-1 min-w-[200px]">
<label className="text-xs font-medium text-muted-foreground mb-1 block">Zmeniť stav</label>
<Select
id="status"
options={statusOptions}
value={task.statusId}
onChange={(e) => handleStatusChange(e.target.value)}
disabled={updateTaskMutation.isPending}
/>
</div>
)}
{canChangePriority && (
<div className="flex-1 min-w-[200px]">
<label className="text-xs font-medium text-muted-foreground mb-1 block">Zmeniť prioritu</label>
<Select
id="priority"
options={priorityOptions}
value={task.priorityId}
onChange={(e) => handlePriorityChange(e.target.value)}
disabled={updateTaskMutation.isPending}
/>
</div>
)}
</div>
)}
{/* Info grid */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<UserIcon className="h-4 w-4" />
<span>Zadal:</span>
<span className="text-foreground font-medium">{task.createdBy?.name || '-'}</span>
</div>
{task.project && (
<div className="flex items-center gap-2 text-muted-foreground">
<FolderOpen className="h-4 w-4" />
<span>Projekt:</span>
<span className="text-foreground font-medium">{task.project.name}</span>
</div>
)}
{task.deadline && (
<div className="flex items-center gap-2 text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Termín:</span>
<span className={`font-medium ${new Date(task.deadline) < new Date() ? 'text-red-500' : 'text-foreground'}`}>
{formatDate(task.deadline)}
{new Date(task.deadline) < new Date() && ' (po termíne!)'}
</span>
</div>
)}
{task.assignees && task.assignees.length > 0 && (
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
<span>Priradení:</span>
<span className="text-foreground font-medium">
{task.assignees.map(a => a.user?.name || a.userId).join(', ')}
</span>
</div>
)}
</div>
{/* Description */}
{task.description ? (
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-2">Popis</h3>
<p className="text-sm whitespace-pre-wrap bg-muted/50 rounded-md p-3">
{task.description}
</p>
</div>
) : (
<div className="text-sm text-muted-foreground italic">
Bez popisu
</div>
)}
{/* Comments */}
<div>
<h3 className="text-sm font-medium text-muted-foreground mb-3">
Komentáre ({comments.length})
</h3>
{/* Add comment - na vrchu */}
{canComment && (
<div className="space-y-2 mb-4">
<Textarea
placeholder="Napíšte komentár... (Ctrl+Enter pre odoslanie)"
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
rows={3}
/>
<div className="flex justify-end">
<Button
size="sm"
onClick={handleSubmitComment}
disabled={!newComment.trim() || addCommentMutation.isPending}
isLoading={addCommentMutation.isPending}
>
<Send className="h-4 w-4 mr-2" />
Odoslať
</Button>
</div>
</div>
)}
{/* Comment list */}
<div className="space-y-3 max-h-64 overflow-y-auto">
{commentsLoading ? (
<p className="text-sm text-muted-foreground">Načítavam...</p>
) : comments.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
Zatiaľ žiadne komentáre
</p>
) : (
comments.map((comment) => (
<div key={comment.id} className="bg-muted/50 rounded-md p-3">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium">{comment.user?.name || 'Neznámy'}</span>
<span className="text-xs text-muted-foreground">
{formatDateTime(comment.createdAt)}
</span>
</div>
<p className="text-sm whitespace-pre-wrap">{comment.content}</p>
</div>
))
)}
</div>
{!canComment && (
<p className="text-sm text-muted-foreground italic mt-4">
Komentovať môže len autor úlohy alebo priradený používateľ.
</p>
)}
</div>
</div>
{/* Footer */}
<div className="flex justify-between items-center p-4 border-t text-xs text-muted-foreground">
<span>Vytvorené: {formatDateTime(task.createdAt)}</span>
<span>Aktualizované: {formatDateTime(task.updatedAt)}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,204 @@
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { tasksApi, type CreateTaskData } from '@/services/tasks.api';
import { projectsApi } from '@/services/projects.api';
import { settingsApi } from '@/services/settings.api'; // Pre statusy a priority
import type { Task } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter, UserSelect } from '@/components/ui';
import toast from 'react-hot-toast';
const taskSchema = z.object({
title: z.string().min(1, 'Názov je povinný'),
description: z.string().optional(),
projectId: z.string().optional(),
statusId: z.string().optional(),
priorityId: z.string().optional(),
deadline: z.string().optional(),
});
type TaskFormData = z.infer<typeof taskSchema>;
interface TaskFormProps {
task: Task | null;
onClose: () => void;
}
export function TaskForm({ task, onClose }: TaskFormProps) {
const queryClient = useQueryClient();
const isEditing = !!task;
// State pre vybraných používateľov
const [selectedAssignees, setSelectedAssignees] = useState<string[]>(
task?.assignees?.map((a) => a.userId) || []
);
const { data: projectsData } = useQuery({
queryKey: ['projects-select'],
queryFn: () => projectsApi.getAll({ limit: 1000 }),
});
const { data: statusesData } = useQuery({
queryKey: ['task-statuses'],
queryFn: () => settingsApi.getTaskStatuses(),
});
const { data: prioritiesData } = useQuery({
queryKey: ['priorities'],
queryFn: () => settingsApi.getPriorities(),
});
const {
register,
handleSubmit,
formState: { errors },
} = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
defaultValues: task
? {
title: task.title,
description: task.description || '',
projectId: task.projectId || '',
statusId: task.statusId,
priorityId: task.priorityId,
deadline: task.deadline?.split('T')[0] || '',
}
: {
title: '',
description: '',
projectId: '',
statusId: '',
priorityId: '',
deadline: '',
},
});
const createMutation = useMutation({
mutationFn: (data: CreateTaskData) => tasksApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
toast.success('Úloha bola vytvorená');
onClose();
},
onError: (error: unknown) => {
console.error('Create task error:', error);
const axiosError = error as { response?: { data?: { message?: string } } };
const message = axiosError.response?.data?.message || 'Chyba pri vytváraní úlohy';
toast.error(message);
},
});
const updateMutation = useMutation({
mutationFn: (data: CreateTaskData) => tasksApi.update(task!.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
toast.success('Úloha bola aktualizovaná');
onClose();
},
onError: (error: unknown) => {
console.error('Update task error:', error);
const axiosError = error as { response?: { data?: { message?: string } } };
const message = axiosError.response?.data?.message || 'Chyba pri aktualizácii úlohy';
toast.error(message);
},
});
const onSubmit = (data: TaskFormData) => {
const cleanData = {
...data,
projectId: data.projectId || undefined,
statusId: data.statusId || undefined,
priorityId: data.priorityId || undefined,
deadline: data.deadline || undefined,
// Pre create: undefined ak prázdne (backend priradí default)
// Pre update: vždy poslať pole (aj prázdne) aby sa aktualizovali assignees
assigneeIds: isEditing ? selectedAssignees : (selectedAssignees.length > 0 ? selectedAssignees : undefined),
};
console.log('Submitting task data:', cleanData);
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
}
};
const isPending = createMutation.isPending || updateMutation.isPending;
const projectOptions = projectsData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
const statusOptions = statusesData?.data.map((s) => ({ value: s.id, label: s.name })) || [];
const priorityOptions = prioritiesData?.data.map((p) => ({ value: p.id, label: p.name })) || [];
// Pripraviť počiatočných používateľov pre editáciu (už priradení)
const initialAssignees = task?.assignees?.map((a) => ({
id: a.userId,
name: a.user?.name || '',
email: a.user?.email || '',
})) || [];
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Input
id="title"
label="Názov *"
error={errors.title?.message}
{...register('title')}
/>
<Textarea
id="description"
label="Popis"
rows={3}
{...register('description')}
/>
<div className="grid gap-4 md:grid-cols-2">
<Select
id="projectId"
label="Projekt"
options={[{ value: '', label: '-- Bez projektu --' }, ...projectOptions]}
{...register('projectId')}
/>
<Select
id="statusId"
label="Stav"
options={[{ value: '', label: '-- Predvolený --' }, ...statusOptions]}
{...register('statusId')}
/>
<Select
id="priorityId"
label="Priorita"
options={[{ value: '', label: '-- Predvolená --' }, ...priorityOptions]}
{...register('priorityId')}
/>
<Input
id="deadline"
type="date"
label="Termín"
{...register('deadline')}
/>
</div>
<UserSelect
label="Priradiť na"
selectedIds={selectedAssignees}
onChange={setSelectedAssignees}
initialUsers={initialAssignees}
placeholder="Vyhľadať používateľa..."
/>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť
</Button>
<Button type="submit" isLoading={isPending}>
{isEditing ? 'Uložiť' : 'Vytvoriť'}
</Button>
</ModalFooter>
</form>
);
}

View File

@@ -0,0 +1,196 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, Search, MessageSquare } from 'lucide-react';
import { tasksApi } from '@/services/tasks.api';
import type { Task } from '@/types';
import {
Button,
Input,
Card,
CardHeader,
CardContent,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
} from '@/components/ui';
import { TaskForm } from './TaskForm';
import { TaskDetail } from './TaskDetail';
import { formatDate } from '@/lib/utils';
import toast from 'react-hot-toast';
export function TasksList() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [isFormOpen, setIsFormOpen] = useState(false);
const [editingTask, setEditingTask] = useState<Task | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<Task | null>(null);
const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['tasks', search],
queryFn: () => tasksApi.getAll({ search, limit: 100 }),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => tasksApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] });
toast.success('Úloha bola vymazaná');
setDeleteConfirm(null);
},
onError: () => {
toast.error('Chyba pri mazaní úlohy');
},
});
const handleEdit = (task: Task) => {
setEditingTask(task);
setIsFormOpen(true);
};
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingTask(null);
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Úlohy</h1>
<Button onClick={() => setIsFormOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Nová úloha
</Button>
</div>
<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>
</CardHeader>
<CardContent>
{isLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Projekt</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.project?.name || '-'}</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">
<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ť">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => setDeleteConfirm(task)} title="Vymazať">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
Žiadne úlohy
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<Modal
isOpen={isFormOpen}
onClose={handleCloseForm}
title={editingTask ? 'Upraviť úlohu' : 'Nová úloha'}
size="lg"
>
<TaskForm task={editingTask} onClose={handleCloseForm} />
</Modal>
<Modal
isOpen={!!deleteConfirm}
onClose={() => setDeleteConfirm(null)}
title="Potvrdiť vymazanie"
>
<p>Naozaj chcete vymazať úlohu "{deleteConfirm?.title}"?</p>
<ModalFooter>
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
Zrušiť
</Button>
<Button
variant="destructive"
onClick={() => deleteConfirm && deleteMutation.mutate(deleteConfirm.id)}
isLoading={deleteMutation.isPending}
>
Vymazať
</Button>
</ModalFooter>
</Modal>
{detailTaskId && (
<TaskDetail
taskId={detailTaskId}
onClose={() => setDetailTaskId(null)}
onEdit={(task) => {
setDetailTaskId(null);
handleEdit(task);
}}
/>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { TasksList } from './TasksList';

View File

@@ -0,0 +1,88 @@
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
import type { ApiResponse, PaginatedResponse } from '@/types';
const API_URL = '/api';
export const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - add auth token
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor - handle token refresh
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
const response = await axios.post(`${API_URL}/auth/refresh`, { refreshToken });
const { accessToken, refreshToken: newRefreshToken } = response.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', newRefreshToken);
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
return api(originalRequest);
}
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
// Generic API helpers
export async function get<T>(url: string): Promise<ApiResponse<T>> {
const response = await api.get<ApiResponse<T>>(url);
return response.data;
}
export async function getPaginated<T>(url: string): Promise<PaginatedResponse<T>> {
const response = await api.get<PaginatedResponse<T>>(url);
return response.data;
}
export async function post<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
const response = await api.post<ApiResponse<T>>(url, data);
return response.data;
}
export async function put<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
const response = await api.put<ApiResponse<T>>(url, data);
return response.data;
}
export async function patch<T>(url: string, data?: unknown): Promise<ApiResponse<T>> {
const response = await api.patch<ApiResponse<T>>(url, data);
return response.data;
}
export async function del<T>(url: string): Promise<ApiResponse<T>> {
const response = await api.delete<ApiResponse<T>>(url);
return response.data;
}

View File

@@ -0,0 +1,33 @@
import { post, get } from './api';
import type { User, LoginResponse } from '@/types';
export interface LoginCredentials {
email: string;
password: string;
}
export interface RegisterData {
email: string;
password: string;
name: string;
}
export const authApi = {
login: (credentials: LoginCredentials) =>
post<LoginResponse>('/auth/login', credentials),
register: (data: RegisterData) =>
post<LoginResponse>('/auth/register', data),
logout: () =>
post<void>('/auth/logout'),
refreshToken: (refreshToken: string) =>
post<{ accessToken: string; refreshToken: string }>('/auth/refresh', { refreshToken }),
getProfile: () =>
get<User>('/auth/me'),
changePassword: (data: { currentPassword: string; newPassword: string }) =>
post<void>('/auth/change-password', data),
};

View File

@@ -0,0 +1,52 @@
import { get, getPaginated, post, put, del } from './api';
import type { Customer } from '@/types';
export interface CustomerFilters {
search?: string;
active?: boolean;
page?: number;
limit?: number;
}
export interface CreateCustomerData {
name: string;
address?: string;
email?: string;
phone?: string;
ico?: string;
dic?: string;
icdph?: string;
contactPerson?: string;
contactEmail?: string;
contactPhone?: string;
notes?: string;
active?: boolean;
}
export type UpdateCustomerData = Partial<CreateCustomerData>;
function buildQueryString(filters: CustomerFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.active !== undefined) params.append('active', String(filters.active));
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export const customersApi = {
getAll: (filters: CustomerFilters = {}) =>
getPaginated<Customer>(`/customers?${buildQueryString(filters)}`),
getById: (id: string) =>
get<Customer>(`/customers/${id}`),
create: (data: CreateCustomerData) =>
post<Customer>('/customers', data),
update: (id: string, data: UpdateCustomerData) =>
put<Customer>(`/customers/${id}`, data),
delete: (id: string) =>
del<void>(`/customers/${id}`),
};

View File

@@ -0,0 +1,75 @@
import { get, getPaginated, post, put, del } from './api';
import type { Equipment, Revision } from '@/types';
export interface EquipmentFilters {
search?: string;
customerId?: string;
typeId?: string;
active?: boolean;
page?: number;
limit?: number;
}
export interface CreateEquipmentData {
name: string;
typeId: string;
brand?: string;
model?: string;
customerId?: string;
address: string;
location?: string;
partNumber?: string;
serialNumber?: string;
installDate?: string;
warrantyEnd?: string;
warrantyStatus?: string;
description?: string;
notes?: string;
active?: boolean;
}
export type UpdateEquipmentData = Partial<CreateEquipmentData>;
export interface CreateRevisionData {
typeId: string;
performedDate: string;
nextDueDate?: string;
findings?: string;
result?: string;
notes?: string;
}
function buildQueryString(filters: EquipmentFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.customerId) params.append('customerId', filters.customerId);
if (filters.typeId) params.append('typeId', filters.typeId);
if (filters.active !== undefined) params.append('active', String(filters.active));
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export const equipmentApi = {
getAll: (filters: EquipmentFilters = {}) =>
getPaginated<Equipment>(`/equipment?${buildQueryString(filters)}`),
getById: (id: string) =>
get<Equipment>(`/equipment/${id}`),
create: (data: CreateEquipmentData) =>
post<Equipment>('/equipment', data),
update: (id: string, data: UpdateEquipmentData) =>
put<Equipment>(`/equipment/${id}`, data),
delete: (id: string) =>
del<void>(`/equipment/${id}`),
// Revisions
getRevisions: (equipmentId: string) =>
get<Revision[]>(`/equipment/${equipmentId}/revisions`),
createRevision: (equipmentId: string, data: CreateRevisionData) =>
post<Revision>(`/equipment/${equipmentId}/revisions`, data),
};

View File

@@ -0,0 +1,51 @@
import { get, getPaginated, post, put, del } from './api';
import type { Project } from '@/types';
export interface ProjectFilters {
search?: string;
customerId?: string;
ownerId?: string;
statusId?: string;
page?: number;
limit?: number;
}
export interface CreateProjectData {
name: string;
description?: string;
customerId?: string;
ownerId?: string; // Backend nastaví aktuálneho používateľa ak prázdne
statusId?: string; // Backend nastaví predvolený ak prázdne
softDeadline?: string;
hardDeadline?: string;
}
export type UpdateProjectData = Partial<CreateProjectData>;
function buildQueryString(filters: ProjectFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.customerId) params.append('customerId', filters.customerId);
if (filters.ownerId) params.append('ownerId', filters.ownerId);
if (filters.statusId) params.append('statusId', filters.statusId);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export const projectsApi = {
getAll: (filters: ProjectFilters = {}) =>
getPaginated<Project>(`/projects?${buildQueryString(filters)}`),
getById: (id: string) =>
get<Project>(`/projects/${id}`),
create: (data: CreateProjectData) =>
post<Project>('/projects', data),
update: (id: string, data: UpdateProjectData) =>
put<Project>(`/projects/${id}`, data),
delete: (id: string) =>
del<void>(`/projects/${id}`),
};

View File

@@ -0,0 +1,75 @@
import { get, getPaginated, post, put, del } from './api';
import type { RMA } from '@/types';
export interface RMAFilters {
search?: string;
customerId?: string;
statusId?: string;
assignedToId?: string;
page?: number;
limit?: number;
}
export interface CreateRMAData {
customerId?: string;
customerName?: string;
customerAddress?: string;
customerEmail?: string;
customerPhone?: string;
customerICO?: string;
submittedBy: string;
productName: string;
invoiceNumber?: string;
purchaseDate?: string;
productNumber?: string;
serialNumber?: string;
accessories?: string;
issueDescription: string;
statusId: string;
requiresApproval?: boolean;
assignedToId?: string;
}
export interface UpdateRMAData {
statusId?: string;
proposedSolutionId?: string;
requiresApproval?: boolean;
approvedById?: string;
receivedDate?: string;
receivedLocation?: string;
internalNotes?: string;
resolutionDate?: string;
resolutionNotes?: string;
assignedToId?: string;
}
function buildQueryString(filters: RMAFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.customerId) params.append('customerId', filters.customerId);
if (filters.statusId) params.append('statusId', filters.statusId);
if (filters.assignedToId) params.append('assignedToId', filters.assignedToId);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export const rmaApi = {
getAll: (filters: RMAFilters = {}) =>
getPaginated<RMA>(`/rma?${buildQueryString(filters)}`),
getById: (id: string) =>
get<RMA>(`/rma/${id}`),
create: (data: CreateRMAData) =>
post<RMA>('/rma', data),
update: (id: string, data: UpdateRMAData) =>
put<RMA>(`/rma/${id}`, data),
delete: (id: string) =>
del<void>(`/rma/${id}`),
approve: (id: string) =>
post<RMA>(`/rma/${id}/approve`),
};

View File

@@ -0,0 +1,76 @@
import { get, getPaginated, post, put, del } from './api';
import type {
UserRole,
User,
TaskStatus,
Priority,
EquipmentType,
RevisionType,
RMAStatus,
RMASolution,
Tag,
SystemSetting,
} from '@/types';
// Config endpoints
export const settingsApi = {
// Task Statuses
getTaskStatuses: () => get<TaskStatus[]>('/settings/task-statuses'),
createTaskStatus: (data: Omit<TaskStatus, 'id'>) => post<TaskStatus>('/settings/task-statuses', data),
updateTaskStatus: (id: string, data: Partial<TaskStatus>) => put<TaskStatus>(`/settings/task-statuses/${id}`, data),
deleteTaskStatus: (id: string) => del<void>(`/settings/task-statuses/${id}`),
// Priorities
getPriorities: () => get<Priority[]>('/settings/priorities'),
createPriority: (data: Omit<Priority, 'id'>) => post<Priority>('/settings/priorities', data),
updatePriority: (id: string, data: Partial<Priority>) => put<Priority>(`/settings/priorities/${id}`, data),
deletePriority: (id: string) => del<void>(`/settings/priorities/${id}`),
// Equipment Types
getEquipmentTypes: () => get<EquipmentType[]>('/settings/equipment-types'),
createEquipmentType: (data: Omit<EquipmentType, 'id'>) => post<EquipmentType>('/settings/equipment-types', data),
updateEquipmentType: (id: string, data: Partial<EquipmentType>) => put<EquipmentType>(`/settings/equipment-types/${id}`, data),
deleteEquipmentType: (id: string) => del<void>(`/settings/equipment-types/${id}`),
// Revision Types
getRevisionTypes: () => get<RevisionType[]>('/settings/revision-types'),
createRevisionType: (data: Omit<RevisionType, 'id'>) => post<RevisionType>('/settings/revision-types', data),
updateRevisionType: (id: string, data: Partial<RevisionType>) => put<RevisionType>(`/settings/revision-types/${id}`, data),
deleteRevisionType: (id: string) => del<void>(`/settings/revision-types/${id}`),
// RMA Statuses
getRMAStatuses: () => get<RMAStatus[]>('/settings/rma-statuses'),
createRMAStatus: (data: Omit<RMAStatus, 'id'>) => post<RMAStatus>('/settings/rma-statuses', data),
updateRMAStatus: (id: string, data: Partial<RMAStatus>) => put<RMAStatus>(`/settings/rma-statuses/${id}`, data),
deleteRMAStatus: (id: string) => del<void>(`/settings/rma-statuses/${id}`),
// RMA Solutions
getRMASolutions: () => get<RMASolution[]>('/settings/rma-solutions'),
createRMASolution: (data: Omit<RMASolution, 'id'>) => post<RMASolution>('/settings/rma-solutions', data),
updateRMASolution: (id: string, data: Partial<RMASolution>) => put<RMASolution>(`/settings/rma-solutions/${id}`, data),
deleteRMASolution: (id: string) => del<void>(`/settings/rma-solutions/${id}`),
// Tags
getTags: () => get<Tag[]>('/settings/tags'),
createTag: (data: Omit<Tag, 'id'>) => post<Tag>('/settings/tags', data),
updateTag: (id: string, data: Partial<Tag>) => put<Tag>(`/settings/tags/${id}`, data),
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}`),
// System Settings
getSystemSettings: () => get<SystemSetting[]>('/settings/system'),
updateSystemSetting: (id: string, value: unknown) => put<SystemSetting>(`/settings/system/${id}`, { value }),
// Users (admin)
getUsers: () => getPaginated<User>('/users?limit=1000'),
// Jednoduchý zoznam pre selecty (server-side vyhľadávanie)
getUsersSimple: (search?: string) => get<{ id: string; name: string; email: string }[]>(`/users/simple${search ? `?search=${encodeURIComponent(search)}` : ''}`),
createUser: (data: { email: string; password: string; name: string; roleId: string }) => post<User>('/users', data),
updateUser: (id: string, data: Partial<User & { password?: string }>) => put<User>(`/users/${id}`, data),
deleteUser: (id: string) => del<void>(`/users/${id}`),
};

View File

@@ -0,0 +1,67 @@
import { get, getPaginated, post, put, del } from './api';
import type { Task } from '@/types';
export interface TaskFilters {
search?: string;
projectId?: string;
statusId?: string;
priorityId?: string;
assigneeId?: string;
page?: number;
limit?: number;
}
export interface CreateTaskData {
title: string;
description?: string;
projectId?: string;
parentId?: string;
statusId?: string;
priorityId?: string;
deadline?: string;
assigneeIds?: string[];
}
export type UpdateTaskData = Partial<CreateTaskData>;
function buildQueryString(filters: TaskFilters): string {
const params = new URLSearchParams();
if (filters.search) params.append('search', filters.search);
if (filters.projectId) params.append('projectId', filters.projectId);
if (filters.statusId) params.append('statusId', filters.statusId);
if (filters.priorityId) params.append('priorityId', filters.priorityId);
if (filters.assigneeId) params.append('assigneeId', filters.assigneeId);
if (filters.page) params.append('page', String(filters.page));
if (filters.limit) params.append('limit', String(filters.limit));
return params.toString();
}
export const tasksApi = {
getAll: (filters: TaskFilters = {}) =>
getPaginated<Task>(`/tasks?${buildQueryString(filters)}`),
getById: (id: string) =>
get<Task>(`/tasks/${id}`),
create: (data: CreateTaskData) =>
post<Task>('/tasks', data),
update: (id: string, data: UpdateTaskData) =>
put<Task>(`/tasks/${id}`, data),
delete: (id: string) =>
del<void>(`/tasks/${id}`),
assignUser: (taskId: string, userId: string) =>
post<void>(`/tasks/${taskId}/assign`, { userId }),
unassignUser: (taskId: string, userId: string) =>
del<void>(`/tasks/${taskId}/assign/${userId}`),
// Comments
getComments: (taskId: string) =>
get<{ id: string; content: string; userId: string; user?: { id: string; name: string }; createdAt: string }[]>(`/tasks/${taskId}/comments`),
addComment: (taskId: string, content: string) =>
post<void>(`/tasks/${taskId}/comments`, { content }),
};

View File

@@ -0,0 +1,80 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { User } from '@/types';
import { authApi, type LoginCredentials } from '@/services/auth.api';
interface AuthState {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (credentials: LoginCredentials) => Promise<void>;
logout: () => Promise<void>;
fetchProfile: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (credentials) => {
set({ isLoading: true, error: null });
try {
const response = await authApi.login(credentials);
const { user, accessToken, refreshToken } = response.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
set({ user, isAuthenticated: true, isLoading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Prihlásenie zlyhalo';
set({ error: message, isLoading: false });
throw err;
}
},
logout: async () => {
try {
await authApi.logout();
} catch {
// Ignore logout errors
} finally {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false });
}
},
fetchProfile: async () => {
const token = localStorage.getItem('accessToken');
if (!token) {
set({ user: null, isAuthenticated: false });
return;
}
set({ isLoading: true });
try {
const response = await authApi.getProfile();
set({ user: response.data, isAuthenticated: true, isLoading: false });
} catch {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false, isLoading: false });
}
},
clearError: () => set({ error: null }),
}),
{
name: 'auth-storage',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
}
)
);

View File

@@ -0,0 +1,91 @@
import { create } from 'zustand';
import type {
TaskStatus,
Priority,
EquipmentType,
RevisionType,
RMAStatus,
RMASolution,
UserRole,
} from '@/types';
import { settingsApi } from '@/services/settings.api';
interface ConfigState {
taskStatuses: TaskStatus[];
priorities: Priority[];
equipmentTypes: EquipmentType[];
revisionTypes: RevisionType[];
rmaStatuses: RMAStatus[];
rmaSolutions: RMASolution[];
userRoles: UserRole[];
isLoading: boolean;
isLoaded: boolean;
fetchConfig: () => Promise<void>;
getTaskStatusById: (id: string) => TaskStatus | undefined;
getPriorityById: (id: string) => Priority | undefined;
getEquipmentTypeById: (id: string) => EquipmentType | undefined;
getRevisionTypeById: (id: string) => RevisionType | undefined;
getRMAStatusById: (id: string) => RMAStatus | undefined;
getRMASolutionById: (id: string) => RMASolution | undefined;
getUserRoleById: (id: string) => UserRole | undefined;
}
export const useConfigStore = create<ConfigState>((set, get) => ({
taskStatuses: [],
priorities: [],
equipmentTypes: [],
revisionTypes: [],
rmaStatuses: [],
rmaSolutions: [],
userRoles: [],
isLoading: false,
isLoaded: false,
fetchConfig: async () => {
if (get().isLoaded) return;
set({ isLoading: true });
try {
const [
taskStatusesRes,
prioritiesRes,
equipmentTypesRes,
revisionTypesRes,
rmaStatusesRes,
rmaSolutionsRes,
userRolesRes,
] = await Promise.all([
settingsApi.getTaskStatuses(),
settingsApi.getPriorities(),
settingsApi.getEquipmentTypes(),
settingsApi.getRevisionTypes(),
settingsApi.getRMAStatuses(),
settingsApi.getRMASolutions(),
settingsApi.getUserRoles(),
]);
set({
taskStatuses: taskStatusesRes.data,
priorities: prioritiesRes.data,
equipmentTypes: equipmentTypesRes.data,
revisionTypes: revisionTypesRes.data,
rmaStatuses: rmaStatusesRes.data,
rmaSolutions: rmaSolutionsRes.data,
userRoles: userRolesRes.data,
isLoading: false,
isLoaded: true,
});
} catch {
set({ isLoading: false });
}
},
getTaskStatusById: (id) => get().taskStatuses.find((s) => s.id === id),
getPriorityById: (id) => get().priorities.find((p) => p.id === id),
getEquipmentTypeById: (id) => get().equipmentTypes.find((t) => t.id === id),
getRevisionTypeById: (id) => get().revisionTypes.find((t) => t.id === id),
getRMAStatusById: (id) => get().rmaStatuses.find((s) => s.id === id),
getRMASolutionById: (id) => get().rmaSolutions.find((s) => s.id === id),
getUserRoleById: (id) => get().userRoles.find((r) => r.id === id),
}));

290
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,290 @@
// User & Auth
export interface User {
id: string;
email: string;
name: string;
active: boolean;
role: UserRole;
createdAt: string;
updatedAt: string;
}
export interface UserRole {
id: string;
code: string;
name: string;
permissions: Record<string, string[]>;
level: number;
}
export interface AuthTokens {
accessToken: string;
refreshToken: string;
}
export interface LoginResponse {
user: User;
accessToken: string;
refreshToken: string;
}
// Customer
export interface Customer {
id: string;
name: string;
address?: string;
email?: string;
phone?: string;
ico?: string;
dic?: string;
icdph?: string;
contactPerson?: string;
contactEmail?: string;
contactPhone?: string;
notes?: string;
active: boolean;
createdAt: string;
updatedAt: string;
_count?: {
projects: number;
equipment: number;
rmas: number;
};
}
// Project
export interface Project {
id: string;
name: string;
description?: string;
customerId?: string;
customer?: Pick<Customer, 'id' | 'name'>;
ownerId: string;
owner: Pick<User, 'id' | 'name' | 'email'>;
statusId: string;
status: TaskStatus;
softDeadline?: string;
hardDeadline?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
_count?: {
tasks: number;
members: number;
};
}
// Task
export interface Task {
id: string;
title: string;
description?: string;
projectId?: string;
project?: Pick<Project, 'id' | 'name'>;
parentId?: string;
statusId: string;
status: TaskStatus;
priorityId: string;
priority: Priority;
deadline?: string;
createdAt: string;
updatedAt: string;
completedAt?: string;
createdById: string;
createdBy: Pick<User, 'id' | 'name'>;
assignees: TaskAssignee[];
_count?: {
subTasks: number;
comments: number;
};
}
export interface TaskAssignee {
id: string;
userId: string;
user: Pick<User, 'id' | 'name' | 'email'>;
assignedAt: string;
}
// Equipment
export interface Equipment {
id: string;
name: string;
typeId: string;
type: EquipmentType;
brand?: string;
model?: string;
customerId?: string;
customer?: Pick<Customer, 'id' | 'name'>;
address: string;
location?: string;
partNumber?: string;
serialNumber?: string;
installDate?: string;
warrantyEnd?: string;
warrantyStatus?: string;
description?: string;
notes?: string;
active: boolean;
createdAt: string;
updatedAt: string;
_count?: {
revisions: number;
};
}
export interface Revision {
id: string;
equipmentId: string;
typeId: string;
type: RevisionType;
performedDate: string;
nextDueDate?: string;
performedById: string;
performedBy: Pick<User, 'id' | 'name'>;
findings?: string;
result?: string;
notes?: string;
createdAt: string;
}
// RMA
export interface RMA {
id: string;
rmaNumber: string;
customerId?: string;
customer?: Pick<Customer, 'id' | 'name'>;
customerName?: string;
customerAddress?: string;
customerEmail?: string;
customerPhone?: string;
customerICO?: string;
submittedBy: string;
productName: string;
invoiceNumber?: string;
purchaseDate?: string;
productNumber?: string;
serialNumber?: string;
accessories?: string;
issueDescription: string;
statusId: string;
status: RMAStatus;
proposedSolutionId?: string;
proposedSolution?: RMASolution;
requiresApproval: boolean;
approvedById?: string;
approvedAt?: string;
receivedDate?: string;
receivedLocation?: string;
internalNotes?: string;
resolutionDate?: string;
resolutionNotes?: string;
assignedToId?: string;
assignedTo?: Pick<User, 'id' | 'name'>;
createdById: string;
createdBy: Pick<User, 'id' | 'name'>;
createdAt: string;
updatedAt: string;
closedAt?: string;
}
// Config Types
export interface TaskStatus {
id: string;
code: string;
name: string;
color?: string;
icon?: string;
swimlaneColumn?: string;
isInitial: boolean;
isFinal: boolean;
order: number;
}
export interface Priority {
id: string;
code: string;
name: string;
color?: string;
icon?: string;
level: number;
order: number;
}
export interface EquipmentType {
id: string;
code: string;
name: string;
color?: string;
icon?: string;
order: number;
}
export interface RevisionType {
id: string;
code: string;
name: string;
intervalDays: number;
reminderDays: number;
color?: string;
order: number;
}
export interface RMAStatus {
id: string;
code: string;
name: string;
color?: string;
icon?: string;
isInitial: boolean;
isFinal: boolean;
canTransitionTo?: string[];
order: number;
}
export interface RMASolution {
id: string;
code: string;
name: string;
color?: string;
order: number;
}
export interface Tag {
id: string;
code: string;
name: string;
color?: string;
entityType: 'PROJECT' | 'TASK' | 'EQUIPMENT' | 'RMA';
order: number;
}
export interface SystemSetting {
id: string;
key: string;
value: unknown;
category: string;
label: string;
description?: string;
dataType: 'string' | 'number' | 'boolean' | 'json';
}
// API Response
export interface ApiResponse<T> {
success: boolean;
message: string;
data: T;
}
export interface PaginatedResponse<T> {
success: boolean;
data: T[];
pagination: {
total: number;
page: number;
limit: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
};
}