/** * 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, 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 { moduleService } from "@/services/module-service"; import { FilterDropdown } from "@/components/shared"; 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 ; if (mime === "application/pdf") return ; if ( mime?.includes("spreadsheet") || name?.endsWith(".csv") || name?.endsWith(".xlsx") || name?.endsWith(".xls") ) return ; if (mime?.includes("zip") || mime?.includes("archive")) return ; return ; } const categoryColors: Record = { 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 = { 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 (
{open && ( <>
setOpen(false)} />
{options.map((opt) => ( ))}
)}
); } // ───────────────────────────────────────────────────────────────────────────── // 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 (
{open && ( <>
setOpen(false)} />
{canEdit && ( )} {canDelete && ( )}
)}
); } // ───────────────────────────────────────────────────────────────────────────── // 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([]); const [total, setTotal] = useState(0); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); // Filters const [search, setSearch] = useState(""); const [categoryFilter, setCategoryFilter] = useState(null); const [moduleIdFilter, setModuleIdFilter] = useState(null); const [modules, setModules] = useState<{ id: string; name: string }[]>([]); // 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([]); // Upload modal const [showUpload, setShowUpload] = useState(false); // Deleting const [, setDeletingId] = useState(null); // ── Load categories ── useEffect(() => { fileAttachmentService.getCategoriesFilterOptions().then((res) => { setCategories(res.data.categories); }).catch(() => {}); moduleService.getMyModules().then((res) => { if (res.success) { setModules(res.data.map(m => ({ id: m.id, name: m.name }))); } }).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_id: moduleIdFilter || 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, moduleIdFilter, limit, offset]); useEffect(() => { void loadFiles(); }, [loadFiles]); const categoryOptions = useMemo(() => 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); setModuleIdFilter(null); setCurrentPage(1); }; // ───────────────────────────────────────────────────────────────────────── // Render // ───────────────────────────────────────────────────────────────────────── return ( 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 New File ) : null, }} >
{/* Filter bar */}
{/* Search */}
{ 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]" />
{/* Category filter */} { setCategoryFilter(v); setCurrentPage(1); }} /> {/* Source Module filter */} ({ value: m.id, label: m.name }))} value={moduleIdFilter} onChange={(val) => { setModuleIdFilter(val as string | null); setCurrentPage(1); }} placeholder="All Modules" /> {/* More Filters label pill */} {/* */}
{/* Table */}
{["File Name", "Size", "Category", "Source Module", "Uploaded By", "Upload Date", "Version", "Actions"].map((h) => ( ))} {isLoading ? ( Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 8 }).map((__, j) => ( ))} )) ) : error ? ( ) : files.length === 0 ? ( ) : ( files.map((file) => ( {/* File Name */} {/* Size */} {/* Category */} {/* Source Module */} {/* Uploaded By */} {/* Upload Date */} {/* Version */} {/* Actions */} )) )}
{h}
{error}

No files found

{canCreate && ( )}
{file.file_size_formatted || formatBytes(file.file_size)} {file.category ? ( {file.category} ) : ( )} {file.source_module ? ( {file.source_module} ) : ( )} {file.uploaded_by_email ? (
{getInitials(file.uploaded_by_email)}
{file.uploaded_by_email.split("@")[0]}
) : ( Unknown )}
{formatDate(file.created_at)} v{file.version} navigate(`/tenant/files/${file.id}`)} onDownload={() => handleDownload(file)} onEdit={() => navigate(`/tenant/files/${file.id}`)} onDelete={() => handleDelete(file.id)} canEdit={canUpdate} canDelete={canDelete} />
{/* Pagination */} {total > 0 && ( {}} /> )}
{/* Upload modal */} setShowUpload(false)} categories={categories} onUploaded={() => { setShowUpload(false); void loadFiles(); }} isTenantAdmin={canCreate} />
); }; export default FilesList;