Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e951a14cc5 | ||
|
|
2981ea6202 | ||
|
|
e471ae9c90 | ||
|
|
4bcd4da11b | ||
|
|
0576d6e61c | ||
|
|
6b39c01f4e | ||
|
|
5145c6af31 | ||
|
|
a72b8dbbbe | ||
|
|
07b01cd707 | ||
|
|
2037e6939e | ||
|
|
2a5792df67 | ||
|
|
d280279322 | ||
|
|
dc4c438ca9 | ||
|
|
bcd41e4ba3 | ||
|
|
0fdf82be61 | ||
|
|
c9b595118a | ||
|
|
1c530a3162 | ||
|
|
c02235f381 | ||
|
|
d3536b60ad |
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "asset_models" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"category_id" UUID NOT NULL,
|
||||||
|
"manufacturer" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"specs" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "asset_models_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "asset_models" ADD CONSTRAINT "asset_models_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `model` on the `asset_items` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `model_id` to the `asset_items` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "asset_items" DROP COLUMN "model",
|
||||||
|
ADD COLUMN "model_id" UUID NOT NULL;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "assignments" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"employee_id" UUID NOT NULL,
|
||||||
|
"asset_item_id" UUID,
|
||||||
|
"license_id" UUID,
|
||||||
|
"assigned_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"returned_at" TIMESTAMPTZ(6),
|
||||||
|
"signed_at" TIMESTAMPTZ(6),
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_asset_item_id_fkey" FOREIGN KEY ("asset_item_id") REFERENCES "asset_items"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_license_id_fkey" FOREIGN KEY ("license_id") REFERENCES "licenses"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
+111
-4
@@ -70,6 +70,10 @@ model Employee {
|
|||||||
// Back-relation for PurchaseOrder.receivedById — an Employee can be the
|
// Back-relation for PurchaseOrder.receivedById — an Employee can be the
|
||||||
// receiver on multiple POs over time.
|
// receiver on multiple POs over time.
|
||||||
purchaseOrdersReceived PurchaseOrder[]
|
purchaseOrdersReceived PurchaseOrder[]
|
||||||
|
// Back-relation for Assignment.employeeId — an Employee can hold multiple
|
||||||
|
// Assignments (assets/licenses) over time, including several active ones
|
||||||
|
// at once (e.g. a laptop + a license seat simultaneously).
|
||||||
|
assignments Assignment[]
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("employees")
|
@@map("employees")
|
||||||
@@ -97,16 +101,30 @@ enum AssetItemStatus {
|
|||||||
// a formal PO (e.g. historical stock predating the system). Set at
|
// a formal PO (e.g. historical stock predating the system). Set at
|
||||||
// intake/creation time only — no update endpoint for this field yet, same
|
// intake/creation time only — no update endpoint for this field yet, same
|
||||||
// precedent as License.vendorId being set-at-creation with no PATCH.
|
// precedent as License.vendorId being set-at-creation with no PATCH.
|
||||||
|
// `modelId` was a free-text `model` String until the "Cập nhật 2026-08-02
|
||||||
|
// (2)" retrofit (docs/asset-license-management.md's "Ràng buộc dữ liệu"
|
||||||
|
// bullet) — same precedent as Request.employeeId/License.vendorId (string
|
||||||
|
// -> FK once the referenced entity existed). No production `AssetItem` data
|
||||||
|
// existed at the time of this change, so this was a plain column-type
|
||||||
|
// migration, not a backfill. Required (not nullable) — every AssetItem must
|
||||||
|
// belong to a real AssetModel. AssetItem deliberately does NOT carry its own
|
||||||
|
// `categoryId` — category is derived via `modelId -> AssetModel.categoryId`
|
||||||
|
// to avoid two sources of truth that could drift apart.
|
||||||
model AssetItem {
|
model AssetItem {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
serialNumber String @unique @map("serial_number")
|
serialNumber String @unique @map("serial_number")
|
||||||
model String
|
modelId String @map("model_id") @db.Uuid
|
||||||
|
model AssetModel @relation(fields: [modelId], references: [id])
|
||||||
purchaseDate DateTime @map("purchase_date") @db.Date
|
purchaseDate DateTime @map("purchase_date") @db.Date
|
||||||
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
||||||
status AssetItemStatus @default(in_stock)
|
status AssetItemStatus @default(in_stock)
|
||||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
// Back-relation for Assignment.assetItemId — an AssetItem can have
|
||||||
|
// multiple Assignment rows over its lifetime (historical + at most one
|
||||||
|
// active, see Assignment model's comment), not built until this task.
|
||||||
|
assignments Assignment[]
|
||||||
|
|
||||||
@@map("asset_items")
|
@@map("asset_items")
|
||||||
}
|
}
|
||||||
@@ -148,6 +166,10 @@ model License {
|
|||||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
// Back-relation for Assignment.licenseId — a License can have multiple
|
||||||
|
// Assignment rows (one per seat handed out, plus historical returns), not
|
||||||
|
// built until this task.
|
||||||
|
assignments Assignment[]
|
||||||
|
|
||||||
@@map("licenses")
|
@@map("licenses")
|
||||||
}
|
}
|
||||||
@@ -237,11 +259,18 @@ enum AssetCategoryValueGroup {
|
|||||||
// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value
|
// (e.g. "Laptop", "Cáp HDMI") freely; only `valueGroup` is a fixed 2-value
|
||||||
// set.
|
// set.
|
||||||
model AssetCategory {
|
model AssetCategory {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
name String
|
name String
|
||||||
valueGroup AssetCategoryValueGroup @map("value_group")
|
valueGroup AssetCategoryValueGroup @map("value_group")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
attributeDefs AssetCategoryAttributeDef[]
|
attributeDefs AssetCategoryAttributeDef[]
|
||||||
|
// Back-relation for AssetModel.categoryId — see "Cập nhật 2026-08-02 (2)"
|
||||||
|
// in docs/asset-license-management.md. Only categories with
|
||||||
|
// valueGroup=high_value are ever actually referenced by an AssetModel (a
|
||||||
|
// business rule enforced in src/lib/asset-models.ts's createAssetModel(),
|
||||||
|
// not expressible as a Prisma-level constraint), but the relation itself
|
||||||
|
// is not restricted to high_value categories at the schema level.
|
||||||
|
assetModels AssetModel[]
|
||||||
|
|
||||||
@@map("asset_categories")
|
@@map("asset_categories")
|
||||||
}
|
}
|
||||||
@@ -293,6 +322,84 @@ model AssetCategoryAttributeDef {
|
|||||||
@@map("asset_category_attribute_defs")
|
@@map("asset_category_attribute_defs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sits between AssetCategory and AssetItem — see docs/
|
||||||
|
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section and the
|
||||||
|
// `AssetModel` row in the "Data model (sơ bộ)" table. 1 AssetModel has many
|
||||||
|
// AssetItem serials (AssetItem.modelId FK, not built in this task).
|
||||||
|
// `manufacturer`/`name` are deliberately two separate String fields (not
|
||||||
|
// merged into one), per the doc, to allow filtering/reporting by
|
||||||
|
// manufacturer alone (e.g. "Dell") without string-parsing a combined field.
|
||||||
|
// `categoryId` is a required FK to AssetCategory — the DB layer doesn't
|
||||||
|
// enforce that the referenced category is `high_value` (Prisma/Postgres FK
|
||||||
|
// constraints can't express that), that business rule is checked in
|
||||||
|
// src/lib/asset-models.ts's createAssetModel() via an explicit lookup
|
||||||
|
// before the create, per the doc's "Ràng buộc dữ liệu" section. `specs` is
|
||||||
|
// JSONB, defaulting to an empty object — the real per-category dynamic
|
||||||
|
// attribute *values* (whose *shape* is defined by
|
||||||
|
// AssetCategoryAttributeDef rows for this model's category). This task
|
||||||
|
// deliberately does NOT validate specs against the category's attribute
|
||||||
|
// defs (loose z.record(z.unknown()) validation only, see
|
||||||
|
// src/lib/asset-models.ts) — that dynamic cross-validation is a separate
|
||||||
|
// follow-up task per the doc's "Cập nhật 2026-08-02 (3)" section.
|
||||||
|
model AssetModel {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
categoryId String @map("category_id") @db.Uuid
|
||||||
|
category AssetCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
manufacturer String
|
||||||
|
name String
|
||||||
|
specs Json @default("{}")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
||||||
|
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
||||||
|
assetItems AssetItem[]
|
||||||
|
|
||||||
|
@@map("asset_models")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assigns an AssetItem (high-value hardware) or a License seat to a specific
|
||||||
|
// Employee — see docs/asset-license-management.md's "## Assignment (chốt
|
||||||
|
// 2026-08-02)" section and the `Assignment` row in the "Data model (sơ bộ)"
|
||||||
|
// table. Exactly one of assetItemId/licenseId must be set (XOR) — enforced
|
||||||
|
// at the Zod layer (src/lib/assignments.ts's `.refine()`), NOT a DB
|
||||||
|
// constraint, per the same convention as PurchaseOrder's
|
||||||
|
// receivedAt/receivedById co-nullability check: Prisma schema can't express
|
||||||
|
// an XOR between two nullable FKs natively. Both FKs stay plain nullable
|
||||||
|
// columns here for that reason. `assignedAt` defaults to now() but is a
|
||||||
|
// real stored column (not derived from createdAt) because a future
|
||||||
|
// "transfer" operation (out of scope for this task, see doc) will create a
|
||||||
|
// new Assignment row whose assignedAt should reflect the transfer moment,
|
||||||
|
// not necessarily row-insert time. `returnedAt` null means still held by
|
||||||
|
// the employee — this is also what "active assignment" means throughout
|
||||||
|
// the codebase (see the uniqueness rule below). `signedAt` is deliberately
|
||||||
|
// just a nullable timestamp marking "handover paperwork signed" — NO file
|
||||||
|
// upload, stakeholder explicitly decided against storing a scanned file at
|
||||||
|
// this stage (see doc). No `@@unique` on assetItemId here for "only one
|
||||||
|
// active assignment at a time" — a plain unique constraint would also
|
||||||
|
// forbid a *second, already-returned* historical Assignment for the same
|
||||||
|
// AssetItem (e.g. item reassigned after being returned), which is valid and
|
||||||
|
// expected. The "at most one ACTIVE (returnedAt IS NULL) assignment per
|
||||||
|
// AssetItem" invariant is instead enforced in application code
|
||||||
|
// (src/lib/assignments.ts's createAssignment(), inside the same transaction
|
||||||
|
// that mutates AssetItem.status/License.seatsUsed) — a partial unique index
|
||||||
|
// (`WHERE returned_at IS NULL`) would express this at the DB level too, but
|
||||||
|
// that's raw SQL outside a Prisma-expressible `@@unique`, so deliberately
|
||||||
|
// not added here; see feature-implementer report for this trade-off.
|
||||||
|
model Assignment {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
employeeId String @map("employee_id") @db.Uuid
|
||||||
|
employee Employee @relation(fields: [employeeId], references: [id])
|
||||||
|
assetItemId String? @map("asset_item_id") @db.Uuid
|
||||||
|
assetItem AssetItem? @relation(fields: [assetItemId], references: [id])
|
||||||
|
licenseId String? @map("license_id") @db.Uuid
|
||||||
|
license License? @relation(fields: [licenseId], references: [id])
|
||||||
|
assignedAt DateTime @default(now()) @map("assigned_at") @db.Timestamptz(6)
|
||||||
|
returnedAt DateTime? @map("returned_at") @db.Timestamptz(6)
|
||||||
|
signedAt DateTime? @map("signed_at") @db.Timestamptz(6)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
|
@@map("assignments")
|
||||||
|
}
|
||||||
|
|
||||||
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
// Minimal internal auth accounts — NOT company SSO (deliberately ruled out,
|
||||||
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
// see CLAUDE.md / auth decision notes). A handful of trusted IT staff
|
||||||
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
// accounts, created by hand via scripts/create-user.ts, no self-registration.
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import {
|
||||||
|
createAssetModel,
|
||||||
|
listAssetModels,
|
||||||
|
validateCreateAssetModelInput,
|
||||||
|
} from "@/lib/asset-models";
|
||||||
|
|
||||||
|
// Mirrors src/app/api/asset-category-attribute-defs/route.ts's pattern —
|
||||||
|
// middleware.ts excludes /api from route protection, so this checks the
|
||||||
|
// session itself. Flat permission model (CLAUDE.md): only "logged in or
|
||||||
|
// not", no role check.
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { categoryId, manufacturer, name, specs } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createAssetModel(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetModels = await listAssetModels();
|
||||||
|
return NextResponse.json(assetModels, { status: 200 });
|
||||||
|
}
|
||||||
@@ -22,11 +22,11 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serialNumber, model, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||||
body as Record<string, unknown>;
|
body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetItemInput({
|
const validation = validateCreateAssetItemInput({
|
||||||
serialNumber,
|
serialNumber,
|
||||||
model,
|
modelId,
|
||||||
purchaseDate,
|
purchaseDate,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
purchaseOrderId,
|
purchaseOrderId,
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { returnAssignment, validateReturnAssignmentInput } from "@/lib/assignments";
|
||||||
|
|
||||||
|
// First dynamic-segment ([id]) route in the codebase — see
|
||||||
|
// docs/asset-license-management.md's "Assignment (chốt 2026-08-02)" section,
|
||||||
|
// bullet (b): update routes use PATCH /api/<entity>/[id], not an
|
||||||
|
// action-based sub-route (e.g. NOT /api/assignments/[id]/return). Next.js
|
||||||
|
// 14 App Router route handler params are a plain synchronous object (not a
|
||||||
|
// Promise) — that changed starting in Next.js 15, which this project does
|
||||||
|
// not use (see package.json / CLAUDE.md).
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the [id] path segment's format up front — an invalid UUID
|
||||||
|
// passed straight to Prisma's `findUnique({ where: { id } })` (a `@db.Uuid`
|
||||||
|
// column) throws a raw PrismaClientKnownRequestError that
|
||||||
|
// returnAssignment()'s try/catch doesn't specifically handle, which would
|
||||||
|
// otherwise leak as an uncaught 500 instead of a clean 400.
|
||||||
|
if (!z.string().uuid().safeParse(params.id).success) {
|
||||||
|
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { returnedAt } = body as Record<string, unknown>;
|
||||||
|
const validation = validateReturnAssignmentInput({ returnedAt });
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await returnAssignment(params.id, validation.value);
|
||||||
|
if (!updated.ok) {
|
||||||
|
return NextResponse.json({ error: updated.error }, { status: updated.status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(updated.value, { status: 200 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import {
|
||||||
|
createAssignment,
|
||||||
|
listAssignments,
|
||||||
|
validateCreateAssignmentInput,
|
||||||
|
} from "@/lib/assignments";
|
||||||
|
|
||||||
|
// Mirrors src/app/api/licenses/route.ts's pattern — middleware.ts excludes
|
||||||
|
// /api from route protection, so this checks the session itself. Flat
|
||||||
|
// permission model (CLAUDE.md): only "logged in or not", no role check.
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { employeeId, assetItemId, licenseId } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateAssignmentInput({ employeeId, assetItemId, licenseId });
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createAssignment(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = await listAssignments();
|
||||||
|
return NextResponse.json(assignments, { status: 200 });
|
||||||
|
}
|
||||||
@@ -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 type { ReactNode } from "react";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { Nav } from "@/components/nav";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "Asset & License Management",
|
title: "Asset & License Management",
|
||||||
description: "Internal IT asset & license management — Ubisoft VN local IT",
|
description: "Internal IT asset & license management — Ubisoft VN local IT",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
// Root layout wraps every route, including /login (middleware.ts can't
|
||||||
|
// exclude it — it's the one page unauthenticated users must be able to
|
||||||
|
// reach). Gate <Nav/> on session presence here rather than rendering it
|
||||||
|
// unconditionally, so it only shows on authenticated pages as intended,
|
||||||
|
// without needing a client-side pathname check.
|
||||||
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body>
|
||||||
|
{session ? <Nav /> : null}
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import type { AssetAttributeDataType } from "./asset-category-attribute-defs";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export interface AssetModelRecord {
|
||||||
|
id: string;
|
||||||
|
category_id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
specs: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAssetModelInput {
|
||||||
|
categoryId: string;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
specs: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; value: CreateAssetModelInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
// Mirrors src/lib/asset-category-attribute-defs.ts's nonEmptyString helper.
|
||||||
|
function nonEmptyString(message: string) {
|
||||||
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||||
|
(value) => value.trim() !== "",
|
||||||
|
{ message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAssetModelSchema = z.object({
|
||||||
|
// FK to AssetCategory — only a UUID *format* check here, whether it
|
||||||
|
// references a real category (and whether that category is
|
||||||
|
// valueGroup=high_value) is left to createAssetModel()'s explicit lookup
|
||||||
|
// below — that's a business rule, not something pure Zod schema
|
||||||
|
// validation on this input alone can express. Mirrors
|
||||||
|
// src/lib/asset-category-attribute-defs.ts's categoryId handling.
|
||||||
|
categoryId: z
|
||||||
|
.string({ required_error: "categoryId is required and must be a valid UUID string" })
|
||||||
|
.uuid("categoryId is required and must be a valid UUID string"),
|
||||||
|
manufacturer: nonEmptyString("manufacturer is required and must be a non-empty string"),
|
||||||
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
|
// Deliberately LOOSE at this stage — just "a plain JSON object" (no
|
||||||
|
// arrays, no primitives at the top level). The category-specific dynamic
|
||||||
|
// shape (which keys are allowed, which are required, and their types) is
|
||||||
|
// validated separately in createAssetModel() below, via a runtime Zod
|
||||||
|
// schema built from that category's AssetCategoryAttributeDef rows — it
|
||||||
|
// can't be done here because that requires a DB lookup keyed on
|
||||||
|
// categoryId, which this pure-input-shape schema doesn't have access to.
|
||||||
|
// Defaults to {} when omitted, matching the DB column default.
|
||||||
|
specs: z
|
||||||
|
.record(z.unknown(), {
|
||||||
|
invalid_type_error: "specs must be a JSON object",
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({}),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw (untyped) asset-model-creation input. categoryId must be a
|
||||||
|
* valid UUID string, manufacturer/name must be non-empty strings, specs
|
||||||
|
* must be a plain JSON object (or omitted, defaulting to {}). Whether
|
||||||
|
* categoryId references a real, high_value AssetCategory is checked in
|
||||||
|
* createAssetModel() below, not here — that requires a DB lookup.
|
||||||
|
*/
|
||||||
|
export function validateCreateAssetModelInput(input: {
|
||||||
|
categoryId?: unknown;
|
||||||
|
manufacturer?: unknown;
|
||||||
|
name?: unknown;
|
||||||
|
specs?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createAssetModelSchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(row: {
|
||||||
|
id: string;
|
||||||
|
categoryId: string;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
specs: Prisma.JsonValue;
|
||||||
|
createdAt: Date;
|
||||||
|
}): AssetModelRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
category_id: row.categoryId,
|
||||||
|
manufacturer: row.manufacturer,
|
||||||
|
name: row.name,
|
||||||
|
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maps an AssetCategoryAttributeDef.dataType to the corresponding Zod leaf
|
||||||
|
// type, per docs/asset-license-management.md's "Cập nhật 2026-08-02 (3)"
|
||||||
|
// section: string -> z.string(), number -> z.number(), boolean ->
|
||||||
|
// z.boolean(), date -> z.coerce.date() (coerce so a JSON-transported date
|
||||||
|
// *string*, e.g. "2027-01-01", is accepted — JSON has no native Date type).
|
||||||
|
function zodTypeForDataType(dataType: AssetAttributeDataType): z.ZodTypeAny {
|
||||||
|
switch (dataType) {
|
||||||
|
case "string":
|
||||||
|
return z.string();
|
||||||
|
case "number":
|
||||||
|
return z.number();
|
||||||
|
case "boolean":
|
||||||
|
return z.boolean();
|
||||||
|
case "date":
|
||||||
|
return z.coerce.date();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Zod object schema AT RUNTIME from a category's
|
||||||
|
* AssetCategoryAttributeDef rows: one shape entry per attribute def (typed
|
||||||
|
* per zodTypeForDataType() above), optional when `required` is false via
|
||||||
|
* `.optional()`, and `.strict()` on the overall object so any `specs` key
|
||||||
|
* not defined for this category is rejected (no unknown keys allowed), per
|
||||||
|
* the doc section referenced above.
|
||||||
|
*/
|
||||||
|
function buildSpecsSchema(
|
||||||
|
attributeDefs: { key: string; dataType: AssetAttributeDataType; required: boolean }[]
|
||||||
|
): z.ZodTypeAny {
|
||||||
|
const shape: Record<string, z.ZodTypeAny> = {};
|
||||||
|
for (const def of attributeDefs) {
|
||||||
|
const leaf = zodTypeForDataType(def.dataType);
|
||||||
|
shape[def.key] = def.required ? leaf : leaf.optional();
|
||||||
|
}
|
||||||
|
return z.object(shape).strict();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAssetModelResult =
|
||||||
|
| { ok: true; value: AssetModelRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an AssetModel. categoryId must reference a real AssetCategory
|
||||||
|
* (checked here via an explicit prisma.assetCategory.findUnique() lookup —
|
||||||
|
* not left to Prisma's P2003 FK-violation catch like other entities'
|
||||||
|
* simple FK checks, e.g. src/lib/asset-category-attribute-defs.ts's
|
||||||
|
* categoryId handling), because there's an additional business rule to
|
||||||
|
* enforce beyond "does this row exist": the referenced category's
|
||||||
|
* valueGroup must be `high_value` (per docs/asset-license-management.md's
|
||||||
|
* "Ràng buộc dữ liệu" section under "Cập nhật 2026-08-02 (2)") — a rule
|
||||||
|
* that can't be expressed as a Postgres FK constraint, so it can't be
|
||||||
|
* caught via a Prisma error code the way a plain missing-FK case can. If
|
||||||
|
* the category doesn't exist at all, or exists but is `low_value`, this
|
||||||
|
* returns a distinct {ok:false,error} for each case rather than a raw
|
||||||
|
* P2003/500.
|
||||||
|
*/
|
||||||
|
export async function createAssetModel(
|
||||||
|
input: CreateAssetModelInput
|
||||||
|
): Promise<CreateAssetModelResult> {
|
||||||
|
const category = await prisma.assetCategory.findUnique({
|
||||||
|
where: { id: input.categoryId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
return { ok: false, error: "categoryId does not reference an existing asset category" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.valueGroup !== "high_value") {
|
||||||
|
return { ok: false, error: "categoryId must reference a category with valueGroup=high_value" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dynamic specs validation, per docs/asset-license-management.md's
|
||||||
|
// "Cập nhật 2026-08-02 (3)" section: build a Zod schema at runtime from
|
||||||
|
// this category's AssetCategoryAttributeDef rows, and validate the
|
||||||
|
// incoming specs object against it. Deliberately done here (after the
|
||||||
|
// category existence/high_value checks above), not inside
|
||||||
|
// validateCreateAssetModelInput()'s static schema, because it needs a DB
|
||||||
|
// lookup keyed on categoryId.
|
||||||
|
const attributeDefs = await prisma.assetCategoryAttributeDef.findMany({
|
||||||
|
where: { categoryId: input.categoryId },
|
||||||
|
});
|
||||||
|
const specsSchema = buildSpecsSchema(attributeDefs);
|
||||||
|
const specsResult = specsSchema.safeParse(input.specs);
|
||||||
|
if (!specsResult.success) {
|
||||||
|
const message = specsResult.error.issues[0]?.message ?? "Invalid specs";
|
||||||
|
const path = specsResult.error.issues[0]?.path.join(".");
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: path ? `specs.${path}: ${message}` : `specs: ${message}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await prisma.assetModel.create({
|
||||||
|
data: {
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
manufacturer: input.manufacturer,
|
||||||
|
name: input.name,
|
||||||
|
// input.specs is validated by Zod to be a plain JSON object
|
||||||
|
// (Record<string, unknown>), but Prisma's generated InputJsonValue
|
||||||
|
// type doesn't structurally accept an index-signature Record — cast
|
||||||
|
// is safe here since the Zod schema already guarantees JSON-shaped
|
||||||
|
// values.
|
||||||
|
specs: input.specs as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ok: true, value: toRecord(created) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAssetModels(): Promise<AssetModelRecord[]> {
|
||||||
|
const rows = await prisma.assetModel.findMany({
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
+35
-19
@@ -7,7 +7,7 @@ export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
|
|||||||
export interface AssetItemRecord {
|
export interface AssetItemRecord {
|
||||||
id: string;
|
id: string;
|
||||||
serial_number: string;
|
serial_number: string;
|
||||||
model: string;
|
model_id: string;
|
||||||
purchase_date: string;
|
purchase_date: string;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
@@ -17,7 +17,7 @@ export interface AssetItemRecord {
|
|||||||
|
|
||||||
export interface CreateAssetItemInput {
|
export interface CreateAssetItemInput {
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
model: string;
|
modelId: string;
|
||||||
purchaseDate: Date;
|
purchaseDate: Date;
|
||||||
warrantyEndDate?: Date;
|
warrantyEndDate?: Date;
|
||||||
purchaseOrderId?: string | null;
|
purchaseOrderId?: string | null;
|
||||||
@@ -47,7 +47,15 @@ function dateString(message: string) {
|
|||||||
|
|
||||||
const createAssetItemSchema = z.object({
|
const createAssetItemSchema = z.object({
|
||||||
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
||||||
model: nonEmptyString("model is required and must be a non-empty string"),
|
// Required FK to AssetModel — replaced the free-text `model` String (see
|
||||||
|
// schema.prisma / docs/asset-license-management.md's "Cập nhật 2026-08-02
|
||||||
|
// (2)" section). Only a UUID *format* check here, whether it references a
|
||||||
|
// real AssetModel is left to Prisma's own FK constraint at insert time
|
||||||
|
// (see createAssetItem()'s P2003 handling below). Mirrors
|
||||||
|
// src/lib/licenses.ts's vendorId handling.
|
||||||
|
modelId: z
|
||||||
|
.string({ required_error: "modelId is required and must be a valid UUID string" })
|
||||||
|
.uuid("modelId is required and must be a valid UUID string"),
|
||||||
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
purchaseDate: dateString("purchaseDate is required and must be a valid date string"),
|
||||||
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
||||||
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
||||||
@@ -64,17 +72,17 @@ const createAssetItemSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates raw (untyped) asset-item-creation input. serialNumber/model
|
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
|
||||||
* must be non-empty strings, purchaseDate must be a valid date string,
|
* non-empty string, modelId must be a valid UUID string, purchaseDate must
|
||||||
* warrantyEndDate is optional but must be a valid date string if present.
|
* be a valid date string, warrantyEndDate is optional but must be a valid
|
||||||
* purchaseOrderId is optional/nullable but must be a valid UUID if present.
|
* date string if present. purchaseOrderId is optional/nullable but must be
|
||||||
* status is not accepted here — new assets always start "in_stock" (same
|
* a valid UUID if present. status is not accepted here — new assets always
|
||||||
* pattern as Request always starting "Draft"), later states are set via
|
* start "in_stock" (same pattern as Request always starting "Draft"), later
|
||||||
* other operations, not at creation.
|
* states are set via other operations, not at creation.
|
||||||
*/
|
*/
|
||||||
export function validateCreateAssetItemInput(input: {
|
export function validateCreateAssetItemInput(input: {
|
||||||
serialNumber?: unknown;
|
serialNumber?: unknown;
|
||||||
model?: unknown;
|
modelId?: unknown;
|
||||||
purchaseDate?: unknown;
|
purchaseDate?: unknown;
|
||||||
warrantyEndDate?: unknown;
|
warrantyEndDate?: unknown;
|
||||||
purchaseOrderId?: unknown;
|
purchaseOrderId?: unknown;
|
||||||
@@ -90,7 +98,7 @@ export function validateCreateAssetItemInput(input: {
|
|||||||
function toRecord(row: {
|
function toRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
model: string;
|
modelId: string;
|
||||||
purchaseDate: Date;
|
purchaseDate: Date;
|
||||||
warrantyEndDate: Date | null;
|
warrantyEndDate: Date | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
@@ -100,7 +108,7 @@ function toRecord(row: {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
serial_number: row.serialNumber,
|
serial_number: row.serialNumber,
|
||||||
model: row.model,
|
model_id: row.modelId,
|
||||||
purchase_date: row.purchaseDate.toISOString(),
|
purchase_date: row.purchaseDate.toISOString(),
|
||||||
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
@@ -119,10 +127,14 @@ export type CreateAssetItemResult =
|
|||||||
* caught here and translated into the same `{ok:false,error}` shape as
|
* caught here and translated into the same `{ok:false,error}` shape as
|
||||||
* input validation failures, rather than letting a raw Prisma error leak
|
* input validation failures, rather than letting a raw Prisma error leak
|
||||||
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
* to the API layer. Mirrors createEmployee()/createRequest()'s P2003
|
||||||
* handling for FK violations. If purchaseOrderId doesn't reference an
|
* handling for FK violations. Two separate FKs can fail here (modelId ->
|
||||||
* existing PurchaseOrder, Prisma raises P2003 (FK constraint violation) —
|
* AssetModel, purchaseOrderId -> PurchaseOrder) — Prisma raises the same
|
||||||
* caught the same way, mirrors src/lib/purchase-orders.ts's
|
* error code (P2003) for either, so the FK constraint name in
|
||||||
* createPurchaseOrder() P2003 handling.
|
* `error.meta.field_name` is used to tell them apart and return a targeted
|
||||||
|
* message, rather than a raw Prisma error leaking to the API layer. Mirrors
|
||||||
|
* src/lib/licenses.ts's createLicense() / src/lib/purchase-orders.ts's
|
||||||
|
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||||
|
* same model.
|
||||||
*/
|
*/
|
||||||
export async function createAssetItem(
|
export async function createAssetItem(
|
||||||
input: CreateAssetItemInput
|
input: CreateAssetItemInput
|
||||||
@@ -131,7 +143,7 @@ export async function createAssetItem(
|
|||||||
const created = await prisma.assetItem.create({
|
const created = await prisma.assetItem.create({
|
||||||
data: {
|
data: {
|
||||||
serialNumber: input.serialNumber,
|
serialNumber: input.serialNumber,
|
||||||
model: input.model,
|
modelId: input.modelId,
|
||||||
purchaseDate: input.purchaseDate,
|
purchaseDate: input.purchaseDate,
|
||||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
warrantyEndDate: input.warrantyEndDate ?? null,
|
||||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
@@ -143,7 +155,11 @@ export async function createAssetItem(
|
|||||||
return { ok: false, error: "serialNumber already exists" };
|
return { ok: false, error: "serialNumber already exists" };
|
||||||
}
|
}
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||||
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||||
|
if (fieldName.includes("purchase_order")) {
|
||||||
|
return { ok: false, error: "purchaseOrderId does not reference an existing purchase order" };
|
||||||
|
}
|
||||||
|
return { ok: false, error: "modelId does not reference an existing asset model" };
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// assignedAt/returnedAt/signedAt are all deliberately NOT accepted from the
|
||||||
|
// client at creation time — same precedent as AssetItem.status always
|
||||||
|
// starting "in_stock" and License.seatsUsed always starting 0:
|
||||||
|
// assignedAt is a system-set "moment of assignment" timestamp
|
||||||
|
// (Prisma's @default(now()) on Assignment.assignedAt), returnedAt must be
|
||||||
|
// null on creation (an Assignment cannot be created already-returned — that
|
||||||
|
// would make no sense given the seat/status side-effects below), and
|
||||||
|
// signedAt (marking handover paperwork as signed) is not wired to any
|
||||||
|
// endpoint in this task at all — see feature-implementer report's "Out of
|
||||||
|
// scope" section. It stays null until a future "mark as signed" operation
|
||||||
|
// is built.
|
||||||
|
export interface CreateAssignmentInput {
|
||||||
|
employeeId: string;
|
||||||
|
assetItemId?: string | null;
|
||||||
|
licenseId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; value: CreateAssignmentInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
// Mirrors src/lib/purchase-orders.ts's dateString helper — accepts any
|
||||||
|
// string Date.parse() can handle and transforms it into a Date for Prisma.
|
||||||
|
function dateString(message: string) {
|
||||||
|
return z
|
||||||
|
.string({ required_error: message, invalid_type_error: message })
|
||||||
|
.refine((value) => !Number.isNaN(Date.parse(value)), { message })
|
||||||
|
.transform((value) => new Date(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAssignmentSchema = z
|
||||||
|
.object({
|
||||||
|
// FK to Employee — only a UUID *format* check here, whether it
|
||||||
|
// references a real employee is left to Prisma's own FK constraint at
|
||||||
|
// insert time (see createAssignment()'s P2003 handling below). Mirrors
|
||||||
|
// src/lib/requests.ts's employee_id handling.
|
||||||
|
employeeId: z
|
||||||
|
.string({ required_error: "employeeId is required and must be a valid UUID string" })
|
||||||
|
.uuid("employeeId is required and must be a valid UUID string"),
|
||||||
|
// Exactly one of assetItemId/licenseId must be set — see the .refine()
|
||||||
|
// below for the XOR check. Unlike other nullable FKs in this codebase
|
||||||
|
// (e.g. AssetItem.purchaseOrderId), whether these reference a real row
|
||||||
|
// is NOT deferred entirely to Prisma's insert-time FK constraint —
|
||||||
|
// createAssignment() needs to read the referenced row anyway (to check
|
||||||
|
// AssetItem.status / License.seatsUsed business rules), so existence is
|
||||||
|
// checked explicitly inside the transaction instead. Only format
|
||||||
|
// validation happens here at the Zod layer.
|
||||||
|
assetItemId: z
|
||||||
|
.string({ invalid_type_error: "assetItemId must be a valid UUID string" })
|
||||||
|
.uuid("assetItemId must be a valid UUID string")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
licenseId: z
|
||||||
|
.string({ invalid_type_error: "licenseId must be a valid UUID string" })
|
||||||
|
.uuid("licenseId must be a valid UUID string")
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(value) => {
|
||||||
|
const hasAssetItem = value.assetItemId !== undefined && value.assetItemId !== null;
|
||||||
|
const hasLicense = value.licenseId !== undefined && value.licenseId !== null;
|
||||||
|
// XOR: exactly one of the two must be set.
|
||||||
|
return hasAssetItem !== hasLicense;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: "Exactly one of assetItemId or licenseId must be set (not both, not neither)",
|
||||||
|
// Attach the error to both fields so it surfaces regardless of which
|
||||||
|
// one the caller looks at first — mirrors
|
||||||
|
// src/lib/purchase-orders.ts's receivedAt/receivedById co-nullability
|
||||||
|
// refine, which attaches to a single path for the same reason.
|
||||||
|
path: ["assetItemId"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw (untyped) assignment-creation input. employeeId must be a
|
||||||
|
* valid UUID string. Exactly one of assetItemId/licenseId must be a valid
|
||||||
|
* UUID string (XOR — both set or both unset is rejected). assignedAt,
|
||||||
|
* returnedAt, and signedAt are not accepted here — see CreateAssignmentInput's
|
||||||
|
* doc comment for why.
|
||||||
|
*/
|
||||||
|
export function validateCreateAssignmentInput(input: {
|
||||||
|
employeeId?: unknown;
|
||||||
|
assetItemId?: unknown;
|
||||||
|
licenseId?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createAssignmentSchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnAssignmentSchema = z.object({
|
||||||
|
// Optional — defaults to "now" at the time returnAssignment() runs if not
|
||||||
|
// provided (mirrors Assignment.assignedAt's own default-now behavior at
|
||||||
|
// creation). Accepting an explicit value lets a caller backdate a return
|
||||||
|
// (e.g. recording a return that was physically handled yesterday but only
|
||||||
|
// entered into the system today) — same spirit as PurchaseOrder accepting
|
||||||
|
// an explicit receivedAt rather than always stamping "now".
|
||||||
|
returnedAt: dateString("returnedAt must be a valid date string").optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ReturnValidationResult =
|
||||||
|
| { ok: true; value: { returnedAt?: Date } }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates raw (untyped) assignment-return input. returnedAt is optional
|
||||||
|
* but must be a valid date string if present.
|
||||||
|
*/
|
||||||
|
export function validateReturnAssignmentInput(input: {
|
||||||
|
returnedAt?: unknown;
|
||||||
|
}): ReturnValidationResult {
|
||||||
|
const result = returnAssignmentSchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(row: {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
assetItemId: string | null;
|
||||||
|
licenseId: string | null;
|
||||||
|
assignedAt: Date;
|
||||||
|
returnedAt: Date | null;
|
||||||
|
signedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): AssignmentRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
employee_id: row.employeeId,
|
||||||
|
asset_item_id: row.assetItemId,
|
||||||
|
license_id: row.licenseId,
|
||||||
|
assigned_at: row.assignedAt.toISOString(),
|
||||||
|
returned_at: row.returnedAt ? row.returnedAt.toISOString() : null,
|
||||||
|
signed_at: row.signedAt ? row.signedAt.toISOString() : null,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal error type used to carry a clean, already-user-facing message out
|
||||||
|
// of the `prisma.$transaction()` callback below, distinguishing
|
||||||
|
// application-level business-rule rejections (400) from unexpected/infra
|
||||||
|
// errors (rethrown as-is). Not exported — callers only ever see the
|
||||||
|
// {ok,error} result shape.
|
||||||
|
class AssignmentBusinessError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "AssignmentBusinessError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssignmentNotFoundError extends Error {
|
||||||
|
constructor() {
|
||||||
|
super("Assignment not found");
|
||||||
|
this.name = "AssignmentNotFoundError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAssignmentResult =
|
||||||
|
| { ok: true; value: AssignmentRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Assignment (assigns an AssetItem or a License seat to an
|
||||||
|
* Employee), enforcing every business rule from docs/
|
||||||
|
* asset-license-management.md's "## Assignment (chốt 2026-08-02)" section
|
||||||
|
* inside a single `prisma.$transaction()`:
|
||||||
|
*
|
||||||
|
* - AssetItem branch: rejects if the AssetItem doesn't exist, rejects if its
|
||||||
|
* current status isn't "in_stock" (stakeholder-confirmed precondition —
|
||||||
|
* see the doc's "chốt 2026-08-02 (2)" note), rejects if there's already an
|
||||||
|
* ACTIVE Assignment (returnedAt IS NULL) for it (explicit
|
||||||
|
* belt-and-suspenders invariant per the doc, on top of the status check),
|
||||||
|
* then atomically flips AssetItem.status to "assigned".
|
||||||
|
* - License branch: rejects if the License doesn't exist, rejects if
|
||||||
|
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
|
||||||
|
* seatsUsed by 1.
|
||||||
|
*
|
||||||
|
* Race-condition note (read this before touching the transaction body):
|
||||||
|
* both branches use a two-step "read for validation, then conditional
|
||||||
|
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
|
||||||
|
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
|
||||||
|
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
|
||||||
|
* "assigned" } })` — is the actual race-closing mechanism: Postgres
|
||||||
|
* evaluates and applies that as a single atomic UPDATE statement, taking a
|
||||||
|
* row lock for its duration. Two concurrent transactions racing on the same
|
||||||
|
* AssetItem/License row serialize on that lock — the first to commit wins,
|
||||||
|
* and the second's WHERE clause is re-evaluated against the now-committed
|
||||||
|
* row and no longer matches, so its `updateMany` returns `count: 0`, which
|
||||||
|
* this function treats as a "lost the race" rejection. This is why the
|
||||||
|
* `findUnique` calls right before each `updateMany` are NOT the
|
||||||
|
* race-closing check by themselves (there's a real TOCTOU gap between a
|
||||||
|
* `findUnique` and a later `update` in isolation) — they only exist to
|
||||||
|
* produce a specific, friendly error message (not-found vs. wrong-status vs.
|
||||||
|
* no-seats) for the *common* (non-racing) case; the `updateMany`'s `count`
|
||||||
|
* is always re-checked afterwards and is what actually gets trusted.
|
||||||
|
* seatsTotal itself is read once via that same `findUnique` and reused in
|
||||||
|
* the `updateMany`'s WHERE clause (`seatsUsed: { lt: seatsTotal }`) — this
|
||||||
|
* is safe because nothing in this codebase ever mutates seatsTotal after
|
||||||
|
* creation (no update endpoint exists for it), so it can't go stale between
|
||||||
|
* the read and the write within the same transaction.
|
||||||
|
*
|
||||||
|
* The explicit "no existing active Assignment for this AssetItem" check
|
||||||
|
* (`tx.assignment.findFirst({ where: { assetItemId, returnedAt: null } })`)
|
||||||
|
* is intentionally layered BEFORE the atomic status update, as defense in
|
||||||
|
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
|
||||||
|
* intervention) — under normal operation this codebase always keeps
|
||||||
|
* `status === "assigned"` in exact sync with "has an active Assignment", so
|
||||||
|
* the atomic status-guarded `updateMany` above is what actually closes the
|
||||||
|
* concurrency race for the AssetItem branch; this check does not need its
|
||||||
|
* own atomic guard on top because the transaction's row lock (from the
|
||||||
|
* subsequent `updateMany`) is what makes the overall operation safe, not
|
||||||
|
* this read on its own.
|
||||||
|
*/
|
||||||
|
export async function createAssignment(
|
||||||
|
input: CreateAssignmentInput
|
||||||
|
): Promise<CreateAssignmentResult> {
|
||||||
|
try {
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
if (input.assetItemId) {
|
||||||
|
const assetItem = await tx.assetItem.findUnique({ where: { id: input.assetItemId } });
|
||||||
|
if (!assetItem) {
|
||||||
|
throw new AssignmentBusinessError(
|
||||||
|
"assetItemId does not reference an existing asset item"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (assetItem.status !== "in_stock") {
|
||||||
|
throw new AssignmentBusinessError(
|
||||||
|
`assetItemId refers to an AssetItem that is not in_stock (current status: ${assetItem.status})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const existingActive = await tx.assignment.findFirst({
|
||||||
|
where: { assetItemId: input.assetItemId, returnedAt: null },
|
||||||
|
});
|
||||||
|
if (existingActive) {
|
||||||
|
throw new AssignmentBusinessError(
|
||||||
|
"assetItemId already has an active assignment (returnedAt IS NULL)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic race-closing gate — see this function's doc comment.
|
||||||
|
const statusUpdate = await tx.assetItem.updateMany({
|
||||||
|
where: { id: input.assetItemId, status: "in_stock" },
|
||||||
|
data: { status: "assigned" },
|
||||||
|
});
|
||||||
|
if (statusUpdate.count === 0) {
|
||||||
|
throw new AssignmentBusinessError(
|
||||||
|
"assetItemId refers to an AssetItem that is not in_stock (lost a race to another concurrent assignment)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.licenseId) {
|
||||||
|
const license = await tx.license.findUnique({ where: { id: input.licenseId } });
|
||||||
|
if (!license) {
|
||||||
|
throw new AssignmentBusinessError("licenseId does not reference an existing license");
|
||||||
|
}
|
||||||
|
if (license.seatsUsed >= license.seatsTotal) {
|
||||||
|
throw new AssignmentBusinessError(
|
||||||
|
"licenseId has no seats remaining (seatsUsed >= seatsTotal)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomic race-closing gate — see this function's doc comment.
|
||||||
|
const seatUpdate = await tx.license.updateMany({
|
||||||
|
where: { id: input.licenseId, seatsUsed: { lt: license.seatsTotal } },
|
||||||
|
data: { seatsUsed: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (seatUpdate.count === 0) {
|
||||||
|
throw new AssignmentBusinessError(
|
||||||
|
"licenseId has no seats remaining (lost a race to another concurrent assignment)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.assignment.create({
|
||||||
|
data: {
|
||||||
|
employeeId: input.employeeId,
|
||||||
|
assetItemId: input.assetItemId ?? null,
|
||||||
|
licenseId: input.licenseId ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, value: toRecord(created) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AssignmentBusinessError) {
|
||||||
|
return { ok: false, error: error.message };
|
||||||
|
}
|
||||||
|
// Only employeeId can still fail with a raw FK violation here —
|
||||||
|
// assetItemId/licenseId existence is already fully resolved above
|
||||||
|
// (found-or-rejected before the transaction ever reaches
|
||||||
|
// tx.assignment.create()).
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||||
|
return { ok: false, error: "employeeId does not reference an existing employee" };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAssignments(): Promise<AssignmentRecord[]> {
|
||||||
|
const rows = await prisma.assignment.findMany({
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReturnAssignmentResult =
|
||||||
|
| { ok: true; value: AssignmentRecord }
|
||||||
|
| { ok: false; status: 404 | 400; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an Assignment as returned (sets returnedAt), and reverses its
|
||||||
|
* side-effects from createAssignment() inside the same transaction:
|
||||||
|
* - If assetItemId is set: flips AssetItem.status back to "in_stock" —
|
||||||
|
* always, regardless of any RMA/repair path (there isn't one built yet,
|
||||||
|
* per the task's instructions; see feature-implementer report).
|
||||||
|
* - If licenseId is set: atomically decrements License.seatsUsed by 1.
|
||||||
|
*
|
||||||
|
* Race-condition note: double-return is closed the same way as
|
||||||
|
* createAssignment()'s races — an atomic, WHERE-guarded `updateMany`
|
||||||
|
* (`where: { id, returnedAt: null }`) is the actual gate. Two concurrent
|
||||||
|
* PATCH-return calls against the same Assignment race on that row's UPDATE
|
||||||
|
* lock; only the first to commit gets `count: 1`, the second gets `count: 0`
|
||||||
|
* and is rejected as "already returned". The initial `findUnique` (used to
|
||||||
|
* read assetItemId/licenseId for the AssetItem/License side-effects, and to
|
||||||
|
* distinguish "not found" from "already returned" in the common case) is
|
||||||
|
* NOT itself the race-closing check, same caveat as createAssignment()'s
|
||||||
|
* doc comment. Decrementing seatsUsed does not need its own conditional
|
||||||
|
* `updateMany` guard the way incrementing did on the assign side — a bare
|
||||||
|
* `{ decrement: 1 }` is a single atomic UPDATE with no upper bound to
|
||||||
|
* enforce (unlike assign's seatsUsed < seatsTotal check), so a plain
|
||||||
|
* `update()` is race-safe on its own here.
|
||||||
|
*/
|
||||||
|
export async function returnAssignment(
|
||||||
|
id: string,
|
||||||
|
input: { returnedAt?: Date }
|
||||||
|
): Promise<ReturnAssignmentResult> {
|
||||||
|
try {
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
const assignment = await tx.assignment.findUnique({ where: { id } });
|
||||||
|
if (!assignment) {
|
||||||
|
throw new AssignmentNotFoundError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnedAt = input.returnedAt ?? new Date();
|
||||||
|
|
||||||
|
// Atomic race-closing gate — see this function's doc comment. Also
|
||||||
|
// doubles as the "already returned" rejection in the non-racing case,
|
||||||
|
// since `assignment.returnedAt !== null` implies this WHERE clause
|
||||||
|
// won't match either.
|
||||||
|
const claim = await tx.assignment.updateMany({
|
||||||
|
where: { id, returnedAt: null },
|
||||||
|
data: { returnedAt },
|
||||||
|
});
|
||||||
|
if (claim.count === 0) {
|
||||||
|
throw new AssignmentBusinessError("Assignment has already been returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignment.assetItemId) {
|
||||||
|
await tx.assetItem.update({
|
||||||
|
where: { id: assignment.assetItemId },
|
||||||
|
data: { status: "in_stock" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assignment.licenseId) {
|
||||||
|
await tx.license.update({
|
||||||
|
where: { id: assignment.licenseId },
|
||||||
|
data: { seatsUsed: { decrement: 1 } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.assignment.findUniqueOrThrow({ where: { id } });
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, value: toRecord(updated) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AssignmentNotFoundError) {
|
||||||
|
return { ok: false, status: 404, error: "Assignment not found" };
|
||||||
|
}
|
||||||
|
if (error instanceof AssignmentBusinessError) {
|
||||||
|
return { ok: false, status: 400, error: error.message };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,731 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
createAssetModel,
|
||||||
|
listAssetModels,
|
||||||
|
validateCreateAssetModelInput,
|
||||||
|
} from "@/lib/asset-models";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// Note: this file has two layers of coverage, mirroring
|
||||||
|
// tests/asset-category-attribute-defs.test.ts:
|
||||||
|
// - lib-level tests below exercise src/lib/asset-models.ts directly
|
||||||
|
// (validation, FK/business-rule error handling, list ordering).
|
||||||
|
// - route-level tests (further down) exercise
|
||||||
|
// src/app/api/asset-models/route.ts over HTTP, including the auth() gate,
|
||||||
|
// malformed JSON, and non-object bodies.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/asset-models/route";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
|
||||||
|
|
||||||
|
const testSession: Session = {
|
||||||
|
user: { id: "test-user-id" },
|
||||||
|
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function postRequest(body: unknown): Request {
|
||||||
|
return new Request("http://localhost/api/asset-models", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetModel.categoryId is a real FK to AssetCategory, and the referenced
|
||||||
|
// category must be valueGroup=high_value (see docs/
|
||||||
|
// asset-license-management.md's "Cập nhật 2026-08-02 (2)" section). Tests
|
||||||
|
// that need valid ids create real rows first and use their ids. Mirrors
|
||||||
|
// tests/asset-category-attribute-defs.test.ts's createTestAssetCategory()
|
||||||
|
// helper.
|
||||||
|
function createTestAssetCategory(
|
||||||
|
overrides: { name?: string; valueGroup?: "high_value" | "low_value" } = {}
|
||||||
|
) {
|
||||||
|
return prisma.assetCategory.create({
|
||||||
|
data: {
|
||||||
|
name: overrides.name ?? "Laptop",
|
||||||
|
valueGroup: overrides.valueGroup ?? "high_value",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for the dynamic-specs-validation tests below: creates an
|
||||||
|
// AssetCategoryAttributeDef row directly via Prisma (no need to go through
|
||||||
|
// src/lib/asset-category-attribute-defs.ts's validation for test setup).
|
||||||
|
// Mirrors tests/asset-category-attribute-defs.test.ts's
|
||||||
|
// createTestAssetCategory() helper pattern.
|
||||||
|
function createTestAttributeDef(overrides: {
|
||||||
|
categoryId: string;
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
dataType: "string" | "number" | "boolean" | "date";
|
||||||
|
required: boolean;
|
||||||
|
sortOrder?: number;
|
||||||
|
}) {
|
||||||
|
return prisma.assetCategoryAttributeDef.create({
|
||||||
|
data: {
|
||||||
|
categoryId: overrides.categoryId,
|
||||||
|
key: overrides.key,
|
||||||
|
label: overrides.label ?? overrides.key,
|
||||||
|
dataType: overrides.dataType,
|
||||||
|
required: overrides.required,
|
||||||
|
sortOrder: overrides.sortOrder ?? 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
|
// deterministic. asset_models must be cleared before asset_categories
|
||||||
|
// since it has an FK (ON DELETE RESTRICT) to it. asset_category_attribute_defs
|
||||||
|
// also has an FK (ON DELETE RESTRICT) to asset_categories, so it must be
|
||||||
|
// cleared before asset_categories too — mirrors tests/
|
||||||
|
// asset-category-attribute-defs.test.ts's cleanup order.
|
||||||
|
await prisma.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategoryAttributeDef.deleteMany();
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
|
// Default to a logged-in session — the lib-level tests below don't go
|
||||||
|
// through the route at all (so this is a no-op for them), and the
|
||||||
|
// route-level tests exercise business logic, not auth gating (that's
|
||||||
|
// covered by the "auth gating" describe block further down).
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCreateAssetModelInput", () => {
|
||||||
|
it("accepts valid input with a non-trivial specs object", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value).toMatchObject({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults specs to {} when omitted", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.specs).toEqual({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing categoryId", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/categoryId/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a categoryId that is not a valid UUID", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "not-a-uuid",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/categoryId/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty manufacturer", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/manufacturer/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/name/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a specs value that is not a plain object", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: "not an object",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/specs/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a specs value that is an array", () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: ["not", "an", "object"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/specs/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAssetModel", () => {
|
||||||
|
it("creates a model for a real high_value category, with a non-trivial specs object", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "cpu",
|
||||||
|
dataType: "string",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value).toMatchObject({
|
||||||
|
category_id: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
expect(typeof created.value.id).toBe("string");
|
||||||
|
expect(created.value.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(created.value.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults specs to {} in the created record when omitted", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Apple",
|
||||||
|
name: "MacBook Pro 14",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value.specs).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clean error when categoryId doesn't reference an existing category", async () => {
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/categoryId/i);
|
||||||
|
expect(created.error).toMatch(/does not reference an existing asset category/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a distinct clean error when categoryId references an existing but low_value category", async () => {
|
||||||
|
const category = await createTestAssetCategory({
|
||||||
|
name: "Cáp HDMI",
|
||||||
|
valueGroup: "low_value",
|
||||||
|
});
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/high_value/i);
|
||||||
|
// Distinct message from the "doesn't exist at all" case.
|
||||||
|
expect(created.error).not.toMatch(/does not reference an existing asset category/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dynamic specs validation, per docs/asset-license-management.md's "Cập
|
||||||
|
// nhật 2026-08-02 (3)" section: createAssetModel() builds a Zod schema at
|
||||||
|
// runtime from the category's AssetCategoryAttributeDef rows and validates
|
||||||
|
// `specs` against it — unknown keys rejected, required keys enforced, and
|
||||||
|
// each key's value type-checked per its dataType.
|
||||||
|
describe("createAssetModel: dynamic specs validation", () => {
|
||||||
|
it("accepts specs that supply all required keys with correct types", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "cpu",
|
||||||
|
dataType: "string",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "has_touchscreen",
|
||||||
|
dataType: "boolean",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value.specs).toEqual({ ram_gb: 16, cpu: "i7-1165G7", has_touchscreen: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects specs missing a required key with a clean 400-style error", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: {},
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/ram_gb/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a specs value of the wrong type for its dataType (string where number is expected)", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: "16" },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/ram_gb/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a specs key not defined as an AssetCategoryAttributeDef for that category", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, unknown_key: "surprise" },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/specs/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts specs that omit an optional (required=false) attribute def key", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "weight_kg",
|
||||||
|
dataType: "number",
|
||||||
|
required: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16 },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value.specs).toEqual({ ram_gb: 16 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a valid date string for a date-typed attribute def", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "warranty_end",
|
||||||
|
dataType: "date",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { warranty_end: "2027-01-01" },
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (!result.ok) return;
|
||||||
|
|
||||||
|
const created = await createAssetModel(result.value);
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value.specs).toEqual({ warranty_end: "2027-01-01" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listAssetModels", () => {
|
||||||
|
it("lists created models, newest first", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
|
||||||
|
const first = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
});
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
if (first.ok) await createAssetModel(first.value);
|
||||||
|
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
|
||||||
|
const second = validateCreateAssetModelInput({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Apple",
|
||||||
|
name: "MacBook Pro 14",
|
||||||
|
});
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
if (second.ok) await createAssetModel(second.value);
|
||||||
|
|
||||||
|
const records = await listAssetModels();
|
||||||
|
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
// newest first
|
||||||
|
expect(records[0]).toMatchObject({ name: "MacBook Pro 14" });
|
||||||
|
expect(records[1]).toMatchObject({ name: "Latitude 5420" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no models", async () => {
|
||||||
|
const records = await listAssetModels();
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/asset-models", () => {
|
||||||
|
it("creates a model on valid input", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "ram_gb",
|
||||||
|
dataType: "number",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
await createTestAttributeDef({
|
||||||
|
categoryId: category.id,
|
||||||
|
key: "cpu",
|
||||||
|
dataType: "string",
|
||||||
|
required: true,
|
||||||
|
});
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
category_id: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
specs: { ram_gb: 16, cpu: "i7-1165G7" },
|
||||||
|
});
|
||||||
|
expect(typeof body.id).toBe("string");
|
||||||
|
expect(body.id.length).toBeGreaterThan(0);
|
||||||
|
expect(Number.isNaN(Date.parse(body.created_at))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults specs to {} when omitted from the request body", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.specs).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing manufacturer", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/manufacturer/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a categoryId that is a well-formed UUID but doesn't reference an existing category", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/categoryId/i);
|
||||||
|
expect(body.error).toMatch(/does not reference an existing asset category/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a categoryId referencing an existing but low_value category, with a distinct 400", async () => {
|
||||||
|
const category = await createTestAssetCategory({
|
||||||
|
name: "Cáp HDMI",
|
||||||
|
valueGroup: "low_value",
|
||||||
|
});
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/high_value/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/asset-models", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await POST(req);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object JSON body", async () => {
|
||||||
|
// Note: arrays are `typeof "object"` in JS, so they pass the route's
|
||||||
|
// object check (same behavior as /api/asset-category-attribute-defs) —
|
||||||
|
// use a JSON string primitive here to actually exercise the "not an
|
||||||
|
// object" branch.
|
||||||
|
const res = await POST(postRequest("not an object"));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON object/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/asset-models", () => {
|
||||||
|
it("lists created models, newest first", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Apple",
|
||||||
|
name: "MacBook Pro 14",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const body = await res.json();
|
||||||
|
expect(Array.isArray(body)).toBe(true);
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
|
// newest first
|
||||||
|
expect(body[0]).toMatchObject({ name: "MacBook Pro 14" });
|
||||||
|
expect(body[1]).toMatchObject({ name: "Latitude 5420" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no models", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/asset-models", () => {
|
||||||
|
// Mirrors tests/asset-category-attribute-defs.test.ts's "auth gating"
|
||||||
|
// block — only checks session presence, per the flat permission model (no
|
||||||
|
// role/permission logic).
|
||||||
|
it("POST /api/asset-models returns 401 without a session", async () => {
|
||||||
|
const category = await createTestAssetCategory({ valueGroup: "high_value" });
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
categoryId: category.id,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5420",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.assetModel.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/asset-models returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
+105
-24
@@ -50,16 +50,52 @@ function createTestPurchaseOrder(overrides: { vendorId?: string } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssetItem.modelId is a real FK to AssetModel (see schema.prisma /
|
||||||
|
// src/lib/assets.ts), and AssetModel.categoryId is a real FK to
|
||||||
|
// AssetCategory — tests that need a valid modelId create the full chain
|
||||||
|
// first and use the model's id. Mirrors tests/asset-models.test.ts's
|
||||||
|
// createTestAssetCategory() helper. Category is always high_value here —
|
||||||
|
// AssetModel only ever references high_value categories per
|
||||||
|
// src/lib/asset-models.ts's business rule.
|
||||||
|
function createTestAssetCategory(overrides: { name?: string } = {}) {
|
||||||
|
return prisma.assetCategory.create({
|
||||||
|
data: {
|
||||||
|
name: overrides.name ?? "Laptop",
|
||||||
|
valueGroup: "high_value",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestAssetModel(overrides: { categoryId?: string } = {}) {
|
||||||
|
return prisma.assetModel.create({
|
||||||
|
data: {
|
||||||
|
categoryId: overrides.categoryId as string,
|
||||||
|
manufacturer: "Dell",
|
||||||
|
name: "Latitude 5440",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestModelId(): Promise<string> {
|
||||||
|
const category = await createTestAssetCategory();
|
||||||
|
const model = await createTestAssetModel({ categoryId: category.id });
|
||||||
|
return model.id;
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Each test starts from empty tables so listing/order assertions are
|
// Each test starts from empty tables so listing/order assertions are
|
||||||
// deterministic. asset_items must be cleared before purchase_orders/
|
// deterministic. asset_items must be cleared before purchase_orders/
|
||||||
// vendors since asset_items has FKs (ON DELETE SET NULL to
|
// vendors/asset_models/asset_categories since asset_items has FKs to all
|
||||||
// purchase_orders) — deleteMany order avoids relying on that cascade
|
// of them — deleteMany order avoids relying on cascade behavior in tests.
|
||||||
// behavior in tests. purchase_orders must be cleared before vendors
|
// purchase_orders must be cleared before vendors (ON DELETE RESTRICT to
|
||||||
// (ON DELETE RESTRICT to vendors, mirrors tests/purchase-orders.test.ts).
|
// vendors, mirrors tests/purchase-orders.test.ts). asset_models must be
|
||||||
|
// cleared before asset_categories (ON DELETE RESTRICT, mirrors
|
||||||
|
// tests/asset-models.test.ts's cleanup order).
|
||||||
await prisma.assetItem.deleteMany();
|
await prisma.assetItem.deleteMany();
|
||||||
await prisma.purchaseOrder.deleteMany();
|
await prisma.purchaseOrder.deleteMany();
|
||||||
await prisma.vendor.deleteMany();
|
await prisma.vendor.deleteMany();
|
||||||
|
await prisma.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
// Default to a logged-in session — the tests below exercise business
|
// Default to a logged-in session — the tests below exercise business
|
||||||
// logic, not auth gating (that's covered by the "auth gating" describe
|
// logic, not auth gating (that's covered by the "auth gating" describe
|
||||||
// block further down).
|
// block further down).
|
||||||
@@ -68,8 +104,9 @@ beforeEach(async () => {
|
|||||||
|
|
||||||
describe("POST /api/assets", () => {
|
describe("POST /api/assets", () => {
|
||||||
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
|
it("creates an asset item in in_stock status on valid input, with no warrantyEndDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-001", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
|
postRequest({ serialNumber: "SN-001", modelId, purchaseDate: "2026-01-15" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
@@ -77,7 +114,7 @@ describe("POST /api/assets", () => {
|
|||||||
|
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
serial_number: "SN-001",
|
serial_number: "SN-001",
|
||||||
model: "Dell Latitude 5440",
|
model_id: modelId,
|
||||||
status: "in_stock",
|
status: "in_stock",
|
||||||
warranty_end_date: null,
|
warranty_end_date: null,
|
||||||
});
|
});
|
||||||
@@ -88,10 +125,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates an asset item with a warrantyEndDate", async () => {
|
it("creates an asset item with a warrantyEndDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-002",
|
serialNumber: "SN-002",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
warrantyEndDate: "2029-01-15",
|
warrantyEndDate: "2029-01-15",
|
||||||
})
|
})
|
||||||
@@ -103,24 +141,26 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty serialNumber", async () => {
|
it("rejects an empty serialNumber", async () => {
|
||||||
const res = await POST(postRequest({ serialNumber: "", model: "Dell", purchaseDate: "2026-01-15" }));
|
const modelId = await createTestModelId();
|
||||||
|
const res = await POST(postRequest({ serialNumber: "", modelId, purchaseDate: "2026-01-15" }));
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/serialNumber/i);
|
expect(body.error).toMatch(/serialNumber/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an empty model", async () => {
|
it("rejects a missing modelId", async () => {
|
||||||
const res = await POST(postRequest({ serialNumber: "SN-003", model: "", purchaseDate: "2026-01-15" }));
|
const res = await POST(postRequest({ serialNumber: "SN-003", purchaseDate: "2026-01-15" }));
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
expect(body.error).toMatch(/model/i);
|
expect(body.error).toMatch(/modelId/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid purchaseDate", async () => {
|
it("rejects an invalid purchaseDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-004", model: "Dell", purchaseDate: "not-a-date" })
|
postRequest({ serialNumber: "SN-004", modelId, purchaseDate: "not-a-date" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
@@ -129,10 +169,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an invalid warrantyEndDate", async () => {
|
it("rejects an invalid warrantyEndDate", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-005",
|
serialNumber: "SN-005",
|
||||||
model: "Dell",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
warrantyEndDate: "not-a-date",
|
warrantyEndDate: "not-a-date",
|
||||||
})
|
})
|
||||||
@@ -144,13 +185,14 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
|
it("creates an asset item with a purchaseOrderId that references a real purchase order", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const vendor = await createTestVendor();
|
const vendor = await createTestVendor();
|
||||||
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
const purchaseOrder = await createTestPurchaseOrder({ vendorId: vendor.id });
|
||||||
|
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-007",
|
serialNumber: "SN-007",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
purchaseOrderId: purchaseOrder.id,
|
purchaseOrderId: purchaseOrder.id,
|
||||||
})
|
})
|
||||||
@@ -162,8 +204,9 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
|
it("creates an asset item with no purchaseOrderId (stays null)", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-008", model: "Dell Latitude 5440", purchaseDate: "2026-01-15" })
|
postRequest({ serialNumber: "SN-008", modelId, purchaseDate: "2026-01-15" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(201);
|
expect(res.status).toBe(201);
|
||||||
@@ -172,10 +215,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
it("rejects a purchaseOrderId that is a well-formed UUID but doesn't reference an existing purchase order", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-009",
|
serialNumber: "SN-009",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
purchaseOrderId: "00000000-0000-0000-0000-000000000000",
|
||||||
})
|
})
|
||||||
@@ -192,10 +236,11 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
it("rejects a purchaseOrderId that is not a valid UUID", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({
|
postRequest({
|
||||||
serialNumber: "SN-012",
|
serialNumber: "SN-012",
|
||||||
model: "Dell Latitude 5440",
|
modelId,
|
||||||
purchaseDate: "2026-01-15",
|
purchaseDate: "2026-01-15",
|
||||||
purchaseOrderId: "not-a-uuid",
|
purchaseOrderId: "not-a-uuid",
|
||||||
})
|
})
|
||||||
@@ -207,8 +252,9 @@ describe("POST /api/assets", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects a duplicate serialNumber", async () => {
|
it("rejects a duplicate serialNumber", async () => {
|
||||||
await POST(postRequest({ serialNumber: "SN-006", model: "Dell", purchaseDate: "2026-01-15" }));
|
const modelId = await createTestModelId();
|
||||||
const res = await POST(postRequest({ serialNumber: "SN-006", model: "HP", purchaseDate: "2026-02-01" }));
|
await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-01-15" }));
|
||||||
|
const res = await POST(postRequest({ serialNumber: "SN-006", modelId, purchaseDate: "2026-02-01" }));
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
@@ -218,14 +264,48 @@ describe("POST /api/assets", () => {
|
|||||||
const remaining = await prisma.assetItem.findMany();
|
const remaining = await prisma.assetItem.findMany();
|
||||||
expect(remaining).toHaveLength(1);
|
expect(remaining).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a modelId that is not a valid UUID", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ serialNumber: "SN-013", modelId: "not-a-uuid", purchaseDate: "2026-01-15" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/modelId/i);
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.assetItem.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a modelId that is a well-formed UUID but doesn't reference an existing asset model", async () => {
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({
|
||||||
|
serialNumber: "SN-014",
|
||||||
|
modelId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
purchaseDate: "2026-01-15",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/modelId/i);
|
||||||
|
expect(body.error).toMatch(/does not reference an existing asset model/i);
|
||||||
|
|
||||||
|
// And it must not have created anything.
|
||||||
|
const remaining = await prisma.assetItem.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("GET /api/assets", () => {
|
describe("GET /api/assets", () => {
|
||||||
it("lists created asset items, newest first", async () => {
|
it("lists created asset items, newest first", async () => {
|
||||||
await POST(postRequest({ serialNumber: "SN-010", model: "Dell", purchaseDate: "2026-01-15" }));
|
const modelId = await createTestModelId();
|
||||||
|
await POST(postRequest({ serialNumber: "SN-010", modelId, purchaseDate: "2026-01-15" }));
|
||||||
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
// Ensure a distinct created_at ordering even on fast filesystems/clocks.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
await POST(postRequest({ serialNumber: "SN-011", model: "HP", purchaseDate: "2026-02-01" }));
|
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET();
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
@@ -235,8 +315,8 @@ describe("GET /api/assets", () => {
|
|||||||
expect(body).toHaveLength(2);
|
expect(body).toHaveLength(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model: "HP" });
|
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", model: "Dell" });
|
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns an empty list when there are no asset items", async () => {
|
it("returns an empty list when there are no asset items", async () => {
|
||||||
@@ -252,10 +332,11 @@ describe("auth gating on /api/assets", () => {
|
|||||||
// session presence, per the flat permission model (no role/permission
|
// session presence, per the flat permission model (no role/permission
|
||||||
// logic).
|
// logic).
|
||||||
it("POST /api/assets returns 401 without a session", async () => {
|
it("POST /api/assets returns 401 without a session", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await POST(
|
const res = await POST(
|
||||||
postRequest({ serialNumber: "SN-020", model: "Dell", purchaseDate: "2026-01-15" })
|
postRequest({ serialNumber: "SN-020", modelId, purchaseDate: "2026-01-15" })
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
|
|||||||
@@ -0,0 +1,764 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import {
|
||||||
|
createAssignment,
|
||||||
|
listAssignments,
|
||||||
|
returnAssignment,
|
||||||
|
validateCreateAssignmentInput,
|
||||||
|
validateReturnAssignmentInput,
|
||||||
|
} from "@/lib/assignments";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// This file has two layers of coverage, mirroring tests/licenses.test.ts:
|
||||||
|
// - lib-level tests exercise src/lib/assignments.ts directly (validation,
|
||||||
|
// transactional business rules, race conditions).
|
||||||
|
// - route-level tests exercise src/app/api/assignments/route.ts and
|
||||||
|
// src/app/api/assignments/[id]/route.ts over HTTP, including the auth()
|
||||||
|
// gate — mirrors tests/licenses.test.ts's @/auth mocking pattern.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/assignments/route";
|
||||||
|
import { PATCH } from "@/app/api/assignments/[id]/route";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
|
||||||
|
|
||||||
|
const testSession: Session = {
|
||||||
|
user: { id: "test-user-id" },
|
||||||
|
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function postRequest(body: unknown): Request {
|
||||||
|
return new Request("http://localhost/api/assignments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRequest(body: unknown): Request {
|
||||||
|
return new Request("http://localhost/api/assignments/x", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment.employeeId is a real FK to Employee — mirrors
|
||||||
|
// tests/requests.test.ts's createTestEmployee() helper.
|
||||||
|
function createTestEmployee(overrides: { name?: string } = {}) {
|
||||||
|
return prisma.employee.create({
|
||||||
|
data: {
|
||||||
|
name: overrides.name ?? "Test Employee",
|
||||||
|
department: "IT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment.assetItemId is a real FK to AssetItem, which itself requires a
|
||||||
|
// full AssetModel -> AssetCategory chain. Mirrors tests/assets.test.ts's
|
||||||
|
// createTestModelId() helper.
|
||||||
|
async function createTestModelId(): Promise<string> {
|
||||||
|
const category = await prisma.assetCategory.create({
|
||||||
|
data: { name: "Laptop", valueGroup: "high_value" },
|
||||||
|
});
|
||||||
|
const model = await prisma.assetModel.create({
|
||||||
|
data: { categoryId: category.id, manufacturer: "Dell", name: "Latitude 5440" },
|
||||||
|
});
|
||||||
|
return model.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
let serialCounter = 0;
|
||||||
|
function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "assigned" | "repair" | "retired" }) {
|
||||||
|
serialCounter += 1;
|
||||||
|
return prisma.assetItem.create({
|
||||||
|
data: {
|
||||||
|
serialNumber: `SN-ASSIGN-${serialCounter}`,
|
||||||
|
modelId: overrides.modelId,
|
||||||
|
purchaseDate: new Date("2026-01-15"),
|
||||||
|
status: overrides.status ?? "in_stock",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assignment.licenseId is a real FK to License, which itself requires a
|
||||||
|
// Vendor. Mirrors tests/licenses.test.ts's createTestVendor() helper.
|
||||||
|
function createTestVendor(overrides: { name?: string } = {}) {
|
||||||
|
return prisma.vendor.create({
|
||||||
|
data: {
|
||||||
|
name: overrides.name ?? "Test Vendor",
|
||||||
|
contactEmail: "[email protected]",
|
||||||
|
contactPhone: "0281234567",
|
||||||
|
address: "123 Test Street",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestLicense(overrides: {
|
||||||
|
vendorId: string;
|
||||||
|
seatsTotal?: number;
|
||||||
|
seatsUsed?: number;
|
||||||
|
}) {
|
||||||
|
return prisma.license.create({
|
||||||
|
data: {
|
||||||
|
name: "Adobe Creative Cloud",
|
||||||
|
vendorId: overrides.vendorId,
|
||||||
|
seatsTotal: overrides.seatsTotal ?? 5,
|
||||||
|
seatsUsed: overrides.seatsUsed ?? 0,
|
||||||
|
licenseType: "subscription",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTestLicenseId(overrides: { seatsTotal?: number; seatsUsed?: number } = {}): Promise<string> {
|
||||||
|
const vendor = await createTestVendor();
|
||||||
|
const license = await createTestLicense({ vendorId: vendor.id, ...overrides });
|
||||||
|
return license.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear in FK-safe order: assignments first (references everything else),
|
||||||
|
// then asset_items/licenses (reference purchase_orders/vendors/models),
|
||||||
|
// then the rest.
|
||||||
|
await prisma.assignment.deleteMany();
|
||||||
|
await prisma.assetItem.deleteMany();
|
||||||
|
await prisma.license.deleteMany();
|
||||||
|
await prisma.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
|
await prisma.purchaseOrder.deleteMany();
|
||||||
|
await prisma.vendor.deleteMany();
|
||||||
|
await prisma.employee.deleteMany();
|
||||||
|
serialCounter = 0;
|
||||||
|
// Default to a logged-in session — the lib-level tests don't go through
|
||||||
|
// the route at all (no-op for them), and the route-level tests exercise
|
||||||
|
// business logic, not auth gating (covered by the "auth gating" block).
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateCreateAssignmentInput", () => {
|
||||||
|
it("accepts input with only assetItemId set", () => {
|
||||||
|
const result = validateCreateAssignmentInput({
|
||||||
|
employeeId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
assetItemId: "22222222-2222-2222-2222-222222222222",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts input with only licenseId set", () => {
|
||||||
|
const result = validateCreateAssignmentInput({
|
||||||
|
employeeId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
licenseId: "33333333-3333-3333-3333-333333333333",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects input with neither assetItemId nor licenseId set", () => {
|
||||||
|
const result = validateCreateAssignmentInput({
|
||||||
|
employeeId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/exactly one/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects input with both assetItemId and licenseId set", () => {
|
||||||
|
const result = validateCreateAssignmentInput({
|
||||||
|
employeeId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
assetItemId: "22222222-2222-2222-2222-222222222222",
|
||||||
|
licenseId: "33333333-3333-3333-3333-333333333333",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/exactly one/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a missing employeeId", () => {
|
||||||
|
const result = validateCreateAssignmentInput({
|
||||||
|
assetItemId: "22222222-2222-2222-2222-222222222222",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/employeeId/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-UUID assetItemId", () => {
|
||||||
|
const result = validateCreateAssignmentInput({
|
||||||
|
employeeId: "11111111-1111-1111-1111-111111111111",
|
||||||
|
assetItemId: "not-a-uuid",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/assetItemId/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("validateReturnAssignmentInput", () => {
|
||||||
|
it("accepts an empty body (returnedAt defaults to now later)", () => {
|
||||||
|
const result = validateReturnAssignmentInput({});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.returnedAt).toBeUndefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicit returnedAt", () => {
|
||||||
|
const result = validateReturnAssignmentInput({ returnedAt: "2026-08-05" });
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) {
|
||||||
|
expect(result.value.returnedAt).toBeInstanceOf(Date);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid returnedAt", () => {
|
||||||
|
const result = validateReturnAssignmentInput({ returnedAt: "not-a-date" });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createAssignment", () => {
|
||||||
|
it("assigns an in_stock AssetItem, flips its status to assigned", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value.asset_item_id).toBe(assetItem.id);
|
||||||
|
expect(created.value.license_id).toBeNull();
|
||||||
|
expect(created.value.returned_at).toBeNull();
|
||||||
|
|
||||||
|
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
|
expect(updatedItem.status).toBe("assigned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["repair", "retired", "assigned"] as const)(
|
||||||
|
"rejects assigning an AssetItem whose status is %s",
|
||||||
|
async (status) => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId, status });
|
||||||
|
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/not in_stock/i);
|
||||||
|
|
||||||
|
const remaining = await prisma.assignment.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it("rejects assigning an AssetItem that already has an active assignment (belt-and-suspenders check)", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const first = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
// Manually force status back to in_stock without closing the active
|
||||||
|
// Assignment row, to exercise the explicit "active assignment already
|
||||||
|
// exists" invariant independently of the status check (data-drift
|
||||||
|
// scenario per src/lib/assignments.ts's doc comment).
|
||||||
|
await prisma.assetItem.update({ where: { id: assetItem.id }, data: { status: "in_stock" } });
|
||||||
|
|
||||||
|
const second = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(second.ok).toBe(false);
|
||||||
|
if (second.ok) return;
|
||||||
|
expect(second.error).toMatch(/already has an active assignment/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects assigning an AssetItem for a second employee while the first assignment is still active", async () => {
|
||||||
|
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||||
|
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
|
||||||
|
const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id });
|
||||||
|
expect(second.ok).toBe(false);
|
||||||
|
if (second.ok) return;
|
||||||
|
expect(second.error).toMatch(/not in_stock/i);
|
||||||
|
|
||||||
|
const activeAssignments = await prisma.assignment.findMany({ where: { assetItemId: assetItem.id, returnedAt: null } });
|
||||||
|
expect(activeAssignments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows re-assigning an AssetItem to a new employee after it has been returned", async () => {
|
||||||
|
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||||
|
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const first = await createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id });
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
if (!first.ok) return;
|
||||||
|
const returned = await returnAssignment(first.value.id, {});
|
||||||
|
expect(returned.ok).toBe(true);
|
||||||
|
|
||||||
|
const second = await createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id });
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
if (!second.ok) return;
|
||||||
|
expect(second.value.employee_id).toBe(employee2.id);
|
||||||
|
|
||||||
|
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
|
expect(updatedItem.status).toBe("assigned");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const created = await createAssignment({
|
||||||
|
employeeId: employee.id,
|
||||||
|
assetItemId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/assetItemId/i);
|
||||||
|
expect(created.error).toMatch(/does not reference an existing asset item/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clean error when employeeId doesn't reference an existing employee", async () => {
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const created = await createAssignment({
|
||||||
|
employeeId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
assetItemId: assetItem.id,
|
||||||
|
});
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/employeeId/i);
|
||||||
|
expect(created.error).toMatch(/does not reference an existing employee/i);
|
||||||
|
|
||||||
|
// And the AssetItem's status must NOT have been mutated — the
|
||||||
|
// transaction must have rolled back entirely.
|
||||||
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
|
expect(item.status).toBe("in_stock");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns a license seat, increments seatsUsed", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
|
||||||
|
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, licenseId });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
expect(created.value.license_id).toBe(licenseId);
|
||||||
|
|
||||||
|
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||||
|
expect(license.seatsUsed).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects assigning a license with no seats remaining (seatsUsed >= seatsTotal)", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 3 });
|
||||||
|
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, licenseId });
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/no seats remaining/i);
|
||||||
|
|
||||||
|
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||||
|
expect(license.seatsUsed).toBe(3);
|
||||||
|
const remaining = await prisma.assignment.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a clean error when licenseId doesn't reference an existing license", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const created = await createAssignment({
|
||||||
|
employeeId: employee.id,
|
||||||
|
licenseId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
});
|
||||||
|
expect(created.ok).toBe(false);
|
||||||
|
if (created.ok) return;
|
||||||
|
expect(created.error).toMatch(/licenseId/i);
|
||||||
|
expect(created.error).toMatch(/does not reference an existing license/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the race: two concurrent createAssignment calls against a license with exactly 1 remaining seat — at least one must fail, seatsUsed never exceeds seatsTotal", async () => {
|
||||||
|
const employee1 = await createTestEmployee({ name: "Racer 1" });
|
||||||
|
const employee2 = await createTestEmployee({ name: "Racer 2" });
|
||||||
|
// seatsTotal=3, seatsUsed=2 -> exactly 1 seat remaining.
|
||||||
|
const licenseId = await createTestLicenseId({ seatsTotal: 3, seatsUsed: 2 });
|
||||||
|
|
||||||
|
const [resultA, resultB] = await Promise.all([
|
||||||
|
createAssignment({ employeeId: employee1.id, licenseId }),
|
||||||
|
createAssignment({ employeeId: employee2.id, licenseId }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const outcomes = [resultA, resultB];
|
||||||
|
const succeeded = outcomes.filter((r) => r.ok);
|
||||||
|
const failed = outcomes.filter((r) => !r.ok);
|
||||||
|
|
||||||
|
expect(succeeded).toHaveLength(1);
|
||||||
|
expect(failed).toHaveLength(1);
|
||||||
|
if (!failed[0].ok) {
|
||||||
|
expect(failed[0].error).toMatch(/no seats remaining/i);
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||||
|
expect(license.seatsUsed).toBe(3);
|
||||||
|
expect(license.seatsUsed).toBeLessThanOrEqual(license.seatsTotal);
|
||||||
|
|
||||||
|
const assignments = await prisma.assignment.findMany({ where: { licenseId } });
|
||||||
|
expect(assignments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the race: two concurrent createAssignment calls against the same in_stock AssetItem — at least one must fail", async () => {
|
||||||
|
const employee1 = await createTestEmployee({ name: "Racer 1" });
|
||||||
|
const employee2 = await createTestEmployee({ name: "Racer 2" });
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const [resultA, resultB] = await Promise.all([
|
||||||
|
createAssignment({ employeeId: employee1.id, assetItemId: assetItem.id }),
|
||||||
|
createAssignment({ employeeId: employee2.id, assetItemId: assetItem.id }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const outcomes = [resultA, resultB];
|
||||||
|
const succeeded = outcomes.filter((r) => r.ok);
|
||||||
|
const failed = outcomes.filter((r) => !r.ok);
|
||||||
|
|
||||||
|
expect(succeeded).toHaveLength(1);
|
||||||
|
expect(failed).toHaveLength(1);
|
||||||
|
|
||||||
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
|
expect(item.status).toBe("assigned");
|
||||||
|
|
||||||
|
const activeAssignments = await prisma.assignment.findMany({
|
||||||
|
where: { assetItemId: assetItem.id, returnedAt: null },
|
||||||
|
});
|
||||||
|
expect(activeAssignments).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("returnAssignment", () => {
|
||||||
|
it("returns an AssetItem assignment: sets returnedAt, flips status back to in_stock", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
|
||||||
|
const returned = await returnAssignment(created.value.id, {});
|
||||||
|
expect(returned.ok).toBe(true);
|
||||||
|
if (!returned.ok) return;
|
||||||
|
expect(returned.value.returned_at).not.toBeNull();
|
||||||
|
|
||||||
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
|
expect(item.status).toBe("in_stock");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts an explicit returnedAt", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
|
||||||
|
const explicitDate = new Date("2026-08-01T00:00:00.000Z");
|
||||||
|
const returned = await returnAssignment(created.value.id, { returnedAt: explicitDate });
|
||||||
|
expect(returned.ok).toBe(true);
|
||||||
|
if (!returned.ok) return;
|
||||||
|
expect(returned.value.returned_at).toBe(explicitDate.toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a license assignment: sets returnedAt, decrements seatsUsed", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 2 });
|
||||||
|
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, licenseId });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
|
||||||
|
let license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||||
|
expect(license.seatsUsed).toBe(3);
|
||||||
|
|
||||||
|
const returned = await returnAssignment(created.value.id, {});
|
||||||
|
expect(returned.ok).toBe(true);
|
||||||
|
|
||||||
|
license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||||
|
expect(license.seatsUsed).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects returning an already-returned assignment (double return)", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, assetItemId: assetItem.id });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
|
||||||
|
const firstReturn = await returnAssignment(created.value.id, {});
|
||||||
|
expect(firstReturn.ok).toBe(true);
|
||||||
|
|
||||||
|
const secondReturn = await returnAssignment(created.value.id, {});
|
||||||
|
expect(secondReturn.ok).toBe(false);
|
||||||
|
if (secondReturn.ok) return;
|
||||||
|
expect(secondReturn.status).toBe(400);
|
||||||
|
expect(secondReturn.error).toMatch(/already been returned/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when returning a nonexistent assignment", async () => {
|
||||||
|
const result = await returnAssignment("00000000-0000-0000-0000-000000000000", {});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.status).toBe(404);
|
||||||
|
expect(result.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes the race: two concurrent returnAssignment calls against the same assignment — only one succeeds", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const licenseId = await createTestLicenseId({ seatsTotal: 5, seatsUsed: 0 });
|
||||||
|
const created = await createAssignment({ employeeId: employee.id, licenseId });
|
||||||
|
expect(created.ok).toBe(true);
|
||||||
|
if (!created.ok) return;
|
||||||
|
|
||||||
|
const [resultA, resultB] = await Promise.all([
|
||||||
|
returnAssignment(created.value.id, {}),
|
||||||
|
returnAssignment(created.value.id, {}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const outcomes = [resultA, resultB];
|
||||||
|
const succeeded = outcomes.filter((r) => r.ok);
|
||||||
|
const failed = outcomes.filter((r) => !r.ok);
|
||||||
|
expect(succeeded).toHaveLength(1);
|
||||||
|
expect(failed).toHaveLength(1);
|
||||||
|
|
||||||
|
const license = await prisma.license.findUniqueOrThrow({ where: { id: licenseId } });
|
||||||
|
expect(license.seatsUsed).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listAssignments", () => {
|
||||||
|
it("lists created assignments, newest first", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const item1 = await createTestAssetItem({ modelId });
|
||||||
|
const item2 = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const first = await createAssignment({ employeeId: employee.id, assetItemId: item1.id });
|
||||||
|
expect(first.ok).toBe(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
const second = await createAssignment({ employeeId: employee.id, assetItemId: item2.id });
|
||||||
|
expect(second.ok).toBe(true);
|
||||||
|
|
||||||
|
const records = await listAssignments();
|
||||||
|
expect(records).toHaveLength(2);
|
||||||
|
expect(records[0].asset_item_id).toBe(item2.id);
|
||||||
|
expect(records[1].asset_item_id).toBe(item1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no assignments", async () => {
|
||||||
|
const records = await listAssignments();
|
||||||
|
expect(records).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/assignments", () => {
|
||||||
|
it("creates an assignment on valid input", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({ employee_id: employee.id, asset_item_id: assetItem.id, license_id: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects both assetItemId and licenseId set", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
const licenseId = await createTestLicenseId();
|
||||||
|
|
||||||
|
const res = await POST(
|
||||||
|
postRequest({ employeeId: employee.id, assetItemId: assetItem.id, licenseId })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/exactly one/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects neither assetItemId nor licenseId set", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const res = await POST(postRequest({ employeeId: employee.id }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/exactly one/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects assigning an AssetItem that isn't in_stock", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId, status: "repair" });
|
||||||
|
|
||||||
|
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/not in_stock/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/assignments", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
const res = await POST(req);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object JSON body", async () => {
|
||||||
|
const res = await POST(postRequest("not an object"));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON object/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/assignments", () => {
|
||||||
|
it("lists created assignments, newest first", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const item1 = await createTestAssetItem({ modelId });
|
||||||
|
const item2 = await createTestAssetItem({ modelId });
|
||||||
|
|
||||||
|
await POST(postRequest({ employeeId: employee.id, assetItemId: item1.id }));
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
|
await POST(postRequest({ employeeId: employee.id, assetItemId: item2.id }));
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveLength(2);
|
||||||
|
expect(body[0].asset_item_id).toBe(item2.id);
|
||||||
|
expect(body[1].asset_item_id).toBe(item1.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no assignments", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/assignments/[id]", () => {
|
||||||
|
it("returns an assignment on valid input", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
|
||||||
|
const created = await createRes.json();
|
||||||
|
|
||||||
|
const res = await PATCH(patchRequest({}), { params: { id: created.id } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.returned_at).not.toBeNull();
|
||||||
|
|
||||||
|
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||||
|
expect(item.status).toBe("in_stock");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for a nonexistent assignment id", async () => {
|
||||||
|
const res = await PATCH(patchRequest({}), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for a malformed id (not a UUID)", async () => {
|
||||||
|
const res = await PATCH(patchRequest({}), { params: { id: "not-a-uuid" } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/UUID/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects double-return with 400", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
const createRes = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
|
||||||
|
const created = await createRes.json();
|
||||||
|
|
||||||
|
await PATCH(patchRequest({}), { params: { id: created.id } });
|
||||||
|
const res = await PATCH(patchRequest({}), { params: { id: created.id } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/already been returned/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/assignments/x", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
const res = await PATCH(req, { params: { id: "00000000-0000-0000-0000-000000000000" } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-object JSON body", async () => {
|
||||||
|
const res = await PATCH(patchRequest("not an object"), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.error).toMatch(/JSON object/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await PATCH(patchRequest({}), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/assignments", () => {
|
||||||
|
it("POST /api/assignments returns 401 without a session", async () => {
|
||||||
|
const employee = await createTestEmployee();
|
||||||
|
const modelId = await createTestModelId();
|
||||||
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await POST(postRequest({ employeeId: employee.id, assetItemId: assetItem.id }));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
|
||||||
|
const remaining = await prisma.assignment.findMany();
|
||||||
|
expect(remaining).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/assignments returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toEqual({ error: "Unauthorized" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user