Compare commits
33 Commits
f32ece6f74
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| be20fe587a | |||
| da6bd071ee | |||
| db8e86b749 | |||
| d739411510 | |||
| 51512ff649 | |||
| 22ce6d18ee | |||
| 52e092502b | |||
| ae65d9f2a8 | |||
| e60d049e69 | |||
| a2b088463d | |||
| 62a3d615f4 | |||
| c35bc39adf | |||
| 09d1d96cb4 | |||
| 1d53e81d5e | |||
| c6fb839005 | |||
| 13e3444258 | |||
| 0b29e706b0 | |||
| 95e501a9c8 | |||
| b0e9c5d1d0 | |||
| 60255f20bb | |||
| 3d77f2846d | |||
| 6690d8a5dd | |||
| 0f952d6c1b | |||
| 23bd0f0c6a | |||
| a89cc36489 | |||
| 439c1b41e6 | |||
| fcd4aa6542 | |||
| 07431c6550 | |||
| 37494c17ef | |||
| 56ad33125d | |||
| 68ba048462 | |||
| d8d734d3f3 | |||
| acf6fc1103 |
@@ -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
|
||||||
|
|
||||||
@@ -51,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
|
||||||
@@ -84,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 }}"
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
# Vector
|
# Vector
|
||||||
|
|
||||||
Hardware parts inventory system. Tracks serialized parts across sites → rooms → bins, with a full audit trail, repair/RMA workflow, tag-based organization, manufacturer EOL tracking, and signed webhook delivery for external integrations.
|
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.
|
||||||
|
|
||||||
Vector 2.0 is a ground-up TypeScript rewrite of the original JavaScript codebase, delivered as a pnpm + Turbo monorepo with shadcn/ui on the frontend and a service-layered Express API on the backend.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,20 +53,24 @@ Vector 2.0 is a ground-up TypeScript rewrite of the original JavaScript codebase
|
|||||||
```
|
```
|
||||||
vector/
|
vector/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── api/ Express 5 + Prisma + zod. Controllers → services → tx.
|
│ ├── api/ Express 5 + Prisma + zod. controllers → services → tx.
|
||||||
│ ├── web/ React 19 + Vite + TanStack Query/Table + shadcn/ui.
|
│ ├── web/ React 19 + Vite + TanStack Query/Table + shadcn/ui.
|
||||||
│ └── e2e/ Playwright smoke tests (login, parts, repairs, admin).
|
│ └── e2e/ Playwright smoke tests (login, parts, repairs, admin).
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── db/ Prisma schema, migrations, seed, singleton client.
|
│ ├── db/ Prisma schema, migrations, seed, singleton client.
|
||||||
│ ├── shared/ zod schemas + DTOs — single source of truth for the API contract.
|
│ ├── shared/ zod schemas + DTOs — the source of truth for the API.
|
||||||
│ ├── ui/ shadcn primitives + Vector design tokens.
|
│ ├── ui/ shadcn primitives + Vector design tokens.
|
||||||
│ └── config/ Shared tsconfig presets + Tailwind preset.
|
│ └── config/ Shared tsconfig + Tailwind tokens.
|
||||||
├── .gitea/workflows/ CI (lint · typecheck · test · build) and gated E2E job.
|
├── .gitea/workflows/ci.yaml lint · typecheck · test · build, gated E2E job.
|
||||||
├── docker-compose.yml Postgres + Redis for local development.
|
├── docker-compose.yml api + web + redis (production-style stack).
|
||||||
└── turbo.json Pipeline: `build`, `test`, `typecheck`, `lint`.
|
└── turbo.json dev · build · test · typecheck · lint.
|
||||||
```
|
```
|
||||||
|
|
||||||
The API is split along a strict controller → service → transaction boundary. Every service function takes `(tx, input, actor)` so controllers can compose multiple services inside a single `prisma.$transaction`, guaranteeing that part mutations and their `PartEvent` audit rows are atomic.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -32,100 +78,100 @@ The API is split along a strict controller → service → transaction boundary.
|
|||||||
|
|
||||||
| Layer | Choice |
|
| Layer | Choice |
|
||||||
| -------------- | ---------------------------------------------------------------------- |
|
| -------------- | ---------------------------------------------------------------------- |
|
||||||
| Language | TypeScript strict mode across all workspaces |
|
| Language | TypeScript strict mode across every workspace |
|
||||||
| API | Express 5, Prisma 5, zod, JWT + refresh-token rotation, CSRF, helmet |
|
| API | Express 5, Prisma 5, zod, JWT + refresh-token rotation, CSRF, helmet |
|
||||||
| Web | React 19, Vite, TanStack Query, TanStack Table, react-hook-form, nuqs |
|
| Web | React 19, Vite, TanStack Query, TanStack Table, react-hook-form, nuqs |
|
||||||
| UI | shadcn/ui on Radix primitives, Tailwind v4, Sonner toasts, Recharts |
|
| UI | shadcn/ui on Radix primitives, Tailwind v4, Sonner toasts, Recharts |
|
||||||
| Database | SQLite for dev (Postgres cutover prepared — schema is portable) |
|
| Database | SQLite (single-file, shipped in prod via Docker volume) |
|
||||||
| Observability | pino (JSON logs in prod, pretty in dev) + request-scoped `requestId` |
|
| Observability | pino (JSON in prod, pretty in dev) with per-request `requestId` |
|
||||||
| Testing | Vitest (unit, coverage gate on services/lib), Playwright (E2E) |
|
| Testing | Vitest (unit), Playwright (E2E) |
|
||||||
| Monorepo tools | pnpm workspaces + Turborepo |
|
| Monorepo | pnpm workspaces + Turborepo |
|
||||||
| CI | Gitea Actions + Renovate (self-hosted) |
|
| 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Getting started
|
## Quick start
|
||||||
|
|
||||||
### Prerequisites
|
Prerequisites: **Node 20+**, **pnpm 10+** (`corepack enable`).
|
||||||
|
|
||||||
- Node.js ≥ 20
|
|
||||||
- pnpm ≥ 10 (`corepack enable` or `npm i -g pnpm`)
|
|
||||||
- Docker (optional; required for Postgres + Redis)
|
|
||||||
|
|
||||||
### Install
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm -C packages/db exec prisma generate
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
|
|
||||||
Copy the API env template and generate a JWT secret:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp apps/api/.env.example apps/api/.env
|
cp apps/api/.env.example apps/api/.env
|
||||||
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # paste into JWT_SECRET
|
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # paste as JWT_SECRET
|
||||||
```
|
|
||||||
|
|
||||||
The default `DATABASE_URL` points at a local SQLite file. To use the containerized Postgres instead, start Docker and update the URL:
|
pnpm -C packages/db exec prisma migrate dev
|
||||||
|
pnpm -C packages/db run db:seed
|
||||||
|
|
||||||
```bash
|
|
||||||
docker compose up -d postgres redis
|
|
||||||
# then edit apps/api/.env:
|
|
||||||
# DATABASE_URL=postgresql://vector:vector@localhost:5432/vector
|
|
||||||
```
|
|
||||||
|
|
||||||
### Database
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm -C packages/db exec prisma migrate dev # apply migrations
|
|
||||||
pnpm -C packages/db run db:seed # create default admin:admin user
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- API: <http://localhost:3001>
|
- API: <http://localhost:3001>
|
||||||
- Web: <http://localhost:5173> (proxies `/api` → API)
|
- Web: <http://localhost:5173> (proxies `/api` → API)
|
||||||
- Default login: `admin` / `admin` — **change immediately.**
|
- Default credentials: **`admin` / `admin`** — change them immediately.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Common tasks
|
## Deployment
|
||||||
|
|
||||||
All tasks run at the workspace root; Turbo fans them out to the right packages with caching.
|
`docker-compose.yml` runs the full stack from prebuilt images
|
||||||
|
(`vector-api`, `vector-web`, `redis`). The SQLite database lives in the
|
||||||
|
`vector-data` volume.
|
||||||
|
|
||||||
| Command | What it does |
|
```bash
|
||||||
| --------------------------------- | --------------------------------------------------------------- |
|
# 1. authenticate to the registry
|
||||||
| `pnpm dev` | Run `apps/api` and `apps/web` concurrently |
|
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 build` | Build every package and app |
|
||||||
| `pnpm typecheck` | `tsc --noEmit` across every workspace |
|
| `pnpm typecheck` | `tsc --noEmit` across the graph |
|
||||||
| `pnpm lint` | ESLint across every workspace |
|
| `pnpm lint` | ESLint across the graph |
|
||||||
| `pnpm test` | Run all Vitest unit test suites |
|
| `pnpm test` | Vitest across packages that define it |
|
||||||
| `pnpm -C apps/api test:coverage` | Unit tests + v8 coverage report (gate: 60% on services/lib) |
|
| `pnpm -C apps/api test:coverage` | Services/lib coverage report (no threshold gate yet) |
|
||||||
| `pnpm -C apps/e2e test` | Run Playwright smoke tests (requires stack running + creds) |
|
| `pnpm -C apps/e2e test` | Playwright, against a live stack (see below) |
|
||||||
| `pnpm -C packages/db run db:studio` | Open Prisma Studio against the current database |
|
| `pnpm -C packages/db run db:studio` | Prisma Studio |
|
||||||
| `pnpm -C packages/db run db:reset` | Drop schema, re-migrate, re-seed |
|
| `pnpm -C packages/db run db:reset` | Drop schema, migrate, seed |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
**Unit tests** live next to the code they cover (`*.test.ts`). Coverage is enforced on `apps/api/src/{services,lib}/**`.
|
**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.
|
||||||
|
|
||||||
- `packages/shared` — zod schema contracts (parts, webhooks, auth).
|
**End-to-end tests** in `apps/e2e/` run Playwright against a live stack and
|
||||||
- `apps/api` — pure helpers (`signBody`, `csvCell`, `http-error`), plus the analytics aggregator tested with an in-memory `Tx` double.
|
skip themselves unless `TEST_USERNAME` and `TEST_PASSWORD` are set:
|
||||||
|
|
||||||
**End-to-end tests** in `apps/e2e/` run against a live stack. Each spec skips itself unless `TEST_USERNAME` and `TEST_PASSWORD` are present in the environment:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm dev # in one terminal
|
pnpm dev # terminal 1
|
||||||
|
|
||||||
TEST_USERNAME=admin TEST_PASSWORD=admin \
|
TEST_USERNAME=admin TEST_PASSWORD=admin \
|
||||||
pnpm -C apps/e2e exec playwright install --with-deps chromium
|
pnpm -C apps/e2e exec playwright install --with-deps chromium
|
||||||
|
|
||||||
TEST_USERNAME=admin TEST_PASSWORD=admin \
|
TEST_USERNAME=admin TEST_PASSWORD=admin \
|
||||||
pnpm -C apps/e2e test
|
pnpm -C apps/e2e test
|
||||||
```
|
```
|
||||||
@@ -136,7 +182,7 @@ Reports land in `apps/e2e/playwright-report/`.
|
|||||||
|
|
||||||
## Continuous integration
|
## Continuous integration
|
||||||
|
|
||||||
CI runs on Gitea Actions — [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml). Every push and pull request executes:
|
`.gitea/workflows/ci.yaml` runs on every push and PR:
|
||||||
|
|
||||||
1. `pnpm install --frozen-lockfile`
|
1. `pnpm install --frozen-lockfile`
|
||||||
2. `prisma generate`
|
2. `prisma generate`
|
||||||
@@ -144,49 +190,26 @@ CI runs on Gitea Actions — [.gitea/workflows/ci.yaml](.gitea/workflows/ci.yaml
|
|||||||
4. `pnpm typecheck`
|
4. `pnpm typecheck`
|
||||||
5. `pnpm -C packages/shared test` + `pnpm -C apps/api test:coverage`
|
5. `pnpm -C packages/shared test` + `pnpm -C apps/api test:coverage`
|
||||||
6. `pnpm build`
|
6. `pnpm build`
|
||||||
7. API coverage uploaded as an artifact.
|
7. API coverage uploaded as a workflow artifact.
|
||||||
|
|
||||||
A second Playwright job is gated behind the repository variable `ENABLE_E2E=true` and requires these secrets:
|
A second Playwright job is gated by repo variable `ENABLE_E2E=true` and needs
|
||||||
|
secrets `E2E_BASE_URL`, `E2E_USERNAME`, `E2E_PASSWORD`.
|
||||||
|
|
||||||
| Secret | Purpose |
|
Dependency updates come from self-hosted Renovate — grouped minor/patch PRs
|
||||||
| ----------------- | --------------------------------------- |
|
weekly, auto-merge for dev-dep patches, `prisma` + `@prisma/client` grouped
|
||||||
| `E2E_BASE_URL` | URL of a running Vector stack |
|
together, Radix/shadcn held for manual review.
|
||||||
| `E2E_USERNAME` | Test admin username |
|
|
||||||
| `E2E_PASSWORD` | Test admin password |
|
|
||||||
|
|
||||||
Dependency updates come from a self-hosted Renovate instance configured via [renovate.json](renovate.json) — grouped minor/patch PRs weekly, auto-merge for dev-dep patches, `prisma` + `@prisma/client` grouped together, Radix/shadcn held for manual review.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
- **API shape**: all responses follow `{ code, message, requestId, details? }` for errors. Paginated lists use `{ data, page, pageSize, total }`.
|
- **API shape** — errors are `{ code, message, requestId, details? }`;
|
||||||
- **Validation**: every request body and query string is parsed through a zod schema from `@vector/shared` before it reaches a controller. No ad-hoc validation inside controllers.
|
paginated lists are `{ data, page, pageSize, total }`.
|
||||||
- **Query keys**: the web app uses a hierarchical factory at [apps/web/src/lib/queryKeys.ts](apps/web/src/lib/queryKeys.ts). Invalidate by domain (`queryKeys.parts.all`) or by filter (`queryKeys.parts.list(filters)`).
|
- **Validation** — every request body and query is parsed through a zod schema
|
||||||
- **Commits**: [Conventional Commits](https://www.conventionalcommits.org/) — Renovate already expects them.
|
from `@vector/shared` before reaching a controller. No ad-hoc validation
|
||||||
- **Webhooks**: every delivery is signed with HMAC-SHA256 over `${timestamp}.${body}` and sent with headers `x-vector-signature`, `x-vector-timestamp`, `x-vector-event`, and the recursion-guard `x-vector-webhook: v1`.
|
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)`).
|
||||||
## Roadmap
|
- **Commits** — [Conventional Commits](https://www.conventionalcommits.org/).
|
||||||
|
Renovate already expects them.
|
||||||
All nine phases of the 2.0 rewrite are in-tree:
|
|
||||||
|
|
||||||
| Phase | Scope |
|
|
||||||
| ----- | ------------------------------------------------------------------------ |
|
|
||||||
| 0 | Monorepo foundation (pnpm + Turbo, apps scaffolded) |
|
|
||||||
| 1 | Strict TypeScript, Prisma schema, env validation, singleton client |
|
|
||||||
| 2 | Service-layered API, transactional mutations, auth hardening, pagination |
|
|
||||||
| 3 | Schema extensions (hosts, repairs, tags, categories, webhooks, FTS-ready) |
|
|
||||||
| 4 | shadcn/ui design system + `DataTable<T>`, Form pattern, query-keys factory|
|
|
||||||
| 5 | Page-by-page rewrite (Parts, Locations split, Manufacturers, Users) |
|
|
||||||
| 6 | Feature slice — Repair/RMA, Tags, Bulk ops, Saved views |
|
|
||||||
| 7 | Analytics dashboard (Recharts) + EOL + Webhooks + streaming CSV export |
|
|
||||||
| 8 | Vitest + Playwright + Gitea Actions CI + Renovate |
|
|
||||||
|
|
||||||
### Deferred follow-ups
|
|
||||||
|
|
||||||
- **Postgres cutover.** Schema is already portable; the data-migration script lives at `packages/db/POSTGRES_FTS.md`.
|
|
||||||
- **BullMQ worker.** Replace the in-process webhook emitter in [apps/api/src/lib/webhook-emitter.ts](apps/api/src/lib/webhook-emitter.ts) with a Redis-backed worker. Signature is stable — one-line swap.
|
|
||||||
- **PDF audit export** via `@react-pdf/renderer` in the worker.
|
|
||||||
- **CSV import wizard UI** to pair with the existing `CsvImportJob` staging table.
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
||||||
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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['0–30d']).toBe(1);
|
expect(byLabel['0–30d']).toBe(1);
|
||||||
expect(byLabel['31–90d']).toBe(1);
|
expect(byLabel['31–90d']).toBe(1);
|
||||||
expect(byLabel['1–2y']).toBe(1);
|
expect(byLabel['1–2y']).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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+156
-102
@@ -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,
|
||||||
|
state: r.brokenPart.state,
|
||||||
|
},
|
||||||
|
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({
|
||||||
export async function create(
|
|
||||||
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: {
|
data: {
|
||||||
partId: input.partId,
|
serialNumber: input.brokenSerial,
|
||||||
hostId: input.hostId ?? null,
|
partModelId: pm.id,
|
||||||
assigneeId: input.assigneeId ?? null,
|
manufacturerId: pm.manufacturerId,
|
||||||
notes: input.notes ?? null,
|
state: 'DEPLOYED',
|
||||||
status: 'PENDING',
|
hostId: host.id,
|
||||||
},
|
},
|
||||||
include: repairInclude,
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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={
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,15 +40,43 @@ 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>;
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}`);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+204
@@ -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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user