feat(i18n): localize inventory assets UI

This commit is contained in:
2026-06-13 17:07:51 +02:00
parent c67e86c91b
commit 3d6b13dc1c
10 changed files with 367 additions and 38 deletions
@@ -18,20 +18,23 @@ export default async function EditAssetPage({
const recipients = await RecipientService.findAll()
const asset = await AssetService.findById(assetId)
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
if (!asset) {
return <div>Asset not found</div>
return <div>{copy.edit.notFound}</div>
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">Edit Asset</h1>
<h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div>
<EditAssetForm
items={items}
recipients={recipients}
asset={asset as unknown as AssetWithAssignment}
formCopy={copy.form}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
@@ -0,0 +1,6 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type AssetListCopy = Dictionary["inventory"]["assets"]["list"]
export type AssetFormCopy = Dictionary["inventory"]["assets"]["form"]
export type AssetStatusCopy = Dictionary["inventory"]["assets"]["status"]
export type AssetFallbackCopy = Dictionary["inventory"]["assets"]["fallback"]
@@ -21,10 +21,14 @@ import type {
UpdateAssetStatus,
} from "@/types"
import type { AssetFormCopy, AssetStatusCopy } from "./asset.copy"
interface EditAssetFormProps {
asset: AssetWithAssignment
items: Item[]
recipients: Recipient[]
formCopy: AssetFormCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
}
@@ -32,6 +36,8 @@ export default function EditAssetForm({
asset,
items,
recipients,
formCopy,
statusCopy,
submitButtonCopy,
}: EditAssetFormProps) {
const router = useRouter()
@@ -83,15 +89,15 @@ export default function EditAssetForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
<label htmlFor="itemId" className="mb-2 block text-lg">
{formCopy.itemLabel}
</label>
<select
id="itemId"
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a item:</option>
<option value="">{formCopy.itemPlaceholder}</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -104,12 +110,12 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number
{formCopy.serialNumberLabel}
</label>
<input
type="text"
id="serialNumber"
placeholder="Serial number"
placeholder={formCopy.serialNumberPlaceholder}
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -119,12 +125,12 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note
{formCopy.deliveryNoteLabel}
</label>
<input
type="text"
id="deliveryNote"
placeholder="Delivery note"
placeholder={formCopy.deliveryNotePlaceholder}
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -134,17 +140,17 @@ export default function EditAssetForm({
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
Status
{formCopy.statusLabel}
</label>
<select
id="status"
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
<option value="">{formCopy.statusPlaceholder}</option>
{Object.values(ITEM_STATUS).map((status) => (
<option key={status} value={status}>
{status}
{statusCopy[status]}
</option>
))}
</select>
@@ -155,14 +161,14 @@ export default function EditAssetForm({
{status === "ASSIGNED" && (
<div>
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
{formCopy.recipientLabel}
</label>
<select
id="recipientId"
{...register("recipientId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a Recipient</option>
<option value="">{formCopy.recipientPlaceholder}</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
@@ -179,7 +185,7 @@ export default function EditAssetForm({
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Update Asset
{formCopy.updateSubmit}
</SubmitButton>
</form>
)
@@ -16,15 +16,21 @@ import {
} from "@/schemas/asset.schema"
import type { ItemWithoutStock, Recipient } from "@/types"
import type { AssetFormCopy, AssetStatusCopy } from "./asset.copy"
interface NewAssetFormProps {
items: ItemWithoutStock[]
recipients: Recipient[]
formCopy: AssetFormCopy
statusCopy: AssetStatusCopy
submitButtonCopy: SubmitButtonCopy
}
export default function NewAssetForm({
items,
recipients,
formCopy,
statusCopy,
submitButtonCopy,
}: NewAssetFormProps) {
const router = useRouter()
@@ -71,15 +77,15 @@ export default function NewAssetForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<input type="hidden" {...register("id")} />
<div>
<label htmlFor="categoryId" className="mb-2 block text-lg">
Item
<label htmlFor="itemId" className="mb-2 block text-lg">
{formCopy.itemLabel}
</label>
<select
id="itemId"
{...register("itemId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a item:</option>
<option value="">{formCopy.itemPlaceholder}</option>
{items?.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
@@ -92,12 +98,12 @@ export default function NewAssetForm({
</div>
<div>
<label htmlFor="serialNumber" className="mb-2 block text-lg">
Serial Number
{formCopy.serialNumberLabel}
</label>
<input
type="text"
id="serialNumber"
placeholder="Serial number"
placeholder={formCopy.serialNumberPlaceholder}
{...register("serialNumber")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -107,12 +113,12 @@ export default function NewAssetForm({
</div>
<div>
<label htmlFor="deliveryNote" className="mb-2 block text-lg">
Delivery Note
{formCopy.deliveryNoteLabel}
</label>
<input
type="text"
id="deliveryNote"
placeholder="Delivery note"
placeholder={formCopy.deliveryNotePlaceholder}
{...register("deliveryNote")}
className="w-full rounded-lg border px-4 py-2"
/>
@@ -122,17 +128,17 @@ export default function NewAssetForm({
</div>
<div>
<label htmlFor="status" className="mb-2 block text-lg">
Status
{formCopy.statusLabel}
</label>
<select
id="status"
{...register("status")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a status</option>
<option value="">{formCopy.statusPlaceholder}</option>
{Object.values(ITEM_STATUS).map((status) => (
<option key={status} value={status}>
{status}
{statusCopy[status]}
</option>
))}
</select>
@@ -143,14 +149,14 @@ export default function NewAssetForm({
{status === "ASSIGNED" && (
<div>
<label htmlFor="recipientId" className="mb-2 block text-lg">
Recipient
{formCopy.recipientLabel}
</label>
<select
id="recipientId"
{...register("recipientId")}
className="w-full rounded-lg border px-4 py-2"
>
<option value="">Select a Recipient</option>
<option value="">{formCopy.recipientPlaceholder}</option>
{recipients?.map((recipient) => (
<option key={recipient.id} value={recipient.id}>
{recipient.firstName} {recipient.lastName}
@@ -167,7 +173,7 @@ export default function NewAssetForm({
isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful}
>
Create Asset
{formCopy.createSubmit}
</SubmitButton>
</form>
)
@@ -10,15 +10,18 @@ export default async function NewAssetPage() {
const items = await ItemService.findAllAssignable()
const recipients = await RecipientService.findAll()
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">New Asset</h1>
<h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div>
<NewAssetForm
items={items}
recipients={recipients}
formCopy={copy.form}
statusCopy={copy.status}
submitButtonCopy={dictionary.common.submitButton}
/>
</div>
+38 -9
View File
@@ -4,8 +4,24 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { AssetService } from "@/services/asset.service"
import type {
AssetFallbackCopy,
AssetStatusCopy,
} from "./_components/asset.copy"
function formatAssetStatus(
status: string,
statusCopy: AssetStatusCopy,
fallbackCopy: AssetFallbackCopy,
) {
return status in statusCopy
? statusCopy[status as keyof AssetStatusCopy]
: fallbackCopy.unknownStatus
}
export default async function AssetsPage(props: {
searchParams?: Promise<{
page?: string
@@ -21,19 +37,22 @@ export default async function AssetsPage(props: {
pageSize: 10,
search,
})
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assets
return (
<div className="flex flex-col gap-4">
<PageHeader
title="Assets"
title={copy.list.title}
link="/inventory/assets/new"
data={assets}
search={search}
addLabel={copy.list.addLabel}
/>
{assets.length === 0 && currentPage === 1 && (
<div className="flex gap-4">
<div className="flex items-center justify-between gap-4">
No Assets found.
{copy.list.empty}
</div>
</div>
)}
@@ -43,19 +62,19 @@ export default async function AssetsPage(props: {
<thead className="border-b">
<tr>
<th scope="col" className="p-4">
Item Name
{copy.list.columns.item}
</th>
<th scope="col" className="p-4">
Category
{copy.list.columns.category}
</th>
<th scope="col" className="p-4">
Serial Number
{copy.list.columns.serialNumber}
</th>
<th scope="col" className="p-4">
Status
{copy.list.columns.status}
</th>
<th scope="col" className="p-4">
Actions
{copy.list.columns.actions}
</th>
</tr>
</thead>
@@ -65,10 +84,20 @@ export default async function AssetsPage(props: {
<td className="p-4">{asset.item?.name}</td>
<td className="p-4">{asset.item?.category?.name}</td>
<td className="p-4">{asset.serialNumber}</td>
<td className="p-4">{asset.status}</td>
<td className="p-4">
{formatAssetStatus(
asset.status,
copy.status,
copy.fallback,
)}
</td>
<td className="flex items-center gap-2 p-4">
<Link href={`/inventory/assets/${asset.id}/edit`} passHref>
<Button variant="outline" size="icon">
<Button
variant="outline"
size="icon"
aria-label={copy.list.actions.edit}
>
<Pencil />
</Button>
</Link>
+50
View File
@@ -182,6 +182,56 @@ export const en = {
itemRequired: "Item is required",
},
},
assets: {
list: {
title: "Assets",
addLabel: "Add Asset",
empty: "No assets found.",
columns: {
item: "Item",
category: "Category",
serialNumber: "Serial Number",
status: "Status",
actions: "Actions",
},
actions: {
edit: "Edit asset",
},
},
new: {
title: "New Asset",
},
edit: {
title: "Edit Asset",
notFound: "Asset not found",
},
form: {
itemLabel: "Item",
itemPlaceholder: "Select an item",
serialNumberLabel: "Serial Number",
serialNumberPlaceholder: "Serial number",
deliveryNoteLabel: "Delivery Note",
deliveryNotePlaceholder: "Delivery note",
statusLabel: "Status",
statusPlaceholder: "Select a status",
recipientLabel: "Recipient",
recipientPlaceholder: "Select a recipient",
createSubmit: "Create Asset",
updateSubmit: "Update Asset",
},
status: {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
RESERVED: "Reserved",
IN_REPAIR: "In repair",
BROKEN: "Broken",
STOLEN: "Stolen",
DISPOSED: "Disposed",
},
fallback: {
unknownStatus: "Unknown status",
},
},
},
login: {
title: "Sign In",
+50
View File
@@ -185,6 +185,56 @@ export const es = {
itemRequired: "El artículo es obligatorio",
},
},
assets: {
list: {
title: "Activos",
addLabel: "Agregar activo",
empty: "No se encontraron activos.",
columns: {
item: "Artículo",
category: "Categoría",
serialNumber: "Número de serie",
status: "Estado",
actions: "Acciones",
},
actions: {
edit: "Editar activo",
},
},
new: {
title: "Nuevo activo",
},
edit: {
title: "Editar activo",
notFound: "Activo no encontrado",
},
form: {
itemLabel: "Artículo",
itemPlaceholder: "Selecciona un artículo",
serialNumberLabel: "Número de serie",
serialNumberPlaceholder: "Número de serie",
deliveryNoteLabel: "Remito",
deliveryNotePlaceholder: "Remito",
statusLabel: "Estado",
statusPlaceholder: "Selecciona un estado",
recipientLabel: "Destinatario",
recipientPlaceholder: "Selecciona un destinatario",
createSubmit: "Crear activo",
updateSubmit: "Actualizar activo",
},
status: {
AVAILABLE: "Disponible",
ASSIGNED: "Asignado",
RESERVED: "Reservado",
IN_REPAIR: "En reparación",
BROKEN: "Roto",
STOLEN: "Robado",
DISPOSED: "Dado de baja",
},
fallback: {
unknownStatus: "Estado desconocido",
},
},
},
login: {
title: "Iniciar sesión",
+72
View File
@@ -0,0 +1,72 @@
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("inventory assets localization", () => {
test("renders asset list and new form UI/status copy in Spanish", async ({
baseURL,
page,
}) => {
await signInAsAdmin(page, baseURL)
await setLocaleCookie(page, "es", baseURL)
await page.goto("/inventory/assets")
await expect(page.locator("html")).toHaveAttribute("lang", "es")
await expect(page.getByRole("heading", { name: "Activos" })).toBeVisible()
await expect(
page.getByRole("link", { name: /Agregar activo/ }),
).toBeVisible()
await expect(page.getByText("No se encontraron activos.")).toBeVisible()
await page.goto("/inventory/assets/new")
await expect(
page.getByRole("heading", { name: "Nuevo activo" }),
).toBeVisible()
await expect(page.getByLabel("Artículo")).toBeVisible()
await expect(page.locator("select#itemId")).toContainText(
"Selecciona un artículo",
)
await expect(page.getByLabel("Número de serie")).toBeVisible()
await expect(page.getByPlaceholder("Número de serie")).toBeVisible()
await expect(page.getByLabel("Remito")).toBeVisible()
await expect(page.getByPlaceholder("Remito")).toBeVisible()
await expect(page.getByLabel("Estado")).toBeVisible()
await expect(page.locator("select#status")).toContainText("Disponible")
await expect(page.locator("select#status")).toContainText("Asignado")
await expect(page.locator("option[value='AVAILABLE']")).toHaveText(
"Disponible",
)
await expect(page.locator("option[value='ASSIGNED']")).toHaveText(
"Asignado",
)
await expect(
page.getByRole("button", { name: "Crear activo" }),
).toBeVisible()
await expect(
page.getByRole("button", { name: /Eliminar activo/i }),
).toHaveCount(0)
})
})
+104
View File
@@ -408,6 +408,110 @@ describe("i18n dictionaries", () => {
})
})
it("provides localized inventory asset UI copy for English and Spanish", () => {
expect(getDictionary("en").inventory.assets).toEqual({
list: {
title: "Assets",
addLabel: "Add Asset",
empty: "No assets found.",
columns: {
item: "Item",
category: "Category",
serialNumber: "Serial Number",
status: "Status",
actions: "Actions",
},
actions: {
edit: "Edit asset",
},
},
new: {
title: "New Asset",
},
edit: {
title: "Edit Asset",
notFound: "Asset not found",
},
form: {
itemLabel: "Item",
itemPlaceholder: "Select an item",
serialNumberLabel: "Serial Number",
serialNumberPlaceholder: "Serial number",
deliveryNoteLabel: "Delivery Note",
deliveryNotePlaceholder: "Delivery note",
statusLabel: "Status",
statusPlaceholder: "Select a status",
recipientLabel: "Recipient",
recipientPlaceholder: "Select a recipient",
createSubmit: "Create Asset",
updateSubmit: "Update Asset",
},
status: {
AVAILABLE: "Available",
ASSIGNED: "Assigned",
RESERVED: "Reserved",
IN_REPAIR: "In repair",
BROKEN: "Broken",
STOLEN: "Stolen",
DISPOSED: "Disposed",
},
fallback: {
unknownStatus: "Unknown status",
},
})
expect(getDictionary("es").inventory.assets).toEqual({
list: {
title: "Activos",
addLabel: "Agregar activo",
empty: "No se encontraron activos.",
columns: {
item: "Artículo",
category: "Categoría",
serialNumber: "Número de serie",
status: "Estado",
actions: "Acciones",
},
actions: {
edit: "Editar activo",
},
},
new: {
title: "Nuevo activo",
},
edit: {
title: "Editar activo",
notFound: "Activo no encontrado",
},
form: {
itemLabel: "Artículo",
itemPlaceholder: "Selecciona un artículo",
serialNumberLabel: "Número de serie",
serialNumberPlaceholder: "Número de serie",
deliveryNoteLabel: "Remito",
deliveryNotePlaceholder: "Remito",
statusLabel: "Estado",
statusPlaceholder: "Selecciona un estado",
recipientLabel: "Destinatario",
recipientPlaceholder: "Selecciona un destinatario",
createSubmit: "Crear activo",
updateSubmit: "Actualizar activo",
},
status: {
AVAILABLE: "Disponible",
ASSIGNED: "Asignado",
RESERVED: "Reservado",
IN_REPAIR: "En reparación",
BROKEN: "Roto",
STOLEN: "Robado",
DISPOSED: "Dado de baja",
},
fallback: {
unknownStatus: "Estado desconocido",
},
})
})
it("keeps dashboard home dictionary keys aligned across locales", () => {
expect(getDictionary("en").dashboardHome).toEqual({
heading: "Dashboard",