From b401f254ec784b7fdd1e112e25d56d658fcc48c4 Mon Sep 17 00:00:00 2001 From: Asis Ferrer Date: Thu, 25 Jun 2026 21:42:09 +0200 Subject: [PATCH] feat(assignments): remaining quantity display and partial return i18n --- .../assignments/_components/return.button.tsx | 14 +-- src/app/(dashboard)/assignments/page.tsx | 15 ++- src/i18n/dictionaries/en.ts | 17 +++ src/i18n/dictionaries/es.ts | 17 +++ tests/e2e/assignments.spec.ts | 108 ++++++++++++++++++ .../app/assignments/assignment-pages.test.ts | 107 ++++++++++++++++- tests/unit/i18n/dictionaries.test.ts | 34 ++++++ 7 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/assignments.spec.ts diff --git a/src/app/(dashboard)/assignments/_components/return.button.tsx b/src/app/(dashboard)/assignments/_components/return.button.tsx index b5f6bb3..6957748 100644 --- a/src/app/(dashboard)/assignments/_components/return.button.tsx +++ b/src/app/(dashboard)/assignments/_components/return.button.tsx @@ -16,20 +16,10 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" +import type { Dictionary } from "@/i18n/dictionaries" import type { ReturnAssignmentFormType } from "@/schemas/assignment.schema" -type PartialReturnCopy = { - title: string - quantity: string - quantityPlaceholder: string - notes: string - notesPlaceholder: string - submit: string - cancel: string - maxQuantity: string - errorConcurrent: string - errorGeneric: string -} +type PartialReturnCopy = Dictionary["inventory"]["assignments"]["partialReturn"] const defaultPartialReturnCopy: PartialReturnCopy = { title: "Devolver artículo", diff --git a/src/app/(dashboard)/assignments/page.tsx b/src/app/(dashboard)/assignments/page.tsx index 2a28328..70dcf66 100644 --- a/src/app/(dashboard)/assignments/page.tsx +++ b/src/app/(dashboard)/assignments/page.tsx @@ -82,7 +82,17 @@ export default async function AssignmentsPage(props: { {assignment?.asset?.serialNumber || copy.fallback.missingValue} - {assignment?.quantity} + + {assignment.status === "PARTIALLY_RETURNED" && + assignment.remainingQuantity !== undefined + ? `${copy.remaining.label}: ${copy.remaining.value + .replace( + "{remaining}", + String(assignment.remainingQuantity), + ) + .replace("{total}", String(assignment.quantity))}` + : assignment?.quantity} +
diff --git a/src/i18n/dictionaries/en.ts b/src/i18n/dictionaries/en.ts index 4cf7a0a..c872c36 100644 --- a/src/i18n/dictionaries/en.ts +++ b/src/i18n/dictionaries/en.ts @@ -366,6 +366,23 @@ export const en = { assetIdRequired: "Asset ID is required when item ID is provided", idRequired: "Assignment ID is required", }, + partialReturn: { + title: "Return item", + quantity: "Quantity", + quantityPlaceholder: "1", + notes: "Notes", + notesPlaceholder: "Optional notes", + submit: "Return", + cancel: "Cancel", + maxQuantity: "Maximum: {max}", + errorConcurrent: + "This return was modified by another user. Reload and try again.", + errorGeneric: "Error processing the return", + }, + remaining: { + label: "Remaining", + value: "{remaining} of {total}", + }, }, people: { list: { diff --git a/src/i18n/dictionaries/es.ts b/src/i18n/dictionaries/es.ts index 5a03c15..d6173b0 100644 --- a/src/i18n/dictionaries/es.ts +++ b/src/i18n/dictionaries/es.ts @@ -371,6 +371,23 @@ export const es = { "El activo es obligatorio cuando se especifica el artículo", idRequired: "El ID de asignación es obligatorio", }, + partialReturn: { + title: "Devolver artículo", + quantity: "Cantidad", + quantityPlaceholder: "1", + notes: "Notas", + notesPlaceholder: "Notas opcionales", + submit: "Devolver", + cancel: "Cancelar", + maxQuantity: "Máximo: {max}", + errorConcurrent: + "La devolución fue modificada por otro usuario. Recarga e inténtalo de nuevo.", + errorGeneric: "Error al procesar la devolución", + }, + remaining: { + label: "Restantes", + value: "{remaining} de {total}", + }, }, people: { list: { diff --git a/tests/e2e/assignments.spec.ts b/tests/e2e/assignments.spec.ts new file mode 100644 index 0000000..9e73713 --- /dev/null +++ b/tests/e2e/assignments.spec.ts @@ -0,0 +1,108 @@ +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("Email").fill("admin@example.test") + await page.getByLabel("Password").fill("admin-password") + await page.getByRole("button", { name: "Sign In" }).click() + await expect(page).toHaveURL("/") +} + +async function createPerson(page: Page, name: string, email: string) { + await page.goto("/people/new") + await page.getByLabel("Nombre").fill(name) + await page.getByLabel("Apellido").fill("E2E") + await page.getByLabel("Departamento").selectOption("OTHER") + await page.getByLabel("Correo electrónico").fill(email) + await page.getByLabel("Teléfono").fill("123456789") + await page.getByLabel("Rol").selectOption("NO_USER") + await page.getByRole("button", { name: "Crear usuario" }).click() + await expect(page.getByText("Usuario creado correctamente")).toBeVisible() +} + +async function createCategory(page: Page, name: string) { + await page.goto("/inventory/categories/new") + await page.locator("input#name").fill(name) + await page.getByRole("button", { name: "Crear categoría" }).click() + await expect(page.getByText("Categoría creada correctamente")).toBeVisible() +} + +async function createItem(page: Page, name: string, categoryName: string) { + await page.goto("/inventory/items/new") + await page.getByLabel("Nombre").fill(name) + await page.getByLabel("Categoría").selectOption({ label: categoryName }) + await page.getByLabel("Stock").click() + await page.getByLabel("Stock").fill("10") + await page.getByRole("button", { name: "Crear artículo" }).click() + await expect(page.getByText("Artículo creado correctamente")).toBeVisible() +} + +async function createAssignment( + page: Page, + personFullName: string, + itemName: string, + quantity: string, +) { + await page.goto("/assignments/new") + await page.getByLabel("Persona").selectOption({ label: personFullName }) + await page.getByLabel("Artículo").selectOption({ label: itemName }) + await page.getByLabel("Cantidad").fill(quantity) + await page.getByRole("button", { name: "Crear asignación" }).click() + await expect(page.getByText("Asignación creada correctamente")).toBeVisible() +} + +test.describe("assignment partial return", () => { + test("creates an assignment and submits a partial return", async ({ + baseURL, + page, + }) => { + const timestamp = Date.now() + const personName = `E2E Person ${timestamp}` + const itemName = `E2E Item ${timestamp}` + const categoryName = `E2E Category ${timestamp}` + + await signInAsAdmin(page, baseURL) + await setLocaleCookie(page, "es", baseURL) + await createCategory(page, categoryName) + await createPerson(page, personName, `e2e-${timestamp}@example.test`) + await createItem(page, itemName, categoryName) + await createAssignment(page, `${personName} E2E`, itemName, "5") + + await page.goto("/assignments") + + const row = page.getByRole("row", { name: new RegExp(personName) }) + await expect(row).toContainText("5") + + const returnButton = row.getByRole("button", { + name: "Devolver asignación", + }) + await returnButton.click() + + const dialog = page.getByRole("dialog") + await expect(dialog).toBeVisible() + await expect( + dialog.getByRole("heading", { name: "Devolver artículo" }), + ).toBeVisible() + + await dialog.getByRole("spinbutton", { name: "Cantidad" }).fill("2") + await dialog.getByRole("button", { name: "Devolver" }).click() + + await expect(dialog).not.toBeVisible() + await expect(row).toContainText("Restantes: 3 de 5") + }) +}) diff --git a/tests/unit/app/assignments/assignment-pages.test.ts b/tests/unit/app/assignments/assignment-pages.test.ts index a05bea4..f578b34 100644 --- a/tests/unit/app/assignments/assignment-pages.test.ts +++ b/tests/unit/app/assignments/assignment-pages.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ returnAssignment: vi.fn(), toastError: vi.fn(), toastSuccess: vi.fn(), + ReturnButton: vi.fn(), })) vi.mock("@/i18n/server", () => ({ @@ -27,6 +28,10 @@ vi.mock("@/actions/assignment.actions", () => ({ returnAssignment: mocks.returnAssignment, })) +vi.mock("@/app/(dashboard)/assignments/_components/return.button", () => ({ + default: mocks.ReturnButton, +})) + vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: mocks.refresh, @@ -58,6 +63,27 @@ describe("assignment pages localization", () => { beforeEach(() => { vi.clearAllMocks() mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" }) + mocks.ReturnButton.mockImplementation( + ({ + assignmentId, + ariaLabel, + assignmentLineId, + remainingQuantity, + }: { + assignmentId: string + ariaLabel: string + assignmentLineId?: string + remainingQuantity?: number + }) => + createElement("button", { + type: "button", + "data-assignment-id": assignmentId, + "data-aria-label": ariaLabel, + "data-assignment-line-id": assignmentLineId, + "data-remaining-quantity": remainingQuantity, + "aria-label": ariaLabel, + }), + ) }) it("renders the assignment list in Spanish with localized action labels and unchanged assignment data", async () => { @@ -70,6 +96,7 @@ describe("assignment pages localization", () => { { id: "assignment-1", quantity: 2, + status: "OPEN", person: { id: "person-1", firstName: "Ada", @@ -102,7 +129,85 @@ describe("assignment pages localization", () => { expect(html).toContain("Laptop") expect(html).toContain("No disponible") expect(html).toContain('aria-label="Editar asignación"') - expect(html).toContain('aria-label="Devolver asignación"') + expect(html).toContain('data-aria-label="Devolver asignación"') + expect(html).toContain('data-assignment-id="assignment-1"') + }) + + it("renders the full quantity for OPEN rows and omits partial-return props", async () => { + const { default: AssignmentsPage } = await import( + "@/app/(dashboard)/assignments/page" + ) + + mocks.findAllWithPersonPaginated.mockResolvedValue({ + data: [ + { + id: "assignment-1", + quantity: 5, + status: "OPEN", + assignmentLineId: "line-1", + remainingQuantity: 5, + person: { + id: "person-1", + firstName: "Ada", + lastName: "Lovelace", + }, + item: { + id: "item-1", + name: "Laptop", + }, + asset: { + serialNumber: null, + }, + }, + ], + totalPages: 1, + }) + + const html = renderToStaticMarkup( + await AssignmentsPage({ searchParams: Promise.resolve({}) }), + ) + + expect(html).toContain('data-assignment-line-id="line-1"') + expect(html).toContain('data-remaining-quantity="5"') + expect(html).not.toContain("Restantes") + }) + + it("renders the remaining quantity for PARTIALLY_RETURNED rows", async () => { + const { default: AssignmentsPage } = await import( + "@/app/(dashboard)/assignments/page" + ) + + mocks.findAllWithPersonPaginated.mockResolvedValue({ + data: [ + { + id: "assignment-1", + quantity: 5, + status: "PARTIALLY_RETURNED", + assignmentLineId: "line-1", + remainingQuantity: 3, + person: { + id: "person-1", + firstName: "Ada", + lastName: "Lovelace", + }, + item: { + id: "item-1", + name: "Laptop", + }, + asset: { + serialNumber: null, + }, + }, + ], + totalPages: 1, + }) + + const html = renderToStaticMarkup( + await AssignmentsPage({ searchParams: Promise.resolve({}) }), + ) + + expect(html).toContain("Restantes") + expect(html).toContain("3 de 5") }) it("renders the localized assignment empty state when no assignments exist", async () => { diff --git a/tests/unit/i18n/dictionaries.test.ts b/tests/unit/i18n/dictionaries.test.ts index 057d06d..d8dbe10 100644 --- a/tests/unit/i18n/dictionaries.test.ts +++ b/tests/unit/i18n/dictionaries.test.ts @@ -479,6 +479,23 @@ describe("i18n dictionaries", () => { assetIdRequired: "Asset ID is required when item ID is provided", idRequired: "Assignment ID is required", }, + partialReturn: { + title: "Return item", + quantity: "Quantity", + quantityPlaceholder: "1", + notes: "Notes", + notesPlaceholder: "Optional notes", + submit: "Return", + cancel: "Cancel", + maxQuantity: "Maximum: {max}", + errorConcurrent: + "This return was modified by another user. Reload and try again.", + errorGeneric: "Error processing the return", + }, + remaining: { + label: "Remaining", + value: "{remaining} of {total}", + }, }) expect(getDictionary("es").inventory.assignments).toEqual({ @@ -544,6 +561,23 @@ describe("i18n dictionaries", () => { "El activo es obligatorio cuando se especifica el artículo", idRequired: "El ID de asignación es obligatorio", }, + partialReturn: { + title: "Devolver artículo", + quantity: "Cantidad", + quantityPlaceholder: "1", + notes: "Notas", + notesPlaceholder: "Notas opcionales", + submit: "Devolver", + cancel: "Cancelar", + maxQuantity: "Máximo: {max}", + errorConcurrent: + "La devolución fue modificada por otro usuario. Recarga e inténtalo de nuevo.", + errorGeneric: "Error al procesar la devolución", + }, + remaining: { + label: "Restantes", + value: "{remaining} de {total}", + }, }) })