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

View File

@@ -1,3 +1,4 @@
import { useState, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -5,8 +6,9 @@ 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 { getFiles, generateTempId } from '@/services/upload.api';
import type { Equipment, Attachment } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter, FileUpload, PendingFileUpload } from '@/components/ui';
import toast from 'react-hot-toast';
const equipmentSchema = z.object({
@@ -37,6 +39,10 @@ interface EquipmentFormProps {
export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
const queryClient = useQueryClient();
const isEditing = !!equipment;
const [files, setFiles] = useState<Attachment[]>([]);
// Generate stable tempId for new equipment file uploads
const tempId = useMemo(() => generateTempId(), []);
const { data: customersData } = useQuery({
queryKey: ['customers-select'],
@@ -48,6 +54,18 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
queryFn: () => settingsApi.getEquipmentTypes(),
});
// Load files when editing
useQuery({
queryKey: ['equipment-files', equipment?.id],
queryFn: async () => {
if (!equipment?.id) return { data: [] };
const response = await getFiles('equipment', equipment.id);
setFiles(response.data);
return response;
},
enabled: isEditing,
});
const {
register,
handleSubmit,
@@ -110,7 +128,8 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
// Include tempId for pending files
createMutation.mutate({ ...cleanData, tempId });
}
};
@@ -211,6 +230,24 @@ export function EquipmentForm({ equipment, onClose }: EquipmentFormProps) {
{...register('notes')}
/>
<div className="border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Prílohy
</label>
{isEditing ? (
<FileUpload
entityType="equipment"
entityId={equipment!.id}
files={files}
onFilesChange={setFiles}
/>
) : (
<PendingFileUpload
tempId={tempId}
/>
)}
</div>
<label className="flex items-center gap-2">
<input type="checkbox" {...register('active')} className="rounded" />
<span className="text-sm">Aktívne</span>

View File

@@ -5,6 +5,7 @@ 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 Zakazka } from '@/services/zakazky.api';
import type { Project } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter } from '@/components/ui';
import { useAuthStore } from '@/store/authStore';
@@ -25,9 +26,10 @@ type ProjectFormData = z.infer<typeof projectSchema>;
interface ProjectFormProps {
project: Project | null;
onClose: () => void;
externalZakazka?: Zakazka | null;
}
export function ProjectForm({ project, onClose }: ProjectFormProps) {
export function ProjectForm({ project, onClose, externalZakazka }: ProjectFormProps) {
const queryClient = useQueryClient();
const { user } = useAuthStore();
const isEditing = !!project;
@@ -47,26 +49,44 @@ export function ProjectForm({ project, onClose }: ProjectFormProps) {
queryFn: () => settingsApi.getUsers(),
});
// Build default values based on editing mode or external zakazka
const getDefaultValues = (): Partial<ProjectFormData> => {
if (project) {
return {
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] || '',
};
}
if (externalZakazka) {
// Pre-fill from external zakazka
return {
name: `${externalZakazka.cislo} - ${externalZakazka.nazov}`,
description: externalZakazka.poznamka || `Zákazník: ${externalZakazka.customer}\nVystavil: ${externalZakazka.vystavil}`,
ownerId: user?.id || '',
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
hardDeadline: externalZakazka.datum_ukoncenia?.split('T')[0] || '',
};
}
return {
ownerId: user?.id || '',
statusId: statusesData?.data.find((s) => s.isInitial)?.id || '',
};
};
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 || '',
},
defaultValues: getDefaultValues(),
});
const createMutation = useMutation({

View File

@@ -1,13 +1,12 @@
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 { useQuery } from '@tanstack/react-query';
import { ExternalLink } from 'lucide-react';
import { zakazkyApi } from '@/services/zakazky.api';
import {
Button,
Input,
Card,
CardHeader,
CardTitle,
CardContent,
Table,
TableHeader,
@@ -17,112 +16,149 @@ import {
TableCell,
Badge,
LoadingOverlay,
Modal,
ModalFooter,
Select,
} 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 [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
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 }),
// Check if external DB is configured
const { data: zakazkyStatus, isLoading: statusLoading } = useQuery({
queryKey: ['zakazky-status'],
queryFn: () => zakazkyApi.checkStatus(),
});
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');
},
// Get available years
const { data: yearsData } = useQuery({
queryKey: ['zakazky-years'],
queryFn: () => zakazkyApi.getYears(),
enabled: !!zakazkyStatus?.data?.configured,
});
const handleEdit = (project: Project) => {
setEditingProject(project);
setIsFormOpen(true);
};
// Get zakazky for selected year
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({
queryKey: ['zakazky', selectedYear, search],
queryFn: () => zakazkyApi.getAll(selectedYear, search || undefined),
enabled: !!zakazkyStatus?.data?.configured,
});
const handleCloseForm = () => {
setIsFormOpen(false);
setEditingProject(null);
};
const isExternalDbConfigured = zakazkyStatus?.data?.configured;
const yearOptions = (yearsData?.data || []).map((year) => ({
value: String(year),
label: String(year),
}));
if (statusLoading) {
return <LoadingOverlay />;
}
if (!isExternalDbConfigured) {
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Zákazky</h1>
<Card>
<CardContent className="py-12 text-center">
<ExternalLink className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Externá databáza nie je nakonfigurovaná</h3>
<p className="text-muted-foreground">
Pre zobrazenie zákaziek je potrebné nakonfigurovať pripojenie k externej databáze.
</p>
<p className="text-sm text-muted-foreground mt-2">
Nastavte premenné EXTERNAL_DB_* v .env súbore.
</p>
</CardContent>
</Card>
</div>
);
}
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>
<h1 className="text-2xl font-bold">Zákazky</h1>
</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" />
<CardTitle className="flex items-center gap-2">
<ExternalLink className="h-5 w-5" />
Zákazky z externej databázy
</CardTitle>
<div className="flex items-end gap-4 mt-4">
<Select
id="year"
label="Rok"
value={String(selectedYear)}
onChange={(e) => setSelectedYear(Number(e.target.value))}
options={yearOptions}
className="w-32"
/>
<div className="flex-1 max-w-sm">
<Input
placeholder="Hľadať projekty..."
id="search"
label="Hľadať"
placeholder="Hľadať zákazky..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
{zakazkyLoading ? (
<LoadingOverlay />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Číslo</TableHead>
<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>
<TableHead>Vystavené</TableHead>
<TableHead>Ukončenie</TableHead>
<TableHead>Vystavil</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>
{zakazkyData?.data?.map((zakazka) => (
<TableRow key={zakazka.id}>
<TableCell className="font-mono font-medium">{zakazka.cislo}</TableCell>
<TableCell className="max-w-xs">
<div className="truncate" title={zakazka.nazov}>
{zakazka.nazov}
</div>
{zakazka.poznamka && (
<div className="text-xs text-muted-foreground truncate" title={zakazka.poznamka}>
{zakazka.poznamka}
</div>
)}
</TableCell>
<TableCell>{zakazka.customer}</TableCell>
<TableCell>
<Badge color={project.status.color}>{project.status.name}</Badge>
<Badge color={zakazka.uzavreta ? 'gray' : 'green'}>
{zakazka.uzavreta ? 'Uzavretá' : 'Otvorená'}
</Badge>
</TableCell>
<TableCell>
{project.hardDeadline ? formatDate(project.hardDeadline) : '-'}
{zakazka.datum_vystavenia
? formatDate(zakazka.datum_vystavenia)
: '-'}
</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>
{zakazka.datum_ukoncenia
? formatDate(zakazka.datum_ukoncenia)
: '-'}
</TableCell>
<TableCell>{zakazka.vystavil}</TableCell>
</TableRow>
))}
{data?.data.length === 0 && (
{(!zakazkyData?.data || zakazkyData.data.length === 0) && (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground">
Žiadne projekty
<TableCell colSpan={7} className="text-center text-muted-foreground py-8">
Žiadne zákazky pre rok {selectedYear}
</TableCell>
</TableRow>
)}
@@ -131,35 +167,6 @@ export function ProjectsList() {
)}
</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

@@ -1,3 +1,4 @@
import { useState, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -5,8 +6,9 @@ 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 { getFiles, generateTempId } from '@/services/upload.api';
import type { RMA, Attachment } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter, FileUpload, PendingFileUpload } from '@/components/ui';
import toast from 'react-hot-toast';
const rmaSchema = z.object({
@@ -44,12 +46,28 @@ interface RMAFormProps {
export function RMAForm({ rma, onClose }: RMAFormProps) {
const queryClient = useQueryClient();
const isEditing = !!rma;
const [files, setFiles] = useState<Attachment[]>([]);
// Generate stable tempId for new RMA file uploads
const tempId = useMemo(() => generateTempId(), []);
const { data: customersData } = useQuery({
queryKey: ['customers-select'],
queryFn: () => customersApi.getAll({ active: true, limit: 1000 }),
});
// Load files when editing
useQuery({
queryKey: ['rma-files', rma?.id],
queryFn: async () => {
if (!rma?.id) return { data: [] };
const response = await getFiles('rma', rma.id);
setFiles(response.data);
return response;
},
enabled: isEditing,
});
const { data: statusesData } = useQuery({
queryKey: ['rma-statuses'],
queryFn: () => settingsApi.getRMAStatuses(),
@@ -140,7 +158,8 @@ export function RMAForm({ rma, onClose }: RMAFormProps) {
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData as CreateRMAData);
// Include tempId for pending files
createMutation.mutate({ ...cleanData, tempId } as CreateRMAData);
}
};
@@ -296,6 +315,24 @@ export function RMAForm({ rma, onClose }: RMAFormProps) {
{...register('resolutionNotes')}
/>
<div className="border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Prílohy (fotky, doklady)
</label>
{isEditing ? (
<FileUpload
entityType="rma"
entityId={rma!.id}
files={files}
onFilesChange={setFiles}
/>
) : (
<PendingFileUpload
tempId={tempId}
/>
)}
</div>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť

View File

@@ -1,19 +1,19 @@
import { useState } from 'react';
import { useState, useMemo, useEffect } 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 { settingsApi } from '@/services/settings.api';
import { zakazkyApi } from '@/services/zakazky.api';
import { getFiles, generateTempId } from '@/services/upload.api';
import type { Task, Attachment } from '@/types';
import { Button, Input, Textarea, Select, ModalFooter, UserSelect, SearchableSelect, FileUpload, PendingFileUpload } 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(),
@@ -35,10 +35,15 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
task?.assignees?.map((a) => a.userId) || []
);
const { data: projectsData } = useQuery({
queryKey: ['projects-select'],
queryFn: () => projectsApi.getAll({ limit: 1000 }),
});
// State pre súbory
const [files, setFiles] = useState<Attachment[]>([]);
// State pre externé zákazky
const [selectedYear, setSelectedYear] = useState<number>(new Date().getFullYear());
const [selectedZakazkaId, setSelectedZakazkaId] = useState<string>('');
// Generate stable tempId for new task file uploads
const tempId = useMemo(() => generateTempId(), []);
const { data: statusesData } = useQuery({
queryKey: ['task-statuses'],
@@ -50,10 +55,42 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
queryFn: () => settingsApi.getPriorities(),
});
// Check if external DB is configured
const { data: zakazkyStatus } = useQuery({
queryKey: ['zakazky-status'],
queryFn: () => zakazkyApi.checkStatus(),
});
// Get available years for external zákazky
const { data: yearsData } = useQuery({
queryKey: ['zakazky-years'],
queryFn: () => zakazkyApi.getYears(),
enabled: !!zakazkyStatus?.data?.configured,
});
// Get external zákazky for selected year
const { data: zakazkyData, isLoading: zakazkyLoading } = useQuery({
queryKey: ['zakazky', selectedYear],
queryFn: () => zakazkyApi.getAll(selectedYear),
enabled: !!zakazkyStatus?.data?.configured,
});
// Load files when editing
useQuery({
queryKey: ['task-files', task?.id],
queryFn: async () => {
if (!task?.id) return { data: [] };
const response = await getFiles('task', task.id);
setFiles(response.data);
return response;
},
enabled: isEditing,
});
const {
register,
handleSubmit,
setValue,
formState: { errors },
} = useForm<TaskFormData>({
resolver: zodResolver(taskSchema),
@@ -61,7 +98,6 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
? {
title: task.title,
description: task.description || '',
projectId: task.projectId || '',
statusId: task.statusId,
priorityId: task.priorityId,
deadline: task.deadline?.split('T')[0] || '',
@@ -69,13 +105,22 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
: {
title: '',
description: '',
projectId: '',
statusId: '',
priorityId: '',
deadline: '',
},
});
// When external zákazka is selected, only set deadline if available
useEffect(() => {
if (selectedZakazkaId && zakazkyData?.data) {
const zakazka = zakazkyData.data.find((z) => String(z.id) === selectedZakazkaId);
if (zakazka?.datum_ukoncenia) {
setValue('deadline', zakazka.datum_ukoncenia.split('T')[0]);
}
}
}, [selectedZakazkaId, zakazkyData, setValue]);
const createMutation = useMutation({
mutationFn: (data: CreateTaskData) => tasksApi.create(data),
onSuccess: () => {
@@ -109,12 +154,9 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
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),
};
@@ -123,25 +165,61 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
if (isEditing) {
updateMutation.mutate(cleanData);
} else {
createMutation.mutate(cleanData);
createMutation.mutate({ ...cleanData, tempId });
}
};
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 })) || [];
const yearOptions = (yearsData?.data || []).map((year) => ({ value: String(year), label: String(year) }));
// Prepare zákazky options for SearchableSelect
const zakazkyOptions = useMemo(() => {
return (zakazkyData?.data || []).map((z) => ({
value: String(z.id),
label: z.nazov,
description: `${z.cislo} · ${z.customer}`,
}));
}, [zakazkyData]);
// 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 || '',
})) || [];
const isExternalDbConfigured = zakazkyStatus?.data?.configured;
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* External zákazky selector */}
{isExternalDbConfigured && (
<div className="grid gap-3 md:grid-cols-[120px_1fr]">
<Select
id="year"
label="Rok"
value={String(selectedYear)}
onChange={(e) => {
setSelectedYear(Number(e.target.value));
setSelectedZakazkaId('');
}}
options={yearOptions}
/>
<SearchableSelect
label="Zákazka"
options={zakazkyOptions}
value={selectedZakazkaId}
onChange={setSelectedZakazkaId}
placeholder="-- Vyberte zákazku --"
searchPlaceholder="Hľadať podľa čísla, názvu alebo zákazníka..."
emptyMessage={zakazkyLoading ? 'Načítavam...' : 'Žiadne zákazky'}
disabled={isEditing}
/>
</div>
)}
<Input
id="title"
label="Názov *"
@@ -156,13 +234,7 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
{...register('description')}
/>
<div className="grid gap-4 md:grid-cols-2">
<Select
id="projectId"
label="Projekt"
options={[{ value: '', label: '-- Bez projektu --' }, ...projectOptions]}
{...register('projectId')}
/>
<div className="grid gap-4 md:grid-cols-3">
<Select
id="statusId"
label="Stav"
@@ -191,6 +263,24 @@ export function TaskForm({ task, onClose }: TaskFormProps) {
placeholder="Vyhľadať používateľa..."
/>
<div className="border-t pt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Prílohy
</label>
{isEditing ? (
<FileUpload
entityType="task"
entityId={task!.id}
files={files}
onFilesChange={setFiles}
/>
) : (
<PendingFileUpload
tempId={tempId}
/>
)}
</div>
<ModalFooter>
<Button type="button" variant="outline" onClick={onClose}>
Zrušiť

View File

@@ -92,7 +92,6 @@ export function TasksList() {
<TableHeader>
<TableRow>
<TableHead>Názov</TableHead>
<TableHead>Projekt</TableHead>
<TableHead>Zadal</TableHead>
<TableHead>Stav</TableHead>
<TableHead>Priorita</TableHead>
@@ -112,7 +111,6 @@ export function TasksList() {
{task.title}
</button>
</TableCell>
<TableCell>{task.project?.name || '-'}</TableCell>
<TableCell>{task.createdBy?.name || '-'}</TableCell>
<TableCell>
<Badge color={task.status.color}>{task.status.name}</Badge>
@@ -141,7 +139,7 @@ export function TasksList() {
))}
{data?.data.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-center text-muted-foreground">
<TableCell colSpan={7} className="text-center text-muted-foreground">
Žiadne úlohy
</TableCell>
</TableRow>

View File

@@ -26,6 +26,7 @@ export interface CreateEquipmentData {
description?: string;
notes?: string;
active?: boolean;
tempId?: string; // For pending file uploads
}
export type UpdateEquipmentData = Partial<CreateEquipmentData>;

View File

@@ -28,6 +28,7 @@ export interface CreateRMAData {
statusId: string;
requiresApproval?: boolean;
assignedToId?: string;
tempId?: string; // For pending file uploads
}
export interface UpdateRMAData {

View File

@@ -20,6 +20,7 @@ export interface CreateTaskData {
priorityId?: string;
deadline?: string;
assigneeIds?: string[];
tempId?: string; // For pending file uploads
}
export type UpdateTaskData = Partial<CreateTaskData>;

View File

@@ -0,0 +1,138 @@
import { api, get, del } from './api';
import type { ApiResponse, Attachment } from '@/types';
export type EntityType = 'equipment' | 'rma' | 'task';
// Pending attachment type (before entity is saved)
export interface PendingAttachment {
id: string;
filename: string;
filepath: string;
mimetype: string;
size: number;
uploadedAt: string;
isPending: true;
}
// Generate unique temp ID for pending uploads
export function generateTempId(): string {
return `temp-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
}
// Upload files for existing entity
export async function uploadFiles(
entityType: EntityType,
entityId: string,
files: File[],
onProgress?: (progress: number) => void
): Promise<ApiResponse<Attachment[]>> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await api.post<ApiResponse<Attachment[]>>(
`/${entityType}/${entityId}/files`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
}
);
return response.data;
}
// Upload pending files (for new entities before they are saved)
export async function uploadPendingFiles(
tempId: string,
files: File[],
onProgress?: (progress: number) => void
): Promise<ApiResponse<PendingAttachment[]>> {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const response = await api.post<ApiResponse<PendingAttachment[]>>(
`/files/pending/${tempId}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
onUploadProgress: (progressEvent) => {
if (onProgress && progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
onProgress(percent);
}
},
}
);
return response.data;
}
// Get pending files
export function getPendingFiles(tempId: string): Promise<ApiResponse<PendingAttachment[]>> {
return get<PendingAttachment[]>(`/files/pending/${tempId}`);
}
// Delete pending file
export async function deletePendingFile(tempId: string, filename: string): Promise<ApiResponse<null>> {
return del<null>(`/files/pending/${tempId}/${filename}`);
}
// Get files for entity
export function getFiles(entityType: EntityType, entityId: string): Promise<ApiResponse<Attachment[]>> {
return get<Attachment[]>(`/${entityType}/${entityId}/files`);
}
// Delete file
export function deleteFile(
entityType: EntityType,
entityId: string,
fileId: string
): Promise<ApiResponse<null>> {
return del<null>(`/${entityType}/${entityId}/files/${fileId}`);
}
// Download file URL
export function getDownloadUrl(entityType: EntityType, fileId: string): string {
return `/api/files/${entityType}/${fileId}/download`;
}
// Get file preview URL (for images)
export function getFilePreviewUrl(filepath: string): string {
// filepath je vo formáte /uploads/equipment/filename.jpg
return filepath;
}
// Format file size
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
// Check if file is image
export function isImageFile(mimetype: string): boolean {
return mimetype.startsWith('image/');
}
// Get file icon based on mimetype
export function getFileIcon(mimetype: string): string {
if (mimetype.startsWith('image/')) return 'image';
if (mimetype === 'application/pdf') return 'file-text';
if (mimetype.includes('word')) return 'file-text';
if (mimetype.includes('excel') || mimetype.includes('spreadsheet')) return 'file-spreadsheet';
if (mimetype.includes('text')) return 'file-text';
return 'file';
}

View File

@@ -0,0 +1,43 @@
import { get } from './api';
// Zakazka interface matching backend
export interface Zakazka {
id: number;
id_stav_zakazky: number;
cislo: string;
datum_vystavenia: string | null;
datum_ukoncenia: string | null;
customer: string;
nazov: string;
poznamka: string | null;
vystavil: string;
uzavreta: boolean;
}
export interface ZakazkyStatus {
configured: boolean;
}
// Check if external DB is configured
export const checkZakazkyStatus = () =>
get<ZakazkyStatus>('/zakazky/status');
// Get available years
export const getAvailableYears = () =>
get<number[]>('/zakazky/years');
// Get zakazky by year
export const getZakazky = (rok: number, search?: string) => {
const params = new URLSearchParams();
params.append('rok', String(rok));
if (search) {
params.append('search', search);
}
return get<Zakazka[]>(`/zakazky?${params.toString()}`);
};
export const zakazkyApi = {
checkStatus: checkZakazkyStatus,
getYears: getAvailableYears,
getAll: getZakazky,
};

View File

@@ -269,6 +269,18 @@ export interface SystemSetting {
dataType: 'string' | 'number' | 'boolean' | 'json';
}
// File Attachment
export interface Attachment {
id: string;
filename: string;
filepath: string;
mimetype: string;
size: number;
uploadedById: string;
uploadedBy?: Pick<User, 'id' | 'name'>;
uploadedAt: string;
}
// API Response
export interface ApiResponse<T> {
success: boolean;