From f62cd6fb37ef446d3edc6b3599cbf42687bfad19 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Sun, 14 Jun 2026 01:20:23 +0200 Subject: [PATCH] feat(i18n): localize movement UI --- .../inventory/items/[itemId]/page.tsx | 20 ++++-- .../(dashboard)/movements/movement.copy.ts | 15 ++++ src/app/(dashboard)/movements/page.tsx | 41 +++++++---- src/i18n/dictionaries/en.ts | 33 +++++++++ src/i18n/dictionaries/es.ts | 33 +++++++++ tests/e2e/movements.spec.ts | 43 ++++++++++++ .../unit/app/movements/movement.copy.test.ts | 32 +++++++++ tests/unit/i18n/dictionaries.test.ts | 70 +++++++++++++++++++ 8 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 src/app/(dashboard)/movements/movement.copy.ts create mode 100644 tests/e2e/movements.spec.ts create mode 100644 tests/unit/app/movements/movement.copy.test.ts diff --git a/src/app/(dashboard)/inventory/items/[itemId]/page.tsx b/src/app/(dashboard)/inventory/items/[itemId]/page.tsx index c403f46..37df1cb 100644 --- a/src/app/(dashboard)/inventory/items/[itemId]/page.tsx +++ b/src/app/(dashboard)/inventory/items/[itemId]/page.tsx @@ -1,3 +1,4 @@ +import { formatMovementType } from "@/app/(dashboard)/movements/movement.copy" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { getI18n } from "@/i18n/server" import { AssetService } from "@/services/asset.service" @@ -15,6 +16,7 @@ export default async function ItemPage({ const movements = await MovementService.findAllByItemId(itemId) const { dictionary } = await getI18n() const copy = dictionary.inventory.items.detail + const movementCopy = dictionary.inventory.movements if (!item) { return
{copy.notFound}
@@ -77,7 +79,7 @@ export default async function ItemPage({ {movements?.length > 0 && ( - Movements + {movementCopy.snippet.title} {movements.map((movement) => ( @@ -86,11 +88,21 @@ export default async function ItemPage({ className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm" >
- Type - {movement.type} + + {movementCopy.snippet.labels.type} + + + {formatMovementType( + movement.type, + movementCopy.types, + movementCopy.fallback, + )} +
- Quantity + + {movementCopy.snippet.labels.quantity} + {movement.quantity}
diff --git a/src/app/(dashboard)/movements/movement.copy.ts b/src/app/(dashboard)/movements/movement.copy.ts new file mode 100644 index 0000000..cb20d80 --- /dev/null +++ b/src/app/(dashboard)/movements/movement.copy.ts @@ -0,0 +1,15 @@ +import type { Dictionary } from "@/i18n/dictionaries" + +export type MovementTypeCopy = Dictionary["inventory"]["movements"]["types"] +export type MovementFallbackCopy = + Dictionary["inventory"]["movements"]["fallback"] + +export function formatMovementType( + type: string, + typeCopy: MovementTypeCopy, + fallbackCopy: MovementFallbackCopy, +) { + return type in typeCopy + ? typeCopy[type as keyof MovementTypeCopy] + : fallbackCopy.unknownType +} diff --git a/src/app/(dashboard)/movements/page.tsx b/src/app/(dashboard)/movements/page.tsx index f571f99..7e7f97e 100644 --- a/src/app/(dashboard)/movements/page.tsx +++ b/src/app/(dashboard)/movements/page.tsx @@ -1,7 +1,10 @@ import PaginationButtons from "@/components/common/pagination" +import { getI18n } from "@/i18n/server" import { formatDate } from "@/lib/utils" import { MovementService } from "@/services/movement.service" +import { formatMovementType } from "./movement.copy" + export default async function MovementsPage(props: { searchParams?: Promise<{ page?: string @@ -13,50 +16,62 @@ export default async function MovementsPage(props: { page: currentPage, pageSize: 12, }) + const { dictionary } = await getI18n() + const copy = dictionary.inventory.movements return (
-

Movements

+

{copy.list.title}

- {movements.length === 0 &&
No movements found
} + {movements.length === 0 &&
{copy.list.empty}
} {movements.length > 0 && (
{movements.map((movement) => ( - - + + diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index f6ab73d..d224073 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -255,6 +255,39 @@ export const en = { invalidUpdateStatus: "Invalid status", }, }, + movements: { + list: { + title: "Movements", + empty: "No movements found", + columns: { + type: "Type", + item: "Item", + serialNumber: "Serial Number", + quantity: "Quantity", + recipient: "Recipient", + date: "Date", + }, + }, + snippet: { + title: "Movements", + labels: { + type: "Type", + quantity: "Quantity", + }, + }, + types: { + IN: "In", + OUT: "Out", + ASSIGNMENT: "Assignment", + RETURN: "Return", + ADJUSTMENT: "Adjustment", + DELETED: "Deleted", + }, + fallback: { + missingValue: "-", + unknownType: "Unknown movement type", + }, + }, }, login: { title: "Sign In", diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index ee5c548..d00eb41 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -259,6 +259,39 @@ export const es = { invalidUpdateStatus: "Estado inválido", }, }, + movements: { + list: { + title: "Movimientos", + empty: "No se encontraron movimientos.", + columns: { + type: "Tipo", + item: "Artículo", + serialNumber: "Número de serie", + quantity: "Cantidad", + recipient: "Destinatario", + date: "Fecha", + }, + }, + snippet: { + title: "Movimientos", + labels: { + type: "Tipo", + quantity: "Cantidad", + }, + }, + types: { + IN: "Entrada", + OUT: "Salida", + ASSIGNMENT: "Asignación", + RETURN: "Devolución", + ADJUSTMENT: "Ajuste", + DELETED: "Eliminación", + }, + fallback: { + missingValue: "-", + unknownType: "Tipo de movimiento desconocido", + }, + }, }, login: { title: "Iniciar sesión", diff --git a/tests/e2e/movements.spec.ts b/tests/e2e/movements.spec.ts new file mode 100644 index 0000000..2afbcf0 --- /dev/null +++ b/tests/e2e/movements.spec.ts @@ -0,0 +1,43 @@ +import { expect, type Page, test } from "@playwright/test" + +async function setLocaleCookie( + page: Page, + locale: "en" | "es", + baseURL?: string, +) { + await page.context().addCookies([ + { + name: "stock-manager-locale", + value: locale, + url: baseURL ?? "http://127.0.0.1:3100", + }, + ]) +} + +async function signInAsAdmin(page: Page, baseURL?: string) { + await setLocaleCookie(page, "en", baseURL) + await page.goto("/login") + await page.getByLabel("Username").fill("admin") + await page.getByLabel("Password").fill("admin-password") + await page.getByRole("button", { name: "Sign In" }).click() + await expect(page).toHaveURL("/") +} + +test.describe("movements localization", () => { + test("renders movement list UI copy in Spanish", async ({ + baseURL, + page, + }) => { + await signInAsAdmin(page, baseURL) + await setLocaleCookie(page, "es", baseURL) + + await page.goto("/movements") + + await expect(page.locator("html")).toHaveAttribute("lang", "es") + await expect( + page.getByRole("heading", { name: "Movimientos" }), + ).toBeVisible() + await expect(page.getByText("No se encontraron movimientos.")).toBeVisible() + await expect(page.getByText("Tipo")).toHaveCount(0) + }) +}) diff --git a/tests/unit/app/movements/movement.copy.test.ts b/tests/unit/app/movements/movement.copy.test.ts new file mode 100644 index 0000000..6aa9d1c --- /dev/null +++ b/tests/unit/app/movements/movement.copy.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest" + +import { formatMovementType } from "@/app/(dashboard)/movements/movement.copy" + +describe("movement copy helpers", () => { + const typeCopy = { + IN: "Entrada", + OUT: "Salida", + ASSIGNMENT: "Asignación", + RETURN: "Devolución", + ADJUSTMENT: "Ajuste", + DELETED: "Eliminación", + } + + const fallbackCopy = { + missingValue: "-", + unknownType: "Tipo de movimiento desconocido", + } + + it("formats known movement types with localized display labels", () => { + expect(formatMovementType("IN", typeCopy, fallbackCopy)).toBe("Entrada") + expect(formatMovementType("RETURN", typeCopy, fallbackCopy)).toBe( + "Devolución", + ) + }) + + it("falls back for unknown movement type values without rewriting the raw value", () => { + expect(formatMovementType("LEGACY", typeCopy, fallbackCopy)).toBe( + "Tipo de movimiento desconocido", + ) + }) +}) diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts index 78e1b09..391cd58 100644 --- a/tests/unit/i18n/dictionaries.test.ts +++ b/tests/unit/i18n/dictionaries.test.ts @@ -559,6 +559,76 @@ describe("i18n dictionaries", () => { }) }) + it("provides localized movement UI copy for English and Spanish", () => { + expect(getDictionary("en").inventory.movements).toEqual({ + list: { + title: "Movements", + empty: "No movements found", + columns: { + type: "Type", + item: "Item", + serialNumber: "Serial Number", + quantity: "Quantity", + recipient: "Recipient", + date: "Date", + }, + }, + snippet: { + title: "Movements", + labels: { + type: "Type", + quantity: "Quantity", + }, + }, + types: { + IN: "In", + OUT: "Out", + ASSIGNMENT: "Assignment", + RETURN: "Return", + ADJUSTMENT: "Adjustment", + DELETED: "Deleted", + }, + fallback: { + missingValue: "-", + unknownType: "Unknown movement type", + }, + }) + + expect(getDictionary("es").inventory.movements).toEqual({ + list: { + title: "Movimientos", + empty: "No se encontraron movimientos.", + columns: { + type: "Tipo", + item: "Artículo", + serialNumber: "Número de serie", + quantity: "Cantidad", + recipient: "Destinatario", + date: "Fecha", + }, + }, + snippet: { + title: "Movimientos", + labels: { + type: "Tipo", + quantity: "Cantidad", + }, + }, + types: { + IN: "Entrada", + OUT: "Salida", + ASSIGNMENT: "Asignación", + RETURN: "Devolución", + ADJUSTMENT: "Ajuste", + DELETED: "Eliminación", + }, + fallback: { + missingValue: "-", + unknownType: "Tipo de movimiento desconocido", + }, + }) + }) + it("keeps dashboard home dictionary keys aligned across locales", () => { expect(getDictionary("en").dashboardHome).toEqual({ heading: "Dashboard",
- Type + {copy.list.columns.type} - Item + {copy.list.columns.item} - Serial Number + {copy.list.columns.serialNumber} - Quantity + {copy.list.columns.quantity} - Recipient + {copy.list.columns.recipient} - Date + {copy.list.columns.date}
{movement.type}{movement?.item?.name} - {movement?.asset?.serialNumber || "-"} + {formatMovementType( + movement.type, + copy.types, + copy.fallback, + )} + + {movement?.item?.name || copy.fallback.missingValue} + + {movement?.asset?.serialNumber || + copy.fallback.missingValue} {movement.quantity} - {movement?.recipient?.firstName || "-"}{" "} - {movement?.recipient?.lastName || "-"} + {movement?.recipient + ? `${movement.recipient.firstName} ${movement.recipient.lastName}` + : copy.fallback.missingValue} {formatDate(movement.createdAt)}