seahaven-ap/src/dashboard/PaymentProcessingGrids.tsx
Adam Moussa 46b570352a
feat(shared): add payment ladder and CSV domain package (AP-13) (#11)
* feat(shared): add payment ladder and CSV domain package

Introduce @seahaven-ap/shared via npm workspaces so FE and future API share
payments-dashboard-compatible status ladders, pay-date helpers, and CSV headers.

* fix(web): build shared package before vite dev

Ensure fresh npm ci && npm run dev (and Playwright webServer) can resolve
@seahaven-ap/shared dist exports that are gitignored.
2026-08-10 19:28:02 -04:00

238 lines
7.6 KiB
TypeScript

import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Stack,
Tab,
Tabs,
Typography,
} from "@mui/material";
import type { GridRowParams, GridRowSelectionModel } from "@mui/x-data-grid";
import { isReadyToPay } from "@seahaven-ap/shared";
import { CreatePayRunButton } from "@/dashboard/CreatePayRunButton";
import { InvoiceDataGrid } from "@/dashboard/InvoiceDataGrid";
import { pendingApprovalColumns, readyToPayColumns } from "@/dashboard/columns";
import { countSelectedRows } from "@/dashboard/selection";
import { useInvoices, useUpdateInvoicePayDate } from "@/domain/invoices/use-invoices";
import type { MockInvoice } from "@/mocks";
import { useNavigate } from "@/router";
type PaymentTab = "outside" | "select" | "pending" | "scheduled" | "errors" | "outbox";
const emptySelection: GridRowSelectionModel = {
type: "include",
ids: new Set(),
};
const rowClickSx = {
border: 1,
borderColor: "divider",
backgroundColor: "background.paper",
cursor: "pointer",
borderRadius: 1,
} as const;
export function PaymentProcessingGrids() {
const navigate = useNavigate();
const [tab, setTab] = useState<PaymentTab>("select");
const [selection, setSelection] = useState<GridRowSelectionModel>(emptySelection);
const { data: invoices = [], isLoading, isError, error } = useInvoices();
const updatePayDate = useUpdateInvoicePayDate();
const openInvoice = (params: GridRowParams<MockInvoice>) => {
navigate("/invoices/:id", { params: { id: params.row.id } });
};
const selectRows = useMemo(
() => invoices.filter((invoice) => isReadyToPay(invoice.status)),
[invoices],
);
const pendingRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "pending_approval"),
[invoices],
);
const scheduledRows = useMemo(
() => invoices.filter((invoice) => invoice.status === "scheduled"),
[invoices],
);
const selectedCount = countSelectedRows(
selection,
selectRows.map((invoice) => invoice.id),
);
const totalDue = selectRows.reduce((sum, row) => sum + row.amountDue, 0);
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 });
};
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", py: 6 }}>
<CircularProgress aria-label="Loading payments" />
</Box>
);
}
if (isError) {
return (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load payments"}
</Alert>
);
}
return (
<Stack spacing={0} data-testid="payment-processing">
<Box sx={{ bgcolor: "#E8EEF7", px: 1, pt: 1 }}>
<Tabs
value={tab}
onChange={(_event, value: PaymentTab) => {
setTab(value);
setSelection(emptySelection);
}}
variant="scrollable"
aria-label="Payment Processing tabs"
sx={{
minHeight: 44,
"& .MuiTab-root": {
bgcolor: "rgba(47,128,237,0.18)",
mr: 0.5,
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
color: "text.primary",
},
"& .Mui-selected": {
bgcolor: "#fff !important",
color: "text.primary !important",
},
"& .MuiTabs-indicator": { display: "none" },
}}
>
<Tab label="Paying Outside Stampli (0)" value="outside" data-testid="tab-pay-outside" />
<Tab
label={`Select to Pay (${selectRows.length})`}
value="select"
data-testid="tab-select-to-pay"
/>
<Tab
label={`Pending Approval (${pendingRows.length})`}
value="pending"
data-testid="tab-pay-pending"
/>
<Tab
label={`Scheduled (${scheduledRows.length})`}
value="scheduled"
data-testid="tab-scheduled"
/>
<Tab label="Payment Export Errors (0)" value="errors" data-testid="tab-export-errors" />
<Tab label="Outbox (0)" value="outbox" data-testid="tab-outbox" />
</Tabs>
</Box>
<Box sx={{ bgcolor: "background.paper", p: 2 }}>
<Alert severity="warning" sx={{ mb: 2 }} data-testid="expedite-banner">
Expedited payments are currently unavailable for you due to various factors. (parity stub)
</Alert>
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 2 }} flexWrap="wrap">
<Chip label="Vendor Name" variant="outlined" size="small" />
<Chip label="Payment Method" variant="outlined" size="small" />
<Chip label="Bank Account" variant="outlined" size="small" />
<Chip label="Status" variant="outlined" size="small" />
<Box sx={{ flexGrow: 1 }} />
<Button variant="outlined" size="small" data-testid="bulk-edit">
Bulk Edit
</Button>
<CreatePayRunButton selectedCount={selectedCount} />
</Stack>
{tab === "select" && (
<InvoiceDataGrid
data-testid="grid-select-to-pay"
rows={selectRows}
columns={readyToPayColumns}
checkboxSelection
rowSelectionModel={selection}
onRowSelectionModelChange={(model) => setSelection(model)}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={(processError) => {
console.error(processError);
}}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{tab === "pending" && (
<InvoiceDataGrid
data-testid="grid-pay-pending"
rows={pendingRows}
columns={pendingApprovalColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{tab === "scheduled" && (
<InvoiceDataGrid
data-testid="grid-scheduled"
rows={scheduledRows}
columns={readyToPayColumns}
onRowClick={openInvoice}
sx={rowClickSx}
/>
)}
{(tab === "outside" || tab === "errors" || tab === "outbox") && (
<Box
sx={{
py: 8,
textAlign: "center",
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
data-testid="payment-empty-stub"
>
<Typography color="text.secondary">No Result Found</Typography>
<Typography variant="caption" color="text.secondary">
parity stub
</Typography>
</Box>
)}
<Stack direction="row" spacing={4} sx={{ mt: 2 }} data-testid="payment-footer-totals">
<Typography variant="body2">
<Box component="span" color="text.secondary">
Total Due:{" "}
</Box>
<strong>
{totalDue.toLocaleString("en-US", { style: "currency", currency: "USD" })}
</strong>
</Typography>
<Typography variant="body2">
<Box component="span" color="text.secondary">
Total To Pay:{" "}
</Box>
<strong>$0.00</strong>
</Typography>
<Typography variant="body2">
<Box component="span" color="text.secondary">
Estimated USD:{" "}
</Box>
<strong>$0.00</strong>
</Typography>
</Stack>
</Box>
</Stack>
);
}