feat: implement file attachment management system including listing, viewing, uploading, and secure link sharing

This commit is contained in:
Yashwin 2026-04-06 19:09:17 +05:30
parent 26566f6620
commit 873d5af758
8 changed files with 2561 additions and 1 deletions

View File

@ -16,6 +16,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Bell, Bell,
Paperclip,
} from "lucide-react"; } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -119,6 +120,15 @@ const tenantAdminPlatformMenu: MenuItem[] = [
], ],
requiredPermission: { resource: "document" }, requiredPermission: { resource: "document" },
}, },
{
icon: Paperclip,
label: "File Attachment Services",
isGroup: true,
children: [
{ label: "Files List", path: "/tenant/files", requiredPermission: { resource: "files" } },
],
requiredPermission: { resource: "files" },
},
{ icon: Package, label: "Modules", path: "/tenant/modules" }, { icon: Package, label: "Modules", path: "/tenant/modules" },
]; ];

View File

@ -0,0 +1,234 @@
import React, { useState } from "react";
import {
Share2,
Calendar,
Download,
Copy,
Check,
Loader2,
Clock,
Shield,
ExternalLink,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Modal } from "./Modal";
import fileAttachmentService, { type FileAttachment } from "@/services/file-attachment-service";
interface FileShareModalProps {
isOpen: boolean;
onClose: () => void;
file: FileAttachment;
}
export const FileShareModal: React.FC<FileShareModalProps> = ({
isOpen,
onClose,
file,
}) => {
const [expiresInHours, setExpiresInHours] = useState<number>(24);
const [maxDownloads, setMaxDownloads] = useState<number | "">("");
const [permissions, setPermissions] = useState<"view" | "download">("download");
const [isSharing, setIsSharing] = useState(false);
const [shareData, setShareData] = useState<{ url: string; token: string } | null>(null);
const [copied, setCopied] = useState(false);
const handleCreateShare = async () => {
setIsSharing(true);
try {
const baseUrl = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api/v1";
const res = await fileAttachmentService.createShare(file.id, {
share_type: "link",
permissions,
expires_in_hours: expiresInHours || undefined,
max_downloads: maxDownloads === "" ? null : Number(maxDownloads),
});
const fullUrl = `${baseUrl}/files/shared/${res.data.share_token}`;
setShareData({ url: fullUrl, token: res.data.share_token });
} catch (error) {
console.error("Failed to share:", error);
} finally {
setIsSharing(false);
}
};
const copyToClipboard = async () => {
if (!shareData) return;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(shareData.url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} else {
throw new Error("Clipboard API unavailable");
}
} catch (err) {
const textArea = document.createElement("textarea");
textArea.value = shareData.url;
textArea.style.position = "fixed";
textArea.style.left = "-9999px";
textArea.style.top = "0";
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand("copy");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (copyErr) {
console.error("Fallback copy failed:", copyErr);
}
document.body.removeChild(textArea);
}
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Share File"
description={file.original_name}
maxWidth="md"
>
<div className="p-6 space-y-6">
{!shareData ? (
<>
{/* Expiry */}
<div className="space-y-2">
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
<Clock className="w-3 h-3" />
Expires In
</label>
<div className="grid grid-cols-4 gap-2">
{[1, 24, 72, 168].map((h) => (
<button
key={h}
onClick={() => setExpiresInHours(h)}
className={cn(
"py-2 text-xs font-medium rounded-lg border transition-all",
expiresInHours === h
? "bg-[#084cc8] border-[#084cc8] text-white shadow-sm"
: "border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30 hover:bg-gray-50"
)}
>
{h === 1 ? "1 Hr" : h === 24 ? "1 Day" : h === 72 ? "3 Days" : "7 Days"}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* Max Downloads */}
<div className="space-y-2">
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
<Download className="w-3 h-3" />
Max Downloads
</label>
<input
type="number"
value={maxDownloads}
onChange={(e) => setMaxDownloads(e.target.value === "" ? "" : Number(e.target.value))}
placeholder="Unlimited"
className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm focus:outline-none focus:ring-1 focus:ring-[#084cc8] focus:border-[#084cc8]"
/>
</div>
{/* Permissions */}
<div className="space-y-2">
<label className="text-xs font-semibold text-[#9aa6b2] uppercase tracking-wider flex items-center gap-2">
<Shield className="w-3 h-3" />
Permission
</label>
<select
value={permissions}
onChange={(e) => setPermissions(e.target.value as any)}
className="w-full h-10 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm focus:outline-none focus:ring-1 focus:ring-[#084cc8] focus:border-[#084cc8]"
>
<option value="view">View Only</option>
<option value="download">Download</option>
</select>
</div>
</div>
<button
onClick={handleCreateShare}
disabled={isSharing}
className="w-full h-11 bg-[#084cc8] hover:bg-[#0640aa] text-white rounded-xl text-sm font-bold transition-all shadow-lg shadow-[#084cc8]/20 flex items-center justify-center gap-2"
>
{isSharing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Creating Link...
</>
) : (
<>
<Share2 className="w-4 h-4" />
Generate Secure Link
</>
)}
</button>
</>
) : (
<div className="space-y-4 animate-in fade-in slide-in-from-bottom-2">
<div className="p-4 bg-emerald-50 border border-emerald-100 rounded-xl">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
<Check className="w-4 h-4 text-emerald-600" />
</div>
<p className="text-sm font-bold text-emerald-800">Share Link Generated</p>
</div>
<p className="text-xs text-emerald-600/80 leading-relaxed">
Anyone with this link can {permissions === 'download' ? 'download' : 'view'} the file
until {new Date(Date.now() + expiresInHours * 3600000).toLocaleString()}.
</p>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold text-[#9aa6b2] uppercase tracking-widest">Share URL</label>
<div className="flex items-center gap-2">
<div className="flex-1 h-11 bg-gray-50 border border-[rgba(0,0,0,0.08)] rounded-xl px-3 flex items-center overflow-hidden">
<code className="text-[11px] text-[#084cc8] truncate font-mono">
{shareData.url}
</code>
</div>
<button
onClick={copyToClipboard}
className={cn(
"w-11 h-11 rounded-xl flex items-center justify-center transition-all",
copied ? "bg-emerald-500 text-white shadow-emerald-500/30" : "bg-[#084cc8] text-white shadow-[#084cc8]/30 hover:scale-105"
)}
>
{copied ? <Check className="w-5 h-5" /> : <Copy className="w-5 h-5" />}
</button>
</div>
</div>
<div className="pt-2 flex gap-2">
<button
onClick={() => setShareData(null)}
className="flex-1 h-10 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
>
Create Another
</button>
<button
onClick={() => window.open(shareData.url, '_blank')}
className="h-10 px-4 flex items-center gap-2 border border-[rgba(0,0,0,0.1)] hover:bg-gray-50 rounded-xl text-xs font-bold text-[#475569] transition-all"
>
<ExternalLink className="w-3.5 h-3.5" />
Test Link
</button>
</div>
</div>
)}
<div className="pt-4 mt-6 border-t border-[rgba(0,0,0,0.06)] flex items-center gap-2">
<Calendar className="w-3.5 h-3.5 text-[#9aa6b2]" />
<p className="text-[10px] text-[#9aa6b2] leading-tight">
Links are automatically revoked after expiration or reaching max downloads for security.
</p>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1,675 @@
/**
* 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 { createPortal } from "react-dom";
import {
X,
Upload,
FileText,
Image,
FileArchive,
AlertCircle,
Table,
CheckCircle2,
Loader2,
RefreshCw,
ChevronDown as ChevronDownIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import fileAttachmentService, {
type CategoriesFilterOptions,
} 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",
];
// ─────────────────────────────────────────────────────────────────────────────
// Component
// ─────────────────────────────────────────────────────────────────────────────
export const FileUploadModal = ({
isOpen,
onClose,
onUploaded,
defaultEntityType = "",
defaultEntityId = "",
categories = [],
isTenantAdmin = true,
}: FileUploadModalProps): ReactElement | null => {
// ── Form state ──
const [entityType, setEntityType] = useState(defaultEntityType);
const [entityId, setEntityId] = useState(defaultEntityId);
const [categoryInput, setCategoryInput] = useState("");
const [tagInput, setTagInput] = useState("");
const [tags, setTags] = useState<string[]>([]);
const [description, setDescription] = useState("");
// ── File state ──
const [files, setFiles] = useState<FileEntry[]>([]);
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("");
const categoryDropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// ── Auto-generate Entity ID ──
useEffect(() => {
if (isOpen && !entityId && !defaultEntityId) {
setEntityId(generateUUID());
}
}, [isOpen, defaultEntityId]);
// ── 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;
setFiles([]);
setEntityType(defaultEntityType);
setEntityId(defaultEntityId);
setCategoryInput("");
setTags([]);
setTagInput("");
setDescription("");
setUploadError(null);
setUploadSuccess(false);
onClose();
};
// ── Add files ──
const addFiles = useCallback((incoming: File[]) => {
const newEntries: FileEntry[] = incoming
.slice(0, MAX_FILES)
.map((file) => ({
file,
id: `${file.name}-${file.size}-${Date.now()}-${Math.random()}`,
status: isBlocked(file.name) ? "blocked" : "idle",
progress: 0,
error: isBlocked(file.name) ? "Blocked file type" : undefined,
}));
setFiles((prev) => {
const combined = [...prev, ...newEntries];
return combined.slice(0, MAX_FILES);
});
}, []);
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) => {
setFiles((prev) => prev.filter((f) => f.id !== id));
};
// ── Tags ──
const addTag = () => {
const t = tagInput.trim();
if (t && !tags.includes(t)) setTags((prev) => [...prev, t]);
setTagInput("");
};
const removeTag = (t: string) => setTags((prev) => prev.filter((x) => x !== t));
// ── Upload ──
const handleUpload = async () => {
if (!entityType.trim()) { setUploadError("Entity Type is required"); return; }
if (!entityId.trim()) { setUploadError("Entity ID is required"); return; }
const validFiles = files.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
setFiles((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() === categoryInput.toLowerCase()
);
if (validFiles.length === 1) {
// Single upload
await fileAttachmentService.upload({
files: [validFiles[0].file],
entity_type: entityType,
entity_id: entityId,
category: matchedCategory ? matchedCategory.category : categoryInput,
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
description: description.trim() || undefined,
tags: tags.length ? tags : undefined,
});
setFiles((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: entityType,
entity_id: entityId,
category: matchedCategory ? matchedCategory.category : categoryInput,
category_id: matchedCategory ? matchedCategory.category_id || undefined : undefined,
description: description.trim() || undefined,
tags: tags.length ? tags : undefined,
},
(_, percent) => {
setFiles((prev) =>
prev.map((f) =>
f.status === "uploading" ? { ...f, progress: Math.max(f.progress, percent) } : f
)
);
}
);
setFiles((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);
setFiles((prev) =>
prev.map((f) =>
f.status === "uploading" ? { ...f, status: "error", error: msg, progress: 0 } : f
)
);
} finally {
setIsUploading(false);
}
};
if (!isOpen) return null;
const validCount = files.filter((f) => f.status !== "blocked").length;
const doneCount = files.filter((f) => f.status === "done").length;
const content = (
<div className="fixed inset-0 z-[200] flex items-center justify-center bg-[rgba(15,23,42,0.55)] backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-[0px_25px_50px_-12px_rgba(0,0,0,0.25)] w-full max-w-[500px] max-h-[92vh] flex flex-col">
{/* Header */}
<div className="flex items-start justify-between px-6 pt-6 pb-4 border-b border-[rgba(0,0,0,0.08)] shrink-0">
<div>
<h2 className="text-[17px] font-semibold text-[#0e1b2a]">Upload New File</h2>
<p className="text-sm text-[#9aa6b2] mt-0.5">Attach files via File Attachment Service</p>
</div>
<button
onClick={handleClose}
disabled={isUploading}
className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors text-[#6b7280] disabled:opacity-40"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Scrollable body */}
<div className="flex-1 min-h-0 overflow-y-auto px-6 py-5 space-y-5">
{/* Drop Zone */}
<div>
<p className="text-xs font-semibold text-[#0e1b2a] mb-2 uppercase tracking-wide">Attach Files</p>
{files.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",
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">
<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]">PDF, DOCX, XLSX up to {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",
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">
<Upload className="w-3.5 h-3.5 text-white" />
</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)]">
{files.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>
)}
<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">
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Entity Type <span className="text-red-500">*</span>
</label>
<select
value={entityType}
onChange={(e) => setEntityType(e.target.value)}
disabled={!!defaultEntityType || isUploading}
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-3 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
>
<option value="">Select type</option>
{ENTITY_TYPES.map((t) => (
<option key={t} value={t}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</option>
))}
</select>
</div>
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Entity ID <span className="text-red-500">*</span>
</label>
<div className="relative group/id">
<input
type="text"
value={entityId}
onChange={(e) => setEntityId(e.target.value)}
disabled={!!defaultEntityId || isUploading}
placeholder="e.g. PRJ-1204 (UUID)"
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8] disabled:bg-gray-50 disabled:text-[#9aa6b2]"
/>
{!defaultEntityId && !isUploading && isTenantAdmin && (
<button
onClick={() => setEntityId(generateUUID())}
title="Regenerate ID"
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[#9aa6b2] hover:text-[#084cc8] transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
</div>
{/* Category Name (Editable Combobox) */}
<div className="relative" ref={categoryDropdownRef}>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">
Category Name <span className="text-[#9aa6b2] font-normal">(Select or Enter New)</span>
</label>
<div className="relative">
<input
type="text"
value={categoryInput}
onChange={(e) => {
setCategoryInput(e.target.value);
setCategorySearch(e.target.value);
setShowCategoryDropdown(true);
}}
onFocus={() => setShowCategoryDropdown(true)}
disabled={isUploading}
placeholder="Type or select a category..."
className="w-full h-9 border border-[rgba(0,0,0,0.12)] rounded-lg pl-3 pr-9 text-sm text-[#0e1b2a] bg-white focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-[#9aa6b2]">
{categoryInput && (
<button
onClick={() => { setCategoryInput(""); setCategorySearch(""); }}
className="p-1 hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
)}
<ChevronDownIcon className="w-3.5 h-3.5" />
</div>
</div>
{showCategoryDropdown && !isUploading && (
<div className="absolute top-full left-0 right-0 z-[210] mt-1 bg-white border border-[rgba(0,0,0,0.1)] shadow-xl rounded-xl py-1 max-h-[200px] overflow-y-auto custom-scrollbar">
{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}
onClick={() => {
setCategoryInput(cat.category);
setShowCategoryDropdown(false);
}}
className="w-full text-left px-4 py-2 text-sm hover:bg-[#084cc8]/5 text-[#475569] hover:text-[#084cc8] transition-colors flex items-center justify-between"
>
{cat.category}
{categoryInput === cat.category && <CheckCircle2 className="w-3.5 h-3.5" />}
</button>
))
) : (
<div className="px-4 py-2 text-xs text-[#9aa6b2] italic">
{categorySearch ? `Press enter to use "${categorySearch}"` : "No categories found"}
</div>
)}
</div>
)}
</div>
{/* Tags */}
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Tags</label>
<div className="flex flex-wrap items-center gap-1.5 min-h-9 border border-[rgba(0,0,0,0.12)] rounded-lg px-2 py-1.5 focus-within:ring-2 focus-within:ring-[#084cc8]/20 focus-within:border-[#084cc8]">
{tags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 bg-gray-100 text-[#0e1b2a] text-xs font-medium rounded-md px-2 py-0.5"
>
{tag}
<button
onClick={() => removeTag(tag)}
disabled={isUploading}
className="text-[#9aa6b2] hover:text-red-500"
>
<X className="w-3 h-3" />
</button>
</span>
))}
<input
type="text"
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag();
}
}}
placeholder={tags.length === 0 ? "Add a tag..." : ""}
disabled={isUploading}
className="flex-1 min-w-[80px] text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
/>
</div>
</div>
{/* Description */}
<div>
<label className="text-xs font-semibold text-[#0e1b2a] block mb-1.5">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isUploading}
maxLength={500}
rows={3}
placeholder="Description of this file..."
className="w-full border border-[rgba(0,0,0,0.12)] rounded-lg px-3 py-2 text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] resize-none focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
/>
</div>
{/* 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>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[rgba(0,0,0,0.08)] shrink-0">
<button
onClick={handleClose}
disabled={isUploading}
className="h-9 px-4 border border-[rgba(0,0,0,0.12)] rounded-lg text-sm font-medium text-[#0e1b2a] hover:bg-gray-50 transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
onClick={handleUpload}
disabled={isUploading || files.length === 0 || validCount === 0}
className="h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold flex items-center gap-2 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Uploading ({doneCount}/{validCount})
</>
) : (
<>
<Upload className="w-4 h-4" />
Upload {validCount > 0 ? `(${validCount})` : ""}
</>
)}
</button>
</div>
</div>
</div>
);
return createPortal(content, document.body);
};
export default FileUploadModal;

View File

@ -35,3 +35,6 @@ export { SupplierContactsModal } from './SupplierContactsModal';
export { SupplierScorecardsModal } from './SupplierScorecardsModal'; export { SupplierScorecardsModal } from './SupplierScorecardsModal';
export { FormTextArea } from './FormTextArea'; export { FormTextArea } from './FormTextArea';
export { RichTextEditor } from './RichTextEditor'; export { RichTextEditor } from './RichTextEditor';
export { FileUploadModal } from './FileUploadModal';
export type { FileUploadModalProps } from './FileUploadModal';
export { FileShareModal } from './FileShareModal';

View File

@ -0,0 +1,615 @@
/**
* FileView File Attachment Services File Detail
*
* Matches the design image (second screen):
* - Left: file preview (iframe for PDF/HTML, image for images, fallback for others)
* - Right panel: File Details (description, category, tags, Properties, Metadata, Technical/Admin section)
* - Bottom: Version History tab + Access Log tab
* - Share button, Download button
* - Admin sees stored path, SHA-256 checksum, edit metadata
* - User only sees basic info
*/
import { useCallback, useEffect, useState, type ReactElement } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import {
Download,
Share2,
ChevronLeft,
FileText,
Copy,
X,
Check,
Loader2,
AlertCircle,
RefreshCw,
ZoomIn,
ZoomOut,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { FileShareModal } from "@/components/shared";
import { cn } from "@/lib/utils";
import fileAttachmentService, {
type FileAttachment,
} from "@/services/file-attachment-service";
import type { RootState } from "@/store/store";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function formatDate(value?: string | null): string {
if (!value) return "—";
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
}
function formatBytes(bytes: number): string {
if (!bytes) 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]}`;
}
const categoryColors: Record<string, string> = {
finance: "bg-purple-100 text-purple-700",
marketing: "bg-orange-100 text-orange-700",
hr: "bg-blue-100 text-blue-700",
data: "bg-teal-100 text-teal-700",
other: "bg-gray-100 text-gray-600",
};
function getCategoryStyle(cat?: string) {
return categoryColors[(cat || "").toLowerCase()] || "bg-gray-100 text-gray-600";
}
function copyToClipboard(text: string): void {
navigator.clipboard.writeText(text).catch(() => {});
}
// ─────────────────────────────────────────────────────────────────────────────
// Preview component
// ─────────────────────────────────────────────────────────────────────────────
function FilePreviewPanel({ file }: { file: FileAttachment }): ReactElement {
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState(false);
const [zoom, setZoom] = useState(1);
useEffect(() => {
setLoading(true);
setErr(false);
fileAttachmentService
.getPreviewUrl(file.id)
.then((url) => setPreviewUrl(url))
.catch(() => setErr(true))
.finally(() => setLoading(false));
return () => {
if (previewUrl) URL.revokeObjectURL(previewUrl);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [file.id]);
const isImage = file.mime_type?.startsWith("image/");
const isPdf = file.mime_type === "application/pdf";
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-[#084cc8] animate-spin" />
</div>
);
}
if (err || !previewUrl) {
return (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2]">
<FileText className="w-16 h-16 text-gray-200" />
<p className="text-sm">Preview not available</p>
<p className="text-xs">{file.mime_type}</p>
</div>
);
}
return (
<div className="flex-1 flex flex-col overflow-hidden relative">
{/* Zoom controls */}
{isImage && (
<div className="absolute top-3 right-3 z-10 flex gap-1">
<button
onClick={() => setZoom((z) => Math.max(0.5, z - 0.25))}
className="w-7 h-7 flex items-center justify-center bg-white/90 border border-[rgba(0,0,0,0.1)] rounded-md hover:bg-white shadow-sm"
>
<ZoomOut className="w-3.5 h-3.5 text-[#475569]" />
</button>
<button
onClick={() => setZoom((z) => Math.min(3, z + 0.25))}
className="w-7 h-7 flex items-center justify-center bg-white/90 border border-[rgba(0,0,0,0.1)] rounded-md hover:bg-white shadow-sm"
>
<ZoomIn className="w-3.5 h-3.5 text-[#475569]" />
</button>
<button
onClick={() => setZoom(1)}
className="w-7 h-7 flex items-center justify-center bg-white/90 border border-[rgba(0,0,0,0.1)] rounded-md hover:bg-white shadow-sm"
>
<RefreshCw className="w-3.5 h-3.5 text-[#475569]" />
</button>
</div>
)}
{isImage ? (
<div className="flex-1 overflow-auto flex items-center justify-center bg-gray-50 p-4">
<img
src={previewUrl}
alt={file.original_name}
style={{ transform: `scale(${zoom})`, transformOrigin: "center" }}
className="max-w-full rounded-lg shadow transition-transform duration-200"
/>
</div>
) : isPdf || file.mime_type?.startsWith("text/") ? (
<iframe
src={previewUrl}
title={file.original_name}
className="flex-1 w-full border-0"
/>
) : (
<div className="flex-1 flex flex-col items-center justify-center gap-3 text-[#9aa6b2] bg-gray-50">
<FileText className="w-16 h-16 text-gray-200" />
<p className="text-sm">No visual preview available</p>
<p className="text-xs text-[#c4cbd6]">{file.mime_type}</p>
</div>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Version Row
// ─────────────────────────────────────────────────────────────────────────────
function VersionRow({ ver, onDownload }: { ver: FileAttachment; onDownload: () => void }): ReactElement {
return (
<tr className="border-b border-[rgba(0,0,0,0.05)] hover:bg-gray-50/50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-[#0e1b2a]">v{ver.version}</span>
{ver.is_current_version && (
<span className="inline-flex items-center text-[10px] font-semibold bg-emerald-100 text-emerald-700 rounded px-1.5 py-0.5">
Current
</span>
)}
</div>
</td>
<td className="px-4 py-3 text-sm text-[#6b7280] whitespace-nowrap">{formatDate(ver.created_at)}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-100 flex items-center justify-center text-[10px] font-bold text-blue-700">
{(ver.uploaded_by_email || "U")[0].toUpperCase()}
</div>
<span className="text-sm text-[#0e1b2a]">
{ver.uploaded_by_email?.split("@")[0] || "Unknown"}
</span>
</div>
</td>
<td className="px-4 py-3 text-sm text-[#6b7280]">
{ver.file_size_formatted || formatBytes(ver.file_size)}
</td>
<td className="px-4 py-3">
<button
onClick={onDownload}
className="text-sm font-medium text-[#084cc8] hover:underline"
>
Download
</button>
</td>
</tr>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main Component
// ─────────────────────────────────────────────────────────────────────────────
const FileView = (): ReactElement => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const permissions = useSelector((state: RootState) => state.auth.permissions);
const isTenantAdmin = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "update" || p.action === "*")
);
// ── State ──
const [file, setFile] = useState<FileAttachment | null>(null);
const [versions, setVersions] = useState<FileAttachment[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editingMetadata, setEditingMetadata] = useState(false);
const [copiedChecksum, setCopiedChecksum] = useState(false);
const [copiedPath, setCopiedPath] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
// Metadata edit form
const [draftDescription, setDraftDescription] = useState("");
const [draftTags, setDraftTags] = useState<string[]>([]);
const [draftTagInput, setDraftTagInput] = useState("");
const [savingMeta, setSavingMeta] = useState(false);
// ── Load ──
const loadFile = useCallback(async () => {
if (!id) return;
setIsLoading(true);
setError(null);
try {
const [fileRes, versionsRes] = await Promise.all([
fileAttachmentService.getById(id),
fileAttachmentService.getVersionHistory(id),
]);
setFile(fileRes.data);
setVersions(versionsRes.data);
setDraftDescription(fileRes.data.description || "");
setDraftTags(fileRes.data.tags || []);
} catch (err: any) {
setError(err?.response?.data?.error?.message || "File not found");
} finally {
setIsLoading(false);
}
}, [id]);
useEffect(() => { void loadFile(); }, [loadFile]);
const handleDownload = () => {
if (file) fileAttachmentService.download(file.id, file.original_name).catch(() => {});
};
const handleSaveMetadata = async () => {
if (!file) return;
setSavingMeta(true);
try {
await fileAttachmentService.updateMetadata(file.id, {
description: draftDescription,
tags: draftTags,
});
await loadFile();
setEditingMetadata(false);
} catch {
// silence
} finally {
setSavingMeta(false);
}
};
const copyChecksum = () => {
if (file) { copyToClipboard(file.checksum); setCopiedChecksum(true); setTimeout(() => setCopiedChecksum(false), 1500); }
};
const copyPath = () => {
if (file) { copyToClipboard(file.file_path); setCopiedPath(true); setTimeout(() => setCopiedPath(false), 1500); }
};
if (isLoading) {
return (
<Layout currentPage="File Attachment Services">
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-[#084cc8] animate-spin" />
</div>
</Layout>
);
}
if (error || !file) {
return (
<Layout currentPage="File Attachment Services">
<div className="flex flex-col items-center justify-center h-64 gap-3">
<AlertCircle className="w-10 h-10 text-red-400" />
<p className="text-[#475569]">{error || "File not found"}</p>
<button
onClick={() => navigate("/tenant/files")}
className="text-sm text-[#084cc8] hover:underline font-medium flex items-center gap-1"
>
<ChevronLeft className="w-4 h-4" /> Back to Files
</button>
</div>
</Layout>
);
}
return (
<Layout
currentPage="File Attachment Services"
breadcrumbs={[
{ label: "File Attachment Services", path: "/tenant/files" },
{ label: "File List", path: "/tenant/files" },
{ label: file.original_name },
]}
>
{/* Top bar */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<button
onClick={() => navigate("/tenant/files")}
className="p-1.5 rounded-lg hover:bg-gray-100 text-[#6b7280] transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-md bg-red-50 border border-red-100 flex items-center justify-center">
<FileText className="w-4 h-4 text-red-500" />
</div>
<div>
<h1 className="text-lg font-semibold text-[#0e1b2a]">{file.original_name}</h1>
<p className="text-xs text-[#9aa6b2]">
{file.mime_type} · {file.file_size_formatted || formatBytes(file.file_size)} · Uploaded {formatDate(file.created_at)}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowShareModal(true)}
className="inline-flex items-center gap-2 h-9 px-3 border border-[rgba(0,0,0,0.1)] rounded-lg text-sm font-medium text-[#475569] hover:bg-gray-50 hover:border-[#084cc8]/30 transition-all font-semibold"
>
<Share2 className="w-3.5 h-3.5" />
Share
</button>
<button
onClick={handleDownload}
className="inline-flex items-center gap-2 h-9 px-4 bg-[#084cc8] hover:bg-[#0640aa] text-white rounded-lg text-sm font-semibold transition-colors"
>
<Download className="w-3.5 h-3.5" />
Download
</button>
</div>
</div>
{/* Main layout: preview + side panel */}
<div className="flex gap-4 h-[calc(100vh-15rem)] min-h-0">
{/* Preview panel */}
<div className="flex-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-xl overflow-hidden flex flex-col min-h-0">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[rgba(0,0,0,0.06)] shrink-0 bg-gray-50/30">
<p className="text-xs font-semibold text-[#9aa6b2]">
Preview
</p>
</div>
<FilePreviewPanel file={file} />
</div>
{/* Right panel */}
<div className="w-[320px] shrink-0 flex flex-col gap-3 overflow-y-auto custom-scrollbar">
{/* File Details */}
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-[#0e1b2a]">File Details</h3>
{isTenantAdmin && !editingMetadata && (
<button
onClick={() => {
setEditingMetadata(true);
setDraftDescription(file.description || "");
setDraftTags(file.tags || []);
}}
className="text-xs font-medium text-[#084cc8] hover:underline"
>
Edit
</button>
)}
{editingMetadata && (
<div className="flex gap-2">
<button
onClick={handleSaveMetadata}
disabled={savingMeta}
className="text-xs font-semibold text-emerald-600 hover:underline disabled:opacity-50"
>
{savingMeta ? "Saving..." : "Save"}
</button>
<button
onClick={() => setEditingMetadata(false)}
className="text-xs text-[#9aa6b2] hover:text-[#0e1b2a]"
>
Cancel
</button>
</div>
)}
</div>
{/* Description */}
<div className="mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">Description</p>
{editingMetadata ? (
<textarea
value={draftDescription}
onChange={(e) => setDraftDescription(e.target.value)}
rows={3}
maxLength={500}
className="w-full border border-[rgba(0,0,0,0.12)] rounded-lg px-2.5 py-1.5 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20"
/>
) : (
<p className="text-sm text-[#475569] leading-relaxed">
{file.description || <span className="text-[#c4cbd6]">No description</span>}
</p>
)}
</div>
{/* Category */}
<div className="mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1">Category</p>
{file.category ? (
<span className={cn("inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize", getCategoryStyle(file.category))}>
{file.category}
</span>
) : <span className="text-sm text-[#c4cbd6]"></span>}
</div>
{/* Tags */}
<div className="mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-1.5">Tags</p>
{editingMetadata ? (
<div className="flex flex-wrap gap-1.5 border border-[rgba(0,0,0,0.12)] rounded-lg p-2 min-h-9 focus-within:ring-2 focus-within:ring-[#084cc8]/20">
{draftTags.map((t) => (
<span key={t} className="inline-flex items-center gap-1 bg-gray-100 text-xs font-medium rounded px-1.5 py-0.5">
{t}
<button onClick={() => setDraftTags((prev) => prev.filter((x) => x !== t))}>
<X className="w-3 h-3 text-[#9aa6b2]" />
</button>
</span>
))}
<input
type="text"
value={draftTagInput}
onChange={(e) => setDraftTagInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
const t = draftTagInput.trim();
if (t && !draftTags.includes(t)) setDraftTags((prev) => [...prev, t]);
setDraftTagInput("");
}
}}
placeholder="Add..."
className="flex-1 min-w-12 text-sm outline-none bg-transparent placeholder:text-[#c4cbd6]"
/>
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{(file.tags || []).length > 0 ? (
file.tags.map((tag) => (
<span key={tag} className="inline-block bg-gray-100 text-[#475569] text-xs font-medium rounded px-2 py-0.5">
{tag}
</span>
))
) : (
<span className="text-sm text-[#c4cbd6]">No tags</span>
)}
</div>
)}
</div>
{/* Properties */}
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Properties</p>
<div className="space-y-1.5">
{[
{ label: "Entity Type", value: file.entity_type },
{ label: "Entity ID", value: file.entity_id?.substring(0, 8) + "…" },
{ label: "Source Module", value: file.source_module },
{ label: "Version", value: `v${file.version}` },
{ label: "Downloads", value: String(file.download_count) },
].map(({ label, value }) => (
<div key={label} className="flex justify-between items-center">
<span className="text-xs text-[#9aa6b2]">{label}</span>
<span className="text-xs font-medium text-[#0e1b2a]">{value || "—"}</span>
</div>
))}
</div>
</div>
{/* Metadata */}
{file.metadata && Object.keys(file.metadata).length > 0 && (
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3 mb-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Metadata</p>
<div className="space-y-1">
{Object.entries(file.metadata).map(([k, v]) => (
<div key={k} className="flex items-center justify-between">
<span className="text-xs text-[#9aa6b2] font-mono">{k}:</span>
<span className="text-xs font-medium text-[#0e1b2a] font-mono max-w-[140px] truncate">
{typeof v === "object" ? JSON.stringify(v) : String(v)}
</span>
</div>
))}
</div>
</div>
)}
{/* Technical — Admin Only */}
{isTenantAdmin && (
<div className="border-t border-[rgba(0,0,0,0.06)] pt-3">
<p className="text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide mb-2">Technical (Admin)</p>
<div className="space-y-3">
<div>
<p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest">Stored Path</p>
<div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]">
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
{file.file_path}
</code>
<button onClick={copyPath} className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]">
{copiedPath ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
</button>
</div>
</div>
{file.checksum && (
<div>
<p className="text-[10px] text-[#9aa6b2] mb-1 uppercase font-bold tracking-widest text">SHA-256 Checksum</p>
<div className="flex items-center gap-1.5 bg-gray-50 rounded-lg px-2 py-1.5 border border-[rgba(0,0,0,0.04)]">
<code className="text-[10px] text-[#475569] flex-1 truncate font-mono">
{file.checksum}
</code>
<button onClick={copyChecksum} className="shrink-0 text-[#9aa6b2] hover:text-[#084cc8]">
{copiedChecksum ? <Check className="w-3 h-3 text-emerald-500" /> : <Copy className="w-3 h-3" />}
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Version History */}
<div className="mt-4 bg-white border border-[rgba(0,0,0,0.08)] rounded-xl overflow-hidden">
<div className="flex border-b border-[rgba(0,0,0,0.08)] bg-gray-50/20">
<div className="flex items-center gap-2 px-5 py-3 text-sm font-semibold border-b-2 border-[#084cc8] text-[#084cc8]">
<RefreshCw className="w-3.5 h-3.5" />
Version History
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[rgba(0,0,0,0.06)]">
{["Version", "Date", "Uploader", "Size", "Action"].map((h) => (
<th key={h} className="px-5 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{versions.length === 0 ? (
<tr>
<td colSpan={5} className="px-5 py-8 text-center text-sm text-[#9aa6b2]">
No version history available
</td>
</tr>
) : (
versions.map((ver) => (
<VersionRow
key={ver.id}
ver={ver}
onDownload={() =>
fileAttachmentService.download(ver.id, ver.original_name).catch(() => {})
}
/>
))
)}
</tbody>
</table>
</div>
</div>
<FileShareModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
file={file}
/>
</Layout>
);
};
export default FileView;

View File

@ -0,0 +1,642 @@
/**
* FilesList File Attachment Services Files List
*
* Matches the design image:
* - Data table with File Name, Size, Category, Source Module, Uploaded By, Upload Date, Version, Actions
* - Search, Category filter, More Filters dropdown (source_module, tags)
* - Upload New File modal
* - Pagination
* - Tenant-admin sees all admin actions; tenant-user sees limited view (conditional render)
*/
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { useSelector } from "react-redux";
import {
Search,
Upload,
FileText,
Image,
FileArchive,
Table as TableIcon,
MoreHorizontal,
Download,
Eye,
Pencil,
Trash2,
SlidersHorizontal,
ChevronDown,
} from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { Pagination } from "@/components/shared";
import { cn } from "@/lib/utils";
import fileAttachmentService, {
type FileAttachment,
type CategoriesFilterOptions,
} from "@/services/file-attachment-service";
import { FileUploadModal } from "@/components/shared/FileUploadModal";
import type { RootState } from "@/store/store";
// ─────────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────────
function formatDate(value: string): string {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: true,
});
}
function formatBytes(bytes: number): string {
if (!bytes) 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 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") ||
name?.endsWith(".xls")
)
return <TableIcon 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-[#084cc8]" />;
}
const categoryColors: Record<string, string> = {
finance: "bg-purple-100 text-purple-700",
marketing: "bg-orange-100 text-orange-700",
hr: "bg-blue-100 text-blue-700",
data: "bg-teal-100 text-teal-700",
legal: "bg-red-100 text-red-700",
training: "bg-yellow-100 text-yellow-700",
other: "bg-gray-100 text-gray-600",
};
function getCategoryStyle(cat: string) {
return categoryColors[cat?.toLowerCase()] || "bg-gray-100 text-gray-600";
}
const moduleColors: Record<string, string> = {
platform: "bg-indigo-100 text-indigo-700",
document: "bg-blue-100 text-blue-700",
capa: "bg-orange-100 text-orange-700",
training: "bg-yellow-100 text-yellow-700",
supplier: "bg-green-100 text-green-700",
audit: "bg-red-100 text-red-700",
api_key: "bg-gray-100 text-gray-700",
};
function getModuleStyle(mod: string) {
return moduleColors[mod?.toLowerCase()] || "bg-gray-100 text-gray-600";
}
function getInitials(email: string): string {
if (!email) return "?";
const parts = email.split("@")[0].split(".");
return parts
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() || "")
.join("");
}
const avatarColors = [
"bg-violet-100 text-violet-700",
"bg-blue-100 text-blue-700",
"bg-emerald-100 text-emerald-700",
"bg-orange-100 text-orange-700",
];
function getAvatarColor(email: string) {
const idx = (email?.charCodeAt(0) || 0) % avatarColors.length;
return avatarColors[idx];
}
// ─────────────────────────────────────────────────────────────────────────────
// FilterDropdown (local inline, no shared dependency)
// ─────────────────────────────────────────────────────────────────────────────
interface DropOption { value: string; label: string }
function FilterPill({
label,
options,
value,
onChange,
}: {
label: string;
options: DropOption[];
value: string | null;
onChange: (v: string | null) => void;
}): ReactElement {
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.value === value);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border transition-colors",
value
? "border-[#084cc8] bg-[#084cc8]/5 text-[#084cc8]"
: "border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30"
)}
>
{label}
{selected && <span className="text-[#084cc8]">: {selected.label}</span>}
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button>
{open && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpen(false)}
/>
<div className="absolute top-full mt-1 left-0 z-20 bg-white border border-[rgba(0,0,0,0.1)] shadow-lg rounded-xl py-1 min-w-[160px]">
<button
onClick={() => { onChange(null); setOpen(false); }}
className={cn(
"w-full text-left px-3 py-2 text-sm hover:bg-gray-50",
!value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]"
)}
>
All
</button>
{options.map((opt) => (
<button
key={opt.value}
onClick={() => { onChange(opt.value); setOpen(false); }}
className={cn(
"w-full text-left px-3 py-2 text-sm hover:bg-gray-50",
value === opt.value ? "font-semibold text-[#0e1b2a]" : "text-[#475569]"
)}
>
{opt.label}
</button>
))}
</div>
</>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Row action menu
// ─────────────────────────────────────────────────────────────────────────────
function ActionMenu({
onView,
onDownload,
onEdit,
onDelete,
canEdit,
canDelete,
}: {
onView: () => void;
onDownload: () => void;
onEdit: () => void;
onDelete: () => void;
canEdit: boolean;
canDelete: boolean;
}): ReactElement {
const [open, setOpen] = useState(false);
return (
<div className="relative">
<button
onClick={() => setOpen(!open)}
className="w-7 h-7 flex items-center justify-center rounded-lg hover:bg-gray-100 text-[#6b7280] transition-colors"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute right-0 top-full mt-1 z-20 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] shadow-lg py-1 min-w-[140px]">
<button
onClick={() => { onView(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
>
<Eye className="w-3.5 h-3.5 text-[#6b7280]" /> View
</button>
<button
onClick={() => { onDownload(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
>
<Download className="w-3.5 h-3.5 text-[#6b7280]" /> Download
</button>
{canEdit && (
<button
onClick={() => { onEdit(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[#0e1b2a] hover:bg-gray-50"
>
<Pencil className="w-3.5 h-3.5 text-[#6b7280]" /> Edit
</button>
)}
{canDelete && (
<button
onClick={() => { onDelete(); setOpen(false); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
)}
</div>
</>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Main component
// ─────────────────────────────────────────────────────────────────────────────
const FilesList = (): ReactElement => {
const navigate = useNavigate();
const permissions = useSelector((state: RootState) => state.auth.permissions);
// Permission checks
const canCreate = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "create" || p.action === "*")
);
const canUpdate = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "update" || p.action === "*")
);
const canDelete = permissions.some(
(p) => (p.resource === "files" || p.resource === "*") && (p.action === "delete" || p.action === "*")
);
// ── State ──
const [files, setFiles] = useState<FileAttachment[]>([]);
const [total, setTotal] = useState(0);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Filters
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [limit] = useState(10);
const offset = (currentPage - 1) * limit;
const totalPages = Math.max(1, Math.ceil(total / limit));
// Filter option data
const [categories, setCategories] = useState<CategoriesFilterOptions["categories"]>([]);
// Upload modal
const [showUpload, setShowUpload] = useState(false);
// Deleting
const [, setDeletingId] = useState<string | null>(null);
// ── Load categories ──
useEffect(() => {
fileAttachmentService.getCategoriesFilterOptions().then((res) => {
setCategories(res.data.categories);
}).catch(() => {});
}, []);
// ── Load files ──
const loadFiles = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const res = await fileAttachmentService.list({
search: search.trim() || undefined,
category: categoryFilter || undefined,
source_module: moduleFilter || undefined,
limit,
offset,
});
setFiles(res.data);
setTotal(res.pagination.total);
} catch (err: any) {
setError(err?.response?.data?.error?.message || "Failed to load files");
} finally {
setIsLoading(false);
}
}, [search, categoryFilter, moduleFilter, limit, offset]);
useEffect(() => {
void loadFiles();
}, [loadFiles]);
// ── Unique module values for filter ──
const moduleOptions = useMemo<DropOption[]>(() => {
const seen = new Set<string>();
const opts: DropOption[] = [];
files.forEach((f) => {
if (f.source_module && !seen.has(f.source_module)) {
seen.add(f.source_module);
opts.push({ value: f.source_module, label: f.source_module });
}
});
return opts;
}, [files]);
const categoryOptions = useMemo<DropOption[]>(() =>
categories.map((c) => ({ value: c.category, label: c.category })),
[categories]);
// ── Actions ──
const handleDownload = (file: FileAttachment) => {
fileAttachmentService.download(file.id, file.original_name).catch(() => {});
};
const handleDelete = async (id: string) => {
if (!window.confirm("Delete this file?")) return;
setDeletingId(id);
try {
await fileAttachmentService.delete(id);
await loadFiles();
} catch {
// silence
} finally {
setDeletingId(null);
}
};
const clearFilters = () => {
setSearch("");
setCategoryFilter(null);
setModuleFilter(null);
setCurrentPage(1);
};
// ─────────────────────────────────────────────────────────────────────────
// Render
// ─────────────────────────────────────────────────────────────────────────
return (
<Layout
currentPage="File Attachment Services"
breadcrumbs={[
{ label: "File Attachment Services" },
{ label: "File List" },
]}
pageHeader={{
title: "Files List",
description: "Manage controlled documents across their entire lifecycle.",
action: canCreate ? (
<button
id="upload-new-file-btn"
onClick={() => setShowUpload(true)}
className="inline-flex items-center gap-2 h-9 px-4 bg-[#112868] hover:bg-[#0c1e52] text-white rounded-lg text-sm font-semibold transition-colors shadow-sm"
>
<Upload className="w-3.5 h-3.5" />
Upload New File
</button>
) : null,
}}
>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
{/* Filter bar */}
<div className="border-b border-[rgba(0,0,0,0.08)] px-5 py-3.5">
<div className="flex flex-wrap items-center gap-3">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#9aa6b2]" />
<input
id="files-search"
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
placeholder="Search by name, ID..."
className="h-9 w-[240px] pl-9 pr-3 bg-white border border-[rgba(0,0,0,0.1)] rounded-lg text-sm text-[#0e1b2a] placeholder:text-[#c4cbd6] focus:outline-none focus:ring-2 focus:ring-[#084cc8]/20 focus:border-[#084cc8]"
/>
</div>
{/* Category filter */}
<FilterPill
label="Category"
options={categoryOptions}
value={categoryFilter}
onChange={(v) => { setCategoryFilter(v); setCurrentPage(1); }}
/>
{/* More Filters (Source Module) */}
<FilterPill
label="Source Module"
options={moduleOptions}
value={moduleFilter}
onChange={(v) => { setModuleFilter(v); setCurrentPage(1); }}
/>
{/* More Filters label pill */}
{/* <button className="inline-flex items-center gap-1.5 h-9 px-3 rounded-lg text-sm font-medium border border-[rgba(0,0,0,0.1)] bg-white text-[#475569] hover:border-[#084cc8]/30 transition-colors">
<SlidersHorizontal className="w-3.5 h-3.5" />
More Filters
<ChevronDown className="w-3.5 h-3.5 opacity-60" />
</button> */}
<div className="ml-auto">
<button
onClick={clearFilters}
className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors"
>
Clear filters
</button>
</div>
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-[rgba(0,0,0,0.06)]">
{["File Name", "Size", "Category", "Source Module", "Uploaded By", "Upload Date", "Version", "Actions"].map((h) => (
<th
key={h}
className="px-4 py-3 text-left text-[11px] font-semibold text-[#9aa6b2] uppercase tracking-wide whitespace-nowrap"
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-[rgba(0,0,0,0.04)]">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i}>
{Array.from({ length: 8 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 bg-gray-100 rounded animate-pulse" />
</td>
))}
</tr>
))
) : error ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center text-sm text-red-500">
{error}
</td>
</tr>
) : files.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-12 text-center">
<div className="flex flex-col items-center gap-2">
<FileText className="w-10 h-10 text-gray-200" />
<p className="text-sm text-[#9aa6b2]">No files found</p>
{canCreate && (
<button
onClick={() => setShowUpload(true)}
className="mt-2 text-sm font-medium text-[#084cc8] hover:underline"
>
Upload your first file
</button>
)}
</div>
</td>
</tr>
) : (
files.map((file) => (
<tr
key={file.id}
className="hover:bg-[#f6f9ff]/60 transition-colors group"
>
{/* File Name */}
<td className="px-4 py-3 min-w-[200px]">
<button
onClick={() => navigate(`/tenant/files/${file.id}`)}
className="flex items-center gap-2.5 hover:text-[#084cc8] transition-colors text-left"
>
<div className="w-7 h-7 rounded-md bg-gray-50 border border-[rgba(0,0,0,0.06)] flex items-center justify-center shrink-0">
{getFileIcon(file.mime_type, file.original_name)}
</div>
<span className="text-sm font-medium text-[#0e1b2a] group-hover:text-[#084cc8] truncate max-w-[200px]">
{file.original_name}
</span>
</button>
</td>
{/* Size */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-sm text-[#6b7280]">
{file.file_size_formatted || formatBytes(file.file_size)}
</span>
</td>
{/* Category */}
<td className="px-4 py-3">
{file.category ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getCategoryStyle(file.category)
)}
>
{file.category}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)}
</td>
{/* Source Module */}
<td className="px-4 py-3">
{file.source_module ? (
<span
className={cn(
"inline-block text-xs font-semibold rounded px-2 py-0.5 capitalize",
getModuleStyle(file.source_module)
)}
>
{file.source_module}
</span>
) : (
<span className="text-[#c4cbd6] text-sm"></span>
)}
</td>
{/* Uploaded By */}
<td className="px-4 py-3 min-w-[140px]">
{file.uploaded_by_email ? (
<div className="flex items-center gap-2">
<div
className={cn(
"w-7 h-7 rounded-full flex items-center justify-center text-[10px] font-bold shrink-0",
getAvatarColor(file.uploaded_by_email)
)}
>
{getInitials(file.uploaded_by_email)}
</div>
<span className="text-sm text-[#0e1b2a] truncate max-w-[130px]">
{file.uploaded_by_email.split("@")[0]}
</span>
</div>
) : (
<span className="text-sm text-[#9aa6b2]">Unknown</span>
)}
</td>
{/* Upload Date */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-sm text-[#6b7280]">{formatDate(file.created_at)}</span>
</td>
{/* Version */}
<td className="px-4 py-3 whitespace-nowrap">
<span className="text-sm text-[#0e1b2a] font-medium">v{file.version}</span>
</td>
{/* Actions */}
<td className="px-4 py-3">
<ActionMenu
onView={() => navigate(`/tenant/files/${file.id}`)}
onDownload={() => handleDownload(file)}
onEdit={() => navigate(`/tenant/files/${file.id}`)}
onDelete={() => handleDelete(file.id)}
canEdit={canUpdate}
canDelete={canDelete}
/>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{total > 0 && (
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalItems={total}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={() => {}}
/>
)}
</div>
{/* Upload modal */}
<FileUploadModal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
categories={categories}
onUploaded={() => {
setShowUpload(false);
void loadFiles();
}}
isTenantAdmin={canCreate}
/>
</Layout>
);
};
export default FilesList;

View File

@ -23,6 +23,8 @@ const DocumentsDueForReview = lazy(() => import("@/pages/tenant/DocumentsDueForR
const Tasks = lazy(() => import("@/pages/tenant/Tasks")); const Tasks = lazy(() => import("@/pages/tenant/Tasks"));
const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings")); const NotificationSettings = lazy(() => import("@/pages/tenant/NotificationSettings"));
const Notifications = lazy(() => import("@/pages/tenant/Notifications")); const Notifications = lazy(() => import("@/pages/tenant/Notifications"));
const FilesList = lazy(() => import("@/pages/tenant/FilesList"));
const FileView = lazy(() => import("@/pages/tenant/FileView"));
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -125,4 +127,12 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/notifications", path: "/tenant/notifications",
element: <LazyRoute component={Notifications} />, element: <LazyRoute component={Notifications} />,
}, },
{
path: "/tenant/files",
element: <LazyRoute component={FilesList} />,
},
{
path: "/tenant/files/:id",
element: <LazyRoute component={FileView} />,
},
]; ];

View File

@ -0,0 +1,371 @@
/**
* File Attachment Service
* Typed API client matching the backend file-attachment.routes.js exactly
*/
import apiClient from './api-client';
// ─────────────────────────────────────────
// Types
// ─────────────────────────────────────────
export interface FileAttachment {
id: string;
tenant_id: string;
original_name: string;
stored_name: string;
file_path: string;
mime_type: string;
file_size: number;
file_size_formatted: string;
checksum: string;
storage_provider: string;
storage_bucket: string | null;
storage_region: string | null;
entity_type: string;
entity_id: string;
category: string;
category_id: string | null;
description: string | null;
tags: string[];
version: number;
is_current_version: boolean;
previous_version_id: string | null;
is_public: boolean;
access_level: string;
download_count: number;
has_thumbnail: boolean;
thumbnail_path: string | null;
metadata: Record<string, any>;
scan_status: string;
scanned_at: string | null;
source_module: string;
source_module_id: string | null;
uploaded_by: string;
uploaded_by_email: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
}
export interface FileAttachmentPagination {
total: number;
limit: number;
offset: number;
}
export interface FileListResponse {
success: boolean;
data: FileAttachment[];
pagination: FileAttachmentPagination;
}
export interface FileDetailResponse {
success: boolean;
data: FileAttachment;
}
export interface VersionHistoryResponse {
success: boolean;
data: FileAttachment[];
}
export interface StorageStats {
quota: {
max_storage: number;
used_storage: number;
available: number;
usage_percent: number;
};
files: {
total: number;
images: number;
pdfs: number;
documents: number;
};
by_entity: Record<string, { count: number; size: number }>;
by_module: Record<string, { count: number; size: number }>;
}
export interface StorageQuota {
id: string;
tenant_id: string;
max_storage_bytes: number;
max_file_size_bytes: number;
used_storage_bytes: number;
file_count: number;
allowed_mime_types: string[] | null;
blocked_extensions: string[];
max_storage_formatted: string;
used_storage_formatted: string;
max_file_size_formatted: string;
updated_at: string;
}
export interface FilterOptions {
tags: string[];
metadata: Record<string, string[]>;
}
export interface CategoriesFilterOptions {
categories: Array<{ category: string; category_id: string | null }>;
}
export interface ShareResponse {
id: string;
tenant_id: string;
file_id: string;
share_token: string;
share_type: 'link' | 'user';
shared_with_user_id: string | null;
shared_with_email: string | null;
permissions: 'view' | 'download';
expires_at: string | null;
max_downloads: number | null;
current_downloads: number;
created_by: string;
created_at: string;
is_active: boolean;
share_url: string;
}
// ─────────────────────────────────────────
// Upload Params (matches UploadFileSchema)
// ─────────────────────────────────────────
export interface UploadFilesParams {
files: File[];
entity_type: string; // required
entity_id: string; // required — must be valid UUID
category?: string; // optional string label
category_id?: string; // optional
description?: string; // max 500 chars
tags?: string[]; // array of tag strings
metadata?: Record<string, any>;
}
// ─────────────────────────────────────────
// List Params
// ─────────────────────────────────────────
export interface FileListParams {
entity_type?: string;
entity_id?: string;
mime_type?: string;
category?: string;
category_id?: string;
source_module?: string;
search?: string;
tags?: string;
uploaded_by?: string;
sort_by?: 'created_at' | 'original_name' | 'file_size' | 'version' | 'category' | 'source_module';
sort_order?: 'ASC' | 'DESC';
limit?: number;
offset?: number;
}
// ─────────────────────────────────────────
// Update Params (matches UpdateFileMetadataSchema)
// ─────────────────────────────────────────
export interface UpdateFileMetadataParams {
category?: string;
category_id?: string | null;
description?: string;
tags?: string[];
metadata?: Record<string, any>;
}
// ─────────────────────────────────────────
// Share Params (matches CreateShareSchema)
// ─────────────────────────────────────────
export interface CreateShareParams {
share_type?: 'link' | 'user';
shared_with_user_id?: string | null;
shared_with_email?: string | null;
permissions?: 'view' | 'download';
expires_at?: Date | null;
expires_in_hours?: number;
max_downloads?: number | null;
}
// ─────────────────────────────────────────
// Service
// ─────────────────────────────────────────
export const fileAttachmentService = {
/** GET /files — list with filters */
list: async (params: FileListParams = {}): Promise<FileListResponse> => {
const response = await apiClient.get<FileListResponse>('/files', { params });
return response.data;
},
/** GET /files/:id — single file detail */
getById: async (id: string): Promise<FileDetailResponse> => {
const response = await apiClient.get<FileDetailResponse>(`/files/${id}`);
return response.data;
},
/** GET /files/entity/:entity_type/:entity_id */
getByEntity: async (
entityType: string,
entityId: string,
params: { category?: string; limit?: number; offset?: number; current_only?: boolean } = {}
): Promise<FileListResponse> => {
const response = await apiClient.get<FileListResponse>(
`/files/entity/${entityType}/${entityId}`,
{ params }
);
return response.data;
},
/** POST /files/upload — single file */
upload: async (params: UploadFilesParams): Promise<FileDetailResponse> => {
const formData = new FormData();
formData.append('file', params.files[0]);
formData.append('entity_type', params.entity_type);
formData.append('entity_id', params.entity_id);
if (params.category) formData.append('category', params.category);
if (params.category_id) formData.append('category_id', params.category_id);
if (params.description) formData.append('description', params.description);
if (params.tags?.length) formData.append('tags', JSON.stringify(params.tags));
if (params.metadata) formData.append('metadata', JSON.stringify(params.metadata));
const response = await apiClient.post<FileDetailResponse>('/files/upload', formData);
return response.data;
},
/** POST /files/upload/multiple — up to 10 files */
uploadMultiple: async (
params: UploadFilesParams,
onProgress?: (fileIndex: number, percent: number) => void
): Promise<{ success: boolean; data: { uploaded: FileAttachment[]; errors: Array<{ file: string; error: string }>; total: number; success_count: number; error_count: number } }> => {
const formData = new FormData();
params.files.forEach((file) => formData.append('files', file));
formData.append('entity_type', params.entity_type);
formData.append('entity_id', params.entity_id);
if (params.category) formData.append('category', params.category);
if (params.category_id) formData.append('category_id', params.category_id);
if (params.description) formData.append('description', params.description);
if (params.tags?.length) formData.append('tags', JSON.stringify(params.tags));
if (params.metadata) formData.append('metadata', JSON.stringify(params.metadata));
const response = await apiClient.post('/files/upload/multiple', formData, {
onUploadProgress: (evt) => {
if (onProgress && evt.total) {
onProgress(0, Math.round((evt.loaded / evt.total) * 100));
}
},
});
return response.data;
},
/** PUT /files/:id — update metadata */
updateMetadata: async (id: string, data: UpdateFileMetadataParams): Promise<FileDetailResponse> => {
const response = await apiClient.put<FileDetailResponse>(`/files/${id}`, data);
return response.data;
},
/** DELETE /files/:id */
delete: async (id: string, hard = false): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/files/${id}`, { params: { hard } });
return response.data;
},
/** GET /files/:id/download — returns blob URL */
getDownloadUrl: (id: string): string => {
const baseUrl = (import.meta as any).env?.VITE_API_BASE_URL || 'http://localhost:3000/api/v1';
return `${baseUrl}/files/${id}/download`;
},
/** GET /files/:id/download (blob) */
download: async (id: string, filename?: string): Promise<void> => {
const response = await apiClient.get(`/files/${id}/download`, { responseType: 'blob' });
const url = URL.createObjectURL(response.data);
const a = document.createElement('a');
a.href = url;
a.download = filename || 'download';
a.click();
URL.revokeObjectURL(url);
},
/** GET /files/:id/preview — returns blob URL for inline preview */
getPreviewUrl: async (id: string): Promise<string> => {
const response = await apiClient.get(`/files/${id}/preview`, { responseType: 'blob' });
return URL.createObjectURL(response.data);
},
/** GET /files/:id/versions — version history */
getVersionHistory: async (id: string): Promise<VersionHistoryResponse> => {
const response = await apiClient.get<VersionHistoryResponse>(`/files/${id}/versions`);
return response.data;
},
/** POST /files/:id/versions — upload new version */
uploadVersion: async (
id: string,
file: File,
options: { entity_id?: string; category?: string; description?: string; tags?: string[]; metadata?: Record<string, any> } = {}
): Promise<FileDetailResponse> => {
const formData = new FormData();
formData.append('file', file);
if (options.entity_id) formData.append('entity_id', options.entity_id);
if (options.category) formData.append('category', options.category);
if (options.description) formData.append('description', options.description);
if (options.tags?.length) formData.append('tags', JSON.stringify(options.tags));
if (options.metadata) formData.append('metadata', JSON.stringify(options.metadata));
const response = await apiClient.post<FileDetailResponse>(`/files/${id}/versions`, formData);
return response.data;
},
/** POST /files/:id/share — create share link */
createShare: async (id: string, params: CreateShareParams): Promise<{ success: boolean; data: ShareResponse }> => {
const response = await apiClient.post(`/files/${id}/share`, params);
return response.data;
},
/** DELETE /files/shares/:shareId — revoke a share */
revokeShare: async (shareId: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/files/shares/${shareId}`);
return response.data;
},
/** GET /files/stats */
getStorageStats: async (): Promise<{ success: boolean; data: StorageStats }> => {
const response = await apiClient.get('/files/stats');
return response.data;
},
/** GET /files/quota */
getQuota: async (): Promise<{ success: boolean; data: StorageQuota }> => {
const response = await apiClient.get('/files/quota');
return response.data;
},
/** PUT /files/quota — admin only */
updateQuota: async (data: Partial<Pick<StorageQuota, 'max_storage_bytes' | 'max_file_size_bytes' | 'allowed_mime_types' | 'blocked_extensions'>>): Promise<{ success: boolean; data: StorageQuota }> => {
const response = await apiClient.put('/files/quota', data);
return response.data;
},
/** GET /files/filter-options */
getFilterOptions: async (params?: { source_module?: string; uploaded_by?: string }): Promise<{ success: boolean; data: FilterOptions }> => {
const response = await apiClient.get('/files/filter-options', { params });
return response.data;
},
/** GET /files/categories-filter-options */
getCategoriesFilterOptions: async (params?: { source_module?: string }): Promise<{ success: boolean; data: CategoriesFilterOptions }> => {
const response = await apiClient.get('/files/categories-filter-options', { params });
return response.data;
},
/** GET /files/:id/content — extract text/html */
extractContent: async (id: string): Promise<{ success: boolean; data: { html: string; text: string; original_name: string; file_size: number; mime_type: string; checksum: string } }> => {
const response = await apiClient.get(`/files/${id}/content`);
return response.data;
},
};
export default fileAttachmentService;