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:
parent
7ebfb8b86b
commit
a6750b09e1
3 changed files with 129 additions and 57 deletions
|
|
@ -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" },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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'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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue