642 lines
24 KiB
TypeScript
642 lines
24 KiB
TypeScript
/**
|
||
* 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;
|