Prepojenie na externu DB, projekt-zakazky
This commit is contained in:
@@ -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' },
|
||||
|
||||
237
frontend/src/components/ui/FileUpload.tsx
Normal file
237
frontend/src/components/ui/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
frontend/src/components/ui/PendingFileUpload.tsx
Normal file
251
frontend/src/components/ui/PendingFileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
frontend/src/components/ui/SearchableSelect.tsx
Normal file
185
frontend/src/components/ui/SearchableSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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ť
|
||||
|
||||
@@ -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ť
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CreateEquipmentData {
|
||||
description?: string;
|
||||
notes?: string;
|
||||
active?: boolean;
|
||||
tempId?: string; // For pending file uploads
|
||||
}
|
||||
|
||||
export type UpdateEquipmentData = Partial<CreateEquipmentData>;
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CreateRMAData {
|
||||
statusId: string;
|
||||
requiresApproval?: boolean;
|
||||
assignedToId?: string;
|
||||
tempId?: string; // For pending file uploads
|
||||
}
|
||||
|
||||
export interface UpdateRMAData {
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CreateTaskData {
|
||||
priorityId?: string;
|
||||
deadline?: string;
|
||||
assigneeIds?: string[];
|
||||
tempId?: string; // For pending file uploads
|
||||
}
|
||||
|
||||
export type UpdateTaskData = Partial<CreateTaskData>;
|
||||
|
||||
138
frontend/src/services/upload.api.ts
Normal file
138
frontend/src/services/upload.api.ts
Normal 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';
|
||||
}
|
||||
43
frontend/src/services/zakazky.api.ts
Normal file
43
frontend/src/services/zakazky.api.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user