Author SHA1 Message Date
toanhoang 03d74d69c1 Rename AssetItemStatus 'assigned' to 'in_use'
CI / test (push) Failing after 9s
'assigned' implied a specific person always holds the item, but
createAssignment() only requires status=in_stock to hand something out --
the status value itself never guaranteed a matching Assignment row exists.
~158 of the 613 real-imported assets carry this status with no Assignment
at all (shared/unowned equipment like meeting-room gear), so "assigned"
was actively misleading for them. 'in_use' doesn't carry that implication.

DB: `ALTER TYPE "AssetItemStatus" RENAME VALUE 'assigned' TO 'in_use'`
(hand-written migration -- runs cleanly, no data migration needed, every
existing row's status just reads under the new name). Renamed across:
schema.prisma enum, src/lib/assets.ts (type union + IMPORT_STATUS_MAP,
which keeps an `assigned` key mapped to `in_use` for backward-compat with
pre-2026-08-25 exports/CSVs), src/lib/assignments.ts (the literal status
value createAssignment() sets), src/app/assets/page.tsx + api/assets/
route.ts (filter dropdown + validStatuses), src/app/maintenance-records/
page.tsx (its own copy of the status list for the "close" targetStatus
picker). maintenance-records.ts itself needed no logic change -- its
closeableTargetStatusValues was already just ["in_stock", "retired"],
"assigned" was never a valid close target, only mentioned in comments
(updated for accuracy).

Also fixes a real regression found while verifying this: tests/
assets.test.ts (a pre-existing, properly-reviewed test file, not written
this session) broke 2 commits ago when GET /api/assets's signature and
response shape changed for pagination -- it still called GET() with no
Request and expected a bare array. tsc didn't catch this until now
because this container has no dev bind mount (Dockerfile is COPY-based,
`docker exec` only reflects the last build) -- see the
ubi-dan-itam-docker-no-bind-mount memory note. Added a getRequest()
helper and updated assertions to the {items, total} shape.

Verified by actually rebuilding and running the full suite fresh (not
just tsc/lint smoke checks): 369/369 tests pass, including
assignments.test.ts and maintenance-records.test.ts, which this rename
touches directly. tsc/lint clean against the rebuilt image. Migration
applied to the real DB, data verified intact (542 in_use + 71 in_stock =
613, matches pre-rename counts exactly).
2026-08-24 21:57:33 -04:00
toanhoang 10508f9d04 Server-side pagination for /assets (50/page), fix duplicated manufacturer in model labels
CI / test (push) Failing after 8s
listAssetItems() now takes {page, pageSize, status, modelId, categoryId,
areaId, search} and returns {items, total} instead of the whole table --
was a bare unpaginated findMany(), shipping all ~613 rows on every page
load. Filtering moved server-side into the same WHERE clause so 'total'
reflects the filtered count, not the table's overall size -- pagination
controls stay correct regardless of which filters are active. 'search'
matches serialNumber, assetTag, or the current (non-returned) assignee's
name via the assignments relation in one query.

GET /api/assets is now the one entity route whose response is not a bare
array (see use-api-list.ts) -- its {items, total} shape needs 'total' for
page controls, so /assets fetches it directly instead of through
useApiList. Query params: page, pageSize, status, modelId, categoryId,
areaId, search.

/assets page: search is debounced 300ms (was firing a request per
keystroke otherwise), changing any filter resets to page 1, added
Previous/Next controls + "showing X-Y / total" count. Row # is now a
running index across pages, not per-page.

Separately: all 135 AssetModel rows from the 2026-08-24 real-data import
have the manufacturer name baked into 'name' already (the source file's
own Model column did, e.g. name="Bose Soundlink Revolve" manufacturer=
"Bose") -- rendering "manufacturer name" everywhere doubled it
("Bose Bose Soundlink Revolve"). Fixed at the display layer
(formatModelLabel(), skips the manufacturer prefix when name already
starts with it) rather than a data migration -- non-destructive, handles
every affected row uniformly, reversible.

Smoke-tested listAssetItems() directly (tsx): page1/page2 have zero
overlap, status filter count matches (71 in_stock), asset-tag search
finds exactly 1 row, assignee-name search correctly joins through
Assignment (4 rows for one employee), last page (13 of 613/50) has the
expected remainder of 13 rows.

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 21:31:22 -04:00
toanhoang 4063b81f16 Add AssetItem.areaId, show assigned-to + area on /assets, mm/dd/yyyy dates
CI / test (push) Failing after 9s
AssetItem.areaId reuses the existing Area entity (built for InventoryScan/
the office-move feature) rather than a free-text location string -- same
share-the-lookup-entity precedent floated for Department. Real-world
coverage from the ITAM export is thin: Physical location was the literal
string DAN for all 613 rows (useless), only Stockroom carried signal, and
only for the ~71 in_stock items -- an assigned item is with a person, not
in a stockroom bin, so most rows have no area by design, not by gap.

/assets page: the assigned employee (via the active, non-returned
Assignment) was already in the DB from the 2026-08-24 backfill but never
shown -- added an Assigned To column, folded into the free-text search
too. Added an Area column + filter dropdown. Removed the Created At
column (not actionable) and format Purchase Date/Warranty End Date as
mm/dd/yyyy instead of raw ISO strings.

CSV import/export: area column added (resolved/created via
createOrGetArea(), same idempotent-by-name helper the bots Area picker
uses -- not a raw UUID). Re-ran the 613-row import after this: 0 created,
613 updated, 0 errors -- backfilled area onto the 71 rows with a
Stockroom value (46 IT Cabinet, 21 Stockroom, 4 For allocation), verified
against the source file.

Hand-written migration, no Vitest coverage -- same deviation as the rest
of this branch, see CLAUDE.md. tsc/lint clean.
2026-08-24 21:05:03 -04:00
toanhoang 6863323cce Add AssetItemStatus.lost, category filter on /assets page
CI / test (push) Failing after 8s
lost added via ALTER TYPE ... ADD VALUE (hand-written migration, stays an
enum -- see the schema.prisma comment on why: status values are read
directly by business logic like createAssignment()s in_stock gate, a
lookup table would not actually decouple that). IMPORT_STATUS_MAP gains
lost/missing -> lost mappings for CSV import/re-sync.

/assets page gets a Category filter dropdown (alongside the existing
Status/Model/search filters) that also narrows the Model dropdown to that
category -- with 135 models after the 2026-08-24 real-data import, an
unfiltered Model list is unwieldy. Picking a Category resets the Model
filter if the currently-selected model does not belong to it.

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 20:41:50 -04:00
toanhoang 3ebd7efd7f Assets page: show model name instead of UUID, row index instead of raw ID, add filters
CI / test (push) Failing after 8s
Model ID column now shows "Manufacturer Name" (looked up from the
already-fetched asset-models list) instead of the raw model_id UUID.
The ID column shows a 1-based row index within the current filtered view
instead of the AssetItem UUID -- the id itself was never actionable in
the UI (no per-row link/detail view exists), just clutter at this scale
(~600+ rows after the real-data import).

Added a filter bar: free-text search (matches serial_number or asset_tag,
case-insensitive substring), status dropdown, model dropdown -- all
client-side (assetItems is already the full list via useApiList, and
filtering ~600 rows in memory is trivial, no need for a server-side
filtered endpoint at this scale).

tsc/lint clean, no Vitest coverage -- same deviation as the rest of this
branch, see CLAUDE.md.
2026-08-24 20:28:30 -04:00
toanhoang 2cbe746bde Add AssetItem.cost/costCurrency/externalPoNumber, make import upsert-capable
CI / test (push) Failing after 9s
Three new fields: cost (Decimal, not Float -- exact money math),
costCurrency (free text), externalPoNumber (free text PO number from an
external system, distinct from purchaseOrderId which is a real FK into
this apps own PurchaseOrder table -- external exports PO numbers are
messy free text with nothing to link to here).

importAssetItems() now upserts: a row matching an existing assetTag or
serialNumber updates that row instead of erroring on the uniqueness
conflict. Needed to backfill cost/PO onto the 613 already-imported rows
without recreating them, and sets this up for future re-syncs when the
company re-exports their ITAM report. Update only touches fields the row
actually carries a value for (blank cell = no change, not clear) --
warrantyEndDate is the one field NOT auto-recomputed from warrantyMonths
on update (unlike create), so a manually-corrected date survives a
re-sync. Placeholder junk (Not available/Pending/N/A/etc, case
-insensitive) in PO number/cost/currency cells is treated as absent.

Re-ran the full 613-row import after this: 0 created, 613 updated, 0
errors -- backfilled cost onto the 8 rows that had one and PO number onto
the 2 that had a real (non-placeholder) value, verified against the
source file.

Hand-written migration, no Vitest coverage -- same deviation as the rest
of this branch, see CLAUDE.md. tsc/lint clean.
2026-08-23 22:14:48 -04:00
toanhoang 1360bb3c45 docs: record real Employee/Assignment backfill (2026-08-24)
CI / test (push) Failing after 8s
No code changes -- this backfilled 95 Employee + 384 Assignment rows from
the real ITAM export using existing Prisma models directly (not
src/lib/assignments.tss createAssignment(), which is for the live
issue-from-stock workflow and would reject every row here since these
AssetItems already carry status=assigned from the earlier bulk import).
See CLAUDE.md for the full rationale and whats still out of scope
(Cost, PO number, Employee.job/manager/email).
2026-08-23 21:50:37 -04:00
toanhoang 69437cf38f Add AssetItem.assetTag, status-aware bulk import, nullable purchaseDate
CI / test (push) Failing after 8s
Prep work for importing a real company ITAM export (613 real assets):

- AssetItem.assetTag (nullable, unique) -- company physical tag, was only
  on InventoryScan (move feature) before, now on the actual asset record.
- AssetItem.purchaseDate is now nullable (was required) -- some legacy
  rows genuinely have no known purchase date. Manual /assets create form
  still requires it in the HTML input; this only relaxes the DB/import
  path for historical data.
- importAssetItems() accepts a status column now (free-text "In use"/
  "In stock"/etc mapped via IMPORT_STATUS_MAP), unlike manual creation
  which always defaults in_stock -- bulk-imported inventory is usually
  already deployed, not freshly purchased, so forcing in_stock would
  misrepresent reality.

Hand-written migrations (asset_tag, purchase_date nullable), no Vitest
coverage -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.

Actually imported 613/613 real AssetItem rows from the export with 0
errors after this (not committed here -- thats data, not code; see
CLAUDE.md for the seeding/import process and whats deliberately out of
scope this round: Cost, PO number, employee Assignment).
2026-08-23 21:33:48 -04:00
toanhoang aa7f5515db Add AssetItem CSV import/export
CI / test (push) Failing after 8s
/assets gets Export CSV (GET /api/assets/export) and Import CSV
(POST /api/assets/import) buttons. Export emits human-readable
manufacturer/model columns instead of raw modelId; import resolves those
back to an AssetModel (case-insensitive match, must already exist -- this
does not auto-create models) so export -> edit in Excel -> re-import is a
real round trip.

Per-row import errors (unmatched model, missing required field, bad date)
are collected and returned alongside the created count rather than
aborting the whole file on the first bad row -- a typo in row 200 should
not cost rows 1-199. warrantyEndDate still auto-fills from the models
warrantyMonths when the CSV omits it, same as manual creation.

Extracted src/lib/csv.ts (splitCsvLine/splitCsvLines/normalizeHeader/
csvField) out of expected-assets.ts so both CSV parsers share one
implementation instead of duplicating it.

Manual smoke test only (tsx script: export shape, valid row + unmatched-
model row + missing-date row all handled correctly), no Vitest coverage,
not through the ba->pm->feature-implementer->test-runner->code-reviewer
pipeline -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.
2026-08-23 07:41:52 -04:00
toanhoang d9b360c1df Auto-fill AssetItem.warrantyEndDate from AssetModel.warrantyMonths
CI / test (push) Failing after 8s
AssetModel gains an optional warrantyMonths (standard vendor warranty
length). createAssetItem() now defaults warrantyEndDate to purchaseDate +
warrantyMonths (month-end clamped, e.g. Dec 31 + 2mo -> Feb 28, not Mar 3)
whenever the caller omits warrantyEndDate -- still overridable per item
for batches with extended/different warranty terms. /asset-models UI gets
an input for the new field; /assets needs no change since it already
omits warrantyEndDate from the payload when left blank.

Hand-written migration + manual smoke test (tsx script, not Vitest),
same deviation as the other 2026-08-2x work in this branch -- see
CLAUDE.md. tsc/lint clean.
2026-08-21 09:10:55 -04:00
toanhoang 8ac95c974b Add ExpectedAsset CSV import + bot search/pick for pre-known inventory
CI / test (push) Failing after 8s
Lets the operator upload a CSV of already-known assets (asset tag, model,
serial, old area/assignee) before an office-move walk, via a new
session-authed /inventory-scans/import page. The Telegram bot gains a
"tim trong list" search step at the asset-tag prompt: picking a match
pre-fills tag/serial and offers one-tap "giu nguyen" buttons for the
old area/assignee, then marks that row matched once the scan is created.

Follows the same-day decision to turn off local OCR (qwen2.5vl via Ollama)
for this feature -- see CLAUDE.md and the ITAM/Ollama vault notes for the
accuracy/speed numbers behind that call.

Deviates from repo convention same as the InventoryScan work above: no
Vitest coverage (manual smoke test only, via simulated webhook calls),
hand-written migration, not through the ba->pm->feature-implementer->
test-runner->code-reviewer pipeline. tsc/lint are clean.
2026-08-21 08:59:18 -04:00
toanhoangandClaude Sonnet 5 b4e2f6c83e docs: note decision to merge inventory-capture bot into this app
CI / test (push) Failing after 9s
Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 09:47:33 -04:00
toanhoangandClaude Sonnet 5 b52106dfa6 fix(ci): use db service hostname instead of localhost for Postgres
CI / test (push) Successful in 4m20s
self-hosted runner is pinned to homelab_proxy network (needed to resolve the gitea host for checkout), so job+service containers are separate network members, not sharing a netns like GitHub-hosted runners service containers do -- localhost inside the job container never reached the db service. Also drops the now-unneeded host port publish (avoids repeating the port-already-allocated failure an earlier orphaned run left behind).

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