feat(assignments): partial return action and ReturnButton modal

This commit is contained in:
2026-06-25 21:18:34 +02:00
parent de40e0bf73
commit 2c03cd4d66
5 changed files with 494 additions and 24 deletions
+21 -2
View File
@@ -6,6 +6,7 @@ import { localizeAssignmentFieldErrors } from "@/actions/assignment.messages"
import { getI18n } from "@/i18n/server"
import {
buildCreateAssignmentSchema,
buildReturnAssignmentSchema,
buildUpdateAssignmentSchema,
type CreateAssignmentFormType,
type ReturnAssignmentFormType,
@@ -120,14 +121,32 @@ export async function updateAssignment(formData: UpdateAssignmentFormType) {
}
}
export async function returnAssignment(formData: ReturnAssignmentFormType) {
type ReturnAssignmentActionResult =
| { success: true; message: string }
| { success: false; errors?: Record<string, string[]>; message?: string }
export async function returnAssignment(
formData: ReturnAssignmentFormType,
): Promise<ReturnAssignmentActionResult> {
const { dictionary } = await getI18n()
const copy = dictionary.inventory.assignments
const userId = await getAuthenticatedUserId()
const validatedFields = buildReturnAssignmentSchema(copy.schema).safeParse(
formData,
)
if (!validatedFields.success) {
return {
success: false,
errors: flattenError(validatedFields.error).fieldErrors,
}
}
const result = await returnAssignmentUseCase({
id: formData.id,
id: validatedFields.data.id,
actorId: userId,
returns: validatedFields.data.returns,
})
if (!result.success) {
@@ -2,45 +2,131 @@
import { ArrowLeft } from "lucide-react"
import { useRouter } from "next/navigation"
import { useTransition } from "react"
import { useState, useTransition } from "react"
import { toast } from "sonner"
import { returnAssignment } from "@/actions/assignment.actions"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
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
}
const defaultPartialReturnCopy: PartialReturnCopy = {
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",
}
export default function ReturnButton({
assignmentId,
ariaLabel,
assignmentLineId,
remainingQuantity,
copy = defaultPartialReturnCopy,
}: {
assignmentId: string
ariaLabel: string
assignmentLineId?: string
remainingQuantity?: number
copy?: PartialReturnCopy
}) {
const router = useRouter()
const [isPending, startTransition] = useTransition()
const [open, setOpen] = useState(false)
const [quantity, setQuantity] = useState(1)
const [notes, setNotes] = useState("")
const [errorKey, setErrorKey] = useState<
"errorConcurrent" | "errorGeneric" | null
>(null)
const isQuantityMode =
assignmentLineId !== undefined && remainingQuantity !== undefined
const isOverMax = isQuantityMode && quantity > remainingQuantity
const canSubmit = isQuantityMode
? quantity >= 1 && quantity <= remainingQuantity && !isPending
: !isPending
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen)
if (!nextOpen) {
setQuantity(1)
setNotes("")
setErrorKey(null)
}
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!canSubmit) return
setErrorKey(null)
const formData: ReturnAssignmentFormType = isQuantityMode
? {
id: assignmentId,
returns: [
{
assignmentLineId,
quantity,
notes: notes.trim() || undefined,
},
],
}
: { id: assignmentId }
const handleReturn = (formData: ReturnAssignmentFormType) => {
startTransition(async () => {
const response = await returnAssignment(formData)
if (!response.success && response.errors?.id) {
toast.error(response.errors.id[0])
if (response.success) {
setOpen(false)
setQuantity(1)
setNotes("")
toast.success(response.message)
router.refresh()
return
}
if (response.success) {
toast.success(response.message)
router.refresh()
if (response.errors?.error?.includes("errorConcurrent")) {
setErrorKey("errorConcurrent")
} else {
toast.error(response.message ?? "Unknown error")
setErrorKey("errorGeneric")
}
})
}
return (
<form action={() => handleReturn({ id: assignmentId })} className="w-full">
<input type="hidden" name="id" value={assignmentId} />
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button
type="submit"
type="button"
className="btn btn-error"
size="icon"
variant="outline"
@@ -49,6 +135,86 @@ export default function ReturnButton({
>
<ArrowLeft />
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription className="sr-only">
{copy.title}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{isQuantityMode && (
<>
<div className="grid gap-2">
<label
htmlFor={`quantity-${assignmentId}`}
className="text-sm font-medium"
>
{copy.quantity}
</label>
<Input
id={`quantity-${assignmentId}`}
type="number"
min={1}
max={remainingQuantity}
value={quantity}
onChange={(event) =>
setQuantity(Number(event.target.value))
}
placeholder={copy.quantityPlaceholder}
aria-invalid={isOverMax || undefined}
/>
{isOverMax && (
<p className="text-destructive text-sm">
{copy.maxQuantity.replace(
"{max}",
String(remainingQuantity),
)}
</p>
)}
</div>
<div className="grid gap-2">
<label
htmlFor={`notes-${assignmentId}`}
className="text-sm font-medium"
>
{copy.notes}
</label>
<textarea
id={`notes-${assignmentId}`}
value={notes}
onChange={(event) => setNotes(event.target.value)}
placeholder={copy.notesPlaceholder}
className="min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:bg-input/30"
/>
</div>
</>
)}
{errorKey && (
<p className="text-destructive text-sm" role="alert">
{copy[errorKey]}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
{copy.cancel}
</Button>
<Button type="submit" disabled={!canSubmit}>
{copy.submit}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest"
import { en } from "@/i18n/dictionaries/en"
const mocks = vi.hoisted(() => ({
revalidatePath: vi.fn(),
getI18n: vi.fn(),
getAuthenticatedUserId: vi.fn(),
returnAssignmentUseCase: vi.fn(),
}))
vi.mock("next/cache", () => ({
revalidatePath: mocks.revalidatePath,
}))
vi.mock("@/i18n/server", () => ({
getI18n: mocks.getI18n,
}))
vi.mock("@/services/auth.service", () => ({
getAuthenticatedUserId: mocks.getAuthenticatedUserId,
}))
vi.mock("@/use-cases/assignment.use-cases", () => ({
returnAssignmentUseCase: mocks.returnAssignmentUseCase,
}))
import { returnAssignment } from "@/actions/assignment.actions"
describe("returnAssignment action", () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.getI18n.mockResolvedValue({ dictionary: en, locale: "en" })
mocks.getAuthenticatedUserId.mockResolvedValue("user-1")
})
it("returns validation errors for a missing assignment id", async () => {
const result = await returnAssignment({ id: "" })
expect(result).toEqual({
success: false,
errors: {
id: [en.inventory.assignments.schema.idRequired],
},
})
expect(mocks.returnAssignmentUseCase).not.toHaveBeenCalled()
})
it("forwards returns to the use case and revalidates on success", async () => {
mocks.returnAssignmentUseCase.mockResolvedValue({ success: true })
const result = await returnAssignment({
id: "assignment-1",
returns: [
{ assignmentLineId: "line-1", quantity: 2, notes: "Slightly damaged" },
],
})
expect(result).toEqual({
success: true as const,
message: en.inventory.assignments.actions.returnSuccess,
})
expect(mocks.returnAssignmentUseCase).toHaveBeenCalledWith({
id: "assignment-1",
actorId: "user-1",
returns: [
{ assignmentLineId: "line-1", quantity: 2, notes: "Slightly damaged" },
],
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/assignments")
})
it("surfaces use-case errors and skips revalidation on failure", async () => {
mocks.returnAssignmentUseCase.mockResolvedValue({
success: false,
errors: { error: ["errorConcurrent"] },
})
const result = await returnAssignment({
id: "assignment-1",
returns: [{ assignmentLineId: "line-1", quantity: 2 }],
})
expect(result).toEqual({
success: false as const,
errors: { error: ["errorConcurrent"] },
message: en.inventory.assignments.actions.returnFailure,
})
expect(mocks.revalidatePath).not.toHaveBeenCalled()
})
it("preserves the legacy full-return shortcut without returns", async () => {
mocks.returnAssignmentUseCase.mockResolvedValue({ success: true })
const result = await returnAssignment({ id: "assignment-1" })
expect(result.success).toBe(true)
expect(mocks.returnAssignmentUseCase).toHaveBeenCalledWith({
id: "assignment-1",
actorId: "user-1",
})
expect(mocks.revalidatePath).toHaveBeenCalledWith("/assignments")
})
})
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest"
import { cleanup, render, screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
returnAssignment: vi.fn(),
refresh: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}))
vi.mock("@/actions/assignment.actions", () => ({
returnAssignment: mocks.returnAssignment,
}))
vi.mock("next/navigation", () => ({
useRouter: () => ({ refresh: mocks.refresh }),
}))
vi.mock("sonner", () => ({
toast: { error: mocks.toastError, success: mocks.toastSuccess },
}))
import ReturnButton from "@/app/(dashboard)/assignments/_components/return.button"
const mockCopy = {
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",
}
describe("ReturnButton", () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
cleanup()
})
it("opens the dialog and submits a partial return", async () => {
mocks.returnAssignment.mockResolvedValue({
success: true,
message: "Returned",
})
render(
<ReturnButton
assignmentId="assignment-1"
ariaLabel="Return assignment"
assignmentLineId="line-1"
remainingQuantity={5}
copy={mockCopy}
/>,
)
await userEvent.click(
screen.getByRole("button", { name: "Return assignment" }),
)
expect(screen.getByRole("dialog")).toBeInTheDocument()
expect(
screen.getByRole("heading", { name: mockCopy.title }),
).toBeInTheDocument()
const quantityInput = screen.getByRole("spinbutton", {
name: mockCopy.quantity,
})
await userEvent.clear(quantityInput)
await userEvent.type(quantityInput, "2")
const notesInput = screen.getByRole("textbox", { name: mockCopy.notes })
await userEvent.type(notesInput, "Slightly damaged")
await userEvent.click(screen.getByRole("button", { name: mockCopy.submit }))
expect(mocks.returnAssignment).toHaveBeenCalledWith({
id: "assignment-1",
returns: [
{
assignmentLineId: "line-1",
quantity: 2,
notes: "Slightly damaged",
},
],
})
expect(mocks.toastSuccess).toHaveBeenCalledWith("Returned")
})
it("disables submit and shows max quantity when quantity exceeds remaining", async () => {
render(
<ReturnButton
assignmentId="assignment-1"
ariaLabel="Return assignment"
assignmentLineId="line-1"
remainingQuantity={3}
copy={mockCopy}
/>,
)
await userEvent.click(
screen.getByRole("button", { name: "Return assignment" }),
)
const quantityInput = screen.getByRole("spinbutton", {
name: mockCopy.quantity,
})
await userEvent.clear(quantityInput)
await userEvent.type(quantityInput, "4")
expect(screen.getByRole("button", { name: mockCopy.submit })).toBeDisabled()
expect(screen.getByText("Máximo: 3")).toBeInTheDocument()
})
it("shows concurrency error when the action returns one", async () => {
mocks.returnAssignment.mockResolvedValue({
success: false,
errors: { error: ["errorConcurrent"] },
message: "Error returning assignment",
})
render(
<ReturnButton
assignmentId="assignment-1"
ariaLabel="Return assignment"
assignmentLineId="line-1"
remainingQuantity={5}
copy={mockCopy}
/>,
)
await userEvent.click(
screen.getByRole("button", { name: "Return assignment" }),
)
const quantityInput = screen.getByRole("spinbutton", {
name: mockCopy.quantity,
})
await userEvent.clear(quantityInput)
await userEvent.type(quantityInput, "2")
await userEvent.click(screen.getByRole("button", { name: mockCopy.submit }))
expect(screen.getByText(mockCopy.errorConcurrent)).toBeInTheDocument()
})
it("opens confirm dialog and submits legacy full return without quantity input", async () => {
mocks.returnAssignment.mockResolvedValue({
success: true,
message: "Returned",
})
render(
<ReturnButton
assignmentId="assignment-1"
ariaLabel="Return assignment"
copy={mockCopy}
/>,
)
await userEvent.click(
screen.getByRole("button", { name: "Return assignment" }),
)
expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument()
await userEvent.click(screen.getByRole("button", { name: mockCopy.submit }))
expect(mocks.returnAssignment).toHaveBeenCalledWith({ id: "assignment-1" })
})
})
+1 -1
View File
@@ -3,7 +3,7 @@ import { defineConfig } from "vitest/config"
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
include: ["tests/**/*.test.ts", "tests/**/*.test.tsx"],
watch: false,
globals: false,
clearMocks: true,