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';
|
||||
|
||||
Reference in New Issue
Block a user