feat(assignments): remaining quantity display and partial return i18n
This commit is contained in:
@@ -16,20 +16,10 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
import type { Dictionary } from "@/i18n/dictionaries"
|
||||||
import type { ReturnAssignmentFormType } from "@/schemas/assignment.schema"
|
import type { ReturnAssignmentFormType } from "@/schemas/assignment.schema"
|
||||||
|
|
||||||
type PartialReturnCopy = {
|
type PartialReturnCopy = Dictionary["inventory"]["assignments"]["partialReturn"]
|
||||||
title: string
|
|
||||||
quantity: string
|
|
||||||
quantityPlaceholder: string
|
|
||||||
notes: string
|
|
||||||
notesPlaceholder: string
|
|
||||||
submit: string
|
|
||||||
cancel: string
|
|
||||||
maxQuantity: string
|
|
||||||
errorConcurrent: string
|
|
||||||
errorGeneric: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPartialReturnCopy: PartialReturnCopy = {
|
const defaultPartialReturnCopy: PartialReturnCopy = {
|
||||||
title: "Devolver artículo",
|
title: "Devolver artículo",
|
||||||
|
|||||||
@@ -82,7 +82,17 @@ export default async function AssignmentsPage(props: {
|
|||||||
{assignment?.asset?.serialNumber ||
|
{assignment?.asset?.serialNumber ||
|
||||||
copy.fallback.missingValue}
|
copy.fallback.missingValue}
|
||||||
</td>
|
</td>
|
||||||
<td className="p-4">{assignment?.quantity}</td>
|
<td className="p-4">
|
||||||
|
{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}
|
||||||
|
</td>
|
||||||
<td className="p-4">
|
<td className="p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
@@ -99,6 +109,9 @@ export default async function AssignmentsPage(props: {
|
|||||||
<ReturnButton
|
<ReturnButton
|
||||||
assignmentId={assignment.id}
|
assignmentId={assignment.id}
|
||||||
ariaLabel={copy.list.actions.return}
|
ariaLabel={copy.list.actions.return}
|
||||||
|
assignmentLineId={assignment.assignmentLineId}
|
||||||
|
remainingQuantity={assignment.remainingQuantity}
|
||||||
|
copy={copy.partialReturn}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -366,6 +366,23 @@ export const en = {
|
|||||||
assetIdRequired: "Asset ID is required when item ID is provided",
|
assetIdRequired: "Asset ID is required when item ID is provided",
|
||||||
idRequired: "Assignment ID is required",
|
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: {
|
people: {
|
||||||
list: {
|
list: {
|
||||||
|
|||||||
@@ -371,6 +371,23 @@ export const es = {
|
|||||||
"El activo es obligatorio cuando se especifica el artículo",
|
"El activo es obligatorio cuando se especifica el artículo",
|
||||||
idRequired: "El ID de asignación es obligatorio",
|
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: {
|
people: {
|
||||||
list: {
|
list: {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
returnAssignment: vi.fn(),
|
returnAssignment: vi.fn(),
|
||||||
toastError: vi.fn(),
|
toastError: vi.fn(),
|
||||||
toastSuccess: vi.fn(),
|
toastSuccess: vi.fn(),
|
||||||
|
ReturnButton: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock("@/i18n/server", () => ({
|
vi.mock("@/i18n/server", () => ({
|
||||||
@@ -27,6 +28,10 @@ vi.mock("@/actions/assignment.actions", () => ({
|
|||||||
returnAssignment: mocks.returnAssignment,
|
returnAssignment: mocks.returnAssignment,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/assignments/_components/return.button", () => ({
|
||||||
|
default: mocks.ReturnButton,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock("next/navigation", () => ({
|
vi.mock("next/navigation", () => ({
|
||||||
useRouter: () => ({
|
useRouter: () => ({
|
||||||
refresh: mocks.refresh,
|
refresh: mocks.refresh,
|
||||||
@@ -58,6 +63,27 @@ describe("assignment pages localization", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
mocks.getI18n.mockResolvedValue({ dictionary: es, locale: "es" })
|
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 () => {
|
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",
|
id: "assignment-1",
|
||||||
quantity: 2,
|
quantity: 2,
|
||||||
|
status: "OPEN",
|
||||||
person: {
|
person: {
|
||||||
id: "person-1",
|
id: "person-1",
|
||||||
firstName: "Ada",
|
firstName: "Ada",
|
||||||
@@ -102,7 +129,85 @@ describe("assignment pages localization", () => {
|
|||||||
expect(html).toContain("Laptop")
|
expect(html).toContain("Laptop")
|
||||||
expect(html).toContain("No disponible")
|
expect(html).toContain("No disponible")
|
||||||
expect(html).toContain('aria-label="Editar asignación"')
|
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 () => {
|
it("renders the localized assignment empty state when no assignments exist", async () => {
|
||||||
|
|||||||
@@ -479,6 +479,23 @@ describe("i18n dictionaries", () => {
|
|||||||
assetIdRequired: "Asset ID is required when item ID is provided",
|
assetIdRequired: "Asset ID is required when item ID is provided",
|
||||||
idRequired: "Assignment ID is required",
|
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({
|
expect(getDictionary("es").inventory.assignments).toEqual({
|
||||||
@@ -544,6 +561,23 @@ describe("i18n dictionaries", () => {
|
|||||||
"El activo es obligatorio cuando se especifica el artículo",
|
"El activo es obligatorio cuando se especifica el artículo",
|
||||||
idRequired: "El ID de asignación es obligatorio",
|
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}",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user