Files
edr-platform/apps/edr-freight-web/portal/src/pages/documents/DocumentsPage.tsx

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>
);
}