Qassure-frontend/src/components/shared/FileUploadModal.tsx

778 lines
30 KiB
TypeScript

/**
* FileUploadModal
* Upload New File modal — drag & drop or click to select, up to 10 files
* Fields: entity_type, entity_id, category_id (select), tags, description
* Matches backend UploadFileSchema exactly
*/
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type DragEvent,
type ReactElement,
useEffect,
} from "react";
import {
X,
Upload,
FileText,
Image,
FileArchive,
AlertCircle,
Table,
CheckCircle2,
Loader2,
RefreshCw,
ChevronDown as ChevronDownIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import {
Modal,
PrimaryButton,
SecondaryButton,
FormField,
FormSelect,
FormTextArea,
FormTagInput,
} from "@/components/shared";
import { useForm, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Input } from "@/components/ui/input";
import fileAttachmentService, {
type CategoriesFilterOptions,
type StorageQuota,
} from "@/services/file-attachment-service";
// ─────────────────────────────────────────────────────────────────────────────
// Constants from backend blocked extensions
// ─────────────────────────────────────────────────────────────────────────────
const BLOCKED_EXTENSIONS = [
".exe",".bat",".cmd",".sh",".ps1",".msi",".dll",".com",".scr",".vbs",".js",
];
const MAX_FILES = 10;
const MAX_SIZE_MB = 50; // UI shows 50 MB as the soft warning (backend: 100 MB)
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function getExt(name: string) {
return name.slice(((name.lastIndexOf(".") - 1) >>> 0) + 1).toLowerCase();
}
// function isBlocked(name: string) {
// return BLOCKED_EXTENSIONS.includes(getExt(name) ? `.${getExt(name)}` : "");
// }
function formatBytes(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]}`;
}
function generateUUID(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
// Fallback for non-secure contexts or older browsers
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
function getFileIcon(mime: string, name: string): ReactElement {
if (mime.startsWith("image/")) return <Image className="w-4 h-4 text-emerald-500" />;
if (mime === "application/pdf") return <FileText className="w-4 h-4 text-red-500" />;
if (mime.includes("spreadsheet") || name.endsWith(".csv") || name.endsWith(".xlsx"))
return <Table className="w-4 h-4 text-green-600" />;
if (mime.includes("zip") || mime.includes("archive"))
return <FileArchive className="w-4 h-4 text-yellow-500" />;
return <FileText className="w-4 h-4 text-blue-500" />;
}
// ─────────────────────────────────────────────────────────────────────────────
// Per-file state
// ─────────────────────────────────────────────────────────────────────────────
type FileStatus = "idle" | "uploading" | "done" | "error" | "blocked";
interface FileEntry {
file: File;
id: string;
status: FileStatus;
progress: number;
error?: string;
}
// ─────────────────────────────────────────────────────────────────────────────
// Props
// ─────────────────────────────────────────────────────────────────────────────
export interface FileUploadModalProps {
isOpen: boolean;
onClose: () => void;
onUploaded?: () => void;
/** Pre-fill entity type (e.g. "document") */
defaultEntityType?: string;
/** Pre-fill entity id */
defaultEntityId?: string;
categories?: CategoriesFilterOptions["categories"];
/** For tenant-admin: show all fields; for tenant-user: same but with permission gating done by parent */
isTenantAdmin?: boolean;
}
// ─────────────────────────────────────────────────────────────────────────────
// ENTITY TYPE OPTIONS (common entity types in the platform)
// ─────────────────────────────────────────────────────────────────────────────
const ENTITY_TYPES = [
"document",
"capa",
"training",
"supplier",
"audit",
"project",
"workflow",
"tenant",
"user",
"other",
];
// ─────────────────────────────────────────────────────────────────────────────
// Validation Schema
// ─────────────────────────────────────────────────────────────────────────────
const fileUploadSchema = z.object({
entity_type: z.string().min(1, "Entity Type is required"),
entity_id: z.string().min(1, "Entity ID is required"),
category: z.string().optional(),
tags: z.array(z.string()).default([]),
description: z.string().max(500, "Description must be at most 500 characters").optional(),
files: z.array(z.any()).min(1, "At least one file is required"),
});
type FileUploadFormData = z.infer<typeof fileUploadSchema>;
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export const FileUploadModal = ({
isOpen,
onClose,
onUploaded,
defaultEntityType = "",
defaultEntityId = "",
categories = [],
isTenantAdmin = true,
}: FileUploadModalProps): ReactElement | null => {
// ── Form configuration ──
const {
control,
handleSubmit,
setValue,
watch,
reset,
formState: { errors },
} = useForm<FileUploadFormData>({
resolver: zodResolver(fileUploadSchema) as any,
defaultValues: {
entity_type: defaultEntityType,
entity_id: defaultEntityId,
category: "",
tags: [],
description: "",
files: [],
},
});
// Watch form values
const entityId = watch("entity_id");
const categoryInput = watch("category");
// ── Local UI state ──
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadSuccess, setUploadSuccess] = useState(false);
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
const [categorySearch, setCategorySearch] = useState("");
// Ref for the hidden file input
const inputRef = useRef<HTMLInputElement>(null);
const categoryDropdownRef = useRef<HTMLDivElement>(null);
// File entries with upload progress (internal state kept separate from zod files array for progress tracking)
const [fileEntries, setFileEntries] = useState<FileEntry[]>([]);
// Storage quota settings fetched dynamically from backend
const [quota, setQuota] = useState<StorageQuota | null>(null);
useEffect(() => {
if (isOpen) {
fileAttachmentService.getQuota()
.then((res) => {
if (res?.data) {
setQuota(res.data);
}
})
.catch((err) => {
console.error("Failed to load quota settings in FileUploadModal:", err);
});
}
}, [isOpen]);
// ── Auto-generate Entity ID ──
useEffect(() => {
if (isOpen && !entityId && !defaultEntityId) {
setValue("entity_id", generateUUID());
}
}, [isOpen, defaultEntityId, entityId, setValue]);
// ── Close dropdown on click outside ──
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (categoryDropdownRef.current && !categoryDropdownRef.current.contains(event.target as Node)) {
setShowCategoryDropdown(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
// ── Reset on close ──
const handleClose = () => {
if (isUploading) return;
reset({
entity_type: defaultEntityType,
entity_id: defaultEntityId || (isOpen ? generateUUID() : ""),
category: "",
tags: [],
description: "",
files: [],
});
setFileEntries([]);
setUploadError(null);
setUploadSuccess(false);
onClose();
};
// ── Add files ──
const addFiles = useCallback((incoming: File[]) => {
const activeBlocked = quota?.blocked_extensions || BLOCKED_EXTENSIONS;
const activeMaxSizeBytes = quota?.max_file_size_bytes || (MAX_SIZE_MB * 1024 * 1024);
const newEntries: FileEntry[] = incoming
.slice(0, MAX_FILES)
.map((file) => {
const ext = getExt(file.name);
const fileExt = ext ? `.${ext}` : "";
const isBlockedType = activeBlocked.some(
(blockedExt) => blockedExt.toLowerCase() === fileExt.toLowerCase()
);
const isTooLarge = file.size > activeMaxSizeBytes;
let status: FileStatus = "idle";
let error: string | undefined;
if (isBlockedType) {
status = "blocked";
error = "Blocked file type";
} else if (isTooLarge) {
status = "blocked";
error = `Exceeds limit of ${formatBytes(activeMaxSizeBytes)}`;
}
return {
file,
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
status,
progress: 0,
error,
};
});
setFileEntries((prev) => {
const combined = [...prev, ...newEntries];
const limited = combined.slice(0, MAX_FILES);
// Sync with react-hook-form
setValue("files", limited.map(e => e.file), { shouldValidate: true });
return limited;
});
}, [setValue, quota]);
const onDrop = useCallback(
(e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragging(false);
const dropped = Array.from(e.dataTransfer.files);
addFiles(dropped);
},
[addFiles]
);
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files) addFiles(Array.from(e.target.files));
e.target.value = "";
};
const removeFile = (id: string) => {
setFileEntries((prev) => {
const filtered = prev.filter((f) => f.id !== id);
// Sync with react-hook-form
setValue("files", filtered.map(e => e.file), { shouldValidate: true });
return filtered;
});
};
// ── Upload ──
const handleUpload = async (data: FileUploadFormData) => {
const validFiles = fileEntries.filter((f) => f.status !== "blocked");
if (validFiles.length === 0) { setUploadError("No valid files to upload"); return; }
setIsUploading(true);
setUploadError(null);
// Mark all valid as uploading
setFileEntries((prev) =>
prev.map((f) => (f.status === "idle" ? { ...f, status: "uploading", progress: 10 } : f))
);
try {
// Find matching category ID if it exists in the list
const matchedCategory = categories.find(
(c) => c.category.toLowerCase() === (data.category || "").toLowerCase()
);
if (validFiles.length === 1) {
// Single upload
await fileAttachmentService.upload({
files: [validFiles[0].file],
entity_type: data.entity_type,
entity_id: data.entity_id,
category: matchedCategory ? matchedCategory.category : data.category,
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
description: data.description?.trim() || undefined,
tags: data.tags?.length ? data.tags : undefined,
});
setFileEntries((prev) =>
prev.map((f) =>
f.id === validFiles[0].id ? { ...f, status: "done", progress: 100 } : f
)
);
} else {
// Multiple upload
const result = await fileAttachmentService.uploadMultiple(
{
files: validFiles.map((f) => f.file),
entity_type: data.entity_type,
entity_id: data.entity_id,
category: matchedCategory ? matchedCategory.category : data.category,
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
description: data.description?.trim() || undefined,
tags: data.tags?.length ? data.tags : undefined,
},
(_, percent) => {
setFileEntries((prev) =>
prev.map((f) =>
f.status === "uploading" ? { ...f, progress: Math.max(f.progress, percent) } : f
)
);
}
);
setFileEntries((prev) =>
prev.map((f) => {
const errEntry = result.data.errors.find((e) => e.file === f.file.name);
if (errEntry) return { ...f, status: "error", error: errEntry.error, progress: 0 };
if (f.status === "uploading") return { ...f, status: "done", progress: 100 };
return f;
})
);
}
setUploadSuccess(true);
onUploaded?.();
} catch (err: any) {
const msg = err?.response?.data?.error?.message || err?.message || "Upload failed";
setUploadError(msg);
setFileEntries((prev) =>
prev.map((f) =>
f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f
)
);
} finally {
setIsUploading(false);
}
};
if (!isOpen) return null;
const validCount = fileEntries.filter((f) => f.status !== "blocked").length;
const doneCount = fileEntries.filter((f) => f.status === "done").length;
const footer = (
<>
<SecondaryButton
onClick={handleClose}
disabled={isUploading}
>
Cancel
</SecondaryButton>
<PrimaryButton
onClick={handleSubmit(handleUpload as any)}
disabled={isUploading}
className="flex items-center gap-2"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</PrimaryButton>
</>
);
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
title="Upload New File"
description="Attach files via File Attachment Service"
maxWidth="md"
footer={footer}
>
<div className="px-6 py-5 space-y-5">
{/* Drop Zone */}
<div>
<p className="text-[13px] font-medium text-[#0e1b2a] mb-2">Attach Files <span className="text-[#e02424]">*</span></p>
{fileEntries.length === 0 ? (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
onClick={() => inputRef.current?.click()}
className={cn(
"border-2 border-dashed rounded-xl flex flex-col items-center justify-center gap-3 py-8 cursor-pointer transition-all",
errors.files ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging
? "border-[#084cc8] bg-[#084cc8]/5"
: "border-[rgba(0,0,0,0.12)] hover:border-[#084cc8]/50 hover:bg-gray-50"
)}
>
<div className="w-10 h-10 rounded-full bg-[#084cc8]/10 flex items-center justify-center">
<Upload className="w-5 h-5 text-[#084cc8]" />
</div>
<div className="text-center text-gray-800">
<p className="text-sm font-medium text-[#0e1b2a]">Click to upload or drag and drop</p>
<p className="text-xs text-[#9aa6b2] mt-0.5">
Attach supporting source files via File Attachment Service
</p>
<p className="text-xs text-[#9aa6b2]">
Allowed formats up to {quota ? formatBytes(quota.max_file_size_bytes) : `${MAX_SIZE_MB}MB`}
</p>
</div>
</div>
) : (
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={cn(
"border-2 border-dashed rounded-xl transition-all",
errors.files ? "border-[#ef4444] bg-[#ef4444]/5" : isDragging ? "border-[#084cc8] bg-[#084cc8]/5" : "border-[rgba(0,0,0,0.08)]"
)}
>
{/* Add files button */}
<div
onClick={() => inputRef.current?.click()}
className="flex items-center justify-center gap-2 py-3 cursor-pointer border-b border-[rgba(0,0,0,0.06)] hover:bg-gray-50 transition-all rounded-t-xl"
>
<div className="w-7 h-7 rounded-full bg-[#084cc8] flex items-center justify-center text-white">
<Upload className="w-3.5 h-3.5" />
</div>
<span className="text-sm font-semibold text-[#084cc8]">Add Files</span>
</div>
{/* File list */}
<div className="divide-y divide-[rgba(0,0,0,0.06)]">
{fileEntries.map((entry) => (
<div key={entry.id} className="flex items-center gap-3 px-4 py-2.5">
<div className="shrink-0">{getFileIcon(entry.file.type, entry.file.name)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-[#0e1b2a] truncate max-w-[180px]">
{entry.file.name}
</span>
<span className="text-xs text-[#9aa6b2] shrink-0">
{formatBytes(entry.file.size)}
</span>
{entry.status === "uploading" && (
<span className="text-xs font-semibold text-[#084cc8] shrink-0">
{entry.progress}%
</span>
)}
{entry.status === "done" && (
<span className="text-xs font-semibold text-emerald-600 shrink-0 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" /> Complete
</span>
)}
{(entry.status === "blocked" || entry.status === "error") && (
<span className="text-xs font-semibold text-red-500 shrink-0 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {entry.error}
</span>
)}
</div>
{entry.status === "uploading" && (
<div className="mt-1.5 h-1 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-[#084cc8] rounded-full transition-all duration-300"
style={{ width: `${entry.progress}%` }}
/>
</div>
)}
{entry.status === "done" && (
<div className="mt-1.5 h-1 bg-emerald-100 rounded-full overflow-hidden">
<div className="h-full bg-emerald-500 rounded-full w-full" />
</div>
)}
{(entry.status === "blocked" || entry.status === "error") && (
<div className="mt-1.5 h-1 bg-red-100 rounded-full" />
)}
</div>
{entry.status !== "uploading" && entry.status !== "done" && (
<button
onClick={() => removeFile(entry.id)}
className="shrink-0 p-1 rounded hover:bg-gray-100 text-[#9aa6b2]"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
))}
</div>
</div>
)}
{errors.files && (
<p className="text-xs text-[#ef4444] mt-1.5 flex items-center gap-1">
<AlertCircle className="w-3 h-3" /> {errors.files.message}
</p>
)}
<p className="text-[11px] text-[#9aa6b2] mt-2">Up to {MAX_FILES} files allowed</p>
{uploadSuccess && (
<div className="flex items-center gap-1.5 mt-2 text-emerald-600">
<CheckCircle2 className="w-3.5 h-3.5" />
<span className="text-xs font-semibold">Files upload successfully.</span>
</div>
)}
</div>
<input
ref={inputRef}
type="file"
multiple
className="hidden"
onChange={onInputChange}
/>
{/* Fields: Entity Type + Entity ID */}
<div className="grid grid-cols-2 gap-4 pb-0">
<Controller
name="entity_type"
control={control}
render={({ field }) => (
<FormSelect
label="Entity Type"
required
value={field.value}
onValueChange={field.onChange}
disabled={!!defaultEntityType || isUploading}
options={ENTITY_TYPES.map((t) => ({
value: t,
label: t.charAt(0).toUpperCase() + t.slice(1),
}))}
placeholder="Select type"
error={errors.entity_type?.message}
/>
)}
/>
<div className="relative">
<Controller
name="entity_id"
control={control}
render={({ field }) => (
<FormField
label="Entity ID"
required
{...field}
disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204"
error={errors.entity_id?.message}
/>
)}
/>
{!defaultEntityId && !isUploading && isTenantAdmin && (
<button
onClick={() => setValue("entity_id", generateUUID(), { shouldValidate: true })}
title="Regenerate ID"
className="absolute right-3 top-[38px] p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
type="button"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Category Name (Editable Combobox) */}
<div className="relative pb-4" ref={categoryDropdownRef}>
<label className="text-[13px] font-medium text-[#0e1b2a] block mb-1.5">
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
</label>
<div className="relative">
<Controller
name="category"
control={control}
render={({ field }) => (
<Input
type="text"
{...field}
onChange={(e) => {
field.onChange(e.target.value);
setCategorySearch(e.target.value);
setShowCategoryDropdown(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (categorySearch.trim()) {
field.onChange(categorySearch.trim());
setShowCategoryDropdown(false);
}
}
}}
onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading}
placeholder="Type or select a category..."
className="pr-10"
/>
)}
/>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5 text-[#9aa6b2]">
{categoryInput && (
<button
type="button"
onClick={() => { setValue("category", ""); setCategorySearch(""); }}
className="p-1.5 hover:text-red-500 transition-colors"
title="Clear input"
>
<X className="w-3.5 h-3.5" />
</button>
)}
<button
type="button"
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
className="p-1.5 hover:text-[#0e1b2a] transition-colors"
title="Toggle dropdown"
>
<ChevronDownIcon className={cn("w-4 h-4 transition-transform duration-200", showCategoryDropdown && "rotate-180")} />
</button>
</div>
</div>
{errors.category && (
<p className="text-xs text-[#ef4444] mt-1">{errors.category.message}</p>
)}
{showCategoryDropdown && !isUploading && (
<div className="absolute top-[calc(100%-12px)] left-0 right-0 z-[210] bg-white border border-[rgba(0,0,0,0.12)] shadow-xl rounded-lg py-1.5 mt-1 max-h-[220px] overflow-y-auto custom-scrollbar animate-in fade-in zoom-in-95 duration-200">
{categories.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase())).length > 0 ? (
categories
.filter(c => c.category.toLowerCase().includes(categorySearch.toLowerCase()))
.map((cat) => (
<button
key={cat.category_id ?? cat.category}
type="button"
onClick={() => {
setValue("category", cat.category, { shouldValidate: true });
setShowCategoryDropdown(false);
}}
className={cn(
"w-full text-left px-4 py-2.5 text-sm transition-colors flex items-center justify-between",
categoryInput === cat.category
? "bg-[#084cc8]/5 text-[#084cc8] font-medium"
: "text-[#475569] hover:bg-gray-50 hover:text-[#0e1b2a]"
)}
>
{cat.category}
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
</button>
))
) : (
<div className="px-4 py-3 text-center">
<p className="text-sm text-[#0e1b2a] font-medium">
{categorySearch ? `Use "${categorySearch}"` : "No categories found"}
</p>
<p className="text-[11px] text-[#9aa6b2] mt-0.5">
{categorySearch ? "Press Enter to create this as a new category" : "Start typing to create a new one"}
</p>
</div>
)}
</div>
)}
</div>
{/* Tags */}
<div className="pb-4">
<Controller
name="tags"
control={control}
render={({ field }) => (
<FormTagInput
label="Tags"
value={field.value || []}
onChange={field.onChange}
error={errors.tags?.message}
placeholder="Add a tag..."
/>
)}
/>
</div>
{/* Description */}
<Controller
name="description"
control={control}
render={({ field }) => (
<FormTextArea
label="Description"
{...field}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="Description of this file..."
error={errors.description?.message}
/>
)}
/>
{/* Upload error */}
{uploadError && (
<div className="flex items-center gap-2 rounded-lg bg-red-50 border border-red-100 px-3 py-2 text-sm text-red-600">
<AlertCircle className="w-4 h-4 shrink-0" />
<span>{uploadError}</span>
</div>
)}
</div>
</Modal>
);
};
export default FileUploadModal;