6 Commits
Author SHA1 Message Date
toanhoang a9dafcc140 fix: add atomic Assignment transfer (close old + create new in one transaction)
The only way to move an Assignment to a new employee was two separate calls
(PATCH return + POST create), which is not atomic and left a race window
where a third party could grab the now-in_stock AssetItem in between, plus
no rollback if the second call failed. Adds transferAssignment() which
closes the old Assignment and creates the new one inside a single
prisma.$transaction(), per docs/asset-license-management.md's "Assignment
(chốt 2026-08-02)" spec. Does not touch AssetItem.status or
License.seatsUsed — the resource never becomes free mid-transfer. Wired
into PATCH /api/assignments/[id]: an employeeId key in the body dispatches
to transfer, a returnedAt key to return, both together is rejected as
mutually exclusive.
2026-08-03 19:46:43 -04:00
toanhoang e951a14cc5 feat: add basic list+create+return UI for Assignment 2026-08-03 07:31:32 -04:00
toanhoang 2981ea6202 feat: add basic list+create UI for AssetItem, License, Request 2026-08-03 07:23:25 -04:00
toanhoang e471ae9c90 feat: add basic list+create UI for AssetCategoryAttributeDef, AssetModel, PurchaseOrder 2026-08-03 07:09:42 -04:00
toanhoang 4bcd4da11b feat: add basic list+create UI for Employee, Vendor, AssetCategory 2026-08-03 06:54:41 -04:00
toanhoang 0576d6e61c feat: add shared UI nav + fetch hook + table/select/label/textarea primitives
Basic infra for a functional (no-polish) UI pass over existing entity APIs.
2026-08-03 06:36:16 -04:00
20 changed files with 3268 additions and 7 deletions
+34 -2
View File
@@ -1,7 +1,12 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { returnAssignment, validateReturnAssignmentInput } from "@/lib/assignments"; import {
returnAssignment,
transferAssignment,
validateReturnAssignmentInput,
validateTransferAssignmentInput,
} from "@/lib/assignments";
// First dynamic-segment ([id]) route in the codebase — see // First dynamic-segment ([id]) route in the codebase — see
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section, // docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section,
@@ -39,7 +44,34 @@ export async function PATCH(
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 }); return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
} }
const { returnedAt } = body as Record<string, unknown>; const { returnedAt, employeeId } = body as Record<string, unknown>;
// Dispatch on body shape — a body carrying employeeId is a transfer
// request, everything else is the existing return behavior. The two are
// mutually exclusive: don't guess intent if a caller sends both (see
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)"
// section, bullet right after the transfer rule, for why this stays a
// single PATCH route rather than an action-based sub-route).
if (employeeId !== undefined && returnedAt !== undefined) {
return NextResponse.json(
{ error: "Provide either employeeId (transfer) or returnedAt (return), not both" },
{ status: 400 }
);
}
if (employeeId !== undefined) {
const transferValidation = validateTransferAssignmentInput({ employeeId });
if (!transferValidation.ok) {
return NextResponse.json({ error: transferValidation.error }, { status: 400 });
}
const transferred = await transferAssignment(params.id, transferValidation.value);
if (!transferred.ok) {
return NextResponse.json({ error: transferred.error }, { status: transferred.status });
}
return NextResponse.json(transferred.value, { status: 200 });
}
const validation = validateReturnAssignmentInput({ returnedAt }); const validation = validateReturnAssignmentInput({ returnedAt });
if (!validation.ok) { if (!validation.ok) {
+168
View File
@@ -0,0 +1,168 @@
"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/asset-categories — snake_case per this repo's
// API-boundary convention (see src/lib/asset-categories.ts's toRecord()).
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
const VALUE_GROUP_OPTIONS = ["high_value", "low_value"] as const;
export default function AssetCategoriesPage() {
const {
data: assetCategories,
loading,
error,
refetch,
} = useApiList<AssetCategoryRecord>("/api/asset-categories");
const [name, setName] = useState("");
const [valueGroup, setValueGroup] = useState<(typeof VALUE_GROUP_OPTIONS)[number]>(
"high_value"
);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/asset-categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, valueGroup }),
});
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("");
setValueGroup("high_value");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Categories</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>Name</TableHead>
<TableHead>Value Group</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetCategories.map((assetCategory) => (
<TableRow key={assetCategory.id}>
<TableCell>{assetCategory.id}</TableCell>
<TableCell>{assetCategory.name}</TableCell>
<TableCell>{assetCategory.value_group}</TableCell>
<TableCell>{assetCategory.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Category</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="valueGroup">Value Group</Label>
<Select
value={valueGroup}
onValueChange={(value) =>
setValueGroup(value as (typeof VALUE_GROUP_OPTIONS)[number])
}
>
<SelectTrigger id="valueGroup" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{VALUE_GROUP_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset Category"}
</Button>
</form>
</section>
</div>
);
}
@@ -0,0 +1,256 @@
"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/asset-category-attribute-defs — snake_case per
// this repo's API-boundary convention (see
// src/lib/asset-category-attribute-defs.ts's toRecord()).
interface AssetCategoryAttributeDefRecord {
id: string;
category_id: string;
key: string;
label: string;
data_type: "string" | "number" | "boolean" | "date";
required: boolean;
sort_order: number;
created_at: string;
}
// Shape returned by GET /api/asset-categories — snake_case per
// src/lib/asset-categories.ts's toRecord(). Only used here to populate the
// categoryId <Select> options.
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
// Fixed 4-value set — mirrors src/lib/asset-category-attribute-defs.ts's
// dataType z.enum() literal values exactly.
const DATA_TYPE_OPTIONS = ["string", "number", "boolean", "date"] as const;
export default function AssetCategoryAttributeDefsPage() {
const {
data: attributeDefs,
loading,
error,
refetch,
} = useApiList<AssetCategoryAttributeDefRecord>("/api/asset-category-attribute-defs");
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
const [categoryId, setCategoryId] = useState("");
const [key, setKey] = useState("");
const [label, setLabel] = useState("");
const [dataType, setDataType] = useState<(typeof DATA_TYPE_OPTIONS)[number]>("string");
const [required, setRequired] = useState(false);
const [sortOrder, setSortOrder] = useState("0");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/asset-category-attribute-defs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
categoryId,
key,
label,
dataType,
required,
sortOrder: Number(sortOrder),
}),
});
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;
}
setCategoryId("");
setKey("");
setLabel("");
setDataType("string");
setRequired(false);
setSortOrder("0");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Category Attribute Defs</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>Category ID</TableHead>
<TableHead>Key</TableHead>
<TableHead>Label</TableHead>
<TableHead>Data Type</TableHead>
<TableHead>Required</TableHead>
<TableHead>Sort Order</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{attributeDefs.map((attributeDef) => (
<TableRow key={attributeDef.id}>
<TableCell>{attributeDef.id}</TableCell>
<TableCell>{attributeDef.category_id}</TableCell>
<TableCell>{attributeDef.key}</TableCell>
<TableCell>{attributeDef.label}</TableCell>
<TableCell>{attributeDef.data_type}</TableCell>
<TableCell>{attributeDef.required ? "Yes" : "No"}</TableCell>
<TableCell>{attributeDef.sort_order}</TableCell>
<TableCell>{attributeDef.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Category Attribute Def</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key">Key</Label>
<Input
id="key"
value={key}
onChange={(event) => setKey(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="label">Label</Label>
<Input
id="label"
value={label}
onChange={(event) => setLabel(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="dataType">Data Type</Label>
<Select
value={dataType}
onValueChange={(value) =>
setDataType(value as (typeof DATA_TYPE_OPTIONS)[number])
}
>
<SelectTrigger id="dataType" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATA_TYPE_OPTIONS.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<input
id="required"
type="checkbox"
checked={required}
onChange={(event) => setRequired(event.target.checked)}
className="h-4 w-4"
/>
<Label htmlFor="required">Required</Label>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="sortOrder">Sort Order</Label>
<Input
id="sortOrder"
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
required
/>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Attribute Def"}
</Button>
</form>
</section>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
"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/asset-models — snake_case per this repo's
// API-boundary convention (see src/lib/asset-models.ts's toRecord()).
interface AssetModelRecord {
id: string;
category_id: string;
manufacturer: string;
name: string;
specs: Record<string, unknown>;
created_at: string;
}
// Shape returned by GET /api/asset-categories — used only to populate the
// categoryId <Select> here, not rendered as its own table. Deliberately NOT
// filtered to valueGroup=high_value client-side: the server
// (createAssetModel() in src/lib/asset-models.ts) enforces that rule and
// returns a clear {error} message if violated, which this page surfaces
// verbatim via submitError instead of duplicating the rule client-side.
interface AssetCategoryRecord {
id: string;
name: string;
value_group: "high_value" | "low_value";
created_at: string;
}
const DEFAULT_SPECS_TEXT = "{}";
export default function AssetModelsPage() {
const {
data: assetModels,
loading,
error,
refetch,
} = useApiList<AssetModelRecord>("/api/asset-models");
const { data: assetCategories } = useApiList<AssetCategoryRecord>(
"/api/asset-categories"
);
const [categoryId, setCategoryId] = useState("");
const [manufacturer, setManufacturer] = useState("");
const [name, setName] = useState("");
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
// specs is parsed client-side first — if it's not valid JSON, fail
// locally without calling the API at all (per task scope).
let specs: unknown;
try {
specs = JSON.parse(specsText);
} catch {
setSubmitError("Invalid JSON");
setSubmitting(false);
return;
}
try {
const response = await fetch("/api/asset-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ categoryId, manufacturer, name, specs }),
});
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;
}
setCategoryId("");
setManufacturer("");
setName("");
setSpecsText(DEFAULT_SPECS_TEXT);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Asset Models</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>Category ID</TableHead>
<TableHead>Manufacturer</TableHead>
<TableHead>Name</TableHead>
<TableHead>Specs</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetModels.map((assetModel) => (
<TableRow key={assetModel.id}>
<TableCell>{assetModel.id}</TableCell>
<TableCell>{assetModel.category_id}</TableCell>
<TableCell>{assetModel.manufacturer}</TableCell>
<TableCell>{assetModel.name}</TableCell>
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
<TableCell>{assetModel.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset Model</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="categoryId">Category</Label>
<Select value={categoryId} onValueChange={setCategoryId}>
<SelectTrigger id="categoryId" className="w-full">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{assetCategories.map((assetCategory) => (
<SelectItem key={assetCategory.id} value={assetCategory.id}>
{assetCategory.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="manufacturer">Manufacturer</Label>
<Input
id="manufacturer"
value={manufacturer}
onChange={(event) => setManufacturer(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="specs">Specs (JSON)</Label>
<Textarea
id="specs"
value={specsText}
onChange={(event) => setSpecsText(event.target.value)}
rows={6}
/>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset Model"}
</Button>
</form>
</section>
</div>
);
}
+254
View File
@@ -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 <Select> here (see src/app/asset-models/page.tsx's
// AssetModelRecord for the full shape).
interface AssetModelRecord {
id: string;
manufacturer: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the id, used to populate
// the purchaseOrderId <Select> 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 <Select.Item> 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<AssetItemRecord>("/api/assets");
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/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<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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 (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Assets</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>Serial Number</TableHead>
<TableHead>Model ID</TableHead>
<TableHead>Purchase Date</TableHead>
<TableHead>Warranty End Date</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assetItems.map((assetItem) => (
<TableRow key={assetItem.id}>
<TableCell>{assetItem.id}</TableCell>
<TableCell>{assetItem.serial_number}</TableCell>
<TableCell>{assetItem.model_id}</TableCell>
<TableCell>{assetItem.purchase_date}</TableCell>
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
<TableCell>{assetItem.status}</TableCell>
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
<TableCell>{assetItem.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="serialNumber">Serial Number</Label>
<Input
id="serialNumber"
value={serialNumber}
onChange={(event) => setSerialNumber(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="modelId">Model</Label>
<Select value={modelId} onValueChange={setModelId} required>
<SelectTrigger id="modelId" className="w-full">
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{assetModels.map((assetModel) => (
<SelectItem key={assetModel.id} value={assetModel.id}>
{assetModel.manufacturer} {assetModel.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseDate">Purchase Date</Label>
<Input
id="purchaseDate"
type="date"
value={purchaseDate}
onChange={(event) => setPurchaseDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="warrantyEndDate">Warranty End Date (optional)</Label>
<Input
id="warrantyEndDate"
type="date"
value={warrantyEndDate}
onChange={(event) => setWarrantyEndDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Asset"}
</Button>
</form>
</section>
</div>
);
}
+329
View File
@@ -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>
);
}
+183
View File
@@ -0,0 +1,183 @@
"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/employees — snake_case per this repo's
// API-boundary convention (see src/lib/employees.ts's toRecord()).
interface EmployeeRecord {
id: string;
name: string;
department: string;
manager_id: string | null;
created_at: string;
}
// Radix <Select.Item> can't take an empty-string value, so "no manager" is
// represented by this sentinel in the form state and translated to
// `undefined` (omitted from the request body) on submit.
const NO_MANAGER_VALUE = "none";
export default function EmployeesPage() {
const { data: employees, loading, error, refetch } = useApiList<EmployeeRecord>(
"/api/employees"
);
const [name, setName] = useState("");
const [department, setDepartment] = useState("");
const [managerId, setManagerId] = useState(NO_MANAGER_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/employees", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
department,
...(managerId !== NO_MANAGER_VALUE ? { managerId } : {}),
}),
});
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("");
setDepartment("");
setManagerId(NO_MANAGER_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Employees</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>Name</TableHead>
<TableHead>Department</TableHead>
<TableHead>Manager ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{employees.map((employee) => (
<TableRow key={employee.id}>
<TableCell>{employee.id}</TableCell>
<TableCell>{employee.name}</TableCell>
<TableCell>{employee.department}</TableCell>
<TableCell>{employee.manager_id ?? "-"}</TableCell>
<TableCell>{employee.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Employee</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="department">Department</Label>
<Input
id="department"
value={department}
onChange={(event) => setDepartment(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="managerId">Manager</Label>
<Select value={managerId} onValueChange={setManagerId}>
<SelectTrigger id="managerId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_MANAGER_VALUE}>None</SelectItem>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Employee"}
</Button>
</form>
</section>
</div>
);
}
+14 -2
View File
@@ -1,15 +1,27 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import "./globals.css"; import "./globals.css";
import { auth } from "@/auth";
import { Nav } from "@/components/nav";
export const metadata = { export const metadata = {
title: "Asset & License Management", title: "Asset & License Management",
description: "Internal IT asset & license management — Ubisoft VN local IT", description: "Internal IT asset & license management — Ubisoft VN local IT",
}; };
export default function RootLayout({ children }: { children: ReactNode }) { // Root layout wraps every route, including /login (middleware.ts can't
// exclude it — it's the one page unauthenticated users must be able to
// reach). Gate <Nav/> on session presence here rather than rendering it
// unconditionally, so it only shows on authenticated pages as intended,
// without needing a client-side pathname check.
export default async function RootLayout({ children }: { children: ReactNode }) {
const session = await auth();
return ( return (
<html lang="en"> <html lang="en">
<body>{children}</body> <body>
{session ? <Nav /> : null}
{children}
</body>
</html> </html>
); );
} }
+270
View File
@@ -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 <Select> (see src/app/vendors/page.tsx's VendorRecord for the
// full shape).
interface VendorRecord {
id: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the field used to
// populate the purchaseOrderId <Select> (id as label — a PurchaseOrder has
// no human-friendly name field, see src/app/purchase-orders/page.tsx).
interface PurchaseOrderRecord {
id: string;
}
// Radix <Select.Item> 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<LicenseRecord>("/api/licenses");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>(
"/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<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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 (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Licenses</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>Name</TableHead>
<TableHead>Vendor ID</TableHead>
<TableHead>Seats Total</TableHead>
<TableHead>Seats Used</TableHead>
<TableHead>Expiry Date</TableHead>
<TableHead>License Type</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{licenses.map((license) => (
<TableRow key={license.id}>
<TableCell>{license.id}</TableCell>
<TableCell>{license.name}</TableCell>
<TableCell>{license.vendor_id}</TableCell>
<TableCell>{license.seats_total}</TableCell>
<TableCell>{license.seats_used}</TableCell>
<TableCell>{license.expiry_date ?? "-"}</TableCell>
<TableCell>{license.license_type}</TableCell>
<TableCell>{license.purchase_order_id ?? "-"}</TableCell>
<TableCell>{license.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New License</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId} required>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="seatsTotal">Seats Total</Label>
<Input
id="seatsTotal"
type="number"
min={1}
step={1}
value={seatsTotal}
onChange={(event) => setSeatsTotal(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="expiryDate">Expiry Date (optional)</Label>
<Input
id="expiryDate"
type="date"
value={expiryDate}
onChange={(event) => setExpiryDate(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="licenseType">License Type</Label>
<Select value={licenseType} onValueChange={setLicenseType} required>
<SelectTrigger id="licenseType" className="w-full">
<SelectValue placeholder="Select a license type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="perpetual">perpetual</SelectItem>
<SelectItem value="subscription">subscription</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create License"}
</Button>
</form>
</section>
</div>
);
}
+258
View File
@@ -0,0 +1,258 @@
"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/purchase-orders — snake_case per this repo's
// API-boundary convention (see src/lib/purchase-orders.ts's toRecord()).
interface PurchaseOrderRecord {
id: string;
vendor_id: string;
order_date: string;
expected_date: string;
received_at: string | null;
received_by: string | null;
created_at: string;
}
// Shape returned by GET /api/vendors — only the fields used to populate the
// vendorId <Select> (see src/app/vendors/page.tsx's VendorRecord for the
// full shape).
interface VendorRecord {
id: string;
name: string;
}
// Shape returned by GET /api/employees — only the fields used to populate
// the receivedById <Select> (see src/app/employees/page.tsx's
// EmployeeRecord for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Radix <Select.Item> can't take an empty-string value, so "not received
// yet" 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.
const NOT_RECEIVED_VALUE = "none";
export default function PurchaseOrdersPage() {
const {
data: purchaseOrders,
loading,
error,
refetch,
} = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
const { data: vendors } = useApiList<VendorRecord>("/api/vendors");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const [vendorId, setVendorId] = useState("");
const [orderDate, setOrderDate] = useState("");
const [expectedDate, setExpectedDate] = useState("");
const [receivedAt, setReceivedAt] = useState("");
const [receivedById, setReceivedById] = useState(NOT_RECEIVED_VALUE);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitError(null);
// Mirror the server's .refine(): receivedAt/receivedById must both be
// empty or both be filled (src/lib/purchase-orders.ts's
// createPurchaseOrderSchema). Server is still the source of truth if
// this check somehow disagrees with it.
const hasReceivedAt = receivedAt.trim() !== "";
const hasReceivedById = receivedById !== NOT_RECEIVED_VALUE;
if (hasReceivedAt !== hasReceivedById) {
setSubmitError("receivedAt and receivedById must both be set or both be unset");
return;
}
setSubmitting(true);
try {
const response = await fetch("/api/purchase-orders", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
vendorId,
orderDate,
expectedDate,
// Omit both keys entirely when left empty, rather than sending
// null — matches the create-form pattern used on other pages
// (e.g. employees page's managerId).
...(hasReceivedAt ? { receivedAt } : {}),
...(hasReceivedById ? { receivedById } : {}),
}),
});
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;
}
setVendorId("");
setOrderDate("");
setExpectedDate("");
setReceivedAt("");
setReceivedById(NOT_RECEIVED_VALUE);
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Purchase Orders</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>Vendor ID</TableHead>
<TableHead>Order Date</TableHead>
<TableHead>Expected Date</TableHead>
<TableHead>Received At</TableHead>
<TableHead>Received By</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{purchaseOrders.map((purchaseOrder) => (
<TableRow key={purchaseOrder.id}>
<TableCell>{purchaseOrder.id}</TableCell>
<TableCell>{purchaseOrder.vendor_id}</TableCell>
<TableCell>{purchaseOrder.order_date}</TableCell>
<TableCell>{purchaseOrder.expected_date}</TableCell>
<TableCell>{purchaseOrder.received_at ?? "-"}</TableCell>
<TableCell>{purchaseOrder.received_by ?? "-"}</TableCell>
<TableCell>{purchaseOrder.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Purchase Order</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="vendorId">Vendor</Label>
<Select value={vendorId} onValueChange={setVendorId} required>
<SelectTrigger id="vendorId" className="w-full">
<SelectValue placeholder="Select a vendor" />
</SelectTrigger>
<SelectContent>
{vendors.map((vendor) => (
<SelectItem key={vendor.id} value={vendor.id}>
{vendor.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="orderDate">Order Date</Label>
<Input
id="orderDate"
type="date"
value={orderDate}
onChange={(event) => setOrderDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="expectedDate">Expected Date</Label>
<Input
id="expectedDate"
type="date"
value={expectedDate}
onChange={(event) => setExpectedDate(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="receivedAt">Received At (optional)</Label>
<Input
id="receivedAt"
type="date"
value={receivedAt}
onChange={(event) => setReceivedAt(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="receivedById">Received By (optional)</Label>
<Select value={receivedById} onValueChange={setReceivedById}>
<SelectTrigger id="receivedById" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NOT_RECEIVED_VALUE}>Not received</SelectItem>
{employees.map((employee) => (
<SelectItem key={employee.id} value={employee.id}>
{employee.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Purchase Order"}
</Button>
</form>
</section>
</div>
);
}
+251
View File
@@ -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 <Select> (see src/app/employees/page.tsx's EmployeeRecord
// for the full shape).
interface EmployeeRecord {
id: string;
name: string;
}
// Shape returned by GET /api/purchase-orders — only the id, shown as the
// <Select> 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 <Select.Item> 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<RequestRecord>("/api/requests");
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>(
"/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<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
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 (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Requests</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>Type</TableHead>
<TableHead>Reason</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Order ID</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{requests.map((request) => (
<TableRow key={request.id}>
<TableCell>{request.id}</TableCell>
<TableCell>{request.employee_id}</TableCell>
<TableCell>{request.type}</TableCell>
<TableCell>{request.reason}</TableCell>
<TableCell>{request.status}</TableCell>
<TableCell>{request.purchase_order_id ?? "-"}</TableCell>
<TableCell>{request.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Request</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="type">Type</Label>
<Select
value={type}
onValueChange={(value) => setType(value as (typeof REQUEST_TYPES)[number])}
required
>
<SelectTrigger id="type" className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{REQUEST_TYPES.map((requestType) => (
<SelectItem key={requestType} value={requestType}>
{requestType}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="reason">Reason</Label>
<Textarea
id="reason"
value={reason}
onChange={(event) => setReason(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="purchaseOrderId">Purchase Order (optional)</Label>
<Select value={purchaseOrderId} onValueChange={setPurchaseOrderId}>
<SelectTrigger id="purchaseOrderId" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={NO_PURCHASE_ORDER_VALUE}>None</SelectItem>
{purchaseOrders.map((purchaseOrder) => (
<SelectItem key={purchaseOrder.id} value={purchaseOrder.id}>
{purchaseOrder.id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Request"}
</Button>
</form>
</section>
</div>
);
}
+216
View File
@@ -0,0 +1,216 @@
"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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
// Shape returned by GET /api/vendors — snake_case per this repo's
// API-boundary convention (see src/lib/vendors.ts's toRecord()).
interface VendorRecord {
id: string;
name: string;
contact_email: string;
contact_phone: string;
address: string;
tax_code: string | null;
notes: string | null;
created_at: string;
}
export default function VendorsPage() {
const { data: vendors, loading, error, refetch } = useApiList<VendorRecord>(
"/api/vendors"
);
const [name, setName] = useState("");
const [contactEmail, setContactEmail] = useState("");
const [contactPhone, setContactPhone] = useState("");
const [address, setAddress] = useState("");
const [taxCode, setTaxCode] = useState("");
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const response = await fetch("/api/vendors", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name,
contactEmail,
contactPhone,
address,
// taxCode/notes are optional+nullable and must be non-empty
// strings if present (src/lib/vendors.ts's createVendorSchema) —
// omit the key entirely when the field is left blank, same
// pattern as employees page's managerId.
...(taxCode.trim() !== "" ? { taxCode } : {}),
...(notes.trim() !== "" ? { notes } : {}),
}),
});
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("");
setContactEmail("");
setContactPhone("");
setAddress("");
setTaxCode("");
setNotes("");
refetch();
} catch {
setSubmitError("Request failed");
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-8 p-6">
<h1 className="text-xl font-semibold">Vendors</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>Name</TableHead>
<TableHead>Contact Email</TableHead>
<TableHead>Contact Phone</TableHead>
<TableHead>Address</TableHead>
<TableHead>Tax Code</TableHead>
<TableHead>Notes</TableHead>
<TableHead>Created At</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vendors.map((vendor) => (
<TableRow key={vendor.id}>
<TableCell>{vendor.id}</TableCell>
<TableCell>{vendor.name}</TableCell>
<TableCell>{vendor.contact_email}</TableCell>
<TableCell>{vendor.contact_phone}</TableCell>
<TableCell>{vendor.address}</TableCell>
<TableCell>{vendor.tax_code ?? "-"}</TableCell>
<TableCell>{vendor.notes ?? "-"}</TableCell>
<TableCell>{vendor.created_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Vendor</h2>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="contactEmail">Contact Email</Label>
<Input
id="contactEmail"
type="email"
value={contactEmail}
onChange={(event) => setContactEmail(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="contactPhone">Contact Phone</Label>
<Input
id="contactPhone"
value={contactPhone}
onChange={(event) => setContactPhone(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="address">Address</Label>
<Input
id="address"
value={address}
onChange={(event) => setAddress(event.target.value)}
required
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="taxCode">Tax Code</Label>
<Input
id="taxCode"
value={taxCode}
onChange={(event) => setTaxCode(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
</div>
{submitError ? (
<p className="text-sm text-destructive">{submitError}</p>
) : null}
<Button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Vendor"}
</Button>
</form>
</section>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
// Shared top nav for every authenticated page. Links point at the entity
// routes each later task in this "basic functional UI" batch will build
// (Employee, Vendor, AssetCategory, AssetCategoryAttributeDef, AssetModel,
// PurchaseOrder, AssetItem, License, Request, Assignment) — most of these
// routes don't exist yet and will 404 until their own task lands, which is
// expected (see task scope: no conditional hiding needed).
const ENTITY_LINKS = [
{ href: "/employees", label: "Employees" },
{ href: "/vendors", label: "Vendors" },
{ href: "/asset-categories", label: "Asset Categories" },
{ href: "/asset-category-attribute-defs", label: "Attribute Defs" },
{ href: "/asset-models", label: "Asset Models" },
{ href: "/assets", label: "Assets" },
{ href: "/licenses", label: "Licenses" },
{ href: "/purchase-orders", label: "Purchase Orders" },
{ href: "/requests", label: "Requests" },
{ href: "/assignments", label: "Assignments" },
] as const;
export function Nav() {
return (
<nav className="flex flex-wrap items-center gap-4 border-b border-border px-4 py-3">
<Link href="/" className="font-semibold whitespace-nowrap">
Asset & License Mgmt
</Link>
<div className="flex flex-1 flex-wrap gap-3 text-sm">
{ENTITY_LINKS.map((link) => (
<Link
key={link.href}
href={link.href}
className="text-muted-foreground hover:text-foreground"
>
{link.label}
</Link>
))}
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => signOut({ callbackUrl: "/login" })}
>
Sign out
</Button>
</nav>
);
}
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+190
View File
@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+91
View File
@@ -0,0 +1,91 @@
"use client";
import { useCallback, useEffect, useState } from "react";
// Small shared data-fetching hook used by every entity page — both for its
// own list table (GET /api/<entity>) and for populating FK <Select>
// dropdowns from other entities' GET endpoints (e.g. an AssetItem page
// fetching /api/employees to build an "assigned to" picker). Deliberately
// minimal: no caching/dedupe/revalidation library, just fetch + state,
// matching the "no polish, just clickable manual testing" scope of this
// task (see CLAUDE.md's "basic functional UI" batch note).
//
// Every API route in this app returns a JSON array on success (200) or
// `{error: string}` on failure (see e.g. src/app/api/employees/route.ts's
// GET/POST) — this hook follows that same shape convention.
type UseApiListResult<T> = {
data: T[];
loading: boolean;
error: string | null;
refetch: () => void;
};
const GENERIC_ERROR_MESSAGE = "Request failed";
export function useApiList<T>(endpoint: string): UseApiListResult<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [refetchCount, setRefetchCount] = useState(0);
const refetch = useCallback(() => {
setRefetchCount((count) => count + 1);
}, []);
useEffect(() => {
let cancelled = false;
async function load(): Promise<void> {
setLoading(true);
setError(null);
try {
const response = await fetch(endpoint);
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
: GENERIC_ERROR_MESSAGE;
if (!cancelled) {
setData([]);
setError(message);
}
return;
}
if (!cancelled) {
setData(Array.isArray(body) ? (body as T[]) : []);
}
} catch {
if (!cancelled) {
setData([]);
setError(GENERIC_ERROR_MESSAGE);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [endpoint, refetchCount]);
return { data, loading, error, refetch };
}
+127 -3
View File
@@ -43,15 +43,21 @@ function dateString(message: string) {
.transform((value) => new Date(value)); .transform((value) => new Date(value));
} }
// Shared between createAssignmentSchema's employeeId field and
// transferAssignmentSchema below — kept as a standalone Zod schema (rather
// than inlined in both places) so the two stay byte-for-byte in sync on the
// exact error message, instead of two copies drifting apart over time.
const employeeIdSchema = z
.string({ required_error: "employeeId is required and must be a valid UUID string" })
.uuid("employeeId is required and must be a valid UUID string");
const createAssignmentSchema = z const createAssignmentSchema = z
.object({ .object({
// FK to Employee — only a UUID *format* check here, whether it // FK to Employee — only a UUID *format* check here, whether it
// references a real employee is left to Prisma's own FK constraint at // references a real employee is left to Prisma's own FK constraint at
// insert time (see createAssignment()'s P2003 handling below). Mirrors // insert time (see createAssignment()'s P2003 handling below). Mirrors
// src/lib/requests.ts's employee_id handling. // src/lib/requests.ts's employee_id handling.
employeeId: z employeeId: employeeIdSchema,
.string({ required_error: "employeeId is required and must be a valid UUID string" })
.uuid("employeeId is required and must be a valid UUID string"),
// Exactly one of assetItemId/licenseId must be set — see the .refine() // Exactly one of assetItemId/licenseId must be set — see the .refine()
// below for the XOR check. Unlike other nullable FKs in this codebase // below for the XOR check. Unlike other nullable FKs in this codebase
// (e.g. AssetItem.purchaseOrderId), whether these reference a real row // (e.g. AssetItem.purchaseOrderId), whether these reference a real row
@@ -137,6 +143,35 @@ export function validateReturnAssignmentInput(input: {
return { ok: true, value: result.data }; return { ok: true, value: result.data };
} }
const transferAssignmentSchema = z.object({
// Reuses employeeIdSchema (see its doc comment above) — the new employee
// to hand the same AssetItem/License over to. assetItemId/licenseId are
// deliberately NOT accepted here: transferAssignment() reuses whichever
// one the original Assignment already had (see that function's doc
// comment), the caller doesn't get to change it via a transfer.
employeeId: employeeIdSchema,
});
export type TransferValidationResult =
| { ok: true; value: { employeeId: string } }
| { ok: false; error: string };
/**
* Validates raw (untyped) assignment-transfer input. employeeId must be a
* valid UUID string (same format check as validateCreateAssignmentInput's
* employeeId field).
*/
export function validateTransferAssignmentInput(input: {
employeeId?: unknown;
}): TransferValidationResult {
const result = transferAssignmentSchema.safeParse(input);
if (!result.success) {
const message = result.error.issues[0]?.message ?? "Invalid input";
return { ok: false, error: message };
}
return { ok: true, value: result.data };
}
function toRecord(row: { function toRecord(row: {
id: string; id: string;
employeeId: string; employeeId: string;
@@ -407,3 +442,92 @@ export async function returnAssignment(
throw error; throw error;
} }
} }
export type TransferAssignmentResult =
| { ok: true; value: AssignmentRecord }
| { ok: false; status: 404 | 400; error: string };
/**
* Transfers an Assignment's underlying AssetItem/License to a new employee —
* docs/asset-license-management.md's "## Assignment (chốt 2026-08-02)":
* "Chuyển tài sản giữa nhân viên ... = đóng Assignment cũ (returnedAt = now)
* + tạo Assignment mới, cả hai trong 1 transaction." Closes the existing
* Assignment row (returnedAt = now) and creates a brand-new one for the new
* employee, reusing the SAME assetItemId/licenseId as the original, all
* inside a single `prisma.$transaction()`.
*
* Deliberately does NOT touch AssetItem.status or License.seatsUsed — unlike
* createAssignment()/returnAssignment(), a transfer is a same-resource
* handover, not a release-then-reacquire: the AssetItem never becomes
* "in_stock" and the License seat is never freed at any point during a
* transfer (no race window for a third party to grab either one mid-transfer
* — that's the whole point of doing this in one transaction instead of a
* separate PATCH-return followed by a POST-create, which is exactly the gap
* this function closes). AssetItem.status stays "assigned" throughout;
* seatsUsed is net zero (same seat, new holder).
*
* Race-condition note: mirrors returnAssignment()'s exact race-closing
* pattern — double-transfer (or transfer-racing-return) is closed by the
* same atomic, WHERE-guarded `updateMany` (`where: { id, returnedAt: null }`)
* on the OLD Assignment row. Only the first caller to commit gets
* `count: 1`; a second, concurrent call against the same id gets `count: 0`
* and is rejected as "already returned" — same semantics as
* returnAssignment(), since from the old Assignment row's point of view a
* transfer's first half *is* a return. The `findUnique` right before the
* `updateMany` is NOT the race-closing check by itself (same caveat as
* createAssignment()/returnAssignment()'s doc comments) — it only exists to
* read assetItemId/licenseId for the new row and to distinguish "not found"
* from "already returned" in the common (non-racing) case.
*/
export async function transferAssignment(
id: string,
input: { employeeId: string }
): Promise<TransferAssignmentResult> {
try {
const created = await prisma.$transaction(async (tx) => {
const assignment = await tx.assignment.findUnique({ where: { id } });
if (!assignment) {
throw new AssignmentNotFoundError();
}
// Atomic race-closing gate — see this function's doc comment. Also
// doubles as the "already returned" rejection in the non-racing case.
const claim = await tx.assignment.updateMany({
where: { id, returnedAt: null },
data: { returnedAt: new Date() },
});
if (claim.count === 0) {
throw new AssignmentBusinessError("Assignment has already been returned");
}
return tx.assignment.create({
data: {
employeeId: input.employeeId,
assetItemId: assignment.assetItemId,
licenseId: assignment.licenseId,
},
});
});
return { ok: true, value: toRecord(created) };
} catch (error) {
if (error instanceof AssignmentNotFoundError) {
return { ok: false, status: 404, error: "Assignment not found" };
}
if (error instanceof AssignmentBusinessError) {
return { ok: false, status: 400, error: error.message };
}
// Only employeeId can fail with a raw FK violation here — assetItemId/
// licenseId are reused verbatim from an already-valid Assignment row,
// so they're guaranteed to still reference existing rows (nothing in
// this codebase deletes AssetItem/License rows that have Assignments).
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
return {
ok: false,
status: 400,
error: "employeeId does not reference an existing employee",
};
}
throw error;
}
}
+192
View File
@@ -5,8 +5,10 @@ import {
createAssignment, createAssignment,
listAssignments, listAssignments,
returnAssignment, returnAssignment,
transferAssignment,
validateCreateAssignmentInput, validateCreateAssignmentInput,
validateReturnAssignmentInput, validateReturnAssignmentInput,
validateTransferAssignmentInput,
} from "@/lib/assignments"; } from "@/lib/assignments";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
@@ -542,6 +544,149 @@ describe("returnAssignment", () => {
}); });
}); });
describe("transferAssignment", () => {
it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(true);
if (!transferred.ok) return;
expect(transferred.value.employee_id).toBe(employee2.id);
expect(transferred.value.asset_item_id).toBe(assetItem.id);
expect(transferred.value.returned_at).toBeNull();
// The AssetItem must remain "assigned" throughout the transfer — never
// flipped to in_stock mid-way (that's the whole point of doing this in
// one transaction rather than a separate return + create).
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).not.toBeNull();
const rows = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id } });
expect(rows).toHaveLength(2);
const newRow = rows.find((row) => row.id !== created.value.id);
expect(newRow).toBeDefined();
expect(newRow?.employeeId).toBe(employee2.id);
expect(newRow?.returnedAt).toBeNull();
});
it("transfers a License assignment to a new employee without changing seatsUsed", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
const created = await createAssignment({ employeeId: employee1.id, licenseId });
expect(created.ok).toBe(true);
if (!created.ok) return;
let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(true);
if (!transferred.ok) return;
expect(transferred.value.employee_id).toBe(employee2.id);
expect(transferred.value.license_id).toBe(licenseId);
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
expect(license.seatsUsed).toBe(3);
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).not.toBeNull();
});
it("rejects transferring an already-returned assignment", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const returned = await returnAssignment(created.value.id, {});
expect(returned.ok).toBe(true);
const transferred = await transferAssignment(created.value.id, { employeeId: employee2.id });
expect(transferred.ok).toBe(false);
if (transferred.ok) return;
expect(transferred.status).toBe(400);
expect(transferred.error).toMatch(/already been returned/i);
});
it("returns 404 when transferring a nonexistent assignment", async () => {
const employee = await createTestEmployee();
const result = await transferAssignment("00000000-0000-0000-0000-000000000000", {
employeeId: employee.id,
});
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.status).toBe(404);
expect(result.error).toMatch(/not found/i);
});
it("returns a clean 400 when transferring to a nonexistent employeeId", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const created = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
expect(created.ok).toBe(true);
if (!created.ok) return;
const transferred = await transferAssignment(created.value.id, {
employeeId: "00000000-0000-0000-0000-000000000000",
});
expect(transferred.ok).toBe(false);
if (transferred.ok) return;
expect(transferred.status).toBe(400);
expect(transferred.error).toMatch(/employeeId/i);
expect(transferred.error).toMatch(/does not reference an existing employee/i);
// Original assignment must not have been closed — the whole transfer
// must have rolled back.
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
expect(original.returnedAt).toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
});
});
describe("validateTransferAssignmentInput", () => {
it("accepts a valid employeeId", () => {
const result = validateTransferAssignmentInput({
employeeId: "11111111-1111-1111-1111-111111111111",
});
expect(result.ok).toBe(true);
});
it("rejects a missing employeeId", () => {
const result = validateTransferAssignmentInput({});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
it("rejects a non-UUID employeeId", () => {
const result = validateTransferAssignmentInput({ employeeId: "not-a-uuid" });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/employeeId/i);
}
});
});
describe("listAssignments", () => { describe("listAssignments", () => {
it("lists created assignments, newest first", async () => { it("lists created assignments, newest first", async () => {
const employee = await createTestEmployee(); const employee = await createTestEmployee();
@@ -736,6 +881,53 @@ describe("PATCH /api/assignments/[id]", () => {
const body = await res.json(); const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" }); expect(body).toEqual({ error: "Unauthorized" });
}); });
it("transfers an assignment to a new employee on valid employeeId input", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(patchRequest({ employeeId: employee2.id }), { params: { id: created.id } });
expect(res.status).toBe(200);
const body = await res.json();
expect(body.employee_id).toBe(employee2.id);
expect(body.asset_item_id).toBe(assetItem.id);
expect(body.returned_at).toBeNull();
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
expect(item.status).toBe("assigned");
});
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
const employee1 = await createTestEmployee({ name: "Employee 1" });
const employee2 = await createTestEmployee({ name: "Employee 2" });
const modelId = await createTestModelId();
const assetItem = await createTestAssetItem({ modelId });
const createRes = await POST(postRequest({ employeeId: employee1.id, assetItemId: assetItem.id }));
const created = await createRes.json();
const res = await PATCH(
patchRequest({ employeeId: employee2.id, returnedAt: "2026-08-01" }),
{ params: { id: created.id } }
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/either employeeId.*or returnedAt/i);
});
it("returns 401 for a transfer request without a session", async () => {
const employee = await createTestEmployee();
mockedAuth.mockResolvedValue(null);
const res = await PATCH(patchRequest({ employeeId: employee.id }), {
params: { id: "00000000-0000-0000-0000-000000000000" },
});
expect(res.status).toBe(401);
const body = await res.json();
expect(body).toEqual({ error: "Unauthorized" });
});
}); });
describe("auth gating on /api/assignments", () => { describe("auth gating on /api/assignments", () => {