Prepojenie na externu DB, projekt-zakazky

This commit is contained in:
2026-02-03 11:20:17 +01:00
parent e4f63a135e
commit cbdd952bc1
37 changed files with 2641 additions and 149 deletions

View File

@@ -11,8 +11,8 @@ 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: '/projects', icon: FolderKanban, label: 'Zákazky' },
{ to: '/customers', icon: Users, label: 'Zákazníci' },
{ to: '/equipment', icon: Wrench, label: 'Zariadenia' },
{ to: '/rma', icon: RotateCcw, label: 'RMA' },

View File

@@ -0,0 +1,237 @@
import { useState, useRef, useCallback } from 'react';
import { Upload, File, Image, FileText, Loader2, Download, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Attachment } from '@/types';
import {
uploadFiles,
deleteFile,
getFilePreviewUrl,
getDownloadUrl,
formatFileSize,
isImageFile,
type EntityType,
} from '@/services/upload.api';
import toast from 'react-hot-toast';
interface FileUploadProps {
entityType: EntityType;
entityId: string;
files: Attachment[];
onFilesChange: (files: Attachment[]) => void;
maxFiles?: number;
accept?: string;
disabled?: boolean;
className?: string;
}
export function FileUpload({
entityType,
entityId,
files,
onFilesChange,
maxFiles = 10,
accept = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv',
disabled = false,
className,
}: FileUploadProps) {
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const droppedFiles = Array.from(e.dataTransfer.files);
await handleUpload(droppedFiles);
},
[disabled, entityType, entityId, files, maxFiles]
);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
await handleUpload(selectedFiles);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUpload = async (newFiles: File[]) => {
if (newFiles.length === 0) return;
// Check max files
if (files.length + newFiles.length > maxFiles) {
toast.error(`Maximálny počet súborov je ${maxFiles}`);
return;
}
setIsUploading(true);
setUploadProgress(0);
try {
const response = await uploadFiles(entityType, entityId, newFiles, setUploadProgress);
onFilesChange([...files, ...response.data]);
toast.success(response.message || 'Súbory boli nahrané');
} catch (error) {
console.error('Upload error:', error);
toast.error('Chyba pri nahrávaní súborov');
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const handleDelete = async (fileId: string) => {
setDeletingId(fileId);
try {
await deleteFile(entityType, entityId, fileId);
onFilesChange(files.filter((f) => f.id !== fileId));
toast.success('Súbor bol vymazaný');
} catch (error) {
console.error('Delete error:', error);
toast.error('Chyba pri mazaní súboru');
} finally {
setDeletingId(null);
}
};
const getFileIcon = (mimetype: string) => {
if (isImageFile(mimetype)) return <Image className="h-5 w-5 text-blue-500" />;
if (mimetype === 'application/pdf') return <FileText className="h-5 w-5 text-red-500" />;
return <File className="h-5 w-5 text-gray-500" />;
};
return (
<div className={cn('space-y-4', className)}>
{/* Drop zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-gray-400',
isUploading && 'pointer-events-none'
)}
onClick={() => !disabled && !isUploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={accept}
onChange={handleFileSelect}
className="hidden"
disabled={disabled || isUploading}
/>
{isUploading ? (
<div className="space-y-2">
<Loader2 className="h-8 w-8 mx-auto animate-spin text-blue-500" />
<p className="text-sm text-gray-600">Nahrávam... {uploadProgress}%</p>
<div className="w-full bg-gray-200 rounded-full h-2 max-w-xs mx-auto">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : (
<>
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-600">
Presuňte súbory sem alebo <span className="text-blue-500 font-medium">kliknite</span>
</p>
<p className="text-xs text-gray-400 mt-1">
Max {maxFiles} súborov, podporované: obrázky, PDF, Word, Excel
</p>
</>
)}
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">
Nahrané súbory ({files.length}/{maxFiles})
</p>
<ul className="divide-y divide-gray-200 border rounded-lg">
{files.map((file) => (
<li
key={file.id}
className="flex items-center justify-between p-3 hover:bg-gray-50"
>
<div className="flex items-center space-x-3 min-w-0">
{isImageFile(file.mimetype) ? (
<img
src={getFilePreviewUrl(file.filepath)}
alt={file.filename}
className="h-10 w-10 object-cover rounded"
/>
) : (
getFileIcon(file.mimetype)
)}
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{file.filename}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.size)}
{file.uploadedBy && `${file.uploadedBy.name}`}
</p>
</div>
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
<a
href={getDownloadUrl(entityType, file.id)}
download
className="p-1.5 text-gray-400 hover:text-blue-500 rounded"
title="Stiahnuť"
>
<Download className="h-4 w-4" />
</a>
{!disabled && (
<button
type="button"
onClick={() => handleDelete(file.id)}
disabled={deletingId === file.id}
className="p-1.5 text-gray-400 hover:text-red-500 rounded disabled:opacity-50"
title="Vymazať"
>
{deletingId === file.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
)}
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,251 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Upload, File, Image, FileText, Loader2, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
uploadPendingFiles,
deletePendingFile,
getPendingFiles,
formatFileSize,
isImageFile,
type PendingAttachment,
} from '@/services/upload.api';
import toast from 'react-hot-toast';
interface PendingFileUploadProps {
tempId: string;
onFilesChange?: (files: PendingAttachment[]) => void;
maxFiles?: number;
accept?: string;
disabled?: boolean;
className?: string;
}
export function PendingFileUpload({
tempId,
onFilesChange,
maxFiles = 10,
accept = 'image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv',
disabled = false,
className,
}: PendingFileUploadProps) {
const [files, setFiles] = useState<PendingAttachment[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Load existing pending files on mount
useEffect(() => {
const loadPendingFiles = async () => {
try {
const response = await getPendingFiles(tempId);
if (response.data && response.data.length > 0) {
setFiles(response.data);
onFilesChange?.(response.data);
}
} catch {
// No pending files yet, that's fine
}
};
loadPendingFiles();
}, [tempId]);
const updateFiles = (newFiles: PendingAttachment[]) => {
setFiles(newFiles);
onFilesChange?.(newFiles);
};
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (disabled) return;
const droppedFiles = Array.from(e.dataTransfer.files);
await handleUpload(droppedFiles);
},
[disabled, tempId, files, maxFiles]
);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
await handleUpload(selectedFiles);
// Reset input
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleUpload = async (newFiles: File[]) => {
if (newFiles.length === 0) return;
// Check max files
if (files.length + newFiles.length > maxFiles) {
toast.error(`Maximálny počet súborov je ${maxFiles}`);
return;
}
setIsUploading(true);
setUploadProgress(0);
try {
const response = await uploadPendingFiles(tempId, newFiles, setUploadProgress);
const updatedFiles = [...files, ...response.data];
updateFiles(updatedFiles);
toast.success(response.message || 'Súbory boli nahrané');
} catch (error) {
console.error('Upload error:', error);
toast.error('Chyba pri nahrávaní súborov');
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const handleDelete = async (file: PendingAttachment) => {
setDeletingId(file.id);
try {
await deletePendingFile(tempId, file.filename);
const updatedFiles = files.filter((f) => f.id !== file.id);
updateFiles(updatedFiles);
toast.success('Súbor bol vymazaný');
} catch (error) {
console.error('Delete error:', error);
toast.error('Chyba pri mazaní súboru');
} finally {
setDeletingId(null);
}
};
const getFileIcon = (mimetype: string) => {
if (isImageFile(mimetype)) return <Image className="h-5 w-5 text-blue-500" />;
if (mimetype === 'application/pdf') return <FileText className="h-5 w-5 text-red-500" />;
return <File className="h-5 w-5 text-gray-500" />;
};
// Get preview URL for pending files
const getPreviewUrl = (file: PendingAttachment) => {
return `/api${file.filepath}`;
};
return (
<div className={cn('space-y-4', className)}>
{/* Drop zone */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
'border-2 border-dashed rounded-lg p-6 text-center transition-colors',
isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300',
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-gray-400',
isUploading && 'pointer-events-none'
)}
onClick={() => !disabled && !isUploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
multiple
accept={accept}
onChange={handleFileSelect}
className="hidden"
disabled={disabled || isUploading}
/>
{isUploading ? (
<div className="space-y-2">
<Loader2 className="h-8 w-8 mx-auto animate-spin text-blue-500" />
<p className="text-sm text-gray-600">Nahrávam... {uploadProgress}%</p>
<div className="w-full bg-gray-200 rounded-full h-2 max-w-xs mx-auto">
<div
className="bg-blue-500 h-2 rounded-full transition-all"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : (
<>
<Upload className="h-8 w-8 mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-600">
Presuňte súbory sem alebo <span className="text-blue-500 font-medium">kliknite</span>
</p>
<p className="text-xs text-gray-400 mt-1">
Max {maxFiles} súborov, podporované: obrázky, PDF, Word, Excel
</p>
</>
)}
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-gray-700">
Nahrané súbory ({files.length}/{maxFiles})
</p>
<ul className="divide-y divide-gray-200 border rounded-lg">
{files.map((file) => (
<li
key={file.id}
className="flex items-center justify-between p-3 hover:bg-gray-50"
>
<div className="flex items-center space-x-3 min-w-0">
{isImageFile(file.mimetype) ? (
<img
src={getPreviewUrl(file)}
alt={file.filename}
className="h-10 w-10 object-cover rounded"
/>
) : (
getFileIcon(file.mimetype)
)}
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{file.filename}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.size)}
</p>
</div>
</div>
<div className="flex items-center space-x-1 flex-shrink-0">
{!disabled && (
<button
type="button"
onClick={() => handleDelete(file)}
disabled={deletingId === file.id}
className="p-1.5 text-gray-400 hover:text-red-500 rounded disabled:opacity-50"
title="Vymazať"
>
{deletingId === file.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</button>
)}
</div>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,185 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Search, ChevronDown, X } from 'lucide-react';
import { cn } from '@/lib/utils';
export interface SearchableOption {
value: string;
label: string;
description?: string;
}
interface SearchableSelectProps {
options: SearchableOption[];
value: string;
onChange: (value: string) => void;
label?: string;
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
disabled?: boolean;
className?: string;
}
export function SearchableSelect({
options,
value,
onChange,
label,
placeholder = '-- Vyberte --',
searchPlaceholder = 'Hľadať...',
emptyMessage = 'Žiadne výsledky',
disabled = false,
className,
}: SearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
setSearch('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Filter options based on search
const filteredOptions = useMemo(() => {
if (!search) return options;
const searchLower = search.toLowerCase();
return options.filter(
(opt) =>
opt.label.toLowerCase().includes(searchLower) ||
opt.description?.toLowerCase().includes(searchLower)
);
}, [options, search]);
// Get selected option label
const selectedOption = options.find((opt) => opt.value === value);
const handleSelect = (optValue: string) => {
onChange(optValue);
setIsOpen(false);
setSearch('');
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setSearch('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
setSearch('');
}
};
return (
<div className={cn('space-y-1', className)} ref={containerRef}>
{label && (
<label className="text-sm font-medium text-foreground">{label}</label>
)}
<div
className={cn(
'relative h-10 w-full rounded-md border border-input bg-background px-3 py-2',
'focus-within:ring-2 focus-within:ring-ring cursor-pointer',
isOpen && 'ring-2 ring-ring',
disabled && 'opacity-50 cursor-not-allowed'
)}
onClick={() => {
if (!disabled) {
setIsOpen(true);
setTimeout(() => inputRef.current?.focus(), 0);
}
}}
>
<div className="flex items-center justify-between h-full">
<span
className={cn(
'text-sm truncate flex-1',
!selectedOption && 'text-muted-foreground'
)}
>
{selectedOption?.label || placeholder}
</span>
<div className="flex items-center gap-1">
{value && (
<button
type="button"
onClick={handleClear}
className="p-0.5 hover:bg-accent rounded"
>
<X className="h-4 w-4 text-muted-foreground" />
</button>
)}
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
isOpen && 'rotate-180'
)}
/>
</div>
</div>
</div>
{/* Dropdown */}
{isOpen && !disabled && (
<div className="relative">
<div className="absolute z-50 w-full mt-1 bg-popover border border-input rounded-md shadow-lg">
{/* Search input */}
<div className="p-2 border-b">
<div className="flex items-center gap-2 px-2 py-1 bg-background rounded border">
<Search className="h-4 w-4 text-muted-foreground" />
<input
ref={inputRef}
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={searchPlaceholder}
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
/>
</div>
</div>
{/* Options list */}
<div className="max-h-48 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="px-3 py-2 text-sm text-muted-foreground">
{emptyMessage}
</div>
) : (
filteredOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option.value)}
className={cn(
'w-full px-3 py-2 text-left hover:bg-accent flex flex-col',
option.value === value && 'bg-accent'
)}
>
<span className="text-sm">{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">
{option.description}
</span>
)}
</button>
))
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -8,3 +8,6 @@ export { Modal, ModalFooter } from './Modal';
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
export { Spinner, LoadingOverlay } from './Spinner';
export { UserSelect } from './UserSelect';
export { SearchableSelect, type SearchableOption } from './SearchableSelect';
export { FileUpload } from './FileUpload';
export { PendingFileUpload } from './PendingFileUpload';