From 6d2ad971ae6bc027d5efaec4e184732bd7932a14 Mon Sep 17 00:00:00 2001 From: keilynrp Date: Tue, 6 Oct 2026 17:05:16 -0600 Subject: [PATCH] feat(i18n): sales en ingles Tarea 12 del plan, decimoseptima parte: traduce a `en` el espacio `sales`, que usan dos paginas: `Sales` (el panel, las cotizaciones y las ordenes, con sus dialogos de rechazo y de alta) y `QuoteDetail` (el detalle de una cotizacion con sus lineas, totales, notas, historial y el dialogo de compartir). Con `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder` y `projects` son 17 de 19; `en` sigue sin ofrecerse hasta que esten todos. Se registra en `TRANSLATED`, asi que los checks de paridad (claves y marcadores) y de cadenas copiadas lo cubren. Siete cadenas iguales de verdad se aprueban una a una en `IDENTICAL_TO_BASE`: `Total` (tres veces), `PDF` (dos), `SKU` y `Subtotal`. El impuesto pasa de `IVA%` a `Tax%`, como en el PDF de la factura en ingles. Ortografia de EE. UU. Tests en ingles real (`english-sales.test.tsx`), para las dos paginas: el panel con la tasa de conversion y `N/A`, los desgloses por estado que salen del `en` de `common`, las cotizaciones y las ordenes con sus acciones, el motivo de rechazo por defecto y la linea de ejemplo que se envian al servidor, el detalle con sus lineas y totales, las acciones segun el estado, el dialogo de compartir, copiar el enlace, el aviso de enlace activo con y sin destinatario y el aviso de fallo al guardar. Comprobado con mutaciones sobre el catalogo real: un texto copiado del espanol, un marcador `{{message}}` perdido y una clave perdida fallan el test. Co-Authored-By: Claude Sonnet 5.5 --- composable-os/src/i18n/catalogues.test.ts | 4 +- composable-os/src/i18n/english-sales.test.tsx | 378 ++++++++++++++++++ composable-os/src/locales/en/sales.json | 141 +++++++ docs/IMPLEMENTATION_ALIGNMENT.md | 2 +- .../superpowers/plans/2026-08-07-i18n-full.md | 7 +- 5 files changed, 527 insertions(+), 5 deletions(-) create mode 100644 composable-os/src/i18n/english-sales.test.tsx create mode 100644 composable-os/src/locales/en/sales.json diff --git a/composable-os/src/i18n/catalogues.test.ts b/composable-os/src/i18n/catalogues.test.ts index 6533b18..1290374 100644 --- a/composable-os/src/i18n/catalogues.test.ts +++ b/composable-os/src/i18n/catalogues.test.ts @@ -31,7 +31,7 @@ const BASE = "es"; * idioma se ofrece solo (`releasedLocales`): no hay un interruptor aparte. */ const TRANSLATED: Record = { - en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects"], + en: ["common", "errors", "auth", "layout", "dashboard", "analytics", "companyProfile", "customerPortal", "contracts", "crm", "discovery", "hr", "inventory", "invoicing", "portalBuilder", "projects", "sales"], }; /** @@ -56,6 +56,8 @@ const IDENTICAL_TO_BASE = [ "portalBuilder:blockTypes.chatbot-widget", "portalBuilder:defaults.text.text", "portalBuilder:preview.cart.subtotal", "portalBuilder:preview.checkout.total", "portalBuilder:editor.off", "portalBuilder:props.color", "portalBuilder:sessions.email", + "sales:quotes.total", "sales:quotes.pdf", "sales:orders.total", "sales:detail.pdf", "sales:detail.lines.sku", + "sales:detail.totals.subtotal", "sales:detail.totals.total", "analytics:tabs.pipeline","companyProfile:identity.logoAlt", "companyProfile:contact.email", "customerPortal:quote.subtotal", "customerPortal:quote.total", ]; diff --git a/composable-os/src/i18n/english-sales.test.tsx b/composable-os/src/i18n/english-sales.test.tsx new file mode 100644 index 0000000..7ab5080 --- /dev/null +++ b/composable-os/src/i18n/english-sales.test.tsx @@ -0,0 +1,378 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import i18next, { type i18n as I18n } from "i18next"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { buildI18nOptions, resources } from "@/i18n"; +import QuoteDetail from "@/pages/QuoteDetail"; +import Sales from "@/pages/Sales"; +import { renderPageWithI18n } from "@/test/i18n"; + +/** + * `sales` en el ingles *real* (tarea 12 del plan de i18n): el panel de ventas, las + * cotizaciones y las ordenes (`Sales`) y el detalle de una cotizacion con su dialogo + * de compartir (`QuoteDetail`), con el catalogo `en` que se envia. Los estados salen + * del `en` de `common`. + */ + +const svc = vi.hoisted(() => ({ + getQuotes: vi.fn(), getOrders: vi.fn(), createQuote: vi.fn(), sendQuote: vi.fn(), acceptQuote: vi.fn(), + rejectQuote: vi.fn(), confirmOrder: vi.fn(), startFulfillment: vi.fn(), fulfillOrder: vi.fn(), + cancelOrder: vi.fn(), getQuotePdfUrl: vi.fn(), + getQuote: vi.fn(), getQuoteHistory: vi.fn(), updateLine: vi.fn(), addLine: vi.fn(), removeLine: vi.fn(), + updateQuote: vi.fn(), +})); +const portal = vi.hoisted(() => ({ getSessions: vi.fn(), createSession: vi.fn(), sendEmail: vi.fn() })); +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn() })); + +vi.mock("@/services/sales", () => ({ salesService: svc })); +vi.mock("@/services/portal", () => ({ portalService: portal })); +vi.mock("sonner", () => ({ toast })); + +async function english(): Promise { + const instance = i18next.createInstance(); + await instance.init(buildI18nOptions(resources, "en")); + return instance; +} + +const quote = (over: Record = {}) => ({ + id: "q1", quote_number: "Q-001", title: "Sun proposal", status: "draft", total: 100, ...over, +}); +const order = (over: Record = {}) => ({ + id: "o1", order_number: "ORD-001", status: "draft", total: 250, quote_id: "abcdef1234567890", ...over, +}); + +const line = (over: Record = {}) => ({ + id: "l1", quote_id: "q1", line_order: 1, description: "Shoes", quantity: 2, unit_price: 50, discount_percent: 0, + sku: "Z-1", unit: "pair", tax_percent: 0, notes: null, amount: 100, product_id: null, created_at: "", updated_at: "", ...over, +}); +const fullQuote = (over: Record = {}) => ({ + id: "q1", workspace_id: "w1", quote_number: "Q-001", title: "Sun proposal", status: "draft", currency: "USD", + subtotal: 100, discount_amount: 10, tax_rate: 0, tax_amount: 5, total: 95, valid_until: null, notes: "Customer notes", + terms: "Conditions", sent_at: null, accepted_at: null, rejected_at: null, lines: [line()], contact_id: null, + organization_id: null, opportunity_id: null, owner_id: null, created_at: "", updated_at: "2026-01-01", ...over, +}); + +const renderSales = (instance: I18n) => renderPageWithI18n(, instance); +const renderDetail = (instance: I18n) => + renderPageWithI18n(, instance, { entry: "/sales/quotes/q1", path: "/sales/quotes/:id" }); +const openTab = (name: string) => fireEvent.mouseDown(screen.getByRole("tab", { name }), { button: 0 }); +/** El boton de confirmacion del dialogo, que comparte nombre con el de la fila. */ +function inDialog(name: string) { + const dialog = screen.getByRole("dialog"); + return Array.from(dialog.querySelectorAll("button")).find((b) => b.textContent === name) as HTMLElement; +} + +beforeEach(() => { + Object.values(svc).forEach((fn) => fn.mockReset()); + Object.values(portal).forEach((fn) => fn.mockReset()); + Object.values(toast).forEach((fn) => fn.mockReset()); + svc.getQuotes.mockResolvedValue([ + quote(), + quote({ id: "q2", quote_number: "Q-002", title: "Moon proposal", status: "sent", total: 300 }), + quote({ id: "q3", quote_number: "Q-003", title: "Sea proposal", status: "accepted", total: 50 }), + ]); + svc.getOrders.mockResolvedValue([ + order(), + order({ id: "o2", order_number: "ORD-002", status: "confirmed" }), + order({ id: "o3", order_number: "ORD-003", status: "in_fulfillment" }), + ]); + svc.sendQuote.mockResolvedValue({}); + svc.acceptQuote.mockResolvedValue({}); + svc.rejectQuote.mockResolvedValue({}); + svc.createQuote.mockResolvedValue({}); + svc.confirmOrder.mockResolvedValue({}); + svc.startFulfillment.mockResolvedValue({}); + svc.fulfillOrder.mockResolvedValue({}); + svc.cancelOrder.mockResolvedValue({}); + svc.getQuotePdfUrl.mockReturnValue("/pdf"); + svc.getQuote.mockResolvedValue(fullQuote()); + svc.getQuoteHistory.mockResolvedValue([ + { id: "e1", quote_id: "q1", user_id: null, event_type: "sent", description: "Quote sent by email", metadata: null, created_at: "2026-03-05T10:00:00Z" }, + ]); + svc.updateLine.mockResolvedValue({}); + svc.addLine.mockResolvedValue({}); + svc.removeLine.mockResolvedValue(undefined); + svc.updateQuote.mockResolvedValue({}); + portal.getSessions.mockResolvedValue([]); + portal.createSession.mockResolvedValue({ id: "s1", portal_url: "https://x/portal/t", client_email: null, expires_at: "" }); + portal.sendEmail.mockResolvedValue({}); + Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } }); +}); + +describe("Sales in English", () => { + it("renders the header, the tabs and the KPIs", async () => { + renderSales(await english()); + + expect(screen.getByRole("heading", { name: "Sales" })).toBeInTheDocument(); + expect(screen.getByText("Manage quotes and orders — connected to the backend in real time.")).toBeInTheDocument(); + for (const tab of ["Dashboard", "Quotes", "Orders"]) { + expect(screen.getByRole("tab", { name: tab })).toBeInTheDocument(); + } + for (const kpi of ["Open quotes", "Pipeline value", "Conversion rate", "Active orders"]) { + expect(await screen.findByText(kpi)).toBeInTheDocument(); + } + expect(screen.queryByText("Ventas")).not.toBeInTheDocument(); + }); + + it("shows the conversion rate as a percentage and N/A when nothing was decided", async () => { + svc.getQuotes.mockResolvedValue([quote({ status: "accepted" }), quote({ id: "q2", status: "rejected" }), quote({ id: "q3", status: "accepted" })]); + const instance = await english(); + const { unmount } = renderSales(instance); + expect(await screen.findByText("67%")).toBeInTheDocument(); + unmount(); + + svc.getQuotes.mockResolvedValue([quote()]); + renderSales(instance); + await screen.findByText("Conversion rate"); + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); + + it("names every status in the dashboard breakdowns", async () => { + renderSales(await english()); + + expect(await screen.findByText("Quotes by status")).toBeInTheDocument(); + expect(screen.getByText("Orders by status")).toBeInTheDocument(); + for (const status of ["Sent", "Accepted", "Rejected", "Expired", "Confirmed", "In fulfillment", "Fulfilled", "Cancelled"]) { + expect(screen.getAllByText(status).length).toBeGreaterThan(0); + } + }); + + it("lists the quotes with English columns, filters and actions", async () => { + renderSales(await english()); + openTab("Quotes"); + + expect(await screen.findByText("Sun proposal")).toBeInTheDocument(); + for (const col of ["Quote no.", "Title", "Status", "Total", "Actions"]) { + expect(screen.getByRole("columnheader", { name: col })).toBeInTheDocument(); + } + for (const chip of ["All", "Draft", "Sent", "Accepted", "Rejected"]) { + expect(screen.getByRole("button", { name: chip })).toBeInTheDocument(); + } + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Accept" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument(); + }); + + it("explains an empty quote list", async () => { + svc.getQuotes.mockResolvedValue([]); + renderSales(await english()); + openTab("Quotes"); + + expect(await screen.findByText("No quotes")).toBeInTheDocument(); + }); + + it("sends and accepts a quote and confirms each", async () => { + renderSales(await english()); + openTab("Quotes"); + + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Quote sent")); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Quote accepted — order created")); + }); + + it("rejects a quote through the dialog, sending an English default reason", async () => { + renderSales(await english()); + openTab("Quotes"); + fireEvent.click(await screen.findByRole("button", { name: "Reject" })); + + expect(await screen.findByText("Reject quote")).toBeInTheDocument(); + expect(screen.getByText("Reason (optional)")).toBeInTheDocument(); + fireEvent.click(inDialog("Reject")); + + await waitFor(() => expect(svc.rejectQuote).toHaveBeenCalledWith("q2", "No reason given")); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Quote rejected")); + }); + + it("creates a quote with an English placeholder line and confirms", async () => { + renderSales(await english()); + openTab("Quotes"); + fireEvent.click(await screen.findByRole("button", { name: /New quote/ })); + + fireEvent.change(await screen.findByPlaceholderText("E.g. Q1 services proposal"), { target: { value: "X" } }); + fireEvent.click(screen.getByRole("button", { name: "Create" })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Quote created")); + expect(svc.createQuote).toHaveBeenCalledWith(expect.objectContaining({ + lines: [expect.objectContaining({ description: "Item" })], + })); + }); + + it("lists the orders with English columns and per-status actions", async () => { + renderSales(await english()); + openTab("Orders"); + + expect(await screen.findByText("ORD-001")).toBeInTheDocument(); + for (const col of ["Order no.", "Quote ID", "Status", "Total", "Actions"]) { + expect(screen.getByRole("columnheader", { name: col })).toBeInTheDocument(); + } + expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start fulfillment" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Fulfill" })).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Cancel" })).toHaveLength(2); + }); + + it.each([ + ["Confirm", "Order confirmed"], + ["Start fulfillment", "Fulfillment started"], + ["Fulfill", "Order fulfilled"], + ])("confirms the action %s", async (button, message) => { + renderSales(await english()); + openTab("Orders"); + fireEvent.click(await screen.findByRole("button", { name: button })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith(message)); + }); + + it("cancels an order and explains an empty order list", async () => { + const instance = await english(); + const { unmount } = renderSales(instance); + openTab("Orders"); + fireEvent.click((await screen.findAllByRole("button", { name: "Cancel" }))[0]); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Order cancelled")); + unmount(); + + svc.getOrders.mockResolvedValue([]); + renderSales(instance); + openTab("Orders"); + expect(await screen.findByText("No orders")).toBeInTheDocument(); + }); + + it("surfaces a failed load as an English toast", async () => { + svc.getQuotes.mockRejectedValue(new Error("down")); + renderSales(await english()); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith("Error loading quotes: down"), { timeout: 5000 }); + }); +}); + +describe("QuoteDetail in English", () => { + it("renders the header, the line table, the totals and the notes", async () => { + renderDetail(await english()); + + expect(await screen.findByText("Sun proposal")).toBeInTheDocument(); + expect(screen.getByText("Draft")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Quotes/ })).toBeInTheDocument(); + for (const col of ["#", "SKU", "Description", "Unit", "Qty", "Unit price", "Disc%", "Tax%", "Line total", "Notes"]) { + expect(screen.getByRole("columnheader", { name: col })).toBeInTheDocument(); + } + for (const text of ["Lines", "Subtotal", "Discount", "Taxes", "Total", "General notes", "Terms and conditions", "History"]) { + expect(screen.getAllByText(text).length).toBeGreaterThan(0); + } + expect(screen.getByPlaceholderText("Notes visible to the customer...")).toHaveValue("Customer notes"); + expect(screen.getByPlaceholderText("Quote terms...")).toHaveValue("Conditions"); + expect(await screen.findByText("Quote sent by email")).toBeInTheDocument(); + }); + + it("offers the draft actions and adds a line with an English description", async () => { + renderDetail(await english()); + await screen.findByText("Sun proposal"); + + expect(screen.getByRole("button", { name: "Send" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /Add line/ })); + + await waitFor(() => expect(svc.addLine).toHaveBeenCalledWith("q1", expect.objectContaining({ description: "New line" }))); + }); + + it("sends the quote and confirms", async () => { + renderDetail(await english()); + fireEvent.click(await screen.findByRole("button", { name: "Send" })); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Quote sent")); + }); + + it("offers accept and reject on a sent quote and no line editing", async () => { + svc.getQuote.mockResolvedValue(fullQuote({ status: "sent" })); + renderDetail(await english()); + + expect(await screen.findByRole("button", { name: "Accept" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument(); + expect(screen.getByText("Sent")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Add line/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Accept" })); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Quote accepted — order created")); + }); + + it("explains why a processed quote cannot be shared", async () => { + svc.getQuote.mockResolvedValue(fullQuote({ status: "accepted" })); + renderDetail(await english()); + + const share = await screen.findByRole("button", { name: /Share/ }); + expect(share).toBeDisabled(); + expect(share).toHaveAttribute("title", "Quote already processed"); + }); + + it("explains an empty quote differently when it is editable", async () => { + svc.getQuote.mockResolvedValue(fullQuote({ lines: [] })); + const instance = await english(); + const { unmount } = renderDetail(instance); + expect(await screen.findByText("No lines. Add one to get started.")).toBeInTheDocument(); + unmount(); + + svc.getQuote.mockResolvedValue(fullQuote({ lines: [], status: "accepted" })); + renderDetail(instance); + expect(await screen.findByText("No lines.")).toBeInTheDocument(); + }); + + it("explains a missing quote and an empty history", async () => { + svc.getQuote.mockRejectedValue(new Error("404")); + const instance = await english(); + const { unmount } = renderDetail(instance); + expect(await screen.findByText("Quote not found.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Back to Sales/ })).toBeInTheDocument(); + unmount(); + + svc.getQuote.mockResolvedValue(fullQuote()); + svc.getQuoteHistory.mockResolvedValue([]); + renderDetail(instance); + expect(await screen.findByText("No events recorded.")).toBeInTheDocument(); + }); + + it("opens the share dialog with the validity options as short day units", async () => { + renderDetail(await english()); + fireEvent.click(await screen.findByRole("button", { name: /Share/ })); + + expect(await screen.findByText("Share quote")).toBeInTheDocument(); + expect(screen.getByText("Customer name")).toBeInTheDocument(); + expect(screen.getByText("Valid for")).toBeInTheDocument(); + for (const d of ["7d", "14d", "30d"]) expect(screen.getByRole("button", { name: d })).toBeInTheDocument(); + expect(screen.getByPlaceholderText("john@company.com")).toBeInTheDocument(); + }); + + it("copies the link and sends the email, saying to whom", async () => { + renderDetail(await english()); + fireEvent.click(await screen.findByRole("button", { name: /Share/ })); + fireEvent.click(await screen.findByRole("button", { name: /Copy link/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Link copied")); + + fireEvent.change(screen.getByPlaceholderText("john@company.com"), { target: { value: "ana@sol.co" } }); + fireEvent.click(screen.getByRole("button", { name: /Send email/ })); + await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Email sent to ana@sol.co")); + }); + + it("shows the active-link banner with and without a recipient", async () => { + const future = new Date(Date.now() + 5 * 86400000).toISOString(); + portal.getSessions.mockResolvedValue([{ id: "s1", action: null, expires_at: future, client_email: "ana@sol.co", portal_url: "u" }]); + const instance = await english(); + const { unmount } = renderDetail(instance); + expect(await screen.findByText(/✓ Active link — sent to ana@sol\.co · expires/)).toBeInTheDocument(); + expect(screen.getByText("Resend / new link")).toBeInTheDocument(); + unmount(); + + portal.getSessions.mockResolvedValue([{ id: "s1", action: null, expires_at: future, client_email: null, portal_url: "u" }]); + renderDetail(instance); + expect(await screen.findByText(/^✓ Active link · expires/)).toBeInTheDocument(); + }); + + it("reports a failed save with the English prefix", async () => { + svc.updateLine.mockRejectedValue(new Error("boom")); + renderDetail(await english()); + const input = (await screen.findAllByDisplayValue("Shoes"))[0]; + fireEvent.change(input, { target: { value: "Boots" } }); + fireEvent.blur(input); + + await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/^Could not save: /))); + }); +}); diff --git a/composable-os/src/locales/en/sales.json b/composable-os/src/locales/en/sales.json new file mode 100644 index 0000000..7ad5bf6 --- /dev/null +++ b/composable-os/src/locales/en/sales.json @@ -0,0 +1,141 @@ +{ + "title": "Sales", + "subtitle": "Manage quotes and orders — connected to the backend in real time.", + "tabs": { + "dashboard": "Dashboard", + "quotes": "Quotes", + "orders": "Orders" + }, + "kpi": { + "openQuotes": "Open quotes", + "pipelineValue": "Pipeline value", + "conversionRate": "Conversion rate", + "activeOrders": "Active orders", + "notAvailable": "N/A" + }, + "dashboard": { + "quotesByStatus": "Quotes by status", + "ordersByStatus": "Orders by status" + }, + "filters": { + "all": "All" + }, + "quotes": { + "new": "New quote", + "empty": "No quotes", + "number": "Quote no.", + "quoteTitle": "Title", + "status": "Status", + "total": "Total", + "actions": "Actions", + "send": "Send", + "accept": "Accept", + "reject": "Reject", + "pdf": "PDF" + }, + "orders": { + "empty": "No orders", + "number": "Order no.", + "quoteId": "Quote ID", + "status": "Status", + "total": "Total", + "actions": "Actions", + "confirm": "Confirm", + "startFulfillment": "Start fulfillment", + "fulfill": "Fulfill", + "cancel": "Cancel" + }, + "rejectDialog": { + "title": "Reject quote", + "reason": "Reason (optional)", + "placeholder": "Reason for rejection...", + "defaultReason": "No reason given", + "cancel": "Cancel", + "confirm": "Reject" + }, + "newQuoteDialog": { + "title": "New quote", + "quoteTitle": "Title *", + "placeholder": "E.g. Q1 services proposal", + "defaultLine": "Item", + "cancel": "Cancel", + "submit": "Create" + }, + "toast": { + "quoteCreated": "Quote created", + "quoteSent": "Quote sent", + "quoteAccepted": "Quote accepted — order created", + "quoteRejected": "Quote rejected", + "orderConfirmed": "Order confirmed", + "fulfillmentStarted": "Fulfillment started", + "orderFulfilled": "Order fulfilled", + "orderCancelled": "Order cancelled", + "quotesLoadFailed": "Error loading quotes: {{message}}", + "ordersLoadFailed": "Error loading orders: {{message}}" + }, + "detail": { + "back": "Quotes", + "backToSales": "Back to Sales", + "notFound": "Quote not found.", + "send": "Send", + "accept": "Accept", + "reject": "Reject", + "pdf": "PDF", + "share": "Share", + "alreadyProcessed": "Quote already processed", + "activeLink": "✓ Active link · expires {{date}}", + "activeLinkSentTo": "✓ Active link — sent to {{email}} · expires {{date}}", + "resend": "Resend / new link", + "shareDialog": { + "title": "Share quote", + "clientName": "Customer name", + "namePlaceholder": "John Smith", + "clientEmail": "Customer email", + "emailPlaceholder": "john@company.com", + "validFor": "Valid for", + "copyLink": "Copy link", + "sendEmail": "Send email" + }, + "lines": { + "title": "Lines", + "index": "#", + "sku": "SKU", + "description": "Description", + "unit": "Unit", + "quantity": "Qty", + "unitPrice": "Unit price", + "discount": "Disc%", + "tax": "Tax%", + "lineTotal": "Line total", + "notes": "Notes", + "empty": "No lines.", + "emptyDraft": "No lines. Add one to get started.", + "add": "Add line", + "newLine": "New line" + }, + "totals": { + "subtotal": "Subtotal", + "discount": "Discount", + "taxes": "Taxes", + "total": "Total" + }, + "notes": { + "title": "General notes", + "placeholder": "Notes visible to the customer..." + }, + "terms": { + "title": "Terms and conditions", + "placeholder": "Quote terms..." + }, + "history": { + "title": "History", + "empty": "No events recorded." + }, + "toast": { + "saveFailed": "Could not save: {{message}}", + "linkCopied": "Link copied", + "copyFailed": "Could not copy automatically", + "emailSent": "Email sent to {{email}}" + } + } +} diff --git a/docs/IMPLEMENTATION_ALIGNMENT.md b/docs/IMPLEMENTATION_ALIGNMENT.md index 68a7293..46f899f 100644 --- a/docs/IMPLEMENTATION_ALIGNMENT.md +++ b/docs/IMPLEMENTATION_ALIGNMENT.md @@ -83,7 +83,7 @@ Legacy documents are historical reference only unless explicitly promoted by ADR | External brand-site integration | Strategic direction documented; public lead intake v1 now has code, contract tests, decision logging, event metadata, and rate-limit response behavior, but is not yet operationally proven under production-like traffic | One validated public integration slice before SDK/builder expansion | High | Run deployment-like validation for CRM public intake before expanding public surfaces | | Public API boundary | Portal proves public interaction can work; CRM public intake now defines the initial site-key/origin/idempotency/rate-limit model | Explicit public endpoint policy with origin, key, idempotency, abuse controls, and logging rules | High | Promote the CRM public intake contract into broader public API conventions only after production validation | | Future stack pressure | Strategic docs mention SDK, builder, semantic layers, and UKIP-compatible ideas | Current stack remains frozen until justified | Medium | Treat those areas as target-state only unless promoted by ADR | -| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder` and `projects` translated (16 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | +| Internationalization | Committed as a core requirement (ADR 0014, product answer 2026-08-07), half built, and now fully decided. The backend is already language-neutral: registered error codes with structured `detail`, no user-facing prose, parity enforced in CI. The three open decisions closed 2026-08-07 — `react-i18next` promoted for the frontend tier only (ADR 0015), and locale ownership settled on `users.locale` with a workspace default plus `portal_sessions.locale` for external recipients (ADR 0016). Built: the frontend catalogue for every page, the three locale columns with their migration, the single resolution helper (`resolve_locale`, and `resolve_user_locale` for an authenticated user), and the backend catalogues (`app/core/i18n/locales//.json`, `translate()`), which today carry the invoice PDF labels and the 9 email templates (internal ones in the recipient's language, the two client-facing ones in the portal session's). Not built: the quote PDF (`sales/pdf.py`), the Spanish prose still returned by some portal API responses and by the workflow `send_email` action's defaults, locale-aware date and amount formatting in the server-rendered artifacts, and a second language offered to anyone. English is under way: the backend catalogues (invoice PDF, 9 emails) are complete but held back in `UNRELEASED_LOCALES`, the frontend has `common`, `errors`, `auth`, `layout`, `dashboard`, `analytics`, `companyProfile`, `customerPortal`, `contracts`, `crm`, `discovery`, `hr`, `inventory`, `invoicing`, `portalBuilder`, `projects` and `sales` translated (17 of 19 namespaces) and offers a language only once every namespace exists. The CI checks (key parity, orphan strings, untranslated copies) already guard it: `src/i18n/catalogues.test.ts`, `backend/tests/test_i18n_catalogue_usage.py` — a frontend catalogue cannot reach the server-rendered artifacts | The product ships in more than one language, with no user-facing string hardcoded in either tier | Medium | Do not start a partial extraction: a half-translated screen reads as broken and is worse than none. The sequenced plan is `docs/superpowers/plans/2026-08-07-i18n-full.md`; the first executable task is the migration. Nothing blocks it now, but nothing schedules it either — it does not improve the wedge, so when it enters is an explicit call. Meanwhile hold the line that already works — new backend code raises codes, never prose | | Schema drift between models and database | Closed. `alembic revision --autogenerate` on an unchanged model set emits an empty `upgrade()`, so a generated migration now contains only the intended change. Resolved in three steps: foreign-key delete rules declared in the models, `users.notification_preferences` tightened to `NOT NULL` in the database, and two leftovers removed — a redundant unique constraint on `public_sites.api_key` that duplicated the unique index, and an index on `quote_events.user_id` that no query used | Autogenerate stays silent on an unchanged model set | Medium | Keep it closed: run autogenerate before and after a model change and treat any unrelated output as drift to fix in its own change set, not to hand-edit away | --- diff --git a/docs/superpowers/plans/2026-08-07-i18n-full.md b/docs/superpowers/plans/2026-08-07-i18n-full.md index 67939bd..392b0ba 100644 --- a/docs/superpowers/plans/2026-08-07-i18n-full.md +++ b/docs/superpowers/plans/2026-08-07-i18n-full.md @@ -457,9 +457,10 @@ completa migrada no hay patron que seguir. `discovery` (bienvenida, dialogo de sesion, chat y blueprint), `hr` (empleados, departamentos y dialogos de alta), `inventory` (centro de operaciones, existencias y paneles), `invoicing` (facturas, pagos y alta), - `portalBuilder` (sesiones, constructor de paginas y bloques de tienda) y `projects` - (lista, detalle con tareas y dialogos de alta). - Quedan 3: `sales`, `settings`, `workflows`. + `portalBuilder` (sesiones, constructor de paginas y bloques de tienda), `projects` + (lista, detalle con tareas y dialogos de alta) y `sales` (panel, cotizaciones, ordenes + y el detalle de una cotizacion con su dialogo de compartir). + Quedan 2: `settings`, `workflows`. - [ ] Ofrecer `en`: mover `en` de `UNRELEASED_LOCALES` a `SUPPORTED_LOCALES` en el backend y el selector de idioma en el dialogo de compartir (pendiente desde la tarea 2). - [ ] Recorrer la aplicacion en ingles buscando lo que el segundo idioma revela: