Qassure-frontend/src/pages/tenant/FilesList.tsx

642 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 <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 [moduleIdFilter, setModuleIdFilter] = useState<string | null>(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<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(() => {});
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<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);
setModuleIdFilter(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); }}
/>
{/* Source Module filter */}
<FilterDropdown
label="Module"
options={modules.map(m => ({ value: m.id, label: m.name }))}
value={moduleIdFilter}
onChange={(val) => {
setModuleIdFilter(val as string | null);
setCurrentPage(1);
}}
placeholder="All Modules"
/>
{/* 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;