Compare commits
5
Commits
6b39c01f4e
...
e951a14cc5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e951a14cc5 | ||
|
|
2981ea6202 | ||
|
|
e471ae9c90 | ||
|
|
4bcd4da11b | ||
|
|
0576d6e61c |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -1,15 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
import { auth } from "@/auth";
|
||||
import { Nav } from "@/components/nav";
|
||||
|
||||
export const metadata = {
|
||||
title: "Asset & License Management",
|
||||
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 (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body>
|
||||
{session ? <Nav /> : null}
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Vendored
+216
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user