Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03d74d69c1 | ||
|
|
10508f9d04 | ||
|
|
4063b81f16 | ||
|
|
6863323cce | ||
|
|
3ebd7efd7f | ||
|
|
2cbe746bde | ||
|
|
1360bb3c45 | ||
|
|
69437cf38f | ||
|
|
aa7f5515db | ||
|
|
d9b360c1df | ||
|
|
8ac95c974b | ||
|
|
b4e2f6c83e | ||
|
|
b52106dfa6 | ||
|
|
d7c0da2328 | ||
|
|
555fbf64fd | ||
|
|
5a8dea2ea3 | ||
|
|
acc79a1d3c | ||
|
|
9773dd8a39 | ||
|
|
84e6db7406 | ||
|
|
77dbd15450 | ||
|
|
ae84737620 | ||
|
|
17c5882e86 | ||
|
|
d5a0f1cb74 | ||
|
|
8a02a7c7ef | ||
|
|
1e6dec9cf2 | ||
|
|
3078d9e8c5 |
@@ -0,0 +1,57 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["**"]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: asset_license_mgmt
|
||||||
|
POSTGRES_PASSWORD: change-me-local-dev
|
||||||
|
POSTGRES_DB: asset_license_mgmt
|
||||||
|
# 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
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 10
|
||||||
|
|
||||||
|
env:
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- run: npm ci
|
||||||
|
|
||||||
|
- run: npx prisma generate
|
||||||
|
|
||||||
|
- run: npx tsc --noEmit
|
||||||
|
|
||||||
|
- run: npm run lint
|
||||||
|
|
||||||
|
- run: npm test
|
||||||
|
|
||||||
|
- run: npm run build
|
||||||
@@ -92,6 +92,24 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
|||||||
- UI transfer cho `Assignment` xong (2026-08-04, `src/app/assignments/page.tsx`): per-row employee `<Select>` + nút Transfer cạnh nút Return sẵn có (chỉ hiện ở row active — `returned_at` null), PATCH `{employeeId}` — route đã tự dispatch sang `transferAssignment()`. Không có thay đổi lib/API, tsc/lint sạch.
|
- UI transfer cho `Assignment` xong (2026-08-04, `src/app/assignments/page.tsx`): per-row employee `<Select>` + nút Transfer cạnh nút Return sẵn có (chỉ hiện ở row active — `returned_at` null), PATCH `{employeeId}` — route đã tự dispatch sang `transferAssignment()`. Không có thay đổi lib/API, tsc/lint sạch.
|
||||||
- `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.
|
- `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`).
|
- 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ở
|
## Việc còn dở
|
||||||
|
|
||||||
@@ -110,6 +128,10 @@ Backend CRUD xong cho toàn bộ entity chốt tới nay: `Request` (chỉ state
|
|||||||
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
|
- Các state tiếp theo của Request: Pending Approval → Approved → Ordered → Received → Delivered → Closed, và nhánh Rejected/Cancelled.
|
||||||
- UI cho các state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra `git status`/working tree trước khi bắt đầu để tránh làm trùng.)
|
- UI cho các state Request kế tiếp (hiện chỉ có Draft) — chờ spec/build state machine ở trên trước. (Lưu ý: có thể đã có tiến triển từ 1 phiên Claude Code khác chạy song song — kiểm tra `git status`/working tree trước khi bắt đầu để tránh làm trùng.)
|
||||||
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
|
- Auth/phân quyền (ai được approve request, ai được xác nhận nhận hàng/bàn giao) — vẫn flat, chưa có role.
|
||||||
|
- `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)
|
||||||
|
|||||||
@@ -140,6 +140,26 @@ Xuất phát từ yêu cầu stakeholder: "muốn thêm 1 menu maintenance — k
|
|||||||
- **Đóng** `MaintenanceRecord` (set `endDate` + `resolutionNotes` qua `PATCH /api/maintenance-records/[id]`, cùng REST-shape tiền lệ đã chốt cho `Assignment`/`PurchaseOrder` — "chốt 2026-08-02 (b)" ở mục Assignment): body nhận thêm `targetStatus` bắt buộc (vd `in_stock` nếu sửa xong, `retired` nếu sửa không thành công) — **không** tự động mặc định về `in_stock`, vì sửa không phải lúc nào cũng thành công.
|
- **Đóng** `MaintenanceRecord` (set `endDate` + `resolutionNotes` qua `PATCH /api/maintenance-records/[id]`, cùng REST-shape tiền lệ đã chốt cho `Assignment`/`PurchaseOrder` — "chốt 2026-08-02 (b)" ở mục Assignment): body nhận thêm `targetStatus` bắt buộc (vd `in_stock` nếu sửa xong, `retired` nếu sửa không thành công) — **không** tự động mặc định về `in_stock`, vì sửa không phải lúc nào cũng thành công.
|
||||||
- Các field khác ngoài hành động "đóng" record không có update endpoint riêng — set 1 lần lúc tạo, không sửa lại sau, theo đúng pattern hiện có (vd `PurchaseOrder.orderDate` không có update endpoint).
|
- Các field khác ngoài hành động "đóng" record không có update endpoint riêng — set 1 lần lúc tạo, không sửa lại sau, theo đúng pattern hiện có (vd `PurchaseOrder.orderDate` không có update endpoint).
|
||||||
|
|
||||||
|
## InventoryScan — kiểm kê văn phòng move (chốt 2026-08-20)
|
||||||
|
|
||||||
|
Bối cảnh: dự án move văn phòng cần kiểm kê toàn bộ thiết bị, càng nhỏ càng phải ghi — nhưng thao tác chụp ảnh rồi gõ Excel trên laptop quá chậm khi đi vòng quanh văn phòng. Giải pháp: bot Telegram (`Inventory_capture_bot`, chạy trên service riêng `inventory-capture` ở `/opt/homelab/inventory-capture/` trên máy `appsrv01`, KHÔNG phải trong repo này) cho phép nhập liệu bằng bấm nút trên điện thoại, gọi ngược vào app này qua các route `/api/bot/*` để ghi thẳng vào Postgres.
|
||||||
|
|
||||||
|
**Vì sao KHÔNG dùng `AssetItem` cho việc này:** `AssetItem` bắt buộc `serialNumber` (unique) + `purchaseDate` — cả hai đều không có sẵn ngay lúc đi bấm nút kiểm kê nhanh. Bắt buộc 1 trong 2 sẽ hoặc chặn luồng kiểm kê, hoặc bịa dữ liệu giả (đi ngược nguyên tắc "không dữ liệu giả" đã áp dụng xuyên suốt codebase này — xem comment `AssetItem`/`License`/`PurchaseOrder`). Quyết định: **entity mới `InventoryScan`** — bản ghi "quét lúc kiểm kê", nhẹ, không unique constraint nào chặn luồng nhập nhanh. Sau này review, cái nào cần lên sổ chính thức thì "promote" sang `AssetItem` thật (đọc serial thật lúc đó) — **bước promote này chưa build**, xem "Việc còn dở".
|
||||||
|
|
||||||
|
**Scope ban đầu (3 category, tự seed qua script, không phải migration seed):** Controller (PS5/Sony, Xbox/Microsoft), Màn hình (Dell/Monitor, HP/Monitor), Workstation (HP/Z4 G4, Lenovo/P620 — category duy nhất có `AssetCategoryAttributeDef`: `cpu`/`ram_gb`/`gpu`, cả 3 `required: false` ở tầng DB dù bot luôn hỏi đủ cả 3 trong hội thoại — lý do: field `required` này chỉ chi phối validate `AssetModel.specs`, không phải `InventoryScan.specs`, và giá trị CPU/RAM/GPU thực tế khác nhau **theo từng máy vật lý** chứ không cố định theo model, nên không nên ép cứng ở tầng `AssetModel`). Category/model KHÔNG dừng ở scope này — bot có nút "➕ Model mới" (gọi `POST /api/bot/asset-models`) cho phép thêm model mới ngay trong lúc kiểm kê, category mới thì vẫn cần seed tay (business call lớn hơn, có thể cần thêm `AssetCategoryAttributeDef`).
|
||||||
|
|
||||||
|
**`Area` (không dùng `Seat`):** Cân nhắc ban đầu là "Seat" gắn cứng với `team`, nhưng lúc build sơ đồ ghế ngồi văn phòng mới **chưa có** — đồng thời cần cả vị trí kiểu phòng (phòng họp, kho), không chỉ ghế cá nhân. Quyết định: 1 bảng `Area` chung (tên unique + `team` nullable) cho cả 2 kiểu, bot tự học dần (list nút bấm lớn theo thời gian, có nút "➕ Area mới") thay vì cần seed sơ đồ trước. Khi có sơ đồ move chính thức, chỉ cần update cột `team` cho từng `Area` đã có — không đổi cấu trúc.
|
||||||
|
|
||||||
|
**`assetTag` + `serialNumber`:** cả hai đều `String?`, **không unique/required** ở tầng `InventoryScan` (khác hẳn `AssetItem.serialNumber`) — cùng lý do "không chặn luồng kiểm kê" ở trên. Đọc từ tem dán trên thiết bị. Hiện tại nhập tay qua bot hoặc **đọc thủ công từ ảnh** (stakeholder tự gửi ảnh cho Claude, không có OCR tự động trong app) — xem "Việc còn dở" cho hướng OCR tự động nếu cần sau.
|
||||||
|
|
||||||
|
**Ảnh:** `InventoryScan.photoFileId` lưu Telegram `file_id`, KHÔNG lưu file vật lý trên disk của app này (tránh phải share volume giữa 2 service khác container/khác concern). `GET /api/inventory-scans/[id]/photo` proxy on-demand qua Telegram Bot API (`getFile` + download) mỗi lần xem — cần `BOT_TOKEN` trong `.env` của **chính app này** (không chỉ bot service) để làm việc đó. Gotcha đã gặp: Telegram trả `Content-Type: application/octet-stream` cho file download bất kể loại thật, browser hiểu nhầm thành tải file — phải tự suy `Content-Type` từ đuôi file trong `file_path` (`.jpg`/`.png`/`.webp`), không tin header gốc.
|
||||||
|
|
||||||
|
**Auth cho `/api/bot/*`:** route riêng (`src/lib/bot-auth.ts`), check header `Authorization: Bearer <BOT_INGEST_SECRET>` — **không** qua Auth.js session như mọi route khác trong app, vì caller là bot service (machine), không phải browser người dùng đã login. `BOT_INGEST_SECRET` là secret riêng, khác `AUTH_SECRET` (session JWT) và khác password của User `[email protected]` (tài khoản review dùng trang `/inventory-scans`, tạo tay qua `scripts/create-user.ts` giống mọi `User` khác — không phải service account đặc biệt).
|
||||||
|
|
||||||
|
**Trang review** (`/inventory-scans`, session-authed như mọi trang khác): list + inline edit (đổi model trong cùng category, area, người gán, specs, asset tag, serial number) + delete. Không có trang "create" riêng vì tạo mới chỉ qua bot.
|
||||||
|
|
||||||
|
Xem thêm: `src/lib/inventory-scans.ts`, `src/lib/areas.ts`, `prisma/schema.prisma` (model `Area`/`InventoryScan`), `tests/areas.test.ts`, `tests/inventory-scans.test.ts`, `tests/bot-api.test.ts`.
|
||||||
|
|
||||||
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
|
## Module tương lai: Rack/Slot management + IoT (chưa build, chỉ note kiến trúc)
|
||||||
|
|
||||||
Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light).
|
Mục tiêu xa hơn: biết chính xác asset nằm ở ô/slot nào trên tủ rack, và hướng dẫn người lấy đúng ô bằng đèn (pick-to-light).
|
||||||
@@ -198,11 +218,12 @@ QR gán asset vào slot (bước 1, thủ công hàng ngày) **không phải IoT
|
|||||||
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
|
- Migrate 2 request đang có (`Firefly_License_Request.docx`, `Eric Pelatan.docx`, ở workspace `ubisoft/` ngoài repo này) vào hệ thống mới sau khi có DB
|
||||||
- **`MaintenanceRecord` (spec BA + stakeholder, chốt 2026-08-04): đã có spec đầy đủ, sẵn sàng cho `pm` chia task** — xem mục "MaintenanceRecord" phía trên. Field cơ bản + `type` (`repair`/`warranty`/`preventive`) + `vendorId` (tái dùng `Vendor`, nullable) đã chốt; đồng bộ `AssetItem.status` đã chốt rõ (auto → `repair` lúc mở, chặn nếu có `Assignment` active hoặc đã `retired`, `targetStatus` bắt buộc lúc đóng); không track `cost`.
|
- **`MaintenanceRecord` (spec BA + stakeholder, chốt 2026-08-04): đã có spec đầy đủ, sẵn sàng cho `pm` chia task** — xem mục "MaintenanceRecord" phía trên. Field cơ bản + `type` (`repair`/`warranty`/`preventive`) + `vendorId` (tái dùng `Vendor`, nullable) đã chốt; đồng bộ `AssetItem.status` đã chốt rõ (auto → `repair` lúc mở, chặn nếu có `Assignment` active hoặc đã `retired`, `targetStatus` bắt buộc lúc đóng); không track `cost`.
|
||||||
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
|
- (Xa hơn, chưa ưu tiên) Rack/Slot + IoT pick-to-light, cron email cảnh báo license hết hạn, Power BI report views
|
||||||
- Setup Gitea tự host ở nhà (2026-07-31) — dự định làm remote chính sau này, giữ GitHub làm backup off-site (đây là "sản phẩm dùng cả đời" của user, không nên chỉ có 1 bản duy nhất). Tạm thời vẫn push GitHub như hiện tại, chưa làm gấp.
|
- Gitea tự host (`git.toanhoang.work`) giờ đã là `origin` chính thức (đổi từ GitHub — cập nhật note cũ 2026-07-31 vốn nói "tạm thời vẫn push GitHub"; chưa xác nhận có còn remote GitHub backup off-site nào không, cần check lại). CI (build+test+tsc+lint qua Gitea Actions) đã setup 2026-08-20, xem `.gitea/workflows/ci.yml` — chỉ chạy trên PR/push, **chưa có auto-deploy**, deploy vẫn làm tay qua `docker compose up -d --build` + `npm run db:migrate` trên `appsrv01` (xem `CLAUDE.md`).
|
||||||
|
- **`InventoryScan` (kiểm kê move, chốt 2026-08-20): xem mục "InventoryScan — kiểm kê văn phòng move" phía trên.** Việc còn dở riêng của feature này: (1) chưa build bước "promote" 1 `InventoryScan` thành `AssetItem` thật (đọc serial thật, tạo `AssetItem` + optionally `Assignment`) — hiện review xong vẫn chỉ nằm ở `InventoryScan`; (2) chưa có OCR tự động đọc `assetTag`/`serialNumber` từ ảnh — stakeholder tự nhờ Claude đọc ảnh thủ công, nếu cần tự động hoá sau thì cần thêm `ANTHROPIC_API_KEY` riêng cho server + route gọi Claude Vision (đã bàn, chưa làm vì stakeholder chọn hướng thủ công trước); (3) `Area.team` đang trống cho toàn bộ `Area` đã tạo — cần điền khi có sơ đồ move chính thức.
|
||||||
|
|
||||||
## Ghi chú & bài học
|
## Ghi chú & bài học
|
||||||
|
|
||||||
- Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây.
|
- Migrate entity `Request` từ SQLite sang Postgres (2026-07-30) và setup ESLint đều đã **CONFIRMED xong** qua lượt verify độc lập (test-runner riêng, không phải agent vừa code tự chấm) — 6 test pass trên Postgres thật, lint sạch, type-check sạch. Chi tiết trạng thái nằm ở `../CLAUDE.md`, không duplicate ở đây.
|
||||||
|
|
||||||
---
|
---
|
||||||
Cập nhật lần cuối: 2026-08-04 (spec `MaintenanceRecord` chốt đủ với stakeholder — sẵn sàng cho `pm`, xem "Việc còn dở")
|
Cập nhật lần cuối: 2026-08-20 (`InventoryScan` — kiểm kê move — chốt đủ + build xong qua Claude Code từ xa (SSH, không qua pipeline agent chuẩn của repo), CI Gitea Actions setup — xem "Việc còn dở")
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "areas" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"team" TEXT,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "areas_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "areas_name_key" ON "areas"("name");
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "inventory_scans" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"category_id" UUID NOT NULL,
|
||||||
|
"model_id" UUID NOT NULL,
|
||||||
|
"area_id" UUID,
|
||||||
|
"assigned_employee_id" UUID,
|
||||||
|
"specs" JSONB NOT NULL DEFAULT '{}',
|
||||||
|
"photo_path" TEXT,
|
||||||
|
"telegram_chat_id" TEXT,
|
||||||
|
"telegram_message_id" INTEGER,
|
||||||
|
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "inventory_scans_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_category_id_fkey" FOREIGN KEY ("category_id") REFERENCES "asset_categories"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_model_id_fkey" FOREIGN KEY ("model_id") REFERENCES "asset_models"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_area_id_fkey" FOREIGN KEY ("area_id") REFERENCES "areas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "inventory_scans" ADD CONSTRAINT "inventory_scans_assigned_employee_id_fkey" FOREIGN KEY ("assigned_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "inventory_scans" ADD COLUMN "asset_tag" TEXT;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "inventory_scans" ADD COLUMN "serial_number" TEXT;
|
||||||
@@ -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';
|
||||||
+197
-4
@@ -74,19 +74,34 @@ model Employee {
|
|||||||
// Assignments (assets/licenses) over time, including several active ones
|
// Assignments (assets/licenses) over time, including several active ones
|
||||||
// at once (e.g. a laptop + a license seat simultaneously).
|
// at once (e.g. a laptop + a license seat simultaneously).
|
||||||
assignments Assignment[]
|
assignments Assignment[]
|
||||||
|
// Back-relation for InventoryScan.assignedEmployeeId — see InventoryScan's
|
||||||
|
// comment. An Employee can have multiple scanned items (workstation +
|
||||||
|
// monitor + controller, etc.) attributed to them from the office-move
|
||||||
|
// inventory walk.
|
||||||
|
inventoryScans InventoryScan[]
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@map("employees")
|
@@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
|
// 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 {
|
enum AssetItemStatus {
|
||||||
in_stock
|
in_stock
|
||||||
assigned
|
in_use
|
||||||
repair
|
repair
|
||||||
retired
|
retired
|
||||||
|
lost
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
// Hardware inventory — see docs/asset-license-management.md's "Data model
|
||||||
@@ -113,13 +128,55 @@ enum AssetItemStatus {
|
|||||||
model AssetItem {
|
model AssetItem {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
serialNumber String @unique @map("serial_number")
|
serialNumber String @unique @map("serial_number")
|
||||||
|
// 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
|
modelId String @map("model_id") @db.Uuid
|
||||||
model AssetModel @relation(fields: [modelId], references: [id])
|
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
|
warrantyEndDate DateTime? @map("warranty_end_date") @db.Date
|
||||||
status AssetItemStatus @default(in_stock)
|
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
|
purchaseOrderId String? @map("purchase_order_id") @db.Uuid
|
||||||
purchaseOrder PurchaseOrder? @relation(fields: [purchaseOrderId], references: [id])
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
// Back-relation for Assignment.assetItemId — an AssetItem can have
|
// Back-relation for Assignment.assetItemId — an AssetItem can have
|
||||||
// multiple Assignment rows over its lifetime (historical + at most one
|
// multiple Assignment rows over its lifetime (historical + at most one
|
||||||
@@ -280,6 +337,8 @@ model AssetCategory {
|
|||||||
// not expressible as a Prisma-level constraint), but the relation itself
|
// not expressible as a Prisma-level constraint), but the relation itself
|
||||||
// is not restricted to high_value categories at the schema level.
|
// is not restricted to high_value categories at the schema level.
|
||||||
assetModels AssetModel[]
|
assetModels AssetModel[]
|
||||||
|
// Back-relation for InventoryScan.categoryId — see InventoryScan's comment.
|
||||||
|
inventoryScans InventoryScan[]
|
||||||
|
|
||||||
@@map("asset_categories")
|
@@map("asset_categories")
|
||||||
}
|
}
|
||||||
@@ -357,10 +416,22 @@ model AssetModel {
|
|||||||
manufacturer String
|
manufacturer String
|
||||||
name String
|
name String
|
||||||
specs Json @default("{}")
|
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)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
// Back-relation for the AssetItem.model -> AssetItem.modelId retrofit —
|
||||||
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
// see "Cập nhật 2026-08-02 (2)" in docs/asset-license-management.md.
|
||||||
assetItems AssetItem[]
|
assetItems AssetItem[]
|
||||||
|
// Back-relation for InventoryScan.modelId — see InventoryScan's comment.
|
||||||
|
inventoryScans InventoryScan[]
|
||||||
|
|
||||||
@@map("asset_models")
|
@@map("asset_models")
|
||||||
}
|
}
|
||||||
@@ -473,3 +544,125 @@ model MaintenanceRecord {
|
|||||||
|
|
||||||
@@map("maintenance_records")
|
@@map("maintenance_records")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Physical location tag for the office-move inventory walk — see the
|
||||||
|
// "chia theo area" decision in the inventory-scans project chat. Deliberately
|
||||||
|
// NOT the pre-existing high-value AssetItem's target ("Seat" tied 1:1 to a
|
||||||
|
// Employee/team) because that data (the new office seating chart) doesn't
|
||||||
|
// exist yet at the time this was built — `team` is nullable and gets filled
|
||||||
|
// in once the seating chart is finalized, no schema change needed then.
|
||||||
|
// Covers both per-seat locations ("Seat 12") and shared-room locations
|
||||||
|
// ("Phòng họp A", "Kho tầng 2") with the same flat entity — splitting seat
|
||||||
|
// vs room into separate types was considered and rejected as premature; add
|
||||||
|
// a `type` column later if filtering by kind actually becomes necessary.
|
||||||
|
// `name` is globally unique so the bot's "reuse existing or create new" flow
|
||||||
|
// (grows the list as the walk progresses, avoids duplicate near-identical
|
||||||
|
// area names from free-typing) has something to match against.
|
||||||
|
model Area {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
name String @unique
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// One physical item logged during the office-move inventory walk (bot flow:
|
||||||
|
// Category -> Model -> [Workstation only: cpu/ram/gpu] -> Area -> assignee ->
|
||||||
|
// optional photo). Deliberately NOT a row in AssetItem: AssetItem requires a
|
||||||
|
// unique serialNumber and a purchaseDate, neither of which is known/at-hand
|
||||||
|
// during a fast walk-and-tap pass — forcing either would either block the
|
||||||
|
// walk or invent fake placeholder data, which this codebase avoids elsewhere
|
||||||
|
// (see AssetItem/License/PurchaseOrder comments' "no fake data" precedent).
|
||||||
|
// InventoryScan is the lightweight capture; promoting a scan to a real
|
||||||
|
// AssetItem (with a real serial once read off the physical tag) is a manual
|
||||||
|
// follow-up step done later in the review UI, not built in this task.
|
||||||
|
// `categoryId`/`modelId` reuse the existing AssetCategory/AssetModel catalog
|
||||||
|
// (seeded with Controller/Màn hình/Workstation for this task's scope) rather
|
||||||
|
// than inventing a parallel classification. `areaId` and `assignedEmployeeId`
|
||||||
|
// are both nullable — plenty of scanned items are shared equipment in a room
|
||||||
|
// (no single assignee) or not yet placed in a known Area. `specs` is JSONB,
|
||||||
|
// same shape/rationale as AssetModel.specs — holds the Workstation-only
|
||||||
|
// cpu/ram/gpu answers; empty object for categories that don't need them.
|
||||||
|
// `telegramChatId`/`telegramMessageId` are kept for traceability back to the
|
||||||
|
// original bot conversation (debugging a bad scan, or re-fetching the photo
|
||||||
|
// from Telegram if `photoPath` is ever lost) — nullable since a scan created
|
||||||
|
// directly from the review UI later won't have either.
|
||||||
|
model InventoryScan {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
categoryId String @map("category_id") @db.Uuid
|
||||||
|
category AssetCategory @relation(fields: [categoryId], references: [id])
|
||||||
|
modelId String @map("model_id") @db.Uuid
|
||||||
|
model AssetModel @relation(fields: [modelId], references: [id])
|
||||||
|
areaId String? @map("area_id") @db.Uuid
|
||||||
|
area Area? @relation(fields: [areaId], references: [id])
|
||||||
|
assignedEmployeeId String? @map("assigned_employee_id") @db.Uuid
|
||||||
|
assignedEmployee Employee? @relation(fields: [assignedEmployeeId], references: [id])
|
||||||
|
// Physical asset tag/barcode label already stuck on the device (see the
|
||||||
|
// "Scan QR/barcode đã có sẵn trên thiết bị" scope decision). Free text,
|
||||||
|
// typed by the bot operator for now — not yet an actual barcode-image
|
||||||
|
// decode. Deliberately NOT unique/required at this table's level (unlike
|
||||||
|
// AssetItem.serialNumber): plenty of scanned items have no tag at all
|
||||||
|
// (Controllers, Monitors bought without one), and enforcing uniqueness
|
||||||
|
// here would block the walk on a typo'd re-entry instead of just letting
|
||||||
|
// review-time cleanup catch it, per this model's "don't block the walk"
|
||||||
|
// precedent (see AssetItem-vs-InventoryScan comment above).
|
||||||
|
assetTag String? @map("asset_tag")
|
||||||
|
// Manufacturer serial number, read off the same physical tag as
|
||||||
|
// assetTag — same "don't block the walk" rationale (nullable, not
|
||||||
|
// unique at this table's level; AssetItem.serialNumber is the unique
|
||||||
|
// one, enforced only once a scan is promoted). Filled in either by
|
||||||
|
// typing, or later by whoever reviews the scan's photo (bot operator or
|
||||||
|
// Claude) reading it off the image — no OCR automation built yet, this
|
||||||
|
// column is just where that value lands once read.
|
||||||
|
serialNumber String? @map("serial_number")
|
||||||
|
specs Json @default("{}")
|
||||||
|
// Stores the Telegram file_id (not a local disk path, despite the DB
|
||||||
|
// column name) — photos live on Telegram's servers, re-fetched on demand
|
||||||
|
// via GET /api/inventory-scans/[id]/photo rather than duplicated onto
|
||||||
|
// this app's own disk. Avoids a shared volume mount between this
|
||||||
|
// container and the separate inventory-capture bot service.
|
||||||
|
photoFileId String? @map("photo_path")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { createOrGetArea, listAreas, validateCreateAreaInput } from "@/lib/areas";
|
||||||
|
|
||||||
|
// Session-authed Area list/create for the review page — mirrors
|
||||||
|
// src/app/api/employees/route.ts's pattern. The bot uses the separate
|
||||||
|
// bot-secret-authed /api/bot/areas instead (see src/lib/bot-auth.ts).
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const areas = await listAreas();
|
||||||
|
return NextResponse.json(areas, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, team } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateAreaInput({ name, team });
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createOrGetArea(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { categoryId, manufacturer, name, specs } = body as Record<string, unknown>;
|
const { categoryId, manufacturer, name, specs, warrantyMonths } = body as Record<string, unknown>;
|
||||||
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs });
|
const validation = validateCreateAssetModelInput({
|
||||||
|
categoryId,
|
||||||
|
manufacturer,
|
||||||
|
name,
|
||||||
|
specs,
|
||||||
|
warrantyMonths,
|
||||||
|
});
|
||||||
|
|
||||||
if (!validation.ok) {
|
if (!validation.ok) {
|
||||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
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 { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
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
|
// Mirrors src/app/api/employees/route.ts's pattern — middleware.ts excludes
|
||||||
// /api from route protection, so this checks the session itself. Flat
|
// /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 });
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { serialNumber, modelId, purchaseDate, warrantyEndDate, purchaseOrderId } =
|
const {
|
||||||
body as Record<string, unknown>;
|
|
||||||
const validation = validateCreateAssetItemInput({
|
|
||||||
serialNumber,
|
serialNumber,
|
||||||
|
assetTag,
|
||||||
modelId,
|
modelId,
|
||||||
purchaseDate,
|
purchaseDate,
|
||||||
warrantyEndDate,
|
warrantyEndDate,
|
||||||
purchaseOrderId,
|
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) {
|
if (!validation.ok) {
|
||||||
@@ -44,12 +64,37 @@ export async function POST(request: Request): Promise<NextResponse> {
|
|||||||
return NextResponse.json(created.value, { status: 201 });
|
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();
|
const session = await auth();
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetItems = await listAssetItems();
|
const url = new URL(request.url);
|
||||||
return NextResponse.json(assetItems, { status: 200 });
|
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,42 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireBotAuth } from "@/lib/bot-auth";
|
||||||
|
import { createOrGetArea, listAreas, validateCreateAreaInput } from "@/lib/areas";
|
||||||
|
|
||||||
|
// Bot-facing Area list/create — see src/lib/areas.ts's createOrGetArea()
|
||||||
|
// comment for why POST is idempotent-by-name rather than erroring on a
|
||||||
|
// name the bot's own keyboard just offered as an existing option.
|
||||||
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const areas = await listAreas();
|
||||||
|
return NextResponse.json({ areas });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, team } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateAreaInput({ name, team });
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createOrGetArea(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireBotAuth } from "@/lib/bot-auth";
|
||||||
|
import { createAssetModel, validateCreateAssetModelInput } from "@/lib/asset-models";
|
||||||
|
|
||||||
|
// Bot-facing AssetModel create — lets the bot's "➕ Model mới" option add a
|
||||||
|
// model to an existing category on the fly (e.g. a new Laptop model),
|
||||||
|
// mirroring src/app/api/bot/areas & employees' "reuse or create" pattern.
|
||||||
|
// Categories themselves are still admin-seeded, not bot-creatable — a new
|
||||||
|
// category is a bigger decision (may need AssetCategoryAttributeDef rows
|
||||||
|
// too) than a same-category model addition.
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { categoryId, manufacturer, name } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateAssetModelInput({ categoryId, manufacturer, name, specs: {} });
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createAssetModel(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireBotAuth } from "@/lib/bot-auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
// One-shot catalog fetch for the bot's Category -> Model inline-keyboard
|
||||||
|
// step, plus each category's attribute defs (currently only Workstation
|
||||||
|
// has any) so the bot knows which extra questions (cpu/ram/gpu) to ask
|
||||||
|
// after a model in that category is picked. Bot-secret auth, not session —
|
||||||
|
// see src/lib/bot-auth.ts.
|
||||||
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = await prisma.assetCategory.findMany({
|
||||||
|
include: {
|
||||||
|
assetModels: { orderBy: [{ manufacturer: "asc" }, { name: "asc" }] },
|
||||||
|
attributeDefs: { orderBy: [{ sortOrder: "asc" }] },
|
||||||
|
},
|
||||||
|
orderBy: [{ name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
categories: categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
models: c.assetModels.map((m) => ({ id: m.id, name: m.name, manufacturer: m.manufacturer })),
|
||||||
|
attribute_defs: c.attributeDefs.map((d) => ({
|
||||||
|
key: d.key,
|
||||||
|
label: d.label,
|
||||||
|
data_type: d.dataType,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireBotAuth } from "@/lib/bot-auth";
|
||||||
|
import { createEmployee, listEmployees, validateCreateEmployeeInput } from "@/lib/employees";
|
||||||
|
|
||||||
|
// Bot-facing Employee list/create for the "assign to" step. `department`
|
||||||
|
// defaults to a placeholder here (not in src/lib/employees.ts itself, which
|
||||||
|
// keeps requiring it — this is a bot-flow-only convenience: the walk needs
|
||||||
|
// speed, department can be corrected later in the review UI) since the bot
|
||||||
|
// only asks for a name, not a full HR record.
|
||||||
|
export async function GET(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const employees = await listEmployees();
|
||||||
|
return NextResponse.json({ employees });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, department } = body as Record<string, unknown>;
|
||||||
|
const validation = validateCreateEmployeeInput({
|
||||||
|
name,
|
||||||
|
department: department || "Chưa cập nhật",
|
||||||
|
});
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createEmployee(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireBotAuth } from "@/lib/bot-auth";
|
||||||
|
import { createInventoryScan, validateCreateInventoryScanInput } from "@/lib/inventory-scans";
|
||||||
|
|
||||||
|
// Bot-facing scan ingest — the actual "save this item" call at the end of
|
||||||
|
// the Telegram bot's Category -> Model -> [specs] -> Area -> assignee flow.
|
||||||
|
export async function POST(request: Request): Promise<NextResponse> {
|
||||||
|
if (!requireBotAuth(request)) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
categoryId,
|
||||||
|
modelId,
|
||||||
|
areaId,
|
||||||
|
assignedEmployeeId,
|
||||||
|
assetTag,
|
||||||
|
serialNumber,
|
||||||
|
specs,
|
||||||
|
photoFileId,
|
||||||
|
telegramChatId,
|
||||||
|
telegramMessageId,
|
||||||
|
expectedAssetId,
|
||||||
|
} = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
const validation = validateCreateInventoryScanInput({
|
||||||
|
categoryId,
|
||||||
|
modelId,
|
||||||
|
areaId,
|
||||||
|
assignedEmployeeId,
|
||||||
|
assetTag,
|
||||||
|
serialNumber,
|
||||||
|
specs,
|
||||||
|
photoFileId,
|
||||||
|
telegramChatId,
|
||||||
|
telegramMessageId,
|
||||||
|
expectedAssetId,
|
||||||
|
});
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await createInventoryScan(validation.value);
|
||||||
|
if (!created.ok) {
|
||||||
|
return NextResponse.json({ error: created.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(created.value, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { getInventoryScan } from "@/lib/inventory-scans";
|
||||||
|
|
||||||
|
// Streams the scan's photo on demand straight from Telegram's file storage
|
||||||
|
// — see prisma/schema.prisma's InventoryScan.photoFileId comment for why
|
||||||
|
// this app doesn't keep its own copy on disk. Telegram's getFile file_path
|
||||||
|
// is short-lived, so this re-resolves it on every request rather than
|
||||||
|
// caching a URL.
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const scan = await getInventoryScan(params.id);
|
||||||
|
if (!scan || !scan.photo_file_id) {
|
||||||
|
return NextResponse.json({ error: "No photo for this scan" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const botToken = process.env.BOT_TOKEN;
|
||||||
|
if (!botToken) {
|
||||||
|
return NextResponse.json({ error: "BOT_TOKEN not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileResp = await fetch(
|
||||||
|
`https://api.telegram.org/bot${botToken}/getFile?file_id=${encodeURIComponent(scan.photo_file_id)}`
|
||||||
|
);
|
||||||
|
const fileJson = await fileResp.json();
|
||||||
|
if (!fileJson.ok) {
|
||||||
|
return NextResponse.json({ error: "Telegram getFile failed" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = fileJson.result.file_path as string;
|
||||||
|
const imageResp = await fetch(`https://api.telegram.org/file/bot${botToken}/${filePath}`);
|
||||||
|
if (!imageResp.ok || !imageResp.body) {
|
||||||
|
return NextResponse.json({ error: "Failed to fetch photo from Telegram" }, { status: 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Telegram serves file content as generic application/octet-stream
|
||||||
|
// regardless of the actual file type, which makes browsers download
|
||||||
|
// instead of display it inline — derive the real content type from the
|
||||||
|
// file_path extension instead of trusting imageResp's header.
|
||||||
|
const extension = filePath.split(".").pop()?.toLowerCase();
|
||||||
|
const contentType =
|
||||||
|
extension === "png"
|
||||||
|
? "image/png"
|
||||||
|
: extension === "webp"
|
||||||
|
? "image/webp"
|
||||||
|
: "image/jpeg";
|
||||||
|
|
||||||
|
return new NextResponse(imageResp.body, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": contentType,
|
||||||
|
"Content-Disposition": "inline",
|
||||||
|
"Cache-Control": "private, max-age=3600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import {
|
||||||
|
deleteInventoryScan,
|
||||||
|
updateInventoryScan,
|
||||||
|
validateUpdateInventoryScanInput,
|
||||||
|
} from "@/lib/inventory-scans";
|
||||||
|
|
||||||
|
// Review-page edit/delete — session-authed, mirrors
|
||||||
|
// src/app/api/assignments/[id]/route.ts's pattern (synchronous params,
|
||||||
|
// explicit UUID pre-check before hitting Prisma).
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.string().uuid().safeParse(params.id).success) {
|
||||||
|
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await request.json();
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Request body must be valid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (typeof body !== "object" || body === null) {
|
||||||
|
return NextResponse.json({ error: "Request body must be a JSON object" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { modelId, areaId, assignedEmployeeId, assetTag, serialNumber, specs } =
|
||||||
|
body as Record<string, unknown>;
|
||||||
|
const validation = validateUpdateInventoryScanInput({
|
||||||
|
modelId,
|
||||||
|
areaId,
|
||||||
|
assignedEmployeeId,
|
||||||
|
assetTag,
|
||||||
|
serialNumber,
|
||||||
|
specs,
|
||||||
|
});
|
||||||
|
if (!validation.ok) {
|
||||||
|
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await updateInventoryScan(params.id, validation.value);
|
||||||
|
if (!updated.ok) {
|
||||||
|
const status = updated.error === "InventoryScan not found" ? 404 : 400;
|
||||||
|
return NextResponse.json({ error: updated.error }, { status });
|
||||||
|
}
|
||||||
|
return NextResponse.json(updated.value, { status: 200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!z.string().uuid().safeParse(params.id).success) {
|
||||||
|
return NextResponse.json({ error: "id must be a valid UUID string" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await deleteInventoryScan(params.id);
|
||||||
|
if (!result.ok) {
|
||||||
|
return NextResponse.json({ error: result.error }, { status: 404 });
|
||||||
|
}
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { listInventoryScans } from "@/lib/inventory-scans";
|
||||||
|
|
||||||
|
// Review-page read — session-authed like every other human-facing route
|
||||||
|
// (src/app/api/assets/route.ts's GET is the mirrored pattern). Create is
|
||||||
|
// bot-only (POST /api/bot/inventory-scans); edit/delete are
|
||||||
|
// /api/inventory-scans/[id].
|
||||||
|
export async function GET(): Promise<NextResponse> {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const scans = await listInventoryScans();
|
||||||
|
return NextResponse.json(scans, { status: 200 });
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ interface AssetModelRecord {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Record<string, unknown>;
|
specs: Record<string, unknown>;
|
||||||
|
warranty_months: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export default function AssetModelsPage() {
|
|||||||
const [manufacturer, setManufacturer] = useState("");
|
const [manufacturer, setManufacturer] = useState("");
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
|
const [specsText, setSpecsText] = useState(DEFAULT_SPECS_TEXT);
|
||||||
|
const [warrantyMonths, setWarrantyMonths] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -83,11 +85,24 @@ export default function AssetModelsPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const trimmedWarranty = warrantyMonths.trim();
|
||||||
|
if (trimmedWarranty !== "" && Number.isNaN(Number(trimmedWarranty))) {
|
||||||
|
setSubmitError("Warranty (months) must be a number");
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/asset-models", {
|
const response = await fetch("/api/asset-models", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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;
|
let body: unknown;
|
||||||
@@ -113,6 +128,7 @@ export default function AssetModelsPage() {
|
|||||||
setManufacturer("");
|
setManufacturer("");
|
||||||
setName("");
|
setName("");
|
||||||
setSpecsText(DEFAULT_SPECS_TEXT);
|
setSpecsText(DEFAULT_SPECS_TEXT);
|
||||||
|
setWarrantyMonths("");
|
||||||
refetch();
|
refetch();
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError("Request failed");
|
setSubmitError("Request failed");
|
||||||
@@ -139,6 +155,7 @@ export default function AssetModelsPage() {
|
|||||||
<TableHead>Manufacturer</TableHead>
|
<TableHead>Manufacturer</TableHead>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Specs</TableHead>
|
<TableHead>Specs</TableHead>
|
||||||
|
<TableHead>Warranty (months)</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>Created At</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -150,6 +167,7 @@ export default function AssetModelsPage() {
|
|||||||
<TableCell>{assetModel.manufacturer}</TableCell>
|
<TableCell>{assetModel.manufacturer}</TableCell>
|
||||||
<TableCell>{assetModel.name}</TableCell>
|
<TableCell>{assetModel.name}</TableCell>
|
||||||
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
|
<TableCell>{JSON.stringify(assetModel.specs)}</TableCell>
|
||||||
|
<TableCell>{assetModel.warranty_months ?? "-"}</TableCell>
|
||||||
<TableCell>{assetModel.created_at}</TableCell>
|
<TableCell>{assetModel.created_at}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -208,6 +226,23 @@ export default function AssetModelsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 ? (
|
{submitError ? (
|
||||||
<p className="text-sm text-destructive">{submitError}</p>
|
<p className="text-sm text-destructive">{submitError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+568
-21
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||||
import { useApiList } from "@/hooks/use-api-list";
|
import { useApiList } from "@/hooks/use-api-list";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -26,23 +26,61 @@ import {
|
|||||||
interface AssetItemRecord {
|
interface AssetItemRecord {
|
||||||
id: string;
|
id: string;
|
||||||
serial_number: string;
|
serial_number: string;
|
||||||
|
asset_tag: string | null;
|
||||||
model_id: string;
|
model_id: string;
|
||||||
purchase_date: string;
|
purchase_date: string | null;
|
||||||
warranty_end_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;
|
purchase_order_id: string | null;
|
||||||
|
external_po_number: string | null;
|
||||||
created_at: string;
|
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
|
// 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
|
// 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 {
|
interface AssetModelRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
category_id: string;
|
||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: 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
|
// Shape returned by GET /api/purchase-orders — only the id, used to populate
|
||||||
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
// the purchaseOrderId <Select> here. PurchaseOrder has no obviously
|
||||||
// human-readable name field (see src/app/purchase-orders/page.tsx's
|
// 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.
|
// NOT_RECEIVED_VALUE / employees page's NO_MANAGER_VALUE.
|
||||||
const NO_PURCHASE_ORDER_VALUE = "none";
|
const NO_PURCHASE_ORDER_VALUE = "none";
|
||||||
|
|
||||||
export default function AssetsPage() {
|
// Same sentinel pattern as NO_PURCHASE_ORDER_VALUE above, for the optional
|
||||||
const {
|
// Area <Select> in the create form.
|
||||||
data: assetItems,
|
const NO_AREA_VALUE = "none";
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useApiList<AssetItemRecord>("/api/assets");
|
|
||||||
|
|
||||||
|
// 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: assetModels } = useApiList<AssetModelRecord>("/api/asset-models");
|
||||||
|
const { data: assetCategories } = useApiList<AssetCategoryRecord>("/api/asset-categories");
|
||||||
const { data: purchaseOrders } = useApiList<PurchaseOrderRecord>("/api/purchase-orders");
|
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 [serialNumber, setSerialNumber] = useState("");
|
||||||
|
const [assetTag, setAssetTag] = useState("");
|
||||||
const [modelId, setModelId] = useState("");
|
const [modelId, setModelId] = useState("");
|
||||||
const [purchaseDate, setPurchaseDate] = useState("");
|
const [purchaseDate, setPurchaseDate] = useState("");
|
||||||
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
const [warrantyEndDate, setWarrantyEndDate] = useState("");
|
||||||
const [purchaseOrderId, setPurchaseOrderId] = useState(NO_PURCHASE_ORDER_VALUE);
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
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>) {
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
|
const hasAssetTag = assetTag.trim() !== "";
|
||||||
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
const hasWarrantyEndDate = warrantyEndDate.trim() !== "";
|
||||||
const hasPurchaseOrderId = purchaseOrderId !== NO_PURCHASE_ORDER_VALUE;
|
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 {
|
try {
|
||||||
const response = await fetch("/api/assets", {
|
const response = await fetch("/api/assets", {
|
||||||
@@ -96,8 +394,13 @@ export default function AssetsPage() {
|
|||||||
// Omit optional keys entirely when left blank, rather than
|
// Omit optional keys entirely when left blank, rather than
|
||||||
// sending null — matches the create-form pattern used on other
|
// sending null — matches the create-form pattern used on other
|
||||||
// pages (e.g. purchase-orders page's receivedAt/receivedById).
|
// pages (e.g. purchase-orders page's receivedAt/receivedById).
|
||||||
|
...(hasAssetTag ? { assetTag } : {}),
|
||||||
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
...(hasWarrantyEndDate ? { warrantyEndDate } : {}),
|
||||||
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
...(hasPurchaseOrderId ? { purchaseOrderId } : {}),
|
||||||
|
...(hasAreaId ? { areaId } : {}),
|
||||||
|
...(hasCost ? { cost: Number(cost) } : {}),
|
||||||
|
...(hasCostCurrency ? { costCurrency } : {}),
|
||||||
|
...(hasExternalPoNumber ? { externalPoNumber } : {}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -121,10 +424,15 @@ export default function AssetsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setSerialNumber("");
|
setSerialNumber("");
|
||||||
|
setAssetTag("");
|
||||||
setModelId("");
|
setModelId("");
|
||||||
setPurchaseDate("");
|
setPurchaseDate("");
|
||||||
setWarrantyEndDate("");
|
setWarrantyEndDate("");
|
||||||
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
setPurchaseOrderId(NO_PURCHASE_ORDER_VALUE);
|
||||||
|
setAreaId(NO_AREA_VALUE);
|
||||||
|
setCost("");
|
||||||
|
setCostCurrency("");
|
||||||
|
setExternalPoNumber("");
|
||||||
refetch();
|
refetch();
|
||||||
} catch {
|
} catch {
|
||||||
setSubmitError("Request failed");
|
setSubmitError("Request failed");
|
||||||
@@ -138,6 +446,96 @@ export default function AssetsPage() {
|
|||||||
<h1 className="text-xl font-semibold">Assets</h1>
|
<h1 className="text-xl font-semibold">Assets</h1>
|
||||||
|
|
||||||
<section className="flex flex-col gap-3">
|
<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 ? (
|
{loading ? (
|
||||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
@@ -146,32 +544,123 @@ export default function AssetsPage() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>ID</TableHead>
|
<TableHead>#</TableHead>
|
||||||
<TableHead>Serial Number</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>Purchase Date</TableHead>
|
||||||
<TableHead>Warranty End Date</TableHead>
|
<TableHead>Warranty End Date</TableHead>
|
||||||
<TableHead>Status</TableHead>
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Cost</TableHead>
|
||||||
<TableHead>Purchase Order ID</TableHead>
|
<TableHead>Purchase Order ID</TableHead>
|
||||||
<TableHead>Created At</TableHead>
|
<TableHead>External PO Number</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{assetItems.map((assetItem) => (
|
{assetItems.map((assetItem, index) => (
|
||||||
<TableRow key={assetItem.id}>
|
<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.serial_number}</TableCell>
|
||||||
<TableCell>{assetItem.model_id}</TableCell>
|
<TableCell>{assetItem.asset_tag ?? "-"}</TableCell>
|
||||||
<TableCell>{assetItem.purchase_date}</TableCell>
|
<TableCell>{modelNameById.get(assetItem.model_id) ?? assetItem.model_id}</TableCell>
|
||||||
<TableCell>{assetItem.warranty_end_date ?? "-"}</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.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{assetItem.cost !== null
|
||||||
|
? `${assetItem.cost}${assetItem.cost_currency ? " " + assetItem.cost_currency : ""}`
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
<TableCell>{assetItem.purchase_order_id ?? "-"}</TableCell>
|
||||||
<TableCell>{assetItem.created_at}</TableCell>
|
<TableCell>{assetItem.external_po_number ?? "-"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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>
|
||||||
|
|
||||||
<section className="flex max-w-sm flex-col gap-4">
|
<section className="flex max-w-sm flex-col gap-4">
|
||||||
@@ -188,6 +677,15 @@ export default function AssetsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex flex-col gap-2">
|
||||||
<Label htmlFor="modelId">Model</Label>
|
<Label htmlFor="modelId">Model</Label>
|
||||||
<Select value={modelId} onValueChange={setModelId} required>
|
<Select value={modelId} onValueChange={setModelId} required>
|
||||||
@@ -197,7 +695,7 @@ export default function AssetsPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
{assetModels.map((assetModel) => (
|
{assetModels.map((assetModel) => (
|
||||||
<SelectItem key={assetModel.id} value={assetModel.id}>
|
<SelectItem key={assetModel.id} value={assetModel.id}>
|
||||||
{assetModel.manufacturer} {assetModel.name}
|
{formatModelLabel(assetModel.manufacturer, assetModel.name)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
@@ -242,6 +740,55 @@ export default function AssetsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</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}
|
{submitError ? <p className="text-sm text-destructive">{submitError}</p> : null}
|
||||||
|
|
||||||
<Button type="submit" disabled={submitting}>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useApiList } from "@/hooks/use-api-list";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
|
// Shape returned by GET /api/inventory-scans — snake_case per this repo's
|
||||||
|
// API-boundary convention (see src/lib/inventory-scans.ts's toRecord()).
|
||||||
|
interface InventoryScanRecord {
|
||||||
|
id: string;
|
||||||
|
category_id: string;
|
||||||
|
category_name: string;
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
model_manufacturer: string;
|
||||||
|
area_id: string | null;
|
||||||
|
area_name: string | null;
|
||||||
|
assigned_employee_id: string | null;
|
||||||
|
assigned_employee_name: string | null;
|
||||||
|
asset_tag: string | null;
|
||||||
|
serial_number: string | null;
|
||||||
|
specs: Record<string, unknown>;
|
||||||
|
photo_file_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AreaRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
team: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmployeeRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssetModelRecord {
|
||||||
|
id: string;
|
||||||
|
category_id: string;
|
||||||
|
manufacturer: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NO_AREA_VALUE = "none";
|
||||||
|
const NO_EMPLOYEE_VALUE = "none";
|
||||||
|
|
||||||
|
export default function InventoryScansPage() {
|
||||||
|
const { data: scans, loading, error, refetch } = useApiList<InventoryScanRecord>(
|
||||||
|
"/api/inventory-scans"
|
||||||
|
);
|
||||||
|
const { data: areas } = useApiList<AreaRecord>("/api/areas");
|
||||||
|
const { data: employees } = useApiList<EmployeeRecord>("/api/employees");
|
||||||
|
const { data: models } = useApiList<AssetModelRecord>("/api/asset-models");
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editModelId, setEditModelId] = useState("");
|
||||||
|
const [editAreaId, setEditAreaId] = useState(NO_AREA_VALUE);
|
||||||
|
const [editEmployeeId, setEditEmployeeId] = useState(NO_EMPLOYEE_VALUE);
|
||||||
|
const [editAssetTag, setEditAssetTag] = useState("");
|
||||||
|
const [editSerialNumber, setEditSerialNumber] = useState("");
|
||||||
|
const [editSpecs, setEditSpecs] = useState<Record<string, string>>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [rowError, setRowError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function startEdit(scan: InventoryScanRecord) {
|
||||||
|
setEditingId(scan.id);
|
||||||
|
setEditModelId(scan.model_id);
|
||||||
|
setEditAreaId(scan.area_id ?? NO_AREA_VALUE);
|
||||||
|
setEditEmployeeId(scan.assigned_employee_id ?? NO_EMPLOYEE_VALUE);
|
||||||
|
setEditAssetTag(scan.asset_tag ?? "");
|
||||||
|
setEditSerialNumber(scan.serial_number ?? "");
|
||||||
|
setEditSpecs(
|
||||||
|
Object.fromEntries(Object.entries(scan.specs).map(([k, v]) => [k, String(v ?? "")]))
|
||||||
|
);
|
||||||
|
setRowError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
setEditingId(null);
|
||||||
|
setRowError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(scan: InventoryScanRecord) {
|
||||||
|
setSaving(true);
|
||||||
|
setRowError(null);
|
||||||
|
try {
|
||||||
|
const specs: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(editSpecs)) {
|
||||||
|
const asNumber = Number(v);
|
||||||
|
specs[k] = v !== "" && !Number.isNaN(asNumber) && /^-?\d+(\.\d+)?$/.test(v) ? asNumber : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/inventory-scans/${scan.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
modelId: editModelId,
|
||||||
|
areaId: editAreaId === NO_AREA_VALUE ? null : editAreaId,
|
||||||
|
assignedEmployeeId: editEmployeeId === NO_EMPLOYEE_VALUE ? null : editEmployeeId,
|
||||||
|
assetTag: editAssetTag.trim() === "" ? null : editAssetTag.trim(),
|
||||||
|
serialNumber: editSerialNumber.trim() === "" ? null : editSerialNumber.trim(),
|
||||||
|
specs,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await response.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
body !== null && typeof body === "object" && "error" in body
|
||||||
|
? String((body as { error: unknown }).error)
|
||||||
|
: "Request failed";
|
||||||
|
setRowError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingId(null);
|
||||||
|
refetch();
|
||||||
|
} catch {
|
||||||
|
setRowError("Request failed");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
if (!confirm("Xoá bản ghi này?")) return;
|
||||||
|
const response = await fetch(`/api/inventory-scans/${id}`, { method: "DELETE" });
|
||||||
|
if (response.ok) {
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-6">
|
||||||
|
<h1 className="text-xl font-semibold">Inventory Scans (Move Project)</h1>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="text-sm text-destructive">{error}</p>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Ảnh</TableHead>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Model</TableHead>
|
||||||
|
<TableHead>Asset Tag</TableHead>
|
||||||
|
<TableHead>Serial Number</TableHead>
|
||||||
|
<TableHead>Specs</TableHead>
|
||||||
|
<TableHead>Area</TableHead>
|
||||||
|
<TableHead>Assigned to</TableHead>
|
||||||
|
<TableHead>Created At</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{scans.map((scan) => {
|
||||||
|
const isEditing = editingId === scan.id;
|
||||||
|
const modelsInCategory = models.filter((m) => m.category_id === scan.category_id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={scan.id}>
|
||||||
|
<TableCell>
|
||||||
|
{scan.photo_file_id ? (
|
||||||
|
<a
|
||||||
|
href={`/api/inventory-scans/${scan.id}/photo`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-blue-500 underline"
|
||||||
|
>
|
||||||
|
Xem ảnh
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
"-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{scan.category_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<Select value={editModelId} onValueChange={setEditModelId}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{modelsInCategory.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.manufacturer} {m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
`${scan.model_manufacturer} ${scan.model_name}`
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
className="h-8 w-28"
|
||||||
|
value={editAssetTag}
|
||||||
|
onChange={(e) => setEditAssetTag(e.target.value)}
|
||||||
|
placeholder="(chưa có)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
scan.asset_tag ?? "-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
className="h-8 w-32"
|
||||||
|
value={editSerialNumber}
|
||||||
|
onChange={(e) => setEditSerialNumber(e.target.value)}
|
||||||
|
placeholder="(chưa có)"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
scan.serial_number ?? "-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
{Object.keys(editSpecs).length === 0 ? (
|
||||||
|
<span className="text-muted-foreground text-xs">(không có)</span>
|
||||||
|
) : (
|
||||||
|
Object.entries(editSpecs).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-center gap-1">
|
||||||
|
<span className="w-16 text-xs text-muted-foreground">{key}</span>
|
||||||
|
<Input
|
||||||
|
className="h-7"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditSpecs((prev) => ({ ...prev, [key]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : Object.keys(scan.specs).length === 0 ? (
|
||||||
|
"-"
|
||||||
|
) : (
|
||||||
|
Object.entries(scan.specs)
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(", ")
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<Select value={editAreaId} onValueChange={setEditAreaId}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_AREA_VALUE}>None</SelectItem>
|
||||||
|
{areas.map((a) => (
|
||||||
|
<SelectItem key={a.id} value={a.id}>
|
||||||
|
{a.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
scan.area_name ?? "-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<Select value={editEmployeeId} onValueChange={setEditEmployeeId}>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_EMPLOYEE_VALUE}>None</SelectItem>
|
||||||
|
{employees.map((e) => (
|
||||||
|
<SelectItem key={e.id} value={e.id}>
|
||||||
|
{e.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
scan.assigned_employee_name ?? "-"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{scan.created_at}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" disabled={saving} onClick={() => saveEdit(scan)}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={cancelEdit}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{rowError ? <p className="text-xs text-destructive">{rowError}</p> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => startEdit(scan)}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleDelete(scan.id)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ interface VendorRecord {
|
|||||||
// assetItemStatusValues constant (not re-derived from an API call, this is
|
// assetItemStatusValues constant (not re-derived from an API call, this is
|
||||||
// a closed set already known client-side, same precedent as licenses
|
// a closed set already known client-side, same precedent as licenses
|
||||||
// page's licenseType/perpetual|subscription <Select>).
|
// 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
|
// 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
|
// represented by this sentinel in the form state and translated to "omit
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const ENTITY_LINKS = [
|
|||||||
{ href: "/requests", label: "Requests" },
|
{ href: "/requests", label: "Requests" },
|
||||||
{ href: "/assignments", label: "Assignments" },
|
{ href: "/assignments", label: "Assignments" },
|
||||||
{ href: "/maintenance-records", label: "Maintenance Records" },
|
{ href: "/maintenance-records", label: "Maintenance Records" },
|
||||||
|
{ href: "/inventory-scans", label: "Inventory Scans (Move)" },
|
||||||
|
{ href: "/inventory-scans/import", label: "Import Expected Assets" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export function Nav() {
|
export function Nav() {
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Area — physical location tag for the office-move inventory walk (see
|
||||||
|
// prisma/schema.prisma's Area model comment). Same shape/conventions as
|
||||||
|
// src/lib/employees.ts: validate -> create (P2002 on duplicate name) ->
|
||||||
|
// list.
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export interface AreaRecord {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
team: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAreaInput {
|
||||||
|
name: string;
|
||||||
|
team?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult =
|
||||||
|
| { ok: true; value: CreateAreaInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
function nonEmptyString(message: string) {
|
||||||
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
||||||
|
(value) => value.trim() !== "",
|
||||||
|
{ message }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createAreaSchema = z.object({
|
||||||
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
||||||
|
team: z.string({ invalid_type_error: "team must be a string" }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function validateCreateAreaInput(input: {
|
||||||
|
name?: unknown;
|
||||||
|
team?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createAreaSchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(row: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
team: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
}): AreaRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
team: row.team,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAreaResult = { ok: true; value: AreaRecord } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an Area, or returns the existing one if `name` already exists
|
||||||
|
* (idempotent-by-name) — this is deliberate, not just P2002 error handling:
|
||||||
|
* the bot's "reuse existing or type a new one" flow calls this every time a
|
||||||
|
* user picks/types an Area name, and re-submitting an Area the bot already
|
||||||
|
* showed as an existing option must succeed, not 409.
|
||||||
|
*/
|
||||||
|
export async function createOrGetArea(input: CreateAreaInput): Promise<CreateAreaResult> {
|
||||||
|
try {
|
||||||
|
const created = await prisma.area.create({
|
||||||
|
data: { name: input.name, team: input.team ?? null },
|
||||||
|
});
|
||||||
|
return { ok: true, value: toRecord(created) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||||
|
const existing = await prisma.area.findUnique({ where: { name: input.name } });
|
||||||
|
if (existing) {
|
||||||
|
return { ok: true, value: toRecord(existing) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAreas(): Promise<AreaRecord[]> {
|
||||||
|
const rows = await prisma.area.findMany({ orderBy: [{ name: "asc" }] });
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export interface AssetModelRecord {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Record<string, unknown>;
|
specs: Record<string, unknown>;
|
||||||
|
warranty_months: number | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ export interface CreateAssetModelInput {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Record<string, unknown>;
|
specs: Record<string, unknown>;
|
||||||
|
warrantyMonths?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ValidationResult =
|
export type ValidationResult =
|
||||||
@@ -57,6 +59,14 @@ const createAssetModelSchema = z.object({
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.default({}),
|
.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;
|
manufacturer?: unknown;
|
||||||
name?: unknown;
|
name?: unknown;
|
||||||
specs?: unknown;
|
specs?: unknown;
|
||||||
|
warrantyMonths?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createAssetModelSchema.safeParse(input);
|
const result = createAssetModelSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -86,6 +97,7 @@ function toRecord(row: {
|
|||||||
manufacturer: string;
|
manufacturer: string;
|
||||||
name: string;
|
name: string;
|
||||||
specs: Prisma.JsonValue;
|
specs: Prisma.JsonValue;
|
||||||
|
warrantyMonths: number | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): AssetModelRecord {
|
}): AssetModelRecord {
|
||||||
return {
|
return {
|
||||||
@@ -94,6 +106,7 @@ function toRecord(row: {
|
|||||||
manufacturer: row.manufacturer,
|
manufacturer: row.manufacturer,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
specs: (row.specs as Record<string, unknown>) ?? {},
|
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||||
|
warranty_months: row.warrantyMonths,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -201,6 +214,7 @@ export async function createAssetModel(
|
|||||||
// is safe here since the Zod schema already guarantees JSON-shaped
|
// is safe here since the Zod schema already guarantees JSON-shaped
|
||||||
// values.
|
// values.
|
||||||
specs: input.specs as Prisma.InputJsonValue,
|
specs: input.specs as Prisma.InputJsonValue,
|
||||||
|
warrantyMonths: input.warrantyMonths ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, value: toRecord(created) };
|
return { ok: true, value: toRecord(created) };
|
||||||
|
|||||||
+631
-16
@@ -1,26 +1,55 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { prisma } from "./prisma";
|
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 {
|
export interface AssetItemRecord {
|
||||||
id: string;
|
id: string;
|
||||||
serial_number: string;
|
serial_number: string;
|
||||||
|
asset_tag: string | null;
|
||||||
model_id: string;
|
model_id: string;
|
||||||
purchase_date: string;
|
purchase_date: string | null;
|
||||||
warranty_end_date: string | null;
|
warranty_end_date: string | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
|
cost: number | null;
|
||||||
|
cost_currency: string | null;
|
||||||
|
area_id: string | null;
|
||||||
purchase_order_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;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAssetItemInput {
|
export interface CreateAssetItemInput {
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
|
assetTag?: string | null;
|
||||||
modelId: string;
|
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;
|
warrantyEndDate?: Date;
|
||||||
|
cost?: number | null;
|
||||||
|
costCurrency?: string | null;
|
||||||
|
areaId?: string | null;
|
||||||
purchaseOrderId?: 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 =
|
export type ValidationResult =
|
||||||
@@ -47,6 +76,10 @@ function dateString(message: string) {
|
|||||||
|
|
||||||
const createAssetItemSchema = z.object({
|
const createAssetItemSchema = z.object({
|
||||||
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
serialNumber: nonEmptyString("serialNumber is required and must be a non-empty string"),
|
||||||
|
assetTag: z
|
||||||
|
.string({ invalid_type_error: "assetTag must be a string" })
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
// Required FK to AssetModel — replaced the free-text `model` String (see
|
// 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
|
// 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
|
// (2)" section). Only a UUID *format* check here, whether it references a
|
||||||
@@ -56,7 +89,7 @@ const createAssetItemSchema = z.object({
|
|||||||
modelId: z
|
modelId: z
|
||||||
.string({ required_error: "modelId is required and must be a valid UUID string" })
|
.string({ required_error: "modelId is required and must be a valid UUID string" })
|
||||||
.uuid("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(),
|
warrantyEndDate: dateString("warrantyEndDate must be a valid date string").optional(),
|
||||||
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
// FK to PurchaseOrder — nullable/optional, supply-side traceability (not
|
||||||
// every AssetItem goes through a formal PO). Only a UUID *format* check
|
// 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")
|
.uuid("purchaseOrderId must be a valid UUID string")
|
||||||
.optional()
|
.optional()
|
||||||
.nullable(),
|
.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
|
* Validates raw (untyped) asset-item-creation input. serialNumber must be a
|
||||||
* non-empty string, modelId must be a valid UUID string, purchaseDate must
|
* non-empty string, assetTag is optional/nullable free text (no format
|
||||||
* be a valid date string, warrantyEndDate is optional but must be a valid
|
* check -- company tag schemes vary), modelId must be a valid UUID string,
|
||||||
* date string if present. purchaseOrderId is optional/nullable but must be
|
* purchaseDate must be a valid date string, warrantyEndDate is optional
|
||||||
* a valid UUID if present. status is not accepted here — new assets always
|
* but must be a valid date string if present. purchaseOrderId is
|
||||||
* start "in_stock" (same pattern as Request always starting "Draft"), later
|
* optional/nullable but must be a valid UUID if present. status is not
|
||||||
* states are set via other operations, not at creation.
|
* 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: {
|
export function validateCreateAssetItemInput(input: {
|
||||||
serialNumber?: unknown;
|
serialNumber?: unknown;
|
||||||
|
assetTag?: unknown;
|
||||||
modelId?: unknown;
|
modelId?: unknown;
|
||||||
purchaseDate?: unknown;
|
purchaseDate?: unknown;
|
||||||
warrantyEndDate?: unknown;
|
warrantyEndDate?: unknown;
|
||||||
purchaseOrderId?: unknown;
|
purchaseOrderId?: unknown;
|
||||||
|
cost?: unknown;
|
||||||
|
costCurrency?: unknown;
|
||||||
|
externalPoNumber?: unknown;
|
||||||
|
areaId?: unknown;
|
||||||
}): ValidationResult {
|
}): ValidationResult {
|
||||||
const result = createAssetItemSchema.safeParse(input);
|
const result = createAssetItemSchema.safeParse(input);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -98,21 +160,31 @@ export function validateCreateAssetItemInput(input: {
|
|||||||
function toRecord(row: {
|
function toRecord(row: {
|
||||||
id: string;
|
id: string;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
|
assetTag: string | null;
|
||||||
modelId: string;
|
modelId: string;
|
||||||
purchaseDate: Date;
|
purchaseDate: Date | null;
|
||||||
warrantyEndDate: Date | null;
|
warrantyEndDate: Date | null;
|
||||||
status: AssetItemStatus;
|
status: AssetItemStatus;
|
||||||
|
cost: Prisma.Decimal | null;
|
||||||
|
costCurrency: string | null;
|
||||||
|
areaId: string | null;
|
||||||
purchaseOrderId: string | null;
|
purchaseOrderId: string | null;
|
||||||
|
externalPoNumber: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}): AssetItemRecord {
|
}): AssetItemRecord {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
serial_number: row.serialNumber,
|
serial_number: row.serialNumber,
|
||||||
|
asset_tag: row.assetTag,
|
||||||
model_id: row.modelId,
|
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,
|
warranty_end_date: row.warrantyEndDate ? row.warrantyEndDate.toISOString() : null,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
|
cost: row.cost ? row.cost.toNumber() : null,
|
||||||
|
cost_currency: row.costCurrency,
|
||||||
|
area_id: row.areaId,
|
||||||
purchase_order_id: row.purchaseOrderId,
|
purchase_order_id: row.purchaseOrderId,
|
||||||
|
external_po_number: row.externalPoNumber,
|
||||||
created_at: row.createdAt.toISOString(),
|
created_at: row.createdAt.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -136,38 +208,581 @@ export type CreateAssetItemResult =
|
|||||||
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
* createPurchaseOrder() pattern for disambiguating between two FKs on the
|
||||||
* same model.
|
* 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(
|
export async function createAssetItem(
|
||||||
input: CreateAssetItemInput
|
input: CreateAssetItemInput
|
||||||
): Promise<CreateAssetItemResult> {
|
): Promise<CreateAssetItemResult> {
|
||||||
try {
|
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({
|
const created = await prisma.assetItem.create({
|
||||||
data: {
|
data: {
|
||||||
serialNumber: input.serialNumber,
|
serialNumber: input.serialNumber,
|
||||||
|
assetTag: input.assetTag ?? null,
|
||||||
modelId: input.modelId,
|
modelId: input.modelId,
|
||||||
purchaseDate: input.purchaseDate,
|
purchaseDate: input.purchaseDate ?? null,
|
||||||
warrantyEndDate: input.warrantyEndDate ?? null,
|
warrantyEndDate,
|
||||||
|
cost: input.cost ?? null,
|
||||||
|
costCurrency: input.costCurrency ?? null,
|
||||||
|
areaId: input.areaId ?? null,
|
||||||
purchaseOrderId: input.purchaseOrderId ?? null,
|
purchaseOrderId: input.purchaseOrderId ?? null,
|
||||||
|
externalPoNumber: input.externalPoNumber ?? null,
|
||||||
|
...(input.status ? { status: input.status } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { ok: true, value: toRecord(created) };
|
return { ok: true, value: toRecord(created) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
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" };
|
return { ok: false, error: "serialNumber already exists" };
|
||||||
}
|
}
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2003") {
|
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 : "";
|
const fieldName = typeof error.meta?.field_name === "string" ? error.meta.field_name : "";
|
||||||
if (fieldName.includes("purchase_order")) {
|
if (fieldName.includes("purchase_order")) {
|
||||||
return { ok: false, error: "purchaseOrderId does not reference an existing 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" };
|
return { ok: false, error: "modelId does not reference an existing asset model" };
|
||||||
}
|
}
|
||||||
throw error;
|
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({
|
const rows = await prisma.assetItem.findMany({
|
||||||
|
include: { model: true, area: true },
|
||||||
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
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
|
* 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
|
* ACTIVE Assignment (returnedAt IS NULL) for it (explicit
|
||||||
* belt-and-suspenders invariant per the doc, on top of the status check),
|
* 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
|
* - License branch: rejects if the License doesn't exist, rejects if
|
||||||
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
|
* seatsUsed >= seatsTotal (no overbooking), then atomically increments
|
||||||
* seatsUsed by 1.
|
* seatsUsed by 1.
|
||||||
@@ -238,7 +238,7 @@ export type CreateAssignmentResult =
|
|||||||
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
|
* `updateMany` with a WHERE guard" pattern instead of a plain read-then-write
|
||||||
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
|
* (`findUnique` then `update`). The WHERE-guarded `updateMany` — e.g.
|
||||||
* `updateMany({ where: { id, status: "in_stock" }, data: { status:
|
* `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
|
* evaluates and applies that as a single atomic UPDATE statement, taking a
|
||||||
* row lock for its duration. Two concurrent transactions racing on the same
|
* row lock for its duration. Two concurrent transactions racing on the same
|
||||||
* AssetItem/License row serialize on that lock — the first to commit wins,
|
* 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
|
* is intentionally layered BEFORE the atomic status update, as defense in
|
||||||
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
|
* depth against AssetItem.status/Assignment-row drift (e.g. manual DB
|
||||||
* intervention) — under normal operation this codebase always keeps
|
* 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
|
* the atomic status-guarded `updateMany` above is what actually closes the
|
||||||
* concurrency race for the AssetItem branch; this check does not need its
|
* 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
|
* 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.
|
// Atomic race-closing gate — see this function's doc comment.
|
||||||
const statusUpdate = await tx.assetItem.updateMany({
|
const statusUpdate = await tx.assetItem.updateMany({
|
||||||
where: { id: input.assetItemId, status: "in_stock" },
|
where: { id: input.assetItemId, status: "in_stock" },
|
||||||
data: { status: "assigned" },
|
data: { status: "in_use" },
|
||||||
});
|
});
|
||||||
if (statusUpdate.count === 0) {
|
if (statusUpdate.count === 0) {
|
||||||
throw new AssignmentBusinessError(
|
throw new AssignmentBusinessError(
|
||||||
@@ -463,7 +463,7 @@ export type TransferAssignmentResult =
|
|||||||
* transfer (no race window for a third party to grab either one mid-transfer
|
* 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
|
* — 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
|
* 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).
|
* seatsUsed is net zero (same seat, new holder).
|
||||||
*
|
*
|
||||||
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
* Race-condition note: mirrors returnAssignment()'s exact race-closing
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Shared-secret auth for the /api/bot/* routes — the inventory-capture
|
||||||
|
// Telegram bot (a separate Python service, not a browser) calls these, so
|
||||||
|
// it can't go through the Auth.js session/cookie flow the rest of the app
|
||||||
|
// uses (src/auth.ts). Deliberately a SEPARATE secret from AUTH_SECRET
|
||||||
|
// (session JWT signing key) and from the review-page User account's
|
||||||
|
// password — a machine credential, not a human one, so it can be rotated
|
||||||
|
// independently without touching either.
|
||||||
|
export function requireBotAuth(request: Request): boolean {
|
||||||
|
const header = request.headers.get("authorization") ?? "";
|
||||||
|
const expected = `Bearer ${process.env.BOT_INGEST_SECRET}`;
|
||||||
|
return Boolean(process.env.BOT_INGEST_SECRET) && header === expected;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// InventoryScan — one physical item logged during the office-move
|
||||||
|
// inventory walk (see prisma/schema.prisma's InventoryScan model comment).
|
||||||
|
// Two write paths: createInventoryScan() (bot ingest, POST
|
||||||
|
// /api/bot/inventory-scans) and updateInventoryScan() (review-page edit,
|
||||||
|
// PATCH /api/inventory-scans/[id]) — same list/toRecord read path for both
|
||||||
|
// the bot (none needed) and the review UI.
|
||||||
|
import { z } from "zod";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
|
export interface InventoryScanRecord {
|
||||||
|
id: string;
|
||||||
|
category_id: string;
|
||||||
|
category_name: string;
|
||||||
|
model_id: string;
|
||||||
|
model_name: string;
|
||||||
|
model_manufacturer: string;
|
||||||
|
area_id: string | null;
|
||||||
|
area_name: string | null;
|
||||||
|
assigned_employee_id: string | null;
|
||||||
|
assigned_employee_name: string | null;
|
||||||
|
asset_tag: string | null;
|
||||||
|
serial_number: string | null;
|
||||||
|
specs: Record<string, unknown>;
|
||||||
|
photo_file_id: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateInventoryScanInput {
|
||||||
|
categoryId: string;
|
||||||
|
modelId: string;
|
||||||
|
areaId?: string;
|
||||||
|
assignedEmployeeId?: string;
|
||||||
|
assetTag?: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
specs?: Record<string, unknown>;
|
||||||
|
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 =
|
||||||
|
| { ok: true; value: CreateInventoryScanInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const uuidField = (message: string) =>
|
||||||
|
z.string({ invalid_type_error: message }).uuid(message);
|
||||||
|
|
||||||
|
const createInventoryScanSchema = z.object({
|
||||||
|
categoryId: uuidField("categoryId must be a valid UUID string"),
|
||||||
|
modelId: uuidField("modelId must be a valid UUID string"),
|
||||||
|
areaId: uuidField("areaId must be a valid UUID string").optional(),
|
||||||
|
assignedEmployeeId: uuidField("assignedEmployeeId must be a valid UUID string").optional(),
|
||||||
|
assetTag: z.string({ invalid_type_error: "assetTag must be a string" }).optional(),
|
||||||
|
serialNumber: z.string({ invalid_type_error: "serialNumber must be a string" }).optional(),
|
||||||
|
specs: z.record(z.unknown(), { invalid_type_error: "specs must be a plain JSON object" }).optional(),
|
||||||
|
photoFileId: z.string({ invalid_type_error: "photoFileId must be a string" }).optional(),
|
||||||
|
telegramChatId: z.string({ invalid_type_error: "telegramChatId must be a string" }).optional(),
|
||||||
|
telegramMessageId: z
|
||||||
|
.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: {
|
||||||
|
categoryId?: unknown;
|
||||||
|
modelId?: unknown;
|
||||||
|
areaId?: unknown;
|
||||||
|
assignedEmployeeId?: unknown;
|
||||||
|
assetTag?: unknown;
|
||||||
|
serialNumber?: unknown;
|
||||||
|
specs?: unknown;
|
||||||
|
photoFileId?: unknown;
|
||||||
|
telegramChatId?: unknown;
|
||||||
|
telegramMessageId?: unknown;
|
||||||
|
expectedAssetId?: unknown;
|
||||||
|
}): ValidationResult {
|
||||||
|
const result = createInventoryScanSchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateInventoryScanInput {
|
||||||
|
modelId?: string;
|
||||||
|
areaId?: string | null;
|
||||||
|
assignedEmployeeId?: string | null;
|
||||||
|
assetTag?: string | null;
|
||||||
|
serialNumber?: string | null;
|
||||||
|
specs?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateInventoryScanSchema = z.object({
|
||||||
|
modelId: uuidField("modelId must be a valid UUID string").optional(),
|
||||||
|
areaId: uuidField("areaId must be a valid UUID string").nullable().optional(),
|
||||||
|
assignedEmployeeId: uuidField("assignedEmployeeId must be a valid UUID string").nullable().optional(),
|
||||||
|
assetTag: z.string({ invalid_type_error: "assetTag must be a string" }).nullable().optional(),
|
||||||
|
serialNumber: z.string({ invalid_type_error: "serialNumber must be a string" }).nullable().optional(),
|
||||||
|
specs: z.record(z.unknown(), { invalid_type_error: "specs must be a plain JSON object" }).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateValidationResult =
|
||||||
|
| { ok: true; value: UpdateInventoryScanInput }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export function validateUpdateInventoryScanInput(input: {
|
||||||
|
modelId?: unknown;
|
||||||
|
areaId?: unknown;
|
||||||
|
assignedEmployeeId?: unknown;
|
||||||
|
assetTag?: unknown;
|
||||||
|
serialNumber?: unknown;
|
||||||
|
specs?: unknown;
|
||||||
|
}): UpdateValidationResult {
|
||||||
|
const result = updateInventoryScanSchema.safeParse(input);
|
||||||
|
if (!result.success) {
|
||||||
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
||||||
|
return { ok: false, error: message };
|
||||||
|
}
|
||||||
|
return { ok: true, value: result.data };
|
||||||
|
}
|
||||||
|
|
||||||
|
const includeForRecord = {
|
||||||
|
category: true,
|
||||||
|
model: true,
|
||||||
|
area: true,
|
||||||
|
assignedEmployee: true,
|
||||||
|
} satisfies Prisma.InventoryScanInclude;
|
||||||
|
|
||||||
|
type ScanWithRelations = Prisma.InventoryScanGetPayload<{ include: typeof includeForRecord }>;
|
||||||
|
|
||||||
|
function toRecord(row: ScanWithRelations): InventoryScanRecord {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
category_id: row.categoryId,
|
||||||
|
category_name: row.category.name,
|
||||||
|
model_id: row.modelId,
|
||||||
|
model_name: row.model.name,
|
||||||
|
model_manufacturer: row.model.manufacturer,
|
||||||
|
area_id: row.areaId,
|
||||||
|
area_name: row.area?.name ?? null,
|
||||||
|
assigned_employee_id: row.assignedEmployeeId,
|
||||||
|
assigned_employee_name: row.assignedEmployee?.name ?? null,
|
||||||
|
asset_tag: row.assetTag,
|
||||||
|
serial_number: row.serialNumber,
|
||||||
|
specs: (row.specs as Record<string, unknown>) ?? {},
|
||||||
|
photo_file_id: row.photoFileId,
|
||||||
|
created_at: row.createdAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateInventoryScanResult =
|
||||||
|
| { ok: true; value: InventoryScanRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an InventoryScan. categoryId/modelId/areaId/assignedEmployeeId
|
||||||
|
* are all plain FKs with no extra business rule beyond "does the row
|
||||||
|
* exist" (unlike AssetModel's high_value check) — a missing reference
|
||||||
|
* raises Prisma's P2003, translated into a clean {ok:false,error} the same
|
||||||
|
* way src/lib/employees.ts's createEmployee() handles managerId.
|
||||||
|
*/
|
||||||
|
export async function createInventoryScan(
|
||||||
|
input: CreateInventoryScanInput
|
||||||
|
): Promise<CreateInventoryScanResult> {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
categoryId: input.categoryId,
|
||||||
|
modelId: input.modelId,
|
||||||
|
areaId: input.areaId ?? null,
|
||||||
|
assignedEmployeeId: input.assignedEmployeeId ?? null,
|
||||||
|
assetTag: input.assetTag ?? null,
|
||||||
|
serialNumber: input.serialNumber ?? null,
|
||||||
|
specs: (input.specs ?? {}) as Prisma.InputJsonValue,
|
||||||
|
photoFileId: input.photoFileId ?? null,
|
||||||
|
telegramChatId: input.telegramChatId ?? null,
|
||||||
|
telegramMessageId: input.telegramMessageId ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInventoryScans(): Promise<InventoryScanRecord[]> {
|
||||||
|
const rows = await prisma.inventoryScan.findMany({
|
||||||
|
include: includeForRecord,
|
||||||
|
orderBy: [{ createdAt: "desc" }, { id: "desc" }],
|
||||||
|
});
|
||||||
|
return rows.map(toRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UpdateInventoryScanResult =
|
||||||
|
| { ok: true; value: InventoryScanRecord }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function updateInventoryScan(
|
||||||
|
id: string,
|
||||||
|
input: UpdateInventoryScanInput
|
||||||
|
): Promise<UpdateInventoryScanResult> {
|
||||||
|
try {
|
||||||
|
const updated = await prisma.inventoryScan.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
modelId: input.modelId,
|
||||||
|
areaId: input.areaId,
|
||||||
|
assignedEmployeeId: input.assignedEmployeeId,
|
||||||
|
assetTag: input.assetTag,
|
||||||
|
serialNumber: input.serialNumber,
|
||||||
|
specs: input.specs as Prisma.InputJsonValue | undefined,
|
||||||
|
},
|
||||||
|
include: includeForRecord,
|
||||||
|
});
|
||||||
|
return { ok: true, value: toRecord(updated) };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||||
|
if (error.code === "P2025") {
|
||||||
|
return { ok: false, error: "InventoryScan not found" };
|
||||||
|
}
|
||||||
|
if (error.code === "P2003") {
|
||||||
|
return { ok: false, error: "modelId/areaId/assignedEmployeeId must reference existing rows" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DeleteInventoryScanResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
export async function deleteInventoryScan(id: string): Promise<DeleteInventoryScanResult> {
|
||||||
|
try {
|
||||||
|
await prisma.inventoryScan.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
|
||||||
|
return { ok: false, error: "InventoryScan not found" };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInventoryScan(id: string): Promise<InventoryScanRecord | null> {
|
||||||
|
const row = await prisma.inventoryScan.findUnique({ where: { id }, include: includeForRecord });
|
||||||
|
return row ? toRecord(row) : null;
|
||||||
|
}
|
||||||
@@ -111,7 +111,7 @@ export function validateCreateMaintenanceRecordInput(input: {
|
|||||||
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
|
// Closed set of the 2 AssetItemStatus values a maintenance-record close is
|
||||||
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
|
// allowed to land on — deliberately NOT the full 4-value AssetItemStatus
|
||||||
// enum (see closeMaintenanceRecordSchema's targetStatus comment below for
|
// 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 closeableTargetStatusValues = ["in_stock", "retired"] as const;
|
||||||
|
|
||||||
const closeMaintenanceRecordSchema = z.object({
|
const closeMaintenanceRecordSchema = z.object({
|
||||||
@@ -130,8 +130,8 @@ const closeMaintenanceRecordSchema = z.object({
|
|||||||
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
|
// Restricted to "in_stock" | "retired" only (NOT the full 4-value
|
||||||
// AssetItemStatus enum) — those are the only two outcomes a repair can
|
// AssetItemStatus enum) — those are the only two outcomes a repair can
|
||||||
// actually resolve to (fixed → back to stock, unfixable → write off).
|
// actually resolve to (fixed → back to stock, unfixable → write off).
|
||||||
// "assigned" is deliberately rejected: setting AssetItem.status to
|
// "in_use" is deliberately rejected: setting AssetItem.status to
|
||||||
// "assigned" here would NOT create a corresponding Assignment row, so
|
// "in_use" here would NOT create a corresponding Assignment row, so
|
||||||
// Assignment (the source of truth for "who currently holds what") and
|
// Assignment (the source of truth for "who currently holds what") and
|
||||||
// AssetItem.status would go out of sync with no way back — createAssignment()
|
// AssetItem.status would go out of sync with no way back — createAssignment()
|
||||||
// only accepts a "in_stock" AssetItem, and there is no standalone
|
// 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
|
* (`where: { id: assetItemId, status: "in_stock" }`) rather than a
|
||||||
* plain read-then-write. Pinning the exact required prior value (not
|
* plain read-then-write. Pinning the exact required prior value (not
|
||||||
* excluding "retired") matters: an excluding guard would still match an
|
* 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
|
* "repair", letting two concurrent opens (or an open racing an Assignment
|
||||||
* create) both win. That `updateMany`'s `count` is the actual
|
* create) both win. That `updateMany`'s `count` is the actual
|
||||||
* race-closing gate: two concurrent opens racing on the same AssetItem
|
* 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
|
// exact prior value ("in_stock") rather than excluding a value
|
||||||
// ("not retired"), mirroring createAssignment()'s own status guard
|
// ("not retired"), mirroring createAssignment()'s own status guard
|
||||||
// (src/lib/assignments.ts) — the excluding form would still match an
|
// (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
|
// or "repair" (neither equals "retired"), letting two opens (or an
|
||||||
// open racing an Assignment create) both win.
|
// open racing an Assignment create) both win.
|
||||||
const statusUpdate = await tx.assetItem.updateMany({
|
const statusUpdate = await tx.assetItem.updateMany({
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
|
// Mirrors tests/employees.test.ts's @/auth mocking pattern.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET, POST } from "@/app/api/areas/route";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
|
||||||
|
|
||||||
|
const testSession: Session = {
|
||||||
|
user: { id: "test-user-id" },
|
||||||
|
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function postRequest(body: unknown): Request {
|
||||||
|
return new Request("http://localhost/api/areas", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.area.deleteMany();
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/areas", () => {
|
||||||
|
it("creates an area with no team on valid input", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "Phong hop A" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({ name: "Phong hop A", team: null });
|
||||||
|
expect(typeof body.id).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates an area with a team", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "Seat 12", team: "Backend" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({ name: "Seat 12", team: "Backend" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty name", async () => {
|
||||||
|
const res = await POST(postRequest({ name: "" }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(typeof body.error).toBe("string");
|
||||||
|
});
|
||||||
|
|
||||||
|
// See src/lib/areas.ts's createOrGetArea() comment: this is deliberate,
|
||||||
|
// not accidental idempotency — the bot's "pick from list or type new"
|
||||||
|
// flow must be able to re-submit a name it just showed as an existing
|
||||||
|
// option without erroring.
|
||||||
|
it("returns the existing row (200-equivalent 201, same id) instead of erroring on a duplicate name", async () => {
|
||||||
|
const first = await POST(postRequest({ name: "Kho tang 2" }));
|
||||||
|
const firstBody = await first.json();
|
||||||
|
|
||||||
|
const second = await POST(postRequest({ name: "Kho tang 2" }));
|
||||||
|
expect(second.status).toBe(201);
|
||||||
|
const secondBody = await second.json();
|
||||||
|
|
||||||
|
expect(secondBody.id).toBe(firstBody.id);
|
||||||
|
|
||||||
|
const all = await prisma.area.findMany();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid JSON body", async () => {
|
||||||
|
const req = new Request("http://localhost/api/areas", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: "not json",
|
||||||
|
});
|
||||||
|
const res = await POST(req);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/areas", () => {
|
||||||
|
it("lists areas alphabetically by name", async () => {
|
||||||
|
await POST(postRequest({ name: "Zeta" }));
|
||||||
|
await POST(postRequest({ name: "Alpha" }));
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.map((a: { name: string }) => a.name)).toEqual(["Alpha", "Zeta"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no areas", async () => {
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/areas", () => {
|
||||||
|
it("POST /api/areas returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await POST(postRequest({ name: "Phong hop A" }));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await prisma.area.findMany()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET /api/areas returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
+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
|
// AssetItem.purchaseOrderId is a real FK to PurchaseOrder (see
|
||||||
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
|
// schema.prisma / src/lib/assets.ts), and PurchaseOrder.vendorId is a real
|
||||||
// FK to Vendor — tests that need a valid purchaseOrderId create real rows
|
// 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 new Promise((resolve) => setTimeout(resolve, 5));
|
||||||
await POST(postRequest({ serialNumber: "SN-011", modelId, purchaseDate: "2026-02-01" }));
|
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);
|
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();
|
const body = await res.json();
|
||||||
expect(Array.isArray(body)).toBe(true);
|
expect(Array.isArray(body.items)).toBe(true);
|
||||||
expect(body).toHaveLength(2);
|
expect(body.items).toHaveLength(2);
|
||||||
|
expect(body.total).toBe(2);
|
||||||
|
|
||||||
// newest first
|
// newest first
|
||||||
expect(body[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
expect(body.items[0]).toMatchObject({ serial_number: "SN-011", model_id: modelId });
|
||||||
expect(body[1]).toMatchObject({ serial_number: "SN-010", 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 () => {
|
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);
|
expect(res.status).toBe(200);
|
||||||
const body = await res.json();
|
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 () => {
|
it("GET /api/assets returns 401 without a session", async () => {
|
||||||
mockedAuth.mockResolvedValue(null);
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
|
||||||
const res = await GET();
|
const res = await GET(getRequest());
|
||||||
|
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
|
|||||||
+11
-11
@@ -72,7 +72,7 @@ async function createTestModelId(): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let serialCounter = 0;
|
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;
|
serialCounter += 1;
|
||||||
return prisma.assetItem.create({
|
return prisma.assetItem.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -223,7 +223,7 @@ describe("validateReturnAssignmentInput", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createAssignment", () => {
|
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 employee = await createTestEmployee();
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
const assetItem = await createTestAssetItem({ modelId });
|
const assetItem = await createTestAssetItem({ modelId });
|
||||||
@@ -236,10 +236,10 @@ describe("createAssignment", () => {
|
|||||||
expect(created.value.returned_at).toBeNull();
|
expect(created.value.returned_at).toBeNull();
|
||||||
|
|
||||||
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
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",
|
"rejects assigning an AssetItem whose status is %s",
|
||||||
async (status) => {
|
async (status) => {
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
@@ -312,7 +312,7 @@ describe("createAssignment", () => {
|
|||||||
expect(second.value.employee_id).toBe(employee2.id);
|
expect(second.value.employee_id).toBe(employee2.id);
|
||||||
|
|
||||||
const updatedItem = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.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 () => {
|
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);
|
expect(failed).toHaveLength(1);
|
||||||
|
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
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({
|
const activeAssignments = await prisma.assignment.findMany({
|
||||||
where: { assetItemId: assetItem.id, returnedAt: null },
|
where: { assetItemId: assetItem.id, returnedAt: null },
|
||||||
@@ -545,7 +545,7 @@ describe("returnAssignment", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("transferAssignment", () => {
|
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 employee1 = await createTestEmployee({ name: "Employee 1" });
|
||||||
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
const employee2 = await createTestEmployee({ name: "Employee 2" });
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
@@ -562,11 +562,11 @@ describe("transferAssignment", () => {
|
|||||||
expect(transferred.value.asset_item_id).toBe(assetItem.id);
|
expect(transferred.value.asset_item_id).toBe(assetItem.id);
|
||||||
expect(transferred.value.returned_at).toBeNull();
|
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
|
// flipped to in_stock mid-way (that's the whole point of doing this in
|
||||||
// one transaction rather than a separate return + create).
|
// one transaction rather than a separate return + create).
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
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 } });
|
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||||
expect(original.returnedAt).not.toBeNull();
|
expect(original.returnedAt).not.toBeNull();
|
||||||
@@ -658,7 +658,7 @@ describe("transferAssignment", () => {
|
|||||||
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
const original = await prisma.assignment.findUniqueOrThrow({ where: { id: created.value.id } });
|
||||||
expect(original.returnedAt).toBeNull();
|
expect(original.returnedAt).toBeNull();
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
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();
|
expect(body.returned_at).toBeNull();
|
||||||
|
|
||||||
const item = await prisma.assetItem.findUniqueOrThrow({ where: { id: assetItem.id } });
|
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 () => {
|
it("rejects a body with both employeeId and returnedAt with a mutual-exclusivity 400", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Covers every /api/bot/* route — these are authed by a shared secret
|
||||||
|
// header (src/lib/bot-auth.ts), not an Auth.js session, since the caller is
|
||||||
|
// the inventory-capture Telegram bot service, not a browser. No @/auth
|
||||||
|
// mock needed here (unlike tests/employees.test.ts and friends).
|
||||||
|
process.env.BOT_INGEST_SECRET = "test-bot-secret";
|
||||||
|
|
||||||
|
import { GET as getCatalog } from "@/app/api/bot/catalog/route";
|
||||||
|
import { GET as getBotAreas, POST as postBotAreas } from "@/app/api/bot/areas/route";
|
||||||
|
import { GET as getBotEmployees, POST as postBotEmployees } from "@/app/api/bot/employees/route";
|
||||||
|
import { POST as postBotAssetModel } from "@/app/api/bot/asset-models/route";
|
||||||
|
import { POST as postBotInventoryScan } from "@/app/api/bot/inventory-scans/route";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const AUTH_HEADER = { authorization: "Bearer test-bot-secret" };
|
||||||
|
|
||||||
|
function req(url: string, opts: { method?: string; body?: unknown; auth?: boolean } = {}): Request {
|
||||||
|
return new Request(`http://localhost${url}`, {
|
||||||
|
method: opts.method ?? "GET",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
...(opts.auth === false ? {} : AUTH_HEADER),
|
||||||
|
},
|
||||||
|
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedCategoryAndModel() {
|
||||||
|
const category = await prisma.assetCategory.create({
|
||||||
|
data: { name: "Workstation", valueGroup: "high_value" },
|
||||||
|
});
|
||||||
|
await prisma.assetCategoryAttributeDef.create({
|
||||||
|
data: { categoryId: category.id, key: "cpu", label: "CPU", dataType: "string", required: false, sortOrder: 1 },
|
||||||
|
});
|
||||||
|
const model = await prisma.assetModel.create({
|
||||||
|
data: { categoryId: category.id, manufacturer: "HP", name: "Z4 G4", specs: {} },
|
||||||
|
});
|
||||||
|
return { category, model };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.inventoryScan.deleteMany();
|
||||||
|
await prisma.assetCategoryAttributeDef.deleteMany();
|
||||||
|
await prisma.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
|
await prisma.area.deleteMany();
|
||||||
|
await prisma.employee.deleteMany();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/bot/catalog", () => {
|
||||||
|
it("returns categories with nested models and attribute defs", async () => {
|
||||||
|
await seedCategoryAndModel();
|
||||||
|
|
||||||
|
const res = await getCatalog(req("/api/bot/catalog"));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.categories).toHaveLength(1);
|
||||||
|
expect(body.categories[0]).toMatchObject({
|
||||||
|
name: "Workstation",
|
||||||
|
models: [{ manufacturer: "HP", name: "Z4 G4" }],
|
||||||
|
attribute_defs: [{ key: "cpu", label: "CPU", data_type: "string" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 with a wrong secret", async () => {
|
||||||
|
const res = await getCatalog(
|
||||||
|
new Request("http://localhost/api/bot/catalog", {
|
||||||
|
headers: { authorization: "Bearer wrong-secret" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 with no auth header", async () => {
|
||||||
|
const res = await getCatalog(req("/api/bot/catalog", { auth: false }));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/bot/areas", () => {
|
||||||
|
it("POST creates an area, and re-posting the same name returns the same row (idempotent)", async () => {
|
||||||
|
const first = await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } }));
|
||||||
|
expect(first.status).toBe(201);
|
||||||
|
const firstBody = await first.json();
|
||||||
|
|
||||||
|
const second = await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } }));
|
||||||
|
const secondBody = await second.json();
|
||||||
|
expect(secondBody.id).toBe(firstBody.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET lists areas", async () => {
|
||||||
|
await postBotAreas(req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" } }));
|
||||||
|
const res = await getBotAreas(req("/api/bot/areas"));
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.areas).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST returns 401 without the bot secret", async () => {
|
||||||
|
const res = await postBotAreas(
|
||||||
|
req("/api/bot/areas", { method: "POST", body: { name: "Seat 12" }, auth: false })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("/api/bot/employees", () => {
|
||||||
|
it("POST creates an employee, defaulting department when omitted", async () => {
|
||||||
|
const res = await postBotEmployees(
|
||||||
|
req("/api/bot/employees", { method: "POST", body: { name: "Toan Hoang" } })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.name).toBe("Toan Hoang");
|
||||||
|
expect(body.department).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GET lists employees", async () => {
|
||||||
|
await postBotEmployees(req("/api/bot/employees", { method: "POST", body: { name: "Toan Hoang" } }));
|
||||||
|
const res = await getBotEmployees(req("/api/bot/employees"));
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.employees).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("POST returns 401 without the bot secret", async () => {
|
||||||
|
const res = await postBotEmployees(
|
||||||
|
req("/api/bot/employees", { method: "POST", body: { name: "Toan" }, auth: false })
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/bot/asset-models", () => {
|
||||||
|
it("creates a model within an existing high_value category", async () => {
|
||||||
|
const { category } = await seedCategoryAndModel();
|
||||||
|
|
||||||
|
const res = await postBotAssetModel(
|
||||||
|
req("/api/bot/asset-models", {
|
||||||
|
method: "POST",
|
||||||
|
body: { categoryId: category.id, manufacturer: "Lenovo", name: "P620" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({ manufacturer: "Lenovo", name: "P620" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 without the bot secret", async () => {
|
||||||
|
const { category } = await seedCategoryAndModel();
|
||||||
|
const res = await postBotAssetModel(
|
||||||
|
req("/api/bot/asset-models", {
|
||||||
|
method: "POST",
|
||||||
|
body: { categoryId: category.id, manufacturer: "Lenovo", name: "P620" },
|
||||||
|
auth: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/bot/inventory-scans", () => {
|
||||||
|
it("creates a scan with specs, asset tag and serial number", async () => {
|
||||||
|
const { category, model } = await seedCategoryAndModel();
|
||||||
|
|
||||||
|
const res = await postBotInventoryScan(
|
||||||
|
req("/api/bot/inventory-scans", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
categoryId: category.id,
|
||||||
|
modelId: model.id,
|
||||||
|
assetTag: "TAG-1",
|
||||||
|
serialNumber: "SN-1",
|
||||||
|
specs: { cpu: "i7" },
|
||||||
|
telegramChatId: "12345",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
category_name: "Workstation",
|
||||||
|
model_name: "Z4 G4",
|
||||||
|
asset_tag: "TAG-1",
|
||||||
|
serial_number: "SN-1",
|
||||||
|
specs: { cpu: "i7" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a categoryId/modelId that doesn't reference an existing row", async () => {
|
||||||
|
const res = await postBotInventoryScan(
|
||||||
|
req("/api/bot/inventory-scans", {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
categoryId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
modelId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 without the bot secret", async () => {
|
||||||
|
const { category, model } = await seedCategoryAndModel();
|
||||||
|
const res = await postBotInventoryScan(
|
||||||
|
req("/api/bot/inventory-scans", {
|
||||||
|
method: "POST",
|
||||||
|
body: { categoryId: category.id, modelId: model.id },
|
||||||
|
auth: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(await prisma.inventoryScan.findMany()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
|
||||||
|
// Mirrors tests/employees.test.ts's @/auth mocking pattern. Creation of
|
||||||
|
// InventoryScan rows only happens via the bot-secret-authed
|
||||||
|
// /api/bot/inventory-scans route (see tests/bot-api.test.ts) — this file
|
||||||
|
// covers the session-authed human routes only (list/edit/delete), so test
|
||||||
|
// data is seeded directly via prisma, not through POST.
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
|
||||||
|
import { GET as listScans } from "@/app/api/inventory-scans/route";
|
||||||
|
import { PATCH, DELETE } from "@/app/api/inventory-scans/[id]/route";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
const mockedAuth = auth as unknown as Mock<() => Promise<Session | null>>;
|
||||||
|
|
||||||
|
const testSession: Session = {
|
||||||
|
user: { id: "test-user-id" },
|
||||||
|
expires: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
async function seedCategoryAndModel() {
|
||||||
|
const category = await prisma.assetCategory.create({
|
||||||
|
data: { name: "Controller", valueGroup: "high_value" },
|
||||||
|
});
|
||||||
|
const model = await prisma.assetModel.create({
|
||||||
|
data: { categoryId: category.id, manufacturer: "Sony", name: "PS5", specs: {} },
|
||||||
|
});
|
||||||
|
const model2 = await prisma.assetModel.create({
|
||||||
|
data: { categoryId: category.id, manufacturer: "Microsoft", name: "Xbox", specs: {} },
|
||||||
|
});
|
||||||
|
return { category, model, model2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchRequest(body: unknown): Request {
|
||||||
|
return new Request("http://localhost/api/inventory-scans/x", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await prisma.inventoryScan.deleteMany();
|
||||||
|
await prisma.assetModel.deleteMany();
|
||||||
|
await prisma.assetCategory.deleteMany();
|
||||||
|
await prisma.area.deleteMany();
|
||||||
|
await prisma.employee.deleteMany();
|
||||||
|
mockedAuth.mockResolvedValue(testSession);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/inventory-scans", () => {
|
||||||
|
it("lists scans newest first, with joined category/model names", async () => {
|
||||||
|
const { category, model } = await seedCategoryAndModel();
|
||||||
|
const scan = await prisma.inventoryScan.create({
|
||||||
|
data: { categoryId: category.id, modelId: model.id, assetTag: "TAG-1" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await listScans();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
expect(body[0]).toMatchObject({
|
||||||
|
id: scan.id,
|
||||||
|
category_name: "Controller",
|
||||||
|
model_name: "PS5",
|
||||||
|
model_manufacturer: "Sony",
|
||||||
|
asset_tag: "TAG-1",
|
||||||
|
area_name: null,
|
||||||
|
assigned_employee_name: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty list when there are no scans", async () => {
|
||||||
|
const res = await listScans();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/inventory-scans/[id]", () => {
|
||||||
|
it("updates area, assignee, asset tag, serial number and specs", async () => {
|
||||||
|
const { category, model } = await seedCategoryAndModel();
|
||||||
|
const scan = await prisma.inventoryScan.create({
|
||||||
|
data: { categoryId: category.id, modelId: model.id },
|
||||||
|
});
|
||||||
|
const area = await prisma.area.create({ data: { name: "Phong hop A" } });
|
||||||
|
const employee = await prisma.employee.create({ data: { name: "Toan", department: "IT" } });
|
||||||
|
|
||||||
|
const res = await PATCH(
|
||||||
|
patchRequest({
|
||||||
|
areaId: area.id,
|
||||||
|
assignedEmployeeId: employee.id,
|
||||||
|
assetTag: "TAG-2",
|
||||||
|
serialNumber: "SN-123",
|
||||||
|
specs: { cpu: "i7" },
|
||||||
|
}),
|
||||||
|
{ params: { id: scan.id } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
area_name: "Phong hop A",
|
||||||
|
assigned_employee_name: "Toan",
|
||||||
|
asset_tag: "TAG-2",
|
||||||
|
serial_number: "SN-123",
|
||||||
|
specs: { cpu: "i7" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows switching to a different model within the same category", async () => {
|
||||||
|
const { category, model, model2 } = await seedCategoryAndModel();
|
||||||
|
const scan = await prisma.inventoryScan.create({
|
||||||
|
data: { categoryId: category.id, modelId: model.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await PATCH(patchRequest({ modelId: model2.id }), { params: { id: scan.id } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.model_name).toBe("Xbox");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears area/assignee when explicitly set to null", async () => {
|
||||||
|
const { category, model } = await seedCategoryAndModel();
|
||||||
|
const area = await prisma.area.create({ data: { name: "Phong hop A" } });
|
||||||
|
const scan = await prisma.inventoryScan.create({
|
||||||
|
data: { categoryId: category.id, modelId: model.id, areaId: area.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await PATCH(patchRequest({ areaId: null }), { params: { id: scan.id } });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json();
|
||||||
|
expect(body.area_id).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for an id that doesn't exist", async () => {
|
||||||
|
const res = await PATCH(patchRequest({ assetTag: "x" }), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for a malformed id", async () => {
|
||||||
|
const res = await PATCH(patchRequest({ assetTag: "x" }), { params: { id: "not-a-uuid" } });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/inventory-scans/[id]", () => {
|
||||||
|
it("deletes an existing scan", async () => {
|
||||||
|
const { category, model } = await seedCategoryAndModel();
|
||||||
|
const scan = await prisma.inventoryScan.create({
|
||||||
|
data: { categoryId: category.id, modelId: model.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), {
|
||||||
|
params: { id: scan.id },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(await prisma.inventoryScan.findUnique({ where: { id: scan.id } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 for an id that doesn't exist", async () => {
|
||||||
|
const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("auth gating on /api/inventory-scans", () => {
|
||||||
|
it("GET returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await listScans();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCH returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await PATCH(patchRequest({ assetTag: "x" }), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("DELETE returns 401 without a session", async () => {
|
||||||
|
mockedAuth.mockResolvedValue(null);
|
||||||
|
const res = await DELETE(new Request("http://localhost/api/inventory-scans/x", { method: "DELETE" }), {
|
||||||
|
params: { id: "00000000-0000-0000-0000-000000000000" },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,7 +61,7 @@ async function createTestModelId(): Promise<string> {
|
|||||||
let serialCounter = 0;
|
let serialCounter = 0;
|
||||||
function createTestAssetItem(overrides: {
|
function createTestAssetItem(overrides: {
|
||||||
modelId: string;
|
modelId: string;
|
||||||
status?: "in_stock" | "assigned" | "repair" | "retired";
|
status?: "in_stock" | "in_use" | "repair" | "retired";
|
||||||
}) {
|
}) {
|
||||||
serialCounter += 1;
|
serialCounter += 1;
|
||||||
return prisma.assetItem.create({
|
return prisma.assetItem.create({
|
||||||
@@ -223,7 +223,7 @@ describe("createMaintenanceRecord", () => {
|
|||||||
|
|
||||||
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
|
it("rejects opening a record for an AssetItem that has an active assignment", async () => {
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
|
const assetItem = await createTestAssetItem({ modelId, status: "in_use" });
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
await prisma.assignment.create({
|
await prisma.assignment.create({
|
||||||
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
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 () => {
|
it("rejects creating a record for an AssetItem with an active assignment", async () => {
|
||||||
const modelId = await createTestModelId();
|
const modelId = await createTestModelId();
|
||||||
const assetItem = await createTestAssetItem({ modelId, status: "assigned" });
|
const assetItem = await createTestAssetItem({ modelId, status: "in_use" });
|
||||||
const employee = await createTestEmployee();
|
const employee = await createTestEmployee();
|
||||||
await prisma.assignment.create({
|
await prisma.assignment.create({
|
||||||
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
data: { employeeId: employee.id, assetItemId: assetItem.id, assignedAt: new Date() },
|
||||||
|
|||||||
Reference in New Issue
Block a user