diff --git a/src/app/assignments/page.tsx b/src/app/assignments/page.tsx new file mode 100644 index 0000000..e685c19 --- /dev/null +++ b/src/app/assignments/page.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useApiList } from "@/hooks/use-api-list"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +// Shape returned by GET /api/assignments — snake_case per this repo's +// API-boundary convention (see src/lib/assignments.ts's toRecord()). +interface AssignmentRecord { + id: string; + employee_id: string; + asset_item_id: string | null; + license_id: string | null; + assigned_at: string; + returned_at: string | null; + signed_at: string | null; + created_at: string; +} + +// Shape returned by GET /api/employees — only the field used to populate the +// employeeId here (see src/app/assets/page.tsx's AssetItemRecord +// for the full shape). +interface AssetItemRecord { + id: string; + serial_number: string; +} + +// Shape returned by GET /api/licenses — only the field used to populate the +// licenseId to show/submit — the actual business rule is +// still enforced server-side, this doesn't reimplement it. +type TargetKind = "asset_item" | "license"; + +export default function AssignmentsPage() { + const { + data: assignments, + loading, + error, + refetch, + } = useApiList("/api/assignments"); + + const { data: employees } = useApiList("/api/employees"); + const { data: assetItems } = useApiList("/api/assets"); + const { data: licenses } = useApiList("/api/licenses"); + + const [employeeId, setEmployeeId] = useState(""); + const [targetKind, setTargetKind] = useState("asset_item"); + const [assetItemId, setAssetItemId] = useState(""); + const [licenseId, setLicenseId] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Per-row error surfaced next to the "Return" button that was clicked, keyed + // by assignment id — kept simple (no modal) per task scope. + const [returnErrors, setReturnErrors] = useState>({}); + const [returningId, setReturningId] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitError(null); + setSubmitting(true); + + try { + const response = await fetch("/api/assignments", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + employeeId, + // Only the one FK relevant to the selected toggle is sent — mirrors + // the server's XOR rule (see createAssignmentSchema's .refine()). + ...(targetKind === "asset_item" ? { assetItemId } : { licenseId }), + }), + }); + + 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"; + setSubmitError(message); + return; + } + + setEmployeeId(""); + setTargetKind("asset_item"); + setAssetItemId(""); + setLicenseId(""); + refetch(); + } catch { + setSubmitError("Request failed"); + } finally { + setSubmitting(false); + } + } + + async function handleReturn(id: string) { + setReturningId(id); + setReturnErrors((previous) => { + const next = { ...previous }; + delete next[id]; + return next; + }); + + try { + // PATCH /api/assignments/[id]'s body is `{returnedAt?: string}` — see + // src/lib/assignments.ts's validateReturnAssignmentInput(). returnedAt + // is optional and defaults to "now" server-side (returnAssignment()), + // so an empty body is sent here rather than guessing a timestamp + // client-side. + const response = await fetch(`/api/assignments/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + + 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"; + setReturnErrors((previous) => ({ ...previous, [id]: message })); + return; + } + + refetch(); + } catch { + setReturnErrors((previous) => ({ ...previous, [id]: "Request failed" })); + } finally { + setReturningId(null); + } + } + + return ( +
+

Assignments

+ +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + + + ID + Employee ID + Asset Item ID + License ID + Assigned At + Returned At + Signed At + Created At + Actions + + + + {assignments.map((assignment) => ( + + {assignment.id} + {assignment.employee_id} + {assignment.asset_item_id ?? "-"} + {assignment.license_id ?? "-"} + {assignment.assigned_at} + {assignment.returned_at ?? "-"} + {assignment.signed_at ?? "-"} + {assignment.created_at} + + {assignment.returned_at === null ? ( +
+ + {returnErrors[assignment.id] ? ( +

+ {returnErrors[assignment.id]} +

+ ) : null} +
+ ) : ( + "-" + )} +
+
+ ))} +
+
+ )} +
+ +
+

New Assignment

+ +
+
+ + +
+ +
+ + +
+ + {targetKind === "asset_item" ? ( +
+ + +
+ ) : ( +
+ + +
+ )} + + {submitError ?

{submitError}

: null} + + +
+
+
+ ); +}