feat: add basic list+create+return UI for Assignment
This commit is contained in:
@@ -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 <Select> here (see src/app/employees/page.tsx's EmployeeRecord
|
||||||
|
// for the full shape).
|
||||||
|
interface EmployeeRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shape returned by GET /api/assets — only the field used to populate the
|
||||||
|
// assetItemId <Select> 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 <Select> here (see src/app/licenses/page.tsx's LicenseRecord for
|
||||||
|
// the full shape).
|
||||||
|
interface LicenseRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The create form's XOR toggle — mirrors the server's own XOR rule in
|
||||||
|
// src/lib/assignments.ts's createAssignmentSchema .refine() (exactly one of
|
||||||
|
// assetItemId/licenseId must be set). This is purely a UI convenience to
|
||||||
|
// decide which single <Select> 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<AssignmentRecord>("/api/assignments");
|
||||||
|
|
||||||
|
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
|
||||||
|
const { data: assetItems } = useApiList<AssetItemRecord>("/api/assets");
|
||||||
|
const { data: licenses } = useApiList<LicenseRecord>("/api/licenses");
|
||||||
|
|
||||||
|
const [employeeId, setEmployeeId] = useState("");
|
||||||
|
const [targetKind, setTargetKind] = useState<TargetKind>("asset_item");
|
||||||
|
const [assetItemId, setAssetItemId] = useState("");
|
||||||
|
const [licenseId, setLicenseId] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(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<Record<string, string>>({});
|
||||||
|
const [returningId, setReturningId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col gap-8 p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Assignments</h1>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>ID</TableHead>
|
||||||
|
<TableHead>Employee ID</TableHead>
|
||||||
|
<TableHead>Asset Item ID</TableHead>
|
||||||
|
<TableHead>License ID</TableHead>
|
||||||
|
<TableHead>Assigned At</TableHead>
|
||||||
|
<TableHead>Returned At</TableHead>
|
||||||
|
<TableHead>Signed At</TableHead>
|
||||||
|
<TableHead>Created At</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{assignments.map((assignment) => (
|
||||||
|
<TableRow key={assignment.id}>
|
||||||
|
<TableCell>{assignment.id}</TableCell>
|
||||||
|
<TableCell>{assignment.employee_id}</TableCell>
|
||||||
|
<TableCell>{assignment.asset_item_id ?? "-"}</TableCell>
|
||||||
|
<TableCell>{assignment.license_id ?? "-"}</TableCell>
|
||||||
|
<TableCell>{assignment.assigned_at}</TableCell>
|
||||||
|
<TableCell>{assignment.returned_at ?? "-"}</TableCell>
|
||||||
|
<TableCell>{assignment.signed_at ?? "-"}</TableCell>
|
||||||
|
<TableCell>{assignment.created_at}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assignment.returned_at === null ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
disabled={returningId === assignment.id}
|
||||||
|
onClick={() => handleReturn(assignment.id)}
|
||||||
|
>
|
||||||
|
{returningId === assignment.id ? "Returning..." : "Return"}
|
||||||
|
</Button>
|
||||||
|
{returnErrors[assignment.id] ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
{returnErrors[assignment.id]}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex max-w-sm flex-col gap-4">
|
||||||
|
<h2 className="text-lg font-semibold">New Assignment</h2>
|
||||||
|
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="employeeId">Employee</Label>
|
||||||
|
<Select value={employeeId} onValueChange={setEmployeeId} required>
|
||||||
|
<SelectTrigger id="employeeId" className="w-full">
|
||||||
|
<SelectValue placeholder="Select an employee" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{employees.map((employee) => (
|
||||||
|
<SelectItem key={employee.id} value={employee.id}>
|
||||||
|
{employee.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="targetKind">Assign</Label>
|
||||||
|
<Select
|
||||||
|
value={targetKind}
|
||||||
|
onValueChange={(value) => setTargetKind(value as TargetKind)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="targetKind" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="asset_item">Asset Item</SelectItem>
|
||||||
|
<SelectItem value="license">License</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{targetKind === "asset_item" ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="assetItemId">Asset Item</Label>
|
||||||
|
<Select value={assetItemId} onValueChange={setAssetItemId} required>
|
||||||
|
<SelectTrigger id="assetItemId" className="w-full">
|
||||||
|
<SelectValue placeholder="Select an asset item" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{assetItems.map((assetItem) => (
|
||||||
|
<SelectItem key={assetItem.id} value={assetItem.id}>
|
||||||
|
{assetItem.serial_number}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="licenseId">License</Label>
|
||||||
|
<Select value={licenseId} onValueChange={setLicenseId} required>
|
||||||
|
<SelectTrigger id="licenseId" className="w-full">
|
||||||
|
<SelectValue placeholder="Select a license" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{licenses.map((license) => (
|
||||||
|
<SelectItem key={license.id} value={license.id}>
|
||||||
|
{license.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Creating..." : "Create Assignment"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user