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.
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.
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.
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.
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.
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).
/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.