mirror of
https://github.com/Sea-Haven-Industries/seahaven-ap.git
synced 2026-09-30 22:03:17 +00:00
335 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|