Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
185
src/components/files/file-upload-zone.tsx
Normal file
185
src/components/files/file-upload-zone.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { Upload, X } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface UploadingFile {
|
||||
id: string;
|
||||
name: string;
|
||||
progress: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
clientId?: string;
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
export function FileUploadZone({
|
||||
entityType,
|
||||
entityId,
|
||||
clientId,
|
||||
onUploadComplete,
|
||||
}: FileUploadZoneProps) {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [uploading, setUploading] = useState<UploadingFile[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uploadFiles = useCallback(
|
||||
async (fileList: FileList) => {
|
||||
const newUploads: UploadingFile[] = Array.from(fileList).map((f) => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: f.name,
|
||||
progress: 0,
|
||||
}));
|
||||
setUploading((prev) => [...prev, ...newUploads]);
|
||||
|
||||
await Promise.all(
|
||||
Array.from(fileList).map(async (file, i) => {
|
||||
const uploadId = newUploads[i]!.id;
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('filename', file.name);
|
||||
if (clientId) formData.append('clientId', clientId);
|
||||
if (entityType) formData.append('entityType', entityType);
|
||||
if (entityId) formData.append('entityId', entityId);
|
||||
|
||||
setUploading((prev) =>
|
||||
prev.map((u) => (u.id === uploadId ? { ...u, progress: 50 } : u)),
|
||||
);
|
||||
|
||||
// Use fetch directly for FormData (apiFetch JSON-encodes body)
|
||||
const portId = (await import('@/stores/ui-store'))
|
||||
.useUIStore.getState().currentPortId;
|
||||
const headers = new Headers();
|
||||
if (portId) headers.set('X-Port-Id', portId);
|
||||
const uploadRes = await fetch('/api/v1/files/upload', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: formData,
|
||||
});
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
setUploading((prev) =>
|
||||
prev.map((u) => (u.id === uploadId ? { ...u, progress: 100 } : u)),
|
||||
);
|
||||
} catch {
|
||||
setUploading((prev) =>
|
||||
prev.map((u) =>
|
||||
u.id === uploadId ? { ...u, error: 'Upload failed' } : u,
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// Clear completed uploads after a moment
|
||||
setTimeout(() => {
|
||||
setUploading((prev) => prev.filter((u) => u.error));
|
||||
onUploadComplete?.();
|
||||
}, 1500);
|
||||
},
|
||||
[clientId, entityType, entityId, onUploadComplete],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
void uploadFiles(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[uploadFiles],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
void uploadFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}
|
||||
},
|
||||
[uploadFiles],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors cursor-pointer',
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50 hover:bg-muted/30',
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground mb-2" />
|
||||
<p className="text-sm font-medium">Drop files here or click to upload</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
PDF, Word, Excel, images up to 50MB
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleChange}
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.txt,.csv"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{uploading.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{uploading.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-3 text-sm">
|
||||
<span className="flex-1 truncate">{u.name}</span>
|
||||
{u.error ? (
|
||||
<span className="text-destructive text-xs">{u.error}</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 w-24 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${u.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{u.progress}%</span>
|
||||
</div>
|
||||
)}
|
||||
{u.error && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setUploading((prev) => prev.filter((x) => x.id !== u.id))
|
||||
}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user