292 lines
9.1 KiB
TypeScript
292 lines
9.1 KiB
TypeScript
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
|
import { useNavigate } from "react-router-dom";
|
|
import { Layout } from "@/components/layout/Layout";
|
|
import {
|
|
DataTable,
|
|
FilterDropdown,
|
|
Pagination,
|
|
PrimaryButton,
|
|
type Column,
|
|
} from "@/components/shared";
|
|
import { documentService } from "@/services/document-service";
|
|
import type { DocumentCategory, DocumentSummary } from "@/types/document";
|
|
import { Plus } from "lucide-react";
|
|
|
|
const formatDate = (value?: string | null): string => {
|
|
if (!value) return "-";
|
|
return new Date(value).toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
});
|
|
};
|
|
|
|
const toLabel = (value: string): string =>
|
|
value
|
|
.split("_")
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
|
|
const Documents = (): ReactElement => {
|
|
const navigate = useNavigate();
|
|
const [documents, setDocuments] = useState<DocumentSummary[]>([]);
|
|
const [categories, setCategories] = useState<DocumentCategory[]>([]);
|
|
const [statuses, setStatuses] = useState<Array<{ code: string; name: string }>>(
|
|
[],
|
|
);
|
|
const [types, setTypes] = useState<Array<{ code: string; name: string }>>([]);
|
|
const [search, setSearch] = useState("");
|
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
|
const [categoryFilter, setCategoryFilter] = useState<string | null>(null);
|
|
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [limit, setLimit] = useState(10);
|
|
const [total, setTotal] = useState(0);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const offset = (currentPage - 1) * limit;
|
|
const totalPages = Math.max(1, Math.ceil(total / limit));
|
|
|
|
useEffect(() => {
|
|
const loadDropdownData = async (): Promise<void> => {
|
|
try {
|
|
const [categoriesRes, statusesRes, typesRes] = await Promise.all([
|
|
documentService.getCategories(),
|
|
documentService.getStatuses(),
|
|
documentService.getTypes(),
|
|
]);
|
|
setCategories(categoriesRes.data || []);
|
|
setStatuses(statusesRes.data || []);
|
|
setTypes(typesRes.data || []);
|
|
} catch {
|
|
// Keep page usable even if some filter metadata endpoints fail.
|
|
}
|
|
};
|
|
|
|
void loadDropdownData();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const loadDocuments = async (): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const response = await documentService.list({
|
|
status: statusFilter || undefined,
|
|
category_id: categoryFilter || undefined,
|
|
document_type: typeFilter || undefined,
|
|
search: search.trim() || undefined,
|
|
limit,
|
|
offset,
|
|
});
|
|
setDocuments(response.data || []);
|
|
setTotal(response.pagination?.total || 0);
|
|
} catch (err: any) {
|
|
setError(
|
|
err?.response?.data?.error?.message || "Failed to load documents",
|
|
);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadDocuments();
|
|
}, [statusFilter, categoryFilter, typeFilter, search, limit, offset]);
|
|
|
|
const columns: Column<DocumentSummary>[] = useMemo(
|
|
() => [
|
|
{
|
|
key: "document_number",
|
|
label: "Document No",
|
|
render: (doc) => (
|
|
<button
|
|
type="button"
|
|
className="text-[#084cc8] hover:underline"
|
|
onClick={() => navigate(`/tenant/documents/${doc.id}`)}
|
|
>
|
|
{doc.document_number}
|
|
</button>
|
|
),
|
|
},
|
|
{
|
|
key: "title",
|
|
label: "Title",
|
|
render: (doc) => <span className="text-[#0f1724]">{doc.title}</span>,
|
|
},
|
|
{
|
|
key: "document_type",
|
|
label: "Type",
|
|
render: (doc) => (
|
|
<span className="text-[#0f1724]">{doc.document_type || "-"}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "category",
|
|
label: "Category",
|
|
render: (doc) => <span className="text-[#0f1724]">{doc.category || "-"}</span>,
|
|
},
|
|
{
|
|
key: "status",
|
|
label: "Status",
|
|
render: (doc) => (
|
|
<span className="inline-flex items-center rounded-md bg-[#112868]/10 px-2 py-1 text-[11px] text-[#112868]">
|
|
{toLabel(doc.status)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "module_name",
|
|
label: "Module",
|
|
render: (doc) => (
|
|
<span className="text-[#0f1724]">{doc.module_name || "Platform"}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "current_version",
|
|
label: "Version",
|
|
render: (doc) => (
|
|
<span className="text-[#0f1724]">{doc.current_version || "-"}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "updated_at",
|
|
label: "Updated",
|
|
render: (doc) => (
|
|
<span className="text-[#6b7280]">{formatDate(doc.updated_at)}</span>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
label: "Actions",
|
|
render: (doc) => (
|
|
<button
|
|
type="button"
|
|
className="text-xs text-[#084cc8] hover:underline font-medium"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
navigate(`/tenant/documents/edit/${doc.id}`);
|
|
}}
|
|
>
|
|
Edit
|
|
</button>
|
|
),
|
|
},
|
|
],
|
|
[navigate],
|
|
);
|
|
|
|
return (
|
|
<Layout
|
|
currentPage="Document Service"
|
|
pageHeader={{
|
|
title: "Document List",
|
|
description:
|
|
"Manage controlled documents, track versions and open document details.",
|
|
tabs: [
|
|
{ label: "Document List", path: "/tenant/documents" },
|
|
{ label: "Create Document", path: "/tenant/documents/create" },
|
|
{ label: "Category Management", path: "/tenant/documents/categories" },
|
|
],
|
|
}}
|
|
>
|
|
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg shadow-[0px_4px_24px_0px_rgba(0,0,0,0.02)] overflow-hidden">
|
|
<div className="border-b border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3 flex flex-col gap-3">
|
|
<div className="flex flex-col md:flex-row gap-2 md:items-center md:justify-between">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<FilterDropdown
|
|
label="Status"
|
|
options={statuses.map((status) => ({
|
|
value: status.code,
|
|
label: status.name,
|
|
}))}
|
|
value={statusFilter}
|
|
onChange={(value) => {
|
|
setStatusFilter(value as string | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="All"
|
|
/>
|
|
<FilterDropdown
|
|
label="Category"
|
|
options={categories.map((category) => ({
|
|
value: category.id,
|
|
label: category.name,
|
|
}))}
|
|
value={categoryFilter}
|
|
onChange={(value) => {
|
|
setCategoryFilter(value as string | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="All"
|
|
/>
|
|
<FilterDropdown
|
|
label="Type"
|
|
options={types.map((type) => ({
|
|
value: type.code,
|
|
label: type.name,
|
|
}))}
|
|
value={typeFilter}
|
|
onChange={(value) => {
|
|
setTypeFilter(value as string | null);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="All"
|
|
/>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
className="h-10 px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-xs text-[#0f1724] hover:bg-gray-50"
|
|
onClick={() => navigate("/tenant/documents/categories")}
|
|
>
|
|
Manage Categories
|
|
</button>
|
|
<PrimaryButton onClick={() => navigate("/tenant/documents/create")}>
|
|
<Plus className="w-3.5 h-3.5 mr-1" />
|
|
New Document
|
|
</PrimaryButton>
|
|
</div>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
value={search}
|
|
onChange={(e) => {
|
|
setSearch(e.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
placeholder="Search by title, description or document number"
|
|
className="h-10 w-full max-w-xl px-3 border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
|
|
/>
|
|
</div>
|
|
|
|
<DataTable
|
|
data={documents}
|
|
columns={columns}
|
|
keyExtractor={(doc) => doc.id}
|
|
emptyMessage="No documents found"
|
|
isLoading={isLoading}
|
|
error={error}
|
|
/>
|
|
|
|
{total > 0 && (
|
|
<Pagination
|
|
currentPage={currentPage}
|
|
totalPages={totalPages}
|
|
totalItems={total}
|
|
limit={limit}
|
|
onPageChange={setCurrentPage}
|
|
onLimitChange={(value) => {
|
|
setLimit(value);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</Layout>
|
|
);
|
|
};
|
|
|
|
export default Documents;
|
|
|