feat(invoicing): add editable company search to QB modal

- Lift customer query state to parent page to maintain consistent cache keys for QB customer lookups
- Restructure SingleOrderQbModal UI into grouped sections for better organization
- Update modal footer padding and add mobile-responsive layout
- Revise line item input labels and add accessible aria labels matching test selectors
- Refactor QB customer lookup effects and callbacks to support edited search queries
- Fix test selectors for invoice quantity and price fields to match updated labels
This commit is contained in:
SJCoetz 2026-07-14 13:00:12 +02:00
parent 7ebfb8b86b
commit a6750b09e1
3 changed files with 129 additions and 57 deletions

View file

@ -580,10 +580,10 @@ describe("InvoicingPage", () => {
fireEvent.change(screen.getByLabelText("Invoice order number"), {
target: { value: "SO-OVERRIDE" },
});
fireEvent.change(screen.getByLabelText("Qty — Wheel hub kit"), {
fireEvent.change(screen.getByLabelText("Qty for Wheel hub kit"), {
target: { value: "4" },
});
fireEvent.change(screen.getByLabelText("Selling price — Wheel hub kit"), {
fireEvent.change(screen.getByLabelText("Selling price for Wheel hub kit"), {
target: { value: "78.5" },
});

View file

@ -402,6 +402,8 @@ function SingleOrderQbModal({
onRequestLoad,
qbCompany,
onQbCompanyChange,
customerQuery,
onCustomerQueryChange,
}: {
open: boolean;
group: QbGroup | null;
@ -412,9 +414,21 @@ function SingleOrderQbModal({
onClose: () => void;
onSubmit: SubmitQbInvoice;
busy: boolean;
onRequestLoad: (group: QbGroup, billingCompany: QbCompany) => void;
/**
* The modal can override the search query (e.g. when the operator
* edits the company name spelling before sending to QuickBooks). When
* omitted, the parent uses the order's stored `companyName`.
*/
onRequestLoad: (group: QbGroup, billingCompany: QbCompany, customerQueryOverride?: string) => void;
qbCompany: QbCompany | null;
onQbCompanyChange: (next: QbCompany) => void;
/**
* The (possibly edited) company name we'll send to the QuickBooks
* webhook. Owned by the parent so the (query, billingCompany) cache
* key stays consistent with what the modal is actually searching for.
*/
customerQuery: string;
onCustomerQueryChange: (next: string) => void;
}) {
const [customer, setCustomer] = useState<QbCustomer | null>(null);
const [orderNumberOverride, setOrderNumberOverride] = useState("");
@ -428,19 +442,37 @@ function SingleOrderQbModal({
// (e.g. "GIELIE") is just visual context on the row.
const orderCompanyName = group?.rows[0]?.companyName ?? "";
const orderCustomerName = group?.rows[0]?.customerName ?? "";
// Pre-select closest match on (re)open. The QB company toggle state is
// owned by the parent (so the cache lookup can use it), so we just seed
// it from the order's companyName when the modal opens with no pick yet.
// Pre-select the closest customer match when the customer list
// changes. Re-runs whenever the parent delivers a fresh list so the
// picker doesn't sit on a stale value. `setCustomer((prev) => prev ?? …)`
// keeps an explicit operator pick sticky — only the initial select
// is automated, never an override.
useEffect(() => {
if (open) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- pre-select closest match on (re)open
setCustomer((prev) => prev ?? pickClosestCustomer(customers, orderCompanyName));
if (!qbCompany) {
const initial = pickInitialQbCompany(orderCompanyName);
if (initial) onQbCompanyChange(initial);
if (!open) return;
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial preselect only when no explicit pick yet
setCustomer((prev) => prev ?? pickClosestCustomer(customers, customerQuery));
}, [open, customers, customerQuery]);
// Auto-pick a billing company (CNL / ISHERWOOD) on open and fire the
// initial customer fetch. Runs once per open transition (depends only
// on `open` + `group` identity) so typing in the search field doesn't
// re-trigger the webhook. The explicit toggle click (`handleCompanyPicked`)
// is the path for "I edited the name, re-search now".
useEffect(() => {
if (!open || !group) return;
if (!qbCompany) {
const initial = pickInitialQbCompany(orderCompanyName);
if (initial) {
onQbCompanyChange(initial);
onRequestLoad(group, initial, customerQuery);
}
} else {
// qbCompany already set (modal was reopened) — re-fetch with the
// current query so the visible customer list stays in sync.
onRequestLoad(group, qbCompany, customerQuery);
}
}, [open, customers, orderCompanyName, qbCompany, onQbCompanyChange]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: only on open/close transition
}, [open, group]);
useEffect(() => {
if (!open || !group) return;
// The pre-existing sibling effect (preselect closest QB customer) is
@ -489,31 +521,20 @@ function SingleOrderQbModal({
};
}),
};
// Drive the fetch off the operator's QB-company selection. Until they
// pick CNL or ISHERWOOD, no customer request fires — the picker just
// shows an empty state.
//
// Stable match key: depend on the resolved cache-key string instead of
// the unstable `group` object (which is recreated on every parent render).
// The latest group is read via ref so the loader closure stays current.
const groupRef = useRef<QbGroup | null>(group);
useEffect(() => {
groupRef.current = group;
}, [group]);
const requestLoadRef = useRef(onRequestLoad);
useEffect(() => {
requestLoadRef.current = onRequestLoad;
}, [onRequestLoad]);
const matchKey = open && qbCompany ? makeMatchKey(orderCompanyName, qbCompany) : "";
useEffect(() => {
if (!matchKey) return;
const g = groupRef.current;
if (g) requestLoadRef.current(g, qbCompany!);
// We intentionally depend only on the matchKey string. The group /
// qbCompany / onRequestLoad values are read from refs so the effect
// doesn't re-fire on every parent re-render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [matchKey]);
// Drive the customer fetch off explicit user actions only — clicking
// a billing company (CNL / ISHERWOOD) or the auto-pick on open. We
// intentionally do NOT refetch on every keystroke of the editable
// `customerQuery` field, so the operator can fix the spelling in
// peace and only fire the webhook when they pick (or re-pick) a
// billing company. Re-clicking the same company is a valid "search
// again with my edits" gesture.
const handleCompanyPicked = useCallback(
(next: QbCompany) => {
onQbCompanyChange(next);
if (group) onRequestLoad(group, next, customerQuery);
},
[group, onQbCompanyChange, onRequestLoad, customerQuery],
);
if (!group) return null;
const allIncluded =
group.rows.length > 0 && group.rows.every((row) => includedRowIds.has(row.id));
@ -528,7 +549,7 @@ function SingleOrderQbModal({
title="Send to QuickBooks"
description={`Pick a QuickBooks customer for order ${group.orderNumber || "no order number"} (${orderCompanyName || "no billing company"}, ${orderCustomerName || "no customer"}).`}
footer={
<div className="flex justify-end gap-2">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>Cancel</Button>
<Button
type="button"
@ -544,26 +565,63 @@ function SingleOrderQbModal({
</div>
}
>
<div className="mb-3 flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">QuickBooks company</span>
<QbCompanyToggle value={qbCompany} onChange={onQbCompanyChange} />
</div>
<section className="rounded-lg border bg-card/50 p-3 sm:p-4">
<header className="mb-3 flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" aria-hidden />
<h3 className="text-sm font-semibold tracking-tight">QuickBooks recipient</h3>
</header>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<label
htmlFor="qb-customer-query"
className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground"
>
Company name to search
</label>
<input
id="qb-customer-query"
type="text"
value={customerQuery}
onChange={(event) => onCustomerQueryChange(event.currentTarget.value)}
placeholder="e.g. Adal Engineering"
className="w-full rounded-md border bg-background px-3 py-2 text-sm shadow-sm focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
<p className="text-xs text-muted-foreground">
Sent to the QuickBooks webhook to find the matching customer.
Edit if the spelling differs from what&apos;s in QuickBooks, then
pick a billing company to re-search.
</p>
</div>
<div className="space-y-1.5">
<span className="text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
Billing company
</span>
<QbCompanyToggle value={qbCompany} onChange={handleCompanyPicked} />
<p className="text-xs text-muted-foreground">
Which QuickBooks company file to search in. Re-click to re-run
the search with your edits above.
</p>
</div>
</div>
</section>
{!qbCompany ? (
<p className="text-sm text-muted-foreground">
<p className="mt-3 text-sm text-muted-foreground">
Pick a QuickBooks company (CNL or ISHERWOOD) to load customers.
</p>
) : customersError ? (
<div className="space-y-2">
<div className="mt-3 space-y-2">
<p className="text-sm text-destructive">{customersError}</p>
<Button type="button" size="sm" variant="outline" onClick={onRetry}>Retry</Button>
</div>
) : customersLoading && customers.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="mt-3 flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Loading QuickBooks customers…
</div>
) : (
<QbCustomerPicker value={customer} onChange={setCustomer} customers={customers} />
<div className="mt-3">
<QbCustomerPicker value={customer} onChange={setCustomer} customers={customers} />
</div>
)}
<div className="mt-4 space-y-3 border-t pt-4">
<div className="flex flex-wrap items-end justify-between gap-2">
@ -649,7 +707,7 @@ function SingleOrderQbModal({
</div>
</div>
<label className="space-y-1 text-sm" htmlFor={qtyId}>
<span>{`Qty — ${label}`}</span>
<span>Qty</span>
<input
id={qtyId}
type="number"
@ -666,11 +724,12 @@ function SingleOrderQbModal({
}));
}}
disabled={!included}
aria-label={`Qty for ${label}`}
className="w-full rounded-md border bg-background px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-muted"
/>
</label>
<label className="space-y-1 text-sm" htmlFor={priceId}>
<span>{`Selling price — ${label}`}</span>
<span>Selling price</span>
<input
id={priceId}
type="number"
@ -688,6 +747,7 @@ function SingleOrderQbModal({
}));
}}
disabled={!included}
aria-label={`Selling price for ${label}`}
className="w-full rounded-md border bg-background px-3 py-2 text-sm disabled:cursor-not-allowed disabled:bg-muted"
/>
</label>
@ -947,6 +1007,12 @@ export default function InvoicingPage() {
// the parent can compute the right cache key for (customerQuery, qbCompany)
// and the modal just renders the toggle + invokes ensureCustomers.
const [singleQbCompany, setSingleQbCompany] = useState<QbCompany | null>(null);
// Editable search query for the single-order modal. The operator can
// fix the spelling of the order's company name before we hit the
// QuickBooks webhook — same column as the auto-pick, just editable.
// Lifted to the parent so the (customerQuery, qbCompany) cache key
// stays consistent with what the modal actually searched for.
const [singleCustomerQuery, setSingleCustomerQuery] = useState<string>("");
const load = useCallback(async () => {
setRows(null);
@ -1236,8 +1302,11 @@ export default function InvoicingPage() {
const openQbSingle = useCallback((group: QbGroup) => {
// Clear any previous pick so the modal opens on a fresh state and the
// toggle re-seeds from the new group's companyName.
// toggle re-seeds from the new group's companyName. Reset the
// editable search query to the order's company — the operator can
// fix the spelling in the modal before clicking CNL/ISHERWOOD.
setSingleQbCompany(null);
setSingleCustomerQuery(group.rows[0]?.companyName ?? "");
setQbModal({ group });
}, []);
@ -1321,9 +1390,9 @@ export default function InvoicingPage() {
}, []);
// Single-order modal lookup keyed by (customer_query, billing_company).
const singleCustomerQuery = qbModalGroup
? qbModalGroup.rows[0]?.companyName ?? ""
: "";
// `singleCustomerQuery` is editable in the modal (operator can fix the
// spelling before sending to QB) — defaulted to the order's company
// on open, and used as-is in the cache key.
const singleMatchKey = qbModalGroup && singleQbCompany
? makeMatchKey(singleCustomerQuery, singleQbCompany)
: "";
@ -1332,11 +1401,12 @@ export default function InvoicingPage() {
const activeCustomersLoading = !!(singleMatchKey && customerLoading[singleMatchKey]);
const handleSingleRequestLoad = useCallback(
(group: QbGroup, billingCompany: QbCompany) => {
(group: QbGroup, billingCompany: QbCompany, customerQueryOverride?: string) => {
// The webhook match is the company (e.g. "Adal Engineering"), not
// the customer (e.g. "GIELIE") — QuickBooks customers are billed
// to the company.
const customerQuery = group.rows[0]?.companyName ?? "";
// to the company. The modal can override the search query (the
// operator may have fixed the spelling in the editable field).
const customerQuery = customerQueryOverride ?? group.rows[0]?.companyName ?? "";
void ensureCustomers(customerQuery, billingCompany);
},
[ensureCustomers],
@ -1612,6 +1682,8 @@ export default function InvoicingPage() {
onRequestLoad={handleSingleRequestLoad}
qbCompany={singleQbCompany}
onQbCompanyChange={setSingleQbCompany}
customerQuery={singleCustomerQuery}
onCustomerQueryChange={setSingleCustomerQuery}
/>
<BulkQbModal
open={qbBulkOpen}

View file

@ -146,7 +146,7 @@
@apply flex-1 overflow-y-auto px-6 py-5;
}
.modal-shell__footer {
@apply flex flex-row flex-wrap items-center justify-end gap-2 border-t px-6 py-3;
@apply flex flex-row flex-wrap items-center justify-end gap-2 border-t px-6 pt-3 pb-5;
}
.modal-shell__footer--split {
@apply justify-between;