seahaven-ap/src/dashboard/DashboardGrids.tsx

335 lines
10 KiB
TypeScript

import { useMemo, useState } from "react";
import { Alert, Box, CircularProgress, Stack, Tab, Tabs, Typography } from "@mui/material";
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
import { ColumnsDrawer } from "@/dashboard/ColumnsDrawer";
import { InvoiceFilterToolbar } from "@/dashboard/InvoiceFilterToolbar";
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
import { columnsForApprovedGrid, columnsFromCatalogKeys } from "@/dashboard/columns";
import {
loadColumnPrefs,
resetColumnPrefs,
saveColumnPrefs,
type ColumnPrefs,
} from "@/dashboard/column-prefs";
import { EMPTY_INVOICE_FILTERS, type InvoiceFilterState } from "@/dashboard/invoice-filters";
import { InvoiceTabLabel } from "@/dashboard/invoice-tabs";
import { countSelectedRows } from "@/dashboard/selection";
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
import { areMocksEnabled, type MockInvoice } from "@/mocks";
import { useNavigate } from "@/router";
type InvoiceTab = "attention" | "all" | "approved" | "rejected" | "late";
const emptySelection: GridRowSelectionModel = {
type: "include",
ids: new Set(),
};
const emptyFilters = EMPTY_INVOICE_FILTERS;
const rowClickSx = {
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
cursor: "pointer",
borderRadius: 1,
} as const;
const invoiceTabsSx = {
minHeight: 44,
"& .MuiTab-root": {
bgcolor: "rgba(47,128,237,0.12)",
mr: 0.5,
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
color: "text.primary",
textTransform: "none",
},
"& .Mui-selected": {
bgcolor: "#fff !important",
color: "text.primary !important",
},
"& .MuiTabs-indicator": {
height: 3,
borderRadius: 1,
backgroundColor: "#7B61FF",
},
} as const;
function applyInvoiceFilters(rows: MockInvoice[], filters: InvoiceFilterState): MockInvoice[] {
const vendorQuery = filters.vendorQuery.trim().toLowerCase();
return rows.filter((invoice) => {
if (filters.action && invoice.actionLabel !== filters.action) {
return false;
}
if (vendorQuery && !invoice.vendorName.toLowerCase().includes(vendorQuery)) {
return false;
}
return true;
});
}
export function DashboardGrids() {
const navigate = useNavigate();
const [tab, setTab] = useState<InvoiceTab>("attention");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const [filters, setFilters] = useState<InvoiceFilterState>(emptyFilters);
const [columnsDrawerOpen, setColumnsDrawerOpen] = useState(false);
const [, setFilterDrawerOpen] = useState(false);
const [columnPrefs, setColumnPrefs] = useState<ColumnPrefs>(() => loadColumnPrefs());
const { data: invoices = [], isLoading, isError, error } = useInvoices();
const updatePayDate = useUpdateInvoicePayDate();
const catalogColumns = useMemo(
() => columnsFromCatalogKeys(columnPrefs.visibleOrder),
[columnPrefs.visibleOrder],
);
const approvedColumns = useMemo(
() => columnsForApprovedGrid(columnPrefs.visibleOrder),
[columnPrefs.visibleOrder],
);
const openInvoice = (params: GridRowParams<MockInvoice>) => {
navigate("/invoices/:id", { params: { id: params.row.id } });
};
const attentionRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
[invoices],
);
const approvedRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "approved"),
[invoices],
);
const rejectedRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "rejected"),
[invoices],
);
const lateRows = useMemo(() => {
const today = new Date().toISOString().slice(0, 10);
return invoices.filter(
(invoice) =>
invoice.dueDate < today && invoice.status !== "paid" && invoice.status !== "rejected",
);
}, [invoices]);
const filteredAttention = useMemo(
() => applyInvoiceFilters(attentionRows, filters),
[attentionRows, filters],
);
const filteredAll = useMemo(() => applyInvoiceFilters(invoices, filters), [invoices, filters]);
const filteredApproved = useMemo(
() => applyInvoiceFilters(approvedRows, filters),
[approvedRows, filters],
);
const filteredRejected = useMemo(
() => applyInvoiceFilters(rejectedRows, filters),
[rejectedRows, filters],
);
const filteredLate = useMemo(() => applyInvoiceFilters(lateRows, filters), [lateRows, filters]);
const selectedCount = countSelectedRows(
selection,
approvedRows.map((invoice) => invoice.id),
);
const processRowUpdate = async (newRow: MockInvoice, oldRow: MockInvoice) => {
if (newRow.payDate === oldRow.payDate) {
return oldRow;
}
const nextPayDate = newRow.payDate?.trim() ? newRow.payDate.trim() : null;
return updatePayDate.mutateAsync({ id: newRow.id, payDate: nextPayDate });
};
const updateColumnPrefs = (next: ColumnPrefs) => {
setColumnPrefs(next);
saveColumnPrefs(next);
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label="Loading invoices" />
</Box>
);
}
if (isError) {
return (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load invoices"}
</Alert>
);
}
return (
<Stack spacing={0} data-testid="invoice-processing">
<Box sx={{ bgcolor: "#E8EEF7", px: 1, pt: 1 }}>
<Tabs
value={tab}
onChange={(_event, value: InvoiceTab) => {
setTab(value);
setSelection(emptySelection);
}}
variant="scrollable"
aria-label="Invoice Processing tabs"
sx={invoiceTabsSx}
>
<Tab
label={
<InvoiceTabLabel
title="For My Attention"
count={filteredAttention.length}
active={tab === "attention"}
/>
}
value="attention"
data-testid="tab-pending"
/>
<Tab
label={
<InvoiceTabLabel
title="New Invoices"
count={filteredAll.length}
active={tab === "all"}
/>
}
value="all"
data-testid="tab-all"
/>
<Tab
label={
<InvoiceTabLabel
title="Approved Invoices"
count={filteredApproved.length}
active={tab === "approved"}
/>
}
value="approved"
data-testid="tab-ready"
/>
<Tab
label={
<InvoiceTabLabel
title="Rejected Invoices"
count={filteredRejected.length}
active={tab === "rejected"}
/>
}
value="rejected"
data-testid="tab-rejected"
/>
<Tab
label={
<InvoiceTabLabel
title="Late Invoices"
count={filteredLate.length}
active={tab === "late"}
urgency
/>
}
value="late"
data-testid="tab-late"
/>
</Tabs>
</Box>
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
{filteredAttention.length > 0 && tab === "attention" ? (
<Typography
data-testid="attention-banner"
variant="body2"
sx={{ display: "block", mb: 1.5, color: "primary.main", textAlign: "left" }}
>
{filteredAttention.length} invoices require approval.{" "}
<Box component="span" sx={{ textDecoration: "underline" }}>
Show invoices ready to approve.
</Box>
</Typography>
) : null}
<InvoiceFilterToolbar
filters={filters}
onFiltersChange={setFilters}
onOpenFilterDrawer={() => setFilterDrawerOpen(true)}
onOpenColumnsDrawer={() => setColumnsDrawerOpen(true)}
trailingActions={
tab === "approved" ? <CreatePayRunButton selectedCount={selectedCount} /> : null
}
/>
{tab === "attention" && (
<InvoiceDataGrid
data-testid="grid-pending"
rows={filteredAttention}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{tab === "all" && (
<InvoiceDataGrid
data-testid="grid-all"
rows={filteredAll}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{tab === "approved" && (
<InvoiceDataGrid
data-testid="grid-ready"
rows={filteredApproved}
columns={approvedColumns}
checkboxSelection
rowSelectionModel={selection}
onRowSelectionModelChange={(model) => setSelection(model)}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={(processError) => {
console.error(processError);
}}
sx={rowClickSx}
/>
)}
{tab === "rejected" && (
<InvoiceDataGrid
data-testid="grid-rejected"
rows={filteredRejected}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{tab === "late" && (
<InvoiceDataGrid
data-testid="grid-late"
rows={filteredLate}
columns={catalogColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
<Typography variant="body2" data-testid="mocks-status" sx={{ mt: 2 }}>
Mocks {areMocksEnabled() ? "enabled" : "disabled"} — {invoices.length} invoices
</Typography>
</Box>
<ColumnsDrawer
open={columnsDrawerOpen}
onClose={() => setColumnsDrawerOpen(false)}
prefs={columnPrefs}
onPrefsChange={updateColumnPrefs}
onReset={() => {
const next = resetColumnPrefs();
setColumnPrefs(next);
}}
/>
</Stack>
);
}