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