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:
@@ -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,6 +283,7 @@ 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 w-56 flex-col gap-2">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -236,6 +300,47 @@ export default function AssignmentsPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
"-"
|
"-"
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user