mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 22:58:17 +00:00
577 lines
18 KiB
TypeScript
577 lines
18 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import {
|
|
CheckCircle2,
|
|
Clock,
|
|
Download,
|
|
Eye,
|
|
File,
|
|
FileImage,
|
|
FileSpreadsheet,
|
|
FileText,
|
|
Filter,
|
|
HardDrive,
|
|
LayoutGrid,
|
|
List,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
|
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
import NewDocumentPage from "./NewDocumentPage";
|
|
import DeleteDocumentDialog from "./DeleteDocumentDialog";
|
|
import {
|
|
documents,
|
|
formatBytes,
|
|
type DocumentFormat,
|
|
type DocumentRecord,
|
|
type DocumentStatus,
|
|
} from "./documents.mock";
|
|
import {
|
|
DataTable,
|
|
DataTableFooter,
|
|
type ColumnDef,
|
|
usePagination,
|
|
Button,
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
CardContent,
|
|
Input,
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
} from "@edr/ui-common";
|
|
|
|
type FilterValue = "All" | DocumentStatus;
|
|
type ViewMode = "grid" | "table";
|
|
|
|
const FILTERS: FilterValue[] = [
|
|
"All",
|
|
"Draft",
|
|
"Pending Review",
|
|
"Approved",
|
|
"Rejected",
|
|
"Expired",
|
|
];
|
|
|
|
export default function DocumentsPage() {
|
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
|
const [filter, setFilter] = useState<FilterValue>("All");
|
|
const [query, setQuery] = useState("");
|
|
const [view, setView] = useState<ViewMode>("table");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
return documents.filter((d) => {
|
|
if (filter !== "All" && d.status !== filter) return false;
|
|
if (!q) return true;
|
|
return (
|
|
d.name.toLowerCase().includes(q) ||
|
|
d.type.toLowerCase().includes(q) ||
|
|
d.linkedReference.toLowerCase().includes(q) ||
|
|
d.uploadedBy.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [filter, query]);
|
|
|
|
const total = filtered.length;
|
|
const pageCount = Math.ceil(total / pagination.pageSize);
|
|
const start = pagination.pageIndex * pagination.pageSize;
|
|
const end = Math.min(start + pagination.pageSize, total);
|
|
|
|
const paginatedData = useMemo(
|
|
() => filtered.slice(start, end),
|
|
[start, end, filtered],
|
|
);
|
|
|
|
const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0);
|
|
const approvedCount = documents.filter((d) => d.status === "Approved").length;
|
|
const pendingCount = documents.filter(
|
|
(d) => d.status === "Pending Review",
|
|
).length;
|
|
|
|
const columns: ColumnDef<DocumentRecord>[] = [
|
|
{
|
|
id: "document",
|
|
header: "Document",
|
|
cell: ({ row }) => {
|
|
const doc = row.original;
|
|
return (
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
|
<FormatIcon format={doc.format} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="truncate font-medium text-slate-900">{doc.name}</p>
|
|
<p className="text-xs text-slate-500">
|
|
{doc.format} · By {doc.uploadedBy}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
{
|
|
accessorKey: "type",
|
|
header: "Type",
|
|
},
|
|
{
|
|
id: "linkedTo",
|
|
header: "Linked To",
|
|
cell: ({ row }) => (
|
|
<div className="text-sm text-slate-700">
|
|
<p>{row.original.linkedReference}</p>
|
|
<p className="text-xs text-slate-500">{row.original.linkedType}</p>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "size",
|
|
header: "Size",
|
|
cell: ({ row }) => (
|
|
<span className="text-sm text-slate-700">
|
|
{formatBytes(row.original.sizeBytes)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
accessorKey: "uploadedAt",
|
|
header: "Uploaded",
|
|
},
|
|
{
|
|
accessorKey: "status",
|
|
header: "Status",
|
|
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
},
|
|
{
|
|
id: "actions",
|
|
size: 40,
|
|
cell: ({ row }) => {
|
|
const doc = row.original;
|
|
return (
|
|
<div
|
|
className="flex justify-end"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="icon">
|
|
<MoreHorizontal />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem>
|
|
<Eye />
|
|
Preview
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<Download />
|
|
Download
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<NewDocumentPage
|
|
mode="edit"
|
|
document={{
|
|
name: doc.name,
|
|
type: doc.type,
|
|
status: doc.status,
|
|
linkedType: doc.linkedType,
|
|
linkedReference: doc.linkedReference,
|
|
notes: doc.notes,
|
|
}}
|
|
>
|
|
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
<Pencil />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
</NewDocumentPage>
|
|
<DropdownMenuSeparator />
|
|
<DeleteDocumentDialog documentName={doc.name}>
|
|
<DropdownMenuItem
|
|
onSelect={(e: Event) => e.preventDefault()}
|
|
variant="destructive"
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DeleteDocumentDialog>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="min-h-screen p-6">
|
|
<div className="space-y-6">
|
|
<Breadcrumbs items={[{ label: "Documents" }]} />
|
|
|
|
<Card className="p-6 flex-row justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
Documents
|
|
</h1>
|
|
<p className="mt-1 text-sm text-secondary-foreground">
|
|
Manage freight documents linked to bookings, consignments, and
|
|
invoices.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
<div className="relative w-full sm:w-80">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
<Input
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setPagination({
|
|
pageIndex: 0,
|
|
pageSize: pagination.pageSize,
|
|
});
|
|
}}
|
|
placeholder="Search documents..."
|
|
className="pl-8!"
|
|
/>
|
|
</div>
|
|
|
|
<NewDocumentPage>
|
|
<Button>
|
|
<Plus />
|
|
Upload Document
|
|
</Button>
|
|
</NewDocumentPage>
|
|
</div>
|
|
</Card>
|
|
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardContent className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-slate-500">Total Documents</p>
|
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
{documents.length}
|
|
</h3>
|
|
</div>
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<FileText />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-slate-500">Approved</p>
|
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
{approvedCount}
|
|
</h3>
|
|
</div>
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<CheckCircle2 />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-slate-500">Pending Review</p>
|
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
{pendingCount}
|
|
</h3>
|
|
</div>
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<Clock />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardContent className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm text-slate-500">Storage Used</p>
|
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
|
{formatBytes(totalSize)}
|
|
</h3>
|
|
</div>
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
|
<HardDrive />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<Card className="p-2">
|
|
<div className="flex flex-col gap-3 sm:flex-row md:items-center md:justify-between">
|
|
<div className="flex flex-wrap gap-1">
|
|
{FILTERS.map((f) => {
|
|
const isActive = f === filter;
|
|
const count =
|
|
f === "All"
|
|
? documents.length
|
|
: documents.filter((d) => d.status === f).length;
|
|
return (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
onClick={() => {
|
|
setFilter(f);
|
|
setPagination({
|
|
pageIndex: 0,
|
|
pageSize: pagination.pageSize,
|
|
});
|
|
}}
|
|
className={
|
|
isActive
|
|
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
}
|
|
>
|
|
{f}
|
|
<span
|
|
className={
|
|
isActive
|
|
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
}
|
|
>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
|
|
<ViewToggleButton
|
|
active={view === "grid"}
|
|
onClick={() => setView("grid")}
|
|
label="Grid view"
|
|
>
|
|
<LayoutGrid className="size-4" />
|
|
<span className="sm:inline">Grid</span>
|
|
</ViewToggleButton>
|
|
<ViewToggleButton
|
|
active={view === "table"}
|
|
onClick={() => setView("table")}
|
|
label="Table view"
|
|
>
|
|
<List className="size-4" />
|
|
<span className="sm:inline">Table</span>
|
|
</ViewToggleButton>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{paginatedData.length === 0 ? (
|
|
<Card className="p-12 text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
No documents match your filters.
|
|
</p>
|
|
</Card>
|
|
) : view === "grid" ? (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{paginatedData.map((doc) => (
|
|
<DocumentCard key={doc.id} doc={doc} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card className="gap-0">
|
|
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
<div>
|
|
<CardTitle>Document Library</CardTitle>
|
|
<CardDescription>
|
|
All freight documents stored in the system.
|
|
</CardDescription>
|
|
</div>
|
|
<Button variant="secondary" size="sm">
|
|
<Filter />
|
|
Filter
|
|
</Button>
|
|
</CardHeader>
|
|
|
|
<CardContent className="px-0">
|
|
<DataTable
|
|
columns={columns}
|
|
data={paginatedData}
|
|
status="success"
|
|
onRowClick={() => { }}
|
|
pagination={{
|
|
pageIndex: pagination.pageIndex,
|
|
pageSize: pagination.pageSize,
|
|
pageCount: pageCount,
|
|
totalCount: total,
|
|
}}
|
|
tableOptions={{
|
|
state: { pagination },
|
|
onPaginationChange: setPagination,
|
|
}}
|
|
containerClassName="border-b shadow-none"
|
|
footer={DataTableFooter}
|
|
/>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ViewToggleButton({
|
|
active,
|
|
onClick,
|
|
label,
|
|
children,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
aria-pressed={active}
|
|
className={
|
|
active
|
|
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-primary shadow-sm"
|
|
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-primary"
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function FormatIcon({ format }: { format: DocumentFormat }) {
|
|
if (format === "PDF") return <FileText />;
|
|
if (format === "DOCX") return <FileText />;
|
|
if (format === "XLSX") return <FileSpreadsheet />;
|
|
if (format === "PNG" || format === "JPG") return <FileImage />;
|
|
return <File />;
|
|
}
|
|
|
|
function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
|
return (
|
|
<Card className="p-5">
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-3">
|
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
|
|
<FormatIcon format={doc.format} />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-semibold text-slate-900">
|
|
{doc.name}
|
|
</p>
|
|
<p className="text-xs text-slate-500">{doc.type}</p>
|
|
</div>
|
|
</div>
|
|
<StatusBadge status={doc.status} />
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
<MetaRow
|
|
label="Linked to"
|
|
value={`${doc.linkedType} · ${doc.linkedReference}`}
|
|
/>
|
|
<MetaRow label="Format" value={doc.format} />
|
|
<MetaRow label="Size" value={formatBytes(doc.sizeBytes)} />
|
|
<MetaRow label="Uploaded" value={doc.uploadedAt} />
|
|
</div>
|
|
|
|
<p className="text-xs text-slate-500">By {doc.uploadedBy}</p>
|
|
|
|
<div
|
|
className="flex items-center justify-end gap-2 border-t pt-3"
|
|
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
|
>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<MoreHorizontal />
|
|
Actions
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem>
|
|
<Eye />
|
|
Preview
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem>
|
|
<Download />
|
|
Download
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<NewDocumentPage
|
|
mode="edit"
|
|
document={{
|
|
name: doc.name,
|
|
type: doc.type,
|
|
status: doc.status,
|
|
linkedType: doc.linkedType,
|
|
linkedReference: doc.linkedReference,
|
|
notes: doc.notes,
|
|
}}
|
|
>
|
|
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
<Pencil />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
</NewDocumentPage>
|
|
<DropdownMenuSeparator />
|
|
<DeleteDocumentDialog documentName={doc.name}>
|
|
<DropdownMenuItem
|
|
onSelect={(e: Event) => e.preventDefault()}
|
|
variant="destructive"
|
|
>
|
|
<Trash2 />
|
|
Delete
|
|
</DropdownMenuItem>
|
|
</DeleteDocumentDialog>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div>
|
|
<p className="text-xs text-slate-500">{label}</p>
|
|
<p className="mt-0.5 text-sm font-medium text-slate-900">{value}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: DocumentStatus }) {
|
|
const styles: Record<DocumentStatus, string> = {
|
|
Draft: "bg-slate-100 text-slate-600",
|
|
"Pending Review": "bg-amber-100 text-amber-700",
|
|
Approved: "bg-emerald-100 text-emerald-700",
|
|
Rejected: "bg-red-100 text-red-700",
|
|
Expired: "bg-slate-200 text-slate-700",
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|