feat(i18n): localize movement UI
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { formatMovementType } from "@/app/(dashboard)/movements/movement.copy"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { getI18n } from "@/i18n/server"
|
import { getI18n } from "@/i18n/server"
|
||||||
import { AssetService } from "@/services/asset.service"
|
import { AssetService } from "@/services/asset.service"
|
||||||
@@ -15,6 +16,7 @@ export default async function ItemPage({
|
|||||||
const movements = await MovementService.findAllByItemId(itemId)
|
const movements = await MovementService.findAllByItemId(itemId)
|
||||||
const { dictionary } = await getI18n()
|
const { dictionary } = await getI18n()
|
||||||
const copy = dictionary.inventory.items.detail
|
const copy = dictionary.inventory.items.detail
|
||||||
|
const movementCopy = dictionary.inventory.movements
|
||||||
|
|
||||||
if (!item) {
|
if (!item) {
|
||||||
return <div>{copy.notFound}</div>
|
return <div>{copy.notFound}</div>
|
||||||
@@ -77,7 +79,7 @@ export default async function ItemPage({
|
|||||||
{movements?.length > 0 && (
|
{movements?.length > 0 && (
|
||||||
<Card className="rounded-sm shadow-none">
|
<Card className="rounded-sm shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Movements</CardTitle>
|
<CardTitle>{movementCopy.snippet.title}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{movements.map((movement) => (
|
{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"
|
className="grid grid-cols-2 gap-x-8 gap-y-2 text-sm"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Type</span>
|
<span className="text-gray-600">
|
||||||
<span>{movement.type}</span>
|
{movementCopy.snippet.labels.type}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatMovementType(
|
||||||
|
movement.type,
|
||||||
|
movementCopy.types,
|
||||||
|
movementCopy.fallback,
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Quantity</span>
|
<span className="text-gray-600">
|
||||||
|
{movementCopy.snippet.labels.quantity}
|
||||||
|
</span>
|
||||||
<span>{movement.quantity}</span>
|
<span>{movement.quantity}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import PaginationButtons from "@/components/common/pagination"
|
import PaginationButtons from "@/components/common/pagination"
|
||||||
|
import { getI18n } from "@/i18n/server"
|
||||||
import { formatDate } from "@/lib/utils"
|
import { formatDate } from "@/lib/utils"
|
||||||
import { MovementService } from "@/services/movement.service"
|
import { MovementService } from "@/services/movement.service"
|
||||||
|
|
||||||
|
import { formatMovementType } from "./movement.copy"
|
||||||
|
|
||||||
export default async function MovementsPage(props: {
|
export default async function MovementsPage(props: {
|
||||||
searchParams?: Promise<{
|
searchParams?: Promise<{
|
||||||
page?: string
|
page?: string
|
||||||
@@ -13,50 +16,62 @@ export default async function MovementsPage(props: {
|
|||||||
page: currentPage,
|
page: currentPage,
|
||||||
pageSize: 12,
|
pageSize: 12,
|
||||||
})
|
})
|
||||||
|
const { dictionary } = await getI18n()
|
||||||
|
const copy = dictionary.inventory.movements
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<h1 className="text-2xl font-bold">Movements</h1>
|
<h1 className="text-2xl font-bold">{copy.list.title}</h1>
|
||||||
</div>
|
</div>
|
||||||
{movements.length === 0 && <div>No movements found</div>}
|
{movements.length === 0 && <div>{copy.list.empty}</div>}
|
||||||
{movements.length > 0 && (
|
{movements.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="text-muted-foreground w-full text-left text-sm">
|
<table className="text-muted-foreground w-full text-left text-sm">
|
||||||
<thead className="border-b">
|
<thead className="border-b">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
Type
|
{copy.list.columns.type}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
Item
|
{copy.list.columns.item}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
Serial Number
|
{copy.list.columns.serialNumber}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
Quantity
|
{copy.list.columns.quantity}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
Recipient
|
{copy.list.columns.recipient}
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="p-4">
|
<th scope="col" className="p-4">
|
||||||
Date
|
{copy.list.columns.date}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{movements.map((movement) => (
|
{movements.map((movement) => (
|
||||||
<tr key={movement.id} className="border-b">
|
<tr key={movement.id} className="border-b">
|
||||||
<td className="p-4">{movement.type}</td>
|
|
||||||
<td className="p-4">{movement?.item?.name}</td>
|
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{movement?.asset?.serialNumber || "-"}
|
{formatMovementType(
|
||||||
|
movement.type,
|
||||||
|
copy.types,
|
||||||
|
copy.fallback,
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{movement?.item?.name || copy.fallback.missingValue}
|
||||||
|
</td>
|
||||||
|
<td className="p-4">
|
||||||
|
{movement?.asset?.serialNumber ||
|
||||||
|
copy.fallback.missingValue}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{movement.quantity}</td>
|
<td className="p-4">{movement.quantity}</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
{movement?.recipient?.firstName || "-"}{" "}
|
{movement?.recipient
|
||||||
{movement?.recipient?.lastName || "-"}
|
? `${movement.recipient.firstName} ${movement.recipient.lastName}`
|
||||||
|
: copy.fallback.missingValue}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{formatDate(movement.createdAt)}</td>
|
<td className="p-4">{formatDate(movement.createdAt)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -255,6 +255,39 @@ export const en = {
|
|||||||
invalidUpdateStatus: "Invalid status",
|
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: {
|
login: {
|
||||||
title: "Sign In",
|
title: "Sign In",
|
||||||
|
|||||||
@@ -259,6 +259,39 @@ export const es = {
|
|||||||
invalidUpdateStatus: "Estado inválido",
|
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: {
|
login: {
|
||||||
title: "Iniciar sesión",
|
title: "Iniciar sesión",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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", () => {
|
it("keeps dashboard home dictionary keys aligned across locales", () => {
|
||||||
expect(getDictionary("en").dashboardHome).toEqual({
|
expect(getDictionary("en").dashboardHome).toEqual({
|
||||||
heading: "Dashboard",
|
heading: "Dashboard",
|
||||||
|
|||||||
Reference in New Issue
Block a user