diff --git a/src/app/(app)/invoicing/page.test.tsx b/src/app/(app)/invoicing/page.test.tsx index e54516f..a4a95a6 100644 --- a/src/app/(app)/invoicing/page.test.tsx +++ b/src/app/(app)/invoicing/page.test.tsx @@ -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" }, }); diff --git a/src/app/(app)/invoicing/page.tsx b/src/app/(app)/invoicing/page.tsx index 7beb7c7..6f449d0 100644 --- a/src/app/(app)/invoicing/page.tsx +++ b/src/app/(app)/invoicing/page.tsx @@ -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(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(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={ -
+
) : customersLoading && customers.length === 0 ? ( -
+
Loading QuickBooks customers…
) : ( - +
+ +
)}
@@ -649,7 +707,7 @@ function SingleOrderQbModal({
@@ -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(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(""); 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} />