diff --git a/specs/product/GLOSSARY.md b/specs/product/GLOSSARY.md index c89fcbcc0..d04b0beef 100644 --- a/specs/product/GLOSSARY.md +++ b/specs/product/GLOSSARY.md @@ -434,6 +434,10 @@ Cancelled`): drafts are fully editable (add/edit/remove lines, cancel); exist in the status enum for later phases; nothing sets them yet, and only `Confirmed` orders can be voided.) +Closing the order panel only hides it; it does not cancel the order. The panel +stays closed even if an earlier open finishes loading. Use **open** again to +view the order (#712). + **Worker sale allocation policy (#612)** — one farm setting deciding how a restricted plain **Worker's** confirmation may draw stock: `AssignedFlocksOnly` (default for every farm) tries the worker's own assigned flocks first, and diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index e8119e9e3..57b8ecd84 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -2790,7 +2790,8 @@ export const en = { + "read-only). The quantity counts units, not eggs — the field is labeled with the " + "unit and shows the resulting egg count while you type (2 trays = 60 eggs, not 60 trays). Each " + "line remembers how many eggs its unit held when it was added, so redefining a carton later never " - + "changes old orders.", + + "changes old orders. Choose close while an order loads to keep the panel closed; " + + "choose open again to view it.", salesConfirming: "Confirming an order allocates real stock — oldest lots first — and is the point " + "where inventory changes hands.", @@ -3312,7 +3313,7 @@ export const en = { "Undo of a mistaken confirm — stock returns to the exact lots it came from. Needs a reason.", glossaryCancelOrderTerm: "Cancel (order)", - glossaryCancelOrderDef: "Close a draft that never happened. No stock involved.", + glossaryCancelOrderDef: "Close a draft that never happened. No stock involved. The close link only hides the panel; use open to view it again.", glossaryInventoryItemTerm: "Inventory item", glossaryInventoryItemDef: diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 35495726e..72c5755c6 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -2264,7 +2264,8 @@ export const es = { + "cuenta unidades, no huevos — el campo indica la unidad y muestra el total de huevos " + "resultante mientras escribe (2 bandejas = 60 huevos, no 60 bandejas). Cada línea " + "recuerda cuántos huevos contenía su unidad cuando se agregó, así que redefinir un cartón más tarde " - + "nunca cambia pedidos antiguos.", + + "nunca cambia pedidos antiguos. Si el pedido aún se está cargando, cerrar mantiene " + + "el panel cerrado. Use abrir de nuevo para verlo.", salesConfirming: "Confirmar un pedido asigna existencias reales — los lotes más antiguos primero — y " + "es el punto donde el inventario cambia de manos.", @@ -2808,7 +2809,7 @@ export const es = { + "vinieron. Requiere un motivo.", glossaryCancelOrderTerm: "Cancelar (pedido)", - glossaryCancelOrderDef: "Cerrar un borrador que nunca se concretó. No involucra existencias.", + glossaryCancelOrderDef: "Cerrar un borrador que nunca se concretó. No involucra existencias. El enlace cerrar solo oculta el panel; use abrir para verlo de nuevo.", glossaryInventoryItemTerm: "Artículo de inventario", glossaryInventoryItemDef: diff --git a/web/src/i18n/tl.ts b/web/src/i18n/tl.ts index 8ec8914c0..25cefc737 100644 --- a/web/src/i18n/tl.ts +++ b/web/src/i18n/tl.ts @@ -2351,7 +2351,8 @@ export const tl = { + "Ang dami ay bilang ng mga unit, hindi mga itlog — nakasaad sa field ang unit at " + "ipinapakita nito ang kabuuang itlog habang nagta-type (2 tray = 60 itlog, hindi 60 tray). " + "Naaalala ng bawat linya kung ilang itlog ang laman ng unit nito noong idinagdag ito, kaya hindi " - + "kailanman binabago ng muling pagtukoy sa isang karton ang mga lumang order.", + + "kailanman binabago ng muling pagtukoy sa isang karton ang mga lumang order. Kung naglo-load pa ang order, " + + "mananatiling sarado ang panel kapag pinili ang isara. Piliin muli ang buksan para tingnan ang order.", salesConfirming: "Ang Pagkumpirma ng isang order ay naglalaan ng aktwal na stock — pinakalumang lote " + "muna — at ito ang sandali kung saan lumilipat ang inventory.", @@ -2905,7 +2906,7 @@ export const tl = { + "Kailangan ng dahilan.", glossaryCancelOrderTerm: "Kanselahin (order)", - glossaryCancelOrderDef: "Isara ang isang draft na hindi natuloy. Walang kinalaman na stock.", + glossaryCancelOrderDef: "Isara ang isang draft na hindi natuloy. Walang kinalaman na stock. Itinatago lang ng isara ang panel; piliin ang buksan para tingnan itong muli.", glossaryInventoryItemTerm: "Item sa imbentaryo", glossaryInventoryItemDef: diff --git a/web/src/routes/SalesPage.test.tsx b/web/src/routes/SalesPage.test.tsx index 468f44d68..97eff2421 100644 --- a/web/src/routes/SalesPage.test.tsx +++ b/web/src/routes/SalesPage.test.tsx @@ -2356,3 +2356,76 @@ it.each([false, true])("discards the line editor on Close before same-order relo expect(within(reopened).getByLabelText(i18n.t("sales:editQuantityAriaLabel"))).toHaveValue(9); expect(within(reopened).getByLabelText(i18n.t("sales:editUnitPriceAriaLabel"))).toHaveValue(4); }); + +// #712 — primary Open reads respect a later panel dismissal. +describe.each(["Draft", "Confirmed"] as const)("Sales Open dismissal (#712), %s panel", (status) => { + it.each([false, true])("stays closed after a held Open settles (different order: %s), then allows a fresh Open", async (different) => { + const original: SalesOrder = { ...DRAFT_TWO, status }; + const target: SalesOrder = different + ? { ...original, id: "other-order", referenceNumber: "SO-OTHER" } + : original; + mockListOrders.mockResolvedValue(different ? [original, target] : [original]); + mockGetOrder.mockResolvedValue(original); + await renderReady(); + const openRow = async (order: SalesOrder) => { + await act(async () => { + fireEvent.click(within(screen.getByRole("row", { name: new RegExp(order.referenceNumber) })) + .getByRole("button", { name: i18n.t("sales:open") })); + }); + }; + await openRow(original); + expect(document.querySelector(".order-panel")).not.toBeNull(); + + let resolveRead!: (order: SalesOrder) => void; + mockGetOrder.mockReturnValueOnce(new Promise((resolve) => { resolveRead = resolve; })); + await openRow(target); + const close = screen.getByRole("button", { name: i18n.t("sales:close") }); + expect(close).toBeEnabled(); + expect(within(screen.getByRole("row", { name: new RegExp(target.referenceNumber) })) + .getByRole("button", { name: i18n.t("sales:open") })).toBeDisabled(); + fireEvent.click(close); + expect(document.querySelector(".order-panel")).toBeNull(); + await act(async () => { resolveRead(target); }); + expect(document.querySelector(".order-panel")).toBeNull(); + + mockGetOrder.mockResolvedValue(target); + await openRow(target); + expect(document.querySelector(".order-panel")).not.toBeNull(); + expect(within(document.querySelector(".order-panel")!) + .getByText(new RegExp(target.referenceNumber))).toBeInTheDocument(); + }); +}); + +describe("Sales primary Open controls (#712)", () => { + it("opens from no panel", async () => { + mockListOrders.mockResolvedValue([DRAFT_TWO]); + mockGetOrder.mockResolvedValue(DRAFT_TWO); + await renderReady(); + expect(document.querySelector(".order-panel")).toBeNull(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: i18n.t("sales:open") })); }); + expect(document.querySelector(".order-panel")).not.toBeNull(); + }); + + it("shows an Open failure and allows retry", async () => { + mockListOrders.mockResolvedValue([DRAFT_TWO]); + mockGetOrder.mockRejectedValueOnce(new Error("Order read failed")).mockResolvedValue(DRAFT_TWO); + await renderReady(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: i18n.t("sales:open") })); }); + expect(document.querySelector(".order-panel")).toBeNull(); + expect(screen.getByText("Order read failed")).toBeInTheDocument(); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: i18n.t("sales:open") })); }); + expect(document.querySelector(".order-panel")).not.toBeNull(); + expect(screen.queryByText("Order read failed")).not.toBeInTheDocument(); + }); + + it("keeps a dismissed Open failure on the page and the panel closed", async () => { + await openOrder(DRAFT_TWO, /Grade A Dozen/); + let rejectRead!: (reason: Error) => void; + mockGetOrder.mockReturnValueOnce(new Promise((_, reject) => { rejectRead = reject; })); + await act(async () => { fireEvent.click(screen.getByRole("button", { name: i18n.t("sales:open") })); }); + fireEvent.click(screen.getByRole("button", { name: i18n.t("sales:close") })); + await act(async () => { rejectRead(new Error("Dismissed order read failed")); }); + expect(document.querySelector(".order-panel")).toBeNull(); + expect(screen.getByText("Dismissed order read failed")).toBeInTheDocument(); + }); +}); diff --git a/web/src/routes/SalesPage.tsx b/web/src/routes/SalesPage.tsx index d9cf7c90f..d35d5c266 100644 --- a/web/src/routes/SalesPage.tsx +++ b/web/src/routes/SalesPage.tsx @@ -228,8 +228,8 @@ export function SalesPage() { // (#474 → #477 → #479 → #625 → #702); the rules and the incidents that // earned them live with the hook. Sales-specific is only WHICH scopes own a // dialog, and that the page-level success message clears on each attempt. - const { busy, isPending, errors, run, openDialog, dismissDialog } = useDialogAction( - ["create-order", "record-payment"], + const { busy, isPending, errors, run, openDialog, dismissDialog, startLoad } = useDialogAction( + ["create-order", "record-payment", "order-panel"], { onAttempt: () => setMessage(null) }, ); // Pulled out for the payments effect's dependency list: it is stable, and @@ -454,6 +454,7 @@ export function SalesPage() { }; const closeOrderPanel = () => { + dismissDialog("order-panel"); setActive(null); // Dismissal discards the draft now; a later Open must use its fetched line. setEditItemId(null); @@ -705,7 +706,9 @@ export function SalesPage() { // Always fetch fresh on open — the list row may be stale relative to // mutations made through the panel since the list was loaded. const onOpen = (id: string) => run(`open:${id}`, async () => { - setActive(await getOrder(id)); + const current = startLoad("order-panel"); + const loaded = await getOrder(id); + if (current()) setActive(loaded); }); // A list failure no longer replaces the workspace: it renders as a banner