Commit Graph
11 Commits
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 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 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 2a5792df67 fix: retrofit AssetItem.model to modelId FK
AssetItem.model was a free-text string with no referential integrity.
Now a required FK to AssetModel, mirroring the Request.employeeId and
License.vendorId retrofit precedent (no backfill needed, no real
AssetItem data existed).
2026-08-02 21:07:12 -04:00
toanhoang a6d7c7b302 feat: add AssetItem.purchaseOrderId (nullable FK) 2026-08-02 20:09:06 -04:00
toanhoang e7e359cbf7 feat: add AssetItem validation + CRUD (src/lib/assets.ts) 2026-07-31 16:04:22 +07:00