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