feat(assignments): partial return action and ReturnButton modal
This commit is contained in:
@@ -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,53 +2,219 @@
|
||||
|
||||
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} />
|
||||
<Button
|
||||
type="submit"
|
||||
className="btn btn-error"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label={ariaLabel}
|
||||
disabled={isPending}
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</form>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
className="btn btn-error"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label={ariaLabel}
|
||||
disabled={isPending}
|
||||
>
|
||||
<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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user