feat: add transfer UI for Assignment

Per-row employee <Select> + Transfer button alongside the existing Return
button, shown for active (returned_at null) rows. PATCHes
/api/assignments/[id] with {employeeId}, which the route already dispatches
to transferAssignment() vs returnAssignment() based on body shape.
This commit is contained in:
toanhoang
2026-08-04 10:43:39 -04:00
parent 713dfad364
commit d700eaa6a9
+120 -15
View File
@@ -88,6 +88,14 @@ export default function AssignmentsPage() {
const [returnErrors, setReturnErrors] = useState<Record<string, string>>({}); const [returnErrors, setReturnErrors] = useState<Record<string, string>>({});
const [returningId, setReturningId] = useState<string | null>(null); const [returningId, setReturningId] = useState<string | null>(null);
// Per-row transfer mini-form state, keyed by assignment id — same shape as
// maintenance-records/page.tsx's close mini-form state (separate maps
// rather than one nested-object map, so each field stays a plain string
// setter).
const [transferEmployeeId, setTransferEmployeeId] = useState<Record<string, string>>({});
const [transferErrors, setTransferErrors] = useState<Record<string, string>>({});
const [transferringId, setTransferringId] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) { async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
setSubmitError(null); setSubmitError(null);
@@ -183,6 +191,61 @@ export default function AssignmentsPage() {
} }
} }
async function handleTransfer(id: string) {
setTransferringId(id);
setTransferErrors((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
const employeeId = transferEmployeeId[id] ?? "";
try {
// PATCH /api/assignments/[id]'s body is `{employeeId}` for a transfer
// — see src/app/api/assignments/[id]/route.ts's dispatch (a body
// carrying employeeId is a transfer, everything else is a return).
// employeeId is sent even if unselected (empty string), which the
// server rejects with a clean 400 message surfaced below, same as any
// other validation failure.
const response = await fetch(`/api/assignments/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ employeeId }),
});
let body: unknown;
try {
body = await response.json();
} catch {
body = null;
}
if (!response.ok) {
const message =
body !== null &&
typeof body === "object" &&
"error" in body &&
typeof (body as { error: unknown }).error === "string"
? (body as { error: string }).error
: "Request failed";
setTransferErrors((previous) => ({ ...previous, [id]: message }));
return;
}
setTransferEmployeeId((previous) => {
const next = { ...previous };
delete next[id];
return next;
});
refetch();
} catch {
setTransferErrors((previous) => ({ ...previous, [id]: "Request failed" }));
} finally {
setTransferringId(null);
}
}
return ( return (
<div className="flex flex-col gap-8 p-6"> <div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Assignments</h1> <h1 className="text-xl font-semibold">Assignments</h1>
@@ -220,21 +283,63 @@ export default function AssignmentsPage() {
<TableCell>{assignment.created_at}</TableCell> <TableCell>{assignment.created_at}</TableCell>
<TableCell> <TableCell>
{assignment.returned_at === null ? ( {assignment.returned_at === null ? (
<div className="flex flex-col gap-1"> <div className="flex w-56 flex-col gap-2">
<Button <div className="flex flex-col gap-1">
type="button" <Button
size="sm" type="button"
variant="outline" size="sm"
disabled={returningId === assignment.id} variant="outline"
onClick={() => handleReturn(assignment.id)} disabled={returningId === assignment.id}
> onClick={() => handleReturn(assignment.id)}
{returningId === assignment.id ? "Returning..." : "Return"} >
</Button> {returningId === assignment.id ? "Returning..." : "Return"}
{returnErrors[assignment.id] ? ( </Button>
<p className="text-xs text-destructive"> {returnErrors[assignment.id] ? (
{returnErrors[assignment.id]} <p className="text-xs text-destructive">
</p> {returnErrors[assignment.id]}
) : null} </p>
) : null}
</div>
<div className="flex flex-col gap-1">
<Select
value={transferEmployeeId[assignment.id] ?? ""}
onValueChange={(value) =>
setTransferEmployeeId((previous) => ({
...previous,
[assignment.id]: value,
}))
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Transfer to..." />
</SelectTrigger>
<SelectContent>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
size="sm"
variant="outline"
disabled={
transferringId === assignment.id ||
!transferEmployeeId[assignment.id]
}
onClick={() => handleTransfer(assignment.id)}
>
{transferringId === assignment.id ? "Transferring..." : "Transfer"}
</Button>
{transferErrors[assignment.id] ? (
<p className="text-xs text-destructive">
{transferErrors[assignment.id]}
</p>
) : null}
</div>
</div> </div>
) : ( ) : (
"-" "-"