Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03d74d69c1 | ||
|
|
10508f9d04 | ||
|
|
4063b81f16 | ||
|
|
6863323cce | ||
|
|
3ebd7efd7f | ||
|
|
2cbe746bde | ||
|
|
1360bb3c45 | ||
|
|
69437cf38f | ||
|
|
aa7f5515db | ||
|
|
d9b360c1df | ||
|
|
8ac95c974b | ||
|
|
b4e2f6c83e | ||
|
|
b52106dfa6 | ||
|
|
d7c0da2328 | ||
|
|
555fbf64fd | ||
|
|
5a8dea2ea3 |
+12
-3
@@ -15,8 +15,11 @@ jobs:
|
||||
POSTGRES_USER: asset_license_mgmt
|
||||
POSTGRES_PASSWORD: change-me-local-dev
|
||||
POSTGRES_DB: asset_license_mgmt
|
||||
ports:
|
||||
- 5432:5432
|
||||
# No `ports:` publish to the host -- reached via the `db` hostname
|
||||
# over homelab_proxy instead (see DATABASE_URL comment below), and
|
||||
# skipping the host publish avoids a repeat of the "port already
|
||||
# allocated" failure an earlier orphaned run's leftover container
|
||||
# caused.
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
@@ -24,7 +27,13 @@ jobs:
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgres://asset_license_mgmt:change-me-local-dev@localhost:5432/asset_license_mgmt
|
||||
# Hostname is the service key ("db"), NOT localhost -- the self-hosted
|
||||
# runner is pinned to the homelab_proxy Docker network (so it can
|
||||
# resolve the `gitea` host for checkout), which means job and service
|
||||
# containers are separate network members rather than sharing a
|
||||
# network namespace the way GitHub-hosted runners' service containers
|
||||
# do. localhost inside the job container does not reach the service.
|
||||
DATABASE_URL: postgres://asset_license_mgmt:change-me-local-dev@db:5432/asset_license_mgmt
|
||||
AUTH_SECRET: ci-only-dummy-secret-not-used-in-prod
|
||||
AUTH_TRUST_HOST: "true"
|
||||
|
||||
|
||||
@@ -93,6 +93,22 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
||||
- `MaintenanceRecord` xong + code-reviewer gate PASS (2026-08-04): entity mới track sửa chữa/bảo hành cho `AssetItem` (spec BA+stakeholder, xem `docs/asset-license-management.md`) — `type` (enum `repair`/`warranty`/`preventive`), `vendorId` (tái dùng `Vendor`, nullable), không track `cost`. Mutator đầu tiên ghi `AssetItem.status` ngoài `Assignment`: mở record → auto `status=repair` (chặn 400 nếu `AssetItem` đang `retired` hoặc có `Assignment` active hoặc đã có record khác đang mở); đóng record (`PATCH`, set `endDate`+`resolutionNotes`) nhận `targetStatus` bắt buộc, giới hạn chỉ `in_stock`/`retired` (không cho `assigned`/`repair` — tránh tạo `AssetItem.status=assigned` mồ côi không có `Assignment` tương ứng, fix sau code-reviewer MEDIUM finding). Code-reviewer tìm 1 gap HIGH (guard `updateMany` ban đầu dùng `status: {not:"retired"}` thay vì pin đúng `status:"in_stock"` như `Assignment`, cho phép race mở 2 record cùng lúc / đụng độ với `Assignment`) — đã fix, giờ mirror đúng pattern atomic guard của `createAssignment()`. CONFIRMED qua test-runner độc lập: 327/327 test (chạy lại 3 lần liên tiếp), tsc/lint sạch.
|
||||
- Test-infra: fix gốc rễ race condition (2026-08-04) — bug lộ ra thật khi lên tới file test thứ 12 (`tests/maintenance-records.test.ts`), gây `PrismaClientKnownRequestError: Foreign key constraint violated` không ổn định ở `beforeEach` của 2 file khác dù chạy riêng lẻ vẫn xanh (root cause: `process.env.DATABASE_URL` bị nhiều file test ghi đè lên cùng 1 biến toàn cục, PrismaClient của `src/lib/prisma.ts` singleton rebind sai DB khi file này chạy đè lên timing của file khác). Fix thật: `tests/setup.ts` giờ dùng `vi.mock("@/lib/prisma", async () => {...})` — mỗi file test tự tạo DB tạm riêng + 1 `PrismaClient` riêng (qua option `datasourceUrl`, không đụng `process.env` toàn cục nữa) — không còn state nào chia sẻ giữa các file. `fileParallelism: false` đã bỏ khỏi `vitest.config.ts` (về default `true`).
|
||||
- **`InventoryScan` + `Area` (2026-08-20, xem `docs/asset-license-management.md` mục "InventoryScan — kiểm kê văn phòng move" cho đầy đủ business context/quyết định):** entity mới phục vụ kiểm kê văn phòng move, nhập liệu qua bot Telegram (`/api/bot/*`, auth riêng qua `src/lib/bot-auth.ts` — bearer secret `BOT_INGEST_SECRET`, không phải session), review qua trang `/inventory-scans` (session-authed bình thường). Files mới: `src/lib/areas.ts`, `src/lib/inventory-scans.ts`, `src/lib/bot-auth.ts`, `src/app/api/bot/**`, `src/app/api/inventory-scans/**`, `src/app/api/areas/**`, `src/app/inventory-scans/page.tsx`, `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`. **Lưu ý quan trọng: build KHÔNG qua pipeline `ba`→`pm`→`feature-implementer`→`test-runner`→`code-reviewer` chuẩn của repo này** — làm qua 1 phiên Claude Code thao tác từ xa qua SSH (không mở với cwd = thư mục project nên không nhận diện được `.claude/agents/`), tự viết test theo đúng convention (`tests/setup.ts` pattern, Postgres thật) và tự chạy `npm test`/`tsc --noEmit`/`npm run lint` — **369/369 test pass (35 test mới), tsc sạch, lint sạch** — nhưng **chưa qua `code-reviewer` gate thật**, khác với `Assignment`/`MaintenanceRecord` ở trên. Nên cân nhắc chạy `code-reviewer` cho diff này khi có phiên Claude Code mở đúng cwd. 3 migration (`20260820150000_add_area_and_inventory_scan`, `20260820180000_add_asset_tag`, `20260820190000_add_serial_number`) viết tay thay vì qua `prisma migrate dev --name ...` như convention chuẩn — đã áp dụng + test xanh nên không cần làm lại, nhưng lệch tiền lệ, ghi rõ ở đây để không ai nhầm là đã theo đúng quy trình.
|
||||
- **`ExpectedAsset` import + bot "🔍 Tìm trong list" (2026-08-21):** sau khi quyết định tắt OCR (xem `Ollama`/`ITAM - Inventory Scans` note trong vault Obsidian — chỉ ~300-400 asset, không đáng đánh đổi tốc độ/độ chính xác), thêm tính năng upload trước 1 file CSV asset đã biết (asset tag/model/serial/khu vực & người giữ cũ) qua trang `/inventory-scans/import` (session-auth), để lúc đi kiểm kê bot có nút "🔍 Tìm trong list" thay vì bắt gõ tay từ đầu — chọn xong tự gợi ý luôn nút "Giữ nguyên: <khu vực/người giữ cũ>" ở bước Area/Assignee. Model mới `ExpectedAsset` (migration viết tay `20260821194252_add_expected_asset`, cùng kiểu deviation như 3 migration InventoryScan ở trên). Files mới: `src/lib/expected-assets.ts` (parser CSV tự viết tay, không thêm dependency — chấp nhận vì file nhỏ/định dạng đơn giản), `src/app/api/expected-assets/import/route.ts`, `src/app/api/bot/expected-assets/route.ts`, `src/app/inventory-scans/import/page.tsx`; sửa `src/lib/inventory-scans.ts` (`createInventoryScan` nhận thêm `expectedAssetId`, đánh dấu matched trong cùng transaction) + `src/app/api/bot/inventory-scans/route.ts`. **Chưa viết Vitest test cho phần này** — chỉ test tay end-to-end qua simulate webhook calls trực tiếp (curl POST vào `/webhook` với payload Telegram giả) + `tsc --noEmit`/`npm run lint` sạch, KHÔNG qua `code-reviewer` gate và KHÔNG có test tự động — lệch chuẩn nhiều hơn cả `InventoryScan` gốc, cần bổ sung test + review khi có phiên đúng cwd.
|
||||
- **`AssetModel.warrantyMonths` -> auto-fill `AssetItem.warrantyEndDate` (2026-08-21):** thêm field `warrantyMonths` (nullable) vào `AssetModel` — thời hạn bảo hành chuẩn theo dòng máy/hãng. `createAssetItem()` trong `src/lib/assets.ts` tự tính `warrantyEndDate = purchaseDate + warrantyMonths` (helper `addMonthsClamped()`, clamp đúng cuối tháng — vd 31/12 + 2 tháng = 28/2, không tràn sang tháng 3) CHỈ khi caller không tự truyền `warrantyEndDate` — vẫn override được per-item (vd batch mua có bảo hành mở rộng khác model chuẩn). UI `/asset-models` có thêm input "Standard Warranty (months)"; trang `/assets` không cần sửa gì (đã omit `warrantyEndDate` khỏi payload khi để trống, server tự điền). Migration viết tay `20260821200217_add_warranty_months`, cùng kiểu deviation như các migration khác trong session này. Test: smoke-test tay qua tsx script (không phải Vitest) — model 36 tháng, case thường, case clamp cuối tháng, case override tay đều đúng, đã dọn data test. `tsc --noEmit`/`lint` sạch.
|
||||
- **Asset Item CSV import/export (2026-08-23):** trang `/assets` có thêm nút "Export CSV" và "Import CSV". Export (`GET /api/assets/export`) render toàn bộ `AssetItem` ra CSV (cột `manufacturer`/`model` thay vì `modelId` thô để đọc/sửa tay được). Import (`POST /api/assets/import`) parse CSV rồi tra `manufacturer`+`model` (không phân biệt hoa/thường) ra đúng `AssetModel` đã có sẵn — KHÔNG tự tạo model mới, phải tạo trước ở `/asset-models`. Lỗi từng dòng (model không khớp, thiếu field bắt buộc, ngày sai định dạng...) được báo riêng, không chặn các dòng hợp lệ khác — trả về `{created, errors: [{row, serialNumber, error}]}`. Warranty vẫn tự tính như bình thường (từ `AssetModel.warrantyMonths`) khi CSV không có cột `warranty end date`. Logic parse CSV dùng chung với `ExpectedAsset` (tách ra `src/lib/csv.ts`: `splitCsvLine`/`splitCsvLines`/`normalizeHeader`/`csvField`). Files mới: `src/lib/csv.ts`, `src/app/api/assets/import/route.ts`, `src/app/api/assets/export/route.ts`; sửa `src/lib/assets.ts` (`parseAssetItemsCsv`/`importAssetItems`/`exportAssetItemsCsv`), `src/lib/expected-assets.ts` (dùng `csv.ts` chung thay vì code trùng), `src/app/assets/page.tsx`. Test: smoke-test tay qua tsx script (export ra CSV hợp lệ, import CSV có cả dòng hợp lệ + dòng lỗi model + dòng thiếu ngày mua — đúng cả 3 case, đã dọn data test), **chưa có Vitest, chưa qua code-reviewer** — cùng deviation như các feature khác trong branch này. `tsc --noEmit`/`lint` sạch.
|
||||
- **`AssetItem.assetTag` + status-aware import + `purchaseDate` optional (2026-08-24):** để bulk-import 1 file export ITAM thật của công ty (ServiceNow-style, 613 asset thật, sheet "Page 1" = union của 8 sheet còn lại trong file gốc). Thay đổi schema:
|
||||
- `AssetItem.assetTag` (nullable, unique) — mã tag công ty (vd "DANLAP001"), trước đây chỉ `InventoryScan` có field này (dành riêng cho move), giờ có ở cả bảng chính.
|
||||
- `AssetItem.purchaseDate` đổi thành **nullable** (trước là NOT NULL) — dữ liệu thật có 6/613 asset không rõ ngày mua (thiếu "Order received" trong file gốc), quyết định 2026-08-24: import hết, không bỏ dòng nào, để trống ngày mua và tính sau. Form tạo tay `/assets` vẫn bắt buộc nhập ngày (input HTML `required`) — chỉ nới lỏng ở tầng DB/import cho dữ liệu lịch sử.
|
||||
- `importAssetItems()` giờ nhận thêm cột `status` (map free-text "In use"→`assigned`, "In stock"→`in_stock`, "Repair"/"Retired"...) — khác với tạo tay qua UI (luôn mặc định `in_stock` vì đó là "vừa mua mới", không đúng khi import tài sản ĐÃ deploy thật). `createAssetItem()` nhận thêm `status?` param, chỉ `importAssetItems()` dùng.
|
||||
- Migration viết tay: `20260824081044_add_asset_item_asset_tag`, `20260824082411_asset_item_purchase_date_nullable`.
|
||||
**Kết quả import thật:** 613/613 tạo thành công, 0 lỗi (khớp 100% với file gốc: 542 assigned + 71 in_stock). Trước khi import phải seed thủ công 14 `AssetCategory` mới + 135 `AssetModel` mới (script `seed-models.ts`, tự viết, chạy 1 lần qua tsx rồi xoá — không phải feature UI, vì import `AssetItem` cố tình KHÔNG tự tạo model, xem comment `importAssetItems()`). 6 asset thiếu ngày mua: `DANCONT014, DANCONT017, DANCONT018, DANCONT020, DANCONT021, DANCONT023` — cần bổ sung tay sau. **Phạm vi đợt này CHƯA làm:** Cost/Cost currency, PO number (text tự do, không map được vào `purchaseOrderId` UUID hiện tại), gán nhân viên thật (`User Assigned` → cần entity `Assignment` riêng, chưa đụng tới) — để đợt sau.
|
||||
- **Backfill Employee + Assignment từ dữ liệu thật (2026-08-24):** tiếp theo đợt import 613 AssetItem — trích 95 Employee khác nhau (từ cột "User Assigned", 2 người không có Department trong file gốc do là "(Other)"/"(Freelance)" → gán tạm "Unknown") + 384 Assignment (asset ↔ nhân viên, dùng ngày "Assigned Date" thật của file làm `assignedAt` lịch sử). **Quan trọng: KHÔNG dùng `createAssignment()` có sẵn** — hàm đó cho luồng nghiệp vụ SỐNG (cấp phát mới từ kho, bắt buộc AssetItem đang `in_stock`, tự chuyển sang `assigned`, không cho set `assignedAt` tay). Asset import đã có sẵn `status=assigned` đúng thực tế rồi nên gọi hàm đó sẽ bị từ chối hết — đây là backfill lịch sử, phải ghi thẳng qua Prisma (`prisma.assignment.create()` trực tiếp, script `seed-assignments.ts`, chạy 1 lần qua tsx rồi xoá, không phải feature UI/lib mới). Kết quả: 95 employee + 384 assignment, 0 lỗi, khớp 100% (kiểm tra chéo DANLAP001 → Duc Luu Minh, đúng phòng ban + ngày gán). 158 asset `status=assigned` không có Assignment — đúng thực tế (thiết bị dùng chung, file gốc không gán riêng ai). **Chưa làm:** Cost, PO number, Job/Manager/Email của Employee (schema `Employee` hiện chỉ có name+department, chưa mở rộng thêm field từ file gốc).
|
||||
- **`AssetItem.cost`/`costCurrency`/`externalPoNumber` + import upsert (2026-08-24):** thêm 3 field: `cost` (Decimal(14,2), không dùng Float để tránh sai số làm tròn), `costCurrency` (text tự do vd "VND"/"CNY"), `externalPoNumber` (text tự do — **khác** `purchaseOrderId` vốn là UUID FK thật vào bảng `PurchaseOrder`; PO number từ export ngoài thường là text rác không map được vào entity thật). CSV import/export cập nhật theo (`parseAssetItemsCsv`/`exportAssetItemsCsv`), cột `po number` tách biệt alias với `purchase order id` để không đụng nhau.
|
||||
- **`importAssetItems()` giờ hỗ trợ upsert** (2026-08-24, thay đổi hành vi quan trọng): dòng có `assetTag`/`serialNumber` đã tồn tại sẽ **UPDATE** thay vì báo lỗi trùng như trước — cần thiết để backfill cost/PO lên 613 asset đã import trước đó, và hữu ích cho lần công ty xuất file ITAM mới sau này (re-sync định kỳ, không phải one-off). Update chỉ đụng field nào dòng CSV **có giá trị** (ô trống = không đổi, không phải xoá) — riêng `warrantyEndDate` chỉ update khi dòng có giá trị rõ ràng, KHÔNG tự tính lại từ `warrantyMonths` như nhánh CREATE (tránh ghi đè warranty đã sửa tay). Kết quả trả về thêm field `updated` bên cạnh `created`/`errors`.
|
||||
- Dữ liệu rác dạng "Not available"/"Pending"/"N/A"/"None"/"-" (case-insensitive) ở cột PO number/cost/cost currency được coi là rỗng (hàm `cleanImportText()`/`parseImportCost()`), không lưu literal text rác vào DB.
|
||||
Migration viết tay: `20260824090149_add_asset_item_cost_po`.
|
||||
**Kết quả backfill thật:** chạy lại đúng file CSV 613 dòng (giờ có thêm 3 cột) → **0 tạo mới, 613 update, 0 lỗi** (đúng — không tạo trùng). 8/613 asset có cost thật (khớp 100% giá trị gốc, kiểm tra chéo), 2/613 có PO number thật, còn lại đúng là rác đã lọc.
|
||||
**Việc còn dở, ý tưởng đã thống nhất với user (chưa làm):** tách `Department` thành entity riêng (như `Area`/`Vendor`), cho cả `Employee` và `AssetItem` cùng tham chiếu qua FK thay vì mỗi bên giữ 1 chuỗi text tự do — giúp asset dùng chung (không gán ai) vẫn gắn được department, và tránh lệch chính tả giữa 2 bảng.
|
||||
- **CI (2026-08-20):** Gitea Actions, workflow `.gitea/workflows/ci.yml` — chạy `npm ci` + `npx prisma generate` + `npx tsc --noEmit` + `npm run lint` + `npm test` (Postgres service container) trên mọi push/PR. Cần runner tự host (`act_runner`) đã đăng ký với instance Gitea `git.toanhoang.work` — xem chi tiết setup ở `/opt/homelab/gitea/CLAUDE.md` (ngoài repo này) nếu cần thêm runner. **Chưa có auto-deploy** — deploy production vẫn làm tay trên `appsrv01`: `docker compose up -d --build app` rồi `docker exec asset-license-mgmt-app npm run db:migrate` (thứ tự bắt buộc: build trước, migrate sau khi container mới đã chạy).
|
||||
|
||||
## Việc còn dở
|
||||
@@ -115,6 +131,7 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
||||
- `InventoryScan` chưa có bước "promote" sang `AssetItem` thật (đọc serial thật, tạo `AssetItem`/`Assignment` từ 1 scan đã review) — hiện chỉ nằm ở `InventoryScan`, chưa lên sổ chính thức.
|
||||
- `InventoryScan.assetTag`/`serialNumber` chưa có OCR tự động — đọc thủ công qua ảnh (nhờ Claude đọc, không phải tính năng trong app). Nếu cần tự động: cần `ANTHROPIC_API_KEY` riêng cho server + route gọi Claude Vision, stakeholder đã chọn hướng thủ công trước mắt.
|
||||
- `code-reviewer` gate chưa chạy cho `InventoryScan`/`Area` (xem note ở "Trạng thái hiện tại") — nên chạy khi có phiên mở đúng cwd project.
|
||||
- **Gộp `inventory-capture` vào `ubi-dan-itam` (chốt hướng 2026-08-20, chưa làm):** stakeholder quyết định nên gộp bot Telegram kiểm kê (hiện là service Python riêng `/opt/homelab/inventory-capture/`, ngoài repo này) thẳng vào app này thay vì giữ 2 service tách rời gọi nhau qua HTTP. Lý do: đúng nguyên tắc "không tách backend/frontend nếu không có lý do hạ tầng khác biệt" đã chốt ở trên; bỏ được vòng HTTP nội bộ (`/api/bot/*` + `BOT_INGEST_SECRET`) hiện dùng để 2 service tự gọi nhau; giảm số nơi giữ `BOT_TOKEN` (hiện đang lặp ở 3 chỗ: DB của `telegram-gateway`, `.env` của `inventory-capture`, `.env` của app này — xem `src/app/api/inventory-scans/[id]/photo/route.ts`). Việc cần làm: viết lại state machine hội thoại (hiện là dict Python trong `inventory-capture/app.py`) sang TypeScript, gắn vào route mới `/api/telegram/webhook` (route mà `telegram-gateway` gọi vào — khác `/api/bot/*` là route mà bot gọi vào), state hội thoại theo `chat_id` dùng in-memory `Map` là được (app này chạy `next start`, process sống lâu dài, không phải serverless — cùng rủi ro mất state khi restart như hiện tại, không tệ hơn). Nên có test theo đúng convention khi làm. Sau khi gộp, cân nhắc thêm proxy `answerCallbackQuery`/`getFile` vào `telegram-gateway` để dọn nốt `BOT_TOKEN` khỏi app này luôn (xuống còn đúng 1 nơi giữ token).
|
||||
|
||||
---
|
||||
Cập nhật lần cuối: 2026-08-04 (UI cho `MaintenanceRecord` xong — list+create+close; UI transfer cho `Assignment` xong)
|
||||
Cập nhật lần cuối: 2026-08-20 (`InventoryScan` xong, CI Gitea Actions setup, quyết định gộp `inventory-capture` vào app này — chưa làm)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Hand-written (same deviation as the other 2026-08-20 migrations in this
|
||||
-- feature -- see CLAUDE.md "Việc còn dở"), not `prisma migrate dev`.
|
||||
CREATE TABLE "expected_assets" (
|
||||
"id" UUID NOT NULL,
|
||||
"asset_tag" TEXT,
|
||||
"model_text" TEXT NOT NULL,
|
||||
"serial_number" TEXT,
|
||||
"old_area_text" TEXT,
|
||||
"old_assignee_text" TEXT,
|
||||
"matched_scan_id" UUID,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "expected_assets_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "expected_assets_matched_scan_id_key" ON "expected_assets"("matched_scan_id");
|
||||
|
||||
ALTER TABLE "expected_assets" ADD CONSTRAINT "expected_assets_matched_scan_id_fkey"
|
||||
FOREIGN KEY ("matched_scan_id") REFERENCES "inventory_scans"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations in this
|
||||
-- feature area -- see CLAUDE.md).
|
||||
ALTER TABLE "asset_models" ADD COLUMN "warranty_months" INTEGER;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||
ALTER TABLE "asset_items" ADD COLUMN "asset_tag" TEXT;
|
||||
CREATE UNIQUE INDEX "asset_items_asset_tag_key" ON "asset_items"("asset_tag");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||
ALTER TABLE "asset_items" ALTER COLUMN "purchase_date" DROP NOT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||
ALTER TABLE "asset_items" ADD COLUMN "cost" DECIMAL(14,2);
|
||||
ALTER TABLE "asset_items" ADD COLUMN "cost_currency" TEXT;
|
||||
ALTER TABLE "asset_items" ADD COLUMN "external_po_number" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||
ALTER TYPE "AssetItemStatus" ADD VALUE 'lost';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||
ALTER TABLE "asset_items" ADD COLUMN "area_id" UUID;
|
||||
ALTER TABLE "asset_items" ADD CONSTRAINT "asset_items_area_id_fkey"
|
||||
FOREIGN KEY ("area_id") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Hand-written (same deviation as other 2026-08-2x migrations -- see CLAUDE.md).
|
||||
ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use';
|
||||
+102
-4
@@ -84,14 +84,24 @@ model Employee {
|
||||
@@map("employees")
|
||||
}
|
||||
|
||||
// Fixed, closed set of 4 values tied to the domain (see docs/
|
||||
// Fixed, closed set of values tied to the domain (see docs/
|
||||
// asset-license-management.md's "Data model (sơ bộ)" table) — native
|
||||
// Postgres enum, same rationale as RequestType above.
|
||||
// Postgres enum, same rationale as RequestType above. `lost` added
|
||||
// 2026-08-25 (ALTER TYPE ... ADD VALUE, not a table -- see the
|
||||
// 2026-08-24/25 discussion in CLAUDE.md on why this stays an enum: the
|
||||
// values are read directly by business logic, e.g. createAssignment()'s
|
||||
// `status !== "in_stock"` gate, not just display labels). `assigned`
|
||||
// renamed to `in_use` same day (ALTER TYPE ... RENAME VALUE) -- "assigned"
|
||||
// implied a specific person always holds it, but createAssignment() only
|
||||
// requires in_stock -> the enum value itself, and ~158 real-imported items
|
||||
// have this status with no Assignment row at all (shared/unowned
|
||||
// equipment) -- "in_use" doesn't carry that false implication.
|
||||
enum AssetItemStatus {
|
||||
in_stock
|
||||
assigned
|
||||
in_use
|
||||
repair
|
||||
retired
|
||||
lost
|
||||
}
|
||||
|
||||
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
||||
@@ -118,13 +128,55 @@ enum AssetItemStatus {
|
||||
model AssetItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
serialNumber String @unique @map("serial_number")
|
||||
// Company-assigned physical tag (e.g. "DANLAP001", printed on a sticker
|
||||
// on the device) -- 2026-08-24, added when bulk-importing the real
|
||||
// ServiceNow ITAM export (see docs/asset-license-management.md). Was
|
||||
// only on InventoryScan before (the office-move walk feature); this is
|
||||
// the same concept but on the actual asset record, not a move-day scan.
|
||||
// Nullable (a manually-entered new purchase may not have one yet) but
|
||||
// unique when present -- the real export had 613/613 unique, non-null
|
||||
// tags, same integrity guarantee as serialNumber.
|
||||
assetTag String? @unique @map("asset_tag")
|
||||
modelId String @map("model_id") @db.Uuid
|
||||
model AssetModel @relation(fields: [modelId], references: [id])
|
||||
purchaseDate DateTime @map("purchase_date") @db.Date
|
||||
// Nullable as of 2026-08-24 (was NOT NULL) -- bulk-importing the real
|
||||
// ITAM export, some legacy rows have no known purchase date at all (no
|
||||
// "Order received" value in the source). Manual /assets create form
|
||||
// still requires it (a NEW purchase should always have a known date);
|
||||
// this only relaxes the DB/import path for historical data. Also means
|
||||
// AssetModel.warrantyMonths auto-fill (see createAssetItem()) can't
|
||||
// compute anything for these rows -- warrantyEndDate stays null too.
|
||||
purchaseDate DateTime? @map("purchase_date") @db.Date
|
||||
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
||||
status AssetItemStatus @default(in_stock)
|
||||
// Purchase cost, in `costCurrency`'s units -- added 2026-08-24 alongside
|
||||
// externalPoNumber for the real ITAM export (very sparse there: 8/613
|
||||
// rows had a nonzero cost). Decimal, not Float, for exact money math (no
|
||||
// binary-float rounding drift) -- Decimal(14,2) comfortably covers VND
|
||||
// amounts in the millions with room to spare.
|
||||
cost Decimal? @db.Decimal(14, 2)
|
||||
costCurrency String? @map("cost_currency")
|
||||
// Reuses the Area entity built for InventoryScan/the office-move feature
|
||||
// (2026-08-25) rather than a free-text location string -- same "share
|
||||
// the lookup entity across tables" precedent floated for Department.
|
||||
// Nullable: real-world coverage is thin (the ITAM export's own location
|
||||
// data was nearly useless -- "Physical location" was the literal string
|
||||
// "DAN" for all 613 rows, no per-item detail; only "Stockroom" carried
|
||||
// any signal, and only for the ~71 in_stock items -- an in_use item is
|
||||
// with a person, not sitting in a stockroom bin, so it has no area here
|
||||
// by design, not by data gap).
|
||||
areaId String? @map("area_id") @db.Uuid
|
||||
area Area? @relation(fields: [areaId], references: [id], onDelete: SetNull)
|
||||
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
||||
// Free-text PO number from an external system (e.g. the real ITAM
|
||||
// export's "PO number" column) -- deliberately NOT the same thing as
|
||||
// purchaseOrderId above, which is a real FK into this app's own
|
||||
// PurchaseOrder table. External exports' PO numbers are messy free text
|
||||
// ("Not available", "Pending", or an actual number) with no matching
|
||||
// PurchaseOrder row to link to here, so this just preserves the raw
|
||||
// value for reference instead of forcing a fake relation.
|
||||
externalPoNumber String? @map("external_po_number")
|
||||
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
|
||||
@@ -364,6 +416,16 @@ model AssetModel {
|
||||
manufacturer String
|
||||
name String
|
||||
specs Json @default("{}")
|
||||
// Standard manufacturer/vendor warranty length for this model, in months
|
||||
// (e.g. Dell Latitude 5420 -> 36). Nullable -- not every model has a known
|
||||
// standard term. Used only as a DEFAULT when creating an AssetItem: see
|
||||
// src/lib/assets.ts's createAssetItem() -- warrantyEndDate = purchaseDate
|
||||
// + warrantyMonths, computed server-side only when the caller doesn't
|
||||
// supply warrantyEndDate explicitly (2026-08-21 decision: a specific
|
||||
// purchase batch's actual warranty can differ from the model's standard
|
||||
// term -- e.g. an extended-warranty deal -- so this must stay an
|
||||
// overridable suggestion, not a hard-locked computed column on AssetItem).
|
||||
warrantyMonths Int? @map("warranty_months")
|
||||
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.
|
||||
@@ -502,6 +564,8 @@ model Area {
|
||||
team String?
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
inventoryScans InventoryScan[]
|
||||
// Back-relation for AssetItem.areaId -- see that field's comment.
|
||||
assetItems AssetItem[]
|
||||
|
||||
@@map("areas")
|
||||
}
|
||||
@@ -565,6 +629,40 @@ model InventoryScan {
|
||||
telegramChatId String? @map("telegram_chat_id")
|
||||
telegramMessageId Int? @map("telegram_message_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
// Back-reference for ExpectedAsset.matchedScanId -- set once someone
|
||||
// picks this scan's pre-uploaded row via the bot's "🔍 Tìm trong list"
|
||||
// step. No @relation attribute needed here; the FK lives on the other
|
||||
// side.
|
||||
expectedAsset ExpectedAsset?
|
||||
|
||||
@@map("inventory_scans")
|
||||
}
|
||||
|
||||
// A row from a pre-move Excel/CSV export of known assets (asset tag,
|
||||
// model, old area/owner), uploaded via POST /api/expected-assets/import
|
||||
// before an office-move walk starts (2026-08-21 decision -- team decided
|
||||
// plain manual entry was faster/more reliable than local OCR for ~300-400
|
||||
// assets, see docs/asset-license-management.md, but typing in a KNOWN
|
||||
// asset tag from scratch during the walk is still wasted effort when it's
|
||||
// sitting right here). Purely a lookup/staging table for the bot's "🔍 Tìm
|
||||
// trong list" picker -- modelText/oldAreaText/oldAssigneeText are free
|
||||
// text straight from the import file, deliberately NOT FK'd into
|
||||
// AssetModel/Area/Employee: import-file text won't reliably match this
|
||||
// app's catalog IDs/spelling, and forcing that match at import time would
|
||||
// block the whole import on one typo instead of just being a soft
|
||||
// "here's the old value" suggestion during the walk. Marked matched (via
|
||||
// matchedScanId) once someone actually picks it while creating a scan, so
|
||||
// it stops showing up in future searches.
|
||||
model ExpectedAsset {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
assetTag String? @map("asset_tag")
|
||||
modelText String @map("model_text")
|
||||
serialNumber String? @map("serial_number")
|
||||
oldAreaText String? @map("old_area_text")
|
||||
oldAssigneeText String? @map("old_assignee_text")
|
||||
matchedScanId String? @unique @map("matched_scan_id") @db.Uuid
|
||||
matchedScan InventoryScan? @relation(fields: [matchedScanId], references: [id])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("expected_assets")
|
||||
}
|
||||
|
||||
@@ -27,8 +27,14 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
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 });
|
||||
const { categoryId, manufacturer, name, specs, warrantyMonths } = body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetModelInput({
|
||||
categoryId,
|
||||
manufacturer,
|
||||
name,
|
||||
specs,
|
||||
warrantyMonths,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { exportAssetItemsCsv } from "@/lib/assets";
|
||||
|
||||
// Session-authed CSV export -- a plain GET so a normal <a href> download
|
||||
// link works (browser sends the NextAuth session cookie on same-origin
|
||||
// navigation same as any other page load; no need for a fetch+blob dance
|
||||
// client-side). Columns match src/lib/assets.ts's parseAssetItemsCsv() so
|
||||
// export -> edit -> re-import is a real round trip.
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const csv = await exportAssetItemsCsv();
|
||||
return new NextResponse(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": 'attachment; filename="asset-items.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { importAssetItems, parseAssetItemsCsv } from "@/lib/assets";
|
||||
|
||||
// Session-authed CSV bulk import (see src/app/assets/page.tsx's import
|
||||
// section). Same "raw text body, no multipart" shape as
|
||||
// src/app/api/expected-assets/import/route.ts -- the upload UI reads the
|
||||
// File client-side (File.text()) and posts the text directly.
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const text = await request.text();
|
||||
if (!text || text.trim() === "") {
|
||||
return NextResponse.json({ error: "Request body must be non-empty CSV text" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = parseAssetItemsCsv(text);
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
||||
}
|
||||
if (parsed.rows.length === 0) {
|
||||
return NextResponse.json({ error: "No data rows found after the header" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await importAssetItems(parsed.rows);
|
||||
return NextResponse.json(result, { status: 201 });
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { createAssetItem, listAssetItems, validateCreateAssetItemInput } from "@/lib/assets";
|
||||
import {
|
||||
createAssetItem,
|
||||
listAssetItems,
|
||||
validateCreateAssetItemInput,
|
||||
type AssetItemStatus,
|
||||
} from "@/lib/assets";
|
||||
|
||||
// Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes
|
||||
// /api from route protection, so this checks the session itself. Flat
|
||||
@@ -22,14 +27,29 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
||||
body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetItemInput({
|
||||
const {
|
||||
serialNumber,
|
||||
assetTag,
|
||||
modelId,
|
||||
purchaseDate,
|
||||
warrantyEndDate,
|
||||
purchaseOrderId,
|
||||
cost,
|
||||
costCurrency,
|
||||
externalPoNumber,
|
||||
areaId,
|
||||
} = body as Record<string, unknown>;
|
||||
const validation = validateCreateAssetItemInput({
|
||||
serialNumber,
|
||||
assetTag,
|
||||
modelId,
|
||||
purchaseDate,
|
||||
warrantyEndDate,
|
||||
purchaseOrderId,
|
||||
cost,
|
||||
costCurrency,
|
||||
externalPoNumber,
|
||||
areaId,
|
||||
});
|
||||
|
||||
if (!validation.ok) {
|
||||
@@ -44,12 +64,37 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
return NextResponse.json(created.value, { status: 201 });
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse> {
|
||||
// The one entity route in this app whose GET response is NOT a bare array
|
||||
// (see src/hooks/use-api-list.ts's doc comment on that being the norm
|
||||
// everywhere else) -- it's `{items, total}` instead, because /assets is
|
||||
// paginated (2026-08-25, see listAssetItems()'s doc comment) and the
|
||||
// client needs `total` to render page controls. src/app/assets/page.tsx
|
||||
// fetches this directly rather than through useApiList for that reason.
|
||||
export async function GET(request: Request): Promise<NextResponse> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const assetItems = await listAssetItems();
|
||||
return NextResponse.json(assetItems, { status: 200 });
|
||||
const url = new URL(request.url);
|
||||
const params = url.searchParams;
|
||||
const page = params.has("page") ? Number(params.get("page")) : undefined;
|
||||
const pageSize = params.has("pageSize") ? Number(params.get("pageSize")) : undefined;
|
||||
const status = params.get("status") ?? undefined;
|
||||
const modelId = params.get("modelId") ?? undefined;
|
||||
const categoryId = params.get("categoryId") ?? undefined;
|
||||
const areaId = params.get("areaId") ?? undefined;
|
||||
const search = params.get("search") ?? undefined;
|
||||
|
||||
const validStatuses = ["in_stock", "in_use", "repair", "retired", "lost"];
|
||||
const result = await listAssetItems({
|
||||
page,
|
||||
pageSize,
|
||||
status: status && validStatuses.includes(status) ? (status as AssetItemStatus) : undefined,
|
||||
modelId,
|
||||
categoryId,
|
||||
areaId,
|
||||
search,
|
||||
});
|
||||
return NextResponse.json(result, { status: 200 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireBotAuth } from "@/lib/bot-auth";
|
||||
import { searchExpectedAssets } from "@/lib/expected-assets";
|
||||
|
||||
// Bot-facing search for the "🔍 Tìm trong list" picker (see
|
||||
// src/lib/expected-assets.ts's searchExpectedAssets() for the matching
|
||||
// rule). ?q= is optional -- an empty/missing query returns the most
|
||||
// recently imported unmatched rows, which is what the bot shows first
|
||||
// before the operator types anything to narrow it down.
|
||||
export async function GET(request: Request): Promise<NextResponse> {
|
||||
if (!requireBotAuth(request)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
const q = url.searchParams.get("q") ?? undefined;
|
||||
const expectedAssets = await searchExpectedAssets(q);
|
||||
return NextResponse.json({ expectedAssets });
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
photoFileId,
|
||||
telegramChatId,
|
||||
telegramMessageId,
|
||||
expectedAssetId,
|
||||
} = body as Record<string, unknown>;
|
||||
|
||||
const validation = validateCreateInventoryScanInput({
|
||||
@@ -43,6 +44,7 @@ export async function POST(request: Request): Promise<NextResponse> {
|
||||
photoFileId,
|
||||
telegramChatId,
|
||||
telegramMessageId,
|
||||
expectedAssetId,
|
||||
});
|
||||
if (!validation.ok) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { importExpectedAssets, parseExpectedAssetsCsv } from "@/lib/expected-assets";
|
||||
|
||||
// Session-authed CSV upload (see src/app/inventory-scans/import/page.tsx) --
|
||||
// the pre-move "here's what we already know" list, uploaded once before an
|
||||
// office-move walk starts. Not bot-facing: this is prep work the operator
|
||||
// does at a laptop before heading out, not something the Telegram flow
|
||||
// itself triggers. Request body is the raw CSV text, not JSON/multipart --
|
||||
// the upload page reads the File client-side (File.text()) and posts the
|
||||
// text directly, same as this repo's other "small file, no need for real
|
||||
// multipart handling" routes.
|
||||
export async function POST(request: Request): Promise<NextResponse> {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const text = await request.text();
|
||||
if (!text || text.trim() === "") {
|
||||
return NextResponse.json({ error: "Request body must be non-empty CSV text" }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = parseExpectedAssetsCsv(text);
|
||||
if (!parsed.ok) {
|
||||
return NextResponse.json({ error: parsed.error }, { status: 400 });
|
||||
}
|
||||
if (parsed.rows.length === 0) {
|
||||
return NextResponse.json({ error: "No data rows found after the header" }, { status: 400 });
|
||||
}
|
||||
|
||||
const result = await importExpectedAssets(parsed.rows);
|
||||
return NextResponse.json({ imported: result.count }, { status: 201 });
|
||||
}
|
||||
@@ -30,6 +30,7 @@ interface AssetModelRecord {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Record<string, unknown>;
|
||||
warranty_months: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -64,6 +65,7 @@ export default function AssetModelsPage() {
|
||||
const [manufacturer, setManufacturer] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
|
||||
const [warrantyMonths, setWarrantyMonths] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
@@ -83,11 +85,24 @@ export default function AssetModelsPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedWarranty = warrantyMonths.trim();
|
||||
if (trimmedWarranty !== "" && Number.isNaN(Number(trimmedWarranty))) {
|
||||
setSubmitError("Warranty (months) must be a number");
|
||||
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 }),
|
||||
body: JSON.stringify({
|
||||
categoryId,
|
||||
manufacturer,
|
||||
name,
|
||||
specs,
|
||||
...(trimmedWarranty !== "" ? { warrantyMonths: Number(trimmedWarranty) } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
let body: unknown;
|
||||
@@ -113,6 +128,7 @@ export default function AssetModelsPage() {
|
||||
setManufacturer("");
|
||||
setName("");
|
||||
setSpecsText(DEFAULT_SPECS_TEXT);
|
||||
setWarrantyMonths("");
|
||||
refetch();
|
||||
} catch {
|
||||
setSubmitError("Request failed");
|
||||
@@ -139,6 +155,7 @@ export default function AssetModelsPage() {
|
||||
<TableHead>Manufacturer</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Specs</TableHead>
|
||||
<TableHead>Warranty (months)</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -150,6 +167,7 @@ export default function AssetModelsPage() {
|
||||
<TableCell>{assetModel.manufacturer}</TableCell>
|
||||
<TableCell>{assetModel.name}</TableCell>
|
||||
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
|
||||
<TableCell>{assetModel.warranty_months ?? "-"}</TableCell>
|
||||
<TableCell>{assetModel.created_at}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -208,6 +226,23 @@ export default function AssetModelsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="warrantyMonths">Standard Warranty (months, optional)</Label>
|
||||
<Input
|
||||
id="warrantyMonths"
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={warrantyMonths}
|
||||
onChange={(event) => setWarrantyMonths(event.target.value)}
|
||||
placeholder="e.g. 36"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used as a default when creating an Asset Item with this model -- warranty end date
|
||||
is auto-filled from purchase date if left blank there, still editable per item.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<p className="text-sm text-destructive">{submitError}</p>
|
||||
) : null}
|
||||
|
||||
+568
-21
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { useApiList } from "@/hooks/use-api-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -26,23 +26,61 @@ import {
|
||||
interface AssetItemRecord {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
asset_tag: string | null;
|
||||
model_id: string;
|
||||
purchase_date: string;
|
||||
purchase_date: string | null;
|
||||
warranty_end_date: string | null;
|
||||
status: "in_stock" | "assigned" | "repair" | "retired";
|
||||
status: "in_stock" | "in_use" | "repair" | "retired" | "lost";
|
||||
cost: number | null;
|
||||
cost_currency: string | null;
|
||||
area_id: string | null;
|
||||
purchase_order_id: string | null;
|
||||
external_po_number: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// Shape returned by GET /api/areas — reused from the office-move feature
|
||||
// (see src/lib/areas.ts). Only id/name needed here.
|
||||
interface AreaRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Shape returned by GET /api/employees — only id/name needed to resolve
|
||||
// the "Assigned To" column/search below.
|
||||
interface EmployeeRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Shape returned by GET /api/assignments — only the fields needed to find
|
||||
// each AssetItem's current (non-returned) assignee.
|
||||
interface AssignmentRecord {
|
||||
employee_id: string;
|
||||
asset_item_id: string | null;
|
||||
returned_at: string | null;
|
||||
}
|
||||
|
||||
// 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).
|
||||
// AssetModelRecord for the full shape). category_id added 2026-08-25, used
|
||||
// to narrow the Model filter dropdown once a Category is picked -- with
|
||||
// 135 models after the 2026-08-24 real-data import, an unfiltered list is
|
||||
// unwieldy.
|
||||
interface AssetModelRecord {
|
||||
id: string;
|
||||
category_id: string;
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Shape returned by GET /api/asset-categories — only id/name, used to
|
||||
// populate the Category filter <Select> here.
|
||||
interface AssetCategoryRecord {
|
||||
id: 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
|
||||
@@ -58,32 +96,292 @@ interface PurchaseOrderRecord {
|
||||
// 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");
|
||||
// Same sentinel pattern as NO_PURCHASE_ORDER_VALUE above, for the optional
|
||||
// Area <Select> in the create form.
|
||||
const NO_AREA_VALUE = "none";
|
||||
|
||||
// Renders an ISO date(-time) string as mm/dd/yyyy, or "-" if absent.
|
||||
// UTC-based (not local time) to match how purchaseDate/warrantyEndDate are
|
||||
// actually stored -- see src/lib/assets.ts's dateString()/addMonthsClamped()
|
||||
// comments on why this app treats these as UTC-midnight values throughout,
|
||||
// not local-timezone dates.
|
||||
function formatDateMMDDYYYY(isoString: string | null): string {
|
||||
if (!isoString) return "-";
|
||||
const date = new Date(isoString);
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||
const yyyy = date.getUTCFullYear();
|
||||
return `${mm}/${dd}/${yyyy}`;
|
||||
}
|
||||
|
||||
// "Manufacturer Name" for display -- except a chunk of AssetModel rows
|
||||
// from the 2026-08-24 real-data import already have the manufacturer
|
||||
// baked into `name` (the source file's own "Model" column did this for
|
||||
// some rows, e.g. name="Logitech Group" with manufacturer="Logitech"),
|
||||
// which without this check renders as "Logitech Logitech Group". Skip the
|
||||
// manufacturer prefix when `name` already starts with it, case-insensitive.
|
||||
function formatModelLabel(manufacturer: string, name: string): string {
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const normalizedManufacturer = manufacturer.trim().toLowerCase();
|
||||
if (normalizedManufacturer && normalizedName.startsWith(normalizedManufacturer)) {
|
||||
return name;
|
||||
}
|
||||
return `${manufacturer} ${name}`;
|
||||
}
|
||||
|
||||
const ASSET_ITEMS_PAGE_SIZE = 50;
|
||||
|
||||
export default function AssetsPage() {
|
||||
const { data: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
||||
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
||||
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
||||
const { data: areas } = useApiList<AreaRecord>("/api/areas");
|
||||
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
|
||||
const { data: assignments } = useApiList<AssignmentRecord>("/api/assignments");
|
||||
|
||||
const [serialNumber, setSerialNumber] = useState("");
|
||||
const [assetTag, setAssetTag] = useState("");
|
||||
const [modelId, setModelId] = useState("");
|
||||
const [purchaseDate, setPurchaseDate] = useState("");
|
||||
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
||||
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
||||
const [areaId, setAreaId] = useState(NO_AREA_VALUE);
|
||||
const [cost, setCost] = useState("");
|
||||
const [costCurrency, setCostCurrency] = useState("");
|
||||
const [externalPoNumber, setExternalPoNumber] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
// Filter/search + pagination state (2026-08-25 -- was client-side-only
|
||||
// filtering over the full ~600+-row table; moved server-side, see
|
||||
// src/lib/assets.ts's listAssetItems() doc comment for why). searchText
|
||||
// is debounced into debouncedSearchText so typing doesn't fire a
|
||||
// request per keystroke.
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const [debouncedSearchText, setDebouncedSearchText] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState("all");
|
||||
const [filterCategoryId, setFilterCategoryId] = useState("all");
|
||||
const [filterModelId, setFilterModelId] = useState("all");
|
||||
const [filterAreaId, setFilterAreaId] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const [assetItems, setAssetItems] = useState<AssetItemRecord[]>([]);
|
||||
const [assetItemsTotal, setAssetItemsTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
function refetch() {
|
||||
setRefreshKey((k) => k + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => setDebouncedSearchText(searchText.trim()), 300);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [searchText]);
|
||||
|
||||
// A filter changing invalidates whatever page we were on -- e.g. page 3
|
||||
// of an unfiltered list may not exist at all once a filter narrows the
|
||||
// result set. Reset to page 1 rather than showing an empty/stale page.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [debouncedSearchText, filterStatus, filterCategoryId, filterModelId, filterAreaId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(page));
|
||||
params.set("pageSize", String(ASSET_ITEMS_PAGE_SIZE));
|
||||
if (filterStatus !== "all") params.set("status", filterStatus);
|
||||
if (filterCategoryId !== "all") params.set("categoryId", filterCategoryId);
|
||||
if (filterModelId !== "all") params.set("modelId", filterModelId);
|
||||
if (filterAreaId !== "all") params.set("areaId", filterAreaId);
|
||||
if (debouncedSearchText) params.set("search", debouncedSearchText);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/assets?${params.toString()}`);
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (cancelled) return;
|
||||
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";
|
||||
setError(message);
|
||||
setAssetItems([]);
|
||||
setAssetItemsTotal(0);
|
||||
return;
|
||||
}
|
||||
const parsed = body as { items?: AssetItemRecord[]; total?: number };
|
||||
setAssetItems(Array.isArray(parsed.items) ? parsed.items : []);
|
||||
setAssetItemsTotal(typeof parsed.total === "number" ? parsed.total : 0);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError("Request failed");
|
||||
setAssetItems([]);
|
||||
setAssetItemsTotal(0);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, filterStatus, filterCategoryId, filterModelId, filterAreaId, debouncedSearchText, refreshKey]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(assetItemsTotal / ASSET_ITEMS_PAGE_SIZE));
|
||||
|
||||
// modelId -> "Manufacturer Name" -- avoids showing the raw model_id UUID
|
||||
// in the table (2026-08-25 -- was previously the literal FK value).
|
||||
const modelNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const m of assetModels) {
|
||||
map.set(m.id, formatModelLabel(m.manufacturer, m.name));
|
||||
}
|
||||
return map;
|
||||
}, [assetModels]);
|
||||
|
||||
const modelCategoryById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const m of assetModels) {
|
||||
map.set(m.id, m.category_id);
|
||||
}
|
||||
return map;
|
||||
}, [assetModels]);
|
||||
|
||||
// Model dropdown options narrow to the picked Category -- with 135
|
||||
// models after the 2026-08-24 real-data import, showing all of them
|
||||
// regardless of category is unwieldy.
|
||||
const modelsForCategoryFilter = useMemo(() => {
|
||||
if (filterCategoryId === "all") return assetModels;
|
||||
return assetModels.filter((m) => m.category_id === filterCategoryId);
|
||||
}, [assetModels, filterCategoryId]);
|
||||
|
||||
function handleFilterCategoryChange(categoryId: string) {
|
||||
setFilterCategoryId(categoryId);
|
||||
// A previously-picked Model may not belong to the newly-picked
|
||||
// Category -- reset rather than silently keep filtering on a model
|
||||
// that's no longer shown in the (now-narrowed) dropdown.
|
||||
if (
|
||||
filterModelId !== "all" &&
|
||||
categoryId !== "all" &&
|
||||
modelCategoryById.get(filterModelId) !== categoryId
|
||||
) {
|
||||
setFilterModelId("all");
|
||||
}
|
||||
}
|
||||
|
||||
const areaNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of areas) {
|
||||
map.set(a.id, a.name);
|
||||
}
|
||||
return map;
|
||||
}, [areas]);
|
||||
|
||||
const employeeNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const e of employees) {
|
||||
map.set(e.id, e.name);
|
||||
}
|
||||
return map;
|
||||
}, [employees]);
|
||||
|
||||
// assetItemId -> employee name, current holder only (returned_at IS
|
||||
// NULL) -- an AssetItem can have multiple historical Assignment rows
|
||||
// over time (see Assignment model's comment), only the active one
|
||||
// matters for "who has this right now".
|
||||
const assigneeNameByAssetItemId = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const a of assignments) {
|
||||
if (a.returned_at !== null || !a.asset_item_id) continue;
|
||||
const name = employeeNameById.get(a.employee_id);
|
||||
if (name) map.set(a.asset_item_id, name);
|
||||
}
|
||||
return map;
|
||||
}, [assignments, employeeNameById]);
|
||||
|
||||
// Filtering + pagination both happen server-side now (see the effect
|
||||
// above) -- `assetItems` here is already exactly the current page's
|
||||
// rows for the active filters, nothing left to compute client-side.
|
||||
|
||||
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
|
||||
"idle"
|
||||
);
|
||||
const [importMessage, setImportMessage] = useState<string | null>(null);
|
||||
|
||||
async function handleImportChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = ""; // allow re-selecting the same file name after an error
|
||||
if (!file) return;
|
||||
|
||||
setImportStatus("uploading");
|
||||
setImportMessage(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const res = await fetch("/api/assets/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
body: text,
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
setImportStatus("error");
|
||||
setImportMessage(body.error ?? "Import thất bại");
|
||||
return;
|
||||
}
|
||||
const errorCount = (body.errors as unknown[] | undefined)?.length ?? 0;
|
||||
setImportStatus(errorCount > 0 ? "error" : "done");
|
||||
const errorSummary =
|
||||
errorCount > 0
|
||||
? " Lỗi: " +
|
||||
(body.errors as { row: number; serialNumber: string; error: string }[])
|
||||
.slice(0, 5)
|
||||
.map((e) => `dòng ${e.row} (${e.serialNumber || "?"}): ${e.error}`)
|
||||
.join("; ") +
|
||||
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
|
||||
: "";
|
||||
setImportMessage(`Tạo mới ${body.created}, cập nhật ${body.updated} asset.` + errorSummary);
|
||||
refetch();
|
||||
} catch {
|
||||
setImportStatus("error");
|
||||
setImportMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
|
||||
const hasAssetTag = assetTag.trim() !== "";
|
||||
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
||||
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
||||
const hasAreaId = areaId !== NO_AREA_VALUE;
|
||||
const hasCost = cost.trim() !== "";
|
||||
const hasCostCurrency = costCurrency.trim() !== "";
|
||||
const hasExternalPoNumber = externalPoNumber.trim() !== "";
|
||||
|
||||
if (hasCost && Number.isNaN(Number(cost))) {
|
||||
setSubmitError("Cost must be a number");
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/assets", {
|
||||
@@ -96,8 +394,13 @@ export default function AssetsPage() {
|
||||
// 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).
|
||||
...(hasAssetTag ? { assetTag } : {}),
|
||||
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
||||
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
||||
...(hasAreaId ? { areaId } : {}),
|
||||
...(hasCost ? { cost: Number(cost) } : {}),
|
||||
...(hasCostCurrency ? { costCurrency } : {}),
|
||||
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -121,10 +424,15 @@ export default function AssetsPage() {
|
||||
}
|
||||
|
||||
setSerialNumber("");
|
||||
setAssetTag("");
|
||||
setModelId("");
|
||||
setPurchaseDate("");
|
||||
setWarrantyEndDate("");
|
||||
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
||||
setAreaId(NO_AREA_VALUE);
|
||||
setCost("");
|
||||
setCostCurrency("");
|
||||
setExternalPoNumber("");
|
||||
refetch();
|
||||
} catch {
|
||||
setSubmitError("Request failed");
|
||||
@@ -138,6 +446,96 @@ export default function AssetsPage() {
|
||||
<h1 className="text-xl font-semibold">Assets</h1>
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="search">Search (serial / asset tag / assigned to)</Label>
|
||||
<Input
|
||||
id="search"
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
placeholder="e.g. DANLAP001"
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="filterStatus">Status</Label>
|
||||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||||
<SelectTrigger id="filterStatus" className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="in_stock">in_stock</SelectItem>
|
||||
<SelectItem value="in_use">in_use</SelectItem>
|
||||
<SelectItem value="repair">repair</SelectItem>
|
||||
<SelectItem value="retired">retired</SelectItem>
|
||||
<SelectItem value="lost">lost</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="filterCategory">Category</Label>
|
||||
<Select value={filterCategoryId} onValueChange={handleFilterCategoryChange}>
|
||||
<SelectTrigger id="filterCategory" className="w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{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="filterModel">Model</Label>
|
||||
<Select value={filterModelId} onValueChange={setFilterModelId}>
|
||||
<SelectTrigger id="filterModel" className="w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{modelsForCategoryFilter.map((assetModel) => (
|
||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="filterArea">Area</Label>
|
||||
<Select value={filterAreaId} onValueChange={setFilterAreaId}>
|
||||
<SelectTrigger id="filterArea" className="w-48">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
{areas.map((area) => (
|
||||
<SelectItem key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="pb-2 text-sm text-muted-foreground">
|
||||
{assetItemsTotal === 0
|
||||
? "0 assets"
|
||||
: `${(page - 1) * ASSET_ITEMS_PAGE_SIZE + 1}-${Math.min(
|
||||
page * ASSET_ITEMS_PAGE_SIZE,
|
||||
assetItemsTotal
|
||||
)} / ${assetItemsTotal} assets`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
) : error ? (
|
||||
@@ -146,32 +544,123 @@ export default function AssetsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Serial Number</TableHead>
|
||||
<TableHead>Model ID</TableHead>
|
||||
<TableHead>Asset Tag</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Area</TableHead>
|
||||
<TableHead>Assigned To</TableHead>
|
||||
<TableHead>Purchase Date</TableHead>
|
||||
<TableHead>Warranty End Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Cost</TableHead>
|
||||
<TableHead>Purchase Order ID</TableHead>
|
||||
<TableHead>Created At</TableHead>
|
||||
<TableHead>External PO Number</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assetItems.map((assetItem) => (
|
||||
{assetItems.map((assetItem, index) => (
|
||||
<TableRow key={assetItem.id}>
|
||||
<TableCell>{assetItem.id}</TableCell>
|
||||
<TableCell>{(page - 1) * ASSET_ITEMS_PAGE_SIZE + index + 1}</TableCell>
|
||||
<TableCell>{assetItem.serial_number}</TableCell>
|
||||
<TableCell>{assetItem.model_id}</TableCell>
|
||||
<TableCell>{assetItem.purchase_date}</TableCell>
|
||||
<TableCell>{assetItem.warranty_end_date ?? "-"}</TableCell>
|
||||
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
||||
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
||||
<TableCell>
|
||||
{assetItem.area_id ? (areaNameById.get(assetItem.area_id) ?? "-") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>{assigneeNameByAssetItemId.get(assetItem.id) ?? "-"}</TableCell>
|
||||
<TableCell>{formatDateMMDDYYYY(assetItem.purchase_date)}</TableCell>
|
||||
<TableCell>{formatDateMMDDYYYY(assetItem.warranty_end_date)}</TableCell>
|
||||
<TableCell>{assetItem.status}</TableCell>
|
||||
<TableCell>
|
||||
{assetItem.cost !== null
|
||||
? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
||||
<TableCell>{assetItem.created_at}</TableCell>
|
||||
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{!loading && !error && assetItemsTotal > 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Page {page} / {totalPages}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="flex max-w-2xl flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold">Import / Export CSV</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cột nhận diện theo tên (không phân biệt hoa/thường): <code>serial number</code>,{" "}
|
||||
<code>manufacturer</code>, <code>model</code> (phải khớp Asset Model đã có sẵn ở{" "}
|
||||
<a href="/asset-models" className="underline">
|
||||
/asset-models
|
||||
</a>
|
||||
), <code>purchase date</code> (không bắt buộc -- cũng nhận <code>order received</code>).
|
||||
Tuỳ chọn: <code>asset tag</code>, <code>warranty end date</code> (bỏ trống thì tự tính
|
||||
từ warranty của model), <code>status</code>/<code>state</code> (nhận{" "}
|
||||
<code>In use</code> → in_use, <code>In stock</code> → in_stock, bỏ trống thì mặc định
|
||||
in_stock), <code>cost</code>, <code>cost currency</code>,{" "}
|
||||
<code>po number</code> (text tự do, khác <code>purchase order id</code> là UUID trong
|
||||
hệ thống), <code>area</code> (tên khu vực -- tự tạo mới nếu chưa có, cũng nhận{" "}
|
||||
<code>stockroom</code>). Dòng có <code>asset tag</code> hoặc <code>serial number</code>{" "}
|
||||
đã tồn tại sẽ <b>cập nhật</b> thay vì báo lỗi trùng -- import lại file mới để đồng bộ dữ
|
||||
liệu.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<a href="/api/assets/export">
|
||||
<Button type="button" variant="outline">
|
||||
Export CSV
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={handleImportChange}
|
||||
disabled={importStatus === "uploading"}
|
||||
className="hidden"
|
||||
id="assets-csv-input"
|
||||
/>
|
||||
<Button asChild>
|
||||
<label htmlFor="assets-csv-input" className="cursor-pointer">
|
||||
{importStatus === "uploading" ? "Đang import..." : "Import CSV"}
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
{importMessage ? (
|
||||
<p
|
||||
className={
|
||||
importStatus === "error" ? "text-sm text-destructive" : "text-sm text-foreground"
|
||||
}
|
||||
>
|
||||
{importMessage}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="flex max-w-sm flex-col gap-4">
|
||||
@@ -188,6 +677,15 @@ export default function AssetsPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="assetTag">Asset Tag (optional)</Label>
|
||||
<Input
|
||||
id="assetTag"
|
||||
value={assetTag}
|
||||
onChange={(event) => setAssetTag(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="modelId">Model</Label>
|
||||
<Select value={modelId} onValueChange={setModelId} required>
|
||||
@@ -197,7 +695,7 @@ export default function AssetsPage() {
|
||||
<SelectContent>
|
||||
{assetModels.map((assetModel) => (
|
||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||
{assetModel.manufacturer} {assetModel.name}
|
||||
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -242,6 +740,55 @@ export default function AssetsPage() {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="areaId">Area (optional)</Label>
|
||||
<Select value={areaId} onValueChange={setAreaId}>
|
||||
<SelectTrigger id="areaId" className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
|
||||
{areas.map((area) => (
|
||||
<SelectItem key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="cost">Cost (optional)</Label>
|
||||
<Input
|
||||
id="cost"
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={cost}
|
||||
onChange={(event) => setCost(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="costCurrency">Cost Currency (optional)</Label>
|
||||
<Input
|
||||
id="costCurrency"
|
||||
value={costCurrency}
|
||||
onChange={(event) => setCostCurrency(event.target.value)}
|
||||
placeholder="e.g. VND, USD"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="externalPoNumber">External PO Number (optional)</Label>
|
||||
<Input
|
||||
id="externalPoNumber"
|
||||
value={externalPoNumber}
|
||||
onChange={(event) => setExternalPoNumber(event.target.value)}
|
||||
placeholder="PO number from an external system, if any"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
||||
|
||||
<Button type="submit" disabled={submitting}>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
// Pre-move CSV upload -- see src/lib/expected-assets.ts's
|
||||
// parseExpectedAssetsCsv() for the accepted column names and
|
||||
// prisma/schema.prisma's ExpectedAsset comment for the "why" (2026-08-21
|
||||
// decision: OCR wasn't reliable/fast enough for ~300-400 assets, but
|
||||
// re-typing a list that already exists in Excel is still wasted effort --
|
||||
// this feeds the bot's "🔍 Tìm trong list" picker instead). Plain
|
||||
// client-side File.text() + raw-text POST, no multipart/FormData -- these
|
||||
// files are small (hundreds of rows) and this repo has no existing
|
||||
// multipart-upload plumbing to reuse.
|
||||
export default function ImportExpectedAssetsPage() {
|
||||
const [status, setStatus] = useState<"idle" | "uploading" | "done" | "error">("idle");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = ""; // allow re-selecting the same file name after an error
|
||||
if (!file) return;
|
||||
|
||||
setStatus("uploading");
|
||||
setMessage(null);
|
||||
try {
|
||||
const text = await file.text();
|
||||
const res = await fetch("/api/expected-assets/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "text/csv" },
|
||||
body: text,
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setMessage(body.error ?? "Import thất bại");
|
||||
return;
|
||||
}
|
||||
setStatus("done");
|
||||
setMessage(`Đã import ${body.imported} dòng.`);
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl space-y-4 p-6">
|
||||
<h1 className="text-xl font-semibold">Import danh sách asset đã có (CSV)</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Upload file CSV liệt kê asset đã biết từ trước (asset tag, model, khu vực/người giữ cũ).
|
||||
Trong lúc đi kiểm kê, bot Telegram sẽ có nút "🔍 Tìm trong list" để chọn từ đây
|
||||
thay vì gõ tay, và tự gợi ý khu vực/người giữ cũ.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Cột nhận diện theo tên (không phân biệt hoa/thường, thứ tự tuỳ ý): <code>asset tag</code>,{" "}
|
||||
<code>model</code>, <code>serial</code>, <code>area</code> (khu vực cũ),{" "}
|
||||
<code>assignee</code> (người giữ cũ). Dòng đầu tiên phải là header.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={handleFileChange}
|
||||
disabled={status === "uploading"}
|
||||
className="hidden"
|
||||
id="csv-input"
|
||||
/>
|
||||
<Button asChild>
|
||||
<label htmlFor="csv-input" className="cursor-pointer">
|
||||
{status === "uploading" ? "Đang import..." : "Chọn file CSV"}
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<p className={status === "error" ? "text-sm text-destructive" : "text-sm text-foreground"}>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ interface VendorRecord {
|
||||
// assetItemStatusValues constant (not re-derived from an API call, this is
|
||||
// a closed set already known client-side, same precedent as licenses
|
||||
// page's licenseType/perpetual|subscription <Select>).
|
||||
const ASSET_ITEM_STATUS_VALUES = ["in_stock", "assigned", "repair", "retired"] as const;
|
||||
const ASSET_ITEM_STATUS_VALUES = ["in_stock", "in_use", "repair", "retired"] as const;
|
||||
|
||||
// Radix <Select.Item> can't take an empty-string value, so "no vendor" is
|
||||
// represented by this sentinel in the form state and translated to "omit
|
||||
|
||||
@@ -23,6 +23,7 @@ const ENTITY_LINKS = [
|
||||
{ href: "/assignments", label: "Assignments" },
|
||||
{ href: "/maintenance-records", label: "Maintenance Records" },
|
||||
{ href: "/inventory-scans", label: "Inventory Scans (Move)" },
|
||||
{ href: "/inventory-scans/import", label: "Import Expected Assets" },
|
||||
] as const;
|
||||
|
||||
export function Nav() {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface AssetModelRecord {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Record<string, unknown>;
|
||||
warranty_months: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -17,6 +18,7 @@ export interface CreateAssetModelInput {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Record<string, unknown>;
|
||||
warrantyMonths?: number | null;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -57,6 +59,14 @@ const createAssetModelSchema = z.object({
|
||||
})
|
||||
.optional()
|
||||
.default({}),
|
||||
// Optional standard warranty length -- see schema.prisma's
|
||||
// AssetModel.warrantyMonths comment for what this drives.
|
||||
warrantyMonths: z
|
||||
.number({ invalid_type_error: "warrantyMonths must be a positive integer" })
|
||||
.int("warrantyMonths must be a positive integer")
|
||||
.positive("warrantyMonths must be a positive integer")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -71,6 +81,7 @@ export function validateCreateAssetModelInput(input: {
|
||||
manufacturer?: unknown;
|
||||
name?: unknown;
|
||||
specs?: unknown;
|
||||
warrantyMonths?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetModelSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -86,6 +97,7 @@ function toRecord(row: {
|
||||
manufacturer: string;
|
||||
name: string;
|
||||
specs: Prisma.JsonValue;
|
||||
warrantyMonths: number | null;
|
||||
createdAt: Date;
|
||||
}): AssetModelRecord {
|
||||
return {
|
||||
@@ -94,6 +106,7 @@ function toRecord(row: {
|
||||
manufacturer: row.manufacturer,
|
||||
name: row.name,
|
||||
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||
warranty_months: row.warrantyMonths,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -201,6 +214,7 @@ export async function createAssetModel(
|
||||
// is safe here since the Zod schema already guarantees JSON-shaped
|
||||
// values.
|
||||
specs: input.specs as Prisma.InputJsonValue,
|
||||
warrantyMonths: input.warrantyMonths ?? null,
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
|
||||
+631
-16
@@ -1,26 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
import { splitCsvLine, splitCsvLines, normalizeHeader, csvField } from "./csv";
|
||||
import { createOrGetArea } from "./areas";
|
||||
|
||||
export type AssetItemStatus = "in_stock" | "assigned" | "repair" | "retired";
|
||||
export type AssetItemStatus = "in_stock" | "in_use" | "repair" | "retired" | "lost";
|
||||
|
||||
export interface AssetItemRecord {
|
||||
id: string;
|
||||
serial_number: string;
|
||||
asset_tag: string | null;
|
||||
model_id: string;
|
||||
purchase_date: string;
|
||||
purchase_date: string | null;
|
||||
warranty_end_date: string | null;
|
||||
status: AssetItemStatus;
|
||||
cost: number | null;
|
||||
cost_currency: string | null;
|
||||
area_id: string | null;
|
||||
purchase_order_id: string | null;
|
||||
// Free-text PO number from an external system -- NOT purchase_order_id
|
||||
// above (a real FK). See schema.prisma's AssetItem.externalPoNumber
|
||||
// comment.
|
||||
external_po_number: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAssetItemInput {
|
||||
serialNumber: string;
|
||||
assetTag?: string | null;
|
||||
modelId: string;
|
||||
purchaseDate: Date;
|
||||
// Optional as of 2026-08-24 (was required) -- some bulk-imported legacy
|
||||
// rows have no known purchase date at all. The manual /assets create
|
||||
// form still marks its date input required (a NEW purchase should
|
||||
// always have a known date); this only relaxes the underlying type for
|
||||
// historical/imported data. See schema.prisma's AssetItem.purchaseDate
|
||||
// comment.
|
||||
purchaseDate?: Date;
|
||||
warrantyEndDate?: Date;
|
||||
cost?: number | null;
|
||||
costCurrency?: string | null;
|
||||
areaId?: string | null;
|
||||
purchaseOrderId?: string | null;
|
||||
externalPoNumber?: string | null;
|
||||
// Only set by importAssetItems() (bulk-loading already-deployed
|
||||
// inventory from a real export -- see docs/asset-license-management.md's
|
||||
// 2026-08-24 section). The manual /assets create form never sends this,
|
||||
// so a plain createAssetItem() call keeps defaulting to "in_stock" same
|
||||
// as before this field existed -- see validateCreateAssetItemInput's
|
||||
// comment for why that stays the rule for *new* purchases entered one at
|
||||
// a time.
|
||||
status?: AssetItemStatus;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -47,6 +76,10 @@ function dateString(message: string) {
|
||||
|
||||
const createAssetItemSchema = z.object({
|
||||
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
||||
assetTag: z
|
||||
.string({ invalid_type_error: "assetTag must be a string" })
|
||||
.optional()
|
||||
.nullable(),
|
||||
// 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
|
||||
@@ -56,7 +89,7 @@ const createAssetItemSchema = z.object({
|
||||
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 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
|
||||
// every AssetItem goes through a formal PO). Only a UUID *format* check
|
||||
@@ -69,23 +102,52 @@ const createAssetItemSchema = z.object({
|
||||
.uuid("purchaseOrderId must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
cost: z
|
||||
.number({ invalid_type_error: "cost must be a non-negative number" })
|
||||
.nonnegative("cost must be a non-negative number")
|
||||
.optional()
|
||||
.nullable(),
|
||||
costCurrency: z
|
||||
.string({ invalid_type_error: "costCurrency must be a string" })
|
||||
.optional()
|
||||
.nullable(),
|
||||
externalPoNumber: z
|
||||
.string({ invalid_type_error: "externalPoNumber must be a string" })
|
||||
.optional()
|
||||
.nullable(),
|
||||
areaId: z
|
||||
.string({ invalid_type_error: "areaId must be a valid UUID string" })
|
||||
.uuid("areaId must be a valid UUID string")
|
||||
.optional()
|
||||
.nullable(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
|
||||
* non-empty string, modelId must be a valid UUID string, purchaseDate must
|
||||
* be a valid date string, warrantyEndDate is optional but must be a valid
|
||||
* date string if present. purchaseOrderId is optional/nullable but must be
|
||||
* a valid UUID if present. status is not accepted here — new assets always
|
||||
* start "in_stock" (same pattern as Request always starting "Draft"), later
|
||||
* states are set via other operations, not at creation.
|
||||
* non-empty string, assetTag is optional/nullable free text (no format
|
||||
* check -- company tag schemes vary), modelId must be a valid UUID string,
|
||||
* purchaseDate must be a valid date string, warrantyEndDate is optional
|
||||
* but must be a valid date string if present. purchaseOrderId is
|
||||
* optional/nullable but must be a valid UUID if present. status is not
|
||||
* accepted here — new assets entered one at a time via the manual /assets
|
||||
* form always start "in_stock" (same pattern as Request always starting
|
||||
* "Draft"). importAssetItems() below is the one exception: it calls
|
||||
* createAssetItem() directly (bypassing this validator) with a real status
|
||||
* resolved from the import file, because it's loading already-deployed
|
||||
* inventory, not new purchases -- see docs/asset-license-management.md's
|
||||
* 2026-08-24 section.
|
||||
*/
|
||||
export function validateCreateAssetItemInput(input: {
|
||||
serialNumber?: unknown;
|
||||
assetTag?: unknown;
|
||||
modelId?: unknown;
|
||||
purchaseDate?: unknown;
|
||||
warrantyEndDate?: unknown;
|
||||
purchaseOrderId?: unknown;
|
||||
cost?: unknown;
|
||||
costCurrency?: unknown;
|
||||
externalPoNumber?: unknown;
|
||||
areaId?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createAssetItemSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -98,21 +160,31 @@ export function validateCreateAssetItemInput(input: {
|
||||
function toRecord(row: {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
assetTag: string | null;
|
||||
modelId: string;
|
||||
purchaseDate: Date;
|
||||
purchaseDate: Date | null;
|
||||
warrantyEndDate: Date | null;
|
||||
status: AssetItemStatus;
|
||||
cost: Prisma.Decimal | null;
|
||||
costCurrency: string | null;
|
||||
areaId: string | null;
|
||||
purchaseOrderId: string | null;
|
||||
externalPoNumber: string | null;
|
||||
createdAt: Date;
|
||||
}): AssetItemRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
serial_number: row.serialNumber,
|
||||
asset_tag: row.assetTag,
|
||||
model_id: row.modelId,
|
||||
purchase_date: row.purchaseDate.toISOString(),
|
||||
purchase_date: row.purchaseDate ? row.purchaseDate.toISOString() : null,
|
||||
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||
status: row.status,
|
||||
cost: row.cost ? row.cost.toNumber() : null,
|
||||
cost_currency: row.costCurrency,
|
||||
area_id: row.areaId,
|
||||
purchase_order_id: row.purchaseOrderId,
|
||||
external_po_number: row.externalPoNumber,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -136,38 +208,581 @@ export type CreateAssetItemResult =
|
||||
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||
* same model.
|
||||
*/
|
||||
// Adds `months` calendar months to `date`, clamping to the last day of the
|
||||
// target month rather than letting JS's native Date rollover push into the
|
||||
// month after (e.g. Jan 31 + 1 month must land on Feb 28/29, not Mar
|
||||
// 2/3 -- `date.setMonth()` alone does the latter). UTC throughout: Prisma
|
||||
// @db.Date fields round-trip as UTC-midnight Dates (see dateString()
|
||||
// above), and doing this in UTC avoids the host server's local timezone
|
||||
// shifting the day near midnight.
|
||||
function addMonthsClamped(date: Date, months: number): Date {
|
||||
const targetMonthIndex = date.getUTCMonth() + months;
|
||||
const daysInTargetMonth = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), targetMonthIndex + 1, 0)
|
||||
).getUTCDate();
|
||||
return new Date(
|
||||
Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
targetMonthIndex,
|
||||
Math.min(date.getUTCDate(), daysInTargetMonth)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAssetItem(
|
||||
input: CreateAssetItemInput
|
||||
): Promise<CreateAssetItemResult> {
|
||||
try {
|
||||
// warrantyEndDate defaults from the model's standard warranty term
|
||||
// (AssetModel.warrantyMonths) when the caller doesn't supply one
|
||||
// explicitly -- see schema.prisma's AssetModel.warrantyMonths comment
|
||||
// for why this stays a suggestion (still overridable per item) rather
|
||||
// than a hard-computed column. A modelId that doesn't exist is left for
|
||||
// the insert below's own P2003 handling, same as before this lookup was
|
||||
// added -- this just also opportunistically uses the model row if it's
|
||||
// there.
|
||||
let warrantyEndDate = input.warrantyEndDate ?? null;
|
||||
if (!warrantyEndDate && input.purchaseDate) {
|
||||
const model = await prisma.assetModel.findUnique({ where: { id: input.modelId } });
|
||||
if (model?.warrantyMonths) {
|
||||
warrantyEndDate = addMonthsClamped(input.purchaseDate, model.warrantyMonths);
|
||||
}
|
||||
}
|
||||
|
||||
const created = await prisma.assetItem.create({
|
||||
data: {
|
||||
serialNumber: input.serialNumber,
|
||||
assetTag: input.assetTag ?? null,
|
||||
modelId: input.modelId,
|
||||
purchaseDate: input.purchaseDate,
|
||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
||||
purchaseDate: input.purchaseDate ?? null,
|
||||
warrantyEndDate,
|
||||
cost: input.cost ?? null,
|
||||
costCurrency: input.costCurrency ?? null,
|
||||
areaId: input.areaId ?? null,
|
||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||
externalPoNumber: input.externalPoNumber ?? null,
|
||||
...(input.status ? { status: input.status } : {}),
|
||||
},
|
||||
});
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
// serialNumber and assetTag are both unique now -- error.meta.target
|
||||
// says which constraint actually fired.
|
||||
const target = Array.isArray(error.meta?.target) ? error.meta.target.join(",") : "";
|
||||
if (target.includes("asset_tag")) {
|
||||
return { ok: false, error: "assetTag already exists" };
|
||||
}
|
||||
return { ok: false, error: "serialNumber already exists" };
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
// Three FKs can fail here now (modelId, purchaseOrderId, areaId) --
|
||||
// field_name says which constraint actually fired.
|
||||
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" };
|
||||
}
|
||||
if (fieldName.includes("area")) {
|
||||
return { ok: false, error: "areaId does not reference an existing area" };
|
||||
}
|
||||
return { ok: false, error: "modelId does not reference an existing asset model" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAssetItems(): Promise<AssetItemRecord[]> {
|
||||
export interface ListAssetItemsOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: AssetItemStatus;
|
||||
modelId?: string;
|
||||
categoryId?: string;
|
||||
areaId?: string;
|
||||
// Matches serialNumber, assetTag, or the name of the AssetItem's current
|
||||
// (non-returned) assignee -- via the assignments relation, not a
|
||||
// separate query, so this stays one round trip.
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ListAssetItemsResult {
|
||||
items: AssetItemRecord[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
|
||||
/**
|
||||
* Lists AssetItems with server-side filtering + pagination (2026-08-25 --
|
||||
* was a bare unpaginated findMany(); at ~600+ rows post the 2026-08-24
|
||||
* real-data import, shipping the whole table to the client on every page
|
||||
* load was wasteful, and /assets's client-side filtering couldn't scale
|
||||
* past "the whole table is already in memory" anyway). Filtering and
|
||||
* pagination both happen in the same WHERE clause -- `total` reflects the
|
||||
* FILTERED count, not the table's overall size, so pagination controls
|
||||
* built from it are correct regardless of which filters are active.
|
||||
*/
|
||||
export async function listAssetItems(
|
||||
options: ListAssetItemsOptions = {}
|
||||
): Promise<ListAssetItemsResult> {
|
||||
const page = options.page && options.page > 0 ? Math.floor(options.page) : 1;
|
||||
const pageSize =
|
||||
options.pageSize && options.pageSize > 0 ? Math.floor(options.pageSize) : DEFAULT_PAGE_SIZE;
|
||||
|
||||
const where: Prisma.AssetItemWhereInput = {};
|
||||
if (options.status) where.status = options.status;
|
||||
if (options.modelId) where.modelId = options.modelId;
|
||||
if (options.categoryId) where.model = { categoryId: options.categoryId };
|
||||
if (options.areaId) where.areaId = options.areaId;
|
||||
if (options.search && options.search.trim() !== "") {
|
||||
const q = options.search.trim();
|
||||
where.OR = [
|
||||
{ serialNumber: { contains: q, mode: "insensitive" } },
|
||||
{ assetTag: { contains: q, mode: "insensitive" } },
|
||||
{
|
||||
assignments: {
|
||||
some: { returnedAt: null, employee: { name: { contains: q, mode: "insensitive" } } },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
prisma.assetItem.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.assetItem.count({ where }),
|
||||
]);
|
||||
|
||||
return { items: rows.map(toRecord), total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// CSV import/export (2026-08-23) -- bulk-load a spreadsheet of existing
|
||||
// assets, and get the current AssetItem table back out as one. Import
|
||||
// resolves modelId from human-readable manufacturer+model text (matching
|
||||
// src/lib/expected-assets.ts's precedent of not requiring raw UUIDs in a
|
||||
// hand-edited spreadsheet) rather than accepting modelId directly --
|
||||
// export emits the same manufacturer+model columns so a round-trip
|
||||
// export -> edit -> re-import works without any UUID lookup by hand.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
export interface ImportAssetItemRow {
|
||||
serialNumber: string;
|
||||
assetTag: string | null;
|
||||
manufacturer: string;
|
||||
modelName: string;
|
||||
purchaseDate: string;
|
||||
warrantyEndDate: string | null;
|
||||
purchaseOrderId: string | null;
|
||||
// Raw text from the CSV's status-ish column (e.g. a real ServiceNow
|
||||
// export's "State": "In use"/"In stock") -- translated to a real
|
||||
// AssetItemStatus by statusFromImportText() in importAssetItems() below,
|
||||
// not here (parsing stays dumb string-in/string-out, same as every other
|
||||
// field on this row).
|
||||
status: string | null;
|
||||
// Raw text -- parsed to a number by importAssetItems() below (also
|
||||
// treats common placeholder junk like "Not available"/"Pending"/"N/A" as
|
||||
// absent, same spirit as externalPoNumber below -- real exports are
|
||||
// messy).
|
||||
cost: string | null;
|
||||
costCurrency: string | null;
|
||||
// NOT the same as purchaseOrderId above -- see schema.prisma's
|
||||
// AssetItem.externalPoNumber comment.
|
||||
externalPoNumber: string | null;
|
||||
// Area name, resolved/created via src/lib/areas.ts's createOrGetArea()
|
||||
// (idempotent by name, same as the bot's Area picker) -- not a raw UUID
|
||||
// column like the other FK-ish fields here, since a hand-edited
|
||||
// spreadsheet realistically has area names, not this app's internal ids.
|
||||
areaName: string | null;
|
||||
}
|
||||
|
||||
const ASSET_ITEM_HEADER_ALIASES: Record<keyof ImportAssetItemRow, string[]> = {
|
||||
serialNumber: ["serialnumber", "serial number", "serial", "sn"],
|
||||
assetTag: ["assettag", "asset tag", "tag", "matag"],
|
||||
manufacturer: ["manufacturer", "brand", "hang", "hang san xuat"],
|
||||
modelName: ["model", "modelname", "model name", "ten model"],
|
||||
purchaseDate: [
|
||||
"purchasedate",
|
||||
"purchase date",
|
||||
"ngaymua",
|
||||
"ngay mua",
|
||||
// "Order received" is the closest proxy for purchase date in a
|
||||
// ServiceNow-style export -- there's no cleaner "date we bought this"
|
||||
// field there (see docs/asset-license-management.md's 2026-08-24
|
||||
// section for why "Assigned Date" is NOT used here -- that's when it
|
||||
// was handed to whoever has it now, a different date entirely).
|
||||
"order received",
|
||||
],
|
||||
warrantyEndDate: [
|
||||
"warrantyenddate",
|
||||
"warranty end date",
|
||||
"warranty",
|
||||
"baohanh",
|
||||
"bao hanh",
|
||||
"warranty expiration",
|
||||
],
|
||||
purchaseOrderId: ["purchaseorderid", "purchase order id", "po", "poid", "po id"],
|
||||
status: ["status", "state", "tinh trang"],
|
||||
cost: ["cost", "gia", "gia tri"],
|
||||
costCurrency: ["costcurrency", "cost currency", "currency", "don vi tien te"],
|
||||
// "po number" is deliberately distinct from purchaseOrderId's aliases
|
||||
// above ("po"/"poid"/"po id") -- matches a real ServiceNow export's
|
||||
// actual "PO number" column name 1:1, and keeps the two fields
|
||||
// unambiguous from each other by header name alone.
|
||||
externalPoNumber: ["ponumber", "po number", "external po number", "so po"],
|
||||
areaName: ["area", "khu vuc", "location", "stockroom", "vi tri"],
|
||||
};
|
||||
|
||||
export type ParseAssetItemsCsvResult =
|
||||
| { ok: true; rows: ImportAssetItemRow[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Parses a CSV export into rows ready for importAssetItems(). Unlike
|
||||
* src/lib/expected-assets.ts's parser (a staging/lookup table with no hard
|
||||
* NOT NULL requirements), the fields an AssetItem actually needs at
|
||||
* creation -- serialNumber/manufacturer/modelName (purchaseDate is
|
||||
* optional as of 2026-08-24, see schema.prisma's AssetItem.purchaseDate
|
||||
* comment) -- must all be present as recognized header columns, or this
|
||||
* fails fast on the whole file rather than silently producing rows
|
||||
* importAssetItems() can never create. Individual rows with one of those
|
||||
* required fields blank still aren't rejected here though -- that's
|
||||
* importAssetItems()'s per-row error, so a
|
||||
* typo in row 200 doesn't block rows 1-199.
|
||||
*/
|
||||
export function parseAssetItemsCsv(text: string): ParseAssetItemsCsvResult {
|
||||
const lines = splitCsvLines(text);
|
||||
if (lines.length === 0) {
|
||||
return { ok: false, error: "CSV file is empty" };
|
||||
}
|
||||
|
||||
const headerCells = splitCsvLine(lines[0]).map(normalizeHeader);
|
||||
const columnIndex: Partial<Record<keyof ImportAssetItemRow, number>> = {};
|
||||
for (const [field, aliases] of Object.entries(ASSET_ITEM_HEADER_ALIASES) as [
|
||||
keyof ImportAssetItemRow,
|
||||
string[],
|
||||
][]) {
|
||||
const idx = headerCells.findIndex((h) => aliases.includes(h));
|
||||
if (idx !== -1) {
|
||||
columnIndex[field] = idx;
|
||||
}
|
||||
}
|
||||
// purchaseDate is NOT required here (as of 2026-08-24 -- see
|
||||
// schema.prisma's AssetItem.purchaseDate comment): some legacy rows in
|
||||
// a real bulk import genuinely have no known purchase date, and that's
|
||||
// fine to import with a null purchaseDate rather than skipping the row.
|
||||
const required: (keyof ImportAssetItemRow)[] = ["serialNumber", "manufacturer", "modelName"];
|
||||
const missing = required.filter((field) => columnIndex[field] === undefined);
|
||||
if (missing.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
`Missing required column(s) in header: ${missing.join(", ")}. ` +
|
||||
"Recognized names: " +
|
||||
Object.values(ASSET_ITEM_HEADER_ALIASES).flat().join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ImportAssetItemRow[] = [];
|
||||
for (const line of lines.slice(1)) {
|
||||
const cells = splitCsvLine(line);
|
||||
const get = (field: keyof ImportAssetItemRow): string => {
|
||||
const idx = columnIndex[field];
|
||||
if (idx === undefined) return "";
|
||||
return (cells[idx] ?? "").trim();
|
||||
};
|
||||
const row: ImportAssetItemRow = {
|
||||
serialNumber: get("serialNumber"),
|
||||
assetTag: get("assetTag") || null,
|
||||
manufacturer: get("manufacturer"),
|
||||
modelName: get("modelName"),
|
||||
purchaseDate: get("purchaseDate"),
|
||||
warrantyEndDate: get("warrantyEndDate") || null,
|
||||
purchaseOrderId: get("purchaseOrderId") || null,
|
||||
status: get("status") || null,
|
||||
cost: get("cost") || null,
|
||||
costCurrency: get("costCurrency") || null,
|
||||
externalPoNumber: get("externalPoNumber") || null,
|
||||
areaName: get("areaName") || null,
|
||||
};
|
||||
if (!row.serialNumber && !row.manufacturer && !row.modelName && !row.purchaseDate) {
|
||||
continue; // fully blank row
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return { ok: true, rows };
|
||||
}
|
||||
|
||||
export interface ImportAssetItemsResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
errors: { row: number; serialNumber: string; error: string }[];
|
||||
}
|
||||
|
||||
// Maps free-text status values from a real export (e.g. ServiceNow's
|
||||
// "State" column: "In use"/"In stock") onto this app's AssetItemStatus
|
||||
// enum -- plus this app's own enum values, in case a re-exported CSV
|
||||
// round-trips one back in. "In use" -> "in_use" is a simplification: it
|
||||
// doesn't require (or create) a matching Assignment row, just reflects
|
||||
// that the item is deployed rather than sitting in stock -- see
|
||||
// docs/asset-license-management.md's 2026-08-24 section for why linking
|
||||
// actual Employee assignments is explicitly out of scope for this import.
|
||||
const IMPORT_STATUS_MAP: Record<string, AssetItemStatus> = {
|
||||
"in use": "in_use",
|
||||
in_use: "in_use",
|
||||
assigned: "in_use", // backward-compat: pre-2026-08-25 exports/CSVs said "assigned"
|
||||
"in stock": "in_stock",
|
||||
in_stock: "in_stock",
|
||||
repair: "repair",
|
||||
"in repair": "repair",
|
||||
retired: "retired",
|
||||
disposed: "retired",
|
||||
lost: "lost",
|
||||
missing: "lost",
|
||||
};
|
||||
|
||||
function statusFromImportText(text: string): AssetItemStatus | null {
|
||||
return IMPORT_STATUS_MAP[text.trim().toLowerCase()] ?? null;
|
||||
}
|
||||
|
||||
// Real external exports (e.g. the ServiceNow-style ITAM report this was
|
||||
// built against) fill blank PO-number-ish cells with placeholder junk
|
||||
// instead of leaving them empty -- treat these as "no value" rather than
|
||||
// storing the literal junk text.
|
||||
const PLACEHOLDER_VALUES = new Set(["not available", "pending", "n/a", "na", "none", "-"]);
|
||||
|
||||
function cleanImportText(text: string | null): string | null {
|
||||
if (!text) return null;
|
||||
return PLACEHOLDER_VALUES.has(text.trim().toLowerCase()) ? null : text;
|
||||
}
|
||||
|
||||
// Cost cells can carry currency symbols/thousands separators depending on
|
||||
// how the source spreadsheet formatted them (e.g. "1,234.56" or "1234").
|
||||
// Strips anything that isn't a digit, minus sign, or decimal point before
|
||||
// parsing -- simple on purpose, this isn't trying to be a general money
|
||||
// parser, just tolerant of the punctuation Excel commonly adds.
|
||||
function parseImportCost(text: string): number | null {
|
||||
const cleaned = cleanImportText(text);
|
||||
if (!cleaned) return null;
|
||||
const numeric = Number(cleaned.replace(/[^0-9.-]/g, ""));
|
||||
return Number.isNaN(numeric) ? null : numeric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates one AssetItem per row via createAssetItem(), OR updates it if a
|
||||
* row with the same assetTag or serialNumber already exists (2026-08-24 --
|
||||
* added so re-uploading a refreshed export, e.g. the company's periodic
|
||||
* ServiceNow re-sync, updates existing rows instead of failing every one
|
||||
* of them on a uniqueness conflict). Matching prefers assetTag when
|
||||
* present (the more stable real-world identifier -- a device's serial
|
||||
* rarely changes but its owner/status might get re-tagged), falling back
|
||||
* to serialNumber. Update only touches fields this row actually carries a
|
||||
* (non-empty) value for -- a blank cell means "no change", not "clear this
|
||||
* field", so partial re-syncs (e.g. a cost-only refresh with no other
|
||||
* columns) don't wipe out data the file simply didn't include this time.
|
||||
* warrantyEndDate is the one exception: it's only touched on UPDATE when
|
||||
* explicitly present in the row (never silently recomputed from
|
||||
* warrantyMonths the way CREATE does), so a manually-corrected warranty
|
||||
* date survives a re-sync.
|
||||
*
|
||||
* Reports per-row failures instead of aborting the whole import on the
|
||||
* first bad row -- matches parseAssetItemsCsv()'s "one typo shouldn't cost
|
||||
* 199 other rows" rationale. `row` in the result is the spreadsheet row
|
||||
* number (header = row 1), for the operator to find and fix in their
|
||||
* original file. Sequential, not batched -- fine at this app's actual
|
||||
* scale (hundreds of rows, see docs/asset-license-management.md), and
|
||||
* keeps each row's error attributable instead of one opaque bulk failure.
|
||||
*/
|
||||
export async function importAssetItems(rows: ImportAssetItemRow[]): Promise<ImportAssetItemsResult> {
|
||||
const errors: ImportAssetItemsResult["errors"] = [];
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
const row = rows[i];
|
||||
const rowNumber = i + 2; // +1 for 0-index, +1 for the header row
|
||||
|
||||
if (!row.serialNumber || !row.manufacturer || !row.modelName) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
serialNumber: row.serialNumber,
|
||||
error: "Missing serialNumber/manufacturer/model",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// purchaseDate is optional (2026-08-24) -- a row with none imports
|
||||
// fine with purchaseDate left null (and consequently no
|
||||
// warrantyMonths auto-fill, which needs a purchase date to add
|
||||
// months to). A row that HAS a purchaseDate value still has to be a
|
||||
// parseable date though, same as before.
|
||||
if (row.purchaseDate && Number.isNaN(Date.parse(row.purchaseDate))) {
|
||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Invalid purchaseDate: "${row.purchaseDate}"` });
|
||||
continue;
|
||||
}
|
||||
let warrantyEndDate: Date | undefined;
|
||||
if (row.warrantyEndDate) {
|
||||
if (Number.isNaN(Date.parse(row.warrantyEndDate))) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
serialNumber: row.serialNumber,
|
||||
error: `Invalid warrantyEndDate: "${row.warrantyEndDate}"`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
warrantyEndDate = new Date(row.warrantyEndDate);
|
||||
}
|
||||
let status: AssetItemStatus | undefined;
|
||||
if (row.status) {
|
||||
const mapped = statusFromImportText(row.status);
|
||||
if (!mapped) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
serialNumber: row.serialNumber,
|
||||
error: `Unrecognized status: "${row.status}" (expected e.g. In use, In stock, Repair, Retired)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
status = mapped;
|
||||
}
|
||||
const cost = row.cost ? parseImportCost(row.cost) : null;
|
||||
const costCurrency = cleanImportText(row.costCurrency);
|
||||
const externalPoNumber = cleanImportText(row.externalPoNumber);
|
||||
|
||||
// Resolved via createOrGetArea() (idempotent by name -- same helper
|
||||
// the bot's Area picker uses), not a raw areaId column: a hand-edited
|
||||
// spreadsheet realistically has area *names*, and this also means a
|
||||
// never-before-seen area name just gets created rather than rejecting
|
||||
// the row.
|
||||
let areaId: string | undefined;
|
||||
const areaName = cleanImportText(row.areaName);
|
||||
if (areaName) {
|
||||
const areaResult = await createOrGetArea({ name: areaName });
|
||||
if (!areaResult.ok) {
|
||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: `Area error: ${areaResult.error}` });
|
||||
continue;
|
||||
}
|
||||
areaId = areaResult.value.id;
|
||||
}
|
||||
|
||||
const model = await prisma.assetModel.findFirst({
|
||||
where: {
|
||||
manufacturer: { equals: row.manufacturer, mode: "insensitive" },
|
||||
name: { equals: row.modelName, mode: "insensitive" },
|
||||
},
|
||||
});
|
||||
if (!model) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
serialNumber: row.serialNumber,
|
||||
error: `No Asset Model matches "${row.manufacturer} / ${row.modelName}" -- create it on /asset-models first`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.assetItem.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
...(row.assetTag ? [{ assetTag: row.assetTag }] : []),
|
||||
{ serialNumber: row.serialNumber },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Partial update -- only fields this row actually carries a value
|
||||
// for (see importAssetItems()'s doc comment above for why blank
|
||||
// means "no change", not "clear").
|
||||
await prisma.assetItem.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
...(row.assetTag ? { assetTag: row.assetTag } : {}),
|
||||
modelId: model.id,
|
||||
...(row.purchaseDate ? { purchaseDate: new Date(row.purchaseDate) } : {}),
|
||||
...(warrantyEndDate ? { warrantyEndDate } : {}),
|
||||
...(status ? { status } : {}),
|
||||
...(cost !== null ? { cost } : {}),
|
||||
...(costCurrency ? { costCurrency } : {}),
|
||||
...(row.purchaseOrderId ? { purchaseOrderId: row.purchaseOrderId } : {}),
|
||||
...(externalPoNumber ? { externalPoNumber } : {}),
|
||||
...(areaId ? { areaId } : {}),
|
||||
},
|
||||
});
|
||||
updated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result = await createAssetItem({
|
||||
serialNumber: row.serialNumber,
|
||||
assetTag: row.assetTag ?? undefined,
|
||||
modelId: model.id,
|
||||
purchaseDate: row.purchaseDate ? new Date(row.purchaseDate) : undefined,
|
||||
warrantyEndDate,
|
||||
status,
|
||||
cost,
|
||||
costCurrency,
|
||||
purchaseOrderId: row.purchaseOrderId ?? undefined,
|
||||
externalPoNumber,
|
||||
areaId,
|
||||
});
|
||||
if (!result.ok) {
|
||||
errors.push({ row: rowNumber, serialNumber: row.serialNumber, error: result.error });
|
||||
continue;
|
||||
}
|
||||
created++;
|
||||
}
|
||||
|
||||
return { created, updated, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the current AssetItem table as CSV, using the same columns
|
||||
* parseAssetItemsCsv() reads -- so "export, edit in Excel, re-import" is a
|
||||
* real round trip, not just a one-way dump. `status` round-trips too as of
|
||||
* 2026-08-24 (see IMPORT_STATUS_MAP above) -- exported as this app's own
|
||||
* enum text ("in_stock"/"in_use"/...), which IMPORT_STATUS_MAP also
|
||||
* recognizes on the way back in.
|
||||
*/
|
||||
export async function exportAssetItemsCsv(): Promise<string> {
|
||||
const rows = await prisma.assetItem.findMany({
|
||||
include: { model: true, area: true },
|
||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
|
||||
const header = [
|
||||
"serial_number",
|
||||
"asset_tag",
|
||||
"manufacturer",
|
||||
"model",
|
||||
"purchase_date",
|
||||
"warranty_end_date",
|
||||
"status",
|
||||
"cost",
|
||||
"cost_currency",
|
||||
"area",
|
||||
"purchase_order_id",
|
||||
"external_po_number",
|
||||
];
|
||||
const lines = [header.join(",")];
|
||||
for (const row of rows) {
|
||||
const cells = [
|
||||
row.serialNumber,
|
||||
row.assetTag ?? "",
|
||||
row.model.manufacturer,
|
||||
row.model.name,
|
||||
row.purchaseDate ? row.purchaseDate.toISOString().slice(0, 10) : "",
|
||||
row.warrantyEndDate ? row.warrantyEndDate.toISOString().slice(0, 10) : "",
|
||||
row.status,
|
||||
row.cost ? row.cost.toString() : "",
|
||||
row.costCurrency ?? "",
|
||||
row.area?.name ?? "",
|
||||
row.purchaseOrderId ?? "",
|
||||
row.externalPoNumber ?? "",
|
||||
];
|
||||
lines.push(cells.map(csvField).join(","));
|
||||
}
|
||||
return lines.join("\r\n") + "\r\n";
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ export type CreateAssignmentResult =
|
||||
* 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".
|
||||
* then atomically flips AssetItem.status to "in_use".
|
||||
* - License branch: rejects if the License doesn't exist, rejects if
|
||||
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
|
||||
* seatsUsed by 1.
|
||||
@@ -238,7 +238,7 @@ export type CreateAssignmentResult =
|
||||
* `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
|
||||
* "in_use" } })` — 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,
|
||||
@@ -262,7 +262,7 @@ export type CreateAssignmentResult =
|
||||
* 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
|
||||
* `status === "in_use"` 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
|
||||
@@ -298,7 +298,7 @@ export async function createAssignment(
|
||||
// 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" },
|
||||
data: { status: "in_use" },
|
||||
});
|
||||
if (statusUpdate.count === 0) {
|
||||
throw new AssignmentBusinessError(
|
||||
@@ -463,7 +463,7 @@ export type TransferAssignmentResult =
|
||||
* transfer (no race window for a third party to grab either one mid-transfer
|
||||
* — that's the whole point of doing this in one transaction instead of a
|
||||
* separate PATCH-return followed by a POST-create, which is exactly the gap
|
||||
* this function closes). AssetItem.status stays "assigned" throughout;
|
||||
* this function closes). AssetItem.status stays "in_use" throughout;
|
||||
* seatsUsed is net zero (same seat, new holder).
|
||||
*
|
||||
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Shared minimal CSV parsing helpers -- extracted from
|
||||
// src/lib/expected-assets.ts (2026-08-21) when src/lib/assets.ts needed
|
||||
// the same line-splitting logic for AssetItem import (2026-08-23). Handles
|
||||
// quoted fields (incl. embedded commas/escaped double-quotes) without a
|
||||
// dependency, which is enough for this app's actual import files (simple
|
||||
// flat tables, not arbitrary user-supplied CSV).
|
||||
|
||||
/**
|
||||
* Splits one CSV line into fields. A quote only opens a quoted field when
|
||||
* it's the very first character of that field -- a stray/legitimate quote
|
||||
* mid-value (e.g. a 24" monitor size) is kept as a literal character
|
||||
* instead of wrongly starting quoted-content mode.
|
||||
*/
|
||||
export function splitCsvLine(line: string): string[] {
|
||||
const fields: string[] = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
current += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
} else if (char === '"' && current === "") {
|
||||
inQuotes = true;
|
||||
} else if (char === ",") {
|
||||
fields.push(current);
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
fields.push(current);
|
||||
return fields.map((f) => f.trim());
|
||||
}
|
||||
|
||||
/** Splits raw CSV text into non-blank lines (handles \r\n, \r, \n). */
|
||||
export function splitCsvLines(text: string): string[] {
|
||||
return text.split(/\r\n|\r|\n/).filter((line) => line.trim() !== "");
|
||||
}
|
||||
|
||||
export function normalizeHeader(header: string): string {
|
||||
return header.trim().toLowerCase().replace(/[_-]+/g, " ").replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
/** Quotes a single CSV field for output if it contains a comma, quote, or
|
||||
* newline -- doubling any embedded quotes, per RFC4180. */
|
||||
export function csvField(value: string): string {
|
||||
if (/[",\n]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// ExpectedAsset — a row from a pre-move Excel/CSV export of known assets,
|
||||
// used as a lookup/staging list for the bot's "🔍 Tìm trong list" picker
|
||||
// during the office-move walk (see prisma/schema.prisma's ExpectedAsset
|
||||
// model comment for the full "why" and the 2026-08-21 decision this
|
||||
// follows). Two write paths: importExpectedAssets() (CSV upload, session
|
||||
// route) and markExpectedAssetMatched() (bot picks one while creating a
|
||||
// scan) -- same list/search read path (searchExpectedAssets()) for both
|
||||
// the bot's picker and any future review UI.
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "./prisma";
|
||||
import { splitCsvLine, splitCsvLines, normalizeHeader } from "./csv";
|
||||
|
||||
export interface ExpectedAssetRecord {
|
||||
id: string;
|
||||
asset_tag: string | null;
|
||||
model_text: string;
|
||||
serial_number: string | null;
|
||||
old_area_text: string | null;
|
||||
old_assignee_text: string | null;
|
||||
matched_scan_id: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ImportExpectedAssetRow {
|
||||
assetTag: string | null;
|
||||
modelText: string;
|
||||
serialNumber: string | null;
|
||||
oldAreaText: string | null;
|
||||
oldAssigneeText: string | null;
|
||||
}
|
||||
|
||||
function toRecord(row: {
|
||||
id: string;
|
||||
assetTag: string | null;
|
||||
modelText: string;
|
||||
serialNumber: string | null;
|
||||
oldAreaText: string | null;
|
||||
oldAssigneeText: string | null;
|
||||
matchedScanId: string | null;
|
||||
createdAt: Date;
|
||||
}): ExpectedAssetRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
asset_tag: row.assetTag,
|
||||
model_text: row.modelText,
|
||||
serial_number: row.serialNumber,
|
||||
old_area_text: row.oldAreaText,
|
||||
old_assignee_text: row.oldAssigneeText,
|
||||
matched_scan_id: row.matchedScanId,
|
||||
created_at: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Header names accepted per logical field, matched case/space/underscore
|
||||
// -insensitively (see normalizeHeader()) -- covers plausible English and
|
||||
// unaccented-Vietnamese exports without requiring the user to rename
|
||||
// columns to match this app's exact field names.
|
||||
const HEADER_ALIASES: Record<keyof ImportExpectedAssetRow, string[]> = {
|
||||
assetTag: ["assettag", "asset tag", "tag", "matag", "ma tag", "ma so"],
|
||||
modelText: ["model", "modeltext", "model text", "model name", "thietbi", "ten model"],
|
||||
serialNumber: ["serial", "serialnumber", "serial number", "sn", "serial no", "so serial"],
|
||||
oldAreaText: ["area", "oldarea", "old area", "location", "khuvuc", "khu vuc", "vitri", "vi tri"],
|
||||
oldAssigneeText: [
|
||||
"assignee",
|
||||
"owner",
|
||||
"oldassignee",
|
||||
"old assignee",
|
||||
"nguoigiu",
|
||||
"nguoi giu",
|
||||
"employee",
|
||||
"nhanvien",
|
||||
"nhan vien",
|
||||
],
|
||||
};
|
||||
|
||||
export type ParseCsvResult =
|
||||
| { ok: true; rows: ImportExpectedAssetRow[] }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Parses a CSV export into rows ready for importExpectedAssets(). Header
|
||||
* row is required; column order doesn't matter (matched by name via
|
||||
* HEADER_ALIASES). modelText is the only field this app's schema requires
|
||||
* NOT NULL, but a row missing it is not rejected outright -- defaults to
|
||||
* "" rather than failing the whole import over one bad row, consistent
|
||||
* with this feature's existing "don't block the walk" precedent (see
|
||||
* InventoryScan.assetTag's schema comment). Fully blank rows are skipped.
|
||||
*/
|
||||
export function parseExpectedAssetsCsv(text: string): ParseCsvResult {
|
||||
const lines = splitCsvLines(text);
|
||||
if (lines.length === 0) {
|
||||
return { ok: false, error: "CSV file is empty" };
|
||||
}
|
||||
|
||||
const headerCells = splitCsvLine(lines[0]).map(normalizeHeader);
|
||||
const columnIndex: Partial<Record<keyof ImportExpectedAssetRow, number>> = {};
|
||||
for (const [field, aliases] of Object.entries(HEADER_ALIASES) as [
|
||||
keyof ImportExpectedAssetRow,
|
||||
string[],
|
||||
][]) {
|
||||
const idx = headerCells.findIndex((h) => aliases.includes(h));
|
||||
if (idx !== -1) {
|
||||
columnIndex[field] = idx;
|
||||
}
|
||||
}
|
||||
if (columnIndex.assetTag === undefined && columnIndex.modelText === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
"Could not find an asset tag or model column in the header row -- recognized names: " +
|
||||
Object.values(HEADER_ALIASES).flat().join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ImportExpectedAssetRow[] = [];
|
||||
for (const line of lines.slice(1)) {
|
||||
const cells = splitCsvLine(line);
|
||||
const get = (field: keyof ImportExpectedAssetRow): string | null => {
|
||||
const idx = columnIndex[field];
|
||||
if (idx === undefined) return null;
|
||||
const value = (cells[idx] ?? "").trim();
|
||||
return value === "" ? null : value;
|
||||
};
|
||||
const row: ImportExpectedAssetRow = {
|
||||
assetTag: get("assetTag"),
|
||||
modelText: get("modelText") ?? "",
|
||||
serialNumber: get("serialNumber"),
|
||||
oldAreaText: get("oldAreaText"),
|
||||
oldAssigneeText: get("oldAssigneeText"),
|
||||
};
|
||||
if (!row.assetTag && !row.modelText && !row.serialNumber) {
|
||||
continue; // fully blank row
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
return { ok: true, rows };
|
||||
}
|
||||
|
||||
export async function importExpectedAssets(
|
||||
rows: ImportExpectedAssetRow[]
|
||||
): Promise<{ count: number }> {
|
||||
const result = await prisma.expectedAsset.createMany({
|
||||
data: rows.map((row) => ({
|
||||
assetTag: row.assetTag,
|
||||
modelText: row.modelText,
|
||||
serialNumber: row.serialNumber,
|
||||
oldAreaText: row.oldAreaText,
|
||||
oldAssigneeText: row.oldAssigneeText,
|
||||
})),
|
||||
});
|
||||
return { count: result.count };
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmatched (matchedScanId IS NULL) rows, optionally filtered by a
|
||||
* case-insensitive substring match against assetTag/modelText/
|
||||
* oldAssigneeText -- this is what the bot's "🔍 Tìm trong list" search
|
||||
* step calls. Capped at 20 results; the bot renders these as inline
|
||||
* keyboard buttons, so more than that is unusable anyway.
|
||||
*/
|
||||
export async function searchExpectedAssets(query?: string): Promise<ExpectedAssetRecord[]> {
|
||||
const where: Prisma.ExpectedAssetWhereInput = { matchedScanId: null };
|
||||
if (query && query.trim() !== "") {
|
||||
const q = query.trim();
|
||||
where.OR = [
|
||||
{ assetTag: { contains: q, mode: "insensitive" } },
|
||||
{ modelText: { contains: q, mode: "insensitive" } },
|
||||
{ oldAssigneeText: { contains: q, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
const rows = await prisma.expectedAsset.findMany({
|
||||
where,
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
take: 20,
|
||||
});
|
||||
return rows.map(toRecord);
|
||||
}
|
||||
|
||||
export async function getExpectedAsset(id: string): Promise<ExpectedAssetRecord | null> {
|
||||
const row = await prisma.expectedAsset.findUnique({ where: { id } });
|
||||
return row ? toRecord(row) : null;
|
||||
}
|
||||
|
||||
export type MarkMatchedResult = { ok: true } | { ok: false; error: string };
|
||||
|
||||
export async function markExpectedAssetMatched(
|
||||
id: string,
|
||||
scanId: string
|
||||
): Promise<MarkMatchedResult> {
|
||||
try {
|
||||
await prisma.expectedAsset.update({
|
||||
where: { id },
|
||||
data: { matchedScanId: scanId },
|
||||
});
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||
return { ok: false, error: "ExpectedAsset not found" };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,11 @@ export interface CreateInventoryScanInput {
|
||||
photoFileId?: string;
|
||||
telegramChatId?: string;
|
||||
telegramMessageId?: number;
|
||||
// Set when the bot's "🔍 Tìm trong list" picker was used -- see
|
||||
// src/lib/expected-assets.ts. Marks that ExpectedAsset row matched to
|
||||
// this scan in the same transaction as the create, so it stops showing
|
||||
// up in future searches.
|
||||
expectedAssetId?: string;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
@@ -60,6 +65,7 @@ const createInventoryScanSchema = z.object({
|
||||
.number({ invalid_type_error: "telegramMessageId must be a number" })
|
||||
.int()
|
||||
.optional(),
|
||||
expectedAssetId: uuidField("expectedAssetId must be a valid UUID string").optional(),
|
||||
});
|
||||
|
||||
export function validateCreateInventoryScanInput(input: {
|
||||
@@ -73,6 +79,7 @@ export function validateCreateInventoryScanInput(input: {
|
||||
photoFileId?: unknown;
|
||||
telegramChatId?: unknown;
|
||||
telegramMessageId?: unknown;
|
||||
expectedAssetId?: unknown;
|
||||
}): ValidationResult {
|
||||
const result = createInventoryScanSchema.safeParse(input);
|
||||
if (!result.success) {
|
||||
@@ -164,8 +171,7 @@ export async function createInventoryScan(
|
||||
input: CreateInventoryScanInput
|
||||
): Promise<CreateInventoryScanResult> {
|
||||
try {
|
||||
const created = await prisma.inventoryScan.create({
|
||||
data: {
|
||||
const data = {
|
||||
categoryId: input.categoryId,
|
||||
modelId: input.modelId,
|
||||
areaId: input.areaId ?? null,
|
||||
@@ -176,14 +182,34 @@ export async function createInventoryScan(
|
||||
photoFileId: input.photoFileId ?? null,
|
||||
telegramChatId: input.telegramChatId ?? null,
|
||||
telegramMessageId: input.telegramMessageId ?? null,
|
||||
},
|
||||
include: includeForRecord,
|
||||
};
|
||||
|
||||
// When the bot's "🔍 Tìm trong list" picker was used, marking the
|
||||
// ExpectedAsset row matched has to land in the same transaction as the
|
||||
// create -- otherwise a crash between the two calls would silently
|
||||
// leave that row unmatched and re-offer it in a later search, letting
|
||||
// the same physical item get logged twice.
|
||||
const created = input.expectedAssetId
|
||||
? await prisma.$transaction(async (tx) => {
|
||||
const scan = await tx.inventoryScan.create({ data, include: includeForRecord });
|
||||
await tx.expectedAsset.update({
|
||||
where: { id: input.expectedAssetId },
|
||||
data: { matchedScanId: scan.id },
|
||||
});
|
||||
return scan;
|
||||
})
|
||||
: await prisma.inventoryScan.create({ data, include: includeForRecord });
|
||||
|
||||
return { ok: true, value: toRecord(created) };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2003") {
|
||||
return { ok: false, error: "categoryId/modelId/areaId/assignedEmployeeId must reference existing rows" };
|
||||
}
|
||||
if (error.code === "P2025") {
|
||||
return { ok: false, error: "expectedAssetId does not reference an existing row" };
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ export function validateCreateMaintenanceRecordInput(input: {
|
||||
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
|
||||
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
|
||||
// enum (see closeMaintenanceRecordSchema's targetStatus comment below for
|
||||
// why "assigned"/"repair" are excluded).
|
||||
// why "in_use"/"repair" are excluded).
|
||||
const closeableTargetStatusValues = ["in_stock", "retired"] as const;
|
||||
|
||||
const closeMaintenanceRecordSchema = z.object({
|
||||
@@ -130,8 +130,8 @@ const closeMaintenanceRecordSchema = z.object({
|
||||
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
|
||||
// AssetItemStatus enum) — those are the only two outcomes a repair can
|
||||
// actually resolve to (fixed → back to stock, unfixable → write off).
|
||||
// "assigned" is deliberately rejected: setting AssetItem.status to
|
||||
// "assigned" here would NOT create a corresponding Assignment row, so
|
||||
// "in_use" is deliberately rejected: setting AssetItem.status to
|
||||
// "in_use" here would NOT create a corresponding Assignment row, so
|
||||
// Assignment (the source of truth for "who currently holds what") and
|
||||
// AssetItem.status would go out of sync with no way back — createAssignment()
|
||||
// only accepts a "in_stock" AssetItem, and there is no standalone
|
||||
@@ -252,7 +252,7 @@ export type CreateMaintenanceRecordResult =
|
||||
* (`where: { id: assetItemId, status: "in_stock" }`) rather than a
|
||||
* plain read-then-write. Pinning the exact required prior value (not
|
||||
* excluding "retired") matters: an excluding guard would still match an
|
||||
* item another concurrent transaction just flipped to "assigned" or
|
||||
* item another concurrent transaction just flipped to "in_use" or
|
||||
* "repair", letting two concurrent opens (or an open racing an Assignment
|
||||
* create) both win. That `updateMany`'s `count` is the actual
|
||||
* race-closing gate: two concurrent opens racing on the same AssetItem
|
||||
@@ -304,7 +304,7 @@ export async function createMaintenanceRecord(
|
||||
// exact prior value ("in_stock") rather than excluding a value
|
||||
// ("not retired"), mirroring createAssignment()'s own status guard
|
||||
// (src/lib/assignments.ts) — the excluding form would still match an
|
||||
// AssetItem another concurrent transaction just flipped to "assigned"
|
||||
// AssetItem another concurrent transaction just flipped to "in_use"
|
||||
// or "repair" (neither equals "retired"), letting two opens (or an
|
||||
// open racing an Assignment create) both win.
|
||||
const statusUpdate = await tx.assetItem.updateMany({
|
||||
|
||||
+18
-8
@@ -24,6 +24,13 @@ function postRequest(body: unknown): Request {
|
||||
});
|
||||
}
|
||||
|
||||
// GET /api/assets takes a Request now (2026-08-25, see route.ts) so it can
|
||||
// read pagination/filter query params -- query is an optional raw query
|
||||
// string (e.g. "status=in_use&page=2"), empty by default.
|
||||
function getRequest(query = ""): Request {
|
||||
return new Request(`http://localhost/api/assets${query ? `?${query}` : ""}`);
|
||||
}
|
||||
|
||||
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
|
||||
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
|
||||
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
|
||||
@@ -331,23 +338,26 @@ describe("GET /api/assets", () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
||||
|
||||
const res = await GET();
|
||||
const res = await GET(getRequest());
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// {items, total} now (2026-08-25, see listAssetItems()'s doc comment
|
||||
// in src/lib/assets.ts) -- was a bare array before pagination.
|
||||
const body = await res.json();
|
||||
expect(Array.isArray(body)).toBe(true);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(Array.isArray(body.items)).toBe(true);
|
||||
expect(body.items).toHaveLength(2);
|
||||
expect(body.total).toBe(2);
|
||||
|
||||
// newest first
|
||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
||||
expect(body.items[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||
expect(body.items[1]).toMatchObject({ serial_number: "SN-010", model_id: modelId });
|
||||
});
|
||||
|
||||
it("returns an empty list when there are no asset items", async () => {
|
||||
const res = await GET();
|
||||
const res = await GET(getRequest());
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual([]);
|
||||
expect(body).toEqual({ items: [], total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -375,7 +385,7 @@ describe("auth gating on /api/assets", () => {
|
||||
it("GET /api/assets returns 401 without a session", async () => {
|
||||
mockedAuth.mockResolvedValue(null);
|
||||
|
||||
const res = await GET();
|
||||
const res = await GET(getRequest());
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
|
||||
+11
-11
@@ -72,7 +72,7 @@ async function createTestModelId(): Promise<string> {
|
||||
}
|
||||
|
||||
let serialCounter = 0;
|
||||
function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "assigned" | "repair" | "retired" }) {
|
||||
function createTestAssetItem(overrides: { modelId: string; status?: "in_stock" | "in_use" | "repair" | "retired" }) {
|
||||
serialCounter += 1;
|
||||
return prisma.assetItem.create({
|
||||
data: {
|
||||
@@ -223,7 +223,7 @@ describe("validateReturnAssignmentInput", () => {
|
||||
});
|
||||
|
||||
describe("createAssignment", () => {
|
||||
it("assigns an in_stock AssetItem, flips its status to assigned", async () => {
|
||||
it("assigns an in_stock AssetItem, flips its status to in_use", async () => {
|
||||
const employee = await createTestEmployee();
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId });
|
||||
@@ -236,10 +236,10 @@ describe("createAssignment", () => {
|
||||
expect(created.value.returned_at).toBeNull();
|
||||
|
||||
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(updatedItem.status).toBe("assigned");
|
||||
expect(updatedItem.status).toBe("in_use");
|
||||
});
|
||||
|
||||
it.each(["repair", "retired", "assigned"] as const)(
|
||||
it.each(["repair", "retired", "in_use"] as const)(
|
||||
"rejects assigning an AssetItem whose status is %s",
|
||||
async (status) => {
|
||||
const employee = await createTestEmployee();
|
||||
@@ -312,7 +312,7 @@ describe("createAssignment", () => {
|
||||
expect(second.value.employee_id).toBe(employee2.id);
|
||||
|
||||
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(updatedItem.status).toBe("assigned");
|
||||
expect(updatedItem.status).toBe("in_use");
|
||||
});
|
||||
|
||||
it("returns a clean error when assetItemId doesn't reference an existing asset item", async () => {
|
||||
@@ -434,7 +434,7 @@ describe("createAssignment", () => {
|
||||
expect(failed).toHaveLength(1);
|
||||
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
expect(item.status).toBe("in_use");
|
||||
|
||||
const activeAssignments = await prisma.assignment.findMany({
|
||||
where: { assetItemId: assetItem.id, returnedAt: null },
|
||||
@@ -545,7 +545,7 @@ describe("returnAssignment", () => {
|
||||
});
|
||||
|
||||
describe("transferAssignment", () => {
|
||||
it("transfers an AssetItem assignment to a new employee, keeping status assigned throughout", async () => {
|
||||
it("transfers an AssetItem assignment to a new employee, keeping status in_use throughout", async () => {
|
||||
const employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||
const modelId = await createTestModelId();
|
||||
@@ -562,11 +562,11 @@ describe("transferAssignment", () => {
|
||||
expect(transferred.value.asset_item_id).toBe(assetItem.id);
|
||||
expect(transferred.value.returned_at).toBeNull();
|
||||
|
||||
// The AssetItem must remain "assigned" throughout the transfer — never
|
||||
// The AssetItem must remain "in_use" throughout the transfer — never
|
||||
// flipped to in_stock mid-way (that's the whole point of doing this in
|
||||
// one transaction rather than a separate return + create).
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
expect(item.status).toBe("in_use");
|
||||
|
||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||
expect(original.returnedAt).not.toBeNull();
|
||||
@@ -658,7 +658,7 @@ describe("transferAssignment", () => {
|
||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||
expect(original.returnedAt).toBeNull();
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
expect(item.status).toBe("in_use");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -898,7 +898,7 @@ describe("PATCH /api/assignments/[id]", () => {
|
||||
expect(body.returned_at).toBeNull();
|
||||
|
||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
||||
expect(item.status).toBe("assigned");
|
||||
expect(item.status).toBe("in_use");
|
||||
});
|
||||
|
||||
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ async function createTestModelId(): Promise<string> {
|
||||
let serialCounter = 0;
|
||||
function createTestAssetItem(overrides: {
|
||||
modelId: string;
|
||||
status?: "in_stock" | "assigned" | "repair" | "retired";
|
||||
status?: "in_stock" | "in_use" | "repair" | "retired";
|
||||
}) {
|
||||
serialCounter += 1;
|
||||
return prisma.assetItem.create({
|
||||
@@ -223,7 +223,7 @@ describe("createMaintenanceRecord", () => {
|
||||
|
||||
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
|
||||
const assetItem = await createTestAssetItem({ modelId, status: "in_use" });
|
||||
const employee = await createTestEmployee();
|
||||
await prisma.assignment.create({
|
||||
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
||||
@@ -495,7 +495,7 @@ describe("POST /api/maintenance-records", () => {
|
||||
|
||||
it("rejects creating a record for an AssetItem with an active assignment", async () => {
|
||||
const modelId = await createTestModelId();
|
||||
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
|
||||
const assetItem = await createTestAssetItem({ modelId, status: "in_use" });
|
||||
const employee = await createTestEmployee();
|
||||
await prisma.assignment.create({
|
||||
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
||||
|
||||
Reference in New Issue
Block a user