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}",
+ },
})
})