diff --git a/src/app/maintenance-records/page.tsx b/src/app/maintenance-records/page.tsx new file mode 100644 index 0000000..2c79e05 --- /dev/null +++ b/src/app/maintenance-records/page.tsx @@ -0,0 +1,415 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useApiList } from "@/hooks/use-api-list"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +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/maintenance-records — snake_case per this +// repo's API-boundary convention (see src/lib/maintenance-records.ts's +// toRecord()). An "open" record is one where end_date is null. +interface MaintenanceRecordRecord { + id: string; + asset_item_id: string; + type: "repair" | "warranty" | "preventive"; + vendor_id: string | null; + issue_description: string; + resolution_notes: string | null; + start_date: string; + end_date: string | null; + created_at: string; +} + +// Shape returned by GET /api/assets — only the field used to populate the +// assetItemId here (see src/app/vendors/page.tsx's VendorRecord for +// the full shape). +interface VendorRecord { + id: string; + name: string; +} + +// The 4 AssetItemStatus enum values, reused verbatim as the targetStatus +// ). +const ASSET_ITEM_STATUS_VALUES = ["in_stock", "assigned", "repair", "retired"] as const; + +// Radix can't take an empty-string value, so "no vendor" is +// represented by this sentinel in the form state and translated to "omit +// the key" on submit — same pattern as licenses page's +// NO_PURCHASE_ORDER_VALUE / employees page's NO_MANAGER_VALUE. +const NO_VENDOR_VALUE = "none"; + +function todayDateString(): string { + return new Date().toISOString().slice(0, 10); +} + +export default function MaintenanceRecordsPage() { + const { + data: maintenanceRecords, + loading, + error, + refetch, + } = useApiList("/api/maintenance-records"); + + const { data: assetItems } = useApiList("/api/assets"); + const { data: vendors } = useApiList("/api/vendors"); + + const [assetItemId, setAssetItemId] = useState(""); + const [type, setType] = useState(""); + const [vendorId, setVendorId] = useState(NO_VENDOR_VALUE); + const [issueDescription, setIssueDescription] = useState(""); + const [startDate, setStartDate] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + // Per-row close mini-form state, keyed by maintenance record id — kept as + // separate maps (rather than one nested-object map) to keep each field's + // setter a plain string setter, same shape as returnErrors/returningId on + // assignments/page.tsx which this page mirrors. + const [closeEndDate, setCloseEndDate] = useState>({}); + const [closeResolutionNotes, setCloseResolutionNotes] = useState>({}); + const [closeTargetStatus, setCloseTargetStatus] = useState>({}); + const [closeErrors, setCloseErrors] = useState>({}); + const [closingId, setClosingId] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitError(null); + setSubmitting(true); + + try { + const response = await fetch("/api/maintenance-records", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + assetItemId, + type, + issueDescription, + startDate, + // Omit the key entirely when left unselected, rather than sending + // null — matches licenses page's purchaseOrderId pattern. + ...(vendorId !== NO_VENDOR_VALUE ? { vendorId } : {}), + }), + }); + + 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; + } + + setAssetItemId(""); + setType(""); + setVendorId(NO_VENDOR_VALUE); + setIssueDescription(""); + setStartDate(""); + refetch(); + } catch { + setSubmitError("Request failed"); + } finally { + setSubmitting(false); + } + } + + async function handleClose(id: string) { + setClosingId(id); + setCloseErrors((previous) => { + const next = { ...previous }; + delete next[id]; + return next; + }); + + const endDate = closeEndDate[id] ?? todayDateString(); + const resolutionNotes = closeResolutionNotes[id] ?? ""; + const targetStatus = closeTargetStatus[id] ?? ""; + + try { + // PATCH /api/maintenance-records/[id]'s body is + // `{endDate, resolutionNotes?, targetStatus}` — see src/lib/ + // maintenance-records.ts's validateCloseMaintenanceRecordInput(). + // Unlike Assignment's returnedAt, targetStatus is REQUIRED and never + // defaulted server-side, so it's always sent here (an empty string + // if unselected, which the server rejects with a clean 400 message + // surfaced below, same as any other validation failure). + const response = await fetch(`/api/maintenance-records/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + endDate, + targetStatus, + ...(resolutionNotes.trim() !== "" ? { resolutionNotes } : {}), + }), + }); + + 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"; + setCloseErrors((previous) => ({ ...previous, [id]: message })); + return; + } + + setCloseEndDate((previous) => { + const next = { ...previous }; + delete next[id]; + return next; + }); + setCloseResolutionNotes((previous) => { + const next = { ...previous }; + delete next[id]; + return next; + }); + setCloseTargetStatus((previous) => { + const next = { ...previous }; + delete next[id]; + return next; + }); + refetch(); + } catch { + setCloseErrors((previous) => ({ ...previous, [id]: "Request failed" })); + } finally { + setClosingId(null); + } + } + + return ( +
+

Maintenance Records

+ +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + + + ID + Asset Item ID + Type + Vendor ID + Issue Description + Resolution Notes + Start Date + End Date + Created At + Actions + + + + {maintenanceRecords.map((record) => ( + + {record.id} + {record.asset_item_id} + {record.type} + {record.vendor_id ?? "-"} + {record.issue_description} + {record.resolution_notes ?? "-"} + {record.start_date} + {record.end_date ?? "-"} + {record.created_at} + + {record.end_date === null ? ( +
+ + setCloseEndDate((previous) => ({ + ...previous, + [record.id]: event.target.value, + })) + } + /> + + setCloseResolutionNotes((previous) => ({ + ...previous, + [record.id]: event.target.value, + })) + } + /> + + + {closeErrors[record.id] ? ( +

+ {closeErrors[record.id]} +

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

New Maintenance Record

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +