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:
46
frontend/src/components/layout/Header.tsx
Normal file
46
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/components/layout/MainLayout.tsx
Normal file
15
frontend/src/components/layout/MainLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/layout/Sidebar.tsx
Normal file
46
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Header } from './Header';
|
||||
export { Sidebar } from './Sidebar';
|
||||
export { MainLayout } from './MainLayout';
|
||||
31
frontend/src/components/ui/Badge.tsx
Normal file
31
frontend/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
frontend/src/components/ui/Button.tsx
Normal file
41
frontend/src/components/ui/Button.tsx
Normal 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';
|
||||
74
frontend/src/components/ui/Card.tsx
Normal file
74
frontend/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/ui/Input.tsx
Normal file
34
frontend/src/components/ui/Input.tsx
Normal 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';
|
||||
78
frontend/src/components/ui/Modal.tsx
Normal file
78
frontend/src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
frontend/src/components/ui/Select.tsx
Normal file
52
frontend/src/components/ui/Select.tsx
Normal 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';
|
||||
32
frontend/src/components/ui/Spinner.tsx
Normal file
32
frontend/src/components/ui/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
frontend/src/components/ui/Table.tsx
Normal file
85
frontend/src/components/ui/Table.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/ui/Textarea.tsx
Normal file
34
frontend/src/components/ui/Textarea.tsx
Normal 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';
|
||||
195
frontend/src/components/ui/UserSelect.tsx
Normal file
195
frontend/src/components/ui/UserSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/src/components/ui/index.ts
Normal file
10
frontend/src/components/ui/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user