Compare commits
35 Commits
7c0d422228
...
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 | |||
| f32ece6f74 | |||
| 261d6a526c |
@@ -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
|
||||
|
||||
@@ -29,7 +29,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -52,7 +51,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Upload API coverage
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: api-coverage
|
||||
@@ -75,7 +74,6 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: pnpm
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm -C packages/db exec prisma generate
|
||||
@@ -86,9 +84,51 @@ jobs:
|
||||
TEST_USERNAME: ${{ secrets.E2E_USERNAME }}
|
||||
TEST_PASSWORD: ${{ secrets.E2E_PASSWORD }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report
|
||||
path: apps/e2e/playwright-report
|
||||
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,41 +1,215 @@
|
||||
# Vector 2.0
|
||||
# Vector
|
||||
|
||||
Hardware parts inventory — monorepo.
|
||||
Serialized hardware inventory for engineering fleets. Track every part from
|
||||
receiving → bin → host → repair → destruction, with a complete per-part audit
|
||||
trail, field-maintenance ticketing, per-technician custody, and manufacturer
|
||||
EOL visibility.
|
||||
|
||||
## Layout
|
||||
Vector is a pnpm + Turborepo monorepo written in strict TypeScript end to end:
|
||||
an Express/Prisma API, a React/Vite web client, shared zod contracts, and a
|
||||
shadcn/ui design system.
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
**Inventory**
|
||||
|
||||
- Serialized `Part`s live under `Site → Room → Bin` locations or on a `Host`.
|
||||
- Every state change writes a `PartEvent` in the same transaction — nothing
|
||||
mutates silently.
|
||||
- `PartModel`s are grouped by `Manufacturer` and `Category`, with optional EOL
|
||||
dates that feed dashboard risk widgets.
|
||||
- `Tag`s and `SavedView`s for cross-cutting organization and reusable filters.
|
||||
|
||||
**Field workflow**
|
||||
|
||||
- `FM` tickets track active issues per host, with attached repairs and close
|
||||
times.
|
||||
- `Repair` logs pair a broken serial with its replacement and auto-ingest parts
|
||||
from a tech's input when the broken serial isn't yet in Vector.
|
||||
- Per-technician **custody** tracks parts held for repair, pending drop-off, or
|
||||
pending destruction — so broken or pre-staged parts never go missing between
|
||||
events.
|
||||
|
||||
**Operations dashboard**
|
||||
|
||||
- Universal KPIs: total parts, total spent, deployed value, open FMs,
|
||||
past-EOL deployments, upcoming-EOL deployments (≤180 d).
|
||||
- Admin-only: 7/30-day repair tempo, FMs opened, average FM close time, repairs
|
||||
trend chart, open-FMs-by-host, and a custody backlog by user.
|
||||
|
||||
**Integrations**
|
||||
|
||||
- Outbound webhooks signed with `HMAC-SHA256(timestamp + "." + body)`, headers
|
||||
`x-vector-signature`, `x-vector-timestamp`, `x-vector-event`, plus a
|
||||
`x-vector-webhook: v1` recursion guard.
|
||||
- Streaming CSV export of the audit log for admins.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/
|
||||
web/ # React + Vite client
|
||||
api/ # Express + Prisma API
|
||||
packages/
|
||||
db/ # Prisma schema + client (placeholder)
|
||||
shared/ # Shared zod schemas + types (placeholder)
|
||||
ui/ # Design system + shadcn primitives (placeholder)
|
||||
config/ # Shared eslint / tsconfig / tailwind (placeholder)
|
||||
vector/
|
||||
├── apps/
|
||||
│ ├── api/ Express 5 + Prisma + zod. controllers → services → tx.
|
||||
│ ├── web/ React 19 + Vite + TanStack Query/Table + shadcn/ui.
|
||||
│ └── e2e/ Playwright smoke tests (login, parts, repairs, admin).
|
||||
├── packages/
|
||||
│ ├── db/ Prisma schema, migrations, seed, singleton client.
|
||||
│ ├── shared/ zod schemas + DTOs — the source of truth for the API.
|
||||
│ ├── ui/ shadcn primitives + Vector design tokens.
|
||||
│ └── config/ Shared tsconfig + Tailwind tokens.
|
||||
├── .gitea/workflows/ci.yaml lint · typecheck · test · build, gated E2E job.
|
||||
├── docker-compose.yml api + web + redis (production-style stack).
|
||||
└── turbo.json dev · build · test · typecheck · lint.
|
||||
```
|
||||
|
||||
## Prereqs
|
||||
The API is strict about its boundaries. Every service takes `(tx, input, actor)`
|
||||
so controllers can compose multiple services inside a single
|
||||
`prisma.$transaction` — part mutations and their audit rows are always atomic
|
||||
or not at all. Controllers do no validation and no business logic; they parse
|
||||
through a zod schema and dispatch.
|
||||
|
||||
- Node >= 20
|
||||
- pnpm (via `npm i -g pnpm` or corepack)
|
||||
- Docker (for Postgres + Redis in later phases — current apps still use SQLite)
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Layer | Choice |
|
||||
| -------------- | ---------------------------------------------------------------------- |
|
||||
| Language | TypeScript strict mode across every workspace |
|
||||
| API | Express 5, Prisma 5, zod, JWT + refresh-token rotation, CSRF, helmet |
|
||||
| Web | React 19, Vite, TanStack Query, TanStack Table, react-hook-form, nuqs |
|
||||
| UI | shadcn/ui on Radix primitives, Tailwind v4, Sonner toasts, Recharts |
|
||||
| Database | SQLite (single-file, shipped in prod via Docker volume) |
|
||||
| Observability | pino (JSON in prod, pretty in dev) with per-request `requestId` |
|
||||
| Testing | Vitest (unit), Playwright (E2E) |
|
||||
| Monorepo | pnpm workspaces + Turborepo |
|
||||
| CI / deps | Gitea Actions + self-hosted Renovate |
|
||||
|
||||
The schema is intentionally Postgres-portable; SQLite is the default because
|
||||
Vector is deployed as a single-container stack per site.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
Prerequisites: **Node 20+**, **pnpm 10+** (`corepack enable`).
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # runs apps/web and apps/api concurrently via Turbo
|
||||
cp apps/api/.env.example apps/api/.env
|
||||
node -e "console.log(require('crypto').randomBytes(48).toString('hex'))" # paste as JWT_SECRET
|
||||
|
||||
pnpm -C packages/db exec prisma migrate dev
|
||||
pnpm -C packages/db run db:seed
|
||||
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The API listens on `http://localhost:3001`; the web app proxies `/api` to it and serves on `http://localhost:5173`.
|
||||
- API: <http://localhost:3001>
|
||||
- Web: <http://localhost:5173> (proxies `/api` → API)
|
||||
- Default credentials: **`admin` / `admin`** — change them immediately.
|
||||
|
||||
## Phase status
|
||||
---
|
||||
|
||||
**Phase 0 — Monorepo foundation** ✅
|
||||
- pnpm workspaces + Turbo
|
||||
- `apps/web` and `apps/api` scaffolded
|
||||
- `packages/*` placeholders
|
||||
- `docker-compose.yml` for Postgres + Redis
|
||||
## Deployment
|
||||
|
||||
Later phases: TypeScript + Postgres migration, API refactor, schema extensions, shadcn redesign, feature slices, observability.
|
||||
`docker-compose.yml` runs the full stack from prebuilt images
|
||||
(`vector-api`, `vector-web`, `redis`). The SQLite database lives in the
|
||||
`vector-data` volume.
|
||||
|
||||
```bash
|
||||
# 1. authenticate to the registry
|
||||
docker login gitea.thewrightserver.net
|
||||
|
||||
# 2. create a .env next to docker-compose.yml with at minimum:
|
||||
# JWT_SECRET=<64-char hex>
|
||||
# CLIENT_ORIGIN=https://vector.example.com
|
||||
# WEB_PORT=8080
|
||||
# COOKIE_SECURE=true # required once you're behind TLS
|
||||
# TAG=latest # or a pinned commit SHA
|
||||
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Redis is included in anticipation of the BullMQ-backed webhook worker; the
|
||||
in-process emitter currently used by the API doesn't depend on it yet.
|
||||
|
||||
---
|
||||
|
||||
## Development commands
|
||||
|
||||
All commands run at the workspace root; Turbo fans them out with caching.
|
||||
|
||||
| Command | Does |
|
||||
| -------------------------------------- | ------------------------------------------------------ |
|
||||
| `pnpm dev` | API + web concurrently |
|
||||
| `pnpm build` | Build every package and app |
|
||||
| `pnpm typecheck` | `tsc --noEmit` across the graph |
|
||||
| `pnpm lint` | ESLint across the graph |
|
||||
| `pnpm test` | Vitest across packages that define it |
|
||||
| `pnpm -C apps/api test:coverage` | Services/lib coverage report (no threshold gate yet) |
|
||||
| `pnpm -C apps/e2e test` | Playwright, against a live stack (see below) |
|
||||
| `pnpm -C packages/db run db:studio` | Prisma Studio |
|
||||
| `pnpm -C packages/db run db:reset` | Drop schema, migrate, seed |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
**Unit tests** live next to the code they cover (`*.test.ts`). The API covers
|
||||
services, `lib/` helpers, and the analytics aggregator (tested with an
|
||||
in-memory `Tx` double). Shared covers every zod contract.
|
||||
|
||||
**End-to-end tests** in `apps/e2e/` run Playwright against a live stack and
|
||||
skip themselves unless `TEST_USERNAME` and `TEST_PASSWORD` are set:
|
||||
|
||||
```bash
|
||||
pnpm dev # terminal 1
|
||||
|
||||
TEST_USERNAME=admin TEST_PASSWORD=admin \
|
||||
pnpm -C apps/e2e exec playwright install --with-deps chromium
|
||||
|
||||
TEST_USERNAME=admin TEST_PASSWORD=admin \
|
||||
pnpm -C apps/e2e test
|
||||
```
|
||||
|
||||
Reports land in `apps/e2e/playwright-report/`.
|
||||
|
||||
---
|
||||
|
||||
## Continuous integration
|
||||
|
||||
`.gitea/workflows/ci.yaml` runs on every push and PR:
|
||||
|
||||
1. `pnpm install --frozen-lockfile`
|
||||
2. `prisma generate`
|
||||
3. `pnpm lint`
|
||||
4. `pnpm typecheck`
|
||||
5. `pnpm -C packages/shared test` + `pnpm -C apps/api test:coverage`
|
||||
6. `pnpm build`
|
||||
7. API coverage uploaded as a workflow artifact.
|
||||
|
||||
A second Playwright job is gated by repo variable `ENABLE_E2E=true` and needs
|
||||
secrets `E2E_BASE_URL`, `E2E_USERNAME`, `E2E_PASSWORD`.
|
||||
|
||||
Dependency updates come from self-hosted Renovate — grouped minor/patch PRs
|
||||
weekly, auto-merge for dev-dep patches, `prisma` + `@prisma/client` grouped
|
||||
together, Radix/shadcn held for manual review.
|
||||
|
||||
---
|
||||
|
||||
## Conventions
|
||||
|
||||
- **API shape** — errors are `{ code, message, requestId, details? }`;
|
||||
paginated lists are `{ data, page, pageSize, total }`.
|
||||
- **Validation** — every request body and query is parsed through a zod schema
|
||||
from `@vector/shared` before reaching a controller. No ad-hoc validation
|
||||
inside route handlers.
|
||||
- **Query keys** — hierarchical factory at `apps/web/src/lib/queryKeys.ts`.
|
||||
Invalidate by domain (`queryKeys.parts.all`) or by filter set
|
||||
(`queryKeys.parts.list(filters)`).
|
||||
- **Commits** — [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
Renovate already expects them.
|
||||
|
||||
@@ -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",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pino": "^10.3.1",
|
||||
|
||||
+5
-10
@@ -3,7 +3,6 @@ import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import { pinoHttp } from 'pino-http';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { prisma } from '@vector/db';
|
||||
|
||||
import { env } from './env.js';
|
||||
@@ -14,6 +13,7 @@ import { errorHandler } from './middleware/error.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import manufacturerRoutes from './routes/manufacturers.js';
|
||||
import partModelRoutes from './routes/part-models.js';
|
||||
import siteRoutes from './routes/sites.js';
|
||||
import roomRoutes from './routes/rooms.js';
|
||||
import binRoutes from './routes/bins.js';
|
||||
@@ -22,6 +22,7 @@ import tagRoutes from './routes/tags.js';
|
||||
import categoryRoutes from './routes/categories.js';
|
||||
import hostRoutes from './routes/hosts.js';
|
||||
import repairRoutes from './routes/repairs.js';
|
||||
import custodyRoutes from './routes/custody.js';
|
||||
import savedViewRoutes from './routes/saved-views.js';
|
||||
import analyticsRoutes from './routes/analytics.js';
|
||||
import webhookRoutes from './routes/webhooks.js';
|
||||
@@ -67,18 +68,11 @@ app.get('/readyz', async (_req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const authLimiter = rateLimit({
|
||||
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/auth', authRoutes);
|
||||
app.use('/api', requireCsrf);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/manufacturers', manufacturerRoutes);
|
||||
app.use('/api/part-models', partModelRoutes);
|
||||
app.use('/api/sites', siteRoutes);
|
||||
app.use('/api/rooms', roomRoutes);
|
||||
app.use('/api/bins', binRoutes);
|
||||
@@ -87,6 +81,7 @@ app.use('/api/tags', tagRoutes);
|
||||
app.use('/api/categories', categoryRoutes);
|
||||
app.use('/api/hosts', hostRoutes);
|
||||
app.use('/api/repairs', repairRoutes);
|
||||
app.use('/api/custody', custodyRoutes);
|
||||
app.use('/api/saved-views', savedViewRoutes);
|
||||
app.use('/api/analytics', analyticsRoutes);
|
||||
app.use('/api/admin/webhooks', webhookRoutes);
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { NextFunction, Request, Response } from 'express';
|
||||
import { prisma } from '@vector/db';
|
||||
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 {
|
||||
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);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { errors } from '../lib/http-error.js';
|
||||
const accessCookieOpts: CookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: env.NODE_ENV === 'production',
|
||||
secure: env.COOKIE_SECURE,
|
||||
path: '/',
|
||||
maxAge: authService.ACCESS_TOKEN_TTL_MS,
|
||||
};
|
||||
@@ -17,7 +17,7 @@ const accessCookieOpts: CookieOptions = {
|
||||
const refreshCookieOpts: CookieOptions = {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: env.NODE_ENV === 'production',
|
||||
secure: env.COOKIE_SECURE,
|
||||
path: '/api/auth',
|
||||
maxAge: authService.REFRESH_TOKEN_TTL_MS,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
UpdateCategoryRequest,
|
||||
} from '@vector/shared';
|
||||
import * as svc from '../services/categories.js';
|
||||
import { errors } from '../lib/http-error.js';
|
||||
|
||||
export async function list(req: Request, res: Response, next: NextFunction) {
|
||||
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) {
|
||||
try {
|
||||
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 {
|
||||
CreateHostRequest,
|
||||
HostListQuery,
|
||||
HostTimelineQuery,
|
||||
UpdateHostRequest,
|
||||
} from '@vector/shared';
|
||||
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) {
|
||||
try {
|
||||
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);
|
||||
} catch (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) {
|
||||
try {
|
||||
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);
|
||||
} catch (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) {
|
||||
try {
|
||||
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) {
|
||||
try {
|
||||
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 { prisma } from '@vector/db';
|
||||
import type {
|
||||
CreateRepairJobRequest,
|
||||
RepairJobListQuery,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
||||
import * as svc from '../services/repairs.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 RepairJobListQuery;
|
||||
const q = req.validated!.query as RepairListQuery;
|
||||
const result = await prisma.$transaction((tx) => svc.list(tx, q));
|
||||
res.json(result);
|
||||
} 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) {
|
||||
try {
|
||||
const repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
|
||||
if (!repair) throw errors.notFound('Repair');
|
||||
res.json(repair);
|
||||
const r = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
|
||||
if (!r) throw errors.notFound('Repair');
|
||||
res.json(r);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listForPart(
|
||||
req: Request<{ id: string }>,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) {
|
||||
export async function log(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const repairs = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
|
||||
res.json(repairs);
|
||||
} catch (err) {
|
||||
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),
|
||||
);
|
||||
if (!req.user) throw errors.unauthorized();
|
||||
const input = req.validated!.body as LogRepairRequest;
|
||||
const repair = await prisma.$transaction((tx) => svc.log(tx, input, req.user!));
|
||||
res.status(201).json(repair);
|
||||
} catch (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);
|
||||
}
|
||||
|
||||
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 = {
|
||||
httpOnly: false,
|
||||
sameSite: 'lax',
|
||||
secure: env.NODE_ENV === 'production',
|
||||
secure: env.COOKIE_SECURE,
|
||||
path: '/',
|
||||
};
|
||||
res.cookie(CSRF_COOKIE, token, opts);
|
||||
|
||||
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
|
||||
const router = Router();
|
||||
|
||||
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.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateCategoryRequest), ctrl.update);
|
||||
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 {
|
||||
CreateHostRequest,
|
||||
HostListQuery,
|
||||
HostTimelineQuery,
|
||||
UpdateHostRequest,
|
||||
} from '@vector/shared';
|
||||
import * as ctrl from '../controllers/hosts.js';
|
||||
@@ -12,7 +13,10 @@ const router = Router();
|
||||
|
||||
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
|
||||
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/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.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import { validate } from '../middleware/validate.js';
|
||||
const router = Router();
|
||||
|
||||
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.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateManufacturerRequest), ctrl.update);
|
||||
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';
|
||||
import * as ctrl from '../controllers/parts.js';
|
||||
import * as tagsCtrl from '../controllers/tags.js';
|
||||
import * as repairsCtrl from '../controllers/repairs.js';
|
||||
import { requireAuth, requireRole } from '../middleware/auth.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.delete('/:id/tags/:tagId', requireAuth, tagsCtrl.unassignFromPart);
|
||||
|
||||
router.get('/:id/repairs', requireAuth, repairsCtrl.listForPart);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
import { Router } from 'express';
|
||||
import {
|
||||
CreateRepairJobRequest,
|
||||
RepairJobListQuery,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
||||
import * as ctrl from '../controllers/repairs.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { validate } from '../middleware/validate.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', requireAuth, validate('query', RepairJobListQuery), ctrl.list);
|
||||
router.post('/', requireAuth, validate('body', CreateRepairJobRequest), ctrl.create);
|
||||
router.get('/', requireAuth, validate('query', RepairListQuery), ctrl.list);
|
||||
router.post('/', requireAuth, validate('body', LogRepairRequest), ctrl.log);
|
||||
router.get('/:id', requireAuth, ctrl.get);
|
||||
router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update);
|
||||
router.delete('/:id', requireAuth, ctrl.remove);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,9 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { Tx } from './types.js';
|
||||
import { dashboard } from './analytics.js';
|
||||
|
||||
// Minimal in-memory tx double exercising the dashboard() aggregator.
|
||||
// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented.
|
||||
function makeTx(args: {
|
||||
type EolPartModel = {
|
||||
id: string;
|
||||
mpn: string;
|
||||
eolDate: Date | null;
|
||||
manufacturerId: string;
|
||||
manufacturer: { name: string };
|
||||
};
|
||||
|
||||
type FakeArgs = {
|
||||
partCount: number;
|
||||
stateRows: { state: string; count: number; totalPrice: number }[];
|
||||
parts: {
|
||||
@@ -12,56 +18,91 @@ function makeTx(args: {
|
||||
state: string;
|
||||
binId: string | null;
|
||||
createdAt: Date;
|
||||
manufacturerId: string;
|
||||
partModelId: string;
|
||||
}[];
|
||||
openRepairs: number;
|
||||
eolManufacturers: { id: string; name: string; eolDate: Date | null }[];
|
||||
pastEolModels: EolPartModel[];
|
||||
upcomingEolModels: EolPartModel[];
|
||||
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 = {
|
||||
part: {
|
||||
count: async () => args.partCount,
|
||||
groupBy: async () =>
|
||||
args.stateRows.map((s) => ({
|
||||
groupBy: async (q: { by: string[]; where?: { custodianId?: unknown } }) => {
|
||||
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,
|
||||
_count: { _all: s.count },
|
||||
_sum: { price: s.totalPrice },
|
||||
})),
|
||||
}));
|
||||
},
|
||||
findMany: async () => args.parts,
|
||||
},
|
||||
repairJob: {
|
||||
count: async () => args.openRepairs,
|
||||
partModel: {
|
||||
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: {
|
||||
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;
|
||||
}
|
||||
|
||||
const now = new Date('2026-04-16T00:00:00.000Z');
|
||||
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', () => {
|
||||
it('aggregates totals, state counts and open repairs', async () => {
|
||||
const EMPTY: FakeArgs = {
|
||||
partCount: 0,
|
||||
stateRows: [],
|
||||
parts: [],
|
||||
pastEolModels: [],
|
||||
upcomingEolModels: [],
|
||||
bins: [],
|
||||
};
|
||||
|
||||
describe('analytics.dashboard — base fields', () => {
|
||||
it('aggregates totals and state counts', async () => {
|
||||
const tx = makeTx({
|
||||
...EMPTY,
|
||||
partCount: 5,
|
||||
stateRows: [
|
||||
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
||||
{ 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.openRepairs).toBe(4);
|
||||
expect(r.byState).toEqual([
|
||||
{ state: 'SPARE', count: 3, totalPrice: 1500 },
|
||||
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
|
||||
@@ -70,75 +111,186 @@ describe('analytics.dashboard', () => {
|
||||
|
||||
it('buckets parts by age correctly', async () => {
|
||||
const tx = makeTx({
|
||||
...EMPTY,
|
||||
partCount: 4,
|
||||
stateRows: [],
|
||||
parts: [
|
||||
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), manufacturerId: 'm' },
|
||||
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), manufacturerId: 'm' },
|
||||
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), manufacturerId: 'm' },
|
||||
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), manufacturerId: 'm' },
|
||||
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' },
|
||||
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' },
|
||||
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
|
||||
{ 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]));
|
||||
expect(byLabel['0–30d']).toBe(1);
|
||||
expect(byLabel['31–90d']).toBe(1);
|
||||
expect(byLabel['1–2y']).toBe(1);
|
||||
expect(byLabel['2y+']).toBe(1);
|
||||
// totals should match
|
||||
expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4);
|
||||
});
|
||||
|
||||
it('ranks top bins and labels them site/room/bin', async () => {
|
||||
const tx = makeTx({
|
||||
...EMPTY,
|
||||
partCount: 4,
|
||||
stateRows: [],
|
||||
parts: [
|
||||
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), manufacturerId: 'm' },
|
||||
{ id: '4', state: 'SPARE', binId: null, 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), partModelId: 'pm' },
|
||||
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' },
|
||||
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
|
||||
],
|
||||
openRepairs: 0,
|
||||
eolManufacturers: [],
|
||||
bins: [
|
||||
{ id: 'b1', name: 'A1', 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([
|
||||
{ binId: 'b1', label: 'HQ / Lab / A1', count: 2 },
|
||||
{ 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({
|
||||
...EMPTY,
|
||||
partCount: 3,
|
||||
stateRows: [],
|
||||
parts: [
|
||||
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
|
||||
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm1' },
|
||||
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), manufacturerId: 'm2' },
|
||||
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
|
||||
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
|
||||
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
|
||||
],
|
||||
openRepairs: 0,
|
||||
eolManufacturers: [
|
||||
{ id: 'm1', name: 'Acme', eolDate: daysAgo(30) },
|
||||
{ id: 'm2', name: 'Beta', eolDate: daysAgo(10) },
|
||||
{ id: 'm3', name: 'Gamma', eolDate: daysAgo(5) },
|
||||
pastEolModels: [
|
||||
{
|
||||
id: 'pm1',
|
||||
mpn: 'ACM-100',
|
||||
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);
|
||||
expect(r.deployedPastEol.map((m) => m.name)).toEqual(['Acme', 'Beta']);
|
||||
expect(r.deployedPastEol[0]).toMatchObject({ manufacturerId: 'm1', deployedCount: 2 });
|
||||
expect(r.deployedPastEol[1]).toMatchObject({ manufacturerId: 'm2', deployedCount: 1 });
|
||||
const r = await dashboard(tx, { isAdmin: false });
|
||||
expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']);
|
||||
expect(r.deployedPastEol[0]).toMatchObject({
|
||||
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';
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
@@ -12,8 +12,25 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
|
||||
{ label: '2y+', maxDays: null },
|
||||
];
|
||||
|
||||
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
||||
const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([
|
||||
const CUSTODY_STATES = [
|
||||
'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.groupBy({
|
||||
by: ['state'],
|
||||
@@ -21,12 +38,27 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
||||
_sum: { price: true },
|
||||
}),
|
||||
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.manufacturer.findMany({
|
||||
where: { eolDate: { not: null, lte: new Date() } },
|
||||
select: { id: true, name: true, eolDate: true },
|
||||
tx.partModel.findMany({
|
||||
where: { eolDate: { not: null, lte: now } },
|
||||
select: {
|
||||
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,
|
||||
}));
|
||||
|
||||
const now = Date.now();
|
||||
const nowMs = now.getTime();
|
||||
const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 }));
|
||||
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 bucket = idx >= 0 ? buckets[idx] : undefined;
|
||||
if (bucket) bucket.count += 1;
|
||||
@@ -69,20 +101,101 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
|
||||
count: binCounts.get(id) ?? 0,
|
||||
}));
|
||||
|
||||
const deployedByMfg = new Map<string, number>();
|
||||
const deployedByModel = new Map<string, number>();
|
||||
for (const part of parts) {
|
||||
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) => ({
|
||||
manufacturerId: m.id,
|
||||
name: m.name,
|
||||
eolDate: m.eolDate ? m.eolDate.toISOString() : null,
|
||||
deployedCount: deployedByMfg.get(m.id) ?? 0,
|
||||
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) => 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 type {
|
||||
CategoryInsights,
|
||||
CategoryListQuery,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
@@ -7,6 +8,147 @@ import type {
|
||||
import { errors } from '../lib/http-error.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) {
|
||||
const { page, pageSize } = q;
|
||||
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 {
|
||||
CreateHostRequest,
|
||||
HostListQuery,
|
||||
HostTimelineQuery,
|
||||
UpdateHostRequest,
|
||||
} from '@vector/shared';
|
||||
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) {
|
||||
const { page, pageSize, q: search } = q;
|
||||
const where: Prisma.HostWhereInput = search
|
||||
? {
|
||||
OR: [
|
||||
const where: Prisma.HostWhereInput = {};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ name: { contains: search } },
|
||||
{ assetId: { 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([
|
||||
tx.host.findMany({
|
||||
where,
|
||||
@@ -33,46 +60,313 @@ export function get(tx: Tx, id: string) {
|
||||
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 {
|
||||
return await tx.host.create({
|
||||
host = await tx.host.create({
|
||||
data: {
|
||||
assetId: input.assetId,
|
||||
name: input.name,
|
||||
location: input.location ?? null,
|
||||
notes: input.notes ?? null,
|
||||
state: input.state ?? 'DEPLOYED',
|
||||
stack: input.stack ?? 'PRODUCTION',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
throw errors.conflict('Host name already exists');
|
||||
throw errors.conflict(mapUniqueViolation(err.meta?.target));
|
||||
}
|
||||
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 = {};
|
||||
if (input.assetId !== undefined) data.assetId = input.assetId;
|
||||
if (input.name !== undefined) data.name = input.name;
|
||||
if (input.location !== undefined) data.location = input.location;
|
||||
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 {
|
||||
return await tx.host.update({ where: { id }, data });
|
||||
host = await tx.host.update({ where: { id }, data });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
await tx.host.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw errors.notFound('Host');
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (err.code === 'P2025') throw errors.notFound('Host');
|
||||
if (err.code === 'P2003') throw errors.conflict('Cannot delete: host has repairs assigned');
|
||||
}
|
||||
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 type {
|
||||
CreateManufacturerRequest,
|
||||
ManufacturerInsights,
|
||||
UpdateManufacturerRequest,
|
||||
PaginationQuery,
|
||||
} from '@vector/shared';
|
||||
import { errors } from '../lib/http-error.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) {
|
||||
const { page, pageSize } = q;
|
||||
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) {
|
||||
try {
|
||||
return await tx.manufacturer.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
eolDate: input.eolDate ? new Date(input.eolDate) : null,
|
||||
},
|
||||
});
|
||||
return await tx.manufacturer.create({ data: { name: input.name } });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
|
||||
throw errors.conflict('Manufacturer already exists');
|
||||
@@ -40,7 +177,6 @@ export async function update(tx: Tx, id: string, input: UpdateManufacturerReques
|
||||
try {
|
||||
const data: Prisma.ManufacturerUpdateInput = {};
|
||||
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 });
|
||||
} catch (err) {
|
||||
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,
|
||||
PaginationQuery,
|
||||
PartListQuery,
|
||||
PartState as PartStateValue,
|
||||
UpdatePartRequest,
|
||||
} from '@vector/shared';
|
||||
import { errors } from '../lib/http-error.js';
|
||||
import * as partModelsSvc from './part-models.js';
|
||||
import * as tagsSvc from './tags.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 = {
|
||||
manufacturer: true,
|
||||
partModel: { include: { category: true } },
|
||||
bin: { include: { room: { include: { site: true } } } },
|
||||
category: true,
|
||||
host: true,
|
||||
custodian: { select: { id: true, username: true } },
|
||||
tags: { include: { tag: true } },
|
||||
} satisfies Prisma.PartInclude;
|
||||
|
||||
@@ -40,26 +100,52 @@ function flattenTags(part: PartWithRelations): PartWithPath {
|
||||
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 {
|
||||
const where: Prisma.PartWhereInput = {};
|
||||
if (q.state) where.state = q.state;
|
||||
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.categoryId) where.categoryId = q.categoryId;
|
||||
if (q.mpn) where.mpn = { contains: q.mpn };
|
||||
if (q.partModelId) where.partModelId = q.partModelId;
|
||||
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
|
||||
if (q.q) {
|
||||
where.OR = [
|
||||
{ serialNumber: { contains: q.q } },
|
||||
{ mpn: { contains: q.q } },
|
||||
{ partModel: { mpn: { contains: q.q } } },
|
||||
{ notes: { contains: q.q } },
|
||||
];
|
||||
}
|
||||
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.
|
||||
where.manufacturer = { eolDate: { lt: new Date() } };
|
||||
}
|
||||
|
||||
const partModelFilter: Prisma.PartModelWhereInput = {};
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -89,17 +175,30 @@ export async function create(
|
||||
input: CreatePartRequest,
|
||||
actor: Actor | null,
|
||||
): 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 {
|
||||
const p = await tx.part.create({
|
||||
data: {
|
||||
serialNumber: input.serialNumber,
|
||||
mpn: input.mpn,
|
||||
manufacturerId: input.manufacturerId,
|
||||
partModelId,
|
||||
manufacturerId,
|
||||
price: input.price ?? null,
|
||||
state: input.state ?? 'SPARE',
|
||||
binId: input.binId ?? null,
|
||||
categoryId: input.categoryId ?? null,
|
||||
replacementPartId: input.replacementPartId ?? null,
|
||||
state,
|
||||
binId: location.binId,
|
||||
hostId: location.hostId,
|
||||
custodianId: location.custodianId,
|
||||
notes: input.notes ?? null,
|
||||
},
|
||||
include: partInclude,
|
||||
@@ -136,25 +235,49 @@ export async function update(
|
||||
|
||||
const data: Prisma.PartUpdateInput = {};
|
||||
if (input.serialNumber !== undefined) data.serialNumber = input.serialNumber;
|
||||
if (input.mpn !== undefined) data.mpn = input.mpn;
|
||||
if (input.manufacturerId !== undefined) {
|
||||
data.manufacturer = { connect: { id: input.manufacturerId } };
|
||||
if (input.partModelId !== undefined) {
|
||||
const pm = await tx.partModel.findUnique({ where: { id: input.partModelId } });
|
||||
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.state !== undefined) data.state = input.state;
|
||||
if (input.binId !== undefined) {
|
||||
data.bin = input.binId ? { connect: { id: input.binId } } : { disconnect: true };
|
||||
}
|
||||
if (input.categoryId !== undefined) {
|
||||
data.category = input.categoryId
|
||||
? { connect: { id: input.categoryId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.replacementPartId !== undefined) {
|
||||
data.replacement = input.replacementPartId
|
||||
? { connect: { id: input.replacementPartId } }
|
||||
|
||||
let nextBinId: string | null = current.binId;
|
||||
let nextHostId: string | null = current.hostId;
|
||||
let nextCustodianId: string | null = current.custodianId;
|
||||
const locationTouched =
|
||||
input.state !== undefined ||
|
||||
input.binId !== undefined ||
|
||||
input.hostId !== undefined ||
|
||||
input.custodianId !== undefined;
|
||||
if (locationTouched) {
|
||||
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 };
|
||||
}
|
||||
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
|
||||
let part: PartWithRelations;
|
||||
@@ -181,7 +304,7 @@ export async function update(
|
||||
newValue: input.state,
|
||||
});
|
||||
}
|
||||
if (input.binId !== undefined && input.binId !== current.binId) {
|
||||
if (nextBinId !== current.binId) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
userId,
|
||||
@@ -191,14 +314,34 @@ export async function update(
|
||||
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({
|
||||
partId: part.id,
|
||||
userId,
|
||||
type: 'FIELD_UPDATED',
|
||||
field: 'mpn',
|
||||
oldValue: current.mpn,
|
||||
newValue: input.mpn,
|
||||
field: 'partModel',
|
||||
oldValue: current.partModel.mpn,
|
||||
newValue: part.partModel.mpn,
|
||||
});
|
||||
}
|
||||
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
|
||||
@@ -211,26 +354,6 @@ export async function update(
|
||||
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) {
|
||||
events.push({
|
||||
partId: part.id,
|
||||
@@ -267,8 +390,11 @@ export async function remove(tx: Tx, id: string) {
|
||||
try {
|
||||
await tx.part.delete({ where: { id } });
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw errors.notFound('Part');
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
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;
|
||||
}
|
||||
@@ -295,6 +421,7 @@ export interface BulkPartsInput {
|
||||
ids: string[];
|
||||
state?: CreatePartRequest['state'];
|
||||
binId?: string | null;
|
||||
hostId?: string | null;
|
||||
addTagIds?: string[];
|
||||
removeTagIds?: string[];
|
||||
}
|
||||
@@ -312,12 +439,13 @@ export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | n
|
||||
const patch: UpdatePartRequest = {};
|
||||
if (input.state !== undefined) patch.state = input.state;
|
||||
if (input.binId !== undefined) patch.binId = input.binId;
|
||||
if (input.hostId !== undefined) patch.hostId = input.hostId;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await update(tx, id, patch, actor);
|
||||
}
|
||||
if (input.addTagIds || input.removeTagIds) {
|
||||
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.removeTagIds ?? []).forEach((t) => next.delete(t));
|
||||
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 type {
|
||||
CreateRepairJobRequest,
|
||||
RepairJobListQuery,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import type { LogRepairRequest, RepairListQuery } from '@vector/shared';
|
||||
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';
|
||||
|
||||
// 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 = {
|
||||
part: {
|
||||
include: { manufacturer: true },
|
||||
},
|
||||
host: true,
|
||||
assignee: { select: { id: true, username: true, email: true, role: true } },
|
||||
} satisfies Prisma.RepairJobInclude;
|
||||
brokenPart: { include: { partModel: true, manufacturer: true } },
|
||||
replacement: { include: { partModel: true, manufacturer: true } },
|
||||
performedBy: { select: { id: true, username: true } },
|
||||
} satisfies Prisma.RepairInclude;
|
||||
|
||||
export async function list(tx: Tx, q: RepairJobListQuery) {
|
||||
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'] };
|
||||
export type RepairWithRelations = Prisma.RepairGetPayload<{ include: typeof repairInclude }>;
|
||||
|
||||
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([
|
||||
tx.repairJob.findMany({
|
||||
tx.repair.findMany({
|
||||
where,
|
||||
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
|
||||
orderBy: { performedAt: 'desc' },
|
||||
include: repairInclude,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
tx.repairJob.count({ where }),
|
||||
tx.repair.count({ where }),
|
||||
]);
|
||||
return { data, page, pageSize, total };
|
||||
}
|
||||
|
||||
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) {
|
||||
return tx.repairJob.findMany({
|
||||
where: { partId },
|
||||
orderBy: { openedAt: 'desc' },
|
||||
include: repairInclude,
|
||||
function repairPayload(r: RepairWithRelations) {
|
||||
return {
|
||||
id: r.id,
|
||||
host: { id: r.host.id, assetId: r.host.assetId, name: r.host.name },
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
const created = await tx.part.create({
|
||||
data: {
|
||||
partId: input.partId,
|
||||
hostId: input.hostId ?? null,
|
||||
assigneeId: input.assigneeId ?? null,
|
||||
notes: input.notes ?? null,
|
||||
status: 'PENDING',
|
||||
serialNumber: input.brokenSerial,
|
||||
partModelId: pm.id,
|
||||
manufacturerId: pm.manufacturerId,
|
||||
state: 'DEPLOYED',
|
||||
hostId: host.id,
|
||||
},
|
||||
include: repairInclude,
|
||||
include: { partModel: true },
|
||||
});
|
||||
await tx.partEvent.create({
|
||||
data: {
|
||||
partId: part.id,
|
||||
userId: actor?.id ?? null,
|
||||
type: 'REPAIR_STARTED',
|
||||
newValue: repair.id,
|
||||
partId: created.id,
|
||||
userId: actor.id,
|
||||
type: 'CREATED',
|
||||
newValue: created.serialNumber,
|
||||
},
|
||||
});
|
||||
return repair;
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
|
||||
throw errors.badRequest('Invalid host or assignee id');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
broken = created;
|
||||
}
|
||||
|
||||
export async function update(
|
||||
tx: Tx,
|
||||
id: string,
|
||||
input: UpdateRepairJobRequest,
|
||||
actor: Actor | null,
|
||||
) {
|
||||
const current = await tx.repairJob.findUnique({ where: { id } });
|
||||
if (!current) throw errors.notFound('Repair');
|
||||
// 3. Custody state is driven by the broken model's destroyOnFail flag.
|
||||
const custodyState = broken.partModel.destroyOnFail
|
||||
? 'PENDING_DESTRUCTION_IN_CUSTODY'
|
||||
: 'PENDING_DROP_IN_CUSTODY';
|
||||
|
||||
const data: Prisma.RepairJobUpdateInput = {};
|
||||
if (input.status !== undefined && input.status !== current.status) {
|
||||
data.status = input.status;
|
||||
// closedAt follows terminal status transitions.
|
||||
const nowTerminal = input.status === 'COMPLETED' || input.status === 'CANCELLED';
|
||||
const wasTerminal = current.status === 'COMPLETED' || current.status === 'CANCELLED';
|
||||
if (nowTerminal && !wasTerminal) data.closedAt = new Date();
|
||||
if (!nowTerminal && wasTerminal) data.closedAt = null;
|
||||
}
|
||||
if (input.hostId !== undefined) {
|
||||
data.host = input.hostId ? { connect: { id: input.hostId } } : { disconnect: true };
|
||||
}
|
||||
if (input.assigneeId !== undefined) {
|
||||
data.assignee = input.assigneeId
|
||||
? { connect: { id: input.assigneeId } }
|
||||
: { disconnect: true };
|
||||
}
|
||||
if (input.notes !== undefined) data.notes = input.notes;
|
||||
// 4. Transition both parts through the standard parts.update machinery so every state
|
||||
// and location change emits the usual PartEvents. The resolver clears host/bin
|
||||
// automatically when entering custody / DEPLOYED.
|
||||
await partsSvc.update(
|
||||
tx,
|
||||
broken.id,
|
||||
{ state: custodyState, custodianId: actor.id },
|
||||
actor,
|
||||
);
|
||||
await partsSvc.update(
|
||||
tx,
|
||||
replacement.id,
|
||||
{ state: 'DEPLOYED', hostId: host.id },
|
||||
actor,
|
||||
);
|
||||
|
||||
const repair = await tx.repairJob.update({
|
||||
where: { id },
|
||||
data,
|
||||
// 5. Persist the Repair row.
|
||||
const repair = await tx.repair.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
brokenPartId: broken.id,
|
||||
replacementPartId: replacement.id,
|
||||
performedById: actor.id,
|
||||
},
|
||||
include: repairInclude,
|
||||
});
|
||||
|
||||
if (input.status === 'COMPLETED' && current.status !== 'COMPLETED') {
|
||||
await tx.partEvent.create({
|
||||
data: {
|
||||
partId: repair.partId,
|
||||
userId: actor?.id ?? null,
|
||||
type: 'REPAIR_COMPLETED',
|
||||
// 6. Swap event on each part — so the part timeline shows the repair link.
|
||||
await tx.partEvent.createMany({
|
||||
data: [
|
||||
{
|
||||
partId: broken.id,
|
||||
userId: actor.id,
|
||||
type: 'PART_SWAPPED',
|
||||
field: 'role',
|
||||
oldValue: 'DEPLOYED',
|
||||
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;
|
||||
}
|
||||
|
||||
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'],
|
||||
include: ['src/services/**', 'src/lib/**'],
|
||||
exclude: ['**/*.test.ts', '**/types.ts'],
|
||||
thresholds: {
|
||||
lines: 60,
|
||||
functions: 60,
|
||||
branches: 60,
|
||||
statements: 60,
|
||||
},
|
||||
// No thresholds today — the coverage report is a signal, not a gate.
|
||||
// Most services still lack unit tests; add a threshold once they do.
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 PartDetail from './pages/PartDetail.js';
|
||||
import Locations from './pages/Locations.js';
|
||||
import BinDetail from './pages/BinDetail.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 MyCustody from './pages/MyCustody.js';
|
||||
import Hosts from './pages/Hosts.js';
|
||||
import HostDetail from './pages/HostDetail.js';
|
||||
import Users from './pages/admin/Users.js';
|
||||
import Webhooks from './pages/admin/Webhooks.js';
|
||||
|
||||
@@ -53,9 +60,16 @@ export default function App() {
|
||||
<Route path="/parts" element={<Parts />} />
|
||||
<Route path="/parts/:id" element={<PartDetail />} />
|
||||
<Route path="/locations" element={<Locations />} />
|
||||
<Route path="/bins/:id" element={<BinDetail />} />
|
||||
<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="/custody" element={<MyCustody />} />
|
||||
<Route path="/hosts" element={<Hosts />} />
|
||||
<Route path="/hosts/:id" element={<HostDetail />} />
|
||||
<Route
|
||||
path="/admin/users"
|
||||
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 { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { HostStack, HostState } from '@vector/shared';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -20,20 +21,39 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Textarea,
|
||||
} 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 { queryKeys } from '../../lib/queryKeys.js';
|
||||
import type { Host } from '../../lib/api/types.js';
|
||||
|
||||
const Schema = z.object({
|
||||
assetId: z.string().trim().min(1, 'Required').max(64),
|
||||
name: z.string().min(1, 'Required').max(128),
|
||||
location: z.string().max(256).optional(),
|
||||
notes: z.string().max(4096).optional(),
|
||||
state: HostState,
|
||||
stack: HostStack,
|
||||
});
|
||||
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 {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -46,26 +66,57 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { name: '', location: '', notes: '' },
|
||||
defaultValues: {
|
||||
assetId: '',
|
||||
name: '',
|
||||
location: '',
|
||||
notes: '',
|
||||
state: 'DEPLOYED',
|
||||
stack: 'PRODUCTION',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset({
|
||||
assetId: host?.assetId ?? '',
|
||||
name: host?.name ?? '',
|
||||
location: host?.location ?? '',
|
||||
notes: host?.notes ?? '',
|
||||
state: host?.state ?? 'DEPLOYED',
|
||||
stack: host?.stack ?? 'PRODUCTION',
|
||||
});
|
||||
}, [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({
|
||||
mutationFn: async (values: Values) => {
|
||||
const payload = {
|
||||
if (editing && host) {
|
||||
return updateHost(host.id, {
|
||||
assetId: values.assetId,
|
||||
name: values.name,
|
||||
location: values.location ? values.location : null,
|
||||
notes: values.notes ? values.notes : null,
|
||||
};
|
||||
return editing && host ? updateHost(host.id, payload) : createHost(payload);
|
||||
state: values.state,
|
||||
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: () => {
|
||||
toast.success(editing ? 'Host updated' : 'Host created');
|
||||
@@ -88,6 +139,37 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
|
||||
<Form {...form}>
|
||||
<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
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -95,7 +177,7 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input autoFocus {...field} />
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -114,6 +196,56 @@ export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps
|
||||
</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
|
||||
control={form.control}
|
||||
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 {
|
||||
ArrowRightLeft,
|
||||
Boxes,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Hand,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
type LucideIcon,
|
||||
MapPinned,
|
||||
Package,
|
||||
Server,
|
||||
Users as UsersIcon,
|
||||
Webhook,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn, Button, Tooltip, TooltipContent, TooltipTrigger } from '@vector/ui';
|
||||
import { useAuth } from '../../contexts/AuthContext.js';
|
||||
@@ -25,9 +27,11 @@ interface NavItem {
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||
{ to: '/parts', label: 'Parts', icon: Package },
|
||||
{ to: '/part-models', label: 'Part models', icon: Layers },
|
||||
{ to: '/locations', label: 'Locations', icon: MapPinned },
|
||||
{ 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: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
|
||||
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
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">
|
||||
{bins.data!.data.map((b) => (
|
||||
<Card key={b.id} className="group relative">
|
||||
<CardContent className="flex items-start gap-2 p-3">
|
||||
<Card
|
||||
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" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-sm">{b.name}</p>
|
||||
@@ -126,7 +134,9 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
|
||||
{b.fullPath}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
{canEdit && (
|
||||
<div className="p-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -150,6 +160,7 @@ export function BinGrid({ roomId, canEdit }: BinGridProps) {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</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,
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -32,11 +31,6 @@ import type { Manufacturer } from '../../lib/api/types.js';
|
||||
|
||||
const Schema = z.object({
|
||||
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>;
|
||||
|
||||
@@ -46,11 +40,6 @@ interface ManufacturerFormDialogProps {
|
||||
manufacturer?: Manufacturer | null;
|
||||
}
|
||||
|
||||
function isoToDateInput(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function ManufacturerFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -61,23 +50,17 @@ export function ManufacturerFormDialog({
|
||||
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(Schema),
|
||||
defaultValues: { name: '', eolDate: '' },
|
||||
defaultValues: { name: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset({
|
||||
name: manufacturer?.name ?? '',
|
||||
eolDate: isoToDateInput(manufacturer?.eolDate ?? null),
|
||||
});
|
||||
form.reset({ name: manufacturer?.name ?? '' });
|
||||
}, [open, manufacturer, form]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
eolDate: values.eolDate ? values.eolDate : null,
|
||||
};
|
||||
const payload = { name: values.name };
|
||||
return editing && manufacturer
|
||||
? updateManufacturer(manufacturer.id, payload)
|
||||
: createManufacturer(payload);
|
||||
@@ -98,8 +81,8 @@ export function ManufacturerFormDialog({
|
||||
<DialogTitle>{editing ? 'Edit manufacturer' : 'New manufacturer'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editing
|
||||
? 'Update this manufacturer. EOL drives replacement alerts on parts.'
|
||||
: 'Add a manufacturer. Names must be unique.'}
|
||||
? 'Update the manufacturer record.'
|
||||
: 'Add a manufacturer. Names must be unique. EOL is tracked per part model.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -118,23 +101,6 @@ export function ManufacturerFormDialog({
|
||||
</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>
|
||||
<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 {
|
||||
ArrowRight,
|
||||
ArrowRightLeft,
|
||||
CheckCircle2,
|
||||
MapPin,
|
||||
Package,
|
||||
Pencil,
|
||||
Tag,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import type { PartEventType } from '@vector/shared';
|
||||
@@ -20,8 +20,7 @@ const EVENT_ICON: Record<PartEventType, LucideIcon> = {
|
||||
STATE_CHANGED: CheckCircle2,
|
||||
LOCATION_CHANGED: MapPin,
|
||||
FIELD_UPDATED: Pencil,
|
||||
REPAIR_STARTED: Wrench,
|
||||
REPAIR_COMPLETED: Wrench,
|
||||
PART_SWAPPED: ArrowRightLeft,
|
||||
TAG_ADDED: Tag,
|
||||
TAG_REMOVED: Tag,
|
||||
};
|
||||
@@ -31,8 +30,7 @@ const EVENT_TITLE: Record<PartEventType, string> = {
|
||||
STATE_CHANGED: 'State changed',
|
||||
LOCATION_CHANGED: 'Location changed',
|
||||
FIELD_UPDATED: 'Field updated',
|
||||
REPAIR_STARTED: 'Repair started',
|
||||
REPAIR_COMPLETED: 'Repair completed',
|
||||
PART_SWAPPED: 'Part swapped',
|
||||
TAG_ADDED: 'Tag added',
|
||||
TAG_REMOVED: 'Tag removed',
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react';
|
||||
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';
|
||||
@@ -6,6 +6,8 @@ import { z } from 'zod';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PartState } from '@vector/shared';
|
||||
import { PartModelCombobox } from '../common/PartModelCombobox.js';
|
||||
import type { PartModel } from '../../lib/api/types.js';
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
} from '@vector/ui';
|
||||
import { listManufacturers } from '../../lib/api/manufacturers.js';
|
||||
import { listBins } from '../../lib/api/bins.js';
|
||||
import { listHosts } from '../../lib/api/hosts.js';
|
||||
import { createPart, updatePart } from '../../lib/api/parts.js';
|
||||
import type { Part } from '../../lib/api/types.js';
|
||||
import { ApiRequestError } from '../../lib/api/client.js';
|
||||
@@ -37,15 +40,43 @@ import { queryKeys } from '../../lib/queryKeys.js';
|
||||
import { partStateOptions } from './PartStateBadge.js';
|
||||
|
||||
// Schema reflects the server's CreatePartRequest but keeps strings for the form, letting the
|
||||
// submit handler coerce to the network shape.
|
||||
const PartFormSchema = z.object({
|
||||
// submit handler coerce to the network shape. The combobox drives partModelId xor (mpn+mfr).
|
||||
const PartFormSchema = z
|
||||
.object({
|
||||
serialNumber: z.string().min(1, 'Required').max(128),
|
||||
mpn: z.string().min(1, 'Required').max(128),
|
||||
manufacturerId: z.string().uuid('Select a manufacturer'),
|
||||
partModelId: z.string().optional(), // set when an existing model is picked
|
||||
mpn: z.string().max(128).optional(), // set when creating a new model
|
||||
manufacturerId: z.string().optional(),
|
||||
state: PartState,
|
||||
binId: z.string().optional(), // '' = none
|
||||
hostId: z.string().optional(), // '' = none
|
||||
price: z.string().optional(), // empty string = null
|
||||
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>;
|
||||
|
||||
@@ -61,14 +92,18 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
const editing = Boolean(part);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [pickedModel, setPickedModel] = useState<PartModel | null>(null);
|
||||
|
||||
const form = useForm<PartFormValues>({
|
||||
resolver: zodResolver(PartFormSchema),
|
||||
defaultValues: {
|
||||
serialNumber: '',
|
||||
partModelId: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
hostId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
},
|
||||
@@ -76,29 +111,37 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
part
|
||||
? {
|
||||
if (part) {
|
||||
setPickedModel(part.partModel ?? null);
|
||||
form.reset({
|
||||
serialNumber: part.serialNumber,
|
||||
mpn: part.mpn,
|
||||
manufacturerId: part.manufacturerId,
|
||||
partModelId: part.partModelId,
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: part.state,
|
||||
binId: part.binId ?? '',
|
||||
hostId: part.hostId ?? '',
|
||||
price: part.price != null ? String(part.price) : '',
|
||||
notes: part.notes ?? '',
|
||||
}
|
||||
: {
|
||||
});
|
||||
} else {
|
||||
setPickedModel(null);
|
||||
form.reset({
|
||||
serialNumber: '',
|
||||
partModelId: '',
|
||||
mpn: '',
|
||||
manufacturerId: '',
|
||||
state: 'SPARE',
|
||||
binId: '',
|
||||
hostId: '',
|
||||
price: '',
|
||||
notes: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [open, part, form]);
|
||||
|
||||
const watchedState = form.watch('state');
|
||||
|
||||
const manufacturers = useQuery({
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
@@ -108,20 +151,30 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
const bins = useQuery({
|
||||
queryKey: queryKeys.bins.list({ 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({
|
||||
mutationFn: async (values: PartFormValues) => {
|
||||
const payload = {
|
||||
const deployed = values.state === 'DEPLOYED';
|
||||
const base = {
|
||||
serialNumber: values.serialNumber,
|
||||
mpn: values.mpn,
|
||||
manufacturerId: values.manufacturerId,
|
||||
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),
|
||||
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
|
||||
? updatePart(part.id, payload)
|
||||
: createPart(payload);
|
||||
@@ -166,7 +219,6 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="serialNumber"
|
||||
@@ -180,28 +232,47 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mpn"
|
||||
render={({ field }) => (
|
||||
name="partModelId"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>MPN</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormLabel>Part model</FormLabel>
|
||||
<PartModelCombobox
|
||||
value={pickedModel}
|
||||
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 />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!pickedModel && form.watch('mpn') && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="manufacturerId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Manufacturer</FormLabel>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<FormLabel>Manufacturer (for new model)</FormLabel>
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select manufacturer" />
|
||||
@@ -219,6 +290,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
@@ -227,7 +299,16 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<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>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -260,12 +341,49 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
/>
|
||||
</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
|
||||
control={form.control}
|
||||
name="binId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Location</FormLabel>
|
||||
<FormLabel>Location (bin)</FormLabel>
|
||||
<Select
|
||||
value={field.value ? field.value : UNASSIGNED}
|
||||
onValueChange={(v) => field.onChange(v === UNASSIGNED ? '' : v)}
|
||||
@@ -288,6 +406,7 @@ export function PartFormDialog({ open, onOpenChange, part }: PartFormDialogProps
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
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',
|
||||
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_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
@@ -13,12 +16,17 @@ const STATE_VARIANT: Record<PartState, BadgeProps['variant']> = {
|
||||
DEPLOYED: 'success',
|
||||
BROKEN: 'warning',
|
||||
PENDING_DESTRUCTION: 'destructive',
|
||||
PENDING_DROP_IN_CUSTODY: 'outline',
|
||||
PENDING_DESTRUCTION_IN_CUSTODY: 'outline',
|
||||
PENDING_REPAIR: 'outline',
|
||||
};
|
||||
|
||||
export function PartStateBadge({ state }: { state: PartState }) {
|
||||
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 }[] = [
|
||||
{ value: 'SPARE', label: 'Spare' },
|
||||
{ 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>
|
||||
<DialogTitle>{editing ? 'Edit subscription' : 'New subscription'}</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ export function listBins(
|
||||
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> {
|
||||
const res = await api.post<BinWithPath>('/bins', input);
|
||||
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 { getList } from './paginated.js';
|
||||
import type { Category } from './types.js';
|
||||
@@ -7,6 +11,16 @@ export function listCategories(filters: { page?: number; pageSize?: number } = {
|
||||
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> {
|
||||
const res = await api.post<Category>('/categories', input);
|
||||
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 { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { Host } from './types.js';
|
||||
import type { Host, HostTimelineEntry, Part } from './types.js';
|
||||
|
||||
export type HostListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
q?: string;
|
||||
state?: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
export function listHosts(filters: HostListFilters = {}) {
|
||||
@@ -18,6 +20,15 @@ export async function getHost(id: string): Promise<Host> {
|
||||
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> {
|
||||
const res = await api.post<Host>('/hosts', input);
|
||||
return res.data;
|
||||
@@ -31,3 +42,8 @@ export async function updateHost(id: string, input: UpdateHostRequest): Promise<
|
||||
export async function deleteHost(id: string): Promise<void> {
|
||||
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 {
|
||||
CreateManufacturerRequest,
|
||||
ManufacturerInsights,
|
||||
UpdateManufacturerRequest,
|
||||
} from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
@@ -15,6 +16,16 @@ export function listManufacturers(filters: ManufacturerListFilters = {}) {
|
||||
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> {
|
||||
const res = await api.post<Manufacturer>('/manufacturers', input);
|
||||
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;
|
||||
manufacturerId?: string;
|
||||
categoryId?: string;
|
||||
partModelId?: string;
|
||||
binId?: string;
|
||||
tagId?: string;
|
||||
eolOnly?: boolean;
|
||||
serialNumber?: string;
|
||||
custodianId?: string;
|
||||
};
|
||||
|
||||
export function listParts(filters: PartListFilters) {
|
||||
|
||||
@@ -1,49 +1,26 @@
|
||||
import type {
|
||||
CreateRepairJobRequest,
|
||||
RepairStatus,
|
||||
UpdateRepairJobRequest,
|
||||
} from '@vector/shared';
|
||||
import type { LogRepairRequest } from '@vector/shared';
|
||||
import { api } from './client.js';
|
||||
import { getList } from './paginated.js';
|
||||
import type { RepairJob } from './types.js';
|
||||
import type { Repair } from './types.js';
|
||||
|
||||
export type RepairListFilters = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
status?: RepairStatus;
|
||||
partId?: string;
|
||||
hostId?: string;
|
||||
assigneeId?: string;
|
||||
openOnly?: boolean;
|
||||
performedById?: string;
|
||||
since?: string;
|
||||
};
|
||||
|
||||
export function listRepairs(filters: RepairListFilters = {}) {
|
||||
return getList<RepairJob>('/repairs', filters);
|
||||
return getList<Repair>('/repairs', filters);
|
||||
}
|
||||
|
||||
export async function getRepair(id: string): Promise<RepairJob> {
|
||||
const res = await api.get<RepairJob>(`/repairs/${id}`);
|
||||
export async function getRepair(id: string): Promise<Repair> {
|
||||
const res = await api.get<Repair>(`/repairs/${id}`);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listRepairsForPart(partId: string): Promise<RepairJob[]> {
|
||||
const res = await api.get<RepairJob[]>(`/parts/${partId}/repairs`);
|
||||
export async function logRepair(input: LogRepairRequest): Promise<Repair> {
|
||||
const res = await api.post<Repair>('/repairs', input);
|
||||
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).
|
||||
// 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
eolDate: string | null;
|
||||
createdAt: 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 {
|
||||
@@ -42,18 +63,21 @@ export interface BinWithPath extends Bin {
|
||||
export interface Part {
|
||||
id: string;
|
||||
serialNumber: string;
|
||||
mpn: string;
|
||||
partModelId: string;
|
||||
manufacturerId: string;
|
||||
price: number | null;
|
||||
state: PartState;
|
||||
binId: string | null;
|
||||
categoryId: string | null;
|
||||
replacementPartId: string | null;
|
||||
hostId: string | null;
|
||||
custodianId: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
manufacturer: Manufacturer;
|
||||
partModel: PartModel;
|
||||
bin: BinWithPath | null;
|
||||
host: Host | null;
|
||||
custodian: Pick<User, 'id' | 'username'> | null;
|
||||
}
|
||||
|
||||
export interface PartEvent {
|
||||
@@ -79,13 +103,46 @@ export interface User {
|
||||
|
||||
export interface Host {
|
||||
id: string;
|
||||
assetId: string;
|
||||
name: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
state: HostState;
|
||||
stack: HostStack;
|
||||
createdAt: 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -97,24 +154,25 @@ export interface Tag {
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
_count?: { partModels: number };
|
||||
}
|
||||
|
||||
export interface RepairJob {
|
||||
export interface Repair {
|
||||
id: string;
|
||||
partId: string;
|
||||
hostId: string | null;
|
||||
assigneeId: string | null;
|
||||
status: RepairStatus;
|
||||
notes: string | null;
|
||||
openedAt: string;
|
||||
closedAt: string | null;
|
||||
hostId: string;
|
||||
brokenPartId: string;
|
||||
replacementPartId: string;
|
||||
performedById: string;
|
||||
performedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
part: Part;
|
||||
host: Host | null;
|
||||
assignee: Pick<User, 'id' | 'username' | 'email' | 'role'> | null;
|
||||
host: Host;
|
||||
brokenPart: Part;
|
||||
replacement: Part;
|
||||
performedBy: Pick<User, 'id' | 'username'>;
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
|
||||
@@ -20,6 +20,7 @@ export const queryKeys = {
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.manufacturers.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.manufacturers.all, 'detail', id] as const,
|
||||
insights: (id: string) => [...queryKeys.manufacturers.all, 'insights', id] as const,
|
||||
},
|
||||
sites: {
|
||||
all: ['sites'] as const,
|
||||
@@ -36,6 +37,7 @@ export const queryKeys = {
|
||||
all: ['bins'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.bins.all, 'list', filters ?? {}] as const,
|
||||
detail: (id: string) => [...queryKeys.bins.all, 'detail', id] as const,
|
||||
},
|
||||
users: {
|
||||
all: ['users'] as const,
|
||||
@@ -47,6 +49,9 @@ export const queryKeys = {
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...queryKeys.hosts.all, 'list', filters ?? {}] 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: {
|
||||
all: ['repairs'] as const,
|
||||
@@ -54,6 +59,18 @@ export const queryKeys = {
|
||||
[...queryKeys.repairs.all, 'list', filters ?? {}] 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: {
|
||||
all: ['tags'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
@@ -63,6 +80,8 @@ export const queryKeys = {
|
||||
all: ['categories'] as const,
|
||||
list: (filters?: Record<string, unknown>) =>
|
||||
[...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: {
|
||||
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 { Link } from 'react-router-dom';
|
||||
import { AlertTriangle, Download, Package, Wrench } from 'lucide-react';
|
||||
import { AlertTriangle, CalendarClock, Download, Package } from 'lucide-react';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
@@ -33,6 +35,9 @@ const STATE_LABELS: Record<PartState, string> = {
|
||||
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_COLORS: Record<PartState, string> = {
|
||||
@@ -40,10 +45,38 @@ const STATE_COLORS: Record<PartState, string> = {
|
||||
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(cents: number): string {
|
||||
return (cents / 100).toLocaleString(undefined, { style: 'currency', currency: 'USD' });
|
||||
const LINE_BLUE = 'hsl(217 91% 60%)';
|
||||
|
||||
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() {
|
||||
@@ -82,18 +115,12 @@ export default function Dashboard() {
|
||||
|
||||
{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
|
||||
icon={<Package className="h-4 w-4" />}
|
||||
label="Total parts"
|
||||
value={data.totalParts.toLocaleString()}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={<Wrench className="h-4 w-4" />}
|
||||
label="Open repairs"
|
||||
value={data.openRepairs.toLocaleString()}
|
||||
href="/repairs"
|
||||
/>
|
||||
<KpiCard
|
||||
label="Deployed value"
|
||||
value={currency(
|
||||
@@ -102,6 +129,10 @@ export default function Dashboard() {
|
||||
.reduce((sum, s) => sum + s.totalPrice, 0),
|
||||
)}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Total spent"
|
||||
value={currency(data.byState.reduce((sum, s) => sum + s.totalPrice, 0))}
|
||||
/>
|
||||
<KpiCard
|
||||
label="Past-EOL deployments"
|
||||
value={data.deployedPastEol
|
||||
@@ -109,9 +140,48 @@ export default function Dashboard() {
|
||||
.toLocaleString()}
|
||||
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>
|
||||
|
||||
{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">
|
||||
<Card>
|
||||
@@ -130,7 +200,12 @@ export default function Dashboard() {
|
||||
>
|
||||
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
|
||||
<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]}>
|
||||
{data.byState.map((s) => (
|
||||
<Cell key={s.state} fill={STATE_COLORS[s.state]} />
|
||||
@@ -155,7 +230,7 @@ export default function Dashboard() {
|
||||
.map((s) => ({
|
||||
name: STATE_LABELS[s.state],
|
||||
state: s.state,
|
||||
value: s.totalPrice / 100,
|
||||
value: s.totalPrice,
|
||||
}))}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
@@ -173,6 +248,9 @@ export default function Dashboard() {
|
||||
formatter={(v: number) =>
|
||||
v.toLocaleString(undefined, { style: 'currency', currency: 'USD' })
|
||||
}
|
||||
contentStyle={TOOLTIP_CONTENT_STYLE}
|
||||
itemStyle={TOOLTIP_ITEM_STYLE}
|
||||
labelStyle={TOOLTIP_LABEL_STYLE}
|
||||
/>
|
||||
<Legend />
|
||||
</PieChart>
|
||||
@@ -192,7 +270,12 @@ export default function Dashboard() {
|
||||
<BarChart data={data.ageBuckets}>
|
||||
<XAxis dataKey="label" tick={{ fontSize: 12 }} />
|
||||
<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]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -219,7 +302,12 @@ export default function Dashboard() {
|
||||
tick={{ fontSize: 11 }}
|
||||
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]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -227,6 +315,66 @@ export default function Dashboard() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
@@ -243,11 +391,17 @@ function KpiCard({
|
||||
icon?: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: 'warn';
|
||||
tone?: 'warn' | 'caution';
|
||||
href?: string;
|
||||
}) {
|
||||
const toneClass =
|
||||
tone === 'warn'
|
||||
? 'border-warning/50'
|
||||
: tone === 'caution'
|
||||
? 'border-warning/30'
|
||||
: undefined;
|
||||
const body = (
|
||||
<Card className={tone === 'warn' ? 'border-warning/50' : undefined}>
|
||||
<Card className={toneClass}>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
{icon && (
|
||||
<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;
|
||||
}
|
||||
|
||||
function PastEolBanner({
|
||||
function EolBanner({
|
||||
tone,
|
||||
title,
|
||||
description,
|
||||
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 (
|
||||
<Card className="border-warning/50 bg-warning/5">
|
||||
<Card className={classes}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<AlertTriangle className="h-4 w-4 text-warning" />
|
||||
Deployed past manufacturer EOL
|
||||
<AlertTriangle
|
||||
className={`h-4 w-4 ${tone === 'warn' ? 'text-warning' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
{title}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
These manufacturers have passed their end-of-life date — plan replacements for any parts
|
||||
still in production.
|
||||
</CardDescription>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 pb-5">
|
||||
{rows.map((row) => (
|
||||
<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"
|
||||
>
|
||||
<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 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
EOL {new Date(row.eolDate).toLocaleDateString()}
|
||||
@@ -309,7 +481,7 @@ function PastEolBanner({
|
||||
{row.deployedCount} deployed
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -322,8 +494,8 @@ function PastEolBanner({
|
||||
function DashboardSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20" />
|
||||
))}
|
||||
</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 { Link, useNavigate } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
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 {
|
||||
Button,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { HostFormDialog } from '../components/hosts/HostFormDialog.js';
|
||||
import { HostStackBadge, HostStateBadge } from '../components/hosts/HostStateBadge.js';
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { deleteHost, listHosts } from '../lib/api/hosts.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
@@ -25,6 +27,7 @@ export default function Hosts() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Host | null>(null);
|
||||
@@ -43,10 +46,36 @@ export default function Hosts() {
|
||||
|
||||
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',
|
||||
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',
|
||||
@@ -70,8 +99,7 @@ export default function Hosts() {
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) =>
|
||||
isAdmin ? (
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
@@ -79,6 +107,12 @@ export default function Hosts() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
@@ -91,19 +125,21 @@ export default function Hosts() {
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null,
|
||||
),
|
||||
},
|
||||
],
|
||||
[isAdmin],
|
||||
[isAdmin, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Hosts"
|
||||
description="Machines and racks where parts are installed for repair work."
|
||||
description="Machines and racks where deployed parts are installed."
|
||||
actions={
|
||||
isAdmin && (
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { parseAsString, useQueryState } from 'nuqs';
|
||||
import { ChevronRight, MapPin } from 'lucide-react';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { SiteList } from '../components/locations/SiteList.js';
|
||||
import { RoomDrawer } from '../components/locations/RoomDrawer.js';
|
||||
import { SiteRoomTree } from '../components/locations/SiteRoomTree.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';
|
||||
|
||||
export default function Locations() {
|
||||
@@ -20,23 +25,94 @@ export default function Locations() {
|
||||
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 (
|
||||
<div className="flex h-[calc(100vh-var(--spacing-topbar,3.25rem)-3rem)] flex-col gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<PageHeader
|
||||
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">
|
||||
<SiteList selectedId={siteId} onSelect={handleSite} canEdit={canEdit} />
|
||||
<SiteRoomTree
|
||||
siteId={siteId}
|
||||
roomId={roomId}
|
||||
onSelectSite={handleSite}
|
||||
onSelectRoom={handleRoom}
|
||||
canEdit={canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-r border-border">
|
||||
<RoomDrawer siteId={siteId} selectedId={roomId} onSelect={handleRoom} canEdit={canEdit} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-col">
|
||||
<Breadcrumb siteName={siteName} roomName={roomName} />
|
||||
<div className="flex-1">
|
||||
{roomId ? (
|
||||
<BinGrid roomId={roomId} canEdit={canEdit} />
|
||||
) : (
|
||||
<EmptyPane siteSelected={Boolean(siteId)} />
|
||||
)}
|
||||
</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 { Link, useNavigate } from 'react-router-dom';
|
||||
import type { ColumnDef } from '@tanstack/react-table';
|
||||
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 {
|
||||
Badge,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -25,6 +25,7 @@ import { useAuth } from '../contexts/AuthContext.js';
|
||||
export default function Manufacturers() {
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'ADMIN';
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
@@ -47,21 +48,14 @@ export default function Manufacturers() {
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: 'eolDate',
|
||||
header: 'EOL',
|
||||
cell: ({ row }) => {
|
||||
if (!row.original.eolDate) {
|
||||
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>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/manufacturers/${row.original.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{row.original.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
@@ -76,8 +70,7 @@ export default function Manufacturers() {
|
||||
id: 'actions',
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
size: 40,
|
||||
cell: ({ row }) =>
|
||||
isAdmin ? (
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
@@ -85,6 +78,12 @@ export default function Manufacturers() {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<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)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
@@ -97,19 +96,21 @@ export default function Manufacturers() {
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : null,
|
||||
),
|
||||
},
|
||||
],
|
||||
[isAdmin],
|
||||
[isAdmin, navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Manufacturers"
|
||||
description="Vendors and their end-of-life dates."
|
||||
description="Vendors supplying parts across the fleet."
|
||||
actions={
|
||||
isAdmin && (
|
||||
<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 { PartEventTimeline } from '../components/parts/PartEventTimeline.js';
|
||||
import { PartFormDialog } from '../components/parts/PartFormDialog.js';
|
||||
import { PartRepairSection } from '../components/parts/PartRepairSection.js';
|
||||
import { TagPicker } from '../components/tags/TagPicker.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;
|
||||
|
||||
return (
|
||||
@@ -102,7 +101,7 @@ export default function PartDetail() {
|
||||
<div>
|
||||
<h1 className="font-mono text-lg font-semibold tracking-tight">{part.serialNumber}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{part.manufacturer.name} · {part.mpn}
|
||||
{part.manufacturer.name} · {part.partModel.mpn}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,7 +131,7 @@ export default function PartDetail() {
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-warning" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{part.manufacturer.name} reached EOL {eolDate.toLocaleDateString()}.
|
||||
{part.partModel.mpn} reached EOL {eolDate.toLocaleDateString()}.
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">
|
||||
Plan a replacement for this part.
|
||||
@@ -150,12 +149,12 @@ export default function PartDetail() {
|
||||
<CardContent>
|
||||
<dl className="space-y-2">
|
||||
<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
|
||||
label="Manufacturer"
|
||||
value={
|
||||
<Link
|
||||
to="/manufacturers"
|
||||
to={`/manufacturers/${part.manufacturerId}`}
|
||||
className="text-foreground hover:underline"
|
||||
>
|
||||
{part.manufacturer.name}
|
||||
@@ -166,7 +165,13 @@ export default function PartDetail() {
|
||||
<DetailRow
|
||||
label="Location"
|
||||
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="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>
|
||||
<TagPicker partId={part.id} />
|
||||
</div>
|
||||
<Separator className="my-3" />
|
||||
<PartRepairSection partId={part.id} />
|
||||
</CardContent>
|
||||
</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 { parseAsString } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import { Edit, HandHelping, MoreHorizontal, Package, Plus, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
@@ -26,7 +26,9 @@ import { PartBulkStateDialog } from '../components/parts/PartBulkStateDialog.js'
|
||||
import { ConfirmDialog } from '../components/ConfirmDialog.js';
|
||||
import { listParts, deletePart } from '../lib/api/parts.js';
|
||||
import { listManufacturers } from '../lib/api/manufacturers.js';
|
||||
import { listCategories } from '../lib/api/categories.js';
|
||||
import { listTags } from '../lib/api/tags.js';
|
||||
import { takeForRepair } from '../lib/api/custody.js';
|
||||
import { ApiRequestError } from '../lib/api/client.js';
|
||||
import type { Part } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
@@ -35,12 +37,14 @@ import { useAuth } from '../contexts/AuthContext.js';
|
||||
type PartsFilters = {
|
||||
state: string | null;
|
||||
manufacturerId: string | null;
|
||||
categoryId: string | null;
|
||||
tagId: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
state: parseAsString,
|
||||
manufacturerId: parseAsString,
|
||||
categoryId: parseAsString,
|
||||
tagId: parseAsString,
|
||||
};
|
||||
|
||||
@@ -62,6 +66,10 @@ export default function Parts() {
|
||||
queryKey: queryKeys.manufacturers.list({ pageSize: 100 }),
|
||||
queryFn: () => listManufacturers({ pageSize: 100 }),
|
||||
});
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: queryKeys.categories.list({ pageSize: 100 }),
|
||||
queryFn: () => listCategories({ pageSize: 100 }),
|
||||
});
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: queryKeys.tags.list({ 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>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -94,15 +113,42 @@ export default function Parts() {
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'mpn',
|
||||
id: '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',
|
||||
header: 'Manufacturer',
|
||||
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',
|
||||
header: 'Location',
|
||||
cell: ({ row }) => {
|
||||
const path = row.original.bin?.fullPath;
|
||||
return path ? (
|
||||
<span className="text-xs font-mono text-muted-foreground">{path}</span>
|
||||
const { host, custodian, bin } = row.original;
|
||||
if (host) {
|
||||
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>
|
||||
);
|
||||
@@ -143,7 +203,7 @@ export default function Parts() {
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
<DropdownMenuItem onSelect={() => navigate(`/parts/${row.original.id}`)}>
|
||||
View
|
||||
</DropdownMenuItem>
|
||||
@@ -151,6 +211,15 @@ export default function Parts() {
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</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 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -168,7 +237,7 @@ export default function Parts() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate, isAdmin],
|
||||
[navigate, isAdmin, takeForRepairMutation],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -197,6 +266,7 @@ export default function Parts() {
|
||||
sort: params.sort,
|
||||
state: params.filters.state,
|
||||
manufacturerId: params.filters.manufacturerId,
|
||||
categoryId: params.filters.categoryId,
|
||||
tagId: params.filters.tagId,
|
||||
})
|
||||
}
|
||||
@@ -208,6 +278,7 @@ export default function Parts() {
|
||||
sort: params.sort,
|
||||
state: params.filters.state ?? undefined,
|
||||
manufacturerId: params.filters.manufacturerId ?? undefined,
|
||||
categoryId: params.filters.categoryId ?? undefined,
|
||||
tagId: params.filters.tagId ?? undefined,
|
||||
})
|
||||
}
|
||||
@@ -223,12 +294,15 @@ export default function Parts() {
|
||||
toolbar={({ filters, setFilter }) => (
|
||||
<PartsFilters
|
||||
manufacturers={manufacturers.data?.data ?? []}
|
||||
categories={categoriesQuery.data?.data ?? []}
|
||||
tags={tagsQuery.data?.data ?? []}
|
||||
state={filters.state ?? ALL}
|
||||
manufacturerId={filters.manufacturerId ?? ALL}
|
||||
categoryId={filters.categoryId ?? ALL}
|
||||
tagId={filters.tagId ?? ALL}
|
||||
onState={(v) => setFilter('state', 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)}
|
||||
/>
|
||||
)}
|
||||
@@ -280,23 +354,29 @@ export default function Parts() {
|
||||
|
||||
interface PartsFiltersProps {
|
||||
manufacturers: { id: string; name: string }[];
|
||||
categories: { id: string; name: string }[];
|
||||
tags: { id: string; name: string }[];
|
||||
state: string;
|
||||
manufacturerId: string;
|
||||
categoryId: string;
|
||||
tagId: string;
|
||||
onState: (v: string) => void;
|
||||
onManufacturer: (v: string) => void;
|
||||
onCategory: (v: string) => void;
|
||||
onTag: (v: string) => void;
|
||||
}
|
||||
|
||||
function PartsFilters({
|
||||
manufacturers,
|
||||
categories,
|
||||
tags,
|
||||
state,
|
||||
manufacturerId,
|
||||
categoryId,
|
||||
tagId,
|
||||
onState,
|
||||
onManufacturer,
|
||||
onCategory,
|
||||
onTag,
|
||||
}: PartsFiltersProps) {
|
||||
return (
|
||||
@@ -327,6 +407,19 @@ function PartsFilters({
|
||||
))}
|
||||
</SelectContent>
|
||||
</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}>
|
||||
<SelectTrigger className="h-8 w-36 text-xs">
|
||||
<SelectValue placeholder="Tag" />
|
||||
|
||||
+51
-174
@@ -1,145 +1,68 @@
|
||||
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 { parseAsString } from 'nuqs';
|
||||
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 { ArrowRightLeft, Plus } from 'lucide-react';
|
||||
import { Button } from '@vector/ui';
|
||||
import { PageHeader } from '../components/layout/PageHeader.js';
|
||||
import { DataTable } from '../components/data-table/DataTable.js';
|
||||
import { RepairFormDialog } from '../components/repairs/RepairFormDialog.js';
|
||||
import {
|
||||
RepairStatusBadge,
|
||||
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 { LogRepairDialog } from '../components/repairs/LogRepairDialog.js';
|
||||
import { listRepairs } from '../lib/api/repairs.js';
|
||||
import type { Repair } from '../lib/api/types.js';
|
||||
import { queryKeys } from '../lib/queryKeys.js';
|
||||
|
||||
type RepairFilters = {
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
const filterParsers = {
|
||||
status: parseAsString,
|
||||
};
|
||||
|
||||
const ALL = '__all__';
|
||||
|
||||
export default function Repairs() {
|
||||
const queryClient = useQueryClient();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RepairJob | null>(null);
|
||||
const [deleting, setDeleting] = useState<RepairJob | null>(null);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
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>[]>(
|
||||
const columns = useMemo<ColumnDef<Repair>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <RepairStatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: 'part',
|
||||
header: 'Part',
|
||||
id: 'performedAt',
|
||||
header: 'When',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={`/parts/${row.original.partId}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{row.original.part.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'mpn',
|
||||
header: 'MPN',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">{row.original.part.mpn}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.performedAt).toLocaleString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'host',
|
||||
header: 'Host',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.original.host?.name ?? '—'}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-xs">{row.original.host.assetId}</span>
|
||||
<span className="text-xs text-muted-foreground">{row.original.host.name}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'openedAt',
|
||||
header: 'Opened',
|
||||
id: 'broken',
|
||||
header: 'Broken',
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(row.original.openedAt).toLocaleDateString()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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"
|
||||
<Link
|
||||
to={`/parts/${row.original.brokenPart.id}`}
|
||||
className="font-mono text-xs hover:underline"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{row.original.brokenPart.serialNumber}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
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">
|
||||
<PageHeader
|
||||
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={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Button onClick={() => setLogOpen(true)}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Open repair
|
||||
Log repair
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<DataTable<RepairJob, RepairFilters>
|
||||
<DataTable<Repair, Record<string, never>>
|
||||
columns={columns}
|
||||
getRowId={(r) => r.id}
|
||||
filterParsers={filterParsers}
|
||||
queryKey={(params) =>
|
||||
queryKeys.repairs.list({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: params.filters.status,
|
||||
})
|
||||
queryKeys.repairs.list({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
queryFn={(params) =>
|
||||
listRepairs({
|
||||
page: params.page,
|
||||
pageSize: params.pageSize,
|
||||
status: (params.filters.status ?? undefined) as RepairStatus | undefined,
|
||||
})
|
||||
listRepairs({ page: params.page, pageSize: params.pageSize })
|
||||
}
|
||||
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={
|
||||
<div className="flex flex-col items-center gap-1 py-8 text-muted-foreground">
|
||||
<Wrench className="h-6 w-6" />
|
||||
<span className="text-sm">No repair jobs yet.</span>
|
||||
<ArrowRightLeft className="h-6 w-6" />
|
||||
<span className="text-sm">No repairs logged yet.</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<RepairFormDialog open={createOpen} onOpenChange={setCreateOpen} />
|
||||
<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)}
|
||||
/>
|
||||
<LogRepairDialog open={logOpen} onOpenChange={setLogOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ export default function Webhooks() {
|
||||
<div className="space-y-5">
|
||||
<PageHeader
|
||||
title="Webhooks"
|
||||
description="Subscribe external receivers to Vector events. Deliveries are signed with HMAC-SHA256."
|
||||
description="Subscribe external receivers to inventory events."
|
||||
actions={
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<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:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: vector-postgres
|
||||
api:
|
||||
image: gitea.thewrightserver.net/josh/vector-api:${TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: vector
|
||||
POSTGRES_PASSWORD: vector
|
||||
POSTGRES_DB: vector
|
||||
ports:
|
||||
- "5432:5432"
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DATABASE_URL: file:/data/vector.db
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — see .env.example}
|
||||
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:
|
||||
- vector-pgdata:/var/lib/postgresql/data
|
||||
- vector-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U vector -d vector"]
|
||||
interval: 5s
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/healthz || exit 1"]
|
||||
interval: 30s
|
||||
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:
|
||||
image: redis:7-alpine
|
||||
container_name: vector-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- vector-redisdata:/data
|
||||
healthcheck:
|
||||
@@ -32,5 +63,5 @@ services:
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
vector-pgdata:
|
||||
vector-data:
|
||||
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())
|
||||
updatedAt DateTime @updatedAt
|
||||
partEvents PartEvent[]
|
||||
hostEvents HostEvent[]
|
||||
refreshTokens RefreshToken[]
|
||||
repairAssignments RepairJob[] @relation("RepairAssignee")
|
||||
custodyParts Part[] @relation("Custody")
|
||||
repairs Repair[]
|
||||
savedViews SavedView[]
|
||||
csvImportJobs CsvImportJob[]
|
||||
}
|
||||
@@ -47,10 +49,30 @@ model RefreshToken {
|
||||
model Manufacturer {
|
||||
id String @id @default(uuid())
|
||||
name String @unique
|
||||
eolDate DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
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 {
|
||||
@@ -93,37 +115,38 @@ model Category {
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
parts Part[]
|
||||
partModels PartModel[]
|
||||
}
|
||||
|
||||
model Part {
|
||||
id String @id @default(uuid())
|
||||
serialNumber String @unique
|
||||
mpn String
|
||||
partModelId String
|
||||
partModel PartModel @relation(fields: [partModelId], references: [id], onDelete: Restrict)
|
||||
manufacturerId String
|
||||
manufacturer Manufacturer @relation(fields: [manufacturerId], references: [id], onDelete: Restrict)
|
||||
price Float?
|
||||
state String @default("SPARE")
|
||||
binId String?
|
||||
bin Bin? @relation(fields: [binId], references: [id], onDelete: SetNull)
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
replacementPartId String?
|
||||
replacement Part? @relation("PartReplacement", fields: [replacementPartId], references: [id], onDelete: SetNull)
|
||||
replacedBy Part[] @relation("PartReplacement")
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
custodianId String?
|
||||
custodian User? @relation("Custody", fields: [custodianId], references: [id], onDelete: SetNull)
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
events PartEvent[]
|
||||
tags PartTag[]
|
||||
repairs RepairJob[]
|
||||
brokenRepairs Repair[] @relation("BrokenRepairs")
|
||||
replacementRepairs Repair[] @relation("ReplacementRepairs")
|
||||
|
||||
@@index([state])
|
||||
@@index([binId])
|
||||
@@index([manufacturerId])
|
||||
@@index([mpn])
|
||||
@@index([categoryId])
|
||||
@@index([replacementPartId])
|
||||
@@index([partModelId])
|
||||
@@index([hostId])
|
||||
@@index([custodianId])
|
||||
}
|
||||
|
||||
model PartEvent {
|
||||
@@ -164,34 +187,57 @@ model PartTag {
|
||||
|
||||
model Host {
|
||||
id String @id @default(uuid())
|
||||
assetId String @unique
|
||||
name String @unique
|
||||
location String?
|
||||
notes String?
|
||||
state String @default("DEPLOYED")
|
||||
stack String @default("PRODUCTION")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
repairs RepairJob[]
|
||||
parts Part[]
|
||||
repairs Repair[]
|
||||
events HostEvent[]
|
||||
|
||||
@@index([state])
|
||||
@@index([stack])
|
||||
}
|
||||
|
||||
model RepairJob {
|
||||
model HostEvent {
|
||||
id String @id @default(uuid())
|
||||
partId String
|
||||
part Part @relation(fields: [partId], references: [id], onDelete: Cascade)
|
||||
hostId String?
|
||||
host Host? @relation(fields: [hostId], references: [id], onDelete: SetNull)
|
||||
assigneeId String?
|
||||
assignee User? @relation("RepairAssignee", fields: [assigneeId], references: [id], onDelete: SetNull)
|
||||
status String @default("PENDING")
|
||||
openedAt DateTime @default(now())
|
||||
closedAt DateTime?
|
||||
notes String?
|
||||
hostId String
|
||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
type String
|
||||
field String?
|
||||
oldValue String?
|
||||
newValue String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@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())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([partId])
|
||||
@@index([status])
|
||||
@@index([hostId])
|
||||
@@index([assigneeId])
|
||||
@@index([status, openedAt(sort: Desc)])
|
||||
@@index([hostId, performedAt(sort: Desc)])
|
||||
@@index([performedById, performedAt(sort: Desc)])
|
||||
@@index([brokenPartId])
|
||||
@@index([replacementPartId])
|
||||
@@map("repairs")
|
||||
}
|
||||
|
||||
model WebhookSubscription {
|
||||
|
||||
@@ -17,6 +17,16 @@ async function main() {
|
||||
|
||||
console.log(`Seeded admin user: ${admin.username} (${admin.email})`);
|
||||
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()
|
||||
|
||||
@@ -9,13 +9,22 @@ declare global {
|
||||
|
||||
function resolveSqliteUrl(raw: string | undefined): string | undefined {
|
||||
if (!raw || !raw.startsWith('file:')) return raw;
|
||||
const rest = raw.slice('file:'.length).replace(/^\/+/, '');
|
||||
if (path.isAbsolute(rest) || /^[A-Za-z]:[\\/]/.test(rest)) {
|
||||
return 'file:' + rest.replace(/\\/g, '/');
|
||||
}
|
||||
let body = raw.slice('file:'.length);
|
||||
|
||||
// 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 schemaDir = path.resolve(here, '..', 'prisma');
|
||||
const absolute = path.resolve(schemaDir, rest);
|
||||
const absolute = path.resolve(schemaDir, body);
|
||||
return 'file:' + absolute.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
|
||||
@@ -18,18 +18,28 @@ export interface BinCount {
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface ManufacturerEolSummary {
|
||||
export interface PartModelEolSummary {
|
||||
partModelId: string;
|
||||
mpn: string;
|
||||
manufacturerId: string;
|
||||
name: string;
|
||||
eolDate: string | null;
|
||||
manufacturerName: string;
|
||||
eolDate: string;
|
||||
deployedCount: number;
|
||||
}
|
||||
|
||||
export interface OperationsAnalytics {
|
||||
repairs7d: number;
|
||||
repairs30d: number;
|
||||
repairsTrend30d: { date: string; count: number }[];
|
||||
custodyBacklog: { userId: string; username: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface DashboardAnalytics {
|
||||
totalParts: number;
|
||||
byState: StateCount[];
|
||||
ageBuckets: AgeBucket[];
|
||||
topBins: BinCount[];
|
||||
deployedPastEol: ManufacturerEolSummary[];
|
||||
openRepairs: number;
|
||||
deployedPastEol: PartModelEolSummary[];
|
||||
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