Compare commits

..

35 Commits

Author SHA1 Message Date
josh be20fe587a chore: remove auth rate limiting
CI / Lint · Typecheck · Test · Build (push) Successful in 51s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m59s
Vector is an internal service — throttling /api/auth requests provides
no meaningful protection and gets in the way of legitimate use. Drops
the express-rate-limit middleware and dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 20:19:12 -04:00
josh da6bd071ee fix: remove lingering FM references from insights services
CI / Lint · Typecheck · Test · Build (push) Successful in 59s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 2m22s
tx.fm.count calls + fmsImplicating fields were missed in the main FM
removal pass. Drops the field from Category/Manufacturer/PartModel
insights types, services, tests, and detail-page stat cards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:51:56 -04:00
josh db8e86b749 feat: remove FM feature from Vector
CI / Lint · Typecheck · Test · Build (push) Failing after 36s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped
FMs move to a separate application. Drops Fm/FmPart tables + Repair.fmId
column, deletes FM_OPENED/FM_CLOSED PartEvent rows, strips FM enums +
webhook events + shared contracts, removes FM routes/services/pages/UI,
and collapses dashboard admin ops to Repairs 7d/30d + trend + custody
backlog.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 18:46:40 -04:00
josh d739411510 docs: rewrite README for current feature set
CI / Lint · Typecheck · Test · Build (push) Successful in 51s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m9s
Lead with a user-facing summary, group features by domain (inventory,
field workflow, operations dashboard, integrations), add the
docker-compose deployment path, and drop the stale 2.0 phase roadmap
and deferred-followups lists.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:34:51 -04:00
josh 51512ff649 fix(dashboard): theme-aware chart tooltip styles
CI / Lint · Typecheck · Test · Build (push) Successful in 55s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m52s
Replace invalid hsl(var(--accent) / ...) cursors with color-mix against
the real --color-foreground token, and style tooltip content/box with
--color-popover so it matches the dark theme instead of rendering as a
white box on a black cursor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:30:47 -04:00
josh 22ce6d18ee feat(dashboard): add total-spent KPI alongside deployed value
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m12s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:27:27 -04:00
josh 52e092502b feat(dashboard): add upcoming EOL + admin operations widgets
CI / Lint · Typecheck · Test · Build (push) Successful in 47s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m5s
Surface operational signal alongside inventory: upcoming-EOL banner and
KPI for everyone; admin-only repairs tempo, FM close time, open FMs by
host, and custody backlog. Service shapes payload by role.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:22:10 -04:00
josh ae65d9f2a8 fix(repairs): stop rejecting log when broken serial already exists
CI / Lint · Typecheck · Test · Build (push) Successful in 1m13s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m35s
The LogRepairRequest superRefine demanded brokenPartModelId or
brokenMpn+brokenManufacturerId on every request, but the service only
consumes those fields when ingesting a brand-new broken part. For
serials already in Vector, the client correctly omits them — schema
then false-rejected the payload. Drop the refine; the service still
throws with the same message when ingest truly needs it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 16:01:14 -04:00
josh e60d049e69 fix(repairs): Log repair submit silently ignored with empty defaults
CI / Lint · Typecheck · Test · Build (push) Successful in 45s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m9s
The broken-model UUID fields used z.string().uuid().optional(), which
only accepts undefined — not the '' defaults. When the broken serial
matched an existing part, those fields unmounted before their
FormMessage could render, so handleSubmit aborted on hidden errors and
the mutation never fired. Accept the empty-string sentinel alongside
UUIDs.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:50:24 -04:00
josh a2b088463d feat(categories): detail page with fleet insights
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m6s
Clicking a category anywhere in the app now opens /categories/:id with
MPN breakdown, manufacturer mix, failures by MPN, and past-EOL exposure
— a dual of the manufacturer detail page.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:41:47 -04:00
josh 62a3d615f4 feat(parts): make MPN and manufacturer cells clickable
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m3s
MPN links to /part-models/:id and manufacturer links to
/manufacturers/:id, matching the cross-navigation pattern used on
other detail and table views.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:25:21 -04:00
josh c35bc39adf chore(web): tighten page and dialog copy for production
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been cancelled
Remove internal tool references (n8n), product self-references, and
implementation-detail meta from page headers and dialog descriptions.
Copy now describes what the user is looking at rather than how the
system handles it behind the scenes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:23:48 -04:00
josh 09d1d96cb4 feat(bins): clickable bin cards with dedicated detail page
CI / Lint · Typecheck · Test · Build (push) Successful in 49s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m2s
Clicking a bin on Locations now navigates to /bins/:id, showing the
bin's site/room/name, created/updated metadata, and a paginated
DataTable of parts currently in the bin. Admins can rename or delete
the bin from the detail page; the BinGrid kebab menu still works
without triggering navigation.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:17:55 -04:00
josh 1d53e81d5e feat(manufacturers): detail page with MPN-level insights
CI / Lint · Typecheck · Test · Build (push) Successful in 47s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m2s
Adds /manufacturers/:id with vendor-wide KPIs, top MPNs by units,
failures by MPN, category mix, past-EOL exposure, and a filtered
PartModels table. Wires upstream links from PartDetail and
PartModelDetail so the manufacturer name is a navigable anchor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 15:10:37 -04:00
josh c6fb839005 feat(part-models): detail page with fleet insights
CI / Playwright (smoke) (push) Has been cancelled
CI / Lint · Typecheck · Test · Build (push) Successful in 46s
CI / Build & push images (push) Successful in 1m6s
Adds /part-models/:id mirroring host/part detail pattern: KPIs for
units, spend, avg price, failure counts, and FMs implicating the
model, a state-breakdown bar chart, and the parts-of-this-model
table. New GET /part-models/:id/insights aggregates via part.groupBy
+ aggregate and repair/fm counts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 14:51:39 -04:00
josh 13e3444258 fix(locations): size card to content instead of full viewport
CI / Lint · Typecheck · Test · Build (push) Successful in 47s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m4s
The page forced the card to fill the viewport via h-[calc(100vh-...)],
leaving awkward empty space when few bins were present. Drop the fixed
height so the card sizes to its tallest column and let the page scroll
naturally if the bin grid overflows.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 14:38:34 -04:00
josh 0b29e706b0 feat(hosts): generate unique 8-digit asset ID
CI / Lint · Typecheck · Test · Build (push) Successful in 1m2s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m21s
Add a Generate button to the host create dialog that fetches a random
8-digit asset ID from the new GET /hosts/generate-asset-id endpoint.
The service retries against the unique index so the returned ID is
guaranteed unused. Edit mode hides the button.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 14:30:24 -04:00
josh 95e501a9c8 fix(hosts): tweak detail page copy and summary style
CI / Lint · Typecheck · Test · Build (push) Successful in 55s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 8s
- Render State/Stack as plain text in the summary (badges still in header).
- Show FM UUID instead of problem text in the timeline entries.
- Rename PART_ARRIVED label to "Part deployed".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 14:11:13 -04:00
josh b0e9c5d1d0 feat: host detail page + FM host context
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m5s
Add /hosts/:id detail page with unified timeline (HostEvents + FMs + Repairs
+ part arrivals/departures) and a deployed-parts table. Hosts list rows now
link to the page. FM list + detail surface inline State/Stack badges next
to the asset ID, with the asset ID linking to the host page.

HostEvent audit model added; create/update in the hosts service now diff
and log state, stack, and field changes the same way parts.ts does.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 14:04:07 -04:00
josh 60255f20bb feat: laundry-list polish pass
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 59s
Seven bundled improvements:
- PartModel combobox on Add Part + Log Repair (known MPN auto-fills;
  unknown reveals manufacturer picker for catalog upsert).
- Host lifecycle: state (DEPLOYED/DEGRADED/TESTING) and stack
  (PRODUCTION/VETTING) fields, driven by external clients via the API.
- Locations page redesigned as a 2-pane tree + bin grid with breadcrumb.
- PENDING_REPAIR custody state: tech takes a SPARE into custody for a
  future swap; resolves to DEPLOYED via Repair or back to SPARE via a
  bin-required drop-off.
- Move Category from Part to PartModel; seed common categories
  (GPU/RAM/SSD/HDD/NIC/CPU/PSU/MOBO). Parts table gets a Category
  column and filter sourced from the model.
- Fix Deployed Value 100x bug on the Dashboard (price is stored as
  dollars, not cents).
- PartModels table shows "No" instead of "--" when destroyOnFail=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 13:36:11 -04:00
josh 3d77f2846d feat: split Repairs into FM, Repair, and Custody workflows
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m0s
The old Repairs module had grown ticketing-system features (status lifecycle,
comments, assignee, notes) that duplicate what the external ticketing tool
already owns. Vector only needs to track whether maintenance is open or closed.

- Rename RepairJob -> Fm (OPEN/CLOSED only), drop RepairComment, assignee, notes
- New Repair table: persistent log of physical part swaps, with ingest on
  unknown broken MPN via partModels.upsertByMpn
- New custody model: PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY
  states + Part.custodianId, with a "My Custody" page for drop-off
- PartModel.destroyOnFail routes broken parts to the destruction path
- Host lookup on /fms and /repairs accepts hostId XOR assetId
- Wire the dormant webhook emitter: fm.opened, fm.closed, repair.logged
- Single fresh Prisma migration (dev DB was wiped, no backfill)

Tests: 60 passing (custody transitions in parts.test.ts; new fms.test.ts,
repairs.test.ts, custody.test.ts covering happy paths, validation failures,
webhook emissions, and ingest-on-unknown-MPN).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 12:22:56 -04:00
josh 6690d8a5dd feat(parts): couple state and location (host vs bin)
CI / Lint · Typecheck · Test · Build (push) Successful in 45s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m23s
DEPLOYED parts live on a host; every other state lives in a bin (or
unassigned). Previously binId and hostId were independent nullable
fields with no validation, so the Edit Part dialog could leave a
DEPLOYED part with only a bin and no host — which silently dropped
it from the repair problem-part picker.

- Service: resolveLocation() helper enforces the invariant on create
  and update. On a state transition, update auto-clears the stale
  relation and emits LOCATION_CHANGED for the cleared side.
- Zod: CreatePartRequest.superRefine rejects mismatched state/location
  up front; UpdatePartRequest rejects both-fields-set.
- Web: PartFormDialog swaps a single Location field between Host
  combobox (DEPLOYED) and Bin combobox (others); switching State
  clears the opposite field. Parts list + detail render host first,
  then bin path, then Unassigned.
- Tests: 9 new cases covering the invariant including the no-op guard
  so an unrelated PATCH on a DEPLOYED part doesn't touch hostId/binId.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:43:02 -04:00
josh 0f952d6c1b feat: rework EOL, repairs, and hosts for real workflow
CI / Lint · Typecheck · Test · Build (push) Successful in 48s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m1s
Four domain-model changes driven by exercising the deployed 2.0 build:

- EOL moves from manufacturer to MPN via new PartModel catalog table,
  so alerts fire on the thing that actually ages.
- Repairs re-home to Host (required hostId + problem text) with an
  optional RepairJobPart join for affected parts; drop Part.replacementPartId.
- New /repairs/:id detail page with editable problem, part list, and
  a RepairComment thread (REPAIR_COMMENTED events fan out to each
  problem part's timeline).
- Host.assetId (required, unique) surfaces prominently on the repair
  page so techs can confirm they're touching the right box.

Single destructive migration reshapes existing dev data. All 7 packages
typecheck clean; 30 API tests pass (9 new covering host membership,
upsertByMpn idempotency + race, assetId 409, comment userId stamping).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 10:17:29 -04:00
josh 23bd0f0c6a fix(deploy): auth/CSRF cookies dropped on plain-HTTP prod
CI / Playwright (smoke) (push) Has been skipped
CI / Lint · Typecheck · Test · Build (push) Successful in 44s
CI / Build & push images (push) Successful in 1m8s
Every cookie was flagged Secure whenever NODE_ENV=production. Over
plain HTTP (single-host compose deploy without TLS) browsers silently
discard Secure cookies, so the access token, refresh token, and CSRF
cookie all vanished after login — producing 401 Unauthorized on every
GET and 403 "CSRF token missing or invalid" on every mutation.

Add COOKIE_SECURE to ApiEnv: optional boolean, falls back to
NODE_ENV === 'production' when unset. Controllers and middleware now
read env.COOKIE_SECURE instead of the NODE_ENV shortcut. The compose
file sets it to false by default with a comment to flip once TLS is in
front; HTTPS deployments can override via .env or drop the override to
pick up the secure default.
2026-04-17 08:31:12 -04:00
josh a89cc36489 fix(deploy): login 500s on fresh container
CI / Lint · Typecheck · Test · Build (push) Successful in 42s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m16s
Two bugs kept a fresh docker-compose deploy from ever accepting admin:admin:

1. resolveSqliteUrl in packages/db/src/client.ts stripped leading slashes
   wholesale — so file:/data/vector.db became a relative path and was
   resolved against packages/db/prisma/. Prisma CLI (migrate deploy)
   correctly wrote to /data/vector.db on the mounted volume; the app's
   runtime client connected to an empty file at packages/db/prisma/data/
   vector.db with no tables, so login threw. The helper now passes Unix
   absolute paths through verbatim, still normalizes file:/// triple-
   slash URLs, and only resolves truly relative paths against the schema
   dir.

2. The Dockerfile CMD ran migrations but not a seed, so even when the
   path bug is fixed the User table is empty — admin:admin 401s forever.
   Added packages/db/ensure-admin.mjs (pure JS, no tsx needed) that
   creates the default admin user iff User.count() === 0, and wired it
   into the API CMD between migrate deploy and node. Credentials can be
   overridden with SEED_ADMIN_{USERNAME,PASSWORD,EMAIL}.
2026-04-17 08:17:22 -04:00
josh 439c1b41e6 deploy(compose): inline restart: unless-stopped per service
CI / Lint · Typecheck · Test · Build (push) Successful in 51s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 5m47s
Drop the x-restart YAML anchor — three-char-longer per service isn't
worth the indirection.
2026-04-16 21:31:30 -04:00
josh fcd4aa6542 deploy(compose): hardcode registry host, pull-only stack
CI / Lint · Typecheck · Test · Build (push) Successful in 51s
CI / Build & push images (push) Has been cancelled
CI / Playwright (smoke) (push) Has been cancelled
Lock images to gitea.thewrightserver.net/josh/{vector-api,vector-web}
and drop the build: sections. docker compose up now only pulls; source
builds happen exclusively in CI.
2026-04-16 21:30:31 -04:00
josh 07431c6550 ci: namespace registry images under repo owner
CI / Lint · Typecheck · Test · Build (push) Successful in 49s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 3m18s
Gitea's OCI registry requires <host>/<owner>/<image>. Pushes to the
bare <host>/<image> path return 404. Prepend github.repository_owner
so REGISTRY_URL can stay as just the hostname.
2026-04-16 21:20:49 -04:00
josh 37494c17ef fix(docker): prisma generate must run before packages/db tsc build
CI / Lint · Typecheck · Test · Build (push) Successful in 49s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Failing after 51s
packages/db/src/index.ts re-exports model types from @prisma/client,
so the generated client has to exist before tsc walks that file. The
previous order hit TS2305 on User/Manufacturer/Site/etc.
2026-04-16 21:18:04 -04:00
josh 56ad33125d ci: fix missed upload-artifact@v4 in check job
CI / Lint · Typecheck · Test · Build (push) Successful in 1m7s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Failing after 28s
The previous commit only hit the e2e job; the check job's coverage
upload step still referenced v4 because of different indentation.
2026-04-16 21:14:59 -04:00
josh 68ba048462 ci: pin upload-artifact to v3 for Gitea compatibility
CI / Lint · Typecheck · Test · Build (push) Failing after 41s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped
Gitea Actions rejects @actions/artifact v2.0+ (upload-artifact@v4,
download-artifact@v4) with a GHESNotSupportedError. v3 is the highest
supported on current Gitea releases.
2026-04-16 21:13:29 -04:00
josh d8d734d3f3 ci: drop 60% coverage gate, keep report as a signal
CI / Lint · Typecheck · Test · Build (push) Failing after 44s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped
CI was failing because only ~7% of services/lib is covered today — the
60% threshold was aspirational, not grounded in what ships. Keep the
v8 report + artifact upload so contributors can see the trend; add a
threshold back once service-level coverage catches up.
2026-04-16 21:12:10 -04:00
josh acf6fc1103 feat(deploy): containerize api + web for single-host docker-compose
CI / Lint · Typecheck · Test · Build (push) Failing after 43s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Has been skipped
- apps/api/Dockerfile: multi-stage build, runs prisma migrate deploy on
  boot. Workspace package.json "main/exports" rewritten to dist so Node
  ESM resolves compiled JS at runtime.
- apps/web/Dockerfile + nginx.conf: static build served by nginx with
  SPA fallback, gzip, cache-bust on hashed assets, and /api reverse
  proxy to the internal api service.
- docker-compose.yml: production-oriented stack — api (SQLite on a
  named volume), web (exposes WEB_PORT), redis (for the upcoming
  worker). Postgres dropped since schema still targets SQLite.
- .dockerignore: keep build context lean.
- ci: add docker job gated on push-to-main that builds and pushes both
  images to ${{ vars.REGISTRY_URL }} using ${{ secrets.REGISTRY_TOKEN }}.
  Tags :latest + :${github.sha}.
2026-04-16 21:10:04 -04:00
josh f32ece6f74 ci: drop pnpm cache from setup-node
CI / Lint · Typecheck · Test · Build (push) Failing after 37s
CI / Playwright (smoke) (push) Has been skipped
The Gitea Actions cache server is unreachable from the runner, so
cache: pnpm hangs ~4m42s on ETIMEDOUT before falling through. Removing
the option drops the step to ~5s; pnpm install on a clean runner is
already fast with the frozen lockfile.
2026-04-16 21:02:20 -04:00
josh 261d6a526c docs: rewrite README as complete onboarding guide
Replace placeholder with a professional README covering architecture,
tech stack, getting-started flow, common tasks, testing, Gitea CI,
conventions, and the nine-phase roadmap.
2026-04-16 21:02:16 -04:00
115 changed files with 10582 additions and 1929 deletions
+28
View File
@@ -0,0 +1,28 @@
**/node_modules
**/dist
**/.turbo
**/coverage
**/tsconfig.tsbuildinfo
# env + local files
**/.env
**/.env.*.local
**/*.local
# VCS + tooling
.git
.gitea
.github
.claude
.vscode
.idea
# playwright artifacts
apps/e2e/test-results
apps/e2e/playwright-report
# logs + local databases
**/*.log
**/dev.db
**/dev.db-journal
+44 -4
View File
@@ -29,7 +29,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: pnpm install --frozen-lockfile
@@ -52,7 +51,7 @@ jobs:
run: pnpm build run: pnpm build
- name: Upload API coverage - name: Upload API coverage
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: api-coverage name: api-coverage
@@ -75,7 +74,6 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: ${{ env.NODE_VERSION }} node-version: ${{ env.NODE_VERSION }}
cache: pnpm
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm -C packages/db exec prisma generate - run: pnpm -C packages/db exec prisma generate
@@ -86,9 +84,51 @@ jobs:
TEST_USERNAME: ${{ secrets.E2E_USERNAME }} TEST_USERNAME: ${{ secrets.E2E_USERNAME }}
TEST_PASSWORD: ${{ secrets.E2E_PASSWORD }} TEST_PASSWORD: ${{ secrets.E2E_PASSWORD }}
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v3
if: always() if: always()
with: with:
name: playwright-report name: playwright-report
path: apps/e2e/playwright-report path: apps/e2e/playwright-report
retention-days: 7 retention-days: 7
docker:
name: Build & push images
runs-on: ubuntu-latest
needs: check
# Only push from main, and only on direct pushes (not PRs from forks).
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
- name: Log in to ${{ vars.REGISTRY_URL }}
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ vars.REGISTRY_URL }}" --username "${{ github.actor }}" --password-stdin
# Gitea's OCI registry is namespaced by owner — images must be at
# <host>/<owner>/<image>. Prepend the repo owner so REGISTRY_URL can
# stay as just the hostname.
- name: Build & push API image
run: |
IMAGE="${{ vars.REGISTRY_URL }}/${{ github.repository_owner }}/vector-api"
docker build \
-f apps/api/Dockerfile \
-t "$IMAGE:${{ github.sha }}" \
-t "$IMAGE:latest" \
.
docker push "$IMAGE:${{ github.sha }}"
docker push "$IMAGE:latest"
- name: Build & push Web image
run: |
IMAGE="${{ vars.REGISTRY_URL }}/${{ github.repository_owner }}/vector-web"
docker build \
-f apps/web/Dockerfile \
-t "$IMAGE:${{ github.sha }}" \
-t "$IMAGE:latest" \
.
docker push "$IMAGE:${{ github.sha }}"
docker push "$IMAGE:latest"
- name: Log out
if: always()
run: docker logout "${{ vars.REGISTRY_URL }}"
+198 -24
View File
@@ -1,41 +1,215 @@
# Vector 2.0 # Vector
Hardware parts inventory — monorepo. Serialized hardware inventory for engineering fleets. Track every part from
receiving → bin → host → repair → destruction, with a complete per-part audit
trail, field-maintenance ticketing, per-technician custody, and manufacturer
EOL visibility.
## Layout Vector is a pnpm + Turborepo monorepo written in strict TypeScript end to end:
an Express/Prisma API, a React/Vite web client, shared zod contracts, and a
shadcn/ui design system.
---
## What it does
**Inventory**
- Serialized `Part`s live under `Site → Room → Bin` locations or on a `Host`.
- Every state change writes a `PartEvent` in the same transaction — nothing
mutates silently.
- `PartModel`s are grouped by `Manufacturer` and `Category`, with optional EOL
dates that feed dashboard risk widgets.
- `Tag`s and `SavedView`s for cross-cutting organization and reusable filters.
**Field workflow**
- `FM` tickets track active issues per host, with attached repairs and close
times.
- `Repair` logs pair a broken serial with its replacement and auto-ingest parts
from a tech's input when the broken serial isn't yet in Vector.
- Per-technician **custody** tracks parts held for repair, pending drop-off, or
pending destruction — so broken or pre-staged parts never go missing between
events.
**Operations dashboard**
- Universal KPIs: total parts, total spent, deployed value, open FMs,
past-EOL deployments, upcoming-EOL deployments (≤180 d).
- Admin-only: 7/30-day repair tempo, FMs opened, average FM close time, repairs
trend chart, open-FMs-by-host, and a custody backlog by user.
**Integrations**
- Outbound webhooks signed with `HMAC-SHA256(timestamp + "." + body)`, headers
`x-vector-signature`, `x-vector-timestamp`, `x-vector-event`, plus a
`x-vector-webhook: v1` recursion guard.
- Streaming CSV export of the audit log for admins.
---
## Architecture
``` ```
apps/ vector/
web/ # React + Vite client ├── apps/
api/ # Express + Prisma API │ ├── api/ Express 5 + Prisma + zod. controllers → services → tx.
packages/ │ ├── web/ React 19 + Vite + TanStack Query/Table + shadcn/ui.
db/ # Prisma schema + client (placeholder) └── e2e/ Playwright smoke tests (login, parts, repairs, admin).
shared/ # Shared zod schemas + types (placeholder) ├── packages/
ui/ # Design system + shadcn primitives (placeholder) ├── db/ Prisma schema, migrations, seed, singleton client.
config/ # Shared eslint / tsconfig / tailwind (placeholder) │ ├── shared/ zod schemas + DTOs — the source of truth for the API.
│ ├── ui/ shadcn primitives + Vector design tokens.
│ └── config/ Shared tsconfig + Tailwind tokens.
├── .gitea/workflows/ci.yaml lint · typecheck · test · build, gated E2E job.
├── docker-compose.yml api + web + redis (production-style stack).
└── turbo.json dev · build · test · typecheck · lint.
``` ```
## Prereqs The API is strict about its boundaries. Every service takes `(tx, input, actor)`
so controllers can compose multiple services inside a single
`prisma.$transaction` — part mutations and their audit rows are always atomic
or not at all. Controllers do no validation and no business logic; they parse
through a zod schema and dispatch.
- Node >= 20 ---
- pnpm (via `npm i -g pnpm` or corepack)
- Docker (for Postgres + Redis in later phases — current apps still use SQLite) ## Tech stack
| Layer | Choice |
| -------------- | ---------------------------------------------------------------------- |
| Language | TypeScript strict mode across every workspace |
| API | Express 5, Prisma 5, zod, JWT + refresh-token rotation, CSRF, helmet |
| Web | React 19, Vite, TanStack Query, TanStack Table, react-hook-form, nuqs |
| UI | shadcn/ui on Radix primitives, Tailwind v4, Sonner toasts, Recharts |
| Database | SQLite (single-file, shipped in prod via Docker volume) |
| Observability | pino (JSON in prod, pretty in dev) with per-request `requestId` |
| Testing | Vitest (unit), Playwright (E2E) |
| Monorepo | pnpm workspaces + Turborepo |
| CI / deps | Gitea Actions + self-hosted Renovate |
The schema is intentionally Postgres-portable; SQLite is the default because
Vector is deployed as a single-container stack per site.
---
## Quick start ## Quick start
Prerequisites: **Node 20+**, **pnpm 10+** (`corepack enable`).
```bash ```bash
pnpm install pnpm install
pnpm dev # runs apps/web and apps/api concurrently via Turbo cp apps/api/.env.example apps/api/.env
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # paste as JWT_SECRET
pnpm -C packages/db exec prisma migrate dev
pnpm -C packages/db run db:seed
pnpm dev
``` ```
The API listens on `http://localhost:3001`; the web app proxies `/api` to it and serves on `http://localhost:5173`. - API: <http://localhost:3001>
- Web: <http://localhost:5173> (proxies `/api` → API)
- Default credentials: **`admin` / `admin`** — change them immediately.
## Phase status ---
**Phase 0 — Monorepo foundation** ## Deployment
- pnpm workspaces + Turbo
- `apps/web` and `apps/api` scaffolded
- `packages/*` placeholders
- `docker-compose.yml` for Postgres + Redis
Later phases: TypeScript + Postgres migration, API refactor, schema extensions, shadcn redesign, feature slices, observability. `docker-compose.yml` runs the full stack from prebuilt images
(`vector-api`, `vector-web`, `redis`). The SQLite database lives in the
`vector-data` volume.
```bash
# 1. authenticate to the registry
docker login gitea.thewrightserver.net
# 2. create a .env next to docker-compose.yml with at minimum:
# JWT_SECRET=<64-char hex>
# CLIENT_ORIGIN=https://vector.example.com
# WEB_PORT=8080
# COOKIE_SECURE=true # required once you're behind TLS
# TAG=latest # or a pinned commit SHA
docker compose pull && docker compose up -d
```
Redis is included in anticipation of the BullMQ-backed webhook worker; the
in-process emitter currently used by the API doesn't depend on it yet.
---
## Development commands
All commands run at the workspace root; Turbo fans them out with caching.
| Command | Does |
| -------------------------------------- | ------------------------------------------------------ |
| `pnpm dev` | API + web concurrently |
| `pnpm build` | Build every package and app |
| `pnpm typecheck` | `tsc --noEmit` across the graph |
| `pnpm lint` | ESLint across the graph |
| `pnpm test` | Vitest across packages that define it |
| `pnpm -C apps/api test:coverage` | Services/lib coverage report (no threshold gate yet) |
| `pnpm -C apps/e2e test` | Playwright, against a live stack (see below) |
| `pnpm -C packages/db run db:studio` | Prisma Studio |
| `pnpm -C packages/db run db:reset` | Drop schema, migrate, seed |
---
## Testing
**Unit tests** live next to the code they cover (`*.test.ts`). The API covers
services, `lib/` helpers, and the analytics aggregator (tested with an
in-memory `Tx` double). Shared covers every zod contract.
**End-to-end tests** in `apps/e2e/` run Playwright against a live stack and
skip themselves unless `TEST_USERNAME` and `TEST_PASSWORD` are set:
```bash
pnpm dev # terminal 1
TEST_USERNAME=admin TEST_PASSWORD=admin \
pnpm -C apps/e2e exec playwright install --with-deps chromium
TEST_USERNAME=admin TEST_PASSWORD=admin \
pnpm -C apps/e2e test
```
Reports land in `apps/e2e/playwright-report/`.
---
## Continuous integration
`.gitea/workflows/ci.yaml` runs on every push and PR:
1. `pnpm install --frozen-lockfile`
2. `prisma generate`
3. `pnpm lint`
4. `pnpm typecheck`
5. `pnpm -C packages/shared test` + `pnpm -C apps/api test:coverage`
6. `pnpm build`
7. API coverage uploaded as a workflow artifact.
A second Playwright job is gated by repo variable `ENABLE_E2E=true` and needs
secrets `E2E_BASE_URL`, `E2E_USERNAME`, `E2E_PASSWORD`.
Dependency updates come from self-hosted Renovate — grouped minor/patch PRs
weekly, auto-merge for dev-dep patches, `prisma` + `@prisma/client` grouped
together, Radix/shadcn held for manual review.
---
## Conventions
- **API shape** — errors are `{ code, message, requestId, details? }`;
paginated lists are `{ data, page, pageSize, total }`.
- **Validation** — every request body and query is parsed through a zod schema
from `@vector/shared` before reaching a controller. No ad-hoc validation
inside route handlers.
- **Query keys** — hierarchical factory at `apps/web/src/lib/queryKeys.ts`.
Invalidate by domain (`queryKeys.parts.all`) or by filter set
(`queryKeys.parts.list(filters)`).
- **Commits** — [Conventional Commits](https://www.conventionalcommits.org/).
Renovate already expects them.
+51
View File
@@ -0,0 +1,51 @@
# syntax=docker/dockerfile:1.7
# ---------- build ----------
FROM node:22-alpine AS build
RUN apk add --no-cache openssl
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /repo
# Manifests first for cache-friendly installs.
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
COPY apps/e2e/package.json ./apps/e2e/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm -C packages/db exec prisma generate \
&& pnpm -C packages/shared build \
&& pnpm -C packages/db build \
&& pnpm -C apps/api build
# Point workspace package.json "main/exports" at compiled JS so Node
# (ESM) resolves dist/ at runtime instead of the original .ts sources.
RUN for p in packages/db packages/shared; do \
node -e "const fs=require('fs');const j=JSON.parse(fs.readFileSync('$p/package.json'));j.main='./dist/index.js';j.types='./dist/index.d.ts';j.exports={'.':{types:'./dist/index.d.ts',default:'./dist/index.js'}};fs.writeFileSync('$p/package.json',JSON.stringify(j,null,2));" ; \
done
# ---------- runtime ----------
FROM node:22-alpine
RUN apk add --no-cache openssl wget
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /app
COPY --from=build /repo /app
ENV NODE_ENV=production
ENV PORT=3001
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD wget -qO- http://localhost:3001/healthz || exit 1
WORKDIR /app/apps/api
# Apply migrations → seed default admin on empty DB → start API.
# Override the default admin credentials via SEED_ADMIN_{USERNAME,PASSWORD,EMAIL}.
CMD ["sh", "-c", "pnpm -C ../../packages/db exec prisma migrate deploy && node ../../packages/db/ensure-admin.mjs && node dist/index.js"]
-1
View File
@@ -22,7 +22,6 @@
"cors": "^2.8.6", "cors": "^2.8.6",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.3.2",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pino": "^10.3.1", "pino": "^10.3.1",
+5 -10
View File
@@ -3,7 +3,6 @@ import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import { pinoHttp } from 'pino-http'; import { pinoHttp } from 'pino-http';
import rateLimit from 'express-rate-limit';
import { prisma } from '@vector/db'; import { prisma } from '@vector/db';
import { env } from './env.js'; import { env } from './env.js';
@@ -14,6 +13,7 @@ import { errorHandler } from './middleware/error.js';
import authRoutes from './routes/auth.js'; import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js'; import userRoutes from './routes/users.js';
import manufacturerRoutes from './routes/manufacturers.js'; import manufacturerRoutes from './routes/manufacturers.js';
import partModelRoutes from './routes/part-models.js';
import siteRoutes from './routes/sites.js'; import siteRoutes from './routes/sites.js';
import roomRoutes from './routes/rooms.js'; import roomRoutes from './routes/rooms.js';
import binRoutes from './routes/bins.js'; import binRoutes from './routes/bins.js';
@@ -22,6 +22,7 @@ import tagRoutes from './routes/tags.js';
import categoryRoutes from './routes/categories.js'; import categoryRoutes from './routes/categories.js';
import hostRoutes from './routes/hosts.js'; import hostRoutes from './routes/hosts.js';
import repairRoutes from './routes/repairs.js'; import repairRoutes from './routes/repairs.js';
import custodyRoutes from './routes/custody.js';
import savedViewRoutes from './routes/saved-views.js'; import savedViewRoutes from './routes/saved-views.js';
import analyticsRoutes from './routes/analytics.js'; import analyticsRoutes from './routes/analytics.js';
import webhookRoutes from './routes/webhooks.js'; import webhookRoutes from './routes/webhooks.js';
@@ -67,18 +68,11 @@ app.get('/readyz', async (_req, res) => {
} }
}); });
const authLimiter = rateLimit({ app.use('/api/auth', authRoutes);
windowMs: 60 * 1000,
limit: env.NODE_ENV === 'production' ? 5 : 50,
standardHeaders: 'draft-7',
legacyHeaders: false,
message: { code: 'RATE_LIMITED', message: 'Too many auth requests. Try again soon.' },
});
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api', requireCsrf); app.use('/api', requireCsrf);
app.use('/api/users', userRoutes); app.use('/api/users', userRoutes);
app.use('/api/manufacturers', manufacturerRoutes); app.use('/api/manufacturers', manufacturerRoutes);
app.use('/api/part-models', partModelRoutes);
app.use('/api/sites', siteRoutes); app.use('/api/sites', siteRoutes);
app.use('/api/rooms', roomRoutes); app.use('/api/rooms', roomRoutes);
app.use('/api/bins', binRoutes); app.use('/api/bins', binRoutes);
@@ -87,6 +81,7 @@ app.use('/api/tags', tagRoutes);
app.use('/api/categories', categoryRoutes); app.use('/api/categories', categoryRoutes);
app.use('/api/hosts', hostRoutes); app.use('/api/hosts', hostRoutes);
app.use('/api/repairs', repairRoutes); app.use('/api/repairs', repairRoutes);
app.use('/api/custody', custodyRoutes);
app.use('/api/saved-views', savedViewRoutes); app.use('/api/saved-views', savedViewRoutes);
app.use('/api/analytics', analyticsRoutes); app.use('/api/analytics', analyticsRoutes);
app.use('/api/admin/webhooks', webhookRoutes); app.use('/api/admin/webhooks', webhookRoutes);
+3 -2
View File
@@ -2,9 +2,10 @@ import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db'; import { prisma } from '@vector/db';
import * as svc from '../services/analytics.js'; import * as svc from '../services/analytics.js';
export async function dashboard(_req: Request, res: Response, next: NextFunction) { export async function dashboard(req: Request, res: Response, next: NextFunction) {
try { try {
const data = await prisma.$transaction((tx) => svc.dashboard(tx)); const isAdmin = req.user?.role === 'ADMIN';
const data = await prisma.$transaction((tx) => svc.dashboard(tx, { isAdmin }));
res.json(data); res.json(data);
} catch (err) { } catch (err) {
next(err); next(err);
+2 -2
View File
@@ -9,7 +9,7 @@ import { errors } from '../lib/http-error.js';
const accessCookieOpts: CookieOptions = { const accessCookieOpts: CookieOptions = {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: env.NODE_ENV === 'production', secure: env.COOKIE_SECURE,
path: '/', path: '/',
maxAge: authService.ACCESS_TOKEN_TTL_MS, maxAge: authService.ACCESS_TOKEN_TTL_MS,
}; };
@@ -17,7 +17,7 @@ const accessCookieOpts: CookieOptions = {
const refreshCookieOpts: CookieOptions = { const refreshCookieOpts: CookieOptions = {
httpOnly: true, httpOnly: true,
sameSite: 'lax', sameSite: 'lax',
secure: env.NODE_ENV === 'production', secure: env.COOKIE_SECURE,
path: '/api/auth', path: '/api/auth',
maxAge: authService.REFRESH_TOKEN_TTL_MS, maxAge: authService.REFRESH_TOKEN_TTL_MS,
}; };
+25
View File
@@ -6,6 +6,7 @@ import type {
UpdateCategoryRequest, UpdateCategoryRequest,
} from '@vector/shared'; } from '@vector/shared';
import * as svc from '../services/categories.js'; import * as svc from '../services/categories.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
@@ -17,6 +18,30 @@ export async function list(req: Request, res: Response, next: NextFunction) {
} }
} }
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const category = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!category) throw errors.notFound('Category');
res.json(category);
} catch (err) {
next(err);
}
}
export async function getInsights(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id));
if (!insights) throw errors.notFound('Category');
res.json(insights);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) { export async function create(req: Request, res: Response, next: NextFunction) {
try { try {
const input = req.validated!.body as CreateCategoryRequest; const input = req.validated!.body as CreateCategoryRequest;
+49
View File
@@ -0,0 +1,49 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type { CustodyListQuery, DropOffRequest } from '@vector/shared';
import * as svc from '../services/custody.js';
import { errors } from '../lib/http-error.js';
export async function listMine(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const q = req.validated!.query as CustodyListQuery;
const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!.id, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function dropOff(
req: Request<{ partId: string }>,
res: Response,
next: NextFunction,
) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as DropOffRequest;
const part = await prisma.$transaction((tx) =>
svc.dropOff(tx, req.params.partId, input, req.user!),
);
res.json(part);
} catch (err) {
next(err);
}
}
export async function takeForRepair(
req: Request<{ partId: string }>,
res: Response,
next: NextFunction,
) {
try {
if (!req.user) throw errors.unauthorized();
const part = await prisma.$transaction((tx) =>
svc.takeForRepair(tx, req.params.partId, req.user!),
);
res.json(part);
} catch (err) {
next(err);
}
}
+43 -2
View File
@@ -3,6 +3,7 @@ import { prisma } from '@vector/db';
import type { import type {
CreateHostRequest, CreateHostRequest,
HostListQuery, HostListQuery,
HostTimelineQuery,
UpdateHostRequest, UpdateHostRequest,
} from '@vector/shared'; } from '@vector/shared';
import * as svc from '../services/hosts.js'; import * as svc from '../services/hosts.js';
@@ -28,10 +29,19 @@ export async function get(req: Request<{ id: string }>, res: Response, next: Nex
} }
} }
export async function generateAssetId(_req: Request, res: Response, next: NextFunction) {
try {
const result = await prisma.$transaction((tx) => svc.generateAssetId(tx));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) { export async function create(req: Request, res: Response, next: NextFunction) {
try { try {
const input = req.validated!.body as CreateHostRequest; const input = req.validated!.body as CreateHostRequest;
const host = await prisma.$transaction((tx) => svc.create(tx, input)); const host = await prisma.$transaction((tx) => svc.create(tx, input, req.user ?? null));
res.status(201).json(host); res.status(201).json(host);
} catch (err) { } catch (err) {
next(err); next(err);
@@ -41,13 +51,44 @@ export async function create(req: Request, res: Response, next: NextFunction) {
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) { export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try { try {
const input = req.validated!.body as UpdateHostRequest; const input = req.validated!.body as UpdateHostRequest;
const host = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input)); const host = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(host); res.json(host);
} catch (err) { } catch (err) {
next(err); next(err);
} }
} }
export async function listDeployedParts(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const parts = await prisma.$transaction((tx) =>
svc.listDeployedParts(tx, req.params.id),
);
res.json(parts);
} catch (err) {
next(err);
}
}
export async function getTimeline(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const q = req.validated!.query as HostTimelineQuery;
const result = await prisma.$transaction((tx) => svc.getTimeline(tx, req.params.id, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) { export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try { try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id)); await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
+24
View File
@@ -18,6 +18,30 @@ export async function list(req: Request, res: Response, next: NextFunction) {
} }
} }
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const m = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!m) throw errors.notFound('Manufacturer');
res.json(m);
} catch (err) {
next(err);
}
}
export async function getInsights(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id));
if (!insights) throw errors.notFound('Manufacturer');
res.json(insights);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) { export async function create(req: Request, res: Response, next: NextFunction) {
try { try {
const input = req.validated!.body as CreateManufacturerRequest; const input = req.validated!.body as CreateManufacturerRequest;
+72
View File
@@ -0,0 +1,72 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreatePartModelRequest,
PartModelListQuery,
UpdatePartModelRequest,
} from '@vector/shared';
import * as svc from '../services/part-models.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartModelListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const model = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!model) throw errors.notFound('Part model');
res.json(model);
} catch (err) {
next(err);
}
}
export async function getInsights(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const insights = await prisma.$transaction((tx) => svc.getInsights(tx, req.params.id));
if (!insights) throw errors.notFound('Part model');
res.json(insights);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreatePartModelRequest;
const model = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(model);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdatePartModelRequest;
const model = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(model);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+9 -48
View File
@@ -1,16 +1,12 @@
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db'; import { prisma } from '@vector/db';
import type { import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import * as svc from '../services/repairs.js'; import * as svc from '../services/repairs.js';
import { errors } from '../lib/http-error.js'; import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) { export async function list(req: Request, res: Response, next: NextFunction) {
try { try {
const q = req.validated!.query as RepairJobListQuery; const q = req.validated!.query as RepairListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q)); const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result); res.json(result);
} catch (err) { } catch (err) {
@@ -20,56 +16,21 @@ export async function list(req: Request, res: Response, next: NextFunction) {
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) { export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try { try {
const repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id)); const r = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!repair) throw errors.notFound('Repair'); if (!r) throw errors.notFound('Repair');
res.json(repair); res.json(r);
} catch (err) { } catch (err) {
next(err); next(err);
} }
} }
export async function listForPart( export async function log(req: Request, res: Response, next: NextFunction) {
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try { try {
const repairs = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id)); if (!req.user) throw errors.unauthorized();
res.json(repairs); const input = req.validated!.body as LogRepairRequest;
} catch (err) { const repair = await prisma.$transaction((tx) => svc.log(tx, input, req.user!));
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRepairJobRequest;
const repair = await prisma.$transaction((tx) =>
svc.create(tx, input, req.user ?? null),
);
res.status(201).json(repair); res.status(201).json(repair);
} catch (err) { } catch (err) {
next(err); next(err);
} }
} }
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateRepairJobRequest;
const repair = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(repair);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.remove(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+4 -1
View File
@@ -11,4 +11,7 @@ if (!parsed.success) {
process.exit(1); process.exit(1);
} }
export const env = parsed.data; export const env = {
...parsed.data,
COOKIE_SECURE: parsed.data.COOKIE_SECURE ?? parsed.data.NODE_ENV === 'production',
};
+1 -1
View File
@@ -12,7 +12,7 @@ export function issueCsrfToken(res: Response): string {
const opts: CookieOptions = { const opts: CookieOptions = {
httpOnly: false, httpOnly: false,
sameSite: 'lax', sameSite: 'lax',
secure: env.NODE_ENV === 'production', secure: env.COOKIE_SECURE,
path: '/', path: '/',
}; };
res.cookie(CSRF_COOKIE, token, opts); res.cookie(CSRF_COOKIE, token, opts);
+2
View File
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
const router = Router(); const router = Router();
router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list); router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/insights', requireAuth, ctrl.getInsights);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateCategoryRequest), ctrl.create);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update); router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import { CustodyListQuery, DropOffRequest } from '@vector/shared';
import * as ctrl from '../controllers/custody.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/mine', requireAuth, validate('query', CustodyListQuery), ctrl.listMine);
router.post(
'/:partId/drop-off',
requireAuth,
validate('body', DropOffRequest),
ctrl.dropOff,
);
router.post('/:partId/take-for-repair', requireAuth, ctrl.takeForRepair);
export default router;
+4
View File
@@ -2,6 +2,7 @@ import { Router } from 'express';
import { import {
CreateHostRequest, CreateHostRequest,
HostListQuery, HostListQuery,
HostTimelineQuery,
UpdateHostRequest, UpdateHostRequest,
} from '@vector/shared'; } from '@vector/shared';
import * as ctrl from '../controllers/hosts.js'; import * as ctrl from '../controllers/hosts.js';
@@ -12,7 +13,10 @@ const router = Router();
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list); router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
router.get('/generate-asset-id', requireAuth, requireRole('ADMIN'), ctrl.generateAssetId);
router.get('/:id', requireAuth, ctrl.get); router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/deployed-parts', requireAuth, ctrl.listDeployedParts);
router.get('/:id/timeline', requireAuth, validate('query', HostTimelineQuery), ctrl.getTimeline);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update); router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+2
View File
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
const router = Router(); const router = Router();
router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list); router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/insights', requireAuth, ctrl.getInsights);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateManufacturerRequest), ctrl.create); router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateManufacturerRequest), ctrl.create);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), ctrl.update); router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove); router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
+32
View File
@@ -0,0 +1,32 @@
import { Router } from 'express';
import {
CreatePartModelRequest,
PartModelListQuery,
UpdatePartModelRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/part-models.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PartModelListQuery), ctrl.list);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/insights', requireAuth, ctrl.getInsights);
router.post(
'/',
requireAuth,
requireRole('ADMIN'),
validate('body', CreatePartModelRequest),
ctrl.create,
);
router.patch(
'/:id',
requireAuth,
requireRole('ADMIN'),
validate('body', UpdatePartModelRequest),
ctrl.update,
);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
-3
View File
@@ -9,7 +9,6 @@ import {
} from '@vector/shared'; } from '@vector/shared';
import * as ctrl from '../controllers/parts.js'; import * as ctrl from '../controllers/parts.js';
import * as tagsCtrl from '../controllers/tags.js'; import * as tagsCtrl from '../controllers/tags.js';
import * as repairsCtrl from '../controllers/repairs.js';
import { requireAuth, requireRole } from '../middleware/auth.js'; import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js'; import { validate } from '../middleware/validate.js';
@@ -27,6 +26,4 @@ router.get('/:id/tags', requireAuth, tagsCtrl.listForPart);
router.post('/:id/tags', requireAuth, validate('body', AssignTagsRequest), tagsCtrl.assignToPart); router.post('/:id/tags', requireAuth, validate('body', AssignTagsRequest), tagsCtrl.assignToPart);
router.delete('/:id/tags/:tagId', requireAuth, tagsCtrl.unassignFromPart); router.delete('/:id/tags/:tagId', requireAuth, tagsCtrl.unassignFromPart);
router.get('/:id/repairs', requireAuth, repairsCtrl.listForPart);
export default router; export default router;
+3 -9
View File
@@ -1,19 +1,13 @@
import { Router } from 'express'; import { Router } from 'express';
import { import { LogRepairRequest, RepairListQuery } from '@vector/shared';
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/repairs.js'; import * as ctrl from '../controllers/repairs.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js'; import { validate } from '../middleware/validate.js';
const router = Router(); const router = Router();
router.get('/', requireAuth, validate('query', RepairJobListQuery), ctrl.list); router.get('/', requireAuth, validate('query', RepairListQuery), ctrl.list);
router.post('/', requireAuth, validate('body', CreateRepairJobRequest), ctrl.create); router.post('/', requireAuth, validate('body', LogRepairRequest), ctrl.log);
router.get('/:id', requireAuth, ctrl.get); router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update);
router.delete('/:id', requireAuth, ctrl.remove);
export default router; export default router;
+207 -55
View File
@@ -2,9 +2,15 @@ import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js'; import type { Tx } from './types.js';
import { dashboard } from './analytics.js'; import { dashboard } from './analytics.js';
// Minimal in-memory tx double exercising the dashboard() aggregator. type EolPartModel = {
// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented. id: string;
function makeTx(args: { mpn: string;
eolDate: Date | null;
manufacturerId: string;
manufacturer: { name: string };
};
type FakeArgs = {
partCount: number; partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[]; stateRows: { state: string; count: number; totalPrice: number }[];
parts: { parts: {
@@ -12,56 +18,91 @@ function makeTx(args: {
state: string; state: string;
binId: string | null; binId: string | null;
createdAt: Date; createdAt: Date;
manufacturerId: string; partModelId: string;
}[]; }[];
openRepairs: number; pastEolModels: EolPartModel[];
eolManufacturers: { id: string; name: string; eolDate: Date | null }[]; upcomingEolModels: EolPartModel[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[]; bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
}): Tx { // Admin-only inputs. Ignored when isAdmin=false path is exercised.
repairs?: { performedAt: Date }[];
custodyGroups?: { custodianId: string | null; count: number }[];
users?: { id: string; username: string }[];
};
function makeTx(args: FakeArgs): Tx {
const tx = { const tx = {
part: { part: {
count: async () => args.partCount, count: async () => args.partCount,
groupBy: async () => groupBy: async (q: { by: string[]; where?: { custodianId?: unknown } }) => {
args.stateRows.map((s) => ({ if (q.by.includes('custodianId')) {
return (args.custodyGroups ?? []).map((g) => ({
custodianId: g.custodianId,
_count: { _all: g.count },
}));
}
return args.stateRows.map((s) => ({
state: s.state, state: s.state,
_count: { _all: s.count }, _count: { _all: s.count },
_sum: { price: s.totalPrice }, _sum: { price: s.totalPrice },
})), }));
},
findMany: async () => args.parts, findMany: async () => args.parts,
}, },
repairJob: { partModel: {
count: async () => args.openRepairs, findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => {
const gt = q.where?.eolDate?.gt;
if (gt !== undefined) return args.upcomingEolModels;
return args.pastEolModels;
}, },
manufacturer: {
findMany: async () => args.eolManufacturers,
}, },
bin: { bin: {
findMany: async () => args.bins, findMany: async () => args.bins,
}, },
repair: {
count: async (q: { where?: { performedAt?: { gte: Date } } }) => {
const gte = q.where?.performedAt?.gte;
if (!gte) return 0;
return (args.repairs ?? []).filter((r) => r.performedAt >= gte).length;
},
findMany: async (q: { where?: { performedAt?: { gte: Date } } }) => {
const gte = q.where?.performedAt?.gte;
if (!gte) return args.repairs ?? [];
return (args.repairs ?? []).filter((r) => r.performedAt >= gte);
},
},
user: {
findMany: async () => args.users ?? [],
},
}; };
return tx as unknown as Tx; return tx as unknown as Tx;
} }
const now = new Date('2026-04-16T00:00:00.000Z'); const now = new Date('2026-04-16T00:00:00.000Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000); const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000);
describe('analytics.dashboard', () => { const EMPTY: FakeArgs = {
it('aggregates totals, state counts and open repairs', async () => { partCount: 0,
stateRows: [],
parts: [],
pastEolModels: [],
upcomingEolModels: [],
bins: [],
};
describe('analytics.dashboard — base fields', () => {
it('aggregates totals and state counts', async () => {
const tx = makeTx({ const tx = makeTx({
...EMPTY,
partCount: 5, partCount: 5,
stateRows: [ stateRows: [
{ state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 },
], ],
parts: [],
openRepairs: 4,
eolManufacturers: [],
bins: [],
}); });
const r = await dashboard(tx); const r = await dashboard(tx, { isAdmin: false });
expect(r.totalParts).toBe(5); expect(r.totalParts).toBe(5);
expect(r.openRepairs).toBe(4);
expect(r.byState).toEqual([ expect(r.byState).toEqual([
{ state: 'SPARE', count: 3, totalPrice: 1500 }, { state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 }, { state: 'DEPLOYED', count: 2, totalPrice: 8000 },
@@ -70,75 +111,186 @@ describe('analytics.dashboard', () => {
it('buckets parts by age correctly', async () => { it('buckets parts by age correctly', async () => {
const tx = makeTx({ const tx = makeTx({
...EMPTY,
partCount: 4, partCount: 4,
stateRows: [],
parts: [ parts: [
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), manufacturerId: 'm' }, { id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' },
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), manufacturerId: 'm' }, { id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' },
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), manufacturerId: 'm' }, { id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), manufacturerId: 'm' }, { id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
], ],
openRepairs: 0,
eolManufacturers: [],
bins: [],
}); });
const r = await dashboard(tx); const r = await dashboard(tx, { isAdmin: false });
const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count])); const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count]));
expect(byLabel['030d']).toBe(1); expect(byLabel['030d']).toBe(1);
expect(byLabel['3190d']).toBe(1); expect(byLabel['3190d']).toBe(1);
expect(byLabel['12y']).toBe(1); expect(byLabel['12y']).toBe(1);
expect(byLabel['2y+']).toBe(1); expect(byLabel['2y+']).toBe(1);
// totals should match
expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4); expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4);
}); });
it('ranks top bins and labels them site/room/bin', async () => { it('ranks top bins and labels them site/room/bin', async () => {
const tx = makeTx({ const tx = makeTx({
...EMPTY,
partCount: 4, partCount: 4,
stateRows: [],
parts: [ parts: [
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' }, { id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' }, { id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), manufacturerId: 'm' }, { id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), manufacturerId: 'm' }, { id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
], ],
openRepairs: 0,
eolManufacturers: [],
bins: [ bins: [
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } }, { id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
{ id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } }, { id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } },
], ],
}); });
const r = await dashboard(tx); const r = await dashboard(tx, { isAdmin: false });
expect(r.topBins).toEqual([ expect(r.topBins).toEqual([
{ binId: 'b1', label: 'HQ / Lab / A1', count: 2 }, { binId: 'b1', label: 'HQ / Lab / A1', count: 2 },
{ binId: 'b2', label: 'HQ / Lab / B2', count: 1 }, { binId: 'b2', label: 'HQ / Lab / B2', count: 1 },
]); ]);
}); });
it('flags manufacturers whose EOL has passed and have deployed parts', async () => { it('flags part models whose EOL has passed and have deployed parts', async () => {
const tx = makeTx({ const tx = makeTx({
...EMPTY,
partCount: 3, partCount: 3,
stateRows: [],
parts: [ parts: [
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' }, { id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' }, { id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm2' }, { id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
], ],
openRepairs: 0, pastEolModels: [
eolManufacturers: [ {
{ id: 'm1', name: 'Acme', eolDate: daysAgo(30) }, id: 'pm1',
{ id: 'm2', name: 'Beta', eolDate: daysAgo(10) }, mpn: 'ACM-100',
{ id: 'm3', name: 'Gamma', eolDate: daysAgo(5) }, eolDate: daysAgo(30),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
{
id: 'pm2',
mpn: 'BET-200',
eolDate: daysAgo(10),
manufacturerId: 'm2',
manufacturer: { name: 'Beta' },
},
{
id: 'pm3',
mpn: 'GAM-300',
eolDate: daysAgo(5),
manufacturerId: 'm3',
manufacturer: { name: 'Gamma' },
},
], ],
bins: [],
}); });
const r = await dashboard(tx); const r = await dashboard(tx, { isAdmin: false });
expect(r.deployedPastEol.map((m) => m.name)).toEqual(['Acme', 'Beta']); expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']);
expect(r.deployedPastEol[0]).toMatchObject({ manufacturerId: 'm1', deployedCount: 2 }); expect(r.deployedPastEol[0]).toMatchObject({
expect(r.deployedPastEol[1]).toMatchObject({ manufacturerId: 'm2', deployedCount: 1 }); partModelId: 'pm1',
manufacturerName: 'Acme',
deployedCount: 2,
});
expect(r.deployedPastEol[1]).toMatchObject({
partModelId: 'pm2',
manufacturerName: 'Beta',
deployedCount: 1,
});
});
});
describe('analytics.dashboard — upcomingEol', () => {
it('lists models with upcoming EOL sorted by date, filters zero-deployed', async () => {
const tx = makeTx({
...EMPTY,
partCount: 3,
parts: [
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
],
upcomingEolModels: [
{
id: 'pm2',
mpn: 'LATER',
eolDate: daysAhead(150),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
{
id: 'pm1',
mpn: 'SOONER',
eolDate: daysAhead(45),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
{
id: 'pm3',
mpn: 'NODEP',
eolDate: daysAhead(30),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
],
});
const r = await dashboard(tx, { isAdmin: false });
expect(r.upcomingEol.map((m) => m.mpn)).toEqual(['SOONER', 'LATER']);
expect(r.upcomingEol[0]).toMatchObject({ partModelId: 'pm1', deployedCount: 1 });
expect(r.upcomingEol[1]).toMatchObject({ partModelId: 'pm2', deployedCount: 2 });
});
});
describe('analytics.dashboard — isAdmin gating', () => {
it('omits operations when isAdmin is false', async () => {
const tx = makeTx(EMPTY);
const r = await dashboard(tx, { isAdmin: false });
expect(r.operations).toBeUndefined();
});
it('returns operations with expected shape when isAdmin is true', async () => {
const tx = makeTx({
...EMPTY,
repairs: [{ performedAt: daysAgo(1) }],
custodyGroups: [{ custodianId: 'u1', count: 1 }],
users: [{ id: 'u1', username: 'alice' }],
});
const r = await dashboard(tx, { isAdmin: true });
expect(r.operations).toBeDefined();
expect(r.operations).toMatchObject({
repairs7d: 1,
repairs30d: 1,
});
expect(r.operations!.repairsTrend30d).toHaveLength(30);
expect(r.operations!.custodyBacklog).toEqual([
{ userId: 'u1', username: 'alice', count: 1 },
]);
});
});
describe('analytics.dashboard — operations fields', () => {
it('repairsTrend30d has 30 entries and zero-fills empty days', async () => {
// Anchor the repairs to real "now" so they land inside the dashboard's
// 30-day window regardless of when the test runs.
const realNow = new Date();
const realDaysAgo = (n: number) => new Date(realNow.getTime() - n * 24 * 60 * 60 * 1000);
const tx = makeTx({
...EMPTY,
repairs: [{ performedAt: realDaysAgo(2) }, { performedAt: realDaysAgo(10) }],
});
const r = await dashboard(tx, { isAdmin: true });
const trend = r.operations!.repairsTrend30d;
expect(trend).toHaveLength(30);
const totalCount = trend.reduce((s, d) => s + d.count, 0);
expect(totalCount).toBe(2);
// Chronological order: earliest first, today last
for (let i = 1; i < trend.length; i++) {
expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true);
}
}); });
}); });
+131 -18
View File
@@ -1,4 +1,4 @@
import type { DashboardAnalytics } from '@vector/shared'; import type { DashboardAnalytics, OperationsAnalytics } from '@vector/shared';
import type { Tx } from './types.js'; import type { Tx } from './types.js';
const DAY = 24 * 60 * 60 * 1000; const DAY = 24 * 60 * 60 * 1000;
@@ -12,8 +12,25 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
{ label: '2y+', maxDays: null }, { label: '2y+', maxDays: null },
]; ];
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> { const CUSTODY_STATES = [
const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([ 'PENDING_REPAIR',
'PENDING_DROP_IN_CUSTODY',
'PENDING_DESTRUCTION_IN_CUSTODY',
] as const;
function utcDateKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
export async function dashboard(
tx: Tx,
opts: { isAdmin: boolean },
): Promise<DashboardAnalytics> {
const now = new Date();
const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY);
const [totalParts, stateRows, parts, pastEolModels, upcomingEolModels] =
await Promise.all([
tx.part.count(), tx.part.count(),
tx.part.groupBy({ tx.part.groupBy({
by: ['state'], by: ['state'],
@@ -21,12 +38,27 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
_sum: { price: true }, _sum: { price: true },
}), }),
tx.part.findMany({ tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, manufacturerId: true }, select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
}), }),
tx.repairJob.count({ where: { status: { in: ['PENDING', 'IN_PROGRESS'] } } }), tx.partModel.findMany({
tx.manufacturer.findMany({ where: { eolDate: { not: null, lte: now } },
where: { eolDate: { not: null, lte: new Date() } }, select: {
select: { id: true, name: true, eolDate: true }, id: true,
mpn: true,
eolDate: true,
manufacturerId: true,
manufacturer: { select: { name: true } },
},
}),
tx.partModel.findMany({
where: { eolDate: { gt: now, lte: upcomingEolCutoff } },
select: {
id: true,
mpn: true,
eolDate: true,
manufacturerId: true,
manufacturer: { select: { name: true } },
},
}), }),
]); ]);
@@ -36,10 +68,10 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
totalPrice: row._sum.price ?? 0, totalPrice: row._sum.price ?? 0,
})); }));
const now = Date.now(); const nowMs = now.getTime();
const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 })); const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 }));
for (const part of parts) { for (const part of parts) {
const ageDays = (now - part.createdAt.getTime()) / DAY; const ageDays = (nowMs - part.createdAt.getTime()) / DAY;
const idx = AGE_BUCKETS.findIndex((b) => b.maxDays === null || ageDays <= b.maxDays); const idx = AGE_BUCKETS.findIndex((b) => b.maxDays === null || ageDays <= b.maxDays);
const bucket = idx >= 0 ? buckets[idx] : undefined; const bucket = idx >= 0 ? buckets[idx] : undefined;
if (bucket) bucket.count += 1; if (bucket) bucket.count += 1;
@@ -69,20 +101,101 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
count: binCounts.get(id) ?? 0, count: binCounts.get(id) ?? 0,
})); }));
const deployedByMfg = new Map<string, number>(); const deployedByModel = new Map<string, number>();
for (const part of parts) { for (const part of parts) {
if (part.state !== 'DEPLOYED') continue; if (part.state !== 'DEPLOYED') continue;
deployedByMfg.set(part.manufacturerId, (deployedByMfg.get(part.manufacturerId) ?? 0) + 1); deployedByModel.set(part.partModelId, (deployedByModel.get(part.partModelId) ?? 0) + 1);
} }
const deployedPastEol = manufacturersWithEol const deployedPastEol = pastEolModels
.map((m) => ({ .map((m) => ({
manufacturerId: m.id, partModelId: m.id,
name: m.name, mpn: m.mpn,
eolDate: m.eolDate ? m.eolDate.toISOString() : null, manufacturerId: m.manufacturerId,
deployedCount: deployedByMfg.get(m.id) ?? 0, manufacturerName: m.manufacturer.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
})) }))
.filter((m) => m.deployedCount > 0) .filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount); .sort((a, b) => b.deployedCount - a.deployedCount);
return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openRepairs }; const upcomingEol = upcomingEolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
manufacturerId: m.manufacturerId,
manufacturerName: m.manufacturer.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => (a.eolDate < b.eolDate ? -1 : 1));
const base: DashboardAnalytics = {
totalParts,
byState,
ageBuckets: buckets,
topBins,
deployedPastEol,
upcomingEol,
};
if (!opts.isAdmin) return base;
const sevenDaysAgo = new Date(nowMs - 7 * DAY);
const thirtyDaysAgo = new Date(nowMs - 30 * DAY);
const [repairs7d, repairs30d, recentRepairs, custodyGroups] = await Promise.all([
tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }),
tx.repair.count({ where: { performedAt: { gte: thirtyDaysAgo } } }),
tx.repair.findMany({
where: { performedAt: { gte: thirtyDaysAgo } },
select: { performedAt: true },
}),
tx.part.groupBy({
by: ['custodianId'],
where: {
custodianId: { not: null },
state: { in: CUSTODY_STATES as unknown as string[] },
},
_count: { _all: true },
}),
]);
const trendByDay = new Map<string, number>();
for (const r of recentRepairs) {
const key = utcDateKey(r.performedAt);
trendByDay.set(key, (trendByDay.get(key) ?? 0) + 1);
}
const repairsTrend30d: { date: string; count: number }[] = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(nowMs - i * DAY);
const key = utcDateKey(d);
repairsTrend30d.push({ date: key, count: trendByDay.get(key) ?? 0 });
}
const topCustodians = [...custodyGroups]
.filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null)
.sort((a, b) => b._count._all - a._count._all)
.slice(0, 8);
const custodyUsers = topCustodians.length
? await tx.user.findMany({
where: { id: { in: topCustodians.map((g) => g.custodianId) } },
select: { id: true, username: true },
})
: [];
const usernames = new Map(custodyUsers.map((u) => [u.id, u.username]));
const custodyBacklog = topCustodians.map((g) => ({
userId: g.custodianId,
username: usernames.get(g.custodianId) ?? 'Unknown',
count: g._count._all,
}));
const operations: OperationsAnalytics = {
repairs7d,
repairs30d,
repairsTrend30d,
custodyBacklog,
};
return { ...base, operations };
} }
+211
View File
@@ -0,0 +1,211 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { getInsights } from './categories.js';
// Minimal in-memory tx double exercising categories.getInsights().
// Only the calls the function makes are stubbed.
interface FakeArgs {
categoryExists: boolean;
totalPartModels: number;
totalParts: number;
priceAgg: {
sum: number | null;
avg: number | null;
min: number | null;
max: number | null;
count: number;
};
repairCount: number;
distinctFailedBrokenPartIds: string[];
modelStateGroups: { partModelId: string; state: string; count: number }[];
allModels: {
id: string;
mpn: string;
manufacturer: { id: string; name: string };
}[];
eolModels: { id: string; mpn: string; eolDate: Date | null }[];
repairsWithModel: { brokenPart: { partModelId: string } }[];
}
function makeTx(args: FakeArgs): Tx {
const tx = {
category: {
findUnique: async () => (args.categoryExists ? { id: 'cat' } : null),
},
partModel: {
count: async () => args.totalPartModels,
findMany: async (opts: { where?: { eolDate?: unknown } }) => {
if (opts?.where && 'eolDate' in opts.where) return args.eolModels;
return args.allModels;
},
},
part: {
count: async () => args.totalParts,
groupBy: async () =>
args.modelStateGroups.map((g) => ({
partModelId: g.partModelId,
state: g.state,
_count: { _all: g.count },
})),
aggregate: async () => ({
_sum: { price: args.priceAgg.sum },
_avg: { price: args.priceAgg.avg },
_min: { price: args.priceAgg.min },
_max: { price: args.priceAgg.max },
_count: { _all: args.priceAgg.count },
}),
},
repair: {
count: async () => args.repairCount,
findMany: async (opts: { select?: { brokenPartId?: boolean } }) => {
if (opts?.select && 'brokenPartId' in opts.select) {
return args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId }));
}
return args.repairsWithModel;
},
},
};
return tx as unknown as Tx;
}
const empty: FakeArgs = {
categoryExists: true,
totalPartModels: 0,
totalParts: 0,
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
modelStateGroups: [],
allModels: [],
eolModels: [],
repairsWithModel: [],
};
describe('categories.getInsights', () => {
it('returns null when category does not exist', async () => {
const tx = makeTx({ ...empty, categoryExists: false });
const r = await getInsights(tx, 'nope');
expect(r).toBeNull();
});
it('aggregates totals and price stats', async () => {
const tx = makeTx({
...empty,
totalPartModels: 3,
totalParts: 12,
priceAgg: { sum: 1200, avg: 300, min: 100, max: 500, count: 4 },
});
const r = await getInsights(tx, 'cat');
expect(r!.totalPartModels).toBe(3);
expect(r!.totalParts).toBe(12);
expect(r!.priceStats).toEqual({
total: 1200,
average: 300,
min: 100,
max: 500,
countWithPrice: 4,
});
});
it('zeros price stats when no parts priced', async () => {
const tx = makeTx({ ...empty, totalParts: 5 });
const r = await getInsights(tx, 'cat');
expect(r!.priceStats).toEqual({
total: 0,
average: 0,
min: null,
max: null,
countWithPrice: 0,
});
});
it('counts repairs and distinct failed parts', async () => {
const tx = makeTx({
...empty,
repairCount: 5,
distinctFailedBrokenPartIds: ['p1', 'p2', 'p3'],
});
const r = await getInsights(tx, 'cat');
expect(r!.failures).toEqual({ repairs: 5, distinctFailedParts: 3 });
});
it('derives topModelsByUnits from modelStateGroups, sorted desc, truncated to 8', async () => {
const modelStateGroups = Array.from({ length: 9 }).map((_, i) => ({
partModelId: `pm${i + 1}`,
state: 'SPARE',
count: i + 1,
}));
const allModels = modelStateGroups.map((g) => ({
id: g.partModelId,
mpn: `MPN-${g.partModelId}`,
manufacturer: { id: 'mfr1', name: 'Acme' },
}));
const tx = makeTx({ ...empty, modelStateGroups, allModels });
const r = await getInsights(tx, 'cat');
expect(r!.topModelsByUnits).toHaveLength(8);
expect(r!.topModelsByUnits[0]).toEqual({ partModelId: 'pm9', mpn: 'MPN-pm9', count: 9 });
expect(r!.topModelsByUnits[7]).toEqual({ partModelId: 'pm2', mpn: 'MPN-pm2', count: 2 });
expect(r!.topModelsByUnits.find((m) => m.partModelId === 'pm1')).toBeUndefined();
});
it('groups failuresByModel, joins MPN, and sorts desc', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: 'pmA', mpn: 'AAA', manufacturer: { id: 'm1', name: 'Acme' } },
{ id: 'pmB', mpn: 'BBB', manufacturer: { id: 'm1', name: 'Acme' } },
],
repairsWithModel: [
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmB' } },
],
});
const r = await getInsights(tx, 'cat');
expect(r!.failuresByModel).toEqual([
{ partModelId: 'pmA', mpn: 'AAA', repairs: 3 },
{ partModelId: 'pmB', mpn: 'BBB', repairs: 1 },
]);
});
it('groups byManufacturer from allModels, sorted desc', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: '1', mpn: 'A', manufacturer: { id: 'm1', name: 'Acme' } },
{ id: '2', mpn: 'B', manufacturer: { id: 'm1', name: 'Acme' } },
{ id: '3', mpn: 'C', manufacturer: { id: 'm2', name: 'Beta' } },
],
});
const r = await getInsights(tx, 'cat');
expect(r!.byManufacturer).toEqual([
{ manufacturerId: 'm1', manufacturerName: 'Acme', count: 2 },
{ manufacturerId: 'm2', manufacturerName: 'Beta', count: 1 },
]);
});
it('pastEolModels includes only models with deployedCount > 0', async () => {
const tx = makeTx({
...empty,
modelStateGroups: [
{ partModelId: 'pmEOL1', state: 'DEPLOYED', count: 2 },
{ partModelId: 'pmEOL1', state: 'SPARE', count: 1 },
{ partModelId: 'pmEOL2', state: 'SPARE', count: 5 },
],
eolModels: [
{ id: 'pmEOL1', mpn: 'OLD-1', eolDate: new Date('2024-01-01') },
{ id: 'pmEOL2', mpn: 'OLD-2', eolDate: new Date('2024-01-01') },
],
});
const r = await getInsights(tx, 'cat');
expect(r!.pastEolModels).toEqual([
{
partModelId: 'pmEOL1',
mpn: 'OLD-1',
eolDate: new Date('2024-01-01').toISOString(),
deployedCount: 2,
},
]);
});
});
+142
View File
@@ -1,5 +1,6 @@
import { Prisma } from '@vector/db'; import { Prisma } from '@vector/db';
import type { import type {
CategoryInsights,
CategoryListQuery, CategoryListQuery,
CreateCategoryRequest, CreateCategoryRequest,
UpdateCategoryRequest, UpdateCategoryRequest,
@@ -7,6 +8,147 @@ import type {
import { errors } from '../lib/http-error.js'; import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js'; import type { Tx } from './types.js';
export function get(tx: Tx, id: string) {
return tx.category.findUnique({
where: { id },
include: { _count: { select: { partModels: true } } },
});
}
export async function getInsights(tx: Tx, id: string): Promise<CategoryInsights | null> {
const category = await tx.category.findUnique({ where: { id }, select: { id: true } });
if (!category) return null;
const now = new Date();
const modelWhere = { partModel: { categoryId: id } };
const [
totalPartModels,
totalParts,
priceAgg,
repairsCount,
distinctFailedParts,
modelStateGroups,
allModels,
eolModels,
repairsWithModel,
] = await Promise.all([
tx.partModel.count({ where: { categoryId: id } }),
tx.part.count({ where: modelWhere }),
tx.part.aggregate({
where: { ...modelWhere, price: { not: null } },
_sum: { price: true },
_avg: { price: true },
_min: { price: true },
_max: { price: true },
_count: { _all: true },
}),
tx.repair.count({ where: { brokenPart: modelWhere } }),
tx.repair.findMany({
where: { brokenPart: modelWhere },
select: { brokenPartId: true },
distinct: ['brokenPartId'],
}),
tx.part.groupBy({
by: ['partModelId', 'state'],
where: modelWhere,
_count: { _all: true },
}),
tx.partModel.findMany({
where: { categoryId: id },
select: {
id: true,
mpn: true,
manufacturer: { select: { id: true, name: true } },
},
}),
tx.partModel.findMany({
where: { categoryId: id, eolDate: { not: null, lte: now } },
select: { id: true, mpn: true, eolDate: true },
}),
tx.repair.findMany({
where: { brokenPart: modelWhere },
select: { brokenPart: { select: { partModelId: true } } },
}),
]);
const mpnById = new Map(allModels.map((m) => [m.id, m.mpn]));
const unitsByModel = new Map<string, number>();
const deployedByModel = new Map<string, number>();
for (const row of modelStateGroups) {
const n = row._count._all;
unitsByModel.set(row.partModelId, (unitsByModel.get(row.partModelId) ?? 0) + n);
if (row.state === 'DEPLOYED') {
deployedByModel.set(row.partModelId, (deployedByModel.get(row.partModelId) ?? 0) + n);
}
}
const topModelsByUnits = [...unitsByModel.entries()]
.map(([partModelId, count]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
const failuresByModelMap = new Map<string, number>();
for (const r of repairsWithModel) {
const pmId = r.brokenPart.partModelId;
failuresByModelMap.set(pmId, (failuresByModelMap.get(pmId) ?? 0) + 1);
}
const failuresByModel = [...failuresByModelMap.entries()]
.map(([partModelId, repairs]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
repairs,
}))
.sort((a, b) => b.repairs - a.repairs)
.slice(0, 8);
const manufacturerCounts = new Map<string, { id: string; name: string; count: number }>();
for (const m of allModels) {
const key = m.manufacturer.id;
const entry = manufacturerCounts.get(key);
if (entry) entry.count += 1;
else manufacturerCounts.set(key, { id: key, name: m.manufacturer.name, count: 1 });
}
const byManufacturer = [...manufacturerCounts.values()]
.map((m) => ({ manufacturerId: m.id, manufacturerName: m.name, count: m.count }))
.sort((a, b) => b.count - a.count);
const pastEolModels = eolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return {
totalPartModels,
totalParts,
priceStats: {
total: priceAgg._sum.price ?? 0,
average: priceAgg._avg.price ?? 0,
min: priceAgg._min.price,
max: priceAgg._max.price,
countWithPrice: priceAgg._count._all,
},
failures: {
repairs: repairsCount,
distinctFailedParts: distinctFailedParts.length,
},
byManufacturer,
topModelsByUnits,
failuresByModel,
pastEolModels,
};
}
export async function list(tx: Tx, q: CategoryListQuery) { export async function list(tx: Tx, q: CategoryListQuery) {
const { page, pageSize } = q; const { page, pageSize } = q;
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx, Actor } from './types.js';
import { dropOff, takeForRepair } from './custody.js';
const custodian: Actor = { id: 'user-1', username: 'tech', role: 'TECHNICIAN' };
const admin: Actor = { id: 'user-admin', username: 'boss', role: 'ADMIN' };
const otherUser: Actor = { id: 'user-2', username: 'other', role: 'TECHNICIAN' };
const brokenModel = {
id: 'pm-1',
manufacturerId: 'mfr-1',
mpn: 'WD-1',
destroyOnFail: false,
eolDate: null,
};
interface CustodyPartRow {
id: string;
serialNumber: string;
partModelId: string;
manufacturerId: string;
state: string;
binId: string | null;
hostId: string | null;
custodianId: string | null;
categoryId: string | null;
price: number | null;
notes: string | null;
partModel: typeof brokenModel;
manufacturer: { id: string; name: string };
bin: unknown;
host: unknown;
category: unknown;
custodian: { id: string; username: string } | null;
tags: unknown[];
}
function custodyPart(overrides: Partial<CustodyPartRow> = {}): CustodyPartRow {
return {
id: 'p-1',
serialNumber: 'SN-1',
partModelId: 'pm-1',
manufacturerId: 'mfr-1',
state: 'PENDING_DROP_IN_CUSTODY',
binId: null,
hostId: null,
custodianId: 'user-1',
categoryId: null,
price: null,
notes: null,
partModel: brokenModel,
manufacturer: { id: 'mfr-1', name: 'WD' },
bin: null,
host: null,
category: null,
custodian: { id: 'user-1', username: 'tech' },
tags: [],
...overrides,
};
}
// Build a Tx stub sufficient for custody.dropOff, which calls partsSvc.update internally.
function buildTx(initial: ReturnType<typeof custodyPart>) {
const current = { ...initial };
const partUpdate = vi.fn(
async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const d = args.data;
if (d.state) current.state = d.state as string;
if (d.bin !== undefined) {
const v = d.bin as { connect?: { id: string }; disconnect?: boolean };
current.binId = v.connect?.id ?? null;
}
if (d.host !== undefined) {
const v = d.host as { connect?: { id: string }; disconnect?: boolean };
current.hostId = v.connect?.id ?? null;
}
if (d.custodian !== undefined) {
const v = d.custodian as { connect?: { id: string }; disconnect?: boolean };
current.custodianId = v.connect?.id ?? null;
current.custodian = v.connect?.id
? { id: v.connect.id, username: 'tech' }
: null;
}
return current;
},
);
const tx = {
part: {
findUnique: async (args: { where: { id: string } }) =>
args.where.id === current.id ? current : null,
update: partUpdate,
},
partEvent: { createMany: vi.fn() },
partTag: { findMany: async () => [] },
} as unknown as Tx;
return { tx, current, partUpdate };
}
describe('custody.dropOff', () => {
it('PENDING_DROP_IN_CUSTODY → BROKEN with a bin; custodian cleared', async () => {
const { tx, current, partUpdate } = buildTx(custodyPart());
await dropOff(tx, 'p-1', { binId: 'bin-2' }, custodian);
expect(partUpdate).toHaveBeenCalledTimes(1);
const call = partUpdate.mock.calls[0]![0] as {
data: { state?: string; bin?: unknown; custodian?: unknown };
};
expect(call.data.state).toBe('BROKEN');
expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } });
expect(call.data.custodian).toEqual({ disconnect: true });
expect(current.state).toBe('BROKEN');
expect(current.custodianId).toBeNull();
});
it('PENDING_DESTRUCTION_IN_CUSTODY → PENDING_DESTRUCTION', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'PENDING_DESTRUCTION_IN_CUSTODY' }),
);
await dropOff(tx, 'p-1', { binId: null }, custodian);
expect(current.state).toBe('PENDING_DESTRUCTION');
expect(current.binId).toBeNull();
expect(current.custodianId).toBeNull();
});
it('admin can drop off a part held by someone else', async () => {
const { tx, current } = buildTx(custodyPart({ custodianId: 'user-2' }));
await dropOff(tx, 'p-1', { binId: 'bin-1' }, admin);
expect(current.state).toBe('BROKEN');
expect(current.custodianId).toBeNull();
});
it('rejects a non-custodian non-admin attempt with 403', async () => {
const { tx, partUpdate } = buildTx(custodyPart());
await expect(
dropOff(tx, 'p-1', { binId: 'bin-1' }, otherUser),
).rejects.toMatchObject({ status: 403 });
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects drop-off on a non-custody state with 400', async () => {
const { tx, partUpdate } = buildTx(
custodyPart({ state: 'SPARE', custodianId: null, custodian: null }),
);
await expect(
dropOff(tx, 'p-1', { binId: 'bin-1' }, custodian),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects drop-off on a missing part with 404', async () => {
const tx = {
part: { findUnique: async () => null, update: vi.fn() },
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(
dropOff(tx, 'p-missing', { binId: null }, custodian),
).rejects.toMatchObject({ status: 404 });
});
it('PENDING_REPAIR → SPARE when returned with a bin; custodian cleared', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
);
await dropOff(tx, 'p-1', { binId: 'bin-7' }, custodian);
expect(current.state).toBe('SPARE');
expect(current.binId).toBe('bin-7');
expect(current.custodianId).toBeNull();
});
it('rejects PENDING_REPAIR drop-off without a bin with 400', async () => {
const { tx, partUpdate } = buildTx(
custodyPart({ state: 'PENDING_REPAIR', binId: null }),
);
await expect(
dropOff(tx, 'p-1', { binId: null }, custodian),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
});
describe('custody.takeForRepair', () => {
it('SPARE → PENDING_REPAIR with the actor as custodian', async () => {
const { tx, current } = buildTx(
custodyPart({ state: 'SPARE', custodianId: null, custodian: null, binId: 'bin-1' }),
);
await takeForRepair(tx, 'p-1', custodian);
expect(current.state).toBe('PENDING_REPAIR');
expect(current.custodianId).toBe('user-1');
});
it('rejects take-for-repair on a non-SPARE part with 400', async () => {
const { tx, partUpdate } = buildTx(custodyPart({ state: 'DEPLOYED' }));
await expect(takeForRepair(tx, 'p-1', custodian)).rejects.toMatchObject({
status: 400,
});
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects take-for-repair on a missing part with 404', async () => {
const tx = {
part: { findUnique: async () => null, update: vi.fn() },
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(takeForRepair(tx, 'p-missing', custodian)).rejects.toMatchObject({
status: 404,
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { Prisma } from '@vector/db';
import type { CustodyListQuery, DropOffRequest } from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as partsSvc from './parts.js';
import type { Actor, Tx } from './types.js';
const custodyInclude = {
partModel: true,
manufacturer: true,
host: true,
custodian: { select: { id: true, username: true } },
} satisfies Prisma.PartInclude;
export async function listMine(tx: Tx, userId: string, q: CustodyListQuery) {
const { page, pageSize } = q;
const where: Prisma.PartWhereInput = { custodianId: userId };
const [data, total] = await Promise.all([
tx.part.findMany({
where,
orderBy: { updatedAt: 'desc' },
include: custodyInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.part.count({ where }),
]);
return { data, page, pageSize, total };
}
// Map of custody-state → state the part returns to when dropped off.
// PENDING_REPAIR is a spare the tech picked up for a future swap; on return it goes back to
// SPARE and must land in a bin (no useful "in-limbo" state for spares). The broken-part
// paths continue to allow binId=null since techs sometimes hold broken parts without a bin.
const DROP_OFF_TARGET: Record<string, { next: 'BROKEN' | 'PENDING_DESTRUCTION' | 'SPARE'; binRequired: boolean }> = {
PENDING_DROP_IN_CUSTODY: { next: 'BROKEN', binRequired: false },
PENDING_DESTRUCTION_IN_CUSTODY: { next: 'PENDING_DESTRUCTION', binRequired: false },
PENDING_REPAIR: { next: 'SPARE', binRequired: true },
};
export async function dropOff(
tx: Tx,
partId: string,
input: DropOffRequest,
actor: Actor,
) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
const target = DROP_OFF_TARGET[part.state];
if (!target) throw errors.badRequest('Part is not in custody');
if (part.custodianId !== actor.id && actor.role !== 'ADMIN') {
throw errors.forbidden('Only the current custodian can drop off this part');
}
if (target.binRequired && !input.binId) {
throw errors.badRequest('A bin is required when returning a spare to inventory');
}
return partsSvc.update(
tx,
partId,
{ state: target.next, binId: input.binId ?? null, custodianId: null },
actor,
);
}
// A tech takes a SPARE into their custody for a future repair. The part waits in
// PENDING_REPAIR until it's used in a Repair (→ DEPLOYED) or dropped back into a bin (→ SPARE).
export async function takeForRepair(tx: Tx, partId: string, actor: Actor) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
if (part.state !== 'SPARE') {
throw errors.badRequest('Only SPARE parts can be taken for a repair');
}
return partsSvc.update(
tx,
partId,
{ state: 'PENDING_REPAIR', custodianId: actor.id },
actor,
);
}
+292
View File
@@ -0,0 +1,292 @@
import { describe, expect, it, vi } from 'vitest';
import type { Actor, Tx } from './types.js';
import { create, getTimeline, update } from './hosts.js';
interface HostRow {
id: string;
assetId: string;
name: string;
location: string | null;
notes: string | null;
state: string;
stack: string;
}
interface HostEventRow {
hostId: string;
userId: string | null;
type: string;
field: string | null;
oldValue: string | null;
newValue: string | null;
}
const ACTOR: Actor = { id: 'user-1', username: 'admin', role: 'ADMIN' };
function buildTx(seed: HostRow[] = []) {
const registry = new Map(seed.map((h) => [h.id, h]));
const hostEvents: HostEventRow[] = [];
const tx = {
host: {
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
const row: HostRow = {
id: `host-${registry.size + 1}`,
assetId: String(args.data.assetId),
name: String(args.data.name),
location: (args.data.location as string | null) ?? null,
notes: (args.data.notes as string | null) ?? null,
state: String(args.data.state ?? 'DEPLOYED'),
stack: String(args.data.stack ?? 'PRODUCTION'),
};
registry.set(row.id, row);
return row;
}),
findUnique: vi.fn(async (args: { where: { id: string } }) => {
const row = registry.get(args.where.id);
return row ? { ...row } : null;
}),
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const current = registry.get(args.where.id);
if (!current) throw new Error(`No host ${args.where.id}`);
const d = args.data;
if (d.assetId !== undefined) current.assetId = String(d.assetId);
if (d.name !== undefined) current.name = String(d.name);
if (d.location !== undefined) current.location = (d.location as string | null) ?? null;
if (d.notes !== undefined) current.notes = (d.notes as string | null) ?? null;
if (d.state !== undefined) current.state = String(d.state);
if (d.stack !== undefined) current.stack = String(d.stack);
return current;
}),
},
hostEvent: {
create: vi.fn(async (args: { data: HostEventRow }) => {
hostEvents.push({
hostId: args.data.hostId,
userId: args.data.userId ?? null,
type: args.data.type,
field: args.data.field ?? null,
oldValue: args.data.oldValue ?? null,
newValue: args.data.newValue ?? null,
});
return args.data;
}),
createMany: vi.fn(async (args: { data: HostEventRow[] }) => {
for (const row of args.data) {
hostEvents.push({
hostId: row.hostId,
userId: row.userId ?? null,
type: row.type,
field: row.field ?? null,
oldValue: row.oldValue ?? null,
newValue: row.newValue ?? null,
});
}
return { count: args.data.length };
}),
},
} as unknown as Tx;
return { tx, registry, hostEvents };
}
describe('hosts.create', () => {
it('defaults state=DEPLOYED and stack=PRODUCTION when not supplied', async () => {
const { tx } = buildTx();
const host = await create(tx, { assetId: 'A-1', name: 'rack-1' }, ACTOR);
expect(host.state).toBe('DEPLOYED');
expect(host.stack).toBe('PRODUCTION');
});
it('emits a CREATED HostEvent', async () => {
const { tx, hostEvents } = buildTx();
await create(tx, { assetId: 'A-1', name: 'rack-1' }, ACTOR);
expect(hostEvents).toHaveLength(1);
expect(hostEvents[0]).toMatchObject({
type: 'CREATED',
newValue: 'A-1',
userId: 'user-1',
});
});
it('persists explicit state and stack', async () => {
const { tx } = buildTx();
const host = await create(
tx,
{ assetId: 'A-2', name: 'rack-2', state: 'TESTING', stack: 'VETTING' },
ACTOR,
);
expect(host.state).toBe('TESTING');
expect(host.stack).toBe('VETTING');
});
});
describe('hosts.update', () => {
const seedHost: HostRow = {
id: 'host-1',
assetId: 'A-1',
name: 'rack-1',
location: null,
notes: null,
state: 'DEPLOYED',
stack: 'PRODUCTION',
};
it('updates state and stack when provided', async () => {
const { tx, registry } = buildTx([{ ...seedHost }]);
await update(tx, 'host-1', { state: 'DEGRADED', stack: 'VETTING' }, ACTOR);
const row = registry.get('host-1')!;
expect(row.state).toBe('DEGRADED');
expect(row.stack).toBe('VETTING');
});
it('emits one HostEvent per changed field', async () => {
const { tx, hostEvents } = buildTx([{ ...seedHost }]);
await update(
tx,
'host-1',
{ state: 'DEGRADED', stack: 'VETTING', name: 'rack-renamed' },
ACTOR,
);
const types = hostEvents.map((e) => ({ type: e.type, field: e.field }));
expect(types).toEqual(
expect.arrayContaining([
{ type: 'STATE_CHANGED', field: 'state' },
{ type: 'STACK_CHANGED', field: 'stack' },
{ type: 'FIELD_UPDATED', field: 'name' },
]),
);
expect(hostEvents).toHaveLength(3);
});
it('emits no HostEvent when values match current', async () => {
const { tx, hostEvents } = buildTx([{ ...seedHost }]);
await update(tx, 'host-1', { state: 'DEPLOYED', stack: 'PRODUCTION' }, ACTOR);
expect(hostEvents).toHaveLength(0);
});
it('leaves state/stack untouched when not provided', async () => {
const { tx, registry } = buildTx([
{ ...seedHost, state: 'TESTING', stack: 'VETTING' },
]);
await update(tx, 'host-1', { name: 'rack-1-renamed' }, ACTOR);
const row = registry.get('host-1')!;
expect(row.state).toBe('TESTING');
expect(row.stack).toBe('VETTING');
expect(row.name).toBe('rack-1-renamed');
});
});
describe('hosts.getTimeline', () => {
it('merges HostEvents, Repairs, PartEvents in reverse-chronological order', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
const now = Date.now();
const t = (offsetMin: number) => new Date(now - offsetMin * 60_000);
const tx = {
host: {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: {
findMany: vi.fn(async () => [
{
id: 'he-1',
type: 'STATE_CHANGED',
field: 'state',
oldValue: 'DEPLOYED',
newValue: 'DEGRADED',
createdAt: t(10),
user: { username: 'alice' },
},
]),
},
repair: {
findMany: vi.fn(async () => [
{
id: 'r-1',
performedAt: t(20),
brokenPart: {
id: 'p-1',
serialNumber: 'CPU1',
partModel: { mpn: 'MPN-A' },
},
replacement: {
id: 'p-2',
serialNumber: 'CPU2',
partModel: { mpn: 'MPN-A' },
},
performedBy: { username: 'bob' },
},
]),
},
partEvent: {
findMany: vi.fn(async () => []),
},
} as unknown as Tx;
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.total).toBe(2);
expect(result.data.map((e) => e.type)).toEqual(['HOST_EVENT', 'REPAIR']);
});
it('classifies PartEvents as ARRIVED/DEPARTED by host name match', async () => {
const hostId = 'host-1';
const hostName = 'Vela';
const now = Date.now();
const tx = {
host: {
findUnique: vi.fn(async () => ({ id: hostId, name: hostName })),
},
hostEvent: { findMany: vi.fn(async () => []) },
repair: { findMany: vi.fn(async () => []) },
partEvent: {
findMany: vi.fn(async () => [
{
id: 'pe-1',
createdAt: new Date(now - 60_000),
oldValue: null,
newValue: hostName,
part: {
id: 'p-1',
serialNumber: 'CPU1',
partModel: { mpn: 'MPN-A' },
},
},
{
id: 'pe-2',
createdAt: new Date(now - 30_000),
oldValue: hostName,
newValue: null,
part: {
id: 'p-1',
serialNumber: 'CPU1',
partModel: { mpn: 'MPN-A' },
},
},
]),
},
} as unknown as Tx;
const result = await getTimeline(tx, hostId, { page: 1, pageSize: 20 });
expect(result.data.map((e) => e.type)).toEqual(['PART_DEPARTED', 'PART_ARRIVED']);
});
});
+308 -14
View File
@@ -2,21 +2,48 @@ import { Prisma } from '@vector/db';
import type { import type {
CreateHostRequest, CreateHostRequest,
HostListQuery, HostListQuery,
HostTimelineQuery,
UpdateHostRequest, UpdateHostRequest,
} from '@vector/shared'; } from '@vector/shared';
import { errors } from '../lib/http-error.js'; import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js'; import type { Actor, Tx } from './types.js';
function mapUniqueViolation(target: unknown): string {
if (Array.isArray(target) && target.includes('assetId')) return 'Asset ID already in use';
return 'Host name already exists';
}
// Accept either `hostId` (uuid) or `assetId` (string) — callers provide exactly one.
// Returns the resolved Host row so downstream writes can use the canonical id.
export async function resolveHost(
tx: Tx,
input: { hostId?: string | null; assetId?: string | null },
) {
if (input.hostId) {
const host = await tx.host.findUnique({ where: { id: input.hostId } });
if (!host) throw errors.notFound('Host');
return host;
}
if (input.assetId) {
const host = await tx.host.findUnique({ where: { assetId: input.assetId } });
if (!host) throw errors.notFound('Host');
return host;
}
throw errors.badRequest('Provide exactly one of hostId or assetId');
}
export async function list(tx: Tx, q: HostListQuery) { export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q; const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search const where: Prisma.HostWhereInput = {};
? { if (search) {
OR: [ where.OR = [
{ name: { contains: search } }, { name: { contains: search } },
{ assetId: { contains: search } },
{ location: { contains: search } }, { location: { contains: search } },
], ];
} }
: {}; if (q.state) where.state = q.state;
if (q.stack) where.stack = q.stack;
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
tx.host.findMany({ tx.host.findMany({
where, where,
@@ -33,46 +60,313 @@ export function get(tx: Tx, id: string) {
return tx.host.findUnique({ where: { id } }); return tx.host.findUnique({ where: { id } });
} }
export async function create(tx: Tx, input: CreateHostRequest) { // Random 8-digit asset ID (zero-padded) that isn't already taken. With ~100M
// possible values and only hundreds of hosts in practice, collisions are rare
// — we still retry a few times to be safe, then bail instead of looping forever.
export async function generateAssetId(tx: Tx): Promise<{ assetId: string }> {
for (let attempt = 0; attempt < 20; attempt++) {
const candidate = String(Math.floor(Math.random() * 100_000_000)).padStart(8, '0');
const existing = await tx.host.findUnique({ where: { assetId: candidate }, select: { id: true } });
if (!existing) return { assetId: candidate };
}
throw errors.conflict('Could not generate a unique asset ID');
}
export function listDeployedParts(tx: Tx, hostId: string) {
return tx.part.findMany({
where: { hostId, state: 'DEPLOYED' },
orderBy: { serialNumber: 'asc' },
include: { partModel: true, manufacturer: true },
});
}
export async function create(tx: Tx, input: CreateHostRequest, actor: Actor | null) {
let host;
try { try {
return await tx.host.create({ host = await tx.host.create({
data: { data: {
assetId: input.assetId,
name: input.name, name: input.name,
location: input.location ?? null, location: input.location ?? null,
notes: input.notes ?? null, notes: input.notes ?? null,
state: input.state ?? 'DEPLOYED',
stack: input.stack ?? 'PRODUCTION',
}, },
}); });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Host name already exists'); throw errors.conflict(mapUniqueViolation(err.meta?.target));
} }
throw err; throw err;
} }
await tx.hostEvent.create({
data: {
hostId: host.id,
userId: actor?.id ?? null,
type: 'CREATED',
newValue: host.assetId,
},
});
return host;
} }
export async function update(tx: Tx, id: string, input: UpdateHostRequest) { export async function update(
tx: Tx,
id: string,
input: UpdateHostRequest,
actor: Actor | null,
) {
const current = await tx.host.findUnique({ where: { id } });
if (!current) throw errors.notFound('Host');
const data: Prisma.HostUpdateInput = {}; const data: Prisma.HostUpdateInput = {};
if (input.assetId !== undefined) data.assetId = input.assetId;
if (input.name !== undefined) data.name = input.name; if (input.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location; if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes; if (input.notes !== undefined) data.notes = input.notes;
if (input.state !== undefined) data.state = input.state;
if (input.stack !== undefined) data.stack = input.stack;
let host;
try { try {
return await tx.host.update({ where: { id }, data }); host = await tx.host.update({ where: { id }, data });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Host'); if (err.code === 'P2025') throw errors.notFound('Host');
if (err.code === 'P2002') throw errors.conflict('Host name already exists'); if (err.code === 'P2002') throw errors.conflict(mapUniqueViolation(err.meta?.target));
} }
throw err; throw err;
} }
const userId = actor?.id ?? null;
const events: Prisma.HostEventCreateManyInput[] = [];
if (input.state !== undefined && input.state !== current.state) {
events.push({
hostId: host.id,
userId,
type: 'STATE_CHANGED',
field: 'state',
oldValue: current.state,
newValue: host.state,
});
}
if (input.stack !== undefined && input.stack !== current.stack) {
events.push({
hostId: host.id,
userId,
type: 'STACK_CHANGED',
field: 'stack',
oldValue: current.stack,
newValue: host.stack,
});
}
if (input.assetId !== undefined && input.assetId !== current.assetId) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'assetId',
oldValue: current.assetId,
newValue: host.assetId,
});
}
if (input.name !== undefined && input.name !== current.name) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'name',
oldValue: current.name,
newValue: host.name,
});
}
if (input.location !== undefined && (input.location ?? null) !== (current.location ?? null)) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'location',
oldValue: current.location ?? null,
newValue: host.location ?? null,
});
}
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
events.push({
hostId: host.id,
userId,
type: 'FIELD_UPDATED',
field: 'notes',
oldValue: current.notes ?? null,
newValue: host.notes ?? null,
});
}
if (events.length > 0) await tx.hostEvent.createMany({ data: events });
return host;
} }
export async function remove(tx: Tx, id: string) { export async function remove(tx: Tx, id: string) {
try { try {
await tx.host.delete({ where: { id } }); await tx.host.delete({ where: { id } });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { if (err instanceof Prisma.PrismaClientKnownRequestError) {
throw errors.notFound('Host'); if (err.code === 'P2025') throw errors.notFound('Host');
if (err.code === 'P2003') throw errors.conflict('Cannot delete: host has repairs assigned');
} }
throw err; throw err;
} }
} }
// Unified host timeline. Merges three sources:
// - HostEvents (state/stack/field changes on the host)
// - Repairs on this host (captures broken/replacement part swaps)
// - PartEvents where a part's host field changed to or from this host
// (covers ad-hoc arrivals/departures outside the repair flow).
//
// Sources are merged in memory and paginated after the sort; the resulting page
// will be small because we cap each source fetch at a safe upper bound.
export type HostTimelineEntry =
| { type: 'HOST_EVENT'; at: Date; hostEvent: HostEventPayload }
| { type: 'REPAIR'; at: Date; repair: RepairSummary }
| { type: 'PART_ARRIVED'; at: Date; part: PartRef; partEventId: string }
| { type: 'PART_DEPARTED'; at: Date; part: PartRef; partEventId: string };
interface HostEventPayload {
id: string;
type: string;
field: string | null;
oldValue: string | null;
newValue: string | null;
createdAt: Date;
user: { username: string } | null;
}
interface RepairSummary {
id: string;
performedAt: Date;
brokenPart: { id: string; serialNumber: string; mpn: string };
replacement: { id: string; serialNumber: string; mpn: string };
performedBy: { username: string } | null;
}
interface PartRef {
id: string;
serialNumber: string;
mpn: string;
}
const TIMELINE_SOURCE_CAP = 500;
export async function getTimeline(tx: Tx, hostId: string, q: HostTimelineQuery) {
const { page, pageSize } = q;
const host = await tx.host.findUnique({
where: { id: hostId },
select: { id: true, name: true },
});
if (!host) throw errors.notFound('Host');
// PartEvent stores the host's name in oldValue/newValue (see parts.ts), not the id.
const [hostEvents, repairs, partEventRows] = await Promise.all([
tx.hostEvent.findMany({
where: { hostId },
orderBy: { createdAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: { user: { select: { username: true } } },
}),
tx.repair.findMany({
where: { hostId },
orderBy: { performedAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: {
brokenPart: { include: { partModel: { select: { mpn: true } } } },
replacement: { include: { partModel: { select: { mpn: true } } } },
performedBy: { select: { username: true } },
},
}),
tx.partEvent.findMany({
where: {
type: 'LOCATION_CHANGED',
field: 'host',
OR: [{ oldValue: host.name }, { newValue: host.name }],
},
orderBy: { createdAt: 'desc' },
take: TIMELINE_SOURCE_CAP,
include: {
part: {
select: { id: true, serialNumber: true, partModel: { select: { mpn: true } } },
},
},
}),
]);
const entries: HostTimelineEntry[] = [];
for (const e of hostEvents) {
entries.push({
type: 'HOST_EVENT',
at: e.createdAt,
hostEvent: {
id: e.id,
type: e.type,
field: e.field,
oldValue: e.oldValue,
newValue: e.newValue,
createdAt: e.createdAt,
user: e.user,
},
});
}
for (const r of repairs) {
entries.push({
type: 'REPAIR',
at: r.performedAt,
repair: {
id: r.id,
performedAt: r.performedAt,
brokenPart: {
id: r.brokenPart.id,
serialNumber: r.brokenPart.serialNumber,
mpn: r.brokenPart.partModel.mpn,
},
replacement: {
id: r.replacement.id,
serialNumber: r.replacement.serialNumber,
mpn: r.replacement.partModel.mpn,
},
performedBy: r.performedBy ? { username: r.performedBy.username } : null,
},
});
}
for (const pe of partEventRows) {
if (!pe.part) continue;
const partRef: PartRef = {
id: pe.part.id,
serialNumber: pe.part.serialNumber,
mpn: pe.part.partModel.mpn,
};
// newValue = this host's name → arrival; oldValue = this host's name → departure.
if (pe.newValue === host.name) {
entries.push({ type: 'PART_ARRIVED', at: pe.createdAt, part: partRef, partEventId: pe.id });
}
if (pe.oldValue === host.name) {
entries.push({
type: 'PART_DEPARTED',
at: pe.createdAt,
part: partRef,
partEventId: pe.id,
});
}
}
entries.sort((a, b) => b.at.getTime() - a.at.getTime());
const total = entries.length;
const start = (page - 1) * pageSize;
const data = entries.slice(start, start + pageSize);
return { data, page, pageSize, total };
}
+221
View File
@@ -0,0 +1,221 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { getInsights } from './manufacturers.js';
// Minimal in-memory tx double exercising manufacturers.getInsights().
// Only the calls the function makes are stubbed.
interface FakeArgs {
mfrExists: boolean;
totalPartModels: number;
totalParts: number;
priceAgg: {
sum: number | null;
avg: number | null;
min: number | null;
max: number | null;
count: number;
};
repairCount: number;
distinctFailedBrokenPartIds: string[];
// rows used by part.groupBy({ by: ['partModelId','state'] })
modelStateGroups: { partModelId: string; state: string; count: number }[];
// rows for the partModel.findMany(select: { id, mpn, category }) call
allModels: {
id: string;
mpn: string;
category: { id: string; name: string } | null;
}[];
// rows for the EOL partModel.findMany call
eolModels: { id: string; mpn: string; eolDate: Date | null }[];
// rows for repair.findMany(select: { brokenPart: { partModelId } }) call
repairsWithModel: { brokenPart: { partModelId: string } }[];
}
function makeTx(args: FakeArgs): Tx {
const tx = {
manufacturer: {
findUnique: async () => (args.mfrExists ? { id: 'mfr' } : null),
},
partModel: {
count: async () => args.totalPartModels,
findMany: async (opts: { where?: { eolDate?: unknown } }) => {
// Distinguish the two findMany calls by whether `where.eolDate` is set
if (opts?.where && 'eolDate' in opts.where) return args.eolModels;
return args.allModels;
},
},
part: {
count: async () => args.totalParts,
groupBy: async () =>
args.modelStateGroups.map((g) => ({
partModelId: g.partModelId,
state: g.state,
_count: { _all: g.count },
})),
aggregate: async () => ({
_sum: { price: args.priceAgg.sum },
_avg: { price: args.priceAgg.avg },
_min: { price: args.priceAgg.min },
_max: { price: args.priceAgg.max },
_count: { _all: args.priceAgg.count },
}),
},
repair: {
count: async () => args.repairCount,
findMany: async (opts: { select?: { brokenPartId?: boolean } }) => {
// distinguish the two findMany calls by selected fields
if (opts?.select && 'brokenPartId' in opts.select) {
return args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId }));
}
return args.repairsWithModel;
},
},
};
return tx as unknown as Tx;
}
const empty: FakeArgs = {
mfrExists: true,
totalPartModels: 0,
totalParts: 0,
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
modelStateGroups: [],
allModels: [],
eolModels: [],
repairsWithModel: [],
};
describe('manufacturers.getInsights', () => {
it('returns null when manufacturer does not exist', async () => {
const tx = makeTx({ ...empty, mfrExists: false });
const r = await getInsights(tx, 'nope');
expect(r).toBeNull();
});
it('aggregates totals and price stats', async () => {
const tx = makeTx({
...empty,
totalPartModels: 3,
totalParts: 12,
priceAgg: { sum: 1200, avg: 300, min: 100, max: 500, count: 4 },
});
const r = await getInsights(tx, 'mfr');
expect(r!.totalPartModels).toBe(3);
expect(r!.totalParts).toBe(12);
expect(r!.priceStats).toEqual({
total: 1200,
average: 300,
min: 100,
max: 500,
countWithPrice: 4,
});
});
it('zeros price stats when no parts priced', async () => {
const tx = makeTx({ ...empty, totalParts: 5 });
const r = await getInsights(tx, 'mfr');
expect(r!.priceStats).toEqual({
total: 0,
average: 0,
min: null,
max: null,
countWithPrice: 0,
});
});
it('counts repairs and distinct failed parts', async () => {
const tx = makeTx({
...empty,
repairCount: 5,
distinctFailedBrokenPartIds: ['p1', 'p2', 'p3'],
});
const r = await getInsights(tx, 'mfr');
expect(r!.failures).toEqual({ repairs: 5, distinctFailedParts: 3 });
});
it('derives topModelsByUnits from modelStateGroups, sorted desc, truncated to 8', async () => {
// 9 MPNs to confirm truncation. Count matches index+1 so pm9 is biggest.
const modelStateGroups = Array.from({ length: 9 }).map((_, i) => ({
partModelId: `pm${i + 1}`,
state: 'SPARE',
count: i + 1,
}));
const allModels = modelStateGroups.map((g) => ({
id: g.partModelId,
mpn: `MPN-${g.partModelId}`,
category: null,
}));
const tx = makeTx({ ...empty, modelStateGroups, allModels });
const r = await getInsights(tx, 'mfr');
expect(r!.topModelsByUnits).toHaveLength(8);
expect(r!.topModelsByUnits[0]).toEqual({ partModelId: 'pm9', mpn: 'MPN-pm9', count: 9 });
expect(r!.topModelsByUnits[7]).toEqual({ partModelId: 'pm2', mpn: 'MPN-pm2', count: 2 });
// pm1 (count=1) should be truncated out
expect(r!.topModelsByUnits.find((m) => m.partModelId === 'pm1')).toBeUndefined();
});
it('groups failuresByModel, joins MPN, and sorts desc', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: 'pmA', mpn: 'AAA', category: null },
{ id: 'pmB', mpn: 'BBB', category: null },
],
// pmA has 3 repairs, pmB has 1
repairsWithModel: [
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmA' } },
{ brokenPart: { partModelId: 'pmB' } },
],
});
const r = await getInsights(tx, 'mfr');
expect(r!.failuresByModel).toEqual([
{ partModelId: 'pmA', mpn: 'AAA', repairs: 3 },
{ partModelId: 'pmB', mpn: 'BBB', repairs: 1 },
]);
});
it('groups byCategory with Uncategorized fallback for null categories', async () => {
const tx = makeTx({
...empty,
allModels: [
{ id: '1', mpn: 'A', category: { id: 'c1', name: 'Network' } },
{ id: '2', mpn: 'B', category: { id: 'c1', name: 'Network' } },
{ id: '3', mpn: 'C', category: null },
],
});
const r = await getInsights(tx, 'mfr');
expect(r!.byCategory).toEqual([
{ categoryId: 'c1', categoryName: 'Network', count: 2 },
{ categoryId: null, categoryName: 'Uncategorized', count: 1 },
]);
});
it('pastEolModels includes only models with deployedCount > 0', async () => {
const tx = makeTx({
...empty,
modelStateGroups: [
{ partModelId: 'pmEOL1', state: 'DEPLOYED', count: 2 },
{ partModelId: 'pmEOL1', state: 'SPARE', count: 1 },
// pmEOL2 is past EOL but has NO deployed parts → should be filtered out
{ partModelId: 'pmEOL2', state: 'SPARE', count: 5 },
],
eolModels: [
{ id: 'pmEOL1', mpn: 'OLD-1', eolDate: new Date('2024-01-01') },
{ id: 'pmEOL2', mpn: 'OLD-2', eolDate: new Date('2024-01-01') },
],
});
const r = await getInsights(tx, 'mfr');
expect(r!.pastEolModels).toEqual([
{
partModelId: 'pmEOL1',
mpn: 'OLD-1',
eolDate: new Date('2024-01-01').toISOString(),
deployedCount: 2,
},
]);
});
});
+143 -7
View File
@@ -1,12 +1,154 @@
import { Prisma } from '@vector/db'; import { Prisma } from '@vector/db';
import type { import type {
CreateManufacturerRequest, CreateManufacturerRequest,
ManufacturerInsights,
UpdateManufacturerRequest, UpdateManufacturerRequest,
PaginationQuery, PaginationQuery,
} from '@vector/shared'; } from '@vector/shared';
import { errors } from '../lib/http-error.js'; import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js'; import type { Tx } from './types.js';
export function get(tx: Tx, id: string) {
return tx.manufacturer.findUnique({
where: { id },
include: { _count: { select: { parts: true, partModels: true } } },
});
}
export async function getInsights(tx: Tx, id: string): Promise<ManufacturerInsights | null> {
const model = await tx.manufacturer.findUnique({ where: { id }, select: { id: true } });
if (!model) return null;
const now = new Date();
const [
totalPartModels,
totalParts,
priceAgg,
repairsCount,
distinctFailedParts,
modelStateGroups,
allModels,
eolModels,
repairsWithModel,
] = await Promise.all([
tx.partModel.count({ where: { manufacturerId: id } }),
tx.part.count({ where: { manufacturerId: id } }),
tx.part.aggregate({
where: { manufacturerId: id, price: { not: null } },
_sum: { price: true },
_avg: { price: true },
_min: { price: true },
_max: { price: true },
_count: { _all: true },
}),
tx.repair.count({ where: { brokenPart: { manufacturerId: id } } }),
tx.repair.findMany({
where: { brokenPart: { manufacturerId: id } },
select: { brokenPartId: true },
distinct: ['brokenPartId'],
}),
tx.part.groupBy({
by: ['partModelId', 'state'],
where: { manufacturerId: id },
_count: { _all: true },
}),
tx.partModel.findMany({
where: { manufacturerId: id },
select: {
id: true,
mpn: true,
category: { select: { id: true, name: true } },
},
}),
tx.partModel.findMany({
where: { manufacturerId: id, eolDate: { not: null, lte: now } },
select: { id: true, mpn: true, eolDate: true },
}),
tx.repair.findMany({
where: { brokenPart: { manufacturerId: id } },
select: { brokenPart: { select: { partModelId: true } } },
}),
]);
const mpnById = new Map(allModels.map((m) => [m.id, m.mpn]));
const unitsByModel = new Map<string, number>();
const deployedByModel = new Map<string, number>();
for (const row of modelStateGroups) {
const n = row._count._all;
unitsByModel.set(row.partModelId, (unitsByModel.get(row.partModelId) ?? 0) + n);
if (row.state === 'DEPLOYED') {
deployedByModel.set(row.partModelId, (deployedByModel.get(row.partModelId) ?? 0) + n);
}
}
const topModelsByUnits = [...unitsByModel.entries()]
.map(([partModelId, count]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
count,
}))
.sort((a, b) => b.count - a.count)
.slice(0, 8);
const failuresByModelMap = new Map<string, number>();
for (const r of repairsWithModel) {
const pmId = r.brokenPart.partModelId;
failuresByModelMap.set(pmId, (failuresByModelMap.get(pmId) ?? 0) + 1);
}
const failuresByModel = [...failuresByModelMap.entries()]
.map(([partModelId, repairs]) => ({
partModelId,
mpn: mpnById.get(partModelId) ?? '',
repairs,
}))
.sort((a, b) => b.repairs - a.repairs)
.slice(0, 8);
const categoryCounts = new Map<string, { id: string | null; name: string; count: number }>();
for (const m of allModels) {
const key = m.category?.id ?? 'uncategorized';
const name = m.category?.name ?? 'Uncategorized';
const entry = categoryCounts.get(key);
if (entry) entry.count += 1;
else categoryCounts.set(key, { id: m.category?.id ?? null, name, count: 1 });
}
const byCategory = [...categoryCounts.values()]
.map((c) => ({ categoryId: c.id, categoryName: c.name, count: c.count }))
.sort((a, b) => b.count - a.count);
const pastEolModels = eolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return {
totalPartModels,
totalParts,
priceStats: {
total: priceAgg._sum.price ?? 0,
average: priceAgg._avg.price ?? 0,
min: priceAgg._min.price,
max: priceAgg._max.price,
countWithPrice: priceAgg._count._all,
},
failures: {
repairs: repairsCount,
distinctFailedParts: distinctFailedParts.length,
},
byCategory,
topModelsByUnits,
failuresByModel,
pastEolModels,
};
}
export async function list(tx: Tx, q: PaginationQuery) { export async function list(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q; const { page, pageSize } = q;
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
@@ -22,12 +164,7 @@ export async function list(tx: Tx, q: PaginationQuery) {
export async function create(tx: Tx, input: CreateManufacturerRequest) { export async function create(tx: Tx, input: CreateManufacturerRequest) {
try { try {
return await tx.manufacturer.create({ return await tx.manufacturer.create({ data: { name: input.name } });
data: {
name: input.name,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
},
});
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Manufacturer already exists'); throw errors.conflict('Manufacturer already exists');
@@ -40,7 +177,6 @@ export async function update(tx: Tx, id: string, input: UpdateManufacturerReques
try { try {
const data: Prisma.ManufacturerUpdateInput = {}; const data: Prisma.ManufacturerUpdateInput = {};
if (input.name !== undefined) data.name = input.name; if (input.name !== undefined) data.name = input.name;
if (input.eolDate !== undefined) data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
return await tx.manufacturer.update({ where: { id }, data }); return await tx.manufacturer.update({ where: { id }, data });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) { if (err instanceof Prisma.PrismaClientKnownRequestError) {
+143
View File
@@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { getInsights } from './part-models.js';
// Minimal in-memory tx double exercising getInsights(). We only stub the
// calls that getInsights actually makes.
function makeTx(args: {
modelExists: boolean;
partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[];
priceAgg: {
sum: number | null;
avg: number | null;
min: number | null;
max: number | null;
count: number;
};
repairCount: number;
distinctFailedBrokenPartIds: string[];
}): Tx {
const tx = {
partModel: {
findUnique: async () => (args.modelExists ? { id: 'pm' } : null),
},
part: {
count: async () => args.partCount,
groupBy: async () =>
args.stateRows.map((s) => ({
state: s.state,
_count: { _all: s.count },
_sum: { price: s.totalPrice },
})),
aggregate: async () => ({
_sum: { price: args.priceAgg.sum },
_avg: { price: args.priceAgg.avg },
_min: { price: args.priceAgg.min },
_max: { price: args.priceAgg.max },
_count: { _all: args.priceAgg.count },
}),
},
repair: {
count: async () => args.repairCount,
findMany: async () =>
args.distinctFailedBrokenPartIds.map((brokenPartId) => ({ brokenPartId })),
},
};
return tx as unknown as Tx;
}
describe('part-models.getInsights', () => {
it('returns null when model does not exist', async () => {
const tx = makeTx({
modelExists: false,
partCount: 0,
stateRows: [],
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
});
const r = await getInsights(tx, 'nope');
expect(r).toBeNull();
});
it('aggregates totalParts and groups by state', async () => {
const tx = makeTx({
modelExists: true,
partCount: 3,
stateRows: [
{ state: 'SPARE', count: 1, totalPrice: 100 },
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
],
priceAgg: { sum: 900, avg: 450, min: 100, max: 500, count: 2 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
});
const r = await getInsights(tx, 'pm');
expect(r).not.toBeNull();
expect(r!.totalParts).toBe(3);
expect(r!.byState).toEqual([
{ state: 'SPARE', count: 1, totalPrice: 100 },
{ state: 'DEPLOYED', count: 2, totalPrice: 800 },
]);
});
it('computes price stats from aggregate (countWithPrice drives average)', async () => {
const tx = makeTx({
modelExists: true,
partCount: 3,
stateRows: [],
// 2 priced parts ($100 + $500), 1 null-priced part
priceAgg: { sum: 600, avg: 300, min: 100, max: 500, count: 2 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
});
const r = await getInsights(tx, 'pm');
expect(r!.priceStats).toEqual({
total: 600,
average: 300,
min: 100,
max: 500,
countWithPrice: 2,
});
});
it('zeros price stats when no parts are priced', async () => {
const tx = makeTx({
modelExists: true,
partCount: 2,
stateRows: [],
priceAgg: { sum: null, avg: null, min: null, max: null, count: 0 },
repairCount: 0,
distinctFailedBrokenPartIds: [],
});
const r = await getInsights(tx, 'pm');
expect(r!.priceStats).toEqual({
total: 0,
average: 0,
min: null,
max: null,
countWithPrice: 0,
});
});
it('counts repairs and distinct failed parts', async () => {
const tx = makeTx({
modelExists: true,
partCount: 5,
stateRows: [],
priceAgg: { sum: 0, avg: null, min: null, max: null, count: 0 },
repairCount: 3,
distinctFailedBrokenPartIds: ['part-a', 'part-b'],
});
const r = await getInsights(tx, 'pm');
expect(r!.failures).toEqual({
repairs: 3,
distinctFailedParts: 2,
});
});
});
+195
View File
@@ -0,0 +1,195 @@
import { Prisma } from '@vector/db';
import type {
CreatePartModelRequest,
PartModelInsights,
PartModelListQuery,
UpdatePartModelRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
const partModelInclude = {
manufacturer: true,
category: true,
_count: { select: { parts: true } },
} satisfies Prisma.PartModelInclude;
export async function list(tx: Tx, q: PartModelListQuery) {
const { page, pageSize, manufacturerId, categoryId, q: search, eolBefore } = q;
const where: Prisma.PartModelWhereInput = {};
if (manufacturerId) where.manufacturerId = manufacturerId;
if (categoryId) where.categoryId = categoryId;
if (search) where.mpn = { contains: search };
if (eolBefore) where.eolDate = { lte: new Date(eolBefore) };
const [data, total] = await Promise.all([
tx.partModel.findMany({
where,
orderBy: [{ manufacturer: { name: 'asc' } }, { mpn: 'asc' }],
include: partModelInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.partModel.count({ where }),
]);
return { data, page, pageSize, total };
}
export function get(tx: Tx, id: string) {
return tx.partModel.findUnique({ where: { id }, include: partModelInclude });
}
export async function getInsights(tx: Tx, id: string): Promise<PartModelInsights | null> {
const model = await tx.partModel.findUnique({ where: { id }, select: { id: true } });
if (!model) return null;
const [totalParts, stateRows, priceAgg, repairs, failedParts] = await Promise.all([
tx.part.count({ where: { partModelId: id } }),
tx.part.groupBy({
by: ['state'],
where: { partModelId: id },
_count: { _all: true },
_sum: { price: true },
}),
tx.part.aggregate({
where: { partModelId: id, price: { not: null } },
_sum: { price: true },
_avg: { price: true },
_min: { price: true },
_max: { price: true },
_count: { _all: true },
}),
tx.repair.count({ where: { brokenPart: { partModelId: id } } }),
tx.repair.findMany({
where: { brokenPart: { partModelId: id } },
select: { brokenPartId: true },
distinct: ['brokenPartId'],
}),
]);
const byState = stateRows.map((row) => ({
state: row.state as PartModelInsights['byState'][number]['state'],
count: row._count._all,
totalPrice: row._sum.price ?? 0,
}));
return {
totalParts,
byState,
priceStats: {
total: priceAgg._sum.price ?? 0,
average: priceAgg._avg.price ?? 0,
min: priceAgg._min.price,
max: priceAgg._max.price,
countWithPrice: priceAgg._count._all,
},
failures: {
repairs,
distinctFailedParts: failedParts.length,
},
};
}
export async function create(tx: Tx, input: CreatePartModelRequest) {
try {
return await tx.partModel.create({
data: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
categoryId: input.categoryId ?? null,
eolDate: input.eolDate ? new Date(input.eolDate) : null,
destroyOnFail: input.destroyOnFail ?? false,
notes: input.notes ?? null,
},
include: partModelInclude,
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdatePartModelRequest) {
const data: Prisma.PartModelUpdateInput = {};
if (input.manufacturerId !== undefined) {
data.manufacturer = { connect: { id: input.manufacturerId } };
}
if (input.mpn !== undefined) data.mpn = input.mpn;
if (input.categoryId !== undefined) {
data.category = input.categoryId
? { connect: { id: input.categoryId } }
: { disconnect: true };
}
if (input.eolDate !== undefined) {
data.eolDate = input.eolDate ? new Date(input.eolDate) : null;
}
if (input.destroyOnFail !== undefined) data.destroyOnFail = input.destroyOnFail;
if (input.notes !== undefined) data.notes = input.notes;
try {
return await tx.partModel.update({ where: { id }, data, include: partModelInclude });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model');
if (err.code === 'P2002') throw errors.conflict('MPN already exists for this manufacturer');
if (err.code === 'P2003') throw errors.badRequest('Manufacturer or category does not exist');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.partModel.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part model');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: part model has parts assigned');
}
}
throw err;
}
}
// Returns an existing PartModel for (manufacturerId, mpn) or creates one on the fly.
// Used by the parts service so a create/update with { manufacturerId, mpn } shorthand
// transparently provisions a catalog row.
export async function upsertByMpn(
tx: Tx,
input: { manufacturerId: string; mpn: string },
) {
const existing = await tx.partModel.findUnique({
where: {
manufacturerId_mpn: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
},
},
});
if (existing) return existing;
try {
return await tx.partModel.create({
data: { manufacturerId: input.manufacturerId, mpn: input.mpn },
});
} catch (err) {
// Lost the race; fetch the row that won.
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
const winner = await tx.partModel.findUnique({
where: {
manufacturerId_mpn: {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
},
},
});
if (winner) return winner;
}
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('Manufacturer does not exist');
}
throw err;
}
}
+342
View File
@@ -0,0 +1,342 @@
import { describe, expect, it, vi } from 'vitest';
import type { Tx, Actor } from './types.js';
import { create, update } from './parts.js';
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
const partModel = {
id: 'pm-1',
manufacturerId: 'mfr-1',
mpn: 'WD-1000',
eolDate: null,
notes: null,
};
// Current-row fixtures used by update tests. Only the fields the service reads are populated.
function sparePart(overrides: Partial<Record<string, unknown>> = {}) {
return {
id: 'p-1',
serialNumber: 'SN-1',
partModelId: 'pm-1',
manufacturerId: 'mfr-1',
state: 'SPARE',
binId: 'bin-1',
hostId: null,
categoryId: null,
price: null,
notes: null,
partModel: { ...partModel },
manufacturer: { id: 'mfr-1', name: 'WD' },
bin: null,
host: null,
category: null,
tags: [],
...overrides,
};
}
function deployedPart(overrides: Partial<Record<string, unknown>> = {}) {
return sparePart({
state: 'DEPLOYED',
binId: null,
hostId: 'host-1',
host: { id: 'host-1', name: 'rack-1', assetId: 'ASSET-001' },
...overrides,
});
}
function custodyPart(overrides: Partial<Record<string, unknown>> = {}) {
return sparePart({
state: 'PENDING_DROP_IN_CUSTODY',
binId: null,
hostId: null,
custodianId: 'user-1',
custodian: { id: 'user-1', username: 'tech' },
bin: null,
host: null,
...overrides,
});
}
describe('parts.create — state/location coupling', () => {
it('rejects DEPLOYED without a hostId', async () => {
const partCreate = vi.fn();
const tx = {
partModel: { findUnique: async () => partModel },
part: { create: partCreate },
partEvent: { create: vi.fn() },
} as unknown as Tx;
await expect(
create(
tx,
{ serialNumber: 'SN-1', partModelId: 'pm-1', state: 'DEPLOYED' },
actor,
),
).rejects.toMatchObject({ status: 400 });
expect(partCreate).not.toHaveBeenCalled();
});
it('rejects DEPLOYED with both hostId and binId', async () => {
const partCreate = vi.fn();
const tx = {
partModel: { findUnique: async () => partModel },
part: { create: partCreate },
partEvent: { create: vi.fn() },
} as unknown as Tx;
await expect(
create(
tx,
{
serialNumber: 'SN-1',
partModelId: 'pm-1',
state: 'DEPLOYED',
hostId: 'host-1',
binId: 'bin-1',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
expect(partCreate).not.toHaveBeenCalled();
});
it('rejects a non-DEPLOYED part that carries a hostId', async () => {
const partCreate = vi.fn();
const tx = {
partModel: { findUnique: async () => partModel },
part: { create: partCreate },
partEvent: { create: vi.fn() },
} as unknown as Tx;
await expect(
create(
tx,
{ serialNumber: 'SN-1', partModelId: 'pm-1', state: 'SPARE', hostId: 'host-1' },
actor,
),
).rejects.toMatchObject({ status: 400 });
expect(partCreate).not.toHaveBeenCalled();
});
it('creates a DEPLOYED part with hostId and writes binId=null', async () => {
const created = sparePart({
id: 'p-new',
state: 'DEPLOYED',
binId: null,
hostId: 'host-1',
host: { id: 'host-1', name: 'rack-1', assetId: 'ASSET-001' },
});
const partCreate = vi.fn();
partCreate.mockResolvedValue(created);
const partEventCreate = vi.fn();
const tx = {
partModel: { findUnique: async () => partModel },
part: {
create: partCreate,
findUnique: async () => created,
},
partEvent: { create: partEventCreate },
} as unknown as Tx;
const r = await create(
tx,
{
serialNumber: 'SN-1',
partModelId: 'pm-1',
state: 'DEPLOYED',
hostId: 'host-1',
},
actor,
);
expect(r.id).toBe('p-new');
const callArgs = partCreate.mock.calls[0]![0] as { data: { binId: string | null; hostId: string | null } };
expect(callArgs.data.binId).toBeNull();
expect(callArgs.data.hostId).toBe('host-1');
});
});
describe('parts.update — state/location coupling', () => {
it('promoting SPARE→DEPLOYED with a hostId clears binId', async () => {
const current = sparePart({ binId: 'bin-1', hostId: null });
const partUpdate = vi.fn();
partUpdate.mockResolvedValue(sparePart({ state: 'DEPLOYED', binId: null, hostId: 'host-1' }));
const tx = {
part: {
findUnique: async () => current,
update: partUpdate,
},
partEvent: { createMany: vi.fn() },
partTag: { findMany: async () => [] },
} as unknown as Tx;
await update(tx, 'p-1', { state: 'DEPLOYED', hostId: 'host-1' }, actor);
const call = partUpdate.mock.calls[0]![0] as { data: { bin?: unknown; host?: unknown } };
expect(call.data.bin).toEqual({ disconnect: true });
expect(call.data.host).toEqual({ connect: { id: 'host-1' } });
});
it('demoting DEPLOYED→BROKEN with a binId clears hostId', async () => {
const current = deployedPart();
const partUpdate = vi.fn();
partUpdate.mockResolvedValue(sparePart({ state: 'BROKEN', binId: 'bin-2', hostId: null }));
const tx = {
part: {
findUnique: async () => current,
update: partUpdate,
},
partEvent: { createMany: vi.fn() },
partTag: { findMany: async () => [] },
} as unknown as Tx;
await update(tx, 'p-1', { state: 'BROKEN', binId: 'bin-2' }, actor);
const call = partUpdate.mock.calls[0]![0] as { data: { bin?: unknown; host?: unknown } };
expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } });
expect(call.data.host).toEqual({ disconnect: true });
});
it('rejects a DEPLOYED transition when neither current nor input supplies a hostId', async () => {
const current = sparePart({ binId: 'bin-1', hostId: null });
const partUpdate = vi.fn();
const tx = {
part: {
findUnique: async () => current,
update: partUpdate,
},
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(
update(tx, 'p-1', { state: 'DEPLOYED' }, actor),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects DEPLOYED→DEPLOYED with a binId (bin not allowed on DEPLOYED)', async () => {
const current = deployedPart();
const partUpdate = vi.fn();
const tx = {
part: {
findUnique: async () => current,
update: partUpdate,
},
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(
update(tx, 'p-1', { binId: 'bin-2' }, actor),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
it('no-op update (notes only) on a DEPLOYED part does not touch bin or host', async () => {
const current = deployedPart();
const partUpdate = vi.fn();
partUpdate.mockResolvedValue(deployedPart({ notes: 'ok' }));
const tx = {
part: {
findUnique: async () => current,
update: partUpdate,
},
partEvent: { createMany: vi.fn() },
partTag: { findMany: async () => [] },
} as unknown as Tx;
await update(tx, 'p-1', { notes: 'ok' }, actor);
const call = partUpdate.mock.calls[0]![0] as { data: { bin?: unknown; host?: unknown } };
expect(call.data.bin).toBeUndefined();
expect(call.data.host).toBeUndefined();
});
});
describe('parts.update — custody state/location coupling', () => {
it('DEPLOYED → PENDING_DROP_IN_CUSTODY requires custodianId; clears host', async () => {
const current = deployedPart();
const partUpdate = vi.fn();
partUpdate.mockResolvedValue(
custodyPart({ state: 'PENDING_DROP_IN_CUSTODY', custodianId: 'user-1' }),
);
const tx = {
part: { findUnique: async () => current, update: partUpdate },
partEvent: { createMany: vi.fn() },
partTag: { findMany: async () => [] },
} as unknown as Tx;
await update(
tx,
'p-1',
{ state: 'PENDING_DROP_IN_CUSTODY', custodianId: 'user-1' },
actor,
);
const call = partUpdate.mock.calls[0]![0] as {
data: { host?: unknown; bin?: unknown; custodian?: unknown };
};
expect(call.data.host).toEqual({ disconnect: true });
expect(call.data.bin).toEqual({ disconnect: true });
expect(call.data.custodian).toEqual({ connect: { id: 'user-1' } });
});
it('rejects transition into a custody state without a custodianId', async () => {
const current = deployedPart();
const partUpdate = vi.fn();
const tx = {
part: { findUnique: async () => current, update: partUpdate },
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(
update(tx, 'p-1', { state: 'PENDING_DROP_IN_CUSTODY' }, actor),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
it('rejects DEPLOYED with a custodianId', async () => {
const current = sparePart({ binId: 'bin-1', hostId: null });
const partUpdate = vi.fn();
const tx = {
part: { findUnique: async () => current, update: partUpdate },
partEvent: { createMany: vi.fn() },
} as unknown as Tx;
await expect(
update(
tx,
'p-1',
{ state: 'DEPLOYED', hostId: 'host-1', custodianId: 'user-1' },
actor,
),
).rejects.toMatchObject({ status: 400 });
expect(partUpdate).not.toHaveBeenCalled();
});
it('custody → BROKEN with a binId clears custodianId', async () => {
const current = custodyPart();
const partUpdate = vi.fn();
partUpdate.mockResolvedValue(
sparePart({ state: 'BROKEN', binId: 'bin-2', hostId: null, custodianId: null }),
);
const tx = {
part: { findUnique: async () => current, update: partUpdate },
partEvent: { createMany: vi.fn() },
partTag: { findMany: async () => [] },
} as unknown as Tx;
await update(
tx,
'p-1',
{ state: 'BROKEN', binId: 'bin-2', custodianId: null },
actor,
);
const call = partUpdate.mock.calls[0]![0] as {
data: { host?: unknown; bin?: unknown; custodian?: unknown };
};
expect(call.data.bin).toEqual({ connect: { id: 'bin-2' } });
expect(call.data.host).toEqual({ disconnect: true });
expect(call.data.custodian).toEqual({ disconnect: true });
});
});
+184 -56
View File
@@ -3,16 +3,76 @@ import type {
CreatePartRequest, CreatePartRequest,
PaginationQuery, PaginationQuery,
PartListQuery, PartListQuery,
PartState as PartStateValue,
UpdatePartRequest, UpdatePartRequest,
} from '@vector/shared'; } from '@vector/shared';
import { errors } from '../lib/http-error.js'; import { errors } from '../lib/http-error.js';
import * as partModelsSvc from './part-models.js';
import * as tagsSvc from './tags.js'; import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js'; import type { Actor, Tx } from './types.js';
// Enforces the Part state/location invariant and auto-clears stale fields on state transitions.
// The matrix is:
// DEPLOYED — hostId required, binId forbidden, custodianId forbidden
// SPARE / BROKEN / PENDING_DESTRUCTION — binId optional, hostId + custodian forbidden
// PENDING_DROP_IN_CUSTODY / PENDING_DESTRUCTION_IN_CUSTODY / PENDING_REPAIR
// — custodianId required, host + bin forbidden
// Callers only need to pass what's changing; anything omitted is inherited from `current`.
function resolveLocation(
state: PartStateValue,
input: {
binId?: string | null;
hostId?: string | null;
custodianId?: string | null;
},
current: {
binId: string | null;
hostId: string | null;
custodianId: string | null;
} = { binId: null, hostId: null, custodianId: null },
): { binId: string | null; hostId: string | null; custodianId: string | null } {
if (state === 'DEPLOYED') {
const hostId = input.hostId !== undefined ? input.hostId : current.hostId;
if (!hostId) throw errors.badRequest('A deployed part must be assigned to a host');
if (input.binId) {
throw errors.badRequest('A deployed part cannot also be in a storage bin');
}
if (input.custodianId) {
throw errors.badRequest('A deployed part cannot be in custody');
}
return { binId: null, hostId, custodianId: null };
}
if (
state === 'PENDING_DROP_IN_CUSTODY' ||
state === 'PENDING_DESTRUCTION_IN_CUSTODY' ||
state === 'PENDING_REPAIR'
) {
const custodianId =
input.custodianId !== undefined ? input.custodianId : current.custodianId;
if (!custodianId) throw errors.badRequest('A part in custody must name a custodian');
if (input.hostId) throw errors.badRequest('A part in custody cannot be on a host');
if (input.binId) throw errors.badRequest('A part in custody cannot be in a bin');
return { binId: null, hostId: null, custodianId };
}
// SPARE / BROKEN / PENDING_DESTRUCTION
if (input.hostId) {
throw errors.badRequest('Only deployed parts can be assigned to a host');
}
if (input.custodianId) {
throw errors.badRequest('Only custody states can have a custodian');
}
const binId = input.binId !== undefined ? input.binId : current.binId;
return { binId, hostId: null, custodianId: null };
}
const partInclude = { const partInclude = {
manufacturer: true, manufacturer: true,
partModel: { include: { category: true } },
bin: { include: { room: { include: { site: true } } } }, bin: { include: { room: { include: { site: true } } } },
category: true, host: true,
custodian: { select: { id: true, username: true } },
tags: { include: { tag: true } }, tags: { include: { tag: true } },
} satisfies Prisma.PartInclude; } satisfies Prisma.PartInclude;
@@ -40,26 +100,52 @@ function flattenTags(part: PartWithRelations): PartWithPath {
return out; return out;
} }
// Resolves a CreatePartRequest's partModelId — either the explicit one passed in, or looked up
// via (manufacturerId, mpn) shorthand that auto-creates a PartModel catalog row on first use.
// Exactly one of those two forms is required; the zod schema enforces that at the boundary.
async function resolvePartModel(
tx: Tx,
input: { partModelId?: string; manufacturerId?: string; mpn?: string },
): Promise<{ partModelId: string; manufacturerId: string }> {
if (input.partModelId) {
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
if (!pm) throw errors.badRequest('Part model does not exist');
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
if (input.manufacturerId && input.mpn) {
const pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.manufacturerId,
mpn: input.mpn,
});
return { partModelId: pm.id, manufacturerId: pm.manufacturerId };
}
throw errors.badRequest('Provide partModelId or both manufacturerId and mpn');
}
function buildWhere(q: PartListQuery): Prisma.PartWhereInput { function buildWhere(q: PartListQuery): Prisma.PartWhereInput {
const where: Prisma.PartWhereInput = {}; const where: Prisma.PartWhereInput = {};
if (q.state) where.state = q.state; if (q.state) where.state = q.state;
if (q.binId) where.binId = q.binId; if (q.binId) where.binId = q.binId;
if (q.hostId) where.hostId = q.hostId;
if (q.custodianId) where.custodianId = q.custodianId;
if (q.manufacturerId) where.manufacturerId = q.manufacturerId; if (q.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.categoryId) where.categoryId = q.categoryId; if (q.partModelId) where.partModelId = q.partModelId;
if (q.mpn) where.mpn = { contains: q.mpn };
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber }; if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) { if (q.q) {
where.OR = [ where.OR = [
{ serialNumber: { contains: q.q } }, { serialNumber: { contains: q.q } },
{ mpn: { contains: q.q } }, { partModel: { mpn: { contains: q.q } } },
{ notes: { contains: q.q } }, { notes: { contains: q.q } },
]; ];
} }
if (q.tagId) where.tags = { some: { tagId: q.tagId } }; if (q.tagId) where.tags = { some: { tagId: q.tagId } };
if (q.eolOnly) {
// Parts attached to a manufacturer with an EOL date that has already passed. const partModelFilter: Prisma.PartModelWhereInput = {};
where.manufacturer = { eolDate: { lt: new Date() } }; if (q.mpn) partModelFilter.mpn = { contains: q.mpn };
} if (q.eolOnly) partModelFilter.eolDate = { lt: new Date() };
if (q.categoryId) partModelFilter.categoryId = q.categoryId;
if (Object.keys(partModelFilter).length > 0) where.partModel = partModelFilter;
return where; return where;
} }
@@ -89,17 +175,30 @@ export async function create(
input: CreatePartRequest, input: CreatePartRequest,
actor: Actor | null, actor: Actor | null,
): Promise<PartWithPath> { ): Promise<PartWithPath> {
const { partModelId, manufacturerId } = await resolvePartModel(tx, input);
// If caller also supplied manufacturerId explicitly, it must match the part model's.
if (input.manufacturerId && input.manufacturerId !== manufacturerId) {
throw errors.badRequest('manufacturerId does not match the selected part model');
}
const state = input.state ?? 'SPARE';
const location = resolveLocation(state, {
binId: input.binId,
hostId: input.hostId,
custodianId: input.custodianId,
});
try { try {
const p = await tx.part.create({ const p = await tx.part.create({
data: { data: {
serialNumber: input.serialNumber, serialNumber: input.serialNumber,
mpn: input.mpn, partModelId,
manufacturerId: input.manufacturerId, manufacturerId,
price: input.price ?? null, price: input.price ?? null,
state: input.state ?? 'SPARE', state,
binId: input.binId ?? null, binId: location.binId,
categoryId: input.categoryId ?? null, hostId: location.hostId,
replacementPartId: input.replacementPartId ?? null, custodianId: location.custodianId,
notes: input.notes ?? null, notes: input.notes ?? null,
}, },
include: partInclude, include: partInclude,
@@ -136,25 +235,49 @@ export async function update(
const data: Prisma.PartUpdateInput = {}; const data: Prisma.PartUpdateInput = {};
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber; if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
if (input.mpn !== undefined) data.mpn = input.mpn; if (input.partModelId !== undefined) {
if (input.manufacturerId !== undefined) { const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
data.manufacturer = { connect: { id: input.manufacturerId } }; if (!pm) throw errors.badRequest('Part model does not exist');
data.partModel = { connect: { id: pm.id } };
// Keep denormalized manufacturerId consistent with the chosen model.
data.manufacturer = { connect: { id: pm.manufacturerId } };
} }
if (input.price !== undefined) data.price = input.price; if (input.price !== undefined) data.price = input.price;
if (input.state !== undefined) data.state = input.state; if (input.state !== undefined) data.state = input.state;
if (input.binId !== undefined) {
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true }; let nextBinId: string | null = current.binId;
} let nextHostId: string | null = current.hostId;
if (input.categoryId !== undefined) { let nextCustodianId: string | null = current.custodianId;
data.category = input.categoryId const locationTouched =
? { connect: { id: input.categoryId } } input.state !== undefined ||
: { disconnect: true }; input.binId !== undefined ||
} input.hostId !== undefined ||
if (input.replacementPartId !== undefined) { input.custodianId !== undefined;
data.replacement = input.replacementPartId if (locationTouched) {
? { connect: { id: input.replacementPartId } } const nextState = input.state ?? (current.state as PartStateValue);
const resolved = resolveLocation(
nextState,
{
binId: input.binId,
hostId: input.hostId,
custodianId: input.custodianId,
},
{
binId: current.binId,
hostId: current.hostId,
custodianId: current.custodianId,
},
);
nextBinId = resolved.binId;
nextHostId = resolved.hostId;
nextCustodianId = resolved.custodianId;
data.bin = resolved.binId ? { connect: { id: resolved.binId } } : { disconnect: true };
data.host = resolved.hostId ? { connect: { id: resolved.hostId } } : { disconnect: true };
data.custodian = resolved.custodianId
? { connect: { id: resolved.custodianId } }
: { disconnect: true }; : { disconnect: true };
} }
if (input.notes !== undefined) data.notes = input.notes; if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations; let part: PartWithRelations;
@@ -181,7 +304,7 @@ export async function update(
newValue: input.state, newValue: input.state,
}); });
} }
if (input.binId !== undefined && input.binId !== current.binId) { if (nextBinId !== current.binId) {
events.push({ events.push({
partId: part.id, partId: part.id,
userId, userId,
@@ -191,14 +314,34 @@ export async function update(
newValue: binPath(part.bin), newValue: binPath(part.bin),
}); });
} }
if (input.mpn !== undefined && input.mpn !== current.mpn) { if (nextHostId !== current.hostId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'host',
oldValue: current.host?.name ?? null,
newValue: part.host?.name ?? null,
});
}
if (nextCustodianId !== current.custodianId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'custodian',
oldValue: current.custodian?.username ?? null,
newValue: part.custodian?.username ?? null,
});
}
if (input.partModelId !== undefined && input.partModelId !== current.partModelId) {
events.push({ events.push({
partId: part.id, partId: part.id,
userId, userId,
type: 'FIELD_UPDATED', type: 'FIELD_UPDATED',
field: 'mpn', field: 'partModel',
oldValue: current.mpn, oldValue: current.partModel.mpn,
newValue: input.mpn, newValue: part.partModel.mpn,
}); });
} }
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) { if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
@@ -211,26 +354,6 @@ export async function update(
newValue: input.serialNumber, newValue: input.serialNumber,
}); });
} }
if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'manufacturer',
oldValue: current.manufacturer.name,
newValue: part.manufacturer.name,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'category',
oldValue: current.category?.name ?? null,
newValue: part.category?.name ?? null,
});
}
if (input.price !== undefined && input.price !== current.price) { if (input.price !== undefined && input.price !== current.price) {
events.push({ events.push({
partId: part.id, partId: part.id,
@@ -267,8 +390,11 @@ export async function remove(tx: Tx, id: string) {
try { try {
await tx.part.delete({ where: { id } }); await tx.part.delete({ where: { id } });
} catch (err) { } catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') { if (err instanceof Prisma.PrismaClientKnownRequestError) {
throw errors.notFound('Part'); if (err.code === 'P2025') throw errors.notFound('Part');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: part is referenced by an FM or repair');
}
} }
throw err; throw err;
} }
@@ -295,6 +421,7 @@ export interface BulkPartsInput {
ids: string[]; ids: string[];
state?: CreatePartRequest['state']; state?: CreatePartRequest['state'];
binId?: string | null; binId?: string | null;
hostId?: string | null;
addTagIds?: string[]; addTagIds?: string[];
removeTagIds?: string[]; removeTagIds?: string[];
} }
@@ -312,12 +439,13 @@ export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | n
const patch: UpdatePartRequest = {}; const patch: UpdatePartRequest = {};
if (input.state !== undefined) patch.state = input.state; if (input.state !== undefined) patch.state = input.state;
if (input.binId !== undefined) patch.binId = input.binId; if (input.binId !== undefined) patch.binId = input.binId;
if (input.hostId !== undefined) patch.hostId = input.hostId;
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
await update(tx, id, patch, actor); await update(tx, id, patch, actor);
} }
if (input.addTagIds || input.removeTagIds) { if (input.addTagIds || input.removeTagIds) {
const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } }); const existing = await tx.partTag.findMany({ where: { partId: id }, select: { tagId: true } });
let next = new Set(existing.map((r) => r.tagId)); const next = new Set(existing.map((r) => r.tagId));
(input.addTagIds ?? []).forEach((t) => next.add(t)); (input.addTagIds ?? []).forEach((t) => next.add(t));
(input.removeTagIds ?? []).forEach((t) => next.delete(t)); (input.removeTagIds ?? []).forEach((t) => next.delete(t));
await tagsSvc.setPartTags(tx, id, [...next], actor); await tagsSvc.setPartTags(tx, id, [...next], actor);
+536
View File
@@ -0,0 +1,536 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const emitMock = vi.fn();
vi.mock('../lib/webhook-emitter.js', () => ({
emit: (...args: unknown[]) => emitMock(...args),
}));
import type { Tx, Actor } from './types.js';
import { log } from './repairs.js';
const actor: Actor = { id: 'user-1', username: 'tech', role: 'ADMIN' };
const host1 = { id: 'host-1', assetId: 'ASSET-001', name: 'rack-1' };
const host2 = { id: 'host-2', assetId: 'ASSET-002', name: 'rack-2' };
const brokenModel = {
id: 'pm-broken',
manufacturerId: 'mfr-1',
mpn: 'WD-BROKEN',
destroyOnFail: false,
eolDate: null,
};
const destroyModel = {
id: 'pm-destroy',
manufacturerId: 'mfr-1',
mpn: 'WD-DESTROY',
destroyOnFail: true,
eolDate: null,
};
const replacementModel = {
id: 'pm-replacement',
manufacturerId: 'mfr-1',
mpn: 'WD-REPLACE',
destroyOnFail: false,
eolDate: null,
};
function partRow(overrides: Partial<Record<string, unknown>>) {
return {
id: overrides.id ?? 'p-x',
serialNumber: overrides.serialNumber ?? 'SN-X',
partModelId: overrides.partModelId ?? 'pm-x',
manufacturerId: 'mfr-1',
state: overrides.state ?? 'SPARE',
binId: overrides.binId ?? null,
hostId: overrides.hostId ?? null,
custodianId: overrides.custodianId ?? null,
categoryId: null,
price: null,
notes: null,
partModel: overrides.partModel ?? brokenModel,
manufacturer: { id: 'mfr-1', name: 'WD' },
bin: null,
host: overrides.host ?? null,
category: null,
custodian: overrides.custodian ?? null,
tags: [],
...overrides,
};
}
// Builds a Tx stub whose `tx.part.findUnique` resolves from an internal registry.
// `tx.part.update` mutates the registry in place so the second parts.update call
// (for the replacement) sees the fallout from the first (broken) update.
function buildTx(options: {
parts: Array<ReturnType<typeof partRow>>;
hosts: Array<{ id: string; assetId: string; name: string }>;
existingPartModel?: { id: string; manufacturerId: string; mpn: string } | null;
}) {
const registry = new Map(options.parts.map((p) => [p.id, p]));
const tx = {
host: {
findUnique: async (args: { where: { id?: string; assetId?: string } }) => {
if (args.where.id) return options.hosts.find((h) => h.id === args.where.id) ?? null;
if (args.where.assetId)
return options.hosts.find((h) => h.assetId === args.where.assetId) ?? null;
return null;
},
},
part: {
findUnique: async (args: {
where: { id?: string; serialNumber?: string };
}) => {
const found = [...registry.values()].find(
(p) =>
(args.where.id && p.id === args.where.id) ||
(args.where.serialNumber && p.serialNumber === args.where.serialNumber),
);
return found ?? null;
},
create: vi.fn(async (args: { data: Record<string, unknown> }) => {
const data = args.data;
const pm =
data.partModelId === brokenModel.id
? brokenModel
: data.partModelId === destroyModel.id
? destroyModel
: replacementModel;
const created = partRow({
id: `p-ingested-${data.serialNumber}`,
serialNumber: data.serialNumber as string,
partModelId: data.partModelId as string,
state: data.state as string,
hostId: data.hostId as string | null,
partModel: pm,
});
registry.set(created.id, created);
return created;
}),
update: vi.fn(async (args: { where: { id: string }; data: Record<string, unknown> }) => {
const current = registry.get(args.where.id);
if (!current) throw new Error(`No part ${args.where.id} in stub registry`);
const data = args.data;
if (data.state) current.state = data.state as string;
if (data.bin !== undefined) {
const v = data.bin as { connect?: { id: string }; disconnect?: boolean };
current.binId = v.connect?.id ?? null;
}
if (data.host !== undefined) {
const v = data.host as { connect?: { id: string }; disconnect?: boolean };
current.hostId = v.connect?.id ?? null;
current.host = v.connect?.id
? options.hosts.find((h) => h.id === v.connect!.id) ?? null
: null;
}
if (data.custodian !== undefined) {
const v = data.custodian as { connect?: { id: string }; disconnect?: boolean };
current.custodianId = v.connect?.id ?? null;
current.custodian = v.connect?.id
? { id: v.connect.id, username: 'tech' }
: null;
}
return current;
}),
},
partModel: {
findUnique: async (args: {
where: {
id?: string;
manufacturerId_mpn?: { manufacturerId: string; mpn: string };
};
}) => {
if (args.where.id) {
if (args.where.id === brokenModel.id) return brokenModel;
if (args.where.id === destroyModel.id) return destroyModel;
if (args.where.id === replacementModel.id) return replacementModel;
return null;
}
if (options.existingPartModel && args.where.manufacturerId_mpn) {
const k = args.where.manufacturerId_mpn;
if (
options.existingPartModel.manufacturerId === k.manufacturerId &&
options.existingPartModel.mpn === k.mpn
) {
return options.existingPartModel;
}
}
return null;
},
create: vi.fn(async (args: { data: { manufacturerId: string; mpn: string } }) => ({
id: `pm-new-${args.data.mpn}`,
manufacturerId: args.data.manufacturerId,
mpn: args.data.mpn,
destroyOnFail: false,
eolDate: null,
})),
},
repair: {
create: vi.fn(async (args: { data: Record<string, unknown> }) => ({
id: 'repair-1',
hostId: args.data.hostId,
brokenPartId: args.data.brokenPartId,
replacementPartId: args.data.replacementPartId,
performedById: args.data.performedById,
performedAt: new Date('2026-04-15T00:00:00Z'),
host: options.hosts.find((h) => h.id === args.data.hostId) ?? host1,
brokenPart: registry.get(args.data.brokenPartId as string),
replacement: registry.get(args.data.replacementPartId as string),
performedBy: { id: actor.id, username: actor.username },
})),
},
partEvent: {
create: vi.fn(),
createMany: vi.fn(),
},
partTag: {
findMany: async () => [],
},
} as unknown as Tx;
return { tx, registry };
}
beforeEach(() => {
emitMock.mockClear();
});
describe('repairs.log — happy paths', () => {
it('swaps a known broken deployed part with a SPARE replacement', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'SPARE',
binId: 'bin-1',
partModel: replacementModel,
});
const { tx, registry } = buildTx({
parts: [broken, replacement],
hosts: [host1],
});
const r = await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
expect(r.id).toBe('repair-1');
// After swap: broken is in custody, replacement is DEPLOYED on the host.
const updatedBroken = registry.get('p-broken')!;
expect(updatedBroken.state).toBe('PENDING_DROP_IN_CUSTODY');
expect(updatedBroken.custodianId).toBe('user-1');
expect(updatedBroken.hostId).toBeNull();
const updatedReplacement = registry.get('p-replacement')!;
expect(updatedReplacement.state).toBe('DEPLOYED');
expect(updatedReplacement.hostId).toBe('host-1');
expect(updatedReplacement.binId).toBeNull();
expect(emitMock).toHaveBeenCalledWith(
expect.objectContaining({
event: 'repair.logged',
payload: expect.objectContaining({
repair: expect.objectContaining({ id: 'repair-1' }),
}),
}),
);
});
it('PART_SWAPPED events are emitted for both parts', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'SPARE',
partModel: replacementModel,
});
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
// Find the createMany call with PART_SWAPPED entries.
const createManyMock = tx.partEvent.createMany as unknown as ReturnType<typeof vi.fn>;
const swapCall = createManyMock.mock.calls.find((c) => {
const data = (c[0] as { data: Array<{ type: string }> }).data;
return data.some((d) => d.type === 'PART_SWAPPED');
});
expect(swapCall).toBeDefined();
const swapData = (swapCall![0] as { data: Array<{ partId: string; type: string }> }).data;
const swapped = swapData.filter((d) => d.type === 'PART_SWAPPED').map((d) => d.partId);
expect(swapped).toEqual(expect.arrayContaining(['p-broken', 'p-replacement']));
});
});
describe('repairs.log — ingest on unknown MPN', () => {
it('ingests a brand-new broken part when the serial is absent', async () => {
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'SPARE',
partModel: replacementModel,
});
const { tx, registry } = buildTx({
parts: [replacement],
hosts: [host1],
existingPartModel: null,
});
await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'NEW-SN',
brokenMpn: 'NEW-MPN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
const partModelCreate = tx.partModel.create as unknown as ReturnType<typeof vi.fn>;
expect(partModelCreate).toHaveBeenCalledWith({
data: { manufacturerId: 'mfr-1', mpn: 'NEW-MPN' },
});
const partCreate = tx.part.create as unknown as ReturnType<typeof vi.fn>;
expect(partCreate).toHaveBeenCalledTimes(1);
const createdArgs = partCreate.mock.calls[0]![0] as {
data: { serialNumber: string; state: string; hostId: string };
};
expect(createdArgs.data.serialNumber).toBe('NEW-SN');
expect(createdArgs.data.state).toBe('DEPLOYED');
expect(createdArgs.data.hostId).toBe('host-1');
// The ingested part must end up in custody just like the known-broken path.
const ingested = registry.get('p-ingested-NEW-SN')!;
expect(ingested.state).toBe('PENDING_DROP_IN_CUSTODY');
expect(ingested.custodianId).toBe('user-1');
});
it('destroyOnFail=true routes the broken part to PENDING_DESTRUCTION_IN_CUSTODY', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: destroyModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: destroyModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'SPARE',
partModel: replacementModel,
});
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-DESTROY',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
expect(registry.get('p-broken')!.state).toBe('PENDING_DESTRUCTION_IN_CUSTODY');
});
});
describe('repairs.log — validation failures', () => {
it('rejects when replacement is missing', async () => {
const { tx } = buildTx({ parts: [], hosts: [host1] });
await expect(
log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'DOES-NOT-EXIST',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
});
it('rejects when replacement is not SPARE', async () => {
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
partModel: replacementModel,
});
const { tx } = buildTx({ parts: [replacement], hosts: [host1] });
await expect(
log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
});
it('accepts a PENDING_REPAIR replacement held by the actor', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'PENDING_REPAIR',
custodianId: actor.id,
custodian: { id: actor.id, username: actor.username },
partModel: replacementModel,
});
const { tx, registry } = buildTx({ parts: [broken, replacement], hosts: [host1] });
const r = await log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
);
expect(r.id).toBe('repair-1');
const updatedReplacement = registry.get('p-replacement')!;
expect(updatedReplacement.state).toBe('DEPLOYED');
expect(updatedReplacement.hostId).toBe('host-1');
expect(updatedReplacement.custodianId).toBeNull();
});
it('rejects a PENDING_REPAIR replacement held by someone else with 400', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-1',
host: host1,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'PENDING_REPAIR',
custodianId: 'user-other',
custodian: { id: 'user-other', username: 'someone' },
partModel: replacementModel,
});
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1] });
await expect(
log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
});
it('rejects when broken part is on a different host than the repair', async () => {
const broken = partRow({
id: 'p-broken',
serialNumber: 'SN-BROKEN',
partModelId: brokenModel.id,
state: 'DEPLOYED',
hostId: 'host-2',
host: host2,
partModel: brokenModel,
});
const replacement = partRow({
id: 'p-replacement',
serialNumber: 'SN-REPLACE',
partModelId: replacementModel.id,
state: 'SPARE',
partModel: replacementModel,
});
const { tx } = buildTx({ parts: [broken, replacement], hosts: [host1, host2] });
await expect(
log(
tx,
{
hostId: 'host-1',
brokenSerial: 'SN-BROKEN',
brokenMpn: 'WD-BROKEN',
brokenManufacturerId: 'mfr-1',
replacementSerial: 'SN-REPLACE',
},
actor,
),
).rejects.toMatchObject({ status: 400 });
});
});
+159 -105
View File
@@ -1,145 +1,199 @@
import { Prisma } from '@vector/db'; import { Prisma } from '@vector/db';
import type { import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js'; import { errors } from '../lib/http-error.js';
import { emit } from '../lib/webhook-emitter.js';
import * as partsSvc from './parts.js';
import * as partModelsSvc from './part-models.js';
import { resolveHost } from './hosts.js';
import type { Actor, Tx } from './types.js'; import type { Actor, Tx } from './types.js';
// A Repair is the persistent log of a physical part swap on a host. The tech enters the broken
// serial + mpn + replacement serial; if the broken part isn't in the catalog we ingest it. The
// broken part is placed into the tech's custody (dropped in a bin later via the custody flow).
const repairInclude = { const repairInclude = {
part: {
include: { manufacturer: true },
},
host: true, host: true,
assignee: { select: { id: true, username: true, email: true, role: true } }, brokenPart: { include: { partModel: true, manufacturer: true } },
} satisfies Prisma.RepairJobInclude; replacement: { include: { partModel: true, manufacturer: true } },
performedBy: { select: { id: true, username: true } },
} satisfies Prisma.RepairInclude;
export async function list(tx: Tx, q: RepairJobListQuery) { export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
const { page, pageSize, status, partId, hostId, assigneeId, openOnly } = q;
const where: Prisma.RepairJobWhereInput = {};
if (status) where.status = status;
if (partId) where.partId = partId;
if (hostId) where.hostId = hostId;
if (assigneeId) where.assigneeId = assigneeId;
if (openOnly) where.status = { in: ['PENDING', 'IN_PROGRESS'] };
function buildWhere(q: RepairListQuery): Prisma.RepairWhereInput {
const where: Prisma.RepairWhereInput = {};
if (q.hostId) where.hostId = q.hostId;
if (q.performedById) where.performedById = q.performedById;
if (q.since) where.performedAt = { gte: new Date(q.since) };
return where;
}
export async function list(tx: Tx, q: RepairListQuery) {
const { page, pageSize } = q;
const where = buildWhere(q);
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
tx.repairJob.findMany({ tx.repair.findMany({
where, where,
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }], orderBy: { performedAt: 'desc' },
include: repairInclude, include: repairInclude,
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
}), }),
tx.repairJob.count({ where }), tx.repair.count({ where }),
]); ]);
return { data, page, pageSize, total }; return { data, page, pageSize, total };
} }
export function get(tx: Tx, id: string) { export function get(tx: Tx, id: string) {
return tx.repairJob.findUnique({ where: { id }, include: repairInclude }); return tx.repair.findUnique({ where: { id }, include: repairInclude });
} }
export function listForPart(tx: Tx, partId: string) { function repairPayload(r: RepairWithRelations) {
return tx.repairJob.findMany({ return {
where: { partId }, id: r.id,
orderBy: { openedAt: 'desc' }, host: { id: r.host.id, assetId: r.host.assetId, name: r.host.name },
include: repairInclude, brokenPart: {
}); id: r.brokenPart.id,
} serialNumber: r.brokenPart.serialNumber,
mpn: r.brokenPart.partModel.mpn,
export async function create( state: r.brokenPart.state,
tx: Tx,
input: CreateRepairJobRequest,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: input.partId } });
if (!part) throw errors.notFound('Part');
try {
const repair = await tx.repairJob.create({
data: {
partId: input.partId,
hostId: input.hostId ?? null,
assigneeId: input.assigneeId ?? null,
notes: input.notes ?? null,
status: 'PENDING',
}, },
include: repairInclude, replacement: {
id: r.replacement.id,
serialNumber: r.replacement.serialNumber,
mpn: r.replacement.partModel.mpn,
state: r.replacement.state,
},
performedBy: r.performedBy,
performedAt: r.performedAt.toISOString(),
};
}
export async function log(
tx: Tx,
input: LogRepairRequest,
actor: Actor,
): Promise<RepairWithRelations> {
const host = await resolveHost(tx, input);
// 1. Resolve replacement — must exist; must be SPARE, or a PENDING_REPAIR held by the actor.
const replacement = await tx.part.findUnique({
where: { serialNumber: input.replacementSerial },
include: { partModel: true },
});
if (!replacement) {
throw errors.badRequest(`Replacement part ${input.replacementSerial} not found`);
}
const heldForRepairByActor =
replacement.state === 'PENDING_REPAIR' && replacement.custodianId === actor.id;
if (replacement.state !== 'SPARE' && !heldForRepairByActor) {
throw errors.badRequest(
`Replacement part ${input.replacementSerial} is ${replacement.state}, must be SPARE or PENDING_REPAIR held by you`,
);
}
// 2. Resolve broken — reuse if found, else ingest.
let broken = await tx.part.findUnique({
where: { serialNumber: input.brokenSerial },
include: { partModel: true },
});
if (broken) {
if (broken.hostId && broken.hostId !== host.id) {
throw errors.badRequest(
`Broken part ${input.brokenSerial} is currently on a different host`,
);
}
} else {
let pm: { id: string; manufacturerId: string };
if (input.brokenPartModelId) {
const existing = await tx.partModel.findUnique({ where: { id: input.brokenPartModelId } });
if (!existing) throw errors.badRequest('Broken part model does not exist');
pm = { id: existing.id, manufacturerId: existing.manufacturerId };
} else {
if (!input.brokenMpn || !input.brokenManufacturerId) {
throw errors.badRequest(
'Provide brokenPartModelId or both brokenMpn and brokenManufacturerId',
);
}
pm = await partModelsSvc.upsertByMpn(tx, {
manufacturerId: input.brokenManufacturerId,
mpn: input.brokenMpn,
});
}
const created = await tx.part.create({
data: {
serialNumber: input.brokenSerial,
partModelId: pm.id,
manufacturerId: pm.manufacturerId,
state: 'DEPLOYED',
hostId: host.id,
},
include: { partModel: true },
}); });
await tx.partEvent.create({ await tx.partEvent.create({
data: { data: {
partId: part.id, partId: created.id,
userId: actor?.id ?? null, userId: actor.id,
type: 'REPAIR_STARTED', type: 'CREATED',
newValue: repair.id, newValue: created.serialNumber,
}, },
}); });
return repair; broken = created;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('Invalid host or assignee id');
} }
throw err;
}
}
export async function update( // 3. Custody state is driven by the broken model's destroyOnFail flag.
tx: Tx, const custodyState = broken.partModel.destroyOnFail
id: string, ? 'PENDING_DESTRUCTION_IN_CUSTODY'
input: UpdateRepairJobRequest, : 'PENDING_DROP_IN_CUSTODY';
actor: Actor | null,
) {
const current = await tx.repairJob.findUnique({ where: { id } });
if (!current) throw errors.notFound('Repair');
const data: Prisma.RepairJobUpdateInput = {}; // 4. Transition both parts through the standard parts.update machinery so every state
if (input.status !== undefined && input.status !== current.status) { // and location change emits the usual PartEvents. The resolver clears host/bin
data.status = input.status; // automatically when entering custody / DEPLOYED.
// closedAt follows terminal status transitions. await partsSvc.update(
const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED'; tx,
const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED'; broken.id,
if (nowTerminal && !wasTerminal) data.closedAt = new Date(); { state: custodyState, custodianId: actor.id },
if (!nowTerminal && wasTerminal) data.closedAt = null; actor,
} );
if (input.hostId !== undefined) { await partsSvc.update(
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true }; tx,
} replacement.id,
if (input.assigneeId !== undefined) { { state: 'DEPLOYED', hostId: host.id },
data.assignee = input.assigneeId actor,
? { connect: { id: input.assigneeId } } );
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
const repair = await tx.repairJob.update({ // 5. Persist the Repair row.
where: { id }, const repair = await tx.repair.create({
data, data: {
hostId: host.id,
brokenPartId: broken.id,
replacementPartId: replacement.id,
performedById: actor.id,
},
include: repairInclude, include: repairInclude,
}); });
if (input.status === 'COMPLETED' && current.status !== 'COMPLETED') { // 6. Swap event on each part — so the part timeline shows the repair link.
await tx.partEvent.create({ await tx.partEvent.createMany({
data: { data: [
partId: repair.partId, {
userId: actor?.id ?? null, partId: broken.id,
type: 'REPAIR_COMPLETED', userId: actor.id,
type: 'PART_SWAPPED',
field: 'role',
oldValue: 'DEPLOYED',
newValue: repair.id, newValue: repair.id,
}, },
{
partId: replacement.id,
userId: actor.id,
type: 'PART_SWAPPED',
field: 'role',
oldValue: 'SPARE',
newValue: repair.id,
},
],
}); });
}
void emit({ event: 'repair.logged', payload: { repair: repairPayload(repair) } });
return repair; return repair;
} }
export async function remove(tx: Tx, id: string) {
try {
await tx.repairJob.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Repair');
}
throw err;
}
}
+2 -6
View File
@@ -9,12 +9,8 @@ export default defineConfig({
reporter: ['text', 'html', 'lcov'], reporter: ['text', 'html', 'lcov'],
include: ['src/services/**', 'src/lib/**'], include: ['src/services/**', 'src/lib/**'],
exclude: ['**/*.test.ts', '**/types.ts'], exclude: ['**/*.test.ts', '**/types.ts'],
thresholds: { // No thresholds today — the coverage report is a signal, not a gate.
lines: 60, // Most services still lack unit tests; add a threshold once they do.
functions: 60,
branches: 60,
statements: 60,
},
}, },
}, },
}); });
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
# ---------- build ----------
FROM node:22-alpine AS build
RUN corepack enable && corepack prepare pnpm@10.33.0 --activate
WORKDIR /repo
COPY pnpm-workspace.yaml pnpm-lock.yaml package.json ./
COPY apps/api/package.json ./apps/api/
COPY apps/web/package.json ./apps/web/
COPY apps/e2e/package.json ./apps/e2e/
COPY packages/db/package.json ./packages/db/
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm -C packages/shared build \
&& pnpm -C apps/web build
# ---------- runtime ----------
FROM nginx:alpine
RUN apk add --no-cache wget
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /repo/apps/web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost/ || exit 1
+37
View File
@@ -0,0 +1,37 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
gzip_min_length 256;
gzip_vary on;
# Reverse-proxy API calls to the internal api service on the compose network.
location /api/ {
proxy_pass http://api:3001/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
client_max_body_size 10m;
}
# Hashed assets — cache aggressively, never revalidate.
location ~* \.(?:js|css|woff2?|svg|png|jpg|jpeg|gif|ico|webp)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# SPA fallback — always serve fresh index.html.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
+14
View File
@@ -11,9 +11,16 @@ import Dashboard from './pages/Dashboard.js';
import Parts from './pages/Parts.js'; import Parts from './pages/Parts.js';
import PartDetail from './pages/PartDetail.js'; import PartDetail from './pages/PartDetail.js';
import Locations from './pages/Locations.js'; import Locations from './pages/Locations.js';
import BinDetail from './pages/BinDetail.js';
import Manufacturers from './pages/Manufacturers.js'; import Manufacturers from './pages/Manufacturers.js';
import ManufacturerDetail from './pages/ManufacturerDetail.js';
import PartModels from './pages/PartModels.js';
import PartModelDetail from './pages/PartModelDetail.js';
import CategoryDetail from './pages/CategoryDetail.js';
import Repairs from './pages/Repairs.js'; import Repairs from './pages/Repairs.js';
import MyCustody from './pages/MyCustody.js';
import Hosts from './pages/Hosts.js'; import Hosts from './pages/Hosts.js';
import HostDetail from './pages/HostDetail.js';
import Users from './pages/admin/Users.js'; import Users from './pages/admin/Users.js';
import Webhooks from './pages/admin/Webhooks.js'; import Webhooks from './pages/admin/Webhooks.js';
@@ -53,9 +60,16 @@ export default function App() {
<Route path="/parts" element={<Parts />} /> <Route path="/parts" element={<Parts />} />
<Route path="/parts/:id" element={<PartDetail />} /> <Route path="/parts/:id" element={<PartDetail />} />
<Route path="/locations" element={<Locations />} /> <Route path="/locations" element={<Locations />} />
<Route path="/bins/:id" element={<BinDetail />} />
<Route path="/manufacturers" element={<Manufacturers />} /> <Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/manufacturers/:id" element={<ManufacturerDetail />} />
<Route path="/part-models" element={<PartModels />} />
<Route path="/part-models/:id" element={<PartModelDetail />} />
<Route path="/categories/:id" element={<CategoryDetail />} />
<Route path="/repairs" element={<Repairs />} /> <Route path="/repairs" element={<Repairs />} />
<Route path="/custody" element={<MyCustody />} />
<Route path="/hosts" element={<Hosts />} /> <Route path="/hosts" element={<Hosts />} />
<Route path="/hosts/:id" element={<HostDetail />} />
<Route <Route
path="/admin/users" path="/admin/users"
element={ element={
+21
View File
@@ -0,0 +1,21 @@
import { Card, CardContent } from '@vector/ui';
interface StatCardProps {
label: string;
value: string;
sub?: string;
}
export function StatCard({ label, value, sub }: StatCardProps) {
return (
<Card>
<CardContent className="p-4">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label}
</div>
<div className="truncate text-2xl font-semibold tracking-tight">{value}</div>
{sub && <div className="mt-0.5 text-xs text-muted-foreground">{sub}</div>}
</CardContent>
</Card>
);
}
@@ -0,0 +1,169 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Check, ChevronsUpDown, Plus, X } from 'lucide-react';
import {
Button,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Popover,
PopoverContent,
PopoverTrigger,
cn,
} from '@vector/ui';
import { listPartModels } from '../../lib/api/part-models.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { PartModel } from '../../lib/api/types.js';
// Async combobox over the PartModel catalog. Two outputs:
// - onPick(model): user chose an existing PartModel — the form should hide the manufacturer
// field and send { partModelId } at submit time.
// - onCreateNew(mpn): user typed an MPN not in the catalog and picked the "Create new" row —
// the form should reveal the manufacturer picker and send { mpn, manufacturerId } at submit
// time so partModels.upsertByMpn provisions the row.
interface PartModelComboboxProps {
value: PartModel | null;
newMpn: string | null;
onPick: (model: PartModel) => void;
onCreateNew: (mpn: string) => void;
onClear: () => void;
disabled?: boolean;
placeholder?: string;
}
export function PartModelCombobox({
value,
newMpn,
onPick,
onCreateNew,
onClear,
disabled,
placeholder = 'Search MPN…',
}: PartModelComboboxProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
useEffect(() => {
const t = setTimeout(() => setDebounced(search.trim()), 200);
return () => clearTimeout(t);
}, [search]);
const query = useQuery({
queryKey: queryKeys.partModels.list({ q: debounced, pageSize: 20 }),
queryFn: () => listPartModels({ q: debounced || undefined, pageSize: 20 }),
enabled: open,
});
const results = useMemo(() => query.data?.data ?? [], [query.data]);
const typed = search.trim();
const hasExactMatch = results.some(
(m) => m.mpn.toLowerCase() === typed.toLowerCase(),
);
const canCreate = typed.length > 0 && !hasExactMatch;
const triggerRef = useRef<HTMLButtonElement>(null);
const label = value
? `${value.manufacturer?.name ?? ''}${value.mpn}`
: newMpn
? `New model: ${newMpn}`
: '';
return (
<div className="flex items-center gap-1">
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'flex-1 justify-between font-normal',
!value && !newMpn && 'text-muted-foreground',
)}
>
<span className="truncate">{label || placeholder}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: triggerRef.current?.offsetWidth }}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Type MPN…"
value={search}
onValueChange={setSearch}
/>
<CommandList>
<CommandEmpty>
{query.isLoading ? 'Searching…' : 'No models found.'}
</CommandEmpty>
<CommandGroup>
{results.map((m) => (
<CommandItem
key={m.id}
value={m.id}
onSelect={() => {
onPick(m);
setSearch('');
setOpen(false);
}}
>
<span className="flex-1 truncate">
<span className="text-muted-foreground">
{m.manufacturer?.name ?? '—'} {' '}
</span>
<span className="font-mono">{m.mpn}</span>
</span>
<Check
className={cn(
'ml-auto h-4 w-4 opacity-0',
value?.id === m.id && 'opacity-100',
)}
/>
</CommandItem>
))}
{canCreate && (
<CommandItem
value={`__create__${typed}`}
onSelect={() => {
onCreateNew(typed);
setSearch('');
setOpen(false);
}}
>
<Plus className="h-3.5 w-3.5" />
Create new model: <span className="font-mono">{typed}</span>
</CommandItem>
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{(value || newMpn) && !disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={onClear}
aria-label="Clear selection"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
);
}
@@ -0,0 +1,101 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { listBins } from '../../lib/api/bins.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { Part } from '../../lib/api/types.js';
const UNASSIGNED = '__none__';
interface DropOffDialogProps {
part: Part | null;
onOpenChange: (open: boolean) => void;
onConfirm: (binId: string | null) => void;
pending: boolean;
}
export function DropOffDialog({ part, onOpenChange, onConfirm, pending }: DropOffDialogProps) {
const open = Boolean(part);
const [binId, setBinId] = useState<string>('');
useEffect(() => {
if (open) setBinId('');
}, [open]);
const bins = useQuery({
queryKey: queryKeys.bins.list({ pageSize: 100 }),
queryFn: () => listBins({ pageSize: 100 }),
enabled: open,
});
const destruction = part?.state === 'PENDING_DESTRUCTION_IN_CUSTODY';
// Spares returned from custody must land in a bin — we don't have a useful "in limbo"
// SPARE state. Destruction / broken drop-offs still allow an unassigned bin.
const returningSpare = part?.state === 'PENDING_REPAIR';
const title = returningSpare ? 'Return spare to bin' : 'Drop in bin';
const description = returningSpare
? `Return ${part?.serialNumber ?? ''} to inventory. Choose a bin — required when returning a spare.`
: destruction
? 'This part is flagged for destruction. It will move to pending-destruction — optionally place it in a destruction bin.'
: `Dropping ${part?.serialNumber ?? ''} into a bin marks it broken.`;
const confirmDisabled = pending || (returningSpare && !binId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm">
<Select
value={binId ? binId : UNASSIGNED}
onValueChange={(v) => setBinId(v === UNASSIGNED ? '' : v)}
>
<SelectTrigger>
<SelectValue placeholder={returningSpare ? 'Select a bin' : 'Unassigned'} />
</SelectTrigger>
<SelectContent>
{!returningSpare && <SelectItem value={UNASSIGNED}>Unassigned</SelectItem>}
{bins.data?.data.map((b) => (
<SelectItem key={b.id} value={b.id}>
{b.fullPath ?? b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button onClick={() => onConfirm(binId || null)} disabled={confirmDisabled}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{returningSpare ? 'Return' : 'Drop off'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -3,8 +3,9 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod'; import { z } from 'zod';
import { Loader2 } from 'lucide-react'; import { Loader2, Sparkles } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { HostStack, HostState } from '@vector/shared';
import { import {
Button, Button,
Dialog, Dialog,
@@ -20,20 +21,39 @@ import {
FormLabel, FormLabel,
FormMessage, FormMessage,
Input, Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea, Textarea,
} from '@vector/ui'; } from '@vector/ui';
import { createHost, updateHost } from '../../lib/api/hosts.js'; import { createHost, generateHostAssetId, updateHost } from '../../lib/api/hosts.js';
import { ApiRequestError } from '../../lib/api/client.js'; import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js'; import { queryKeys } from '../../lib/queryKeys.js';
import type { Host } from '../../lib/api/types.js'; import type { Host } from '../../lib/api/types.js';
const Schema = z.object({ const Schema = z.object({
assetId: z.string().trim().min(1, 'Required').max(64),
name: z.string().min(1, 'Required').max(128), name: z.string().min(1, 'Required').max(128),
location: z.string().max(256).optional(), location: z.string().max(256).optional(),
notes: z.string().max(4096).optional(), notes: z.string().max(4096).optional(),
state: HostState,
stack: HostStack,
}); });
type Values = z.infer<typeof Schema>; type Values = z.infer<typeof Schema>;
const STATE_LABELS: Record<z.infer<typeof HostState>, string> = {
DEPLOYED: 'Deployed',
DEGRADED: 'Degraded',
TESTING: 'Testing',
};
const STACK_LABELS: Record<z.infer<typeof HostStack>, string> = {
PRODUCTION: 'Production',
VETTING: 'Vetting',
};
interface HostFormDialogProps { interface HostFormDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -46,26 +66,57 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
const form = useForm<Values>({ const form = useForm<Values>({
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
defaultValues: { name: '', location: '', notes: '' }, defaultValues: {
assetId: '',
name: '',
location: '',
notes: '',
state: 'DEPLOYED',
stack: 'PRODUCTION',
},
}); });
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
form.reset({ form.reset({
assetId: host?.assetId ?? '',
name: host?.name ?? '', name: host?.name ?? '',
location: host?.location ?? '', location: host?.location ?? '',
notes: host?.notes ?? '', notes: host?.notes ?? '',
state: host?.state ?? 'DEPLOYED',
stack: host?.stack ?? 'PRODUCTION',
}); });
}, [open, host, form]); }, [open, host, form]);
const generateMutation = useMutation({
mutationFn: () => generateHostAssetId(),
onSuccess: ({ assetId }) => {
form.setValue('assetId', assetId, { shouldDirty: true, shouldValidate: true });
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Generate failed'),
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: Values) => { mutationFn: async (values: Values) => {
const payload = { if (editing && host) {
return updateHost(host.id, {
assetId: values.assetId,
name: values.name, name: values.name,
location: values.location ? values.location : null, location: values.location ? values.location : null,
notes: values.notes ? values.notes : null, notes: values.notes ? values.notes : null,
}; state: values.state,
return editing && host ? updateHost(host.id, payload) : createHost(payload); stack: values.stack,
});
}
return createHost({
assetId: values.assetId,
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
state: values.state,
stack: values.stack,
});
}, },
onSuccess: () => { onSuccess: () => {
toast.success(editing ? 'Host updated' : 'Host created'); toast.success(editing ? 'Host updated' : 'Host created');
@@ -88,6 +139,37 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3"> <form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="assetId"
render={({ field }) => (
<FormItem>
<FormLabel>Asset ID</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input autoFocus placeholder="e.g. ASSET-001" {...field} />
</FormControl>
{!editing && (
<Button
type="button"
variant="outline"
onClick={() => generateMutation.mutate()}
disabled={generateMutation.isPending}
title="Generate an unused 8-digit asset ID"
>
{generateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Sparkles className="h-4 w-4" />
)}
Generate
</Button>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -95,7 +177,7 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
<FormItem> <FormItem>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input autoFocus {...field} /> <Input {...field} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -114,6 +196,56 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
</FormItem> </FormItem>
)} )}
/> />
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="state"
render={({ field }) => (
<FormItem>
<FormLabel>State</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{HostState.options.map((s) => (
<SelectItem key={s} value={s}>
{STATE_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="stack"
render={({ field }) => (
<FormItem>
<FormLabel>Stack</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{HostStack.options.map((s) => (
<SelectItem key={s} value={s}>
{STACK_LABELS[s]}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="notes" name="notes"
@@ -0,0 +1,20 @@
import type { HostStack, HostState } from '@vector/shared';
import { Badge, type BadgeProps } from '@vector/ui';
const STATE_VARIANT: Record<HostState, BadgeProps['variant']> = {
DEPLOYED: 'secondary',
DEGRADED: 'destructive',
TESTING: 'outline',
};
export function HostStateBadge({ state }: { state: HostState }) {
return <Badge variant={STATE_VARIANT[state]}>{state}</Badge>;
}
export function HostStackBadge({ stack }: { stack: HostStack }) {
return (
<Badge variant="outline" className="text-xs">
{stack}
</Badge>
);
}
@@ -0,0 +1,210 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
ArrowRightLeft,
LogIn,
LogOut,
Pencil,
type LucideIcon,
} from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
import { listHostTimeline } from '../../lib/api/hosts.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { HostTimelineEntry } from '../../lib/api/types.js';
const ENTRY_ICON: Record<HostTimelineEntry['type'], LucideIcon> = {
HOST_EVENT: Pencil,
REPAIR: ArrowRightLeft,
PART_ARRIVED: LogIn,
PART_DEPARTED: LogOut,
};
const HOST_EVENT_TITLE: Record<string, string> = {
CREATED: 'Created',
STATE_CHANGED: 'State changed',
STACK_CHANGED: 'Stack changed',
FIELD_UPDATED: 'Field updated',
};
function formatWhen(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function EntryRow({ entry }: { entry: HostTimelineEntry }) {
switch (entry.type) {
case 'HOST_EVENT': {
const { hostEvent } = entry;
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">
{HOST_EVENT_TITLE[hostEvent.type] ?? hostEvent.type}
</span>
{hostEvent.field && (
<span className="text-xs text-muted-foreground">· {hostEvent.field}</span>
)}
{(hostEvent.oldValue || hostEvent.newValue) && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="font-mono">{hostEvent.oldValue ?? '—'}</span>
<ArrowRight className="h-3 w-3" />
<span className="font-mono text-foreground">{hostEvent.newValue ?? '—'}</span>
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatWhen(entry.at)}
{hostEvent.user?.username ? ` · ${hostEvent.user.username}` : ''}
</div>
</>
);
}
case 'REPAIR': {
const { repair } = entry;
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">Repair</span>
<span className="inline-flex flex-wrap items-center gap-1 text-xs">
<Link
to={`/parts/${repair.brokenPart.id}`}
className="font-mono text-muted-foreground hover:underline"
>
{repair.brokenPart.serialNumber}
</Link>
<span className="text-muted-foreground"> BROKEN</span>
<span className="text-muted-foreground">·</span>
<Link
to={`/parts/${repair.replacement.id}`}
className="font-mono text-foreground hover:underline"
>
{repair.replacement.serialNumber}
</Link>
<span className="text-muted-foreground"> DEPLOYED</span>
</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatWhen(entry.at)}
{repair.performedBy?.username ? ` · ${repair.performedBy.username}` : ''}
</div>
</>
);
}
case 'PART_ARRIVED':
case 'PART_DEPARTED': {
const { part } = entry;
const label = entry.type === 'PART_ARRIVED' ? 'Part deployed' : 'Part departed';
return (
<>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
<Link
to={`/parts/${part.id}`}
className="font-mono text-xs text-muted-foreground hover:underline"
>
{part.serialNumber}
</Link>
<span className="text-xs text-muted-foreground">· {part.mpn}</span>
</div>
<div className="mt-0.5 text-xs text-muted-foreground">{formatWhen(entry.at)}</div>
</>
);
}
}
}
function entryKey(entry: HostTimelineEntry): string {
switch (entry.type) {
case 'HOST_EVENT':
return `he-${entry.hostEvent.id}`;
case 'REPAIR':
return `r-${entry.repair.id}`;
case 'PART_ARRIVED':
return `pa-${entry.partEventId}`;
case 'PART_DEPARTED':
return `pd-${entry.partEventId}`;
}
}
export function HostTimeline({ hostId }: { hostId: string }) {
const [page, setPage] = useState(1);
const pageSize = 20;
const query = useQuery({
queryKey: queryKeys.hosts.timeline(hostId, { page, pageSize }),
queryFn: () => listHostTimeline(hostId, { page, pageSize }),
placeholderData: (prev) => prev,
});
if (query.isPending) {
return (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (query.isError) {
return <p className="text-sm text-destructive">Could not load history.</p>;
}
const entries = query.data?.data ?? [];
const total = query.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (entries.length === 0) {
return <p className="text-sm text-muted-foreground">No activity yet.</p>;
}
return (
<div className="space-y-1">
<ol className="relative ml-3 border-l border-border">
{entries.map((entry) => {
const Icon = ENTRY_ICON[entry.type];
return (
<li key={entryKey(entry)} className="relative pl-6 pb-4 last:pb-0">
<span className="absolute -left-[11px] top-0 flex h-5 w-5 items-center justify-center rounded-full border border-border bg-background">
<Icon className="h-3 w-3 text-muted-foreground" />
</span>
<EntryRow entry={entry} />
</li>
);
})}
</ol>
{pageCount > 1 && (
<div className="flex items-center justify-end gap-2 pt-2 text-xs text-muted-foreground">
<span>
Page {page} of {pageCount}
</span>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={page <= 1 || query.isFetching}
onClick={() => setPage(page - 1)}
>
Previous
</Button>
<Button
variant="ghost"
size="sm"
className="h-7"
disabled={page >= pageCount || query.isFetching}
onClick={() => setPage(page + 1)}
>
Next
</Button>
</div>
)}
</div>
);
}
+6 -2
View File
@@ -1,16 +1,18 @@
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { import {
ArrowRightLeft,
Boxes, Boxes,
ChevronsLeft, ChevronsLeft,
ChevronsRight, ChevronsRight,
Hand,
LayoutDashboard, LayoutDashboard,
Layers,
type LucideIcon, type LucideIcon,
MapPinned, MapPinned,
Package, Package,
Server, Server,
Users as UsersIcon, Users as UsersIcon,
Webhook, Webhook,
Wrench,
} from 'lucide-react'; } from 'lucide-react';
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui'; import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js'; import { useAuth } from '../../contexts/AuthContext.js';
@@ -25,9 +27,11 @@ interface NavItem {
const NAV_ITEMS: NavItem[] = [ const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard }, { to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/parts', label: 'Parts', icon: Package }, { to: '/parts', label: 'Parts', icon: Package },
{ to: '/part-models', label: 'Part models', icon: Layers },
{ to: '/locations', label: 'Locations', icon: MapPinned }, { to: '/locations', label: 'Locations', icon: MapPinned },
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes }, { to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
{ to: '/repairs', label: 'Repairs', icon: Wrench }, { to: '/repairs', label: 'Repairs', icon: ArrowRightLeft },
{ to: '/custody', label: 'My Custody', icon: Hand },
{ to: '/hosts', label: 'Hosts', icon: Server }, { to: '/hosts', label: 'Hosts', icon: Server },
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true }, { to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true }, { to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
+13 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
@@ -117,8 +118,15 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
) : ( ) : (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4"> <div className="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-4">
{bins.data!.data.map((b) => ( {bins.data!.data.map((b) => (
<Card key={b.id} className="group relative"> <Card
<CardContent className="flex items-start gap-2 p-3"> key={b.id}
className="group relative transition-colors hover:border-primary/40 hover:bg-accent/30"
>
<CardContent className="flex items-start gap-2 p-0">
<Link
to={`/bins/${b.id}`}
className="flex min-w-0 flex-1 items-start gap-2 p-3"
>
<Archive className="mt-0.5 h-4 w-4 text-muted-foreground" /> <Archive className="mt-0.5 h-4 w-4 text-muted-foreground" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{b.name}</p> <p className="truncate font-medium text-sm">{b.name}</p>
@@ -126,7 +134,9 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
{b.fullPath} {b.fullPath}
</p> </p>
</div> </div>
</Link>
{canEdit && ( {canEdit && (
<div className="p-2">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
@@ -150,6 +160,7 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
)} )}
</CardContent> </CardContent>
</Card> </Card>
@@ -1,204 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { DoorOpen, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
cn,
} from '@vector/ui';
import { createRoom, deleteRoom, listRooms, updateRoom } from '../../lib/api/rooms.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { NamePromptDialog } from '../NamePromptDialog.js';
import { ConfirmDialog } from '../ConfirmDialog.js';
import type { Room } from '../../lib/api/types.js';
interface RoomDrawerProps {
siteId: string | null;
selectedId: string | null;
onSelect: (id: string) => void;
canEdit: boolean;
}
export function RoomDrawer({ siteId, selectedId, onSelect, canEdit }: RoomDrawerProps) {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<Room | null>(null);
const [deleting, setDeleting] = useState<Room | null>(null);
const rooms = useQuery({
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
enabled: Boolean(siteId),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
const createMutation = useMutation({
mutationFn: (name: string) => createRoom({ name, siteId: siteId! }),
onSuccess: (r) => {
toast.success('Room created');
invalidate();
setCreating(false);
onSelect(r.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Room renamed');
invalidate();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteRoom(id),
onSuccess: (_, id) => {
toast.success('Room deleted');
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setDeleting(null);
if (selectedId === id) onSelect('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
if (!siteId) {
return (
<div className="flex h-full items-center justify-center p-6 text-sm text-muted-foreground">
Select a site to see its rooms.
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Rooms
</h2>
{canEdit && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{rooms.isPending ? (
<div className="space-y-2 px-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : rooms.isError ? (
<p className="px-3 text-xs text-destructive">Failed to load rooms.</p>
) : rooms.data && rooms.data.data.length === 0 ? (
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
<DoorOpen className="h-5 w-5" />
<span className="text-xs">No rooms in this site</span>
</div>
) : (
<ul className="space-y-0.5">
{rooms.data!.data.map((r) => {
const active = r.id === selectedId;
return (
<li key={r.id}>
<div
className={cn(
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
active
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60',
)}
>
<button
type="button"
onClick={() => onSelect(r.id)}
className="flex flex-1 items-center gap-2 text-left"
>
<DoorOpen className="h-4 w-4 opacity-70" />
<span className="truncate">{r.name}</span>
</button>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setRenaming(r)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting(r)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
<NamePromptDialog
open={creating}
onOpenChange={setCreating}
title="New room"
label="Room name"
confirmLabel="Create"
pending={createMutation.isPending}
onSubmit={(name) => createMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename room"
label="Room name"
confirmLabel="Rename"
initialValue={renaming?.name ?? ''}
pending={renameMutation.isPending}
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete room?"
description={
deleting
? `Remove ${deleting.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
@@ -1,195 +0,0 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Building2, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
cn,
} from '@vector/ui';
import { createSite, deleteSite, listSites, updateSite } from '../../lib/api/sites.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { NamePromptDialog } from '../NamePromptDialog.js';
import { ConfirmDialog } from '../ConfirmDialog.js';
import type { Site } from '../../lib/api/types.js';
interface SiteListProps {
selectedId: string | null;
onSelect: (id: string) => void;
canEdit: boolean;
}
export function SiteList({ selectedId, onSelect, canEdit }: SiteListProps) {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<Site | null>(null);
const [deleting, setDeleting] = useState<Site | null>(null);
const sites = useQuery({
queryKey: queryKeys.sites.list({ pageSize: 100 }),
queryFn: () => listSites({ pageSize: 100 }),
});
const invalidate = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
const createMutation = useMutation({
mutationFn: (name: string) => createSite({ name }),
onSuccess: (s) => {
toast.success('Site created');
invalidate();
setCreating(false);
onSelect(s.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Site renamed');
invalidate();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteSite(id),
onSuccess: (_, id) => {
toast.success('Site deleted');
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setDeleting(null);
if (selectedId === id) onSelect('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Sites
</h2>
{canEdit && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{sites.isPending ? (
<div className="space-y-2 px-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : sites.isError ? (
<p className="px-3 text-xs text-destructive">Failed to load sites.</p>
) : sites.data && sites.data.data.length === 0 ? (
<div className="flex flex-col items-center gap-1 py-6 text-muted-foreground">
<Building2 className="h-5 w-5" />
<span className="text-xs">No sites yet</span>
</div>
) : (
<ul className="space-y-0.5">
{sites.data!.data.map((s) => {
const active = s.id === selectedId;
return (
<li key={s.id}>
<div
className={cn(
'group flex items-center justify-between rounded-md px-2 py-1.5 text-sm',
active
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60',
)}
>
<button
type="button"
onClick={() => onSelect(s.id)}
className="flex flex-1 items-center gap-2 text-left"
>
<Building2 className="h-4 w-4 opacity-70" />
<span className="truncate">{s.name}</span>
</button>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setRenaming(s)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting(s)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
})}
</ul>
)}
</div>
<NamePromptDialog
open={creating}
onOpenChange={setCreating}
title="New site"
label="Site name"
confirmLabel="Create"
pending={createMutation.isPending}
onSubmit={(name) => createMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename site"
label="Site name"
confirmLabel="Rename"
initialValue={renaming?.name ?? ''}
pending={renameMutation.isPending}
onSubmit={(name) => renaming && renameMutation.mutate({ id: renaming.id, name })}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete site?"
description={
deleting
? `Remove ${deleting.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
@@ -0,0 +1,434 @@
import { useEffect, useMemo, useState } from 'react';
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import {
Building2,
ChevronDown,
ChevronRight,
DoorOpen,
MoreHorizontal,
Plus,
Trash2,
} from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
cn,
} from '@vector/ui';
import { createSite, deleteSite, listSites, updateSite } from '../../lib/api/sites.js';
import { createRoom, deleteRoom, listRooms, updateRoom } from '../../lib/api/rooms.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { NamePromptDialog } from '../NamePromptDialog.js';
import { ConfirmDialog } from '../ConfirmDialog.js';
import type { Room, Site } from '../../lib/api/types.js';
// A single tree view combining the former SiteList and RoomDrawer. Sites expand to show their
// rooms inline; the whole thing shares the same URL state (?site=&room=) so deep links still
// resolve. Each row keeps its inline rename/delete action; creation happens per level.
interface SiteRoomTreeProps {
siteId: string | null;
roomId: string | null;
onSelectSite: (id: string) => void;
onSelectRoom: (id: string) => void;
canEdit: boolean;
}
type RenameTarget = { kind: 'site'; value: Site } | { kind: 'room'; value: Room };
type DeleteTarget = { kind: 'site'; value: Site } | { kind: 'room'; value: Room };
export function SiteRoomTree({
siteId,
roomId,
onSelectSite,
onSelectRoom,
canEdit,
}: SiteRoomTreeProps) {
const queryClient = useQueryClient();
const [creatingSite, setCreatingSite] = useState(false);
const [creatingRoomInSite, setCreatingRoomInSite] = useState<string | null>(null);
const [renaming, setRenaming] = useState<RenameTarget | null>(null);
const [deleting, setDeleting] = useState<DeleteTarget | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const sites = useQuery({
queryKey: queryKeys.sites.list({ pageSize: 100 }),
queryFn: () => listSites({ pageSize: 100 }),
});
// Ensure the selected site is expanded on load / deep link.
useEffect(() => {
if (siteId) setExpanded((prev) => (prev.has(siteId) ? prev : new Set(prev).add(siteId)));
}, [siteId]);
const siteIds = useMemo(() => {
const list = sites.data?.data ?? [];
return list.filter((s) => expanded.has(s.id)).map((s) => s.id);
}, [sites.data, expanded]);
const roomQueries = useQueries({
queries: siteIds.map((id) => ({
queryKey: queryKeys.rooms.list({ siteId: id, pageSize: 100 }),
queryFn: () => listRooms({ siteId: id, pageSize: 100 }),
})),
});
const roomsBySite = useMemo(() => {
const m = new Map<string, Room[]>();
siteIds.forEach((id, i) => {
m.set(id, roomQueries[i]?.data?.data ?? []);
});
return m;
}, [siteIds, roomQueries]);
const invalidateSites = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.sites.all });
const invalidateRooms = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.rooms.all });
const createSiteMutation = useMutation({
mutationFn: (name: string) => createSite({ name }),
onSuccess: (s) => {
toast.success('Site created');
invalidateSites();
setCreatingSite(false);
setExpanded((prev) => new Set(prev).add(s.id));
onSelectSite(s.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const createRoomMutation = useMutation({
mutationFn: (vars: { siteId: string; name: string }) =>
createRoom({ name: vars.name, siteId: vars.siteId }),
onSuccess: (r) => {
toast.success('Room created');
invalidateRooms();
setCreatingRoomInSite(null);
onSelectRoom(r.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameSiteMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateSite(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Site renamed');
invalidateSites();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const renameRoomMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateRoom(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Room renamed');
invalidateRooms();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteSiteMutation = useMutation({
mutationFn: (id: string) => deleteSite(id),
onSuccess: (_, id) => {
toast.success('Site deleted');
invalidateSites();
invalidateRooms();
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setDeleting(null);
if (siteId === id) onSelectSite('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const deleteRoomMutation = useMutation({
mutationFn: (id: string) => deleteRoom(id),
onSuccess: (_, id) => {
toast.success('Room deleted');
invalidateRooms();
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setDeleting(null);
if (roomId === id) onSelectRoom('');
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const toggle = (id: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const hasSites = Boolean(sites.data && sites.data.data.length > 0);
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-3 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Sites
</h2>
{canEdit && hasSites && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setCreatingSite(true)}
aria-label="Add site"
>
<Plus className="h-4 w-4" />
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{sites.isPending ? (
<div className="space-y-2 px-1">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : sites.isError ? (
<p className="px-3 text-xs text-destructive">Failed to load sites.</p>
) : !hasSites ? (
<div className="flex flex-col items-center gap-2 py-8 text-muted-foreground">
<Building2 className="h-5 w-5" />
<span className="text-xs">No sites yet</span>
{canEdit && (
<Button size="sm" variant="outline" onClick={() => setCreatingSite(true)}>
<Plus className="h-3.5 w-3.5" />
Add site
</Button>
)}
</div>
) : (
<ul className="space-y-0.5">
{sites.data!.data.map((s) => {
const isOpen = expanded.has(s.id);
const siteActive = s.id === siteId;
const rooms = roomsBySite.get(s.id) ?? [];
return (
<li key={s.id}>
<div
className={cn(
'group flex items-center rounded-md text-sm',
siteActive
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60',
)}
>
<button
type="button"
onClick={() => toggle(s.id)}
className="flex h-8 w-7 items-center justify-center text-muted-foreground"
aria-label={isOpen ? 'Collapse' : 'Expand'}
>
{isOpen ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</button>
<button
type="button"
onClick={() => {
onSelectSite(s.id);
setExpanded((prev) => new Set(prev).add(s.id));
}}
className="flex flex-1 items-center gap-2 py-1.5 text-left"
>
<Building2 className="h-4 w-4 opacity-70" />
<span className="truncate">{s.name}</span>
</button>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="mr-1 h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
onSelect={() => setCreatingRoomInSite(s.id)}
>
<Plus className="h-3.5 w-3.5" />
Add room
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setRenaming({ kind: 'site', value: s })}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting({ kind: 'site', value: s })}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{isOpen && (
<ul className="ml-6 mt-0.5 space-y-0.5 border-l border-border pl-1">
{rooms.length === 0 ? (
<li className="px-2 py-1 text-xs text-muted-foreground">
No rooms yet
{canEdit && (
<Button
variant="link"
size="sm"
className="h-auto px-1.5 py-0 text-xs"
onClick={() => setCreatingRoomInSite(s.id)}
>
+ add
</Button>
)}
</li>
) : (
rooms.map((r) => {
const roomActive = r.id === roomId;
return (
<li key={r.id}>
<div
className={cn(
'group flex items-center rounded-md text-sm',
roomActive
? 'bg-accent text-accent-foreground'
: 'text-foreground hover:bg-accent/60',
)}
>
<button
type="button"
onClick={() => onSelectRoom(r.id)}
className="flex flex-1 items-center gap-2 py-1.5 pl-2 text-left"
>
<DoorOpen className="h-3.5 w-3.5 opacity-70" />
<span className="truncate">{r.name}</span>
</button>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="mr-1 h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
>
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem
onSelect={() =>
setRenaming({ kind: 'room', value: r })
}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() =>
setDeleting({ kind: 'room', value: r })
}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</li>
);
})
)}
</ul>
)}
</li>
);
})}
</ul>
)}
</div>
<NamePromptDialog
open={creatingSite}
onOpenChange={setCreatingSite}
title="New site"
label="Site name"
confirmLabel="Create"
pending={createSiteMutation.isPending}
onSubmit={(name) => createSiteMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(creatingRoomInSite)}
onOpenChange={(o) => !o && setCreatingRoomInSite(null)}
title="New room"
label="Room name"
confirmLabel="Create"
pending={createRoomMutation.isPending}
onSubmit={(name) =>
creatingRoomInSite &&
createRoomMutation.mutate({ siteId: creatingRoomInSite, name })
}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title={renaming?.kind === 'site' ? 'Rename site' : 'Rename room'}
label={renaming?.kind === 'site' ? 'Site name' : 'Room name'}
confirmLabel="Rename"
initialValue={renaming?.value.name ?? ''}
pending={renameSiteMutation.isPending || renameRoomMutation.isPending}
onSubmit={(name) => {
if (!renaming) return;
if (renaming.kind === 'site') {
renameSiteMutation.mutate({ id: renaming.value.id, name });
} else {
renameRoomMutation.mutate({ id: renaming.value.id, name });
}
}}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title={deleting?.kind === 'site' ? 'Delete site?' : 'Delete room?'}
description={
deleting
? deleting.kind === 'site'
? `Remove ${deleting.value.name}. All rooms and bins inside will be deleted too. Parts in those bins become unassigned.`
: `Remove ${deleting.value.name}. All bins inside will be deleted too. Parts in those bins become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteSiteMutation.isPending || deleteRoomMutation.isPending}
onConfirm={() => {
if (!deleting) return;
if (deleting.kind === 'site') deleteSiteMutation.mutate(deleting.value.id);
else deleteRoomMutation.mutate(deleting.value.id);
}}
/>
</div>
);
}
@@ -15,7 +15,6 @@ import {
DialogTitle, DialogTitle,
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -32,11 +31,6 @@ import type { Manufacturer } from '../../lib/api/types.js';
const Schema = z.object({ const Schema = z.object({
name: z.string().min(1, 'Required').max(128), name: z.string().min(1, 'Required').max(128),
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
}); });
type Values = z.infer<typeof Schema>; type Values = z.infer<typeof Schema>;
@@ -46,11 +40,6 @@ interface ManufacturerFormDialogProps {
manufacturer?: Manufacturer | null; manufacturer?: Manufacturer | null;
} }
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
export function ManufacturerFormDialog({ export function ManufacturerFormDialog({
open, open,
onOpenChange, onOpenChange,
@@ -61,23 +50,17 @@ export function ManufacturerFormDialog({
const form = useForm<Values>({ const form = useForm<Values>({
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
defaultValues: { name: '', eolDate: '' }, defaultValues: { name: '' },
}); });
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
form.reset({ form.reset({ name: manufacturer?.name ?? '' });
name: manufacturer?.name ?? '',
eolDate: isoToDateInput(manufacturer?.eolDate ?? null),
});
}, [open, manufacturer, form]); }, [open, manufacturer, form]);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: Values) => { mutationFn: async (values: Values) => {
const payload = { const payload = { name: values.name };
name: values.name,
eolDate: values.eolDate ? values.eolDate : null,
};
return editing && manufacturer return editing && manufacturer
? updateManufacturer(manufacturer.id, payload) ? updateManufacturer(manufacturer.id, payload)
: createManufacturer(payload); : createManufacturer(payload);
@@ -98,8 +81,8 @@ export function ManufacturerFormDialog({
<DialogTitle>{editing ? 'Edit manufacturer' : 'New manufacturer'}</DialogTitle> <DialogTitle>{editing ? 'Edit manufacturer' : 'New manufacturer'}</DialogTitle>
<DialogDescription> <DialogDescription>
{editing {editing
? 'Update this manufacturer. EOL drives replacement alerts on parts.' ? 'Update the manufacturer record.'
: 'Add a manufacturer. Names must be unique.'} : 'Add a manufacturer. Names must be unique. EOL is tracked per part model.'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -118,23 +101,6 @@ export function ManufacturerFormDialog({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>End-of-life date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Optional. Parts from this manufacturer will show a replacement alert past this
date.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
@@ -0,0 +1,304 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@vector/ui';
import { createPartModel, updatePartModel } from '../../lib/api/part-models.js';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { createCategory, listCategories } from '../../lib/api/categories.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { PartModel } from '../../lib/api/types.js';
const Schema = z.object({
manufacturerId: z.string().uuid('Pick a manufacturer'),
mpn: z.string().min(1, 'Required').max(128),
categoryId: z.string().optional(), // '' = unassigned
eolDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'YYYY-MM-DD')
.or(z.literal(''))
.optional(),
destroyOnFail: z.boolean(),
notes: z.string().max(4096).optional(),
});
type Values = z.infer<typeof Schema>;
const UNASSIGNED = '__none__';
function isoToDateInput(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toISOString().slice(0, 10);
}
interface PartModelFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
partModel?: PartModel | null;
}
export function PartModelFormDialog({
open,
onOpenChange,
partModel,
}: PartModelFormDialogProps) {
const editing = Boolean(partModel);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: {
manufacturerId: '',
mpn: '',
categoryId: '',
eolDate: '',
destroyOnFail: false,
notes: '',
},
});
useEffect(() => {
if (!open) return;
form.reset({
manufacturerId: partModel?.manufacturerId ?? '',
mpn: partModel?.mpn ?? '',
categoryId: partModel?.categoryId ?? '',
eolDate: isoToDateInput(partModel?.eolDate ?? null),
destroyOnFail: partModel?.destroyOnFail ?? false,
notes: partModel?.notes ?? '',
});
}, [open, partModel, form]);
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
const categories = useQuery({
queryKey: queryKeys.categories.list({ pageSize: 100 }),
queryFn: () => listCategories({ pageSize: 100 }),
enabled: open,
});
const createCategoryMutation = useMutation({
mutationFn: (name: string) => createCategory({ name }),
onSuccess: (cat) => {
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
form.setValue('categoryId', cat.id);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not add category'),
});
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
manufacturerId: values.manufacturerId,
mpn: values.mpn,
categoryId: values.categoryId ? values.categoryId : null,
eolDate: values.eolDate ? values.eolDate : null,
destroyOnFail: values.destroyOnFail,
notes: values.notes ? values.notes : null,
};
return editing && partModel
? updatePartModel(partModel.id, payload)
: createPartModel(payload);
},
onSuccess: () => {
toast.success(editing ? 'Part model updated' : 'Part model created');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit part model' : 'New part model'}</DialogTitle>
<DialogDescription>
A part model (MPN) is the catalog entry that carries an end-of-life date.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="manufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Manufacturer</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select manufacturer" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="mpn"
render={({ field }) => (
<FormItem>
<FormLabel>MPN</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="categoryId"
render={({ field }) => (
<FormItem>
<FormLabel>Category</FormLabel>
<div className="flex items-center gap-2">
<Select
value={field.value ? field.value : UNASSIGNED}
onValueChange={(v) => {
if (v === '__new__') {
const name = window.prompt('New category name')?.trim();
if (name) createCategoryMutation.mutate(name);
return;
}
field.onChange(v === UNASSIGNED ? '' : v);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Unassigned" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={UNASSIGNED}>Unassigned</SelectItem>
{categories.data?.data.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
<SelectItem value="__new__">+ Add category</SelectItem>
</SelectContent>
</Select>
</div>
<FormDescription>
Groups like GPU / RAM / SSD describe this model family.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="eolDate"
render={({ field }) => (
<FormItem>
<FormLabel>EOL date</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormDescription>
Deployed parts past this date surface on the dashboard.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="destroyOnFail"
render={({ field }) => (
<FormItem className="flex items-start gap-2 space-y-0">
<FormControl>
<Checkbox
id="destroyOnFail"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
className="mt-0.5"
/>
</FormControl>
<div className="space-y-0.5">
<FormLabel htmlFor="destroyOnFail">Destroy on fail</FormLabel>
<FormDescription>
When this model fails, its broken part goes to the destruction path instead
of being held for return/repair.
</FormDescription>
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{editing ? 'Save changes' : 'Create'}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -2,12 +2,12 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
ArrowRight, ArrowRight,
ArrowRightLeft,
CheckCircle2, CheckCircle2,
MapPin, MapPin,
Package, Package,
Pencil, Pencil,
Tag, Tag,
Wrench,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import type { PartEventType } from '@vector/shared'; import type { PartEventType } from '@vector/shared';
@@ -20,8 +20,7 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
STATE_CHANGED: CheckCircle2, STATE_CHANGED: CheckCircle2,
LOCATION_CHANGED: MapPin, LOCATION_CHANGED: MapPin,
FIELD_UPDATED: Pencil, FIELD_UPDATED: Pencil,
REPAIR_STARTED: Wrench, PART_SWAPPED: ArrowRightLeft,
REPAIR_COMPLETED: Wrench,
TAG_ADDED: Tag, TAG_ADDED: Tag,
TAG_REMOVED: Tag, TAG_REMOVED: Tag,
}; };
@@ -31,8 +30,7 @@ const EVENT_TITLE: Record<PartEventType, string> = {
STATE_CHANGED: 'State changed', STATE_CHANGED: 'State changed',
LOCATION_CHANGED: 'Location changed', LOCATION_CHANGED: 'Location changed',
FIELD_UPDATED: 'Field updated', FIELD_UPDATED: 'Field updated',
REPAIR_STARTED: 'Repair started', PART_SWAPPED: 'Part swapped',
REPAIR_COMPLETED: 'Repair completed',
TAG_ADDED: 'Tag added', TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed', TAG_REMOVED: 'Tag removed',
}; };
+151 -32
View File
@@ -1,4 +1,4 @@
import { useEffect } from 'react'; import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -6,6 +6,8 @@ import { z } from 'zod';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { PartState } from '@vector/shared'; import { PartState } from '@vector/shared';
import { PartModelCombobox } from '../common/PartModelCombobox.js';
import type { PartModel } from '../../lib/api/types.js';
import { import {
Button, Button,
Dialog, Dialog,
@@ -30,6 +32,7 @@ import {
} from '@vector/ui'; } from '@vector/ui';
import { listManufacturers } from '../../lib/api/manufacturers.js'; import { listManufacturers } from '../../lib/api/manufacturers.js';
import { listBins } from '../../lib/api/bins.js'; import { listBins } from '../../lib/api/bins.js';
import { listHosts } from '../../lib/api/hosts.js';
import { createPart, updatePart } from '../../lib/api/parts.js'; import { createPart, updatePart } from '../../lib/api/parts.js';
import type { Part } from '../../lib/api/types.js'; import type { Part } from '../../lib/api/types.js';
import { ApiRequestError } from '../../lib/api/client.js'; import { ApiRequestError } from '../../lib/api/client.js';
@@ -37,16 +40,44 @@ import { queryKeys } from '../../lib/queryKeys.js';
import { partStateOptions } from './PartStateBadge.js'; import { partStateOptions } from './PartStateBadge.js';
// Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the // Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the
// submit handler coerce to the network shape. // submit handler coerce to the network shape. The combobox drives partModelId xor (mpn+mfr).
const PartFormSchema = z.object({ const PartFormSchema = z
.object({
serialNumber: z.string().min(1, 'Required').max(128), serialNumber: z.string().min(1, 'Required').max(128),
mpn: z.string().min(1, 'Required').max(128), partModelId: z.string().optional(), // set when an existing model is picked
manufacturerId: z.string().uuid('Select a manufacturer'), mpn: z.string().max(128).optional(), // set when creating a new model
manufacturerId: z.string().optional(),
state: PartState, state: PartState,
binId: z.string().optional(), // '' = none binId: z.string().optional(), // '' = none
hostId: z.string().optional(), // '' = none
price: z.string().optional(), // empty string = null price: z.string().optional(), // empty string = null
notes: z.string().max(4096).optional(), notes: z.string().max(4096).optional(),
}); })
.superRefine((v, ctx) => {
const hasModel = Boolean(v.partModelId);
const hasNew = Boolean(v.mpn && v.mpn.length > 0);
if (!hasModel && !hasNew) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Pick a part model or enter a new MPN',
path: ['partModelId'],
});
}
if (hasNew && !v.manufacturerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Select a manufacturer for the new model',
path: ['manufacturerId'],
});
}
if (v.state === 'DEPLOYED' && !v.hostId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'A deployed part must be assigned to a host',
path: ['hostId'],
});
}
});
type PartFormValues = z.infer<typeof PartFormSchema>; type PartFormValues = z.infer<typeof PartFormSchema>;
const UNASSIGNED = '__none__'; const UNASSIGNED = '__none__';
@@ -61,14 +92,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
const editing = Boolean(part); const editing = Boolean(part);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
const form = useForm<PartFormValues>({ const form = useForm<PartFormValues>({
resolver: zodResolver(PartFormSchema), resolver: zodResolver(PartFormSchema),
defaultValues: { defaultValues: {
serialNumber: '', serialNumber: '',
partModelId: '',
mpn: '', mpn: '',
manufacturerId: '', manufacturerId: '',
state: 'SPARE', state: 'SPARE',
binId: '', binId: '',
hostId: '',
price: '', price: '',
notes: '', notes: '',
}, },
@@ -76,29 +111,37 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
form.reset( if (part) {
part setPickedModel(part.partModel ?? null);
? { form.reset({
serialNumber: part.serialNumber, serialNumber: part.serialNumber,
mpn: part.mpn, partModelId: part.partModelId,
manufacturerId: part.manufacturerId, mpn: '',
manufacturerId: '',
state: part.state, state: part.state,
binId: part.binId ?? '', binId: part.binId ?? '',
hostId: part.hostId ?? '',
price: part.price != null ? String(part.price) : '', price: part.price != null ? String(part.price) : '',
notes: part.notes ?? '', notes: part.notes ?? '',
} });
: { } else {
setPickedModel(null);
form.reset({
serialNumber: '', serialNumber: '',
partModelId: '',
mpn: '', mpn: '',
manufacturerId: '', manufacturerId: '',
state: 'SPARE', state: 'SPARE',
binId: '', binId: '',
hostId: '',
price: '', price: '',
notes: '', notes: '',
}, });
); }
}, [open, part, form]); }, [open, part, form]);
const watchedState = form.watch('state');
const manufacturers = useQuery({ const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }), queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }), queryFn: () => listManufacturers({ pageSize: 100 }),
@@ -108,20 +151,30 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
const bins = useQuery({ const bins = useQuery({
queryKey: queryKeys.bins.list({ pageSize: 100 }), queryKey: queryKeys.bins.list({ pageSize: 100 }),
queryFn: () => listBins({ pageSize: 100 }), queryFn: () => listBins({ pageSize: 100 }),
enabled: open, enabled: open && watchedState !== 'DEPLOYED',
});
const hosts = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open && watchedState === 'DEPLOYED',
}); });
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (values: PartFormValues) => { mutationFn: async (values: PartFormValues) => {
const payload = { const deployed = values.state === 'DEPLOYED';
const base = {
serialNumber: values.serialNumber, serialNumber: values.serialNumber,
mpn: values.mpn,
manufacturerId: values.manufacturerId,
state: values.state, state: values.state,
binId: values.binId ? values.binId : null, binId: deployed ? null : values.binId ? values.binId : null,
hostId: deployed ? (values.hostId ? values.hostId : null) : null,
price: values.price === '' ? null : Number(values.price), price: values.price === '' ? null : Number(values.price),
notes: values.notes ? values.notes : null, notes: values.notes ? values.notes : null,
}; };
const modelPayload = values.partModelId
? { partModelId: values.partModelId }
: { mpn: values.mpn!, manufacturerId: values.manufacturerId! };
const payload = { ...base, ...modelPayload };
return editing && part return editing && part
? updatePart(part.id, payload) ? updatePart(part.id, payload)
: createPart(payload); : createPart(payload);
@@ -166,7 +219,6 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3"> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<FormField <FormField
control={form.control} control={form.control}
name="serialNumber" name="serialNumber"
@@ -180,28 +232,47 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
</FormItem> </FormItem>
)} )}
/> />
<FormField <FormField
control={form.control} control={form.control}
name="mpn" name="partModelId"
render={({ field }) => ( render={() => (
<FormItem> <FormItem>
<FormLabel>MPN</FormLabel> <FormLabel>Part model</FormLabel>
<FormControl> <PartModelCombobox
<Input {...field} /> value={pickedModel}
</FormControl> newMpn={form.watch('mpn') || null}
onPick={(m) => {
setPickedModel(m);
form.setValue('partModelId', m.id, { shouldValidate: true });
form.setValue('mpn', '');
form.setValue('manufacturerId', '');
}}
onCreateNew={(mpn) => {
setPickedModel(null);
form.setValue('partModelId', '');
form.setValue('mpn', mpn, { shouldValidate: true });
}}
onClear={() => {
setPickedModel(null);
form.setValue('partModelId', '');
form.setValue('mpn', '');
form.setValue('manufacturerId', '');
}}
/>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div>
{!pickedModel && form.watch('mpn') && (
<FormField <FormField
control={form.control} control={form.control}
name="manufacturerId" name="manufacturerId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Manufacturer</FormLabel> <FormLabel>Manufacturer (for new model)</FormLabel>
<Select value={field.value} onValueChange={field.onChange}> <Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select manufacturer" /> <SelectValue placeholder="Select manufacturer" />
@@ -219,6 +290,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
</FormItem> </FormItem>
)} )}
/> />
)}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<FormField <FormField
@@ -227,7 +299,16 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>State</FormLabel> <FormLabel>State</FormLabel>
<Select value={field.value} onValueChange={field.onChange}> <Select
value={field.value}
onValueChange={(v) => {
field.onChange(v);
// State and location are coupled: DEPLOYED lives on a host, all other
// states live in a bin. Clear the now-invalid field on transition.
if (v === 'DEPLOYED') form.setValue('binId', '');
else form.setValue('hostId', '');
}}
>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue /> <SelectValue />
@@ -260,12 +341,49 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
/> />
</div> </div>
{watchedState === 'PENDING_DROP_IN_CUSTODY' ||
watchedState === 'PENDING_DESTRUCTION_IN_CUSTODY' ? (
<div className="space-y-1">
<div className="text-sm font-medium">Location</div>
<div className="inline-flex items-center rounded-md border border-border bg-muted/50 px-2.5 py-1 text-xs">
In custody: {part?.custodian?.username ?? '—'}
</div>
<p className="text-xs text-muted-foreground">
Drop-off happens through the My Custody page.
</p>
</div>
) : watchedState === 'DEPLOYED' ? (
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Location (host)</FormLabel>
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
{hosts.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
) : (
<FormField <FormField
control={form.control} control={form.control}
name="binId" name="binId"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Location</FormLabel> <FormLabel>Location (bin)</FormLabel>
<Select <Select
value={field.value ? field.value : UNASSIGNED} value={field.value ? field.value : UNASSIGNED}
onValueChange={(v) => field.onChange(v === UNASSIGNED ? '' : v)} onValueChange={(v) => field.onChange(v === UNASSIGNED ? '' : v)}
@@ -288,6 +406,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
</FormItem> </FormItem>
)} )}
/> />
)}
<FormField <FormField
control={form.control} control={form.control}
@@ -1,74 +0,0 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { Button, Skeleton } from '@vector/ui';
import { listRepairsForPart } from '../../lib/api/repairs.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { RepairStatusBadge } from '../repairs/RepairStatusBadge.js';
import { RepairFormDialog } from '../repairs/RepairFormDialog.js';
import type { RepairJob } from '../../lib/api/types.js';
interface PartRepairSectionProps {
partId: string;
}
export function PartRepairSection({ partId }: PartRepairSectionProps) {
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const query = useQuery({
queryKey: [...queryKeys.parts.detail(partId), 'repairs'],
queryFn: () => listRepairsForPart(partId),
});
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">Repair history</p>
<Button variant="outline" size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="h-3.5 w-3.5" />
Open repair
</Button>
</div>
{query.isPending ? (
<Skeleton className="h-16 w-full" />
) : !query.data || query.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No repair jobs yet.</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{query.data.map((repair) => (
<li
key={repair.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex items-center gap-2">
<RepairStatusBadge status={repair.status} />
<span className="text-xs text-muted-foreground">
Opened {new Date(repair.openedAt).toLocaleDateString()}
{repair.host ? ` · ${repair.host.name}` : ''}
</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setEditing(repair)}
>
Edit
</Button>
</li>
))}
</ul>
)}
<RepairFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
defaultPartId={partId}
/>
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
/>
</div>
);
}
@@ -6,6 +6,9 @@ const STATE_LABEL: Record<PartState, string> = {
DEPLOYED: 'Deployed', DEPLOYED: 'Deployed',
BROKEN: 'Broken', BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction', PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
PENDING_REPAIR: 'Held for repair',
}; };
const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = { const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
@@ -13,12 +16,17 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
DEPLOYED: 'success', DEPLOYED: 'success',
BROKEN: 'warning', BROKEN: 'warning',
PENDING_DESTRUCTION: 'destructive', PENDING_DESTRUCTION: 'destructive',
PENDING_DROP_IN_CUSTODY: 'outline',
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
PENDING_REPAIR: 'outline',
}; };
export function PartStateBadge({ state }: { state: PartState }) { export function PartStateBadge({ state }: { state: PartState }) {
return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>; return <Badge variant={STATE_VARIANT[state]}>{STATE_LABEL[state]}</Badge>;
} }
// Options users can set via the Part form. Custody states are intentionally excluded —
// they're only reached via the Repair flow, then unwound via the Custody drop-off page.
export const partStateOptions: { value: PartState; label: string }[] = [ export const partStateOptions: { value: PartState; label: string }[] = [
{ value: 'SPARE', label: 'Spare' }, { value: 'SPARE', label: 'Spare' },
{ value: 'DEPLOYED', label: 'Deployed' }, { value: 'DEPLOYED', label: 'Deployed' },
@@ -0,0 +1,334 @@
import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { logRepair } from '../../lib/api/repairs.js';
import { listHosts } from '../../lib/api/hosts.js';
import { listManufacturers } from '../../lib/api/manufacturers.js';
import { listParts } from '../../lib/api/parts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { PartModel, Repair } from '../../lib/api/types.js';
import { PartModelCombobox } from '../common/PartModelCombobox.js';
// When the broken serial matches an existing Part the model fields are skipped entirely;
// otherwise the tech either picks an existing PartModel (partModelId) or types a new MPN
// and a manufacturer. The refine mirrors LogRepairRequest.superRefine on the server.
const Schema = z
.object({
hostId: z.string().uuid('Pick a host'),
brokenSerial: z.string().trim().min(1, 'Required').max(128),
brokenPartModelId: z.union([z.literal(''), z.string().uuid()]).optional(),
brokenMpn: z.string().trim().max(128).optional(),
brokenManufacturerId: z.union([z.literal(''), z.string().uuid()]).optional(),
replacementSerial: z.string().trim().min(1, 'Required').max(128),
brokenExists: z.boolean().optional(),
})
.superRefine((v, ctx) => {
if (v.brokenExists) return;
const hasModel = Boolean(v.brokenPartModelId);
const hasNew = Boolean(v.brokenMpn && v.brokenMpn.length > 0);
if (!hasModel && !hasNew) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Pick a part model or enter a new MPN',
path: ['brokenPartModelId'],
});
}
if (hasNew && !v.brokenManufacturerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Select a manufacturer for the new model',
path: ['brokenManufacturerId'],
});
}
});
type Values = z.infer<typeof Schema>;
interface LogRepairDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onLogged?: (repair: Repair) => void;
}
export function LogRepairDialog({ open, onOpenChange, onLogged }: LogRepairDialogProps) {
const queryClient = useQueryClient();
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: {
hostId: '',
brokenSerial: '',
brokenPartModelId: '',
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
brokenExists: false,
},
});
useEffect(() => {
if (!open) return;
setPickedModel(null);
form.reset({
hostId: '',
brokenSerial: '',
brokenPartModelId: '',
brokenMpn: '',
brokenManufacturerId: '',
replacementSerial: '',
brokenExists: false,
});
}, [open, form]);
const brokenSerial = form.watch('brokenSerial').trim();
const hosts = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open,
});
const manufacturers = useQuery({
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }),
enabled: open,
});
// Debounced live lookup — as the tech types a broken serial, hint whether Vector
// already knows that part (existing) or will auto-ingest it (new).
const brokenLookup = useQuery({
queryKey: queryKeys.parts.list({ serialNumber: brokenSerial, pageSize: 1 }),
queryFn: () => listParts({ serialNumber: brokenSerial, pageSize: 1 }),
enabled: open && brokenSerial.length >= 3,
staleTime: 5_000,
});
const existingBroken = brokenLookup.data?.data.find(
(p) => p.serialNumber === brokenSerial,
);
// Keep a form-level flag so the zod refine can skip model validation when the broken part
// is already in the catalog (server just reuses the existing PartModel).
useEffect(() => {
form.setValue('brokenExists', Boolean(existingBroken), { shouldValidate: true });
}, [existingBroken, form]);
const mutation = useMutation({
mutationFn: (v: Values) => {
const base = {
hostId: v.hostId,
brokenSerial: v.brokenSerial.trim(),
replacementSerial: v.replacementSerial.trim(),
};
// If the broken part is already catalogued, the server ignores model fields entirely.
if (existingBroken) return logRepair(base);
const modelPayload = v.brokenPartModelId
? { brokenPartModelId: v.brokenPartModelId }
: {
brokenMpn: v.brokenMpn?.trim(),
brokenManufacturerId: v.brokenManufacturerId,
};
return logRepair({ ...base, ...modelPayload });
},
onSuccess: (repair) => {
toast.success('Repair logged');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
onOpenChange(false);
onLogged?.(repair);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not log repair'),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Log a repair</DialogTitle>
<DialogDescription>
Record a physical part swap. The broken part goes into your custody until you drop it
in a bin from the My Custody page.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((v) => mutation.mutate(v))}
className="space-y-3"
>
<FormField
control={form.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<Select value={field.value} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select host" />
</SelectTrigger>
</FormControl>
<SelectContent>
{hosts.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.assetId} {h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="brokenSerial"
render={({ field }) => (
<FormItem>
<FormLabel>Broken serial</FormLabel>
<FormControl>
<Input autoFocus placeholder="SN-…" {...field} />
</FormControl>
{brokenSerial.length >= 3 && (
<FormDescription>
{brokenLookup.isFetching
? 'Looking up…'
: existingBroken
? `Found: ${existingBroken.partModel.mpn}`
: 'Will be ingested as a new part.'}
</FormDescription>
)}
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="replacementSerial"
render={({ field }) => (
<FormItem>
<FormLabel>Replacement serial</FormLabel>
<FormControl>
<Input placeholder="SN-…" {...field} />
</FormControl>
<FormDescription>Must be an existing SPARE.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
{!existingBroken && (
<>
<FormField
control={form.control}
name="brokenPartModelId"
render={() => (
<FormItem>
<FormLabel>Broken part model</FormLabel>
<PartModelCombobox
value={pickedModel}
newMpn={form.watch('brokenMpn') || null}
onPick={(m) => {
setPickedModel(m);
form.setValue('brokenPartModelId', m.id, { shouldValidate: true });
form.setValue('brokenMpn', '');
form.setValue('brokenManufacturerId', '');
}}
onCreateNew={(mpn) => {
setPickedModel(null);
form.setValue('brokenPartModelId', '');
form.setValue('brokenMpn', mpn, { shouldValidate: true });
}}
onClear={() => {
setPickedModel(null);
form.setValue('brokenPartModelId', '');
form.setValue('brokenMpn', '');
form.setValue('brokenManufacturerId', '');
}}
/>
<FormMessage />
</FormItem>
)}
/>
{!pickedModel && form.watch('brokenMpn') && (
<FormField
control={form.control}
name="brokenManufacturerId"
render={({ field }) => (
<FormItem>
<FormLabel>Manufacturer (for new model)</FormLabel>
<Select value={field.value ?? ''} onValueChange={field.onChange}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select" />
</SelectTrigger>
</FormControl>
<SelectContent>
{manufacturers.data?.data.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button type="submit" disabled={mutation.isPending}>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Log repair
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
@@ -1,313 +0,0 @@
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { z } from 'zod';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
} from '@vector/ui';
import { createRepair, updateRepair } from '../../lib/api/repairs.js';
import { listHosts } from '../../lib/api/hosts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import type { RepairJob } from '../../lib/api/types.js';
import { repairStatusOptions } from './RepairStatusBadge.js';
const NONE = '__none__';
const CreateSchema = z.object({
partId: z.string().uuid('Pick a valid part id'),
hostId: z.string().optional(),
notes: z.string().max(4096).optional(),
});
const EditSchema = z.object({
status: z.enum(['PENDING', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED']),
hostId: z.string().optional(),
notes: z.string().max(4096).optional(),
});
type CreateValues = z.infer<typeof CreateSchema>;
type EditValues = z.infer<typeof EditSchema>;
interface RepairFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
repair?: RepairJob | null;
defaultPartId?: string;
}
export function RepairFormDialog({
open,
onOpenChange,
repair,
defaultPartId,
}: RepairFormDialogProps) {
const editing = Boolean(repair);
const queryClient = useQueryClient();
const hostsQuery = useQuery({
queryKey: queryKeys.hosts.list({ pageSize: 100 }),
queryFn: () => listHosts({ pageSize: 100 }),
enabled: open,
});
const createForm = useForm<CreateValues>({
resolver: zodResolver(CreateSchema),
defaultValues: { partId: '', hostId: NONE, notes: '' },
});
const editForm = useForm<EditValues>({
resolver: zodResolver(EditSchema),
defaultValues: { status: 'PENDING', hostId: NONE, notes: '' },
});
useEffect(() => {
if (!open) return;
if (editing && repair) {
editForm.reset({
status: repair.status,
hostId: repair.hostId ?? NONE,
notes: repair.notes ?? '',
});
} else {
createForm.reset({ partId: defaultPartId ?? '', hostId: NONE, notes: '' });
}
}, [open, editing, repair, defaultPartId, createForm, editForm]);
const createMutation = useMutation({
mutationFn: async (values: CreateValues) =>
createRepair({
partId: values.partId,
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
notes: values.notes ? values.notes : null,
}),
onSuccess: () => {
toast.success('Repair opened');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
const editMutation = useMutation({
mutationFn: async (values: EditValues) =>
updateRepair(repair!.id, {
status: values.status,
hostId: values.hostId && values.hostId !== NONE ? values.hostId : null,
notes: values.notes ? values.notes : null,
}),
onSuccess: () => {
toast.success('Repair updated');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onOpenChange(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Save failed'),
});
const pending = createMutation.isPending || editMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{editing ? 'Edit repair' : 'Open repair'}</DialogTitle>
<DialogDescription>
{editing
? 'Advance status, re-assign the host, or update notes.'
: 'Open a repair job for a part. Status starts as PENDING.'}
</DialogDescription>
</DialogHeader>
{editing ? (
<Form {...editForm}>
<form
onSubmit={editForm.handleSubmit((v) => editMutation.mutate(v))}
className="space-y-3"
>
<FormField
control={editForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{hostsQuery.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
Save changes
</Button>
</DialogFooter>
</form>
</Form>
) : (
<Form {...createForm}>
<form
onSubmit={createForm.handleSubmit((v) => createMutation.mutate(v))}
className="space-y-3"
>
<FormField
control={createForm.control}
name="partId"
render={({ field }) => (
<FormItem>
<FormLabel>Part ID</FormLabel>
<FormControl>
<input
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm"
placeholder="Part UUID"
autoFocus
{...field}
/>
</FormControl>
<FormDescription>
Paste the part UUID to open a repair against it.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="hostId"
render={({ field }) => (
<FormItem>
<FormLabel>Host (optional)</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? NONE}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="None" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{hostsQuery.data?.data.map((h) => (
<SelectItem key={h.id} value={h.id}>
{h.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes</FormLabel>
<FormControl>
<Textarea rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
Open repair
</Button>
</DialogFooter>
</form>
</Form>
)}
</DialogContent>
</Dialog>
);
}
@@ -1,24 +0,0 @@
import type { RepairStatus } from '@vector/shared';
import { Badge } from '@vector/ui';
const LABELS: Record<RepairStatus, string> = {
PENDING: 'Pending',
IN_PROGRESS: 'In progress',
COMPLETED: 'Completed',
CANCELLED: 'Cancelled',
};
const VARIANTS: Record<RepairStatus, 'outline' | 'warning' | 'success' | 'secondary'> = {
PENDING: 'outline',
IN_PROGRESS: 'warning',
COMPLETED: 'success',
CANCELLED: 'secondary',
};
export const repairStatusOptions: { value: RepairStatus; label: string }[] = (
Object.keys(LABELS) as RepairStatus[]
).map((value) => ({ value, label: LABELS[value] }));
export function RepairStatusBadge({ status }: { status: RepairStatus }) {
return <Badge variant={VARIANTS[status]}>{LABELS[status]}</Badge>;
}
@@ -91,7 +91,7 @@ export function WebhookFormDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{editing ? 'Edit subscription' : 'New subscription'}</DialogTitle> <DialogTitle>{editing ? 'Edit subscription' : 'New subscription'}</DialogTitle>
<DialogDescription> <DialogDescription>
Vector signs each delivery with HMAC-SHA256. The signing secret is shown once on create. Each delivery is signed with HMAC-SHA256. The signing secret is shown once on create.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
+5
View File
@@ -9,6 +9,11 @@ export function listBins(
return getList<BinWithPath>('/bins', filters); return getList<BinWithPath>('/bins', filters);
} }
export async function getBin(id: string): Promise<BinWithPath> {
const res = await api.get<BinWithPath>(`/bins/${id}`);
return res.data;
}
export async function createBin(input: CreateBinRequest): Promise<BinWithPath> { export async function createBin(input: CreateBinRequest): Promise<BinWithPath> {
const res = await api.post<BinWithPath>('/bins', input); const res = await api.post<BinWithPath>('/bins', input);
return res.data; return res.data;
+15 -1
View File
@@ -1,4 +1,8 @@
import type { CreateCategoryRequest, UpdateCategoryRequest } from '@vector/shared'; import type {
CategoryInsights,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import { api } from './client.js'; import { api } from './client.js';
import { getList } from './paginated.js'; import { getList } from './paginated.js';
import type { Category } from './types.js'; import type { Category } from './types.js';
@@ -7,6 +11,16 @@ export function listCategories(filters: { page?: number; pageSize?: number } = {
return getList<Category>('/categories', filters); return getList<Category>('/categories', filters);
} }
export async function getCategory(id: string): Promise<Category> {
const res = await api.get<Category>(`/categories/${id}`);
return res.data;
}
export async function getCategoryInsights(id: string): Promise<CategoryInsights> {
const res = await api.get<CategoryInsights>(`/categories/${id}/insights`);
return res.data;
}
export async function createCategory(input: CreateCategoryRequest): Promise<Category> { export async function createCategory(input: CreateCategoryRequest): Promise<Category> {
const res = await api.post<Category>('/categories', input); const res = await api.post<Category>('/categories', input);
return res.data; return res.data;
+18
View File
@@ -0,0 +1,18 @@
import type { DropOffRequest } from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { Part } from './types.js';
export function listMyCustody(filters: { page?: number; pageSize?: number } = {}) {
return getList<Part>('/custody/mine', filters);
}
export async function dropOff(partId: string, input: DropOffRequest): Promise<Part> {
const res = await api.post<Part>(`/custody/${partId}/drop-off`, input);
return res.data;
}
export async function takeForRepair(partId: string): Promise<Part> {
const res = await api.post<Part>(`/custody/${partId}/take-for-repair`);
return res.data;
}
+17 -1
View File
@@ -1,12 +1,14 @@
import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared'; import type { CreateHostRequest, UpdateHostRequest } from '@vector/shared';
import { api } from './client.js'; import { api } from './client.js';
import { getList } from './paginated.js'; import { getList } from './paginated.js';
import type { Host } from './types.js'; import type { Host, HostTimelineEntry, Part } from './types.js';
export type HostListFilters = { export type HostListFilters = {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
q?: string; q?: string;
state?: string;
stack?: string;
}; };
export function listHosts(filters: HostListFilters = {}) { export function listHosts(filters: HostListFilters = {}) {
@@ -18,6 +20,15 @@ export async function getHost(id: string): Promise<Host> {
return res.data; return res.data;
} }
export async function listHostDeployedParts(id: string): Promise<Part[]> {
const res = await api.get<Part[]>(`/hosts/${id}/deployed-parts`);
return res.data;
}
export function listHostTimeline(id: string, filters: { page?: number; pageSize?: number } = {}) {
return getList<HostTimelineEntry>(`/hosts/${id}/timeline`, filters);
}
export async function createHost(input: CreateHostRequest): Promise<Host> { export async function createHost(input: CreateHostRequest): Promise<Host> {
const res = await api.post<Host>('/hosts', input); const res = await api.post<Host>('/hosts', input);
return res.data; return res.data;
@@ -31,3 +42,8 @@ export async function updateHost(id: string, input: UpdateHostRequest): Promise<
export async function deleteHost(id: string): Promise<void> { export async function deleteHost(id: string): Promise<void> {
await api.delete(`/hosts/${id}`); await api.delete(`/hosts/${id}`);
} }
export async function generateHostAssetId(): Promise<{ assetId: string }> {
const res = await api.get<{ assetId: string }>('/hosts/generate-asset-id');
return res.data;
}
+11
View File
@@ -1,5 +1,6 @@
import type { import type {
CreateManufacturerRequest, CreateManufacturerRequest,
ManufacturerInsights,
UpdateManufacturerRequest, UpdateManufacturerRequest,
} from '@vector/shared'; } from '@vector/shared';
import { api } from './client.js'; import { api } from './client.js';
@@ -15,6 +16,16 @@ export function listManufacturers(filters: ManufacturerListFilters = {}) {
return getList<Manufacturer>('/manufacturers', filters); return getList<Manufacturer>('/manufacturers', filters);
} }
export async function getManufacturer(id: string): Promise<Manufacturer> {
const res = await api.get<Manufacturer>(`/manufacturers/${id}`);
return res.data;
}
export async function getManufacturerInsights(id: string): Promise<ManufacturerInsights> {
const res = await api.get<ManufacturerInsights>(`/manufacturers/${id}/insights`);
return res.data;
}
export async function createManufacturer(input: CreateManufacturerRequest): Promise<Manufacturer> { export async function createManufacturer(input: CreateManufacturerRequest): Promise<Manufacturer> {
const res = await api.post<Manufacturer>('/manufacturers', input); const res = await api.post<Manufacturer>('/manufacturers', input);
return res.data; return res.data;
+48
View File
@@ -0,0 +1,48 @@
import type {
CreatePartModelRequest,
PartModelInsights,
UpdatePartModelRequest,
} from '@vector/shared';
import { api } from './client.js';
import { getList } from './paginated.js';
import type { PartModel } from './types.js';
export type PartModelListFilters = {
page?: number;
pageSize?: number;
manufacturerId?: string;
categoryId?: string;
q?: string;
eolBefore?: string;
};
export function listPartModels(filters: PartModelListFilters = {}) {
return getList<PartModel>('/part-models', filters);
}
export async function getPartModel(id: string): Promise<PartModel> {
const res = await api.get<PartModel>(`/part-models/${id}`);
return res.data;
}
export async function createPartModel(input: CreatePartModelRequest): Promise<PartModel> {
const res = await api.post<PartModel>('/part-models', input);
return res.data;
}
export async function updatePartModel(
id: string,
input: UpdatePartModelRequest,
): Promise<PartModel> {
const res = await api.patch<PartModel>(`/part-models/${id}`, input);
return res.data;
}
export async function deletePartModel(id: string): Promise<void> {
await api.delete(`/part-models/${id}`);
}
export async function getPartModelInsights(id: string): Promise<PartModelInsights> {
const res = await api.get<PartModelInsights>(`/part-models/${id}/insights`);
return res.data;
}
+3
View File
@@ -15,9 +15,12 @@ export type PartListFilters = {
state?: string; state?: string;
manufacturerId?: string; manufacturerId?: string;
categoryId?: string; categoryId?: string;
partModelId?: string;
binId?: string; binId?: string;
tagId?: string; tagId?: string;
eolOnly?: boolean; eolOnly?: boolean;
serialNumber?: string;
custodianId?: string;
}; };
export function listParts(filters: PartListFilters) { export function listParts(filters: PartListFilters) {
+9 -32
View File
@@ -1,49 +1,26 @@
import type { import type { LogRepairRequest } from '@vector/shared';
CreateRepairJobRequest,
RepairStatus,
UpdateRepairJobRequest,
} from '@vector/shared';
import { api } from './client.js'; import { api } from './client.js';
import { getList } from './paginated.js'; import { getList } from './paginated.js';
import type { RepairJob } from './types.js'; import type { Repair } from './types.js';
export type RepairListFilters = { export type RepairListFilters = {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
status?: RepairStatus;
partId?: string;
hostId?: string; hostId?: string;
assigneeId?: string; performedById?: string;
openOnly?: boolean; since?: string;
}; };
export function listRepairs(filters: RepairListFilters = {}) { export function listRepairs(filters: RepairListFilters = {}) {
return getList<RepairJob>('/repairs', filters); return getList<Repair>('/repairs', filters);
} }
export async function getRepair(id: string): Promise<RepairJob> { export async function getRepair(id: string): Promise<Repair> {
const res = await api.get<RepairJob>(`/repairs/${id}`); const res = await api.get<Repair>(`/repairs/${id}`);
return res.data; return res.data;
} }
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> { export async function logRepair(input: LogRepairRequest): Promise<Repair> {
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`); const res = await api.post<Repair>('/repairs', input);
return res.data; return res.data;
} }
export async function createRepair(input: CreateRepairJobRequest): Promise<RepairJob> {
const res = await api.post<RepairJob>('/repairs', input);
return res.data;
}
export async function updateRepair(
id: string,
input: UpdateRepairJobRequest,
): Promise<RepairJob> {
const res = await api.patch<RepairJob>(`/repairs/${id}`, input);
return res.data;
}
export async function deleteRepair(id: string): Promise<void> {
await api.delete(`/repairs/${id}`);
}
+74 -16
View File
@@ -1,4 +1,10 @@
import type { PartEventType, PartState, RepairStatus, Role } from '@vector/shared'; import type {
HostState,
HostStack,
PartEventType,
PartState,
Role,
} from '@vector/shared';
// Shapes mirror Prisma rows the API returns (dates serialized as ISO strings). // Shapes mirror Prisma rows the API returns (dates serialized as ISO strings).
// Keep these in sync with apps/api/src/services responses. // Keep these in sync with apps/api/src/services responses.
@@ -6,9 +12,24 @@ import type { PartEventType, PartState, RepairStatus, Role } from '@vector/share
export interface Manufacturer { export interface Manufacturer {
id: string; id: string;
name: string; name: string;
eolDate: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
_count?: { parts: number; partModels: number };
}
export interface PartModel {
id: string;
manufacturerId: string;
mpn: string;
categoryId: string | null;
eolDate: string | null;
destroyOnFail: boolean;
notes: string | null;
createdAt: string;
updatedAt: string;
manufacturer?: Manufacturer;
category?: Category | null;
_count?: { parts: number };
} }
export interface Site { export interface Site {
@@ -42,18 +63,21 @@ export interface BinWithPath extends Bin {
export interface Part { export interface Part {
id: string; id: string;
serialNumber: string; serialNumber: string;
mpn: string; partModelId: string;
manufacturerId: string; manufacturerId: string;
price: number | null; price: number | null;
state: PartState; state: PartState;
binId: string | null; binId: string | null;
categoryId: string | null; hostId: string | null;
replacementPartId: string | null; custodianId: string | null;
notes: string | null; notes: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
manufacturer: Manufacturer; manufacturer: Manufacturer;
partModel: PartModel;
bin: BinWithPath | null; bin: BinWithPath | null;
host: Host | null;
custodian: Pick<User, 'id' | 'username'> | null;
} }
export interface PartEvent { export interface PartEvent {
@@ -79,13 +103,46 @@ export interface User {
export interface Host { export interface Host {
id: string; id: string;
assetId: string;
name: string; name: string;
location: string | null; location: string | null;
notes: string | null; notes: string | null;
state: HostState;
stack: HostStack;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
export interface HostEvent {
id: string;
type: string;
field: string | null;
oldValue: string | null;
newValue: string | null;
createdAt: string;
user: { username: string } | null;
}
interface RepairTimelineSummary {
id: string;
performedAt: string;
brokenPart: { id: string; serialNumber: string; mpn: string };
replacement: { id: string; serialNumber: string; mpn: string };
performedBy: { username: string } | null;
}
interface PartTimelineRef {
id: string;
serialNumber: string;
mpn: string;
}
export type HostTimelineEntry =
| { type: 'HOST_EVENT'; at: string; hostEvent: HostEvent }
| { type: 'REPAIR'; at: string; repair: RepairTimelineSummary }
| { type: 'PART_ARRIVED'; at: string; part: PartTimelineRef; partEventId: string }
| { type: 'PART_DEPARTED'; at: string; part: PartTimelineRef; partEventId: string };
export interface Tag { export interface Tag {
id: string; id: string;
name: string; name: string;
@@ -97,24 +154,25 @@ export interface Tag {
export interface Category { export interface Category {
id: string; id: string;
name: string; name: string;
description?: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
_count?: { partModels: number };
} }
export interface RepairJob { export interface Repair {
id: string; id: string;
partId: string; hostId: string;
hostId: string | null; brokenPartId: string;
assigneeId: string | null; replacementPartId: string;
status: RepairStatus; performedById: string;
notes: string | null; performedAt: string;
openedAt: string;
closedAt: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
part: Part; host: Host;
host: Host | null; brokenPart: Part;
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null; replacement: Part;
performedBy: Pick<User, 'id' | 'username'>;
} }
export interface SavedView { export interface SavedView {
+19
View File
@@ -20,6 +20,7 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) => list: (filters?: Record<string, unknown>) =>
[...queryKeys.manufacturers.all, 'list', filters ?? {}] as const, [...queryKeys.manufacturers.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const, detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const,
insights: (id: string) => [...queryKeys.manufacturers.all, 'insights', id] as const,
}, },
sites: { sites: {
all: ['sites'] as const, all: ['sites'] as const,
@@ -36,6 +37,7 @@ export const queryKeys = {
all: ['bins'] as const, all: ['bins'] as const,
list: (filters?: Record<string, unknown>) => list: (filters?: Record<string, unknown>) =>
[...queryKeys.bins.all, 'list', filters ?? {}] as const, [...queryKeys.bins.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.bins.all, 'detail', id] as const,
}, },
users: { users: {
all: ['users'] as const, all: ['users'] as const,
@@ -47,6 +49,9 @@ export const queryKeys = {
list: (filters?: Record<string, unknown>) => list: (filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'list', filters ?? {}] as const, [...queryKeys.hosts.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const, detail: (id: string) => [...queryKeys.hosts.all, 'detail', id] as const,
deployedParts: (id: string) => [...queryKeys.hosts.all, 'deployed-parts', id] as const,
timeline: (id: string, filters?: Record<string, unknown>) =>
[...queryKeys.hosts.all, 'timeline', id, filters ?? {}] as const,
}, },
repairs: { repairs: {
all: ['repairs'] as const, all: ['repairs'] as const,
@@ -54,6 +59,18 @@ export const queryKeys = {
[...queryKeys.repairs.all, 'list', filters ?? {}] as const, [...queryKeys.repairs.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const, detail: (id: string) => [...queryKeys.repairs.all, 'detail', id] as const,
}, },
custody: {
all: ['custody'] as const,
mine: (filters?: Record<string, unknown>) =>
[...queryKeys.custody.all, 'mine', filters ?? {}] as const,
},
partModels: {
all: ['part-models'] as const,
list: (filters?: Record<string, unknown>) =>
[...queryKeys.partModels.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.partModels.all, 'detail', id] as const,
insights: (id: string) => [...queryKeys.partModels.all, 'insights', id] as const,
},
tags: { tags: {
all: ['tags'] as const, all: ['tags'] as const,
list: (filters?: Record<string, unknown>) => list: (filters?: Record<string, unknown>) =>
@@ -63,6 +80,8 @@ export const queryKeys = {
all: ['categories'] as const, all: ['categories'] as const,
list: (filters?: Record<string, unknown>) => list: (filters?: Record<string, unknown>) =>
[...queryKeys.categories.all, 'list', filters ?? {}] as const, [...queryKeys.categories.all, 'list', filters ?? {}] as const,
detail: (id: string) => [...queryKeys.categories.all, 'detail', id] as const,
insights: (id: string) => [...queryKeys.categories.all, 'insights', id] as const,
}, },
webhooks: { webhooks: {
all: ['webhooks'] as const, all: ['webhooks'] as const,
+305
View File
@@ -0,0 +1,305 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Edit, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import { deleteBin, getBin, updateBin } from '../lib/api/bins.js';
import { listParts } from '../lib/api/parts.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { NamePromptDialog } from '../components/NamePromptDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import type { Part } from '../lib/api/types.js';
function currency(dollars: number): string {
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
export default function BinDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [renameOpen, setRenameOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const binQuery = useQuery({
queryKey: queryKeys.bins.detail(id!),
queryFn: () => getBin(id!),
enabled: Boolean(id),
});
const bin = binQuery.data;
const locationQuery = bin
? `?site=${bin.room.siteId}&room=${bin.roomId}`
: '';
const renameMutation = useMutation({
mutationFn: (name: string) => updateBin(id!, { name }),
onSuccess: () => {
toast.success('Bin renamed');
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
setRenameOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed');
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteBin(id!),
onSuccess: () => {
toast.success('Bin deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
navigate(`/locations${locationQuery}`, { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
const partColumns = useMemo<ColumnDef<Part>[]>(
() => [
{
accessorKey: 'serialNumber',
header: 'Serial',
cell: ({ row }) => (
<Link to={`/parts/${row.original.id}`} className="font-mono text-xs hover:underline">
{row.original.serialNumber}
</Link>
),
},
{
id: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<Link
to={`/part-models/${row.original.partModelId}`}
className="font-mono text-xs hover:underline"
>
{row.original.partModel.mpn}
</Link>
),
},
{
id: 'manufacturer',
header: 'Manufacturer',
cell: ({ row }) => (
<Link
to={`/manufacturers/${row.original.manufacturerId}`}
className="text-xs hover:underline"
>
{row.original.manufacturer.name}
</Link>
),
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) =>
row.original.price != null ? (
<span className="tabular-nums">{currency(row.original.price)}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
],
[],
);
if (binQuery.isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (binQuery.isError || !bin) {
const msg =
binQuery.error instanceof ApiRequestError ? binQuery.error.body.message : 'Bin not found.';
return (
<Card>
<CardHeader>
<CardTitle>Bin unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/locations')}>
<ArrowLeft className="h-4 w-4" />
Back to locations
</Button>
</CardContent>
</Card>
);
}
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/locations${locationQuery}`)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold tracking-tight">{bin.name}</h1>
<p className="font-mono text-xs text-muted-foreground">{bin.fullPath}</p>
</div>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setRenameOpen(true)}>
<Edit className="h-3.5 w-3.5" />
Rename
</Button>
)}
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_1.6fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow label="Name" value={bin.name} />
<DetailRow
label="Site"
value={
<Link
to={`/locations?site=${bin.room.siteId}`}
className="text-foreground hover:underline"
>
{bin.room.site.name}
</Link>
}
/>
<DetailRow
label="Room"
value={
<Link
to={`/locations?site=${bin.room.siteId}&room=${bin.roomId}`}
className="text-foreground hover:underline"
>
{bin.room.name}
</Link>
}
/>
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(bin.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(bin.updatedAt).toLocaleString()} />
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Parts in this bin</CardTitle>
<CardDescription>Every unit currently assigned to {bin.name}.</CardDescription>
</CardHeader>
<CardContent>
<DataTable<Part, Record<string, never>>
columns={partColumns}
getRowId={(p) => p.id}
queryKey={(params) =>
queryKeys.parts.list({
binId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
})
}
queryFn={(params) =>
listParts({
binId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
})
}
searchPlaceholder="Search serial..."
emptyState={
<div className="py-8 text-center text-sm text-muted-foreground">
No parts in this bin yet.
</div>
}
/>
</CardContent>
</Card>
</div>
<NamePromptDialog
open={renameOpen}
onOpenChange={setRenameOpen}
title="Rename bin"
label="Bin name"
confirmLabel="Rename"
initialValue={bin.name}
pending={renameMutation.isPending}
onSubmit={(name) => renameMutation.mutate(name)}
/>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete bin?"
description={`Remove ${bin.name}. Parts in this bin become unassigned.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+517
View File
@@ -0,0 +1,517 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ArrowLeft, Edit, Trash2 } from 'lucide-react';
import {
Bar,
BarChart,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { toast } from 'sonner';
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import {
deleteCategory,
getCategory,
getCategoryInsights,
updateCategory,
} from '../lib/api/categories.js';
import { listPartModels } from '../lib/api/part-models.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { NamePromptDialog } from '../components/NamePromptDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { StatCard } from '../components/StatCard.js';
import type { PartModel } from '../lib/api/types.js';
const MANUFACTURER_COLORS = [
'hsl(217 91% 60%)',
'hsl(142 71% 45%)',
'hsl(262 83% 58%)',
'hsl(38 92% 50%)',
'hsl(340 82% 52%)',
'hsl(197 80% 50%)',
'hsl(0 84% 60%)',
'hsl(160 60% 40%)',
];
const BAR_COLOR = 'hsl(217 91% 60%)';
const FAILURE_COLOR = 'hsl(0 84% 60%)';
function currency(dollars: number): string {
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
export default function CategoryDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const categoryQuery = useQuery({
queryKey: queryKeys.categories.detail(id!),
queryFn: () => getCategory(id!),
enabled: Boolean(id),
});
const insightsQuery = useQuery({
queryKey: queryKeys.categories.insights(id!),
queryFn: () => getCategoryInsights(id!),
enabled: Boolean(id),
});
const renameMutation = useMutation({
mutationFn: (name: string) => updateCategory(id!, { name }),
onSuccess: () => {
toast.success('Category renamed');
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
setEditOpen(false);
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed');
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteCategory(id!),
onSuccess: () => {
toast.success('Category deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.categories.all });
navigate('/parts', { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
const modelColumns = useMemo<ColumnDef<PartModel>[]>(
() => [
{
accessorKey: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<Link
to={`/part-models/${row.original.id}`}
className="font-mono text-xs font-medium hover:underline"
>
{row.original.mpn}
</Link>
),
},
{
id: 'manufacturer',
header: 'Manufacturer',
cell: ({ row }) =>
row.original.manufacturer ? (
<Link
to={`/manufacturers/${row.original.manufacturer.id}`}
className="text-sm hover:underline"
>
{row.original.manufacturer.name}
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
const iso = row.original.eolDate;
if (!iso) return <span className="text-sm text-muted-foreground"></span>;
const pastEol = new Date(iso).getTime() <= Date.now();
return (
<div className="flex items-center gap-2">
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
</div>
);
},
},
{
id: 'deployedCount',
header: 'Parts',
cell: ({ row }) => (
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
),
},
],
[],
);
if (categoryQuery.isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (categoryQuery.isError || !categoryQuery.data) {
const msg =
categoryQuery.error instanceof ApiRequestError
? categoryQuery.error.body.message
: 'Category not found.';
return (
<Card>
<CardHeader>
<CardTitle>Category unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/parts')}>
<ArrowLeft className="h-4 w-4" />
Back to parts
</Button>
</CardContent>
</Card>
);
}
const category = categoryQuery.data;
const insights = insightsQuery.data;
const failureRate =
insights && insights.totalParts > 0
? Math.round((insights.failures.repairs / insights.totalParts) * 100)
: null;
const topModelsData =
insights?.topModelsByUnits.map((m) => ({
name: m.mpn,
count: m.count,
})) ?? [];
const failuresData =
insights?.failuresByModel.map((m) => ({
name: m.mpn,
repairs: m.repairs,
})) ?? [];
const manufacturerData =
insights?.byManufacturer.map((m) => ({
name: m.manufacturerName,
count: m.count,
})) ?? [];
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(-1)}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold tracking-tight">{category.name}</h1>
<p className="text-xs text-muted-foreground">
{insights
? `${insights.totalPartModels} MPNs · ${insights.totalParts} parts`
: '—'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Edit className="h-3.5 w-3.5" />
Edit
</Button>
)}
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{insightsQuery.isPending || !insights ? (
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)
) : (
<>
<StatCard label="MPNs" value={insights.totalPartModels.toLocaleString()} />
<StatCard label="Parts" value={insights.totalParts.toLocaleString()} />
<StatCard label="Total spent" value={currency(insights.priceStats.total)} />
<StatCard
label="Avg price"
value={
insights.priceStats.countWithPrice > 0
? currency(insights.priceStats.average)
: '—'
}
sub={
insights.priceStats.countWithPrice > 0
? `${insights.priceStats.countWithPrice} priced`
: 'No priced parts'
}
/>
<StatCard
label="Failures"
value={insights.failures.repairs.toLocaleString()}
sub={
failureRate != null
? `${failureRate}% of parts · ${insights.failures.distinctFailedParts} distinct`
: undefined
}
/>
</>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Top MPNs by units</CardTitle>
<CardDescription>
Where this category's inventory is concentrated.
</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : topModelsData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No parts in this category yet.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={topModelsData} layout="vertical" margin={{ left: 16 }}>
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
<Bar dataKey="count" fill={BAR_COLOR} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Manufacturer mix</CardTitle>
<CardDescription>Which vendors supply this category.</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : manufacturerData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No MPNs yet.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={manufacturerData}
dataKey="count"
nameKey="name"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
>
{manufacturerData.map((_m, i) => (
<Cell key={i} fill={MANUFACTURER_COLORS[i % MANUFACTURER_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Failures by MPN</CardTitle>
<CardDescription>
Which models in this category have failed most.
</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : failuresData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No failures recorded in this category.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={failuresData} layout="vertical" margin={{ left: 16 }}>
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
<Bar dataKey="repairs" fill={FAILURE_COLOR} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow label="Name" value={category.name} />
{category.description && (
<DetailRow label="Description" value={category.description} />
)}
<DetailRow
label="# MPNs"
value={
<span className="tabular-nums">{category._count?.partModels ?? ''}</span>
}
/>
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(category.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(category.updatedAt).toLocaleString()} />
</dl>
</CardContent>
</Card>
</div>
{insights && insights.pastEolModels.length > 0 && (
<Card className="border-warning/50 bg-warning/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" />
Past-EOL MPNs with deployed parts
</CardTitle>
<CardDescription>
These models have passed their end-of-life date plan replacements.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pb-5">
{insights.pastEolModels.map((m) => (
<div
key={m.partModelId}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-mono text-xs font-medium">{m.mpn}</div>
{m.eolDate && (
<div className="text-xs text-muted-foreground">
EOL {new Date(m.eolDate).toLocaleDateString()}
</div>
)}
</div>
<div className="flex items-center gap-3">
<span className="tabular-nums text-muted-foreground">
{m.deployedCount} deployed
</span>
<Button asChild variant="outline" size="sm">
<Link to={`/part-models/${m.partModelId}`}>View</Link>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Part models</CardTitle>
<CardDescription>Every MPN in this category.</CardDescription>
</CardHeader>
<CardContent>
<DataTable<PartModel, Record<string, never>>
columns={modelColumns}
getRowId={(m) => m.id}
queryKey={(params) =>
queryKeys.partModels.list({
categoryId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
})
}
queryFn={(params) =>
listPartModels({
categoryId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
})
}
searchPlaceholder="Search MPN..."
emptyState={
<div className="py-8 text-center text-sm text-muted-foreground">
No MPNs in this category yet.
</div>
}
/>
</CardContent>
</Card>
<NamePromptDialog
open={editOpen}
onOpenChange={setEditOpen}
title="Rename category"
label="Name"
initialValue={category.name}
confirmLabel="Save"
pending={renameMutation.isPending}
onSubmit={(name) => renameMutation.mutate(name)}
/>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete category?"
description={`Remove ${category.name}. Fails if any part models reference it.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+203 -31
View File
@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { AlertTriangle, Download, Package, Wrench } from 'lucide-react'; import { AlertTriangle, CalendarClock, Download, Package } from 'lucide-react';
import { import {
Bar, Bar,
BarChart, BarChart,
Cell, Cell,
Legend, Legend,
Line,
LineChart,
Pie, Pie,
PieChart, PieChart,
ResponsiveContainer, ResponsiveContainer,
@@ -33,6 +35,9 @@ const STATE_LABELS: Record<PartState, string> = {
DEPLOYED: 'Deployed', DEPLOYED: 'Deployed',
BROKEN: 'Broken', BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction', PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
PENDING_REPAIR: 'Held for repair',
}; };
const STATE_COLORS: Record<PartState, string> = { const STATE_COLORS: Record<PartState, string> = {
@@ -40,10 +45,38 @@ const STATE_COLORS: Record<PartState, string> = {
DEPLOYED: 'hsl(142 71% 45%)', DEPLOYED: 'hsl(142 71% 45%)',
BROKEN: 'hsl(0 84% 60%)', BROKEN: 'hsl(0 84% 60%)',
PENDING_DESTRUCTION: 'hsl(38 92% 50%)', PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
PENDING_REPAIR: 'hsl(197 80% 50%)',
}; };
function currency(cents: number): string { const LINE_BLUE = 'hsl(217 91% 60%)';
return (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' });
const TOOLTIP_CURSOR_FILL = 'color-mix(in oklch, var(--color-foreground) 8%, transparent)';
const TOOLTIP_CURSOR_STROKE = 'color-mix(in oklch, var(--color-foreground) 24%, transparent)';
const TOOLTIP_CONTENT_STYLE: React.CSSProperties = {
backgroundColor: 'var(--color-popover)',
border: '1px solid var(--color-border)',
borderRadius: '0.375rem',
color: 'var(--color-popover-foreground)',
fontSize: '0.8rem',
boxShadow: '0 4px 16px oklch(0 0 0 / 0.4)',
};
const TOOLTIP_ITEM_STYLE: React.CSSProperties = {
color: 'var(--color-popover-foreground)',
};
const TOOLTIP_LABEL_STYLE: React.CSSProperties = {
color: 'var(--color-muted-foreground)',
marginBottom: '0.125rem',
};
function currency(dollars: number): string {
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
}
function shortDate(iso: string): string {
const d = new Date(`${iso}T00:00:00Z`);
return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`;
} }
export default function Dashboard() { export default function Dashboard() {
@@ -82,18 +115,12 @@ export default function Dashboard() {
{data && ( {data && (
<> <>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<KpiCard <KpiCard
icon={<Package className="h-4 w-4" />} icon={<Package className="h-4 w-4" />}
label="Total parts" label="Total parts"
value={data.totalParts.toLocaleString()} value={data.totalParts.toLocaleString()}
/> />
<KpiCard
icon={<Wrench className="h-4 w-4" />}
label="Open repairs"
value={data.openRepairs.toLocaleString()}
href="/repairs"
/>
<KpiCard <KpiCard
label="Deployed value" label="Deployed value"
value={currency( value={currency(
@@ -102,6 +129,10 @@ export default function Dashboard() {
.reduce((sum, s) => sum + s.totalPrice, 0), .reduce((sum, s) => sum + s.totalPrice, 0),
)} )}
/> />
<KpiCard
label="Total spent"
value={currency(data.byState.reduce((sum, s) => sum + s.totalPrice, 0))}
/>
<KpiCard <KpiCard
label="Past-EOL deployments" label="Past-EOL deployments"
value={data.deployedPastEol value={data.deployedPastEol
@@ -109,9 +140,48 @@ export default function Dashboard() {
.toLocaleString()} .toLocaleString()}
tone={data.deployedPastEol.length > 0 ? 'warn' : undefined} tone={data.deployedPastEol.length > 0 ? 'warn' : undefined}
/> />
<KpiCard
icon={<CalendarClock className="h-4 w-4" />}
label="Upcoming EOL (180d)"
value={data.upcomingEol
.reduce((sum, m) => sum + m.deployedCount, 0)
.toLocaleString()}
tone={data.upcomingEol.length > 0 ? 'caution' : undefined}
/>
</div> </div>
{data.deployedPastEol.length > 0 && <PastEolBanner rows={data.deployedPastEol} />} {data.operations && (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-2">
<KpiCard
label="Repairs (7d)"
value={data.operations.repairs7d.toLocaleString()}
href="/repairs"
/>
<KpiCard
label="Repairs (30d)"
value={data.operations.repairs30d.toLocaleString()}
href="/repairs"
/>
</div>
)}
{data.deployedPastEol.length > 0 && (
<EolBanner
tone="warn"
title="Deployed past part-model EOL"
description="These MPNs have passed their end-of-life date — plan replacements for any parts still in production."
rows={data.deployedPastEol}
/>
)}
{data.upcomingEol.length > 0 && (
<EolBanner
tone="caution"
title="EOL within 180 days"
description="MPNs with a near-term EOL and deployed parts. Get procurement ahead of the wave."
rows={data.upcomingEol}
/>
)}
<div className="grid gap-4 lg:grid-cols-2"> <div className="grid gap-4 lg:grid-cols-2">
<Card> <Card>
@@ -130,7 +200,12 @@ export default function Dashboard() {
> >
<XAxis dataKey="name" tick={{ fontSize: 12 }} /> <XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} /> <YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} /> <Tooltip
cursor={{ fill: TOOLTIP_CURSOR_FILL }}
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}> <Bar dataKey="count" radius={[4, 4, 0, 0]}>
{data.byState.map((s) => ( {data.byState.map((s) => (
<Cell key={s.state} fill={STATE_COLORS[s.state]} /> <Cell key={s.state} fill={STATE_COLORS[s.state]} />
@@ -155,7 +230,7 @@ export default function Dashboard() {
.map((s) => ({ .map((s) => ({
name: STATE_LABELS[s.state], name: STATE_LABELS[s.state],
state: s.state, state: s.state,
value: s.totalPrice / 100, value: s.totalPrice,
}))} }))}
dataKey="value" dataKey="value"
nameKey="name" nameKey="name"
@@ -173,6 +248,9 @@ export default function Dashboard() {
formatter={(v: number) => formatter={(v: number) =>
v.toLocaleString(undefined, { style: 'currency', currency: 'USD' }) v.toLocaleString(undefined, { style: 'currency', currency: 'USD' })
} }
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/> />
<Legend /> <Legend />
</PieChart> </PieChart>
@@ -192,7 +270,12 @@ export default function Dashboard() {
<BarChart data={data.ageBuckets}> <BarChart data={data.ageBuckets}>
<XAxis dataKey="label" tick={{ fontSize: 12 }} /> <XAxis dataKey="label" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} /> <YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} /> <Tooltip
cursor={{ fill: TOOLTIP_CURSOR_FILL }}
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/>
<Bar dataKey="count" fill="hsl(217 91% 60%)" radius={[4, 4, 0, 0]} /> <Bar dataKey="count" fill="hsl(217 91% 60%)" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -219,7 +302,12 @@ export default function Dashboard() {
tick={{ fontSize: 11 }} tick={{ fontSize: 11 }}
width={180} width={180}
/> />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} /> <Tooltip
cursor={{ fill: TOOLTIP_CURSOR_FILL }}
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/>
<Bar dataKey="count" fill="hsl(262 83% 58%)" radius={[0, 4, 4, 0]} /> <Bar dataKey="count" fill="hsl(262 83% 58%)" radius={[0, 4, 4, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -227,6 +315,66 @@ export default function Dashboard() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{data.operations && (
<Card>
<CardHeader>
<CardTitle>Repairs (last 30 days)</CardTitle>
<CardDescription>Daily count of logged part swaps.</CardDescription>
</CardHeader>
<CardContent className="h-72">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data.operations.repairsTrend30d.map((d) => ({
label: shortDate(d.date),
count: d.count,
}))}
>
<XAxis dataKey="label" tick={{ fontSize: 11 }} interval={3} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip
cursor={{ stroke: TOOLTIP_CURSOR_STROKE }}
contentStyle={TOOLTIP_CONTENT_STYLE}
itemStyle={TOOLTIP_ITEM_STYLE}
labelStyle={TOOLTIP_LABEL_STYLE}
/>
<Line
type="monotone"
dataKey="count"
stroke={LINE_BLUE}
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
)}
{data.operations && data.operations.custodyBacklog.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Parts sitting in custody</CardTitle>
<CardDescription>
Users holding parts that haven't been dropped off or returned.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pb-5">
{data.operations.custodyBacklog.map((u) => (
<Link
key={u.userId}
to={`/parts?custodianId=${u.userId}`}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm transition-colors hover:bg-accent/40"
>
<span className="truncate font-medium">{u.username}</span>
<span className="tabular-nums text-muted-foreground">
{u.count} pending
</span>
</Link>
))}
</CardContent>
</Card>
)}
</> </>
)} )}
</div> </div>
@@ -243,11 +391,17 @@ function KpiCard({
icon?: React.ReactNode; icon?: React.ReactNode;
label: string; label: string;
value: string; value: string;
tone?: 'warn'; tone?: 'warn' | 'caution';
href?: string; href?: string;
}) { }) {
const toneClass =
tone === 'warn'
? 'border-warning/50'
: tone === 'caution'
? 'border-warning/30'
: undefined;
const body = ( const body = (
<Card className={tone === 'warn' ? 'border-warning/50' : undefined}> <Card className={toneClass}>
<CardContent className="flex items-center gap-3 p-4"> <CardContent className="flex items-center gap-3 p-4">
{icon && ( {icon && (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent text-accent-foreground"> <div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-accent text-accent-foreground">
@@ -273,31 +427,49 @@ function KpiCard({
return body; return body;
} }
function PastEolBanner({ function EolBanner({
tone,
title,
description,
rows, rows,
}: { }: {
rows: { manufacturerId: string; name: string; eolDate: string | null; deployedCount: number }[]; tone: 'warn' | 'caution';
title: string;
description: string;
rows: {
partModelId: string;
mpn: string;
manufacturerId: string;
manufacturerName: string;
eolDate: string;
deployedCount: number;
}[];
}) { }) {
const classes =
tone === 'warn'
? 'border-warning/50 bg-warning/5'
: 'border-warning/30 bg-warning/[0.03]';
return ( return (
<Card className="border-warning/50 bg-warning/5"> <Card className={classes}>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" /> <AlertTriangle
Deployed past manufacturer EOL className={`h-4 w-4 ${tone === 'warn' ? 'text-warning' : 'text-muted-foreground'}`}
/>
{title}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>{description}</CardDescription>
These manufacturers have passed their end-of-life date plan replacements for any parts
still in production.
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 pb-5"> <CardContent className="space-y-2 pb-5">
{rows.map((row) => ( {rows.map((row) => (
<div <div
key={row.manufacturerId} key={row.partModelId}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm" className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate font-medium">{row.name}</div> <div className="truncate font-medium">
{row.manufacturerName} · <span className="font-mono">{row.mpn}</span>
</div>
{row.eolDate && ( {row.eolDate && (
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
EOL {new Date(row.eolDate).toLocaleDateString()} EOL {new Date(row.eolDate).toLocaleDateString()}
@@ -309,7 +481,7 @@ function PastEolBanner({
{row.deployedCount} deployed {row.deployedCount} deployed
</span> </span>
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link to={`/parts?manufacturerId=${row.manufacturerId}&state=DEPLOYED`}>View</Link> <Link to={`/part-models/${row.partModelId}`}>View</Link>
</Button> </Button>
</div> </div>
</div> </div>
@@ -322,8 +494,8 @@ function PastEolBanner({
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-20" /> <Skeleton key={i} className="h-20" />
))} ))}
</div> </div>
+245
View File
@@ -0,0 +1,245 @@
import { useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Edit, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import { deleteHost, getHost, listHostDeployedParts } from '../lib/api/hosts.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
import { HostStateBadge, HostStackBadge } from '../components/hosts/HostStateBadge.js';
import { HostTimeline } from '../components/hosts/HostTimeline.js';
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
export default function HostDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const { data: host, isPending, isError, error } = useQuery({
queryKey: queryKeys.hosts.detail(id!),
queryFn: () => getHost(id!),
enabled: Boolean(id),
});
const deployedPartsQuery = useQuery({
queryKey: queryKeys.hosts.deployedParts(id!),
queryFn: () => listHostDeployedParts(id!),
enabled: Boolean(id),
});
const deleteMutation = useMutation({
mutationFn: () => deleteHost(id!),
onSuccess: () => {
toast.success('Host deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.hosts.all });
navigate('/hosts', { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
if (isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (isError || !host) {
const msg = error instanceof ApiRequestError ? error.body.message : 'Host not found.';
return (
<Card>
<CardHeader>
<CardTitle>Host unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/hosts')}>
<ArrowLeft className="h-4 w-4" />
Back to hosts
</Button>
</CardContent>
</Card>
);
}
const deployedParts = deployedPartsQuery.data ?? [];
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/hosts')} aria-label="Back">
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold tracking-tight">{host.name}</h1>
<p className="text-xs text-muted-foreground">
<span className="font-mono">{host.assetId}</span>
{host.location ? ` · ${host.location}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<HostStateBadge state={host.state} />
<HostStackBadge stack={host.stack} />
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Edit className="h-3.5 w-3.5" />
Edit
</Button>
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-[1fr_1.2fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow
label="Asset ID"
value={<span className="font-mono text-xs">{host.assetId}</span>}
/>
<DetailRow label="Name" value={host.name} />
<DetailRow label="State" value={host.state} />
<DetailRow label="Stack" value={host.stack} />
<DetailRow
label="Location"
value={
host.location ?? <span className="text-muted-foreground italic"></span>
}
/>
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(host.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(host.updatedAt).toLocaleString()} />
</dl>
{host.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{host.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">History</CardTitle>
<CardDescription>
FMs, repairs, part swaps, and host field changes.
</CardDescription>
</CardHeader>
<CardContent>
<HostTimeline hostId={host.id} />
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Deployed parts</CardTitle>
<CardDescription>Parts currently installed on this host.</CardDescription>
</CardHeader>
<CardContent>
{deployedPartsQuery.isPending ? (
<Skeleton className="h-16 w-full" />
) : deployedParts.length === 0 ? (
<p className="text-sm text-muted-foreground">No parts deployed here.</p>
) : (
<div className="overflow-hidden rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/40 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 text-left font-medium">Serial</th>
<th className="px-3 py-2 text-left font-medium">MPN</th>
<th className="px-3 py-2 text-left font-medium">Manufacturer</th>
<th className="px-3 py-2 text-left font-medium">State</th>
</tr>
</thead>
<tbody>
{deployedParts.map((p) => (
<tr key={p.id} className="border-t">
<td className="px-3 py-2">
<Link
to={`/parts/${p.id}`}
className="font-mono text-xs hover:underline"
>
{p.serialNumber}
</Link>
</td>
<td className="px-3 py-2 font-mono text-xs">{p.partModel.mpn}</td>
<td className="px-3 py-2 text-muted-foreground">
{p.manufacturer.name}
</td>
<td className="px-3 py-2">
<PartStateBadge state={p.state} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<HostFormDialog open={editOpen} onOpenChange={setEditOpen} host={host} />
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete host?"
description={`Permanently remove ${host.name}. Fails if any repair jobs reference it.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+43 -7
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Edit, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react'; import { Edit, Eye, MoreHorizontal, Plus, Server, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Button, Button,
@@ -14,6 +15,7 @@ import {
import { PageHeader } from '../components/layout/PageHeader.js'; import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js'; import { DataTable } from '../components/data-table/DataTable.js';
import { HostFormDialog } from '../components/hosts/HostFormDialog.js'; import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deleteHost, listHosts } from '../lib/api/hosts.js'; import { deleteHost, listHosts } from '../lib/api/hosts.js';
import { ApiRequestError } from '../lib/api/client.js'; import { ApiRequestError } from '../lib/api/client.js';
@@ -25,6 +27,7 @@ export default function Hosts() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === 'ADMIN';
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<Host | null>(null); const [editing, setEditing] = useState<Host | null>(null);
@@ -43,10 +46,36 @@ export default function Hosts() {
const columns = useMemo<ColumnDef<Host>[]>( const columns = useMemo<ColumnDef<Host>[]>(
() => [ () => [
{
accessorKey: 'assetId',
header: 'Asset ID',
cell: ({ row }) => (
<Link
to={`/hosts/${row.original.id}`}
className="font-mono text-xs font-medium hover:underline"
>
{row.original.assetId}
</Link>
),
},
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Name', header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => (
<Link to={`/hosts/${row.original.id}`} className="font-medium hover:underline">
{row.original.name}
</Link>
),
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => <HostStateBadge state={row.original.state} />,
},
{
accessorKey: 'stack',
header: 'Stack',
cell: ({ row }) => <HostStackBadge stack={row.original.stack} />,
}, },
{ {
accessorKey: 'location', accessorKey: 'location',
@@ -70,8 +99,7 @@ export default function Hosts() {
id: 'actions', id: 'actions',
header: () => <span className="sr-only">Actions</span>, header: () => <span className="sr-only">Actions</span>,
size: 40, size: 40,
cell: ({ row }) => cell: ({ row }) => (
isAdmin ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button variant="ghost" size="icon" className="h-7 w-7">
@@ -79,6 +107,12 @@ export default function Hosts() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36"> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => navigate(`/hosts/${row.original.id}`)}>
<Eye className="h-3.5 w-3.5" />
View
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem onSelect={() => setEditing(row.original)}> <DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
Edit Edit
@@ -91,19 +125,21 @@ export default function Hosts() {
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null, ),
}, },
], ],
[isAdmin], [isAdmin, navigate],
); );
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="Hosts" title="Hosts"
description="Machines and racks where parts are installed for repair work." description="Machines and racks where deployed parts are installed."
actions={ actions={
isAdmin && ( isAdmin && (
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
+86 -10
View File
@@ -1,8 +1,13 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { parseAsString, useQueryState } from 'nuqs'; import { parseAsString, useQueryState } from 'nuqs';
import { ChevronRight, MapPin } from 'lucide-react';
import { PageHeader } from '../components/layout/PageHeader.js'; import { PageHeader } from '../components/layout/PageHeader.js';
import { SiteList } from '../components/locations/SiteList.js'; import { SiteRoomTree } from '../components/locations/SiteRoomTree.js';
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
import { BinGrid } from '../components/locations/BinGrid.js'; import { BinGrid } from '../components/locations/BinGrid.js';
import { listSites } from '../lib/api/sites.js';
import { listRooms } from '../lib/api/rooms.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js'; import { useAuth } from '../contexts/AuthContext.js';
export default function Locations() { export default function Locations() {
@@ -20,23 +25,94 @@ export default function Locations() {
void setRoomId(id || null); void setRoomId(id || null);
}; };
const sites = useQuery({
queryKey: queryKeys.sites.list({ pageSize: 100 }),
queryFn: () => listSites({ pageSize: 100 }),
});
const rooms = useQuery({
queryKey: queryKeys.rooms.list({ siteId, pageSize: 100 }),
queryFn: () => listRooms({ siteId: siteId!, pageSize: 100 }),
enabled: Boolean(siteId),
});
const siteName = useMemo(
() => sites.data?.data.find((s) => s.id === siteId)?.name,
[sites.data, siteId],
);
const roomName = useMemo(
() => rooms.data?.data.find((r) => r.id === roomId)?.name,
[rooms.data, roomId],
);
return ( return (
<div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4"> <div className="flex flex-col gap-4">
<PageHeader <PageHeader
title="Locations" title="Locations"
description="Sites → Rooms → Bins. Select a site to drill in." description="Sites → Rooms → Bins. Pick a room to see its bins."
/> />
<div className="grid min-h-0 flex-1 grid-cols-[16rem_16rem_1fr] overflow-hidden rounded-lg border border-border bg-card"> <div className="grid grid-cols-[18rem_1fr] overflow-hidden rounded-lg border border-border bg-card">
<div className="border-r border-border"> <div className="border-r border-border">
<SiteList selectedId={siteId} onSelect={handleSite} canEdit={canEdit} /> <SiteRoomTree
siteId={siteId}
roomId={roomId}
onSelectSite={handleSite}
onSelectRoom={handleRoom}
canEdit={canEdit}
/>
</div> </div>
<div className="border-r border-border"> <div className="flex flex-col">
<RoomDrawer siteId={siteId} selectedId={roomId} onSelect={handleRoom} canEdit={canEdit} /> <Breadcrumb siteName={siteName} roomName={roomName} />
</div> <div className="flex-1">
<div> {roomId ? (
<BinGrid roomId={roomId} canEdit={canEdit} /> <BinGrid roomId={roomId} canEdit={canEdit} />
) : (
<EmptyPane siteSelected={Boolean(siteId)} />
)}
</div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
function Breadcrumb({
siteName,
roomName,
}: {
siteName: string | undefined;
roomName: string | undefined;
}) {
return (
<div className="flex items-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground">
{siteName ? (
<>
<span className="text-foreground">{siteName}</span>
{roomName && (
<>
<ChevronRight className="h-3.5 w-3.5 opacity-60" />
<span className="text-foreground">{roomName}</span>
</>
)}
</>
) : (
<span>Select a site to begin.</span>
)}
</div>
);
}
function EmptyPane({ siteSelected }: { siteSelected: boolean }) {
return (
<div className="flex h-full items-center justify-center p-8">
<div className="flex max-w-sm flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/30 px-8 py-10 text-center">
<MapPin className="h-6 w-6 text-muted-foreground" />
<p className="text-sm font-medium">
{siteSelected ? 'Pick a room' : 'Pick a site and room'}
</p>
<p className="text-xs text-muted-foreground">
Bins live inside rooms. Expand a site in the tree and choose a room to manage its bins.
</p>
</div>
</div>
);
}
+495
View File
@@ -0,0 +1,495 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, ArrowLeft, Edit, Trash2 } from 'lucide-react';
import {
Bar,
BarChart,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { toast } from 'sonner';
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import {
deleteManufacturer,
getManufacturer,
getManufacturerInsights,
} from '../lib/api/manufacturers.js';
import { listPartModels } from '../lib/api/part-models.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { ManufacturerFormDialog } from '../components/manufacturers/ManufacturerFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { StatCard } from '../components/StatCard.js';
import type { PartModel } from '../lib/api/types.js';
const CATEGORY_COLORS = [
'hsl(217 91% 60%)',
'hsl(142 71% 45%)',
'hsl(262 83% 58%)',
'hsl(38 92% 50%)',
'hsl(340 82% 52%)',
'hsl(197 80% 50%)',
'hsl(0 84% 60%)',
'hsl(160 60% 40%)',
];
const BAR_COLOR = 'hsl(217 91% 60%)';
const FAILURE_COLOR = 'hsl(0 84% 60%)';
function currency(dollars: number): string {
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
export default function ManufacturerDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const mfrQuery = useQuery({
queryKey: queryKeys.manufacturers.detail(id!),
queryFn: () => getManufacturer(id!),
enabled: Boolean(id),
});
const insightsQuery = useQuery({
queryKey: queryKeys.manufacturers.insights(id!),
queryFn: () => getManufacturerInsights(id!),
enabled: Boolean(id),
});
const deleteMutation = useMutation({
mutationFn: () => deleteManufacturer(id!),
onSuccess: () => {
toast.success('Manufacturer deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.manufacturers.all });
navigate('/manufacturers', { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
const modelColumns = useMemo<ColumnDef<PartModel>[]>(
() => [
{
accessorKey: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<Link
to={`/part-models/${row.original.id}`}
className="font-mono text-xs font-medium hover:underline"
>
{row.original.mpn}
</Link>
),
},
{
id: 'category',
header: 'Category',
cell: ({ row }) =>
row.original.category ? (
<Link to={`/categories/${row.original.category.id}`}>
<Badge variant="outline" className="hover:bg-accent">
{row.original.category.name}
</Badge>
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
const iso = row.original.eolDate;
if (!iso) return <span className="text-sm text-muted-foreground"></span>;
const pastEol = new Date(iso).getTime() <= Date.now();
return (
<div className="flex items-center gap-2">
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
</div>
);
},
},
{
id: 'deployedCount',
header: 'Parts',
cell: ({ row }) => (
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
),
},
],
[],
);
if (mfrQuery.isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (mfrQuery.isError || !mfrQuery.data) {
const msg =
mfrQuery.error instanceof ApiRequestError
? mfrQuery.error.body.message
: 'Manufacturer not found.';
return (
<Card>
<CardHeader>
<CardTitle>Manufacturer unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/manufacturers')}>
<ArrowLeft className="h-4 w-4" />
Back to manufacturers
</Button>
</CardContent>
</Card>
);
}
const mfr = mfrQuery.data;
const insights = insightsQuery.data;
const failureRate =
insights && insights.totalParts > 0
? Math.round((insights.failures.repairs / insights.totalParts) * 100)
: null;
const topModelsData =
insights?.topModelsByUnits.map((m) => ({
name: m.mpn,
count: m.count,
})) ?? [];
const failuresData =
insights?.failuresByModel.map((m) => ({
name: m.mpn,
repairs: m.repairs,
})) ?? [];
const categoryData =
insights?.byCategory.map((c) => ({
name: c.categoryName,
count: c.count,
})) ?? [];
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/manufacturers')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="text-lg font-semibold tracking-tight">{mfr.name}</h1>
<p className="text-xs text-muted-foreground">
{insights
? `${insights.totalPartModels} MPNs · ${insights.totalParts} parts`
: '—'}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Edit className="h-3.5 w-3.5" />
Edit
</Button>
)}
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
{insightsQuery.isPending || !insights ? (
Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)
) : (
<>
<StatCard label="MPNs" value={insights.totalPartModels.toLocaleString()} />
<StatCard label="Parts" value={insights.totalParts.toLocaleString()} />
<StatCard label="Total spent" value={currency(insights.priceStats.total)} />
<StatCard
label="Avg price"
value={
insights.priceStats.countWithPrice > 0
? currency(insights.priceStats.average)
: '—'
}
sub={
insights.priceStats.countWithPrice > 0
? `${insights.priceStats.countWithPrice} priced`
: 'No priced parts'
}
/>
<StatCard
label="Failures"
value={insights.failures.repairs.toLocaleString()}
sub={
failureRate != null
? `${failureRate}% of parts · ${insights.failures.distinctFailedParts} distinct`
: undefined
}
/>
</>
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Top MPNs by units</CardTitle>
<CardDescription>Where this vendor's inventory is concentrated.</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : topModelsData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No parts from this manufacturer yet.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={topModelsData} layout="vertical" margin={{ left: 16 }}>
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
<Bar dataKey="count" fill={BAR_COLOR} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Category mix</CardTitle>
<CardDescription>What kinds of parts this vendor supplies.</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : categoryData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No MPNs yet.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
dataKey="count"
nameKey="name"
innerRadius={55}
outerRadius={90}
paddingAngle={2}
>
{categoryData.map((_c, i) => (
<Cell key={i} fill={CATEGORY_COLORS[i % CATEGORY_COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Failures by MPN</CardTitle>
<CardDescription>
Which of this vendor's models have failed most the "stop buying the X" signal.
</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : failuresData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No failures recorded for this vendor.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={failuresData} layout="vertical" margin={{ left: 16 }}>
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
<Bar dataKey="repairs" fill={FAILURE_COLOR} radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow label="Name" value={mfr.name} />
<DetailRow
label="# MPNs"
value={<span className="tabular-nums">{mfr._count?.partModels ?? '—'}</span>}
/>
<DetailRow
label="# parts"
value={<span className="tabular-nums">{mfr._count?.parts ?? '—'}</span>}
/>
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(mfr.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(mfr.updatedAt).toLocaleString()} />
</dl>
</CardContent>
</Card>
</div>
{insights && insights.pastEolModels.length > 0 && (
<Card className="border-warning/50 bg-warning/5">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-warning" />
Past-EOL MPNs with deployed parts
</CardTitle>
<CardDescription>
These models have passed their end-of-life date plan replacements.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 pb-5">
{insights.pastEolModels.map((m) => (
<div
key={m.partModelId}
className="flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<div className="min-w-0">
<div className="truncate font-mono text-xs font-medium">{m.mpn}</div>
{m.eolDate && (
<div className="text-xs text-muted-foreground">
EOL {new Date(m.eolDate).toLocaleDateString()}
</div>
)}
</div>
<div className="flex items-center gap-3">
<span className="tabular-nums text-muted-foreground">
{m.deployedCount} deployed
</span>
<Button asChild variant="outline" size="sm">
<Link to={`/part-models/${m.partModelId}`}>View</Link>
</Button>
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-base">Part models</CardTitle>
<CardDescription>Every MPN this manufacturer supplies.</CardDescription>
</CardHeader>
<CardContent>
<DataTable<PartModel, Record<string, never>>
columns={modelColumns}
getRowId={(m) => m.id}
queryKey={(params) =>
queryKeys.partModels.list({
manufacturerId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
})
}
queryFn={(params) =>
listPartModels({
manufacturerId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
})
}
searchPlaceholder="Search MPN..."
emptyState={
<div className="py-8 text-center text-sm text-muted-foreground">
No MPNs from this manufacturer yet.
</div>
}
/>
</CardContent>
</Card>
<ManufacturerFormDialog
open={editOpen}
onOpenChange={setEditOpen}
manufacturer={mfr}
/>
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete manufacturer?"
description={`Remove ${mfr.name}. Fails if any parts or part models reference it.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+23 -22
View File
@@ -1,10 +1,10 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Building, Edit, MoreHorizontal, Plus, Trash2 } from 'lucide-react'; import { Building, Edit, Eye, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Badge,
Button, Button,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -25,6 +25,7 @@ import { useAuth } from '../contexts/AuthContext.js';
export default function Manufacturers() { export default function Manufacturers() {
const { user } = useAuth(); const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === 'ADMIN';
const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -47,21 +48,14 @@ export default function Manufacturers() {
{ {
accessorKey: 'name', accessorKey: 'name',
header: 'Name', header: 'Name',
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>, cell: ({ row }) => (
}, <Link
{ to={`/manufacturers/${row.original.id}`}
accessorKey: 'eolDate', className="font-medium hover:underline"
header: 'EOL', >
cell: ({ row }) => { {row.original.name}
if (!row.original.eolDate) { </Link>
return <span className="text-xs text-muted-foreground"></span>; ),
}
const d = new Date(row.original.eolDate);
const past = d.getTime() < Date.now();
return (
<Badge variant={past ? 'warning' : 'outline'}>{d.toLocaleDateString()}</Badge>
);
},
}, },
{ {
accessorKey: 'createdAt', accessorKey: 'createdAt',
@@ -76,8 +70,7 @@ export default function Manufacturers() {
id: 'actions', id: 'actions',
header: () => <span className="sr-only">Actions</span>, header: () => <span className="sr-only">Actions</span>,
size: 40, size: 40,
cell: ({ row }) => cell: ({ row }) => (
isAdmin ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7"> <Button variant="ghost" size="icon" className="h-7 w-7">
@@ -85,6 +78,12 @@ export default function Manufacturers() {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36"> <DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => navigate(`/manufacturers/${row.original.id}`)}>
<Eye className="h-3.5 w-3.5" />
View
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem onSelect={() => setEditing(row.original)}> <DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
Edit Edit
@@ -97,19 +96,21 @@ export default function Manufacturers() {
<Trash2 className="h-3.5 w-3.5" /> <Trash2 className="h-3.5 w-3.5" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null, ),
}, },
], ],
[isAdmin], [isAdmin, navigate],
); );
return ( return (
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="Manufacturers" title="Manufacturers"
description="Vendors and their end-of-life dates." description="Vendors supplying parts across the fleet."
actions={ actions={
isAdmin && ( isAdmin && (
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
+135
View File
@@ -0,0 +1,135 @@
import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Hand, PackageCheck, Undo2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { DropOffDialog } from '../components/custody/DropOffDialog.js';
import { dropOff, listMyCustody } from '../lib/api/custody.js';
import { ApiRequestError } from '../lib/api/client.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import type { Part } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
export default function MyCustody() {
const queryClient = useQueryClient();
const [dropping, setDropping] = useState<Part | null>(null);
const mutation = useMutation({
mutationFn: ({ partId, binId }: { partId: string; binId: string | null }) =>
dropOff(partId, { binId }),
onSuccess: () => {
toast.success('Dropped off');
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
setDropping(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Drop-off failed'),
});
const columns = useMemo<ColumnDef<Part>[]>(
() => [
{
id: 'serial',
header: 'Serial',
cell: ({ row }) => (
<Link
to={`/parts/${row.original.id}`}
className="font-mono text-xs font-medium text-foreground hover:underline"
>
{row.original.serialNumber}
</Link>
),
},
{
id: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<div className="flex flex-col">
<span className="font-mono text-xs">{row.original.partModel.mpn}</span>
<span className="text-xs text-muted-foreground">
{row.original.manufacturer.name}
</span>
</div>
),
},
{
id: 'state',
header: 'State',
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
},
{
id: 'since',
header: 'Since',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{new Date(row.original.updatedAt).toLocaleDateString()}
</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 160,
cell: ({ row }) => {
const pending = row.original.state === 'PENDING_REPAIR';
return (
<Button
size="sm"
variant="outline"
onClick={() => setDropping(row.original)}
>
{pending ? (
<Undo2 className="h-3.5 w-3.5" />
) : (
<PackageCheck className="h-3.5 w-3.5" />
)}
{pending ? 'Return to bin' : 'Drop in bin'}
</Button>
);
},
},
],
[],
);
return (
<div className="space-y-5">
<PageHeader
title="My Custody"
description="Broken parts you're holding until you drop them in a bin."
/>
<DataTable<Part, Record<string, never>>
columns={columns}
getRowId={(p) => p.id}
queryKey={(params) =>
queryKeys.custody.mine({ page: params.page, pageSize: params.pageSize })
}
queryFn={(params) =>
listMyCustody({ page: params.page, pageSize: params.pageSize })
}
enableSearch={false}
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Hand className="h-6 w-6" />
<span className="text-sm">Nothing in your custody.</span>
</div>
}
/>
<DropOffDialog
part={dropping}
onOpenChange={(o) => !o && setDropping(null)}
onConfirm={(binId) =>
dropping && mutation.mutate({ partId: dropping.id, binId })
}
pending={mutation.isPending}
/>
</div>
);
}
+12 -9
View File
@@ -20,7 +20,6 @@ import { useAuth } from '../contexts/AuthContext.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js'; import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { PartEventTimeline } from '../components/parts/PartEventTimeline.js'; import { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
import { PartFormDialog } from '../components/parts/PartFormDialog.js'; import { PartFormDialog } from '../components/parts/PartFormDialog.js';
import { PartRepairSection } from '../components/parts/PartRepairSection.js';
import { TagPicker } from '../components/tags/TagPicker.js'; import { TagPicker } from '../components/tags/TagPicker.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConfirmDialog } from '../components/ConfirmDialog.js';
@@ -89,7 +88,7 @@ export default function PartDetail() {
); );
} }
const eolDate = part.manufacturer.eolDate ? new Date(part.manufacturer.eolDate) : null; const eolDate = part.partModel.eolDate ? new Date(part.partModel.eolDate) : null;
const pastEol = eolDate ? eolDate.getTime() < Date.now() : false; const pastEol = eolDate ? eolDate.getTime() < Date.now() : false;
return ( return (
@@ -102,7 +101,7 @@ export default function PartDetail() {
<div> <div>
<h1 className="font-mono text-lg font-semibold tracking-tight">{part.serialNumber}</h1> <h1 className="font-mono text-lg font-semibold tracking-tight">{part.serialNumber}</h1>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{part.manufacturer.name} · {part.mpn} {part.manufacturer.name} · {part.partModel.mpn}
</p> </p>
</div> </div>
</div> </div>
@@ -132,7 +131,7 @@ export default function PartDetail() {
<AlertTriangle className="mt-0.5 h-4 w-4 text-warning" /> <AlertTriangle className="mt-0.5 h-4 w-4 text-warning" />
<div className="text-sm"> <div className="text-sm">
<span className="font-medium text-foreground"> <span className="font-medium text-foreground">
{part.manufacturer.name} reached EOL {eolDate.toLocaleDateString()}. {part.partModel.mpn} reached EOL {eolDate.toLocaleDateString()}.
</span>{' '} </span>{' '}
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Plan a replacement for this part. Plan a replacement for this part.
@@ -150,12 +149,12 @@ export default function PartDetail() {
<CardContent> <CardContent>
<dl className="space-y-2"> <dl className="space-y-2">
<DetailRow label="Serial" value={<span className="font-mono text-xs">{part.serialNumber}</span>} /> <DetailRow label="Serial" value={<span className="font-mono text-xs">{part.serialNumber}</span>} />
<DetailRow label="MPN" value={part.mpn} /> <DetailRow label="MPN" value={part.partModel.mpn} />
<DetailRow <DetailRow
label="Manufacturer" label="Manufacturer"
value={ value={
<Link <Link
to="/manufacturers" to={`/manufacturers/${part.manufacturerId}`}
className="text-foreground hover:underline" className="text-foreground hover:underline"
> >
{part.manufacturer.name} {part.manufacturer.name}
@@ -166,7 +165,13 @@ export default function PartDetail() {
<DetailRow <DetailRow
label="Location" label="Location"
value={ value={
part.bin?.fullPath ? ( part.host ? (
<span className="font-mono text-xs">
{part.host.assetId} / {part.host.name}
</span>
) : part.custodian ? (
<span className="text-xs">Custody: {part.custodian.username}</span>
) : part.bin?.fullPath ? (
<span className="font-mono text-xs">{part.bin.fullPath}</span> <span className="font-mono text-xs">{part.bin.fullPath}</span>
) : ( ) : (
<span className="text-muted-foreground italic">Unassigned</span> <span className="text-muted-foreground italic">Unassigned</span>
@@ -219,8 +224,6 @@ export default function PartDetail() {
<p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p> <p className="mb-2 text-xs font-medium text-muted-foreground">Tags</p>
<TagPicker partId={part.id} /> <TagPicker partId={part.id} />
</div> </div>
<Separator className="my-3" />
<PartRepairSection partId={part.id} />
</CardContent> </CardContent>
</Card> </Card>
+448
View File
@@ -0,0 +1,448 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Edit, Trash2 } from 'lucide-react';
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
import { toast } from 'sonner';
import type { PartState } from '@vector/shared';
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Separator,
Skeleton,
} from '@vector/ui';
import { deletePartModel, getPartModel, getPartModelInsights } from '../lib/api/part-models.js';
import { listParts } from '../lib/api/parts.js';
import { ApiRequestError } from '../lib/api/client.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { PartStateBadge } from '../components/parts/PartStateBadge.js';
import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { StatCard } from '../components/StatCard.js';
import type { Part } from '../lib/api/types.js';
const STATE_LABELS: Record<PartState, string> = {
SPARE: 'Spare',
DEPLOYED: 'Deployed',
BROKEN: 'Broken',
PENDING_DESTRUCTION: 'Pending destruction',
PENDING_DROP_IN_CUSTODY: 'In custody',
PENDING_DESTRUCTION_IN_CUSTODY: 'In custody (destroy)',
PENDING_REPAIR: 'Held for repair',
};
const STATE_FILL: Record<PartState, string> = {
SPARE: 'hsl(217 91% 60%)',
DEPLOYED: 'hsl(142 71% 45%)',
BROKEN: 'hsl(0 84% 60%)',
PENDING_DESTRUCTION: 'hsl(38 92% 50%)',
PENDING_DROP_IN_CUSTODY: 'hsl(262 83% 58%)',
PENDING_DESTRUCTION_IN_CUSTODY: 'hsl(340 82% 52%)',
PENDING_REPAIR: 'hsl(197 80% 50%)',
};
function currency(dollars: number): string {
return dollars.toLocaleString(undefined, { style: 'currency', currency: 'USD' });
}
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="grid grid-cols-[10rem_1fr] gap-2 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="text-foreground">{value}</dd>
</div>
);
}
export default function PartModelDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const [editOpen, setEditOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const modelQuery = useQuery({
queryKey: queryKeys.partModels.detail(id!),
queryFn: () => getPartModel(id!),
enabled: Boolean(id),
});
const insightsQuery = useQuery({
queryKey: queryKeys.partModels.insights(id!),
queryFn: () => getPartModelInsights(id!),
enabled: Boolean(id),
});
const deleteMutation = useMutation({
mutationFn: () => deletePartModel(id!),
onSuccess: () => {
toast.success('Part model deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
navigate('/part-models', { replace: true });
},
onError: (err) => {
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed');
},
});
const partColumns = useMemo<ColumnDef<Part>[]>(
() => [
{
accessorKey: 'serialNumber',
header: 'Serial',
cell: ({ row }) => (
<Link to={`/parts/${row.original.id}`} className="font-mono text-xs hover:underline">
{row.original.serialNumber}
</Link>
),
},
{
accessorKey: 'state',
header: 'State',
cell: ({ row }) => <PartStateBadge state={row.original.state} />,
},
{
id: 'location',
header: 'Location',
cell: ({ row }) => {
const p = row.original;
if (p.host) {
return (
<span className="font-mono text-xs">
{p.host.assetId} / {p.host.name}
</span>
);
}
if (p.custodian) {
return <span className="text-xs">Custody: {p.custodian.username}</span>;
}
if (p.bin?.fullPath) {
return <span className="font-mono text-xs">{p.bin.fullPath}</span>;
}
return <span className="text-xs text-muted-foreground italic">Unassigned</span>;
},
},
{
accessorKey: 'price',
header: 'Price',
cell: ({ row }) =>
row.original.price != null ? (
<span className="tabular-nums">{currency(row.original.price)}</span>
) : (
<span className="text-muted-foreground"></span>
),
},
],
[],
);
if (modelQuery.isPending) {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-48 w-full" />
</div>
);
}
if (modelQuery.isError || !modelQuery.data) {
const msg =
modelQuery.error instanceof ApiRequestError
? modelQuery.error.body.message
: 'Part model not found.';
return (
<Card>
<CardHeader>
<CardTitle>Part model unavailable</CardTitle>
<CardDescription>{msg}</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" onClick={() => navigate('/part-models')}>
<ArrowLeft className="h-4 w-4" />
Back to part models
</Button>
</CardContent>
</Card>
);
}
const model = modelQuery.data;
const insights = insightsQuery.data;
const eolDate = model.eolDate ? new Date(model.eolDate) : null;
const pastEol = eolDate ? eolDate.getTime() <= Date.now() : false;
const failureRate =
insights && insights.totalParts > 0
? Math.round((insights.failures.repairs / insights.totalParts) * 100)
: null;
const chartData =
insights?.byState.map((s) => ({
name: STATE_LABELS[s.state],
state: s.state,
count: s.count,
})) ?? [];
return (
<div className="space-y-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="icon"
onClick={() => navigate('/part-models')}
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<h1 className="font-mono text-lg font-semibold tracking-tight">{model.mpn}</h1>
<p className="text-xs text-muted-foreground">
{model.manufacturer ? (
<Link
to={`/manufacturers/${model.manufacturerId}`}
className="hover:underline"
>
{model.manufacturer.name}
</Link>
) : (
'—'
)}
{' · '}
{model.category ? (
<Link
to={`/categories/${model.category.id}`}
className="hover:underline"
>
{model.category.name}
</Link>
) : (
'Uncategorized'
)}
{eolDate && (
<>
{' · EOL '}
<span className={pastEol ? 'text-warning' : ''}>
{eolDate.toLocaleDateString()}
</span>
</>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{isAdmin && (
<Button variant="outline" size="sm" onClick={() => setEditOpen(true)}>
<Edit className="h-3.5 w-3.5" />
Edit
</Button>
)}
{isAdmin && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setConfirmDelete(true)}
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</Button>
)}
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{insightsQuery.isPending || !insights ? (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)
) : (
<>
<StatCard label="Units" value={insights.totalParts.toLocaleString()} />
<StatCard label="Total spent" value={currency(insights.priceStats.total)} />
<StatCard
label="Avg price"
value={
insights.priceStats.countWithPrice > 0
? currency(insights.priceStats.average)
: '—'
}
sub={
insights.priceStats.countWithPrice > 0
? `${insights.priceStats.countWithPrice} priced`
: 'No priced units'
}
/>
<StatCard
label="Failures"
value={insights.failures.repairs.toLocaleString()}
sub={
failureRate != null
? `${failureRate}% of units · ${insights.failures.distinctFailedParts} distinct`
: undefined
}
/>
</>
)}
</div>
<div className="grid gap-4 md:grid-cols-[1fr_1.2fr]">
<Card>
<CardHeader>
<CardTitle className="text-base">Summary</CardTitle>
</CardHeader>
<CardContent>
<dl className="space-y-2">
<DetailRow
label="Manufacturer"
value={
model.manufacturer ? (
<Link
to={`/manufacturers/${model.manufacturerId}`}
className="text-foreground hover:underline"
>
{model.manufacturer.name}
</Link>
) : (
'—'
)
}
/>
<DetailRow
label="MPN"
value={<span className="font-mono text-xs">{model.mpn}</span>}
/>
<DetailRow
label="Category"
value={
model.category ? (
<Link
to={`/categories/${model.category.id}`}
className="text-foreground hover:underline"
>
{model.category.name}
</Link>
) : (
<span className="text-muted-foreground italic">Uncategorized</span>
)
}
/>
<DetailRow
label="EOL"
value={
eolDate ? (
<span className={pastEol ? 'text-warning' : ''}>
{eolDate.toLocaleDateString()}
</span>
) : (
<span className="text-muted-foreground"></span>
)
}
/>
<DetailRow label="Destroy on fail" value={model.destroyOnFail ? 'Yes' : 'No'} />
<Separator className="my-2" />
<DetailRow label="Created" value={new Date(model.createdAt).toLocaleString()} />
<DetailRow label="Updated" value={new Date(model.updatedAt).toLocaleString()} />
</dl>
{model.notes && (
<>
<Separator className="my-3" />
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">Notes</p>
<p className="whitespace-pre-wrap text-sm text-foreground">{model.notes}</p>
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">State breakdown</CardTitle>
<CardDescription>How the fleet is distributed across lifecycle states.</CardDescription>
</CardHeader>
<CardContent className="h-72">
{insightsQuery.isPending ? (
<Skeleton className="h-full w-full" />
) : chartData.length === 0 ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No units of this model yet.
</div>
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 16 }}>
<XAxis type="number" tick={{ fontSize: 12 }} allowDecimals={false} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} width={120} />
<Tooltip cursor={{ fill: 'hsl(var(--accent) / 0.2)' }} />
<Bar dataKey="count" radius={[0, 4, 4, 0]}>
{chartData.map((s) => (
<Cell key={s.state} fill={STATE_FILL[s.state]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Parts of this model</CardTitle>
<CardDescription>Every unit tracked under {model.mpn}.</CardDescription>
</CardHeader>
<CardContent>
<DataTable<Part, Record<string, never>>
columns={partColumns}
getRowId={(p) => p.id}
queryKey={(params) =>
queryKeys.parts.list({
partModelId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
})
}
queryFn={(params) =>
listParts({
partModelId: id,
page: params.page,
pageSize: params.pageSize,
q: params.q,
sort: params.sort,
})
}
searchPlaceholder="Search serial..."
emptyState={
<div className="py-8 text-center text-sm text-muted-foreground">
No parts of this model yet.
</div>
}
/>
</CardContent>
</Card>
<PartModelFormDialog open={editOpen} onOpenChange={setEditOpen} partModel={model} />
<ConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
title="Delete part model?"
description={`Remove ${model.mpn}. Fails if any parts reference this model.`}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate()}
/>
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Check, Edit, Eye, Layers, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js';
import { PartModelFormDialog } from '../components/part-models/PartModelFormDialog.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deletePartModel, listPartModels } from '../lib/api/part-models.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { PartModel } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js';
import { useAuth } from '../contexts/AuthContext.js';
export default function PartModels() {
const { user } = useAuth();
const isAdmin = user?.role === 'ADMIN';
const navigate = useNavigate();
const queryClient = useQueryClient();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<PartModel | null>(null);
const [deleting, setDeleting] = useState<PartModel | null>(null);
const deleteMutation = useMutation({
mutationFn: (id: string) => deletePartModel(id),
onSuccess: () => {
toast.success('Part model deleted');
queryClient.invalidateQueries({ queryKey: queryKeys.partModels.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const columns = useMemo<ColumnDef<PartModel>[]>(
() => [
{
accessorKey: 'manufacturer',
header: 'Manufacturer',
cell: ({ row }) => (
<span className="text-sm">{row.original.manufacturer?.name ?? '—'}</span>
),
},
{
accessorKey: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<Link
to={`/part-models/${row.original.id}`}
className="font-mono text-xs font-medium hover:underline"
>
{row.original.mpn}
</Link>
),
},
{
id: 'category',
header: 'Category',
cell: ({ row }) =>
row.original.category ? (
<Link to={`/categories/${row.original.category.id}`}>
<Badge variant="outline" className="hover:bg-accent">
{row.original.category.name}
</Badge>
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
),
},
{
accessorKey: 'eolDate',
header: 'EOL',
cell: ({ row }) => {
const iso = row.original.eolDate;
if (!iso) return <span className="text-sm text-muted-foreground"></span>;
const pastEol = new Date(iso).getTime() <= Date.now();
return (
<div className="flex items-center gap-2">
<span className="text-sm">{new Date(iso).toLocaleDateString()}</span>
{pastEol && <Badge variant="destructive">Past EOL</Badge>}
</div>
);
},
},
{
id: 'deployedCount',
header: 'Deployed',
cell: ({ row }) => (
<span className="text-sm tabular-nums">{row.original._count?.parts ?? 0}</span>
),
},
{
id: 'destroyOnFail',
header: 'Destroy on fail',
cell: ({ row }) =>
row.original.destroyOnFail ? (
<Check className="h-4 w-4 text-foreground" />
) : (
<span className="text-xs text-muted-foreground">No</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => navigate(`/part-models/${row.original.id}`)}>
<Eye className="h-3.5 w-3.5" />
View
</DropdownMenuItem>
{isAdmin && (
<>
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
),
},
],
[isAdmin, navigate],
);
return (
<div className="space-y-5">
<PageHeader
title="Part models"
description="Catalog of tracked MPNs with end-of-life dates and fleet counts."
actions={
isAdmin && (
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" />
New part model
</Button>
)
}
/>
<DataTable<PartModel, Record<string, never>>
columns={columns}
getRowId={(m) => m.id}
queryKey={(params) =>
queryKeys.partModels.list({ page: params.page, pageSize: params.pageSize, q: params.q })
}
queryFn={(params) =>
listPartModels({ page: params.page, pageSize: params.pageSize, q: params.q })
}
searchPlaceholder="Search MPN..."
emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Layers className="h-6 w-6" />
<span className="text-sm">No part models yet.</span>
</div>
}
/>
<PartModelFormDialog open={createOpen} onOpenChange={setCreateOpen} />
<PartModelFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
partModel={editing}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete part model?"
description={
deleting
? `Remove ${deleting.mpn}. Fails if any parts reference this model.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
+102 -9
View File
@@ -4,7 +4,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { parseAsString } from 'nuqs'; import { parseAsString } from 'nuqs';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Edit, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react'; import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
import { import {
Button, Button,
DropdownMenu, DropdownMenu,
@@ -26,7 +26,9 @@ import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js'
import { ConfirmDialog } from '../components/ConfirmDialog.js'; import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { listParts, deletePart } from '../lib/api/parts.js'; import { listParts, deletePart } from '../lib/api/parts.js';
import { listManufacturers } from '../lib/api/manufacturers.js'; import { listManufacturers } from '../lib/api/manufacturers.js';
import { listCategories } from '../lib/api/categories.js';
import { listTags } from '../lib/api/tags.js'; import { listTags } from '../lib/api/tags.js';
import { takeForRepair } from '../lib/api/custody.js';
import { ApiRequestError } from '../lib/api/client.js'; import { ApiRequestError } from '../lib/api/client.js';
import type { Part } from '../lib/api/types.js'; import type { Part } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js'; import { queryKeys } from '../lib/queryKeys.js';
@@ -35,12 +37,14 @@ import { useAuth } from '../contexts/AuthContext.js';
type PartsFilters = { type PartsFilters = {
state: string | null; state: string | null;
manufacturerId: string | null; manufacturerId: string | null;
categoryId: string | null;
tagId: string | null; tagId: string | null;
}; };
const filterParsers = { const filterParsers = {
state: parseAsString, state: parseAsString,
manufacturerId: parseAsString, manufacturerId: parseAsString,
categoryId: parseAsString,
tagId: parseAsString, tagId: parseAsString,
}; };
@@ -62,6 +66,10 @@ export default function Parts() {
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }), queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
queryFn: () => listManufacturers({ pageSize: 100 }), queryFn: () => listManufacturers({ pageSize: 100 }),
}); });
const categoriesQuery = useQuery({
queryKey: queryKeys.categories.list({ pageSize: 100 }),
queryFn: () => listCategories({ pageSize: 100 }),
});
const tagsQuery = useQuery({ const tagsQuery = useQuery({
queryKey: queryKeys.tags.list({ pageSize: 100 }), queryKey: queryKeys.tags.list({ pageSize: 100 }),
queryFn: () => listTags({ pageSize: 100 }), queryFn: () => listTags({ pageSize: 100 }),
@@ -79,6 +87,17 @@ export default function Parts() {
}, },
}); });
const takeForRepairMutation = useMutation({
mutationFn: (id: string) => takeForRepair(id),
onSuccess: () => {
toast.success('Part moved into your custody');
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
queryClient.invalidateQueries({ queryKey: queryKeys.custody.all });
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Could not take part'),
});
const columns = useMemo<ColumnDef<Part>[]>( const columns = useMemo<ColumnDef<Part>[]>(
() => [ () => [
{ {
@@ -94,15 +113,42 @@ export default function Parts() {
), ),
}, },
{ {
accessorKey: 'mpn', id: 'mpn',
header: 'MPN', header: 'MPN',
cell: ({ row }) => <span className="text-sm">{row.original.mpn}</span>, cell: ({ row }) => (
<Link
to={`/part-models/${row.original.partModelId}`}
className="text-sm font-mono hover:underline"
>
{row.original.partModel.mpn}
</Link>
),
}, },
{ {
id: 'manufacturer', id: 'manufacturer',
header: 'Manufacturer', header: 'Manufacturer',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.manufacturer.name}</span> <Link
to={`/manufacturers/${row.original.manufacturerId}`}
className="text-sm text-muted-foreground hover:underline"
>
{row.original.manufacturer.name}
</Link>
),
},
{
id: 'category',
header: 'Category',
cell: ({ row }) =>
row.original.partModel.category ? (
<Link
to={`/categories/${row.original.partModel.category.id}`}
className="text-xs text-muted-foreground hover:underline"
>
{row.original.partModel.category.name}
</Link>
) : (
<span className="text-xs text-muted-foreground"></span>
), ),
}, },
{ {
@@ -114,9 +160,23 @@ export default function Parts() {
id: 'location', id: 'location',
header: 'Location', header: 'Location',
cell: ({ row }) => { cell: ({ row }) => {
const path = row.original.bin?.fullPath; const { host, custodian, bin } = row.original;
return path ? ( if (host) {
<span className="text-xs font-mono text-muted-foreground">{path}</span> return (
<span className="text-xs font-mono text-muted-foreground">
{host.assetId} / {host.name}
</span>
);
}
if (custodian) {
return (
<span className="text-xs text-muted-foreground">
Custody: {custodian.username}
</span>
);
}
return bin?.fullPath ? (
<span className="text-xs font-mono text-muted-foreground">{bin.fullPath}</span>
) : ( ) : (
<span className="text-xs text-muted-foreground italic">Unassigned</span> <span className="text-xs text-muted-foreground italic">Unassigned</span>
); );
@@ -143,7 +203,7 @@ export default function Parts() {
<MoreHorizontal className="h-4 w-4" /> <MoreHorizontal className="h-4 w-4" />
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40"> <DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}> <DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
View View
</DropdownMenuItem> </DropdownMenuItem>
@@ -151,6 +211,15 @@ export default function Parts() {
<Edit className="h-3.5 w-3.5" /> <Edit className="h-3.5 w-3.5" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
{row.original.state === 'SPARE' && (
<DropdownMenuItem
onSelect={() => takeForRepairMutation.mutate(row.original.id)}
disabled={takeForRepairMutation.isPending}
>
<HandHelping className="h-3.5 w-3.5" />
Take into custody
</DropdownMenuItem>
)}
{isAdmin && ( {isAdmin && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -168,7 +237,7 @@ export default function Parts() {
), ),
}, },
], ],
[navigate, isAdmin], [navigate, isAdmin, takeForRepairMutation],
); );
return ( return (
@@ -197,6 +266,7 @@ export default function Parts() {
sort: params.sort, sort: params.sort,
state: params.filters.state, state: params.filters.state,
manufacturerId: params.filters.manufacturerId, manufacturerId: params.filters.manufacturerId,
categoryId: params.filters.categoryId,
tagId: params.filters.tagId, tagId: params.filters.tagId,
}) })
} }
@@ -208,6 +278,7 @@ export default function Parts() {
sort: params.sort, sort: params.sort,
state: params.filters.state ?? undefined, state: params.filters.state ?? undefined,
manufacturerId: params.filters.manufacturerId ?? undefined, manufacturerId: params.filters.manufacturerId ?? undefined,
categoryId: params.filters.categoryId ?? undefined,
tagId: params.filters.tagId ?? undefined, tagId: params.filters.tagId ?? undefined,
}) })
} }
@@ -223,12 +294,15 @@ export default function Parts() {
toolbar={({ filters, setFilter }) => ( toolbar={({ filters, setFilter }) => (
<PartsFilters <PartsFilters
manufacturers={manufacturers.data?.data ?? []} manufacturers={manufacturers.data?.data ?? []}
categories={categoriesQuery.data?.data ?? []}
tags={tagsQuery.data?.data ?? []} tags={tagsQuery.data?.data ?? []}
state={filters.state ?? ALL} state={filters.state ?? ALL}
manufacturerId={filters.manufacturerId ?? ALL} manufacturerId={filters.manufacturerId ?? ALL}
categoryId={filters.categoryId ?? ALL}
tagId={filters.tagId ?? ALL} tagId={filters.tagId ?? ALL}
onState={(v) => setFilter('state', v === ALL ? null : v)} onState={(v) => setFilter('state', v === ALL ? null : v)}
onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)} onManufacturer={(v) => setFilter('manufacturerId', v === ALL ? null : v)}
onCategory={(v) => setFilter('categoryId', v === ALL ? null : v)}
onTag={(v) => setFilter('tagId', v === ALL ? null : v)} onTag={(v) => setFilter('tagId', v === ALL ? null : v)}
/> />
)} )}
@@ -280,23 +354,29 @@ export default function Parts() {
interface PartsFiltersProps { interface PartsFiltersProps {
manufacturers: { id: string; name: string }[]; manufacturers: { id: string; name: string }[];
categories: { id: string; name: string }[];
tags: { id: string; name: string }[]; tags: { id: string; name: string }[];
state: string; state: string;
manufacturerId: string; manufacturerId: string;
categoryId: string;
tagId: string; tagId: string;
onState: (v: string) => void; onState: (v: string) => void;
onManufacturer: (v: string) => void; onManufacturer: (v: string) => void;
onCategory: (v: string) => void;
onTag: (v: string) => void; onTag: (v: string) => void;
} }
function PartsFilters({ function PartsFilters({
manufacturers, manufacturers,
categories,
tags, tags,
state, state,
manufacturerId, manufacturerId,
categoryId,
tagId, tagId,
onState, onState,
onManufacturer, onManufacturer,
onCategory,
onTag, onTag,
}: PartsFiltersProps) { }: PartsFiltersProps) {
return ( return (
@@ -327,6 +407,19 @@ function PartsFilters({
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={categoryId} onValueChange={onCategory}>
<SelectTrigger className="h-8 w-40 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All categories</SelectItem>
{categories.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tagId} onValueChange={onTag}> <Select value={tagId} onValueChange={onTag}>
<SelectTrigger className="h-8 w-36 text-xs"> <SelectTrigger className="h-8 w-36 text-xs">
<SelectValue placeholder="Tag" /> <SelectValue placeholder="Tag" />
+51 -174
View File
@@ -1,145 +1,68 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import type { ColumnDef } from '@tanstack/react-table'; import type { ColumnDef } from '@tanstack/react-table';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { ArrowRightLeft, Plus } from 'lucide-react';
import { parseAsString } from 'nuqs'; import { Button } from '@vector/ui';
import { toast } from 'sonner';
import { Edit, MoreHorizontal, Plus, Trash2, Wrench } from 'lucide-react';
import type { RepairStatus } from '@vector/shared';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { PageHeader } from '../components/layout/PageHeader.js'; import { PageHeader } from '../components/layout/PageHeader.js';
import { DataTable } from '../components/data-table/DataTable.js'; import { DataTable } from '../components/data-table/DataTable.js';
import { RepairFormDialog } from '../components/repairs/RepairFormDialog.js'; import { LogRepairDialog } from '../components/repairs/LogRepairDialog.js';
import { import { listRepairs } from '../lib/api/repairs.js';
RepairStatusBadge, import type { Repair } from '../lib/api/types.js';
repairStatusOptions,
} from '../components/repairs/RepairStatusBadge.js';
import { ConfirmDialog } from '../components/ConfirmDialog.js';
import { deleteRepair, listRepairs } from '../lib/api/repairs.js';
import { ApiRequestError } from '../lib/api/client.js';
import type { RepairJob } from '../lib/api/types.js';
import { queryKeys } from '../lib/queryKeys.js'; import { queryKeys } from '../lib/queryKeys.js';
type RepairFilters = {
status: string | null;
};
const filterParsers = {
status: parseAsString,
};
const ALL = '__all__';
export default function Repairs() { export default function Repairs() {
const queryClient = useQueryClient(); const [logOpen, setLogOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<RepairJob | null>(null);
const [deleting, setDeleting] = useState<RepairJob | null>(null);
const deleteMutation = useMutation({ const columns = useMemo<ColumnDef<Repair>[]>(
mutationFn: (id: string) => deleteRepair(id),
onSuccess: () => {
toast.success('Repair removed');
queryClient.invalidateQueries({ queryKey: queryKeys.repairs.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
const columns = useMemo<ColumnDef<RepairJob>[]>(
() => [ () => [
{ {
accessorKey: 'status', id: 'performedAt',
header: 'Status', header: 'When',
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
},
{
id: 'part',
header: 'Part',
cell: ({ row }) => ( cell: ({ row }) => (
<Link <span className="text-xs text-muted-foreground">
to={`/parts/${row.original.partId}`} {new Date(row.original.performedAt).toLocaleString()}
className="font-medium text-foreground hover:underline" </span>
>
{row.original.part.serialNumber}
</Link>
),
},
{
id: 'mpn',
header: 'MPN',
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">{row.original.part.mpn}</span>
), ),
}, },
{ {
id: 'host', id: 'host',
header: 'Host', header: 'Host',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <div className="flex flex-col">
{row.original.host?.name ?? '—'} <span className="font-mono text-xs">{row.original.host.assetId}</span>
</span> <span className="text-xs text-muted-foreground">{row.original.host.name}</span>
</div>
), ),
}, },
{ {
accessorKey: 'openedAt', id: 'broken',
header: 'Opened', header: 'Broken',
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-xs text-muted-foreground"> <Link
{new Date(row.original.openedAt).toLocaleDateString()} to={`/parts/${row.original.brokenPart.id}`}
</span> className="font-mono text-xs hover:underline"
),
},
{
accessorKey: 'closedAt',
header: 'Closed',
cell: ({ row }) => (
<span className="text-xs text-muted-foreground">
{row.original.closedAt
? new Date(row.original.closedAt).toLocaleDateString()
: '—'}
</span>
),
},
{
id: 'actions',
header: () => <span className="sr-only">Actions</span>,
size: 40,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-36">
<DropdownMenuItem onSelect={() => setEditing(row.original)}>
<Edit className="h-3.5 w-3.5" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setDeleting(row.original)}
className="text-destructive focus:text-destructive"
> >
<Trash2 className="h-3.5 w-3.5" /> {row.original.brokenPart.serialNumber}
Delete </Link>
</DropdownMenuItem> ),
</DropdownMenuContent> },
</DropdownMenu> {
id: 'replacement',
header: 'Replacement',
cell: ({ row }) => (
<Link
to={`/parts/${row.original.replacement.id}`}
className="font-mono text-xs hover:underline"
>
{row.original.replacement.serialNumber}
</Link>
),
},
{
id: 'performedBy',
header: 'By',
cell: ({ row }) => (
<span className="text-xs">{row.original.performedBy.username}</span>
), ),
}, },
], ],
@@ -150,80 +73,34 @@ export default function Repairs() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="Repairs" title="Repairs"
description="Open RMAs and host-attached repair jobs." description="Physical part swaps. Logging a repair moves the broken part into your custody."
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setLogOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Open repair Log repair
</Button> </Button>
} }
/> />
<DataTable<RepairJob, RepairFilters> <DataTable<Repair, Record<string, never>>
columns={columns} columns={columns}
getRowId={(r) => r.id} getRowId={(r) => r.id}
filterParsers={filterParsers}
queryKey={(params) => queryKey={(params) =>
queryKeys.repairs.list({ queryKeys.repairs.list({ page: params.page, pageSize: params.pageSize })
page: params.page,
pageSize: params.pageSize,
status: params.filters.status,
})
} }
queryFn={(params) => queryFn={(params) =>
listRepairs({ listRepairs({ page: params.page, pageSize: params.pageSize })
page: params.page,
pageSize: params.pageSize,
status: (params.filters.status ?? undefined) as RepairStatus | undefined,
})
} }
enableSearch={false} enableSearch={false}
toolbar={({ filters, setFilter }) => (
<Select
value={filters.status ?? ALL}
onValueChange={(v) => setFilter('status', v === ALL ? null : v)}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Any status" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>Any status</SelectItem>
{repairStatusOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
emptyState={ emptyState={
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground"> <div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
<Wrench className="h-6 w-6" /> <ArrowRightLeft className="h-6 w-6" />
<span className="text-sm">No repair jobs yet.</span> <span className="text-sm">No repairs logged yet.</span>
</div> </div>
} }
/> />
<RepairFormDialog open={createOpen} onOpenChange={setCreateOpen} /> <LogRepairDialog open={logOpen} onOpenChange={setLogOpen} />
<RepairFormDialog
open={Boolean(editing)}
onOpenChange={(o) => !o && setEditing(null)}
repair={editing}
/>
<ConfirmDialog
open={Boolean(deleting)}
onOpenChange={(o) => !o && setDeleting(null)}
title="Delete repair?"
description={
deleting
? `Remove repair for ${deleting.part.serialNumber}. This cannot be undone.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div> </div>
); );
} }
+1 -1
View File
@@ -142,7 +142,7 @@ export default function Webhooks() {
<div className="space-y-5"> <div className="space-y-5">
<PageHeader <PageHeader
title="Webhooks" title="Webhooks"
description="Subscribe external receivers to Vector events. Deliveries are signed with HMAC-SHA256." description="Subscribe external receivers to inventory events."
actions={ actions={
<Button onClick={() => setCreateOpen(true)}> <Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
+47 -16
View File
@@ -1,28 +1,59 @@
# Vector — single-instance production deployment.
#
# Quick start:
# 1. Log in to the registry so compose can pull:
# docker login gitea.thewrightserver.net
#
# 2. Create a .env file next to this compose file containing at minimum:
# JWT_SECRET=<64+ char random hex>
# CLIENT_ORIGIN=http://your-host:8080
# WEB_PORT=8080
# TAG=latest # or a specific commit SHA
#
# 3. Pull + start:
# docker compose pull && docker compose up -d
#
# Data lives in the `vector-data` volume (SQLite db). Redis is included
# in anticipation of the BullMQ worker follow-up; the API does not yet
# depend on it.
services: services:
postgres: api:
image: postgres:16-alpine image: gitea.thewrightserver.net/josh/vector-api:${TAG:-latest}
container_name: vector-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_USER: vector NODE_ENV: production
POSTGRES_PASSWORD: vector PORT: 3001
POSTGRES_DB: vector DATABASE_URL: file:/data/vector.db
ports: JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — see .env.example}
- "5432:5432" CLIENT_ORIGIN: ${CLIENT_ORIGIN:-http://localhost:8080}
# Browsers drop Secure cookies over plain HTTP. Flip to "true" once
# this deployment sits behind TLS (reverse proxy, Cloudflare, etc).
COOKIE_SECURE: ${COOKIE_SECURE:-false}
volumes: volumes:
- vector-pgdata:/var/lib/postgresql/data - vector-data:/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U vector -d vector"] test: ["CMD-SHELL", "wget -qO- http://localhost:3001/healthz || exit 1"]
interval: 5s interval: 30s
timeout: 5s timeout: 5s
retries: 10 retries: 3
start_period: 20s
depends_on:
redis:
condition: service_healthy
web:
image: gitea.thewrightserver.net/josh/vector-web:${TAG:-latest}
restart: unless-stopped
ports:
- "${WEB_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: vector-redis
restart: unless-stopped restart: unless-stopped
ports:
- "6379:6379"
volumes: volumes:
- vector-redisdata:/data - vector-redisdata:/data
healthcheck: healthcheck:
@@ -32,5 +63,5 @@ services:
retries: 10 retries: 10
volumes: volumes:
vector-pgdata: vector-data:
vector-redisdata: vector-redisdata:
+30
View File
@@ -0,0 +1,30 @@
// Bootstrap seed run at container start. Creates a default admin user
// when the User table is empty so a fresh deployment has something to
// log into. Subsequent boots see an existing user and skip silently.
import bcrypt from 'bcryptjs';
import { prisma } from './dist/client.js';
try {
const count = await prisma.user.count();
if (count === 0) {
const username = process.env.SEED_ADMIN_USERNAME ?? 'admin';
const password = process.env.SEED_ADMIN_PASSWORD ?? 'admin';
const email = process.env.SEED_ADMIN_EMAIL ?? 'admin@vector.local';
await prisma.user.create({
data: {
username,
email,
passwordHash: await bcrypt.hash(password, 12),
role: 'ADMIN',
},
});
console.log(`[seed] Created default admin user "${username}". Change this password immediately.`);
} else {
console.log(`[seed] ${count} user(s) already exist — skipping default admin seed.`);
}
} catch (err) {
console.error('[seed] Failed to ensure admin user:', err);
process.exit(1);
} finally {
await prisma.$disconnect();
}
@@ -0,0 +1,204 @@
-- Domain rework: EOL moves from Manufacturer to a new PartModel catalog (keyed by manufacturerId+mpn);
-- repairs move from Part-scoped to Host-scoped with an optional problem-parts join; Host gains a
-- required+unique assetId; RepairJob gains a `problem` field and loses its direct `partId` FK.
--
-- Data-preserving reshape for SQLite. Steps:
-- 1. Create new catalog + join + comment tables.
-- 2. Seed PartModel from DISTINCT (manufacturerId, mpn) on Part, carrying Manufacturer.eolDate forward.
-- 3. Snapshot existing RepairJob.partId into RepairJobPart so historic repairs still remember the part.
-- 4. Ensure every RepairJob has a hostId (synthesize "__Unassigned__" host if needed) before hostId NOT NULL.
-- 5. Rebuild Host (add assetId NOT NULL+unique, backfilled with "H-<short>"), Manufacturer (drop eolDate),
-- Part (add partModelId via lookup + hostId nullable, drop mpn + replacementPartId), and RepairJob
-- (drop partId, add problem with COALESCE(notes, fallback), hostId NOT NULL).
-- CreateTable: PartModel catalog
CREATE TABLE "PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- Backfill PartModel from DISTINCT (manufacturerId, mpn) on existing Part rows, carrying the
-- current Manufacturer.eolDate into the new per-MPN eolDate. Admins re-tune per MPN afterward.
INSERT INTO "PartModel" ("id", "manufacturerId", "mpn", "eolDate", "createdAt", "updatedAt")
SELECT
lower(hex(randomblob(16))),
d."manufacturerId",
d."mpn",
m."eolDate",
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM (SELECT DISTINCT "manufacturerId", "mpn" FROM "Part") d
LEFT JOIN "Manufacturer" m ON m."id" = d."manufacturerId";
-- CreateTable: RepairJobPart join
CREATE TABLE "RepairJobPart" (
"repairJobId" TEXT NOT NULL,
"partId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("repairJobId", "partId"),
CONSTRAINT "RepairJobPart_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairJobPart_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- Snapshot old single-part repairs into the new join table so each historic repair still points at
-- its original problem part.
INSERT INTO "RepairJobPart" ("repairJobId", "partId", "createdAt")
SELECT "id", "partId", CURRENT_TIMESTAMP FROM "RepairJob";
-- CreateTable: RepairComment
CREATE TABLE "RepairComment" (
"id" TEXT NOT NULL PRIMARY KEY,
"repairJobId" TEXT NOT NULL,
"userId" TEXT,
"content" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RepairComment_repairJobId_fkey" FOREIGN KEY ("repairJobId") REFERENCES "RepairJob" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "RepairComment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- Ensure every RepairJob has a hostId before we tighten the column to NOT NULL. Repairs with a
-- NULL hostId get attached to a synthetic "__Unassigned__" host so no rows are lost; admins can
-- reassign them afterward.
INSERT INTO "Host" ("id", "name", "createdAt", "updatedAt")
SELECT lower(hex(randomblob(16))), '__Unassigned__', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE EXISTS (SELECT 1 FROM "RepairJob" WHERE "hostId" IS NULL)
AND NOT EXISTS (SELECT 1 FROM "Host" WHERE "name" = '__Unassigned__');
UPDATE "RepairJob"
SET "hostId" = (SELECT "id" FROM "Host" WHERE "name" = '__Unassigned__')
WHERE "hostId" IS NULL;
-- RedefineTables: destructive reshape of Host / Manufacturer / Part / RepairJob.
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
-- Host: add assetId NOT NULL + unique, backfilled with "H-<first-8-chars-of-id>" so existing rows
-- carry a deterministic placeholder admins can rename.
CREATE TABLE "new_Host" (
"id" TEXT NOT NULL PRIMARY KEY,
"assetId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Host" ("id", "assetId", "name", "location", "notes", "createdAt", "updatedAt")
SELECT "id", 'H-' || substr("id", 1, 8), "name", "location", "notes", "createdAt", "updatedAt"
FROM "Host";
DROP TABLE "Host";
ALTER TABLE "new_Host" RENAME TO "Host";
CREATE UNIQUE INDEX "Host_assetId_key" ON "Host"("assetId");
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
-- Manufacturer: drop eolDate (EOL now lives on PartModel).
CREATE TABLE "new_Manufacturer" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Manufacturer" ("id", "name", "createdAt", "updatedAt")
SELECT "id", "name", "createdAt", "updatedAt" FROM "Manufacturer";
DROP TABLE "Manufacturer";
ALTER TABLE "new_Manufacturer" RENAME TO "Manufacturer";
CREATE UNIQUE INDEX "Manufacturer_name_key" ON "Manufacturer"("name");
-- Part: add partModelId (looked up via the backfilled PartModel rows), add hostId (nullable;
-- admins populate when deploying parts). Drop the free-text mpn column and the unused
-- replacementPartId self-relation.
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"partModelId" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"categoryId" TEXT,
"hostId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("id", "serialNumber", "partModelId", "manufacturerId", "price", "state", "binId", "categoryId", "hostId", "notes", "createdAt", "updatedAt")
SELECT
p."id",
p."serialNumber",
(SELECT pm."id" FROM "PartModel" pm WHERE pm."manufacturerId" = p."manufacturerId" AND pm."mpn" = p."mpn" LIMIT 1),
p."manufacturerId",
p."price",
p."state",
p."binId",
p."categoryId",
NULL,
p."notes",
p."createdAt",
p."updatedAt"
FROM "Part" p;
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
-- RepairJob: drop partId (problem part now lives in RepairJobPart), require hostId, add problem
-- (carried over from notes as a best-effort fallback for historical rows).
CREATE TABLE "new_RepairJob" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"assigneeId" TEXT,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"problem" TEXT NOT NULL,
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" DATETIME,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "RepairJob_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "RepairJob_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_RepairJob" ("id", "hostId", "assigneeId", "status", "problem", "openedAt", "closedAt", "notes", "createdAt", "updatedAt")
SELECT
r."id",
r."hostId",
r."assigneeId",
r."status",
COALESCE(r."notes", 'Imported repair — problem not recorded'),
r."openedAt",
r."closedAt",
r."notes",
r."createdAt",
r."updatedAt"
FROM "RepairJob" r;
DROP TABLE "RepairJob";
ALTER TABLE "new_RepairJob" RENAME TO "RepairJob";
CREATE INDEX "RepairJob_status_idx" ON "RepairJob"("status");
CREATE INDEX "RepairJob_hostId_idx" ON "RepairJob"("hostId");
CREATE INDEX "RepairJob_assigneeId_idx" ON "RepairJob"("assigneeId");
CREATE INDEX "RepairJob_status_openedAt_idx" ON "RepairJob"("status", "openedAt" DESC);
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- Indexes for the new catalog + join + comment tables.
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
CREATE INDEX "RepairJobPart_partId_idx" ON "RepairJobPart"("partId");
CREATE INDEX "RepairComment_repairJobId_createdAt_idx" ON "RepairComment"("repairJobId", "createdAt");
@@ -0,0 +1,164 @@
/*
Warnings:
- You are about to drop the `RepairComment` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `RepairJob` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `RepairJobPart` table. If the table is not empty, all the data it contains will be lost.
*/
-- DropIndex
DROP INDEX "RepairComment_repairJobId_createdAt_idx";
-- DropIndex
DROP INDEX "RepairJob_status_openedAt_idx";
-- DropIndex
DROP INDEX "RepairJob_assigneeId_idx";
-- DropIndex
DROP INDEX "RepairJob_hostId_idx";
-- DropIndex
DROP INDEX "RepairJob_status_idx";
-- DropIndex
DROP INDEX "RepairJobPart_partId_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RepairComment";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RepairJob";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "RepairJobPart";
PRAGMA foreign_keys=on;
-- CreateTable
CREATE TABLE "fms" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'OPEN',
"problem" TEXT NOT NULL,
"openedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"closedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "fms_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "fm_parts" (
"fmId" TEXT NOT NULL,
"partId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("fmId", "partId"),
CONSTRAINT "fm_parts_fmId_fkey" FOREIGN KEY ("fmId") REFERENCES "fms" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "fm_parts_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "repairs" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"brokenPartId" TEXT NOT NULL,
"replacementPartId" TEXT NOT NULL,
"performedById" TEXT NOT NULL,
"performedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"fmId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "repairs_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_brokenPartId_fkey" FOREIGN KEY ("brokenPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_fmId_fkey" FOREIGN KEY ("fmId") REFERENCES "fms" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"partModelId" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"categoryId" TEXT,
"hostId" TEXT,
"custodianId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_custodianId_fkey" FOREIGN KEY ("custodianId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("binId", "categoryId", "createdAt", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "categoryId", "createdAt", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
CREATE INDEX "Part_categoryId_idx" ON "Part"("categoryId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
CREATE INDEX "Part_custodianId_idx" ON "Part"("custodianId");
CREATE TABLE "new_PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"destroyOnFail" BOOLEAN NOT NULL DEFAULT false,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_PartModel" ("createdAt", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt") SELECT "createdAt", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt" FROM "PartModel";
DROP TABLE "PartModel";
ALTER TABLE "new_PartModel" RENAME TO "PartModel";
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
-- CreateIndex
CREATE INDEX "fms_status_idx" ON "fms"("status");
-- CreateIndex
CREATE INDEX "fms_hostId_idx" ON "fms"("hostId");
-- CreateIndex
CREATE INDEX "fms_status_openedAt_idx" ON "fms"("status", "openedAt" DESC);
-- CreateIndex
CREATE INDEX "fm_parts_partId_idx" ON "fm_parts"("partId");
-- CreateIndex
CREATE INDEX "repairs_hostId_performedAt_idx" ON "repairs"("hostId", "performedAt" DESC);
-- CreateIndex
CREATE INDEX "repairs_fmId_idx" ON "repairs"("fmId");
-- CreateIndex
CREATE INDEX "repairs_performedById_performedAt_idx" ON "repairs"("performedById", "performedAt" DESC);
-- CreateIndex
CREATE INDEX "repairs_brokenPartId_idx" ON "repairs"("brokenPartId");
-- CreateIndex
CREATE INDEX "repairs_replacementPartId_idx" ON "repairs"("replacementPartId");
@@ -0,0 +1,78 @@
/*
Warnings:
- You are about to drop the column `categoryId` on the `Part` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Host" (
"id" TEXT NOT NULL PRIMARY KEY,
"assetId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"location" TEXT,
"notes" TEXT,
"state" TEXT NOT NULL DEFAULT 'DEPLOYED',
"stack" TEXT NOT NULL DEFAULT 'PRODUCTION',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
INSERT INTO "new_Host" ("assetId", "createdAt", "id", "location", "name", "notes", "updatedAt") SELECT "assetId", "createdAt", "id", "location", "name", "notes", "updatedAt" FROM "Host";
DROP TABLE "Host";
ALTER TABLE "new_Host" RENAME TO "Host";
CREATE UNIQUE INDEX "Host_assetId_key" ON "Host"("assetId");
CREATE UNIQUE INDEX "Host_name_key" ON "Host"("name");
CREATE INDEX "Host_state_idx" ON "Host"("state");
CREATE INDEX "Host_stack_idx" ON "Host"("stack");
CREATE TABLE "new_Part" (
"id" TEXT NOT NULL PRIMARY KEY,
"serialNumber" TEXT NOT NULL,
"partModelId" TEXT NOT NULL,
"manufacturerId" TEXT NOT NULL,
"price" REAL,
"state" TEXT NOT NULL DEFAULT 'SPARE',
"binId" TEXT,
"hostId" TEXT,
"custodianId" TEXT,
"notes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Part_partModelId_fkey" FOREIGN KEY ("partModelId") REFERENCES "PartModel" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Part_binId_fkey" FOREIGN KEY ("binId") REFERENCES "Bin" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Part_custodianId_fkey" FOREIGN KEY ("custodianId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Part" ("binId", "createdAt", "custodianId", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt") SELECT "binId", "createdAt", "custodianId", "hostId", "id", "manufacturerId", "notes", "partModelId", "price", "serialNumber", "state", "updatedAt" FROM "Part";
DROP TABLE "Part";
ALTER TABLE "new_Part" RENAME TO "Part";
CREATE UNIQUE INDEX "Part_serialNumber_key" ON "Part"("serialNumber");
CREATE INDEX "Part_state_idx" ON "Part"("state");
CREATE INDEX "Part_binId_idx" ON "Part"("binId");
CREATE INDEX "Part_manufacturerId_idx" ON "Part"("manufacturerId");
CREATE INDEX "Part_partModelId_idx" ON "Part"("partModelId");
CREATE INDEX "Part_hostId_idx" ON "Part"("hostId");
CREATE INDEX "Part_custodianId_idx" ON "Part"("custodianId");
CREATE TABLE "new_PartModel" (
"id" TEXT NOT NULL PRIMARY KEY,
"manufacturerId" TEXT NOT NULL,
"mpn" TEXT NOT NULL,
"eolDate" DATETIME,
"destroyOnFail" BOOLEAN NOT NULL DEFAULT false,
"notes" TEXT,
"categoryId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "PartModel_manufacturerId_fkey" FOREIGN KEY ("manufacturerId") REFERENCES "Manufacturer" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "PartModel_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_PartModel" ("createdAt", "destroyOnFail", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt") SELECT "createdAt", "destroyOnFail", "eolDate", "id", "manufacturerId", "mpn", "notes", "updatedAt" FROM "PartModel";
DROP TABLE "PartModel";
ALTER TABLE "new_PartModel" RENAME TO "PartModel";
CREATE INDEX "PartModel_manufacturerId_idx" ON "PartModel"("manufacturerId");
CREATE INDEX "PartModel_eolDate_idx" ON "PartModel"("eolDate");
CREATE INDEX "PartModel_categoryId_idx" ON "PartModel"("categoryId");
CREATE UNIQUE INDEX "PartModel_manufacturerId_mpn_key" ON "PartModel"("manufacturerId", "mpn");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "HostEvent" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"userId" TEXT,
"type" TEXT NOT NULL,
"field" TEXT,
"oldValue" TEXT,
"newValue" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HostEvent_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "HostEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "HostEvent_hostId_createdAt_idx" ON "HostEvent"("hostId", "createdAt" DESC);
-- CreateIndex
CREATE INDEX "HostEvent_userId_idx" ON "HostEvent"("userId");
@@ -0,0 +1,59 @@
/*
Warnings:
- You are about to drop the `fm_parts` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the `fms` table. If the table is not empty, all the data it contains will be lost.
- You are about to drop the column `fmId` on the `repairs` table. All the data in the column will be lost.
*/
-- Drop orphan part_events referencing the retired FM event types.
DELETE FROM "part_events" WHERE "type" IN ('FM_OPENED', 'FM_CLOSED');
-- DropIndex
DROP INDEX "fm_parts_partId_idx";
-- DropIndex
DROP INDEX "fms_status_openedAt_idx";
-- DropIndex
DROP INDEX "fms_hostId_idx";
-- DropIndex
DROP INDEX "fms_status_idx";
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "fm_parts";
PRAGMA foreign_keys=on;
-- DropTable
PRAGMA foreign_keys=off;
DROP TABLE "fms";
PRAGMA foreign_keys=on;
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_repairs" (
"id" TEXT NOT NULL PRIMARY KEY,
"hostId" TEXT NOT NULL,
"brokenPartId" TEXT NOT NULL,
"replacementPartId" TEXT NOT NULL,
"performedById" TEXT NOT NULL,
"performedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "repairs_hostId_fkey" FOREIGN KEY ("hostId") REFERENCES "Host" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_brokenPartId_fkey" FOREIGN KEY ("brokenPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_replacementPartId_fkey" FOREIGN KEY ("replacementPartId") REFERENCES "Part" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "repairs_performedById_fkey" FOREIGN KEY ("performedById") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_repairs" ("brokenPartId", "createdAt", "hostId", "id", "performedAt", "performedById", "replacementPartId", "updatedAt") SELECT "brokenPartId", "createdAt", "hostId", "id", "performedAt", "performedById", "replacementPartId", "updatedAt" FROM "repairs";
DROP TABLE "repairs";
ALTER TABLE "new_repairs" RENAME TO "repairs";
CREATE INDEX "repairs_hostId_performedAt_idx" ON "repairs"("hostId", "performedAt" DESC);
CREATE INDEX "repairs_performedById_performedAt_idx" ON "repairs"("performedById", "performedAt" DESC);
CREATE INDEX "repairs_brokenPartId_idx" ON "repairs"("brokenPartId");
CREATE INDEX "repairs_replacementPartId_idx" ON "repairs"("replacementPartId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
+76 -30
View File
@@ -24,8 +24,10 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
partEvents PartEvent[] partEvents PartEvent[]
hostEvents HostEvent[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
repairAssignments RepairJob[] @relation("RepairAssignee") custodyParts Part[] @relation("Custody")
repairs Repair[]
savedViews SavedView[] savedViews SavedView[]
csvImportJobs CsvImportJob[] csvImportJobs CsvImportJob[]
} }
@@ -47,10 +49,30 @@ model RefreshToken {
model Manufacturer { model Manufacturer {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique
eolDate DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parts Part[] parts Part[]
partModels PartModel[]
}
model PartModel {
id String @id @default(uuid())
manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
mpn String
eolDate DateTime?
destroyOnFail Boolean @default(false)
notes String?
categoryId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
parts Part[]
@@unique([manufacturerId, mpn])
@@index([manufacturerId])
@@index([eolDate])
@@index([categoryId])
} }
model Site { model Site {
@@ -93,37 +115,38 @@ model Category {
description String? description String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
parts Part[] partModels PartModel[]
} }
model Part { model Part {
id String @id @default(uuid()) id String @id @default(uuid())
serialNumber String @unique serialNumber String @unique
mpn String partModelId String
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
manufacturerId String manufacturerId String
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict) manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
price Float? price Float?
state String @default("SPARE") state String @default("SPARE")
binId String? binId String?
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull) bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
categoryId String? hostId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
replacementPartId String? custodianId String?
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull) custodian User? @relation("Custody", fields: [custodianId], references: [id], onDelete: SetNull)
replacedBy Part[] @relation("PartReplacement")
notes String? notes String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
events PartEvent[] events PartEvent[]
tags PartTag[] tags PartTag[]
repairs RepairJob[] brokenRepairs Repair[] @relation("BrokenRepairs")
replacementRepairs Repair[] @relation("ReplacementRepairs")
@@index([state]) @@index([state])
@@index([binId]) @@index([binId])
@@index([manufacturerId]) @@index([manufacturerId])
@@index([mpn]) @@index([partModelId])
@@index([categoryId]) @@index([hostId])
@@index([replacementPartId]) @@index([custodianId])
} }
model PartEvent { model PartEvent {
@@ -164,34 +187,57 @@ model PartTag {
model Host { model Host {
id String @id @default(uuid()) id String @id @default(uuid())
assetId String @unique
name String @unique name String @unique
location String? location String?
notes String? notes String?
state String @default("DEPLOYED")
stack String @default("PRODUCTION")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
repairs RepairJob[] parts Part[]
repairs Repair[]
events HostEvent[]
@@index([state])
@@index([stack])
} }
model RepairJob { model HostEvent {
id String @id @default(uuid()) id String @id @default(uuid())
partId String hostId String
part Part @relation(fields: [partId], references: [id], onDelete: Cascade) host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
hostId String? userId String?
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull) user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
assigneeId String? type String
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull) field String?
status String @default("PENDING") oldValue String?
openedAt DateTime @default(now()) newValue String?
closedAt DateTime? createdAt DateTime @default(now())
notes String?
@@index([hostId, createdAt(sort: Desc)])
@@index([userId])
}
model Repair {
id String @id @default(uuid())
hostId String
host Host @relation(fields: [hostId], references: [id], onDelete: Restrict)
brokenPartId String
brokenPart Part @relation("BrokenRepairs", fields: [brokenPartId], references: [id], onDelete: Restrict)
replacementPartId String
replacement Part @relation("ReplacementRepairs", fields: [replacementPartId], references: [id], onDelete: Restrict)
performedById String
performedBy User @relation(fields: [performedById], references: [id], onDelete: Restrict)
performedAt DateTime @default(now())
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([partId]) @@index([hostId, performedAt(sort: Desc)])
@@index([status]) @@index([performedById, performedAt(sort: Desc)])
@@index([hostId]) @@index([brokenPartId])
@@index([assigneeId]) @@index([replacementPartId])
@@index([status, openedAt(sort: Desc)]) @@map("repairs")
} }
model WebhookSubscription { model WebhookSubscription {
+10
View File
@@ -17,6 +17,16 @@ async function main() {
console.log(`Seeded admin user: ${admin.username} (${admin.email})`); console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
console.log('Default password: admin — change this immediately!'); console.log('Default password: admin — change this immediately!');
const categoryNames = ['GPU', 'RAM', 'SSD', 'HDD', 'NIC', 'CPU', 'PSU', 'MOBO'];
for (const name of categoryNames) {
await prisma.category.upsert({
where: { name },
update: {},
create: { name },
});
}
console.log(`Seeded ${categoryNames.length} part categories.`);
} }
main() main()
+14 -5
View File
@@ -9,13 +9,22 @@ declare global {
function resolveSqliteUrl(raw: string | undefined): string | undefined { function resolveSqliteUrl(raw: string | undefined): string | undefined {
if (!raw || !raw.startsWith('file:')) return raw; if (!raw || !raw.startsWith('file:')) return raw;
const rest = raw.slice('file:'.length).replace(/^\/+/, ''); let body = raw.slice('file:'.length);
if (path.isAbsolute(rest) || /^[A-Za-z]:[\\/]/.test(rest)) {
return 'file:' + rest.replace(/\\/g, '/'); // file:///unix/path is equivalent to file:/unix/path; collapse the host part.
} if (body.startsWith('///')) body = body.slice(2);
// Windows: "/C:/..." → "C:/..." , then any backslashes to forward slashes.
const win = body.match(/^\/?([A-Za-z]:[\\/].*)$/);
if (win?.[1]) return 'file:' + win[1].replace(/\\/g, '/');
// Unix absolute (e.g. "/data/vector.db") — pass through verbatim.
if (body.startsWith('/')) return 'file:' + body;
// Relative — resolve against the schema dir so dev's default (file:./dev.db) keeps working.
const here = path.dirname(fileURLToPath(import.meta.url)); const here = path.dirname(fileURLToPath(import.meta.url));
const schemaDir = path.resolve(here, '..', 'prisma'); const schemaDir = path.resolve(here, '..', 'prisma');
const absolute = path.resolve(schemaDir, rest); const absolute = path.resolve(schemaDir, body);
return 'file:' + absolute.replace(/\\/g, '/'); return 'file:' + absolute.replace(/\\/g, '/');
} }
+15 -5
View File
@@ -18,18 +18,28 @@ export interface BinCount {
count: number; count: number;
} }
export interface ManufacturerEolSummary { export interface PartModelEolSummary {
partModelId: string;
mpn: string;
manufacturerId: string; manufacturerId: string;
name: string; manufacturerName: string;
eolDate: string | null; eolDate: string;
deployedCount: number; deployedCount: number;
} }
export interface OperationsAnalytics {
repairs7d: number;
repairs30d: number;
repairsTrend30d: { date: string; count: number }[];
custodyBacklog: { userId: string; username: string; count: number }[];
}
export interface DashboardAnalytics { export interface DashboardAnalytics {
totalParts: number; totalParts: number;
byState: StateCount[]; byState: StateCount[];
ageBuckets: AgeBucket[]; ageBuckets: AgeBucket[];
topBins: BinCount[]; topBins: BinCount[];
deployedPastEol: ManufacturerEolSummary[]; deployedPastEol: PartModelEolSummary[];
openRepairs: number; upcomingEol: PartModelEolSummary[];
operations?: OperationsAnalytics;
} }

Some files were not shown because too many files have changed in this diff Show More