From 2981ea62023271ea0bc9c574a8e79b77f9b73f08 Mon Sep 17 00:00:00 2001 From: toanhoang Date: Mon, 3 Aug 2026 07:23:25 -0400 Subject: [PATCH] feat: add basic list+create UI for AssetItem, License, Request --- src/app/assets/page.tsx | 254 +++++++++++++++++++++++++++++++++++ src/app/licenses/page.tsx | 270 ++++++++++++++++++++++++++++++++++++++ src/app/requests/page.tsx | 251 +++++++++++++++++++++++++++++++++++ 3 files changed, 775 insertions(+) create mode 100644 src/app/assets/page.tsx create mode 100644 src/app/licenses/page.tsx create mode 100644 src/app/requests/page.tsx diff --git a/src/app/assets/page.tsx b/src/app/assets/page.tsx new file mode 100644 index 0000000..7fbae61 --- /dev/null +++ b/src/app/assets/page.tsx @@ -0,0 +1,254 @@ +"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 { + 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/assets — snake_case per this repo's +// API-boundary convention (see src/lib/assets.ts's toRecord()). +interface AssetItemRecord { + id: string; + serial_number: string; + model_id: string; + purchase_date: string; + warranty_end_date: string | null; + status: "in_stock" | "assigned" | "repair" | "retired"; + purchase_order_id: string | null; + created_at: string; +} + +// Shape returned by GET /api/asset-models — only the fields used to populate +// the modelId here. PurchaseOrder has no obviously +// human-readable name field (see src/app/purchase-orders/page.tsx's +// PurchaseOrderRecord for the full shape), so the id itself is shown as the +// label. +interface PurchaseOrderRecord { + id: string; +} + +// Radix can't take an empty-string value, so "no purchase +// order" is represented by this sentinel in the form state and translated to +// "omit the key" on submit — same pattern as purchase-orders page's +// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE. +const NO_PURCHASE_ORDER_VALUE = "none"; + +export default function AssetsPage() { + const { + data: assetItems, + loading, + error, + refetch, + } = useApiList("/api/assets"); + + const { data: assetModels } = useApiList("/api/asset-models"); + const { data: purchaseOrders } = useApiList("/api/purchase-orders"); + + const [serialNumber, setSerialNumber] = useState(""); + const [modelId, setModelId] = useState(""); + const [purchaseDate, setPurchaseDate] = useState(""); + const [warrantyEndDate, setWarrantyEndDate] = useState(""); + const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setSubmitError(null); + + const hasWarrantyEndDate = warrantyEndDate.trim() !== ""; + const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE; + + try { + const response = await fetch("/api/assets", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + serialNumber, + modelId, + purchaseDate, + // Omit optional keys entirely when left blank, rather than + // sending null — matches the create-form pattern used on other + // pages (e.g. purchase-orders page's receivedAt/receivedById). + ...(hasWarrantyEndDate ? { warrantyEndDate } : {}), + ...(hasPurchaseOrderId ? { purchaseOrderId } : {}), + }), + }); + + 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; + } + + setSerialNumber(""); + setModelId(""); + setPurchaseDate(""); + setWarrantyEndDate(""); + setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE); + refetch(); + } catch { + setSubmitError("Request failed"); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Assets

+ +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + + + ID + Serial Number + Model ID + Purchase Date + Warranty End Date + Status + Purchase Order ID + Created At + + + + {assetItems.map((assetItem) => ( + + {assetItem.id} + {assetItem.serial_number} + {assetItem.model_id} + {assetItem.purchase_date} + {assetItem.warranty_end_date ?? "-"} + {assetItem.status} + {assetItem.purchase_order_id ?? "-"} + {assetItem.created_at} + + ))} + +
+ )} +
+ +
+

New Asset

+ +
+
+ + setSerialNumber(event.target.value)} + required + /> +
+ +
+ + +
+ +
+ + setPurchaseDate(event.target.value)} + required + /> +
+ +
+ + setWarrantyEndDate(event.target.value)} + /> +
+ +
+ + +
+ + {submitError ?

{submitError}

: null} + + +
+
+
+ ); +} diff --git a/src/app/licenses/page.tsx b/src/app/licenses/page.tsx new file mode 100644 index 0000000..8258282 --- /dev/null +++ b/src/app/licenses/page.tsx @@ -0,0 +1,270 @@ +"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 { + 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/licenses — snake_case per this repo's +// API-boundary convention (see src/lib/licenses.ts's toRecord()). +interface LicenseRecord { + id: string; + name: string; + vendor_id: string; + seats_total: number; + seats_used: number; + expiry_date: string | null; + license_type: "perpetual" | "subscription"; + purchase_order_id: string | null; + created_at: string; +} + +// Shape returned by GET /api/vendors — only the fields used to populate the +// vendorId (id as label — a PurchaseOrder has +// no human-friendly name field, see src/app/purchase-orders/page.tsx). +interface PurchaseOrderRecord { + id: string; +} + +// Radix can't take an empty-string value, so "no purchase +// order" is represented by this sentinel in the form state and translated +// to "omit the key" on submit — same pattern as purchase-orders page's +// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE. +const NO_PURCHASE_ORDER_VALUE = "none"; + +export default function LicensesPage() { + const { + data: licenses, + loading, + error, + refetch, + } = useApiList("/api/licenses"); + const { data: vendors } = useApiList("/api/vendors"); + const { data: purchaseOrders } = useApiList( + "/api/purchase-orders" + ); + + const [name, setName] = useState(""); + const [vendorId, setVendorId] = useState(""); + const [seatsTotal, setSeatsTotal] = useState(""); + const [expiryDate, setExpiryDate] = useState(""); + const [licenseType, setLicenseType] = useState(""); + const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitError(null); + setSubmitting(true); + + try { + const response = await fetch("/api/licenses", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name, + vendorId, + seatsTotal: Number(seatsTotal), + licenseType, + // Omit optional keys entirely when left blank, rather than + // sending null — matches the create-form pattern used on other + // pages (e.g. purchase-orders page's receivedAt/receivedById). + ...(expiryDate.trim() !== "" ? { expiryDate } : {}), + ...(purchaseOrderId !== NO_PURCHASE_ORDER_VALUE ? { purchaseOrderId } : {}), + }), + }); + + 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; + } + + setName(""); + setVendorId(""); + setSeatsTotal(""); + setExpiryDate(""); + setLicenseType(""); + setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE); + refetch(); + } catch { + setSubmitError("Request failed"); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Licenses

+ +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + + + ID + Name + Vendor ID + Seats Total + Seats Used + Expiry Date + License Type + Purchase Order ID + Created At + + + + {licenses.map((license) => ( + + {license.id} + {license.name} + {license.vendor_id} + {license.seats_total} + {license.seats_used} + {license.expiry_date ?? "-"} + {license.license_type} + {license.purchase_order_id ?? "-"} + {license.created_at} + + ))} + +
+ )} +
+ +
+

New License

+ +
+
+ + setName(event.target.value)} + required + /> +
+ +
+ + +
+ +
+ + setSeatsTotal(event.target.value)} + required + /> +
+ +
+ + setExpiryDate(event.target.value)} + /> +
+ +
+ + +
+ +
+ + +
+ + {submitError ?

{submitError}

: null} + + +
+
+
+ ); +} diff --git a/src/app/requests/page.tsx b/src/app/requests/page.tsx new file mode 100644 index 0000000..86e0a59 --- /dev/null +++ b/src/app/requests/page.tsx @@ -0,0 +1,251 @@ +"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 { 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/requests — snake_case per this repo's +// API-boundary convention (see src/lib/requests.ts's toRecord()). `status` +// is always "Draft" right now (no state-machine transitions built yet), see +// src/lib/requests.ts's RequestStatus comment. +interface RequestRecord { + id: string; + employee_id: string; + type: "asset" | "license"; + reason: string; + status: string; + purchase_order_id: string | null; + created_at: string; +} + +// Shape returned by GET /api/employees — only the fields used to populate +// the employeeId label since PurchaseOrder has no human-friendly name field (see +// src/app/purchase-orders/page.tsx's PurchaseOrderRecord for the full +// shape). +interface PurchaseOrderRecord { + id: string; +} + +// The exact enum literal values from RequestType (src/lib/requests.ts) — +// don't invent labels not accepted by the Zod schema. +const REQUEST_TYPES = ["asset", "license"] as const; + +// Radix can't take an empty-string value, so "no purchase +// order" is represented by this sentinel in the form state and translated +// to "omit the key" on submit — same pattern as employees page's +// NO_MANAGER_VALUE / purchase-orders page's NOT_RECEIVED_VALUE. +const NO_PURCHASE_ORDER_VALUE = "none"; + +export default function RequestsPage() { + const { + data: requests, + loading, + error, + refetch, + } = useApiList("/api/requests"); + const { data: employees } = useApiList("/api/employees"); + const { data: purchaseOrders } = useApiList( + "/api/purchase-orders" + ); + + const [employeeId, setEmployeeId] = useState(""); + const [type, setType] = useState<(typeof REQUEST_TYPES)[number] | "">(""); + const [reason, setReason] = useState(""); + const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setSubmitError(null); + + const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE; + + try { + const response = await fetch("/api/requests", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + employeeId, + type, + reason, + // Omit the key entirely when left unset, rather than sending + // null — matches the create-form pattern used on other pages + // (e.g. employees page's managerId). + ...(hasPurchaseOrderId ? { purchaseOrderId } : {}), + }), + }); + + 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(""); + setType(""); + setReason(""); + setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE); + refetch(); + } catch { + setSubmitError("Request failed"); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Requests

+ +
+ {loading ? ( +

Loading...

+ ) : error ? ( +

{error}

+ ) : ( + + + + ID + Employee ID + Type + Reason + Status + Purchase Order ID + Created At + + + + {requests.map((request) => ( + + {request.id} + {request.employee_id} + {request.type} + {request.reason} + {request.status} + {request.purchase_order_id ?? "-"} + {request.created_at} + + ))} + +
+ )} +
+ +
+

New Request

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