feat(i18n): localize inventory categories UI

This commit is contained in:
2026-06-12 23:01:33 +02:00
parent 589b042e7c
commit e9a07eb28e
14 changed files with 315 additions and 17 deletions
@@ -13,6 +13,7 @@ export default async function EditCategoryPage({
const { categoryId } = await params const { categoryId } = await params
const category = await CategoryService.findById(categoryId) const category = await CategoryService.findById(categoryId)
const { dictionary } = await getI18n() const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
if (!category) { if (!category) {
notFound() notFound()
@@ -21,10 +22,11 @@ export default async function EditCategoryPage({
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">Edit Category</h1> <h1 className="text-2xl font-bold">{copy.edit.title}</h1>
</div> </div>
<EditCategoryForm <EditCategoryForm
category={category} category={category}
formCopy={copy.form}
submitButtonCopy={dictionary.common.submitButton} submitButtonCopy={dictionary.common.submitButton}
/> />
</div> </div>
@@ -0,0 +1,4 @@
import type { Dictionary } from "@/i18n/dictionaries"
export type CategoryFormCopy = Dictionary["inventory"]["categories"]["form"]
export type CategoryDeleteCopy = Dictionary["inventory"]["categories"]["delete"]
@@ -7,10 +7,14 @@ import { toast } from "sonner"
import { deleteCategoryAction } from "@/actions/category.actions" import { deleteCategoryAction } from "@/actions/category.actions"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import type { CategoryDeleteCopy } from "./category.copy"
export default function DeleteCategoryButton({ export default function DeleteCategoryButton({
categoryId, categoryId,
copy,
}: { }: {
categoryId: string categoryId: string
copy: CategoryDeleteCopy
}) { }) {
const router = useRouter() const router = useRouter()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
@@ -28,7 +32,7 @@ export default function DeleteCategoryButton({
toast.success(response.message) toast.success(response.message)
router.refresh() router.refresh()
} else { } else {
toast.error(response.message ?? "Unknown error") toast.error(response.message ?? copy.unknownError)
} }
}) })
} }
@@ -42,6 +46,7 @@ export default function DeleteCategoryButton({
size="icon" size="icon"
variant="outline" variant="outline"
disabled={isPending} disabled={isPending}
aria-label={isPending ? copy.pending : copy.label}
> >
<Trash /> <Trash />
</Button> </Button>
@@ -14,12 +14,15 @@ import {
updateCategorySchema, updateCategorySchema,
} from "@/schemas/category.schema" } from "@/schemas/category.schema"
import type { CategorySummary } from "@/types" import type { CategorySummary } from "@/types"
import type { CategoryFormCopy } from "./category.copy"
export default function EditCategoryForm({ export default function EditCategoryForm({
category, category,
formCopy,
submitButtonCopy, submitButtonCopy,
}: { }: {
category: CategorySummary category: CategorySummary
formCopy: CategoryFormCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
@@ -64,12 +67,12 @@ export default function EditCategoryForm({
<input type="hidden" {...register("id")} /> <input type="hidden" {...register("id")} />
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg"> <label htmlFor="name" className="mb-2 block text-lg">
Name {formCopy.nameLabel}
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
placeholder="Category name" placeholder={formCopy.namePlaceholder}
{...register("name")} {...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : "" errors.name ? "border-error" : ""
@@ -82,7 +85,7 @@ export default function EditCategoryForm({
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Update Category {formCopy.updateSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -13,10 +13,13 @@ import {
type CreateCategoryFormType, type CreateCategoryFormType,
createCategorySchema, createCategorySchema,
} from "@/schemas/category.schema" } from "@/schemas/category.schema"
import type { CategoryFormCopy } from "./category.copy"
export default function NewCategoryForm({ export default function NewCategoryForm({
formCopy,
submitButtonCopy, submitButtonCopy,
}: { }: {
formCopy: CategoryFormCopy
submitButtonCopy: SubmitButtonCopy submitButtonCopy: SubmitButtonCopy
}) { }) {
const router = useRouter() const router = useRouter()
@@ -56,12 +59,12 @@ export default function NewCategoryForm({
<form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}> <form className="flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<label htmlFor="name" className="mb-2 block text-lg"> <label htmlFor="name" className="mb-2 block text-lg">
Name {formCopy.nameLabel}
</label> </label>
<input <input
type="text" type="text"
id="name" id="name"
placeholder="Category name" placeholder={formCopy.namePlaceholder}
{...register("name")} {...register("name")}
className={`w-full rounded-lg border px-4 py-2 ${ className={`w-full rounded-lg border px-4 py-2 ${
errors.name ? "border-error" : "" errors.name ? "border-error" : ""
@@ -74,7 +77,7 @@ export default function NewCategoryForm({
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
isSubmitSuccessful={isSubmitSuccessful} isSubmitSuccessful={isSubmitSuccessful}
> >
Create Category {formCopy.createSubmit}
</SubmitButton> </SubmitButton>
</form> </form>
) )
@@ -4,13 +4,17 @@ import NewCategoryForm from "../_components/new.category.form"
export default async function NewCategoryPage() { export default async function NewCategoryPage() {
const { dictionary } = await getI18n() const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
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">New Category</h1> <h1 className="text-2xl font-bold">{copy.new.title}</h1>
</div> </div>
<NewCategoryForm submitButtonCopy={dictionary.common.submitButton} /> <NewCategoryForm
formCopy={copy.form}
submitButtonCopy={dictionary.common.submitButton}
/>
</div> </div>
) )
} }
@@ -4,6 +4,7 @@ import Link from "next/link"
import PageHeader from "@/components/common/pageheader" import PageHeader from "@/components/common/pageheader"
import PaginationButtons from "@/components/common/pagination" import PaginationButtons from "@/components/common/pagination"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server"
import { CategoryService } from "@/services/category.service" import { CategoryService } from "@/services/category.service"
import DeleteCategoryButton from "./_components/delete.category.button" import DeleteCategoryButton from "./_components/delete.category.button"
@@ -23,18 +24,21 @@ export default async function Items(props: {
pageSize: 10, pageSize: 10,
search, search,
}) })
const { dictionary } = await getI18n()
const copy = dictionary.inventory.categories
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Categories" title={copy.list.title}
addLabel={copy.list.addLabel}
link="/inventory/categories/new" link="/inventory/categories/new"
data={categories} data={categories}
/> />
{categories.length === 0 && currentPage === 1 && ( {categories.length === 0 && currentPage === 1 && (
<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">
No Categories found. {copy.list.empty}
</div> </div>
</div> </div>
)} )}
@@ -44,13 +48,13 @@ export default async function Items(props: {
<thead className="border-b"> <thead className="border-b">
<tr> <tr>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Name {copy.list.columns.name}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Items {copy.list.columns.items}
</th> </th>
<th scope="col" className="p-4"> <th scope="col" className="p-4">
Actions {copy.list.columns.actions}
</th> </th>
</tr> </tr>
</thead> </thead>
@@ -68,12 +72,16 @@ export default async function Items(props: {
className="btn btn-primary" className="btn btn-primary"
variant="outline" variant="outline"
size="icon" size="icon"
aria-label={copy.list.actions.edit}
> >
<Pencil /> <Pencil />
</Button> </Button>
</Link> </Link>
{category._count.items === 0 && ( {category._count.items === 0 && (
<DeleteCategoryButton categoryId={category.id} /> <DeleteCategoryButton
categoryId={category.id}
copy={copy.delete}
/>
)} )}
</td> </td>
</tr> </tr>
+5 -1
View File
@@ -5,9 +5,12 @@ import Search from "@/components/common/search"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getI18n } from "@/i18n/server" import { getI18n } from "@/i18n/server"
import { getPageHeaderAddLabel } from "./pageheader.utils"
interface PageHeaderProps { interface PageHeaderProps {
title?: string title?: string
link?: string link?: string
addLabel?: string
search?: string search?: string
data: unknown[] data: unknown[]
} }
@@ -15,6 +18,7 @@ interface PageHeaderProps {
export default async function PageHeader({ export default async function PageHeader({
title, title,
link, link,
addLabel,
search, search,
data, data,
}: PageHeaderProps) { }: PageHeaderProps) {
@@ -32,7 +36,7 @@ export default async function PageHeader({
<div className="justify-end md:ml-auto md:flex"> <div className="justify-end md:ml-auto md:flex">
<Link href={link} passHref> <Link href={link} passHref>
<Button className="btn btn-primary"> <Button className="btn btn-primary">
Add {title} <Plus /> {getPageHeaderAddLabel({ addLabel, title })} <Plus />
</Button> </Button>
</Link> </Link>
</div> </div>
+11
View File
@@ -0,0 +1,11 @@
type PageHeaderAddLabelInput = {
addLabel?: string
title?: string
}
export function getPageHeaderAddLabel({
addLabel,
title,
}: PageHeaderAddLabelInput) {
return addLabel ?? `Add ${title}`
}
+51
View File
@@ -63,6 +63,57 @@ export const en = {
label: "Sign Out", label: "Sign Out",
}, },
}, },
inventory: {
categories: {
list: {
title: "Categories",
addLabel: "Add Category",
empty: "No categories found.",
columns: {
name: "Name",
items: "Items",
actions: "Actions",
},
actions: {
edit: "Edit category",
delete: "Delete category",
},
},
new: {
title: "New Category",
},
edit: {
title: "Edit Category",
},
form: {
nameLabel: "Name",
namePlaceholder: "Category name",
createSubmit: "Create Category",
updateSubmit: "Update Category",
},
delete: {
label: "Delete category",
pending: "Deleting...",
unknownError: "Unknown error",
},
actions: {
createSuccess: "Category created successfully",
createFailure: "Failed to create category",
updateSuccess: "Category updated successfully",
updateFailure: "Failed to update category",
deleteSuccess: "Category deleted successfully",
deleteFailure: "Failed to delete category",
duplicateName: "Category already exists",
unchangedName: "Category name unchanged",
notFound: "Category not found",
hasItems: "Cannot delete category with items",
},
schema: {
nameRequired: "Name is required and must be at least 3 characters long",
idRequired: "ID is required",
},
},
},
login: { login: {
title: "Sign In", title: "Sign In",
usernameLabel: "Username", usernameLabel: "Username",
+52
View File
@@ -65,6 +65,58 @@ export const es = {
label: "Cerrar sesión", label: "Cerrar sesión",
}, },
}, },
inventory: {
categories: {
list: {
title: "Categorías",
addLabel: "Agregar categoría",
empty: "No se encontraron categorías.",
columns: {
name: "Nombre",
items: "Artículos",
actions: "Acciones",
},
actions: {
edit: "Editar categoría",
delete: "Eliminar categoría",
},
},
new: {
title: "Nueva categoría",
},
edit: {
title: "Editar categoría",
},
form: {
nameLabel: "Nombre",
namePlaceholder: "Nombre de la categoría",
createSubmit: "Crear categoría",
updateSubmit: "Actualizar categoría",
},
delete: {
label: "Eliminar categoría",
pending: "Eliminando...",
unknownError: "Error desconocido",
},
actions: {
createSuccess: "Categoría creada correctamente",
createFailure: "Error al crear la categoría",
updateSuccess: "Categoría actualizada correctamente",
updateFailure: "Error al actualizar la categoría",
deleteSuccess: "Categoría eliminada correctamente",
deleteFailure: "Error al eliminar la categoría",
duplicateName: "La categoría ya existe",
unchangedName: "El nombre de la categoría no cambió",
notFound: "Categoría no encontrada",
hasItems: "No se puede eliminar una categoría con artículos",
},
schema: {
nameRequired:
"El nombre es obligatorio y debe tener al menos 3 caracteres",
idRequired: "El ID es obligatorio",
},
},
},
login: { login: {
title: "Iniciar sesión", title: "Iniciar sesión",
usernameLabel: "Usuario", usernameLabel: "Usuario",
+30
View File
@@ -68,6 +68,36 @@ test.describe("language switcher", () => {
await expectLocaleCookie(page, "es") await expectLocaleCookie(page, "es")
}) })
test("renders inventory categories copy in the active locale", async ({
baseURL,
page,
}) => {
await signInAsAdmin(page, baseURL)
await setLocaleCookie(page, "es", baseURL)
await page.goto("/inventory/categories")
await expect(page.locator("html")).toHaveAttribute("lang", "es")
await expect(
page.getByRole("heading", { name: "Categorías" }),
).toBeVisible()
await expect(
page.getByRole("link", { name: /Agregar categoría/ }),
).toBeVisible()
await expect(page.getByText("No se encontraron categorías.")).toBeVisible()
await page.goto("/inventory/categories/new")
await expect(
page.getByRole("heading", { name: "Nueva categoría" }),
).toBeVisible()
await expect(page.getByLabel("Nombre")).toBeVisible()
await expect(page.getByPlaceholder("Nombre de la categoría")).toBeVisible()
await expect(
page.getByRole("button", { name: "Crear categoría" }),
).toBeVisible()
})
test("switches the authenticated dashboard language from the navbar", async ({ test("switches the authenticated dashboard language from the navbar", async ({
baseURL, baseURL,
page, page,
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest"
import { getPageHeaderAddLabel } from "@/components/common/pageheader.utils"
describe("PageHeader", () => {
it("uses the explicit add label when provided", () => {
expect(
getPageHeaderAddLabel({
addLabel: "Agregar categoría",
title: "Categorías",
}),
).toBe("Agregar categoría")
})
it("keeps the legacy fallback when no explicit add label is provided", () => {
expect(getPageHeaderAddLabel({ title: "Items" })).toBe("Add Items")
})
})
+103
View File
@@ -163,6 +163,109 @@ describe("i18n dictionaries", () => {
}) })
}) })
it("provides localized inventory category copy for English and Spanish", () => {
expect(getDictionary("en").inventory.categories).toEqual({
list: {
title: "Categories",
addLabel: "Add Category",
empty: "No categories found.",
columns: {
name: "Name",
items: "Items",
actions: "Actions",
},
actions: {
edit: "Edit category",
delete: "Delete category",
},
},
new: {
title: "New Category",
},
edit: {
title: "Edit Category",
},
form: {
nameLabel: "Name",
namePlaceholder: "Category name",
createSubmit: "Create Category",
updateSubmit: "Update Category",
},
delete: {
label: "Delete category",
pending: "Deleting...",
unknownError: "Unknown error",
},
actions: {
createSuccess: "Category created successfully",
createFailure: "Failed to create category",
updateSuccess: "Category updated successfully",
updateFailure: "Failed to update category",
deleteSuccess: "Category deleted successfully",
deleteFailure: "Failed to delete category",
duplicateName: "Category already exists",
unchangedName: "Category name unchanged",
notFound: "Category not found",
hasItems: "Cannot delete category with items",
},
schema: {
nameRequired: "Name is required and must be at least 3 characters long",
idRequired: "ID is required",
},
})
expect(getDictionary("es").inventory.categories).toEqual({
list: {
title: "Categorías",
addLabel: "Agregar categoría",
empty: "No se encontraron categorías.",
columns: {
name: "Nombre",
items: "Artículos",
actions: "Acciones",
},
actions: {
edit: "Editar categoría",
delete: "Eliminar categoría",
},
},
new: {
title: "Nueva categoría",
},
edit: {
title: "Editar categoría",
},
form: {
nameLabel: "Nombre",
namePlaceholder: "Nombre de la categoría",
createSubmit: "Crear categoría",
updateSubmit: "Actualizar categoría",
},
delete: {
label: "Eliminar categoría",
pending: "Eliminando...",
unknownError: "Error desconocido",
},
actions: {
createSuccess: "Categoría creada correctamente",
createFailure: "Error al crear la categoría",
updateSuccess: "Categoría actualizada correctamente",
updateFailure: "Error al actualizar la categoría",
deleteSuccess: "Categoría eliminada correctamente",
deleteFailure: "Error al eliminar la categoría",
duplicateName: "La categoría ya existe",
unchangedName: "El nombre de la categoría no cambió",
notFound: "Categoría no encontrada",
hasItems: "No se puede eliminar una categoría con artículos",
},
schema: {
nameRequired:
"El nombre es obligatorio y debe tener al menos 3 caracteres",
idRequired: "El ID es obligatorio",
},
})
})
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",