chore: initial Vector 2.0 monorepo
CI / Lint · Typecheck · Test · Build (push) Failing after 5m41s
CI / Playwright (smoke) (push) Has been skipped

Ground-up TypeScript rewrite of the Vector hardware parts inventory
system. Ships the full roadmap (Phases 0-8) in one initial commit:

- pnpm + Turbo monorepo: apps/{api,web,e2e}, packages/{db,shared,ui,config}
- Express 5 + Prisma 5 + zod validation + JWT w/ refresh-token rotation
- React 19 + Vite + shadcn/ui + TanStack Query/Table + nuqs URL state
- Repair/RMA, tags, bulk ops, saved views, CSV audit export
- Analytics dashboard on Recharts + EOL tracking
- Signed webhook subscriptions (HMAC-SHA256) with in-process emitter
- Vitest unit tests (shared schemas, api services/helpers) + Playwright skeleton
- Gitea Actions CI (lint, typecheck, test+coverage, build) + Renovate

Deferred follow-ups: Postgres cutover (data-migration script ready),
BullMQ worker for webhook delivery, @react-pdf PDF export, CSV import wizard.
This commit is contained in:
2026-04-16 20:52:32 -04:00
commit 7c0d422228
216 changed files with 19393 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
NODE_ENV=development
PORT=3001
CLIENT_ORIGIN=http://localhost:5173
# Provisional local SQLite. Switch to Postgres when Docker is available:
# DATABASE_URL=postgresql://vector:vector@localhost:5432/vector
DATABASE_URL=file:../../packages/db/prisma/dev.db
# Generate: node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
JWT_SECRET=replace-with-at-least-32-char-random-hex-secret
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@vector/api",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"scripts": {
"dev": "tsx watch --env-file=.env src/index.ts",
"start": "node --env-file=.env dist/index.js",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"clean": "rimraf dist .turbo coverage"
},
"dependencies": {
"@vector/db": "workspace:*",
"@vector/shared": "workspace:*",
"bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7",
"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",
"pino-http": "^11.0.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.7",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.2",
"@types/supertest": "^7.2.0",
"@vector/config": "workspace:*",
"@vitest/coverage-v8": "^4.1.4",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2",
"vitest": "^4.1.4"
}
}
+95
View File
@@ -0,0 +1,95 @@
import express from 'express';
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';
import { logger } from './lib/logger.js';
import { requestId } from './middleware/request-id.js';
import { requireCsrf } from './middleware/csrf.js';
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 siteRoutes from './routes/sites.js';
import roomRoutes from './routes/rooms.js';
import binRoutes from './routes/bins.js';
import partRoutes from './routes/parts.js';
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 savedViewRoutes from './routes/saved-views.js';
import analyticsRoutes from './routes/analytics.js';
import webhookRoutes from './routes/webhooks.js';
import auditRoutes from './routes/audit.js';
export const app = express();
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(helmet({ contentSecurityPolicy: false, crossOriginResourcePolicy: { policy: 'same-site' } }));
app.use(
cors({
origin: env.CLIENT_ORIGIN,
credentials: true,
}),
);
app.use(express.json({ limit: '1mb' }));
app.use(cookieParser());
app.use(requestId);
app.use(
pinoHttp({
logger,
customProps: (req) => ({ requestId: (req as express.Request).requestId }),
customLogLevel: (_req, res, err) => {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
}),
);
app.get('/healthz', (_req, res) => {
res.json({ status: 'ok' });
});
app.get('/readyz', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`;
res.json({ status: 'ok', db: 'ok' });
} catch {
res.status(503).json({ status: 'error', db: 'unreachable' });
}
});
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', requireCsrf);
app.use('/api/users', userRoutes);
app.use('/api/manufacturers', manufacturerRoutes);
app.use('/api/sites', siteRoutes);
app.use('/api/rooms', roomRoutes);
app.use('/api/bins', binRoutes);
app.use('/api/parts', partRoutes);
app.use('/api/tags', tagRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/hosts', hostRoutes);
app.use('/api/repairs', repairRoutes);
app.use('/api/saved-views', savedViewRoutes);
app.use('/api/analytics', analyticsRoutes);
app.use('/api/admin/webhooks', webhookRoutes);
app.use('/api/admin/audit', auditRoutes);
app.use(errorHandler);
+12
View File
@@ -0,0 +1,12 @@
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) {
try {
const data = await prisma.$transaction((tx) => svc.dashboard(tx));
res.json(data);
} catch (err) {
next(err);
}
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { csvCell } from './audit-export.js';
describe('csvCell', () => {
it('returns empty string for null / undefined', () => {
expect(csvCell(null)).toBe('');
expect(csvCell(undefined)).toBe('');
});
it('stringifies primitives', () => {
expect(csvCell('hi')).toBe('hi');
expect(csvCell(42)).toBe('42');
expect(csvCell(true)).toBe('true');
});
it('formats Date as ISO string', () => {
const d = new Date('2026-01-01T00:00:00.000Z');
expect(csvCell(d)).toBe('2026-01-01T00:00:00.000Z');
});
it('quotes values containing commas', () => {
expect(csvCell('a,b')).toBe('"a,b"');
});
it('quotes values containing newlines', () => {
expect(csvCell('line1\nline2')).toBe('"line1\nline2"');
expect(csvCell('line1\r\nline2')).toBe('"line1\r\nline2"');
});
it('escapes embedded double-quotes by doubling them', () => {
expect(csvCell('say "hi"')).toBe('"say ""hi"""');
});
it('leaves plain text untouched', () => {
expect(csvCell('plain-text_123')).toBe('plain-text_123');
});
});
+100
View File
@@ -0,0 +1,100 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma, Prisma } from '@vector/db';
import { PartEventType } from '@vector/shared';
const Query = z.object({
from: z.coerce.date().optional(),
to: z.coerce.date().optional(),
type: PartEventType.optional(),
partId: z.string().uuid().optional(),
});
const HEADERS = [
'createdAt',
'eventType',
'partId',
'serialNumber',
'field',
'oldValue',
'newValue',
'actorUsername',
];
// CSV-escape: wrap in quotes, double up embedded quotes. Handles commas, newlines, quotes.
export function csvCell(value: unknown): string {
if (value === null || value === undefined) return '';
const str = value instanceof Date ? value.toISOString() : String(value);
if (/["\n\r,]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
return str;
}
export async function eventsCsv(req: Request, res: Response, next: NextFunction) {
try {
const parsed = Query.safeParse(req.query);
if (!parsed.success) {
res.status(400).json({
code: 'VALIDATION_FAILED',
message: 'Invalid export filters',
issues: parsed.error.issues,
});
return;
}
const { from, to, type, partId } = parsed.data;
const where: Prisma.PartEventWhereInput = {};
if (type) where.type = type;
if (partId) where.partId = partId;
if (from || to) {
where.createdAt = {};
if (from) where.createdAt.gte = from;
if (to) where.createdAt.lte = to;
}
res.setHeader('content-type', 'text/csv; charset=utf-8');
res.setHeader(
'content-disposition',
`attachment; filename="vector-audit-${new Date().toISOString().slice(0, 10)}.csv"`,
);
res.setHeader('cache-control', 'no-store');
res.write(HEADERS.join(',') + '\n');
// Keyset-paginate by createdAt+id so we never materialize the full table in memory.
const BATCH = 1000;
let cursor: { id: string } | undefined;
for (;;) {
const rows = await prisma.partEvent.findMany({
where,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: BATCH,
...(cursor ? { skip: 1, cursor } : {}),
include: {
part: { select: { serialNumber: true } },
user: { select: { username: true } },
},
});
if (rows.length === 0) break;
for (const row of rows) {
res.write(
[
csvCell(row.createdAt),
csvCell(row.type),
csvCell(row.partId),
csvCell(row.part.serialNumber),
csvCell(row.field),
csvCell(row.oldValue),
csvCell(row.newValue),
csvCell(row.user?.username ?? null),
].join(',') + '\n',
);
}
if (rows.length < BATCH) break;
const last = rows[rows.length - 1];
if (!last) break;
cursor = { id: last.id };
}
res.end();
} catch (err) {
next(err);
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { CookieOptions, NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type { LoginRequest } from '@vector/shared';
import { env } from '../env.js';
import * as authService from '../services/auth.js';
import { issueCsrfToken } from '../middleware/csrf.js';
import { errors } from '../lib/http-error.js';
const accessCookieOpts: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/',
maxAge: authService.ACCESS_TOKEN_TTL_MS,
};
const refreshCookieOpts: CookieOptions = {
httpOnly: true,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/api/auth',
maxAge: authService.REFRESH_TOKEN_TTL_MS,
};
function setAuthCookies(res: Response, tokens: authService.AuthTokens) {
res.cookie('token', tokens.accessToken, accessCookieOpts);
res.cookie('refresh', tokens.refreshToken, refreshCookieOpts);
issueCsrfToken(res);
}
function clearAuthCookies(res: Response) {
res.clearCookie('token', { path: '/' });
res.clearCookie('refresh', { path: '/api/auth' });
res.clearCookie('csrf', { path: '/' });
}
export async function login(req: Request, res: Response, next: NextFunction) {
try {
const { username, password } = req.validated!.body as LoginRequest;
const { user, tokens } = await prisma.$transaction((tx) =>
authService.login(tx, username, password),
);
setAuthCookies(res, tokens);
res.json(user);
} catch (err) {
next(err);
}
}
export async function refresh(req: Request, res: Response, next: NextFunction) {
try {
const presented = req.cookies?.refresh;
if (!presented) throw errors.unauthorized('Missing refresh token');
const { user, tokens } = await prisma.$transaction((tx) =>
authService.rotate(tx, presented),
);
setAuthCookies(res, tokens);
res.json(user);
} catch (err) {
next(err);
}
}
export async function logout(req: Request, res: Response, next: NextFunction) {
try {
const presented = req.cookies?.refresh;
await prisma.$transaction((tx) => authService.revoke(tx, presented));
clearAuthCookies(res);
res.json({ message: 'Logged out' });
} catch (err) {
next(err);
}
}
export async function me(req: Request, res: Response, next: NextFunction) {
try {
const user = await prisma.user.findUnique({ where: { id: req.user!.id } });
if (!user) throw errors.unauthorized();
res.json({ id: user.id, username: user.username, email: user.email, role: user.role });
} catch (err) {
next(err);
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '@vector/db';
import {
CreateBinRequest,
PaginationQuery,
UpdateBinRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
const BinListQuery = PaginationQuery.extend({
roomId: z.string().uuid().optional(),
siteId: z.string().uuid().optional(),
});
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as z.infer<typeof BinListQuery>;
const result = await prisma.$transaction((tx) => svc.listBins(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const bin = await prisma.$transaction((tx) => svc.getBin(tx, req.params.id));
if (!bin) throw errors.notFound('Bin');
res.json(bin);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateBinRequest;
const bin = await prisma.$transaction((tx) => svc.createBin(tx, input));
res.status(201).json(bin);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateBinRequest;
const bin = await prisma.$transaction((tx) => svc.updateBin(tx, req.params.id, input));
res.json(bin);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeBin(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { BinListQuery };
+49
View File
@@ -0,0 +1,49 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import * as svc from '../services/categories.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as CategoryListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateCategoryRequest;
const category = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(category);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateCategoryRequest;
const category = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input),
);
res.json(category);
} 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);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as svc from '../services/hosts.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 HostListQuery;
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 host = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!host) throw errors.notFound('Host');
res.json(host);
} 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));
res.status(201).json(host);
} catch (err) {
next(err);
}
}
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));
res.json(host);
} 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);
}
}
+50
View File
@@ -0,0 +1,50 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateManufacturerRequest,
PaginationQuery,
UpdateManufacturerRequest,
} from '@vector/shared';
import * as svc from '../services/manufacturers.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 PaginationQuery);
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateManufacturerRequest;
const m = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(m);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateManufacturerRequest;
const m = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(m);
} 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);
}
}
export { errors };
+88
View File
@@ -0,0 +1,88 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
BulkPartsRequest,
CreatePartRequest,
PartEventsQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import * as svc from '../services/parts.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 PartListQuery;
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 part = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!part) throw errors.notFound('Part');
res.json(part);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreatePartRequest;
const part = await prisma.$transaction((tx) =>
svc.create(tx, input, req.user ?? null),
);
res.status(201).json(part);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdatePartRequest;
const part = await prisma.$transaction((tx) =>
svc.update(tx, req.params.id, input, req.user ?? null),
);
res.json(part);
} 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);
}
}
export async function bulk(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as BulkPartsRequest;
const result = await prisma.$transaction((tx) =>
svc.bulkUpdate(tx, input, req.user ?? null),
);
res.json(result);
} catch (err) {
next(err);
}
}
export async function getEvents(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PartEventsQuery;
const result = await prisma.$transaction((tx) =>
svc.listEvents(tx, req.params.id, q),
);
res.json(result);
} catch (err) {
next(err);
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} 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 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 repair = await prisma.$transaction((tx) => svc.get(tx, req.params.id));
if (!repair) throw errors.notFound('Repair');
res.json(repair);
} catch (err) {
next(err);
}
}
export async function listForPart(
req: Request<{ id: string }>,
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),
);
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);
}
}
+63
View File
@@ -0,0 +1,63 @@
import type { NextFunction, Request, Response } from 'express';
import { z } from 'zod';
import { prisma } from '@vector/db';
import {
CreateRoomRequest,
PaginationQuery,
UpdateRoomRequest,
} from '@vector/shared';
import * as svc from '../services/locations.js';
import { errors } from '../lib/http-error.js';
const RoomListQuery = PaginationQuery.extend({ siteId: z.string().uuid().optional() });
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as z.infer<typeof RoomListQuery>;
const result = await prisma.$transaction((tx) => svc.listRooms(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const room = await prisma.$transaction((tx) => svc.getRoom(tx, req.params.id));
if (!room) throw errors.notFound('Room');
res.json(room);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateRoomRequest;
const room = await prisma.$transaction((tx) => svc.createRoom(tx, input));
res.status(201).json(room);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateRoomRequest;
const room = await prisma.$transaction((tx) => svc.updateRoom(tx, req.params.id, input));
res.json(room);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeRoom(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
export { RoomListQuery };
+54
View File
@@ -0,0 +1,54 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateSavedViewRequest,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import * as svc from '../services/saved-views.js';
import { errors } from '../lib/http-error.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const q = req.validated!.query as SavedViewListQuery;
const result = await prisma.$transaction((tx) => svc.listMine(tx, req.user!, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as CreateSavedViewRequest;
const view = await prisma.$transaction((tx) => svc.create(tx, req.user!, input));
res.status(201).json(view);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
const input = req.validated!.body as UpdateSavedViewRequest;
const view = await prisma.$transaction((tx) =>
svc.update(tx, req.user!, req.params.id, input),
);
res.json(view);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
if (!req.user) throw errors.unauthorized();
await prisma.$transaction((tx) => svc.remove(tx, req.user!, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateSiteRequest,
PaginationQuery,
UpdateSiteRequest,
} from '@vector/shared';
import * as svc from '../services/locations.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 PaginationQuery;
const result = await prisma.$transaction((tx) => svc.listSites(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function get(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const site = await prisma.$transaction((tx) => svc.getSite(tx, req.params.id));
if (!site) throw errors.notFound('Site');
res.json(site);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateSiteRequest;
const site = await prisma.$transaction((tx) => svc.createSite(tx, input));
res.status(201).json(site);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateSiteRequest;
const site = await prisma.$transaction((tx) => svc.updateSite(tx, req.params.id, input));
res.json(site);
} catch (err) {
next(err);
}
}
export async function remove(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
await prisma.$transaction((tx) => svc.removeSite(tx, req.params.id));
res.status(204).end();
} catch (err) {
next(err);
}
}
+92
View File
@@ -0,0 +1,92 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import * as svc from '../services/tags.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as TagListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateTagRequest;
const tag = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(tag);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateTagRequest;
const tag = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(tag);
} 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);
}
}
export async function listForPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const tags = await prisma.$transaction((tx) => svc.listForPart(tx, req.params.id));
res.json(tags);
} catch (err) {
next(err);
}
}
export async function assignToPart(
req: Request<{ id: string }>,
res: Response,
next: NextFunction,
) {
try {
const input = req.validated!.body as AssignTagsRequest;
const tags = await prisma.$transaction((tx) =>
svc.assignToPart(tx, req.params.id, input, req.user ?? null),
);
res.json(tags);
} catch (err) {
next(err);
}
}
export async function unassignFromPart(
req: Request<{ id: string; tagId: string }>,
res: Response,
next: NextFunction,
) {
try {
const tags = await prisma.$transaction((tx) =>
svc.unassignFromPart(tx, req.params.id, req.params.tagId, req.user ?? null),
);
res.json(tags);
} catch (err) {
next(err);
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateUserRequest,
PaginationQuery,
UpdateUserRequest,
} from '@vector/shared';
import * as svc from '../services/users.js';
export async function listUsers(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as PaginationQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function createUser(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateUserRequest;
const u = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(u);
} catch (err) {
next(err);
}
}
export async function updateUser(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateUserRequest;
const u = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(u);
} catch (err) {
next(err);
}
}
export async function deleteUser(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);
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import type {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import * as svc from '../services/webhooks.js';
export async function list(req: Request, res: Response, next: NextFunction) {
try {
const q = req.validated!.query as WebhookSubscriptionListQuery;
const result = await prisma.$transaction((tx) => svc.list(tx, q));
res.json(result);
} catch (err) {
next(err);
}
}
export async function create(req: Request, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as CreateWebhookSubscriptionRequest;
const sub = await prisma.$transaction((tx) => svc.create(tx, input));
res.status(201).json(sub);
} catch (err) {
next(err);
}
}
export async function update(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const input = req.validated!.body as UpdateWebhookSubscriptionRequest;
const sub = await prisma.$transaction((tx) => svc.update(tx, req.params.id, input));
res.json(sub);
} 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);
}
}
export async function rotate(req: Request<{ id: string }>, res: Response, next: NextFunction) {
try {
const sub = await prisma.$transaction((tx) => svc.rotateSecret(tx, req.params.id));
res.json(sub);
} catch (err) {
next(err);
}
}
+14
View File
@@ -0,0 +1,14 @@
import 'dotenv/config';
import { ApiEnv } from '@vector/shared';
const parsed = ApiEnv.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment configuration:');
for (const issue of parsed.error.issues) {
console.error(` ${issue.path.join('.') || '(root)'}: ${issue.message}`);
}
process.exit(1);
}
export const env = parsed.data;
+7
View File
@@ -0,0 +1,7 @@
import './env.js';
import { app } from './app.js';
import { env } from './env.js';
app.listen(env.PORT, () => {
console.log(`Vector API listening on port ${env.PORT}`);
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { AppError, errors } from './http-error.js';
describe('AppError', () => {
it('carries status, code, message, and optional details', () => {
const e = new AppError(418, 'TEAPOT', 'short and stout', { reason: 'tea' });
expect(e).toBeInstanceOf(Error);
expect(e.status).toBe(418);
expect(e.code).toBe('TEAPOT');
expect(e.message).toBe('short and stout');
expect(e.details).toEqual({ reason: 'tea' });
expect(e.name).toBe('AppError');
});
});
describe('errors factory', () => {
it('unauthorized defaults and overrides', () => {
const def = errors.unauthorized();
expect(def.status).toBe(401);
expect(def.code).toBe('UNAUTHORIZED');
expect(def.message).toBe('Unauthorized');
expect(errors.unauthorized('custom').message).toBe('custom');
});
it('notFound uses resource name in message', () => {
const e = errors.notFound('Part');
expect(e.status).toBe(404);
expect(e.message).toBe('Part not found');
});
it('validation wraps details', () => {
const e = errors.validation({ field: 'x' });
expect(e.status).toBe(400);
expect(e.code).toBe('VALIDATION_ERROR');
expect(e.details).toEqual({ field: 'x' });
});
it('conflict requires a message', () => {
const e = errors.conflict('serial already exists');
expect(e.status).toBe(409);
expect(e.code).toBe('CONFLICT');
expect(e.message).toBe('serial already exists');
});
it('tooManyRequests returns 429', () => {
expect(errors.tooManyRequests().status).toBe(429);
});
});
+23
View File
@@ -0,0 +1,23 @@
export class AppError extends Error {
status: number;
code: string;
details?: unknown;
constructor(status: number, code: string, message: string, details?: unknown) {
super(message);
this.name = 'AppError';
this.status = status;
this.code = code;
this.details = details;
}
}
export const errors = {
unauthorized: (msg = 'Unauthorized') => new AppError(401, 'UNAUTHORIZED', msg),
forbidden: (msg = 'Forbidden') => new AppError(403, 'FORBIDDEN', msg),
notFound: (resource: string) => new AppError(404, 'NOT_FOUND', `${resource} not found`),
conflict: (msg: string) => new AppError(409, 'CONFLICT', msg),
badRequest: (msg: string, details?: unknown) => new AppError(400, 'BAD_REQUEST', msg, details),
validation: (details: unknown) => new AppError(400, 'VALIDATION_ERROR', 'Validation failed', details),
tooManyRequests: (msg = 'Too many requests') => new AppError(429, 'RATE_LIMITED', msg),
} as const;
+9
View File
@@ -0,0 +1,9 @@
import pino from 'pino';
import { env } from '../env.js';
export const logger = pino({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
...(env.NODE_ENV === 'production'
? {}
: { transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss.l' } } }),
});
+84
View File
@@ -0,0 +1,84 @@
import type { WebhookEventName } from '@vector/shared';
import { prisma } from '@vector/db';
import * as webhooksSvc from '../services/webhooks.js';
import { logger } from './logger.js';
// Recursion guard: deliveries include this header so receivers know the payload
// originated from Vector and can short-circuit echo loops. Worker-side BullMQ
// delivery (planned in the Phase 7 follow-up) will honor the same header plus a
// max-depth check.
export const VECTOR_HOOK_HEADER = 'x-vector-webhook';
const DELIVERY_TIMEOUT_MS = 8_000;
const MAX_ATTEMPTS = 3;
const BACKOFF_MS = [0, 2_000, 10_000];
interface EmitOptions {
event: WebhookEventName;
payload: Record<string, unknown>;
}
// Fire-and-forget: collects active subscriptions for the event and schedules delivery
// to each. Never throws into caller. This is the interim in-process implementation;
// the plan calls for a BullMQ worker — keep the signature stable so swapping stays
// a one-line change in `emit`.
export async function emit({ event, payload }: EmitOptions): Promise<void> {
const subs = await prisma
.$transaction((tx) => webhooksSvc.listActiveForEvent(tx, event))
.catch((err) => {
logger.warn({ err, event }, 'webhook emit: subscription lookup failed');
return [];
});
if (subs.length === 0) return;
const body = JSON.stringify({ event, data: payload, emittedAt: new Date().toISOString() });
for (const sub of subs) {
if (!sub.secret) continue;
void deliver(sub.id, sub.url, sub.secret, body, event).catch((err) => {
logger.warn({ err, event, subId: sub.id }, 'webhook delivery crashed');
});
}
}
async function deliver(
subId: string,
url: string,
secret: string,
body: string,
event: WebhookEventName,
): Promise<void> {
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
const wait = BACKOFF_MS[attempt] ?? 0;
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
const timestamp = Math.floor(Date.now() / 1000);
const signature = webhooksSvc.signBody(secret, body, timestamp);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), DELIVERY_TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
[VECTOR_HOOK_HEADER]: 'v1',
'x-vector-event': event,
'x-vector-timestamp': String(timestamp),
'x-vector-signature': signature,
},
body,
signal: controller.signal,
});
clearTimeout(timeout);
if (res.ok) {
logger.debug({ subId, event, status: res.status, attempt }, 'webhook delivered');
return;
}
logger.warn(
{ subId, event, status: res.status, attempt },
'webhook non-2xx, will retry',
);
} catch (err) {
clearTimeout(timeout);
logger.warn({ err, subId, event, attempt }, 'webhook delivery error');
}
}
logger.error({ subId, event }, 'webhook delivery exhausted retries');
}
+32
View File
@@ -0,0 +1,32 @@
import type { NextFunction, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import type { Role } from '@vector/shared';
import { env } from '../env.js';
import { errors } from '../lib/http-error.js';
type JwtPayload = { id: string; username: string; role: Role };
export function requireAuth(req: Request, _res: Response, next: NextFunction) {
const token = req.cookies?.token;
if (!token) {
next(errors.unauthorized());
return;
}
try {
const decoded = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
req.user = { id: decoded.id, username: decoded.username, role: decoded.role };
next();
} catch {
next(errors.unauthorized('Invalid or expired token'));
}
}
export function requireRole(role: Role) {
return (req: Request, _res: Response, next: NextFunction) => {
if (!req.user || req.user.role !== role) {
next(errors.forbidden());
return;
}
next();
};
}
+39
View File
@@ -0,0 +1,39 @@
import { randomBytes, timingSafeEqual } from 'node:crypto';
import type { CookieOptions, NextFunction, Request, Response } from 'express';
import { env } from '../env.js';
import { errors } from '../lib/http-error.js';
export const CSRF_COOKIE = 'csrf';
export const CSRF_HEADER = 'x-csrf-token';
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
export function issueCsrfToken(res: Response): string {
const token = randomBytes(32).toString('hex');
const opts: CookieOptions = {
httpOnly: false,
sameSite: 'lax',
secure: env.NODE_ENV === 'production',
path: '/',
};
res.cookie(CSRF_COOKIE, token, opts);
return token;
}
function tokensMatch(a: string, b: string): boolean {
if (a.length !== b.length) return false;
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
export function requireCsrf(req: Request, _res: Response, next: NextFunction) {
if (SAFE_METHODS.has(req.method)) {
next();
return;
}
const cookieToken = req.cookies?.[CSRF_COOKIE];
const headerToken = req.header(CSRF_HEADER);
if (!cookieToken || !headerToken || !tokensMatch(cookieToken, headerToken)) {
next(errors.forbidden('CSRF token missing or invalid'));
return;
}
next();
}
+57
View File
@@ -0,0 +1,57 @@
import type { NextFunction, Request, Response } from 'express';
import { ZodError } from 'zod';
import { Prisma } from '@vector/db';
import { AppError } from '../lib/http-error.js';
import { logger } from '../lib/logger.js';
interface ErrorEnvelope {
code: string;
message: string;
requestId: string;
details?: unknown;
}
export function errorHandler(
err: unknown,
req: Request,
res: Response,
_next: NextFunction,
) {
const requestId = req.requestId ?? 'unknown';
let envelope: ErrorEnvelope;
let status = 500;
if (err instanceof AppError) {
status = err.status;
envelope = { code: err.code, message: err.message, requestId, details: err.details };
} else if (err instanceof ZodError) {
status = 400;
envelope = {
code: 'VALIDATION_ERROR',
message: 'Validation failed',
requestId,
details: err.issues,
};
} else if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') {
status = 404;
envelope = { code: 'NOT_FOUND', message: 'Resource not found', requestId };
} else if (err.code === 'P2002') {
status = 409;
envelope = { code: 'CONFLICT', message: 'Unique constraint violated', requestId };
} else if (err.code === 'P2003') {
status = 409;
envelope = { code: 'CONFLICT', message: 'Foreign key constraint violated', requestId };
} else {
envelope = { code: 'DB_ERROR', message: 'Database error', requestId };
}
} else {
envelope = { code: 'INTERNAL_ERROR', message: 'Internal server error', requestId };
}
const logPayload = { requestId, status, err };
if (status >= 500) logger.error(logPayload, 'request failed');
else logger.warn(logPayload, 'request rejected');
res.status(status).json(envelope);
}
+10
View File
@@ -0,0 +1,10 @@
import { randomUUID } from 'node:crypto';
import type { NextFunction, Request, Response } from 'express';
export function requestId(req: Request, res: Response, next: NextFunction) {
const incoming = req.header('x-request-id');
const id = incoming && incoming.length <= 128 ? incoming : randomUUID();
req.requestId = id;
res.setHeader('X-Request-Id', id);
next();
}
+17
View File
@@ -0,0 +1,17 @@
import type { NextFunction, Request, Response } from 'express';
import type { ZodTypeAny } from 'zod';
type Target = 'body' | 'query' | 'params';
export function validate(target: Target, schema: ZodTypeAny) {
return (req: Request, _res: Response, next: NextFunction) => {
const result = schema.safeParse(req[target]);
if (!result.success) {
next(result.error);
return;
}
req.validated ??= {};
req.validated[target] = result.data;
next();
};
}
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import * as ctrl from '../controllers/analytics.js';
import { requireAuth } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth);
router.get('/dashboard', ctrl.dashboard);
export default router;
+10
View File
@@ -0,0 +1,10 @@
import { Router } from 'express';
import * as ctrl from '../controllers/audit-export.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
const router = Router();
router.use(requireAuth, requireRole('ADMIN'));
router.get('/events.csv', ctrl.eventsCsv);
export default router;
+14
View File
@@ -0,0 +1,14 @@
import { Router } from 'express';
import { LoginRequest } from '@vector/shared';
import * as ctrl from '../controllers/auth.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.post('/login', validate('body', LoginRequest), ctrl.login);
router.post('/refresh', ctrl.refresh);
router.post('/logout', ctrl.logout);
router.get('/me', requireAuth, ctrl.me);
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { CreateBinRequest, UpdateBinRequest } from '@vector/shared';
import * as ctrl from '../controllers/bins.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', ctrl.BinListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateBinRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateBinRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/categories.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', CategoryListQuery), ctrl.list);
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);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/hosts.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', HostListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateHostRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateHostRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+18
View File
@@ -0,0 +1,18 @@
import { Router } from 'express';
import {
CreateManufacturerRequest,
PaginationQuery,
UpdateManufacturerRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/manufacturers.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list);
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);
export default router;
+32
View File
@@ -0,0 +1,32 @@
import { Router } from 'express';
import {
AssignTagsRequest,
BulkPartsRequest,
CreatePartRequest,
PartEventsQuery,
PartListQuery,
UpdatePartRequest,
} 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';
const router = Router();
router.get('/', requireAuth, validate('query', PartListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreatePartRequest), ctrl.create);
router.post('/bulk', requireAuth, validate('body', BulkPartsRequest), ctrl.bulk);
router.get('/:id', requireAuth, ctrl.get);
router.get('/:id/events', requireAuth, validate('query', PartEventsQuery), ctrl.getEvents);
router.patch('/:id', requireAuth, validate('body', UpdatePartRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
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;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} 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('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, validate('body', UpdateRepairJobRequest), ctrl.update);
router.delete('/:id', requireAuth, ctrl.remove);
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { CreateRoomRequest, UpdateRoomRequest } from '@vector/shared';
import * as ctrl from '../controllers/rooms.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', ctrl.RoomListQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateRoomRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateRoomRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+20
View File
@@ -0,0 +1,20 @@
import { Router } from 'express';
import {
CreateSavedViewRequest,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/saved-views.js';
import { requireAuth } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.use(requireAuth);
router.get('/', validate('query', SavedViewListQuery), ctrl.list);
router.post('/', validate('body', CreateSavedViewRequest), ctrl.create);
router.patch('/:id', validate('body', UpdateSavedViewRequest), ctrl.update);
router.delete('/:id', ctrl.remove);
export default router;
+15
View File
@@ -0,0 +1,15 @@
import { Router } from 'express';
import { CreateSiteRequest, PaginationQuery, UpdateSiteRequest } from '@vector/shared';
import * as ctrl from '../controllers/sites.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', PaginationQuery), ctrl.list);
router.post('/', requireAuth, requireRole('ADMIN'), validate('body', CreateSiteRequest), ctrl.create);
router.get('/:id', requireAuth, ctrl.get);
router.patch('/:id', requireAuth, requireRole('ADMIN'), validate('body', UpdateSiteRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+19
View File
@@ -0,0 +1,19 @@
import { Router } from 'express';
import {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import * as ctrl from '../controllers/tags.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.get('/', requireAuth, validate('query', TagListQuery), ctrl.list);
router.post('/', requireAuth, validate('body', CreateTagRequest), ctrl.create);
router.patch('/:id', requireAuth, validate('body', UpdateTagRequest), ctrl.update);
router.delete('/:id', requireAuth, requireRole('ADMIN'), ctrl.remove);
export default router;
+16
View File
@@ -0,0 +1,16 @@
import { Router } from 'express';
import { CreateUserRequest, PaginationQuery, UpdateUserRequest } from '@vector/shared';
import * as ctrl from '../controllers/users.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.use(requireAuth, requireRole('ADMIN'));
router.get('/', validate('query', PaginationQuery), ctrl.listUsers);
router.post('/', validate('body', CreateUserRequest), ctrl.createUser);
router.patch('/:id', validate('body', UpdateUserRequest), ctrl.updateUser);
router.delete('/:id', ctrl.deleteUser);
export default router;
+21
View File
@@ -0,0 +1,21 @@
import { Router } from 'express';
import {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import * as ctrl from '../controllers/webhooks.js';
import { requireAuth, requireRole } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
const router = Router();
router.use(requireAuth, requireRole('ADMIN'));
router.get('/', validate('query', WebhookSubscriptionListQuery), ctrl.list);
router.post('/', validate('body', CreateWebhookSubscriptionRequest), ctrl.create);
router.patch('/:id', validate('body', UpdateWebhookSubscriptionRequest), ctrl.update);
router.post('/:id/rotate-secret', ctrl.rotate);
router.delete('/:id', ctrl.remove);
export default router;
+144
View File
@@ -0,0 +1,144 @@
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: {
partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[];
parts: {
id: string;
state: string;
binId: string | null;
createdAt: Date;
manufacturerId: string;
}[];
openRepairs: number;
eolManufacturers: { id: string; name: string; eolDate: Date | null }[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
}): Tx {
const tx = {
part: {
count: async () => args.partCount,
groupBy: async () =>
args.stateRows.map((s) => ({
state: s.state,
_count: { _all: s.count },
_sum: { price: s.totalPrice },
})),
findMany: async () => args.parts,
},
repairJob: {
count: async () => args.openRepairs,
},
manufacturer: {
findMany: async () => args.eolManufacturers,
},
bin: {
findMany: async () => args.bins,
},
};
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);
describe('analytics.dashboard', () => {
it('aggregates totals, state counts and open repairs', async () => {
const tx = makeTx({
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);
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 },
]);
});
it('buckets parts by age correctly', async () => {
const tx = makeTx({
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' },
],
openRepairs: 0,
eolManufacturers: [],
bins: [],
});
const r = await dashboard(tx);
const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count]));
expect(byLabel['030d']).toBe(1);
expect(byLabel['3190d']).toBe(1);
expect(byLabel['12y']).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({
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' },
],
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);
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 () => {
const tx = makeTx({
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' },
],
openRepairs: 0,
eolManufacturers: [
{ id: 'm1', name: 'Acme', eolDate: daysAgo(30) },
{ id: 'm2', name: 'Beta', eolDate: daysAgo(10) },
{ id: 'm3', name: 'Gamma', eolDate: daysAgo(5) },
],
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 });
});
});
+88
View File
@@ -0,0 +1,88 @@
import type { DashboardAnalytics } from '@vector/shared';
import type { Tx } from './types.js';
const DAY = 24 * 60 * 60 * 1000;
const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
{ label: '030d', maxDays: 30 },
{ label: '3190d', maxDays: 90 },
{ label: '91180d', maxDays: 180 },
{ label: '181365d', maxDays: 365 },
{ label: '12y', maxDays: 730 },
{ label: '2y+', maxDays: null },
];
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
const [totalParts, stateRows, parts, openRepairs, manufacturersWithEol] = await Promise.all([
tx.part.count(),
tx.part.groupBy({
by: ['state'],
_count: { _all: true },
_sum: { price: true },
}),
tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, manufacturerId: 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 },
}),
]);
const byState = stateRows.map((row) => ({
state: row.state as DashboardAnalytics['byState'][number]['state'],
count: row._count._all,
totalPrice: row._sum.price ?? 0,
}));
const now = Date.now();
const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 }));
for (const part of parts) {
const ageDays = (now - 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;
}
const binCounts = new Map<string, number>();
for (const part of parts) {
if (!part.binId) continue;
binCounts.set(part.binId, (binCounts.get(part.binId) ?? 0) + 1);
}
const topBinIds = [...binCounts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([id]) => id);
const binRows = topBinIds.length
? await tx.bin.findMany({
where: { id: { in: topBinIds } },
include: { room: { include: { site: true } } },
})
: [];
const binLabels = new Map(
binRows.map((b) => [b.id, `${b.room.site.name} / ${b.room.name} / ${b.name}`]),
);
const topBins = topBinIds.map((id) => ({
binId: id,
label: binLabels.get(id) ?? 'Unknown',
count: binCounts.get(id) ?? 0,
}));
const deployedByMfg = new Map<string, number>();
for (const part of parts) {
if (part.state !== 'DEPLOYED') continue;
deployedByMfg.set(part.manufacturerId, (deployedByMfg.get(part.manufacturerId) ?? 0) + 1);
}
const deployedPastEol = manufacturersWithEol
.map((m) => ({
manufacturerId: m.id,
name: m.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : null,
deployedCount: deployedByMfg.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openRepairs };
}
+126
View File
@@ -0,0 +1,126 @@
import { createHash, randomBytes } from 'node:crypto';
import bcrypt from 'bcryptjs';
import jwt, { type SignOptions } from 'jsonwebtoken';
import { Prisma } from '@vector/db';
import type { Role } from '@vector/shared';
import { env } from '../env.js';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export const ACCESS_TOKEN_TTL_MS = 15 * 60 * 1000;
export const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export interface AuthTokens {
accessToken: string;
refreshToken: string;
refreshExpiresAt: Date;
}
export interface AuthUser {
id: string;
username: string;
email: string;
role: Role;
}
function hashRefreshToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
function signAccessToken(user: AuthUser): string {
const opts: SignOptions = { expiresIn: Math.floor(ACCESS_TOKEN_TTL_MS / 1000) };
return jwt.sign({ id: user.id, username: user.username, role: user.role }, env.JWT_SECRET, opts);
}
async function issueRefreshToken(
tx: Tx,
userId: string,
replacedBy?: string,
): Promise<{ token: string; expiresAt: Date }> {
const token = randomBytes(48).toString('hex');
const tokenHash = hashRefreshToken(token);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS);
await tx.refreshToken.create({
data: { userId, tokenHash, expiresAt, replacedBy: replacedBy ?? null },
});
return { token, expiresAt };
}
export async function login(
tx: Tx,
username: string,
password: string,
): Promise<{ user: AuthUser; tokens: AuthTokens }> {
const user = await tx.user.findUnique({ where: { username } });
if (!user) throw errors.unauthorized('Invalid credentials');
const ok = await bcrypt.compare(password, user.passwordHash);
if (!ok) throw errors.unauthorized('Invalid credentials');
const publicUser: AuthUser = {
id: user.id,
username: user.username,
email: user.email,
role: user.role as Role,
};
const accessToken = signAccessToken(publicUser);
const { token: refreshToken, expiresAt } = await issueRefreshToken(tx, user.id);
return {
user: publicUser,
tokens: { accessToken, refreshToken, refreshExpiresAt: expiresAt },
};
}
export async function rotate(
tx: Tx,
presentedToken: string,
): Promise<{ user: AuthUser; tokens: AuthTokens }> {
const tokenHash = hashRefreshToken(presentedToken);
const existing = await tx.refreshToken.findUnique({
where: { tokenHash },
include: { user: true },
});
if (!existing || existing.revokedAt || existing.expiresAt < new Date()) {
throw errors.unauthorized('Invalid refresh token');
}
const { token: newToken, expiresAt } = await issueRefreshToken(tx, existing.userId);
const newRow = await tx.refreshToken.findUnique({
where: { tokenHash: hashRefreshToken(newToken) },
});
await tx.refreshToken.update({
where: { id: existing.id },
data: { revokedAt: new Date(), replacedBy: newRow?.id ?? null },
});
const publicUser: AuthUser = {
id: existing.user.id,
username: existing.user.username,
email: existing.user.email,
role: existing.user.role as Role,
};
return {
user: publicUser,
tokens: { accessToken: signAccessToken(publicUser), refreshToken: newToken, refreshExpiresAt: expiresAt },
};
}
export async function revoke(tx: Tx, presentedToken: string | undefined): Promise<void> {
if (!presentedToken) return;
const tokenHash = hashRefreshToken(presentedToken);
try {
await tx.refreshToken.update({
where: { tokenHash },
data: { revokedAt: new Date() },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') return;
throw err;
}
}
export async function revokeAllForUser(tx: Tx, userId: string): Promise<void> {
await tx.refreshToken.updateMany({
where: { userId, revokedAt: null },
data: { revokedAt: new Date() },
});
}
+60
View File
@@ -0,0 +1,60 @@
import { Prisma } from '@vector/db';
import type {
CategoryListQuery,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export async function list(tx: Tx, q: CategoryListQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.category.findMany({
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.category.count(),
]);
return { data, page, pageSize, total };
}
export async function create(tx: Tx, input: CreateCategoryRequest) {
try {
return await tx.category.create({
data: { name: input.name, description: input.description ?? null },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Category name already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateCategoryRequest) {
const data: Prisma.CategoryUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.description !== undefined) data.description = input.description;
try {
return await tx.category.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Category');
if (err.code === 'P2002') throw errors.conflict('Category name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.category.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Category');
}
throw err;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { Prisma } from '@vector/db';
import type {
CreateHostRequest,
HostListQuery,
UpdateHostRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export async function list(tx: Tx, q: HostListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.HostWhereInput = search
? {
OR: [
{ name: { contains: search } },
{ location: { contains: search } },
],
}
: {};
const [data, total] = await Promise.all([
tx.host.findMany({
where,
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.host.count({ where }),
]);
return { data, page, pageSize, total };
}
export function get(tx: Tx, id: string) {
return tx.host.findUnique({ where: { id } });
}
export async function create(tx: Tx, input: CreateHostRequest) {
try {
return await tx.host.create({
data: {
name: input.name,
location: input.location ?? null,
notes: input.notes ?? null,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Host name already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateHostRequest) {
const data: Prisma.HostUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.location !== undefined) data.location = input.location;
if (input.notes !== undefined) data.notes = input.notes;
try {
return 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');
}
throw err;
}
}
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');
}
throw err;
}
}
+216
View File
@@ -0,0 +1,216 @@
import { Prisma } from '@vector/db';
import type {
CreateBinRequest,
CreateRoomRequest,
CreateSiteRequest,
PaginationQuery,
UpdateBinRequest,
UpdateRoomRequest,
UpdateSiteRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
const binInclude = { room: { include: { site: true } } } satisfies Prisma.BinInclude;
type BinWithRelations = Prisma.BinGetPayload<{ include: typeof binInclude }>;
export type BinWithPath = BinWithRelations & { fullPath: string };
function binPath(bin: BinWithRelations): string {
return `${bin.room.site.name}.${bin.room.name}.${bin.name}`;
}
export function withBinPath(bin: BinWithRelations): BinWithPath {
return { ...bin, fullPath: binPath(bin) };
}
// ---- sites ----
export async function listSites(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.site.findMany({
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.site.count(),
]);
return { data, page, pageSize, total };
}
export function getSite(tx: Tx, id: string) {
return tx.site.findUnique({
where: { id },
include: { rooms: { include: { bins: true } } },
});
}
export async function createSite(tx: Tx, input: CreateSiteRequest) {
try {
return await tx.site.create({ data: { name: input.name } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Site already exists');
}
throw err;
}
}
export async function updateSite(tx: Tx, id: string, input: UpdateSiteRequest) {
try {
return await tx.site.update({ where: { id }, data: { name: input.name } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Site');
if (err.code === 'P2002') throw errors.conflict('Site already exists');
}
throw err;
}
}
export async function removeSite(tx: Tx, id: string) {
try {
await tx.site.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Site');
}
throw err;
}
}
// ---- rooms ----
export async function listRooms(tx: Tx, q: PaginationQuery & { siteId?: string }) {
const { page, pageSize, siteId } = q;
const where = siteId ? { siteId } : {};
const [data, total] = await Promise.all([
tx.room.findMany({
where,
orderBy: { name: 'asc' },
include: { site: true },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.room.count({ where }),
]);
return { data, page, pageSize, total };
}
export function getRoom(tx: Tx, id: string) {
return tx.room.findUnique({ where: { id }, include: { site: true, bins: true } });
}
export async function createRoom(tx: Tx, input: CreateRoomRequest) {
try {
return await tx.room.create({
data: { name: input.name, siteId: input.siteId },
include: { site: true },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Room already exists in this site');
}
throw err;
}
}
export async function updateRoom(tx: Tx, id: string, input: UpdateRoomRequest) {
const data: Prisma.RoomUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.siteId !== undefined) data.site = { connect: { id: input.siteId } };
try {
return await tx.room.update({ where: { id }, data, include: { site: true } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Room');
if (err.code === 'P2002') throw errors.conflict('Room already exists in this site');
}
throw err;
}
}
export async function removeRoom(tx: Tx, id: string) {
try {
await tx.room.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Room');
}
throw err;
}
}
// ---- bins ----
export async function listBins(
tx: Tx,
q: PaginationQuery & { roomId?: string; siteId?: string },
) {
const { page, pageSize, roomId, siteId } = q;
const where: Prisma.BinWhereInput = {};
if (roomId) where.roomId = roomId;
if (siteId) where.room = { siteId };
const [rows, total] = await Promise.all([
tx.bin.findMany({
where,
orderBy: { name: 'asc' },
include: binInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.bin.count({ where }),
]);
return { data: rows.map(withBinPath), page, pageSize, total };
}
export async function getBin(tx: Tx, id: string): Promise<BinWithPath | null> {
const bin = await tx.bin.findUnique({ where: { id }, include: binInclude });
return bin ? withBinPath(bin) : null;
}
export async function createBin(tx: Tx, input: CreateBinRequest): Promise<BinWithPath> {
try {
const bin = await tx.bin.create({
data: { name: input.name, roomId: input.roomId },
include: binInclude,
});
return withBinPath(bin);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Bin already exists in this room');
}
throw err;
}
}
export async function updateBin(
tx: Tx,
id: string,
input: UpdateBinRequest,
): Promise<BinWithPath> {
const data: Prisma.BinUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.roomId !== undefined) data.room = { connect: { id: input.roomId } };
try {
const bin = await tx.bin.update({ where: { id }, data, include: binInclude });
return withBinPath(bin);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Bin');
if (err.code === 'P2002') throw errors.conflict('Bin already exists in this room');
}
throw err;
}
}
export async function removeBin(tx: Tx, id: string) {
try {
await tx.bin.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Bin');
}
throw err;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { Prisma } from '@vector/db';
import type {
CreateManufacturerRequest,
UpdateManufacturerRequest,
PaginationQuery,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export async function list(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.manufacturer.findMany({
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.manufacturer.count(),
]);
return { data, page, pageSize, total };
}
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,
},
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Manufacturer already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateManufacturerRequest) {
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) {
if (err.code === 'P2025') throw errors.notFound('Manufacturer');
if (err.code === 'P2002') throw errors.conflict('Manufacturer already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.manufacturer.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Manufacturer');
if (err.code === 'P2003') {
throw errors.conflict('Cannot delete: manufacturer has parts assigned');
}
}
throw err;
}
}
+328
View File
@@ -0,0 +1,328 @@
import { Prisma } from '@vector/db';
import type {
CreatePartRequest,
PaginationQuery,
PartListQuery,
UpdatePartRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import * as tagsSvc from './tags.js';
import type { Actor, Tx } from './types.js';
const partInclude = {
manufacturer: true,
bin: { include: { room: { include: { site: true } } } },
category: true,
tags: { include: { tag: true } },
} satisfies Prisma.PartInclude;
type PartWithRelations = Prisma.PartGetPayload<{ include: typeof partInclude }>;
type BinWithSite = NonNullable<PartWithRelations['bin']>;
export type PartWithPath = Omit<PartWithRelations, 'tags'> & {
bin: (BinWithSite & { fullPath?: string }) | null;
tags: { id: string; name: string; color: string | null }[];
};
function binPath(bin: BinWithSite | null | undefined): string | null {
if (!bin) return null;
return `${bin.room.site.name}.${bin.room.name}.${bin.name}`;
}
function flattenTags(part: PartWithRelations): PartWithPath {
const { tags, ...rest } = part;
const out = rest as PartWithPath;
if (out.bin) out.bin.fullPath = binPath(out.bin) ?? undefined;
out.tags = tags.map((t) => ({
id: t.tag.id,
name: t.tag.name,
color: t.tag.color,
}));
return out;
}
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.manufacturerId) where.manufacturerId = q.manufacturerId;
if (q.categoryId) where.categoryId = q.categoryId;
if (q.mpn) where.mpn = { contains: q.mpn };
if (q.serialNumber) where.serialNumber = { contains: q.serialNumber };
if (q.q) {
where.OR = [
{ serialNumber: { contains: q.q } },
{ 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() } };
}
return where;
}
export async function list(tx: Tx, q: PartListQuery) {
const { page, pageSize } = q;
const where = buildWhere(q);
const [rows, total] = await Promise.all([
tx.part.findMany({
where,
orderBy: { createdAt: 'desc' },
include: partInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.part.count({ where }),
]);
return { data: rows.map(flattenTags), page, pageSize, total };
}
export async function get(tx: Tx, id: string): Promise<PartWithPath | null> {
const p = await tx.part.findUnique({ where: { id }, include: partInclude });
return p ? flattenTags(p) : null;
}
export async function create(
tx: Tx,
input: CreatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
try {
const p = await tx.part.create({
data: {
serialNumber: input.serialNumber,
mpn: input.mpn,
manufacturerId: input.manufacturerId,
price: input.price ?? null,
state: input.state ?? 'SPARE',
binId: input.binId ?? null,
categoryId: input.categoryId ?? null,
replacementPartId: input.replacementPartId ?? null,
notes: input.notes ?? null,
},
include: partInclude,
});
await tx.partEvent.create({
data: {
partId: p.id,
userId: actor?.id ?? null,
type: 'CREATED',
newValue: p.serialNumber,
},
});
if (input.tagIds && input.tagIds.length > 0) {
await tagsSvc.setPartTags(tx, p.id, input.tagIds, actor);
}
const refreshed = await tx.part.findUnique({ where: { id: p.id }, include: partInclude });
return flattenTags(refreshed!);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Serial number already exists');
}
throw err;
}
}
export async function update(
tx: Tx,
id: string,
input: UpdatePartRequest,
actor: Actor | null,
): Promise<PartWithPath> {
const current = await tx.part.findUnique({ where: { id }, include: partInclude });
if (!current) throw errors.notFound('Part');
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.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 } }
: { disconnect: true };
}
if (input.notes !== undefined) data.notes = input.notes;
let part: PartWithRelations;
try {
part = await tx.part.update({ where: { id }, data, include: partInclude });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Part');
if (err.code === 'P2002') throw errors.conflict('Serial number already exists');
}
throw err;
}
const userId = actor?.id ?? null;
const events: Prisma.PartEventCreateManyInput[] = [];
if (input.state !== undefined && input.state !== current.state) {
events.push({
partId: part.id,
userId,
type: 'STATE_CHANGED',
field: 'state',
oldValue: current.state,
newValue: input.state,
});
}
if (input.binId !== undefined && input.binId !== current.binId) {
events.push({
partId: part.id,
userId,
type: 'LOCATION_CHANGED',
field: 'bin',
oldValue: binPath(current.bin),
newValue: binPath(part.bin),
});
}
if (input.mpn !== undefined && input.mpn !== current.mpn) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'mpn',
oldValue: current.mpn,
newValue: input.mpn,
});
}
if (input.serialNumber !== undefined && input.serialNumber !== current.serialNumber) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'serialNumber',
oldValue: current.serialNumber,
newValue: input.serialNumber,
});
}
if (input.manufacturerId !== undefined && input.manufacturerId !== current.manufacturerId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'manufacturer',
oldValue: current.manufacturer.name,
newValue: part.manufacturer.name,
});
}
if (input.categoryId !== undefined && input.categoryId !== current.categoryId) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'category',
oldValue: current.category?.name ?? null,
newValue: part.category?.name ?? null,
});
}
if (input.price !== undefined && input.price !== current.price) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'price',
oldValue: current.price?.toString() ?? null,
newValue: input.price?.toString() ?? null,
});
}
if (input.notes !== undefined && (input.notes ?? null) !== (current.notes ?? null)) {
events.push({
partId: part.id,
userId,
type: 'FIELD_UPDATED',
field: 'notes',
oldValue: current.notes ?? null,
newValue: input.notes ?? null,
});
}
if (events.length > 0) await tx.partEvent.createMany({ data: events });
if (input.tagIds !== undefined) {
await tagsSvc.setPartTags(tx, part.id, input.tagIds, actor);
const refreshed = await tx.part.findUnique({ where: { id: part.id }, include: partInclude });
return flattenTags(refreshed!);
}
return flattenTags(part);
}
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');
}
throw err;
}
}
export async function listEvents(tx: Tx, partId: string, q: PaginationQuery) {
const { page, pageSize } = q;
const [data, total] = await Promise.all([
tx.partEvent.findMany({
where: { partId },
orderBy: { createdAt: 'desc' },
include: { user: { select: { username: true } } },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.partEvent.count({ where: { partId } }),
]);
return { data, page, pageSize, total };
}
// Bulk mutation. Batches all writes inside a single transaction so partial failures roll back.
// Intentionally capped so callers can't accidentally lock the whole parts table.
export interface BulkPartsInput {
ids: string[];
state?: CreatePartRequest['state'];
binId?: string | null;
addTagIds?: string[];
removeTagIds?: string[];
}
const BULK_LIMIT = 500;
export async function bulkUpdate(tx: Tx, input: BulkPartsInput, actor: Actor | null) {
if (input.ids.length === 0) throw errors.badRequest('No part ids supplied');
if (input.ids.length > BULK_LIMIT) {
throw errors.badRequest(`Bulk operations are limited to ${BULK_LIMIT} parts per call`);
}
const touched: string[] = [];
for (const id of input.ids) {
const patch: UpdatePartRequest = {};
if (input.state !== undefined) patch.state = input.state;
if (input.binId !== undefined) patch.binId = input.binId;
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));
(input.addTagIds ?? []).forEach((t) => next.add(t));
(input.removeTagIds ?? []).forEach((t) => next.delete(t));
await tagsSvc.setPartTags(tx, id, [...next], actor);
}
touched.push(id);
}
return { updated: touched.length };
}
+145
View File
@@ -0,0 +1,145 @@
import { Prisma } from '@vector/db';
import type {
CreateRepairJobRequest,
RepairJobListQuery,
UpdateRepairJobRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
const repairInclude = {
part: {
include: { manufacturer: true },
},
host: true,
assignee: { select: { id: true, username: true, email: true, role: true } },
} satisfies Prisma.RepairJobInclude;
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'] };
const [data, total] = await Promise.all([
tx.repairJob.findMany({
where,
orderBy: [{ status: 'asc' }, { openedAt: 'desc' }],
include: repairInclude,
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.repairJob.count({ where }),
]);
return { data, page, pageSize, total };
}
export function get(tx: Tx, id: string) {
return tx.repairJob.findUnique({ where: { id }, include: repairInclude });
}
export function listForPart(tx: Tx, partId: string) {
return tx.repairJob.findMany({
where: { partId },
orderBy: { openedAt: 'desc' },
include: repairInclude,
});
}
export async function create(
tx: Tx,
input: CreateRepairJobRequest,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: input.partId } });
if (!part) throw errors.notFound('Part');
try {
const repair = await tx.repairJob.create({
data: {
partId: input.partId,
hostId: input.hostId ?? null,
assigneeId: input.assigneeId ?? null,
notes: input.notes ?? null,
status: 'PENDING',
},
include: repairInclude,
});
await tx.partEvent.create({
data: {
partId: part.id,
userId: actor?.id ?? null,
type: 'REPAIR_STARTED',
newValue: repair.id,
},
});
return repair;
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('Invalid host or assignee id');
}
throw err;
}
}
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');
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;
const repair = await tx.repairJob.update({
where: { id },
data,
include: repairInclude,
});
if (input.status === 'COMPLETED' && current.status !== 'COMPLETED') {
await tx.partEvent.create({
data: {
partId: repair.partId,
userId: actor?.id ?? null,
type: 'REPAIR_COMPLETED',
newValue: repair.id,
},
});
}
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;
}
}
+106
View File
@@ -0,0 +1,106 @@
import { Prisma } from '@vector/db';
import type {
CreateSavedViewRequest,
SavedViewFilter,
SavedViewListQuery,
UpdateSavedViewRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
interface SavedViewRow {
id: string;
userId: string;
resource: string;
name: string;
filterJson: string;
createdAt: Date;
updatedAt: Date;
}
interface SavedView {
id: string;
userId: string;
resource: string;
name: string;
filter: SavedViewFilter;
createdAt: Date;
updatedAt: Date;
}
// SavedView.filterJson is a JSON-encoded string on SQLite; we parse on read, stringify on write.
function hydrate(row: SavedViewRow): SavedView {
let filter: SavedViewFilter = {};
try {
filter = JSON.parse(row.filterJson) as SavedViewFilter;
} catch {
// Corrupt row — surface an empty view rather than a 500. Bad rows should be cleaned up.
}
const { filterJson: _ignored, ...rest } = row;
void _ignored;
return { ...rest, filter };
}
export async function listMine(tx: Tx, actor: Actor, q: SavedViewListQuery) {
const where: Prisma.SavedViewWhereInput = { userId: actor.id };
if (q.resource) where.resource = q.resource;
const [rows, total] = await Promise.all([
tx.savedView.findMany({
where,
orderBy: { name: 'asc' },
skip: (q.page - 1) * q.pageSize,
take: q.pageSize,
}),
tx.savedView.count({ where }),
]);
return { data: rows.map(hydrate), page: q.page, pageSize: q.pageSize, total };
}
export async function create(tx: Tx, actor: Actor, input: CreateSavedViewRequest) {
try {
const row = await tx.savedView.create({
data: {
userId: actor.id,
resource: input.resource,
name: input.name,
filterJson: JSON.stringify(input.filter),
},
});
return hydrate(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('A saved view with this name already exists');
}
throw err;
}
}
export async function update(
tx: Tx,
actor: Actor,
id: string,
input: UpdateSavedViewRequest,
) {
const existing = await tx.savedView.findUnique({ where: { id } });
if (!existing || existing.userId !== actor.id) throw errors.notFound('Saved view');
const data: Prisma.SavedViewUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.filter !== undefined) data.filterJson = JSON.stringify(input.filter);
try {
const row = await tx.savedView.update({ where: { id }, data });
return hydrate(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('A saved view with this name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, actor: Actor, id: string) {
const existing = await tx.savedView.findUnique({ where: { id } });
if (!existing || existing.userId !== actor.id) throw errors.notFound('Saved view');
await tx.savedView.delete({ where: { id } });
}
+157
View File
@@ -0,0 +1,157 @@
import { Prisma } from '@vector/db';
import type {
AssignTagsRequest,
CreateTagRequest,
TagListQuery,
UpdateTagRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Actor, Tx } from './types.js';
export async function list(tx: Tx, q: TagListQuery) {
const { page, pageSize, q: search } = q;
const where: Prisma.TagWhereInput = search ? { name: { contains: search } } : {};
const [data, total] = await Promise.all([
tx.tag.findMany({
where,
orderBy: { name: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.tag.count({ where }),
]);
return { data, page, pageSize, total };
}
export async function create(tx: Tx, input: CreateTagRequest) {
try {
return await tx.tag.create({
data: { name: input.name, color: input.color ?? null },
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Tag name already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateTagRequest) {
const data: Prisma.TagUpdateInput = {};
if (input.name !== undefined) data.name = input.name;
if (input.color !== undefined) data.color = input.color;
try {
return await tx.tag.update({ where: { id }, data });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('Tag');
if (err.code === 'P2002') throw errors.conflict('Tag name already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.tag.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('Tag');
}
throw err;
}
}
// Replace the full set of tags on a part. Emits TAG_ADDED / TAG_REMOVED events for the diff so
// history stays accurate. Used by assignPart / the part create/update flow.
export async function setPartTags(
tx: Tx,
partId: string,
tagIds: string[],
actor: Actor | null,
) {
const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } });
const before = new Set(existing.map((r) => r.tagId));
const after = new Set(tagIds);
const toAdd = [...after].filter((id) => !before.has(id));
const toRemove = [...before].filter((id) => !after.has(id));
if (toRemove.length > 0) {
await tx.partTag.deleteMany({
where: { partId, tagId: { in: toRemove } },
});
}
if (toAdd.length > 0) {
try {
await tx.partTag.createMany({
data: toAdd.map((tagId) => ({ partId, tagId })),
});
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2003') {
throw errors.badRequest('One or more tag ids are invalid');
}
throw err;
}
}
if (toAdd.length + toRemove.length > 0) {
const tagNames = await tx.tag.findMany({
where: { id: { in: [...toAdd, ...toRemove] } },
select: { id: true, name: true },
});
const nameById = new Map(tagNames.map((t) => [t.id, t.name]));
const events: Prisma.PartEventCreateManyInput[] = [
...toAdd.map((id) => ({
partId,
userId: actor?.id ?? null,
type: 'TAG_ADDED' as const,
newValue: nameById.get(id) ?? id,
})),
...toRemove.map((id) => ({
partId,
userId: actor?.id ?? null,
type: 'TAG_REMOVED' as const,
oldValue: nameById.get(id) ?? id,
})),
];
await tx.partEvent.createMany({ data: events });
}
}
export async function listForPart(tx: Tx, partId: string) {
const rows = await tx.partTag.findMany({
where: { partId },
include: { tag: true },
orderBy: { tag: { name: 'asc' } },
});
return rows.map((r) => r.tag);
}
export async function assignToPart(
tx: Tx,
partId: string,
input: AssignTagsRequest,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } });
const merged = Array.from(new Set([...existing.map((e) => e.tagId), ...input.tagIds]));
await setPartTags(tx, partId, merged, actor);
return listForPart(tx, partId);
}
export async function unassignFromPart(
tx: Tx,
partId: string,
tagId: string,
actor: Actor | null,
) {
const part = await tx.part.findUnique({ where: { id: partId } });
if (!part) throw errors.notFound('Part');
const existing = await tx.partTag.findMany({ where: { partId }, select: { tagId: true } });
const next = existing.map((e) => e.tagId).filter((id) => id !== tagId);
await setPartTags(tx, partId, next, actor);
return listForPart(tx, partId);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Prisma } from '@vector/db';
import type { Role } from '@vector/shared';
export type Tx = Prisma.TransactionClient;
export interface Actor {
id: string;
username: string;
role: Role;
}
+77
View File
@@ -0,0 +1,77 @@
import bcrypt from 'bcryptjs';
import { Prisma, type User } from '@vector/db';
import type {
CreateUserRequest,
PaginationQuery,
UpdateUserRequest,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
export type PublicUser = Pick<User, 'id' | 'username' | 'email' | 'role' | 'createdAt'>;
export function toPublic(u: User): PublicUser {
return { id: u.id, username: u.username, email: u.email, role: u.role, createdAt: u.createdAt };
}
export async function list(tx: Tx, q: PaginationQuery) {
const { page, pageSize } = q;
const [rows, total] = await Promise.all([
tx.user.findMany({
orderBy: { username: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.user.count(),
]);
return { data: rows.map(toPublic), page, pageSize, total };
}
export async function create(tx: Tx, input: CreateUserRequest): Promise<PublicUser> {
try {
const passwordHash = await bcrypt.hash(input.password, 12);
const u = await tx.user.create({
data: {
username: input.username,
email: input.email,
passwordHash,
role: input.role ?? 'TECHNICIAN',
},
});
return toPublic(u);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') {
throw errors.conflict('Username or email already exists');
}
throw err;
}
}
export async function update(tx: Tx, id: string, input: UpdateUserRequest): Promise<PublicUser> {
const data: Prisma.UserUpdateInput = {};
if (input.username !== undefined) data.username = input.username;
if (input.email !== undefined) data.email = input.email;
if (input.role !== undefined) data.role = input.role;
if (input.password !== undefined) data.passwordHash = await bcrypt.hash(input.password, 12);
try {
const u = await tx.user.update({ where: { id }, data });
return toPublic(u);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2025') throw errors.notFound('User');
if (err.code === 'P2002') throw errors.conflict('Username or email already exists');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.user.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('User');
}
throw err;
}
}
+41
View File
@@ -0,0 +1,41 @@
import crypto from 'node:crypto';
import { describe, expect, it } from 'vitest';
import { signBody } from './webhooks.js';
describe('signBody', () => {
it('produces a stable hex HMAC-SHA256 of `${timestamp}.${body}`', () => {
const secret = 'test-secret';
const body = JSON.stringify({ event: 'part.created', data: { id: 'p1' } });
const ts = 1_700_000_000;
const signature = signBody(secret, body, ts);
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${body}`)
.digest('hex');
expect(signature).toBe(expected);
expect(signature).toHaveLength(64);
expect(signature).toMatch(/^[0-9a-f]+$/);
});
it('changes when body changes', () => {
const a = signBody('s', '{"a":1}', 1);
const b = signBody('s', '{"a":2}', 1);
expect(a).not.toBe(b);
});
it('changes when timestamp changes (prevents replay)', () => {
const body = '{}';
const a = signBody('s', body, 1);
const b = signBody('s', body, 2);
expect(a).not.toBe(b);
});
it('changes when secret changes', () => {
const body = '{}';
const a = signBody('secret-a', body, 1);
const b = signBody('secret-b', body, 1);
expect(a).not.toBe(b);
});
});
+138
View File
@@ -0,0 +1,138 @@
import crypto from 'node:crypto';
import { Prisma } from '@vector/db';
import type {
CreateWebhookSubscriptionRequest,
UpdateWebhookSubscriptionRequest,
WebhookEventName,
WebhookSubscriptionListQuery,
} from '@vector/shared';
import { errors } from '../lib/http-error.js';
import type { Tx } from './types.js';
// The DB stores `events` as a JSON string (pending Postgres cutover to String[]).
// Parse on the way out, stringify on the way in. Keep this boundary in the service.
interface StoredSubscription {
id: string;
url: string;
secret: string;
events: string;
active: boolean;
createdAt: Date;
updatedAt: Date;
}
export interface WebhookSubscriptionDto {
id: string;
url: string;
events: WebhookEventName[];
active: boolean;
createdAt: string;
updatedAt: string;
// `secret` is returned only on create so operators can copy it into their receiver config.
secret?: string;
}
function toDto(sub: StoredSubscription, includeSecret = false): WebhookSubscriptionDto {
let events: WebhookEventName[] = [];
try {
const parsed = JSON.parse(sub.events);
if (Array.isArray(parsed)) events = parsed as WebhookEventName[];
} catch {
events = [];
}
return {
id: sub.id,
url: sub.url,
events,
active: sub.active,
createdAt: sub.createdAt.toISOString(),
updatedAt: sub.updatedAt.toISOString(),
...(includeSecret ? { secret: sub.secret } : {}),
};
}
export async function list(tx: Tx, q: WebhookSubscriptionListQuery) {
const { page, pageSize, active } = q;
const where: Prisma.WebhookSubscriptionWhereInput = {};
if (active !== undefined) where.active = active;
const [rows, total] = await Promise.all([
tx.webhookSubscription.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
tx.webhookSubscription.count({ where }),
]);
return { data: rows.map((r) => toDto(r)), page, pageSize, total };
}
export async function create(tx: Tx, input: CreateWebhookSubscriptionRequest) {
const secret = crypto.randomBytes(24).toString('base64url');
const row = await tx.webhookSubscription.create({
data: {
url: input.url,
secret,
events: JSON.stringify(input.events),
active: input.active ?? true,
},
});
return toDto(row, true);
}
export async function update(tx: Tx, id: string, input: UpdateWebhookSubscriptionRequest) {
const data: Prisma.WebhookSubscriptionUpdateInput = {};
if (input.url !== undefined) data.url = input.url;
if (input.events !== undefined) data.events = JSON.stringify(input.events);
if (input.active !== undefined) data.active = input.active;
try {
const row = await tx.webhookSubscription.update({ where: { id }, data });
return toDto(row);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('WebhookSubscription');
}
throw err;
}
}
export async function remove(tx: Tx, id: string) {
try {
await tx.webhookSubscription.delete({ where: { id } });
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('WebhookSubscription');
}
throw err;
}
}
export async function rotateSecret(tx: Tx, id: string) {
const secret = crypto.randomBytes(24).toString('base64url');
try {
const row = await tx.webhookSubscription.update({
where: { id },
data: { secret },
});
return toDto(row, true);
} catch (err) {
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
throw errors.notFound('WebhookSubscription');
}
throw err;
}
}
export async function listActiveForEvent(tx: Tx, event: WebhookEventName) {
const rows = await tx.webhookSubscription.findMany({ where: { active: true } });
return rows
.map((r) => toDto(r, true))
.filter((s) => s.events.includes(event));
}
export function signBody(secret: string, body: string, timestamp: number): string {
return crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
}
+21
View File
@@ -0,0 +1,21 @@
import type { Role } from '@vector/shared';
declare global {
namespace Express {
interface Request {
user?: {
id: string;
username: string;
role: Role;
};
validated?: {
body?: unknown;
query?: unknown;
params?: unknown;
};
requestId?: string;
}
}
}
export {};
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "@vector/config/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"declarationMap": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["dist", "node_modules"]
}
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
include: ['src/services/**', 'src/lib/**'],
exclude: ['**/*.test.ts', '**/types.ts'],
thresholds: {
lines: 60,
functions: 60,
branches: 60,
statements: 60,
},
},
},
});
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@vector/e2e",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"test": "playwright test",
"test:ui": "playwright test --ui",
"install-browsers": "playwright install --with-deps chromium",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf test-results playwright-report .turbo"
},
"devDependencies": {
"@playwright/test": "^1.50.0",
"@types/node": "^22.10.2",
"typescript": "^5.7.2"
}
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig, devices } from '@playwright/test';
// Pointed at a local dev server by default. Override in CI with BASE_URL.
// Start the web + api stack yourself (`pnpm dev` from repo root) before running `pnpm -C apps/e2e test`.
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:5173';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
expect: { timeout: 5_000 },
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from '@playwright/test';
const username = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
test.beforeEach(async ({ page }) => {
test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set');
await page.goto('/login');
await page.getByLabel(/username/i).fill(username!);
await page.getByLabel(/password/i).fill(password!);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 });
});
test('admin can fetch the audit CSV export', async ({ page, request }) => {
const csv = await request.get('/api/admin/audit/events.csv');
expect(csv.status()).toBe(200);
expect(csv.headers()['content-type']).toContain('text/csv');
const body = await csv.text();
expect(body.split('\n')[0]).toContain('createdAt');
expect(body.split('\n')[0]).toContain('eventType');
});
+26
View File
@@ -0,0 +1,26 @@
import { expect, test } from '@playwright/test';
const username = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
test.beforeEach(async ({ page }) => {
test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set');
await page.goto('/login');
await page.getByLabel(/username/i).fill(username!);
await page.getByLabel(/password/i).fill(password!);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 });
});
test('bulk-edit dialog opens from the parts table', async ({ page }) => {
await page.goto('/parts');
// Select the first visible checkbox (row selector). If there are no rows, skip.
const rowCheckbox = page.locator('tr [role=checkbox]').first();
if ((await rowCheckbox.count()) === 0) test.skip(true, 'no parts to bulk-edit');
await rowCheckbox.check();
await page.getByRole('button', { name: /bulk|change state|edit selected/i }).first().click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(page.getByText(/bulk/i).first()).toBeVisible();
});
+28
View File
@@ -0,0 +1,28 @@
import { expect, test } from '@playwright/test';
// Requires a dev user. Set TEST_USERNAME / TEST_PASSWORD in the environment; otherwise the test skips.
const username = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
test.describe('login', () => {
test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set');
test('logs in and lands on the dashboard', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/username/i).fill(username!);
await page.getByLabel(/password/i).fill(password!);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 });
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
});
test('shows an error on bad credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel(/username/i).fill('does-not-exist');
await page.getByLabel(/password/i).fill('wrong-password');
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page.getByText(/invalid|incorrect|unauthor/i)).toBeVisible();
});
});
+37
View File
@@ -0,0 +1,37 @@
import { expect, test } from '@playwright/test';
const username = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
// Lightweight fixture: every test starts logged in as an admin.
test.beforeEach(async ({ page }) => {
test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set');
await page.goto('/login');
await page.getByLabel(/username/i).fill(username!);
await page.getByLabel(/password/i).fill(password!);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 });
});
test.describe('parts', () => {
test('lists parts with working search', async ({ page }) => {
await page.goto('/parts');
await expect(page.getByRole('heading', { name: /parts/i })).toBeVisible();
const search = page.getByPlaceholder(/search/i);
if (await search.count()) {
await search.fill('nonexistent-serial-xxxxxxx');
// Search debounces — give it a beat.
await page.waitForTimeout(600);
await expect(page.getByText(/no parts|no results|empty/i).first()).toBeVisible();
}
});
test('opens the create part dialog', async ({ page }) => {
await page.goto('/parts');
const newBtn = page.getByRole('button', { name: /new part|add part|\+ part/i }).first();
if (await newBtn.count()) {
await newBtn.click();
await expect(page.getByRole('dialog')).toBeVisible();
}
});
});
+24
View File
@@ -0,0 +1,24 @@
import { expect, test } from '@playwright/test';
const username = process.env.TEST_USERNAME;
const password = process.env.TEST_PASSWORD;
test.beforeEach(async ({ page }) => {
test.skip(!username || !password, 'TEST_USERNAME/TEST_PASSWORD not set');
await page.goto('/login');
await page.getByLabel(/username/i).fill(username!);
await page.getByLabel(/password/i).fill(password!);
await page.getByRole('button', { name: /sign in|log in/i }).click();
await expect(page).toHaveURL(/\/(?!login)/, { timeout: 10_000 });
});
test('repairs page renders and filters by status', async ({ page }) => {
await page.goto('/repairs');
await expect(page.getByRole('heading', { name: /repairs/i })).toBeVisible();
const statusFilter = page.getByRole('combobox').first();
if (await statusFilter.count()) {
await statusFilter.click();
await page.getByRole('option', { name: /in progress|pending/i }).first().click();
}
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["node"]
},
"include": ["playwright.config.ts", "tests/**/*.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+16
View File
@@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
+29
View File
@@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vector</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+46
View File
@@ -0,0 +1,46 @@
{
"name": "@vector/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "tsc -p tsconfig.json --noEmit",
"clean": "rimraf dist .turbo"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.99.0",
"@tanstack/react-table": "^8.20.6",
"@vector/shared": "workspace:*",
"@vector/ui": "workspace:*",
"axios": "^1.15.0",
"lucide-react": "^0.469.0",
"nuqs": "^2.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-hook-form": "^7.54.2",
"react-router-dom": "^7.14.1",
"recharts": "^3.8.1",
"sonner": "^1.7.1",
"zod": "^3.24.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vector/config": "workspace:*",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "^5.7.2",
"vite": "^8.0.4"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+86
View File
@@ -0,0 +1,86 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { NuqsAdapter } from 'nuqs/adapters/react-router/v7';
import { TooltipProvider, Toaster } from '@vector/ui';
import { AuthProvider } from './contexts/AuthContext.js';
import { RequireAuth } from './components/auth/RequireAuth.js';
import { AppShell } from './components/layout/AppShell.js';
import { ErrorBoundary } from './components/layout/ErrorBoundary.js';
import Login from './pages/Login.js';
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 Manufacturers from './pages/Manufacturers.js';
import Repairs from './pages/Repairs.js';
import Hosts from './pages/Hosts.js';
import Users from './pages/admin/Users.js';
import Webhooks from './pages/admin/Webhooks.js';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: (failureCount, error) => {
// Don't retry auth failures — the refresh interceptor handles those once already.
const status = (error as { status?: number })?.status;
if (status === 401 || status === 403) return false;
return failureCount < 2;
},
staleTime: 10_000,
refetchOnWindowFocus: false,
},
},
});
export default function App() {
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<NuqsAdapter>
<AuthProvider>
<TooltipProvider delayDuration={150}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
element={
<RequireAuth>
<AppShell />
</RequireAuth>
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/parts" element={<Parts />} />
<Route path="/parts/:id" element={<PartDetail />} />
<Route path="/locations" element={<Locations />} />
<Route path="/manufacturers" element={<Manufacturers />} />
<Route path="/repairs" element={<Repairs />} />
<Route path="/hosts" element={<Hosts />} />
<Route
path="/admin/users"
element={
<RequireAuth role="ADMIN">
<Users />
</RequireAuth>
}
/>
<Route
path="/admin/webhooks"
element={
<RequireAuth role="ADMIN">
<Webhooks />
</RequireAuth>
}
/>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster position="bottom-right" />
</TooltipProvider>
</AuthProvider>
</NuqsAdapter>
</BrowserRouter>
</QueryClientProvider>
</ErrorBoundary>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+56
View File
@@ -0,0 +1,56 @@
import { Loader2 } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@vector/ui';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
destructive?: boolean;
pending?: boolean;
onConfirm: () => void;
}
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirm',
destructive,
pending,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
Cancel
</Button>
<Button
variant={destructive ? 'destructive' : 'default'}
onClick={onConfirm}
disabled={pending}
>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import { Loader2 } from 'lucide-react';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
} from '@vector/ui';
interface NamePromptDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
label?: string;
initialValue?: string;
confirmLabel?: string;
pending?: boolean;
onSubmit: (value: string) => void;
}
export function NamePromptDialog({
open,
onOpenChange,
title,
description,
label = 'Name',
initialValue = '',
confirmLabel = 'Save',
pending,
onSubmit,
}: NamePromptDialogProps) {
const [value, setValue] = useState(initialValue);
useEffect(() => {
if (open) setValue(initialValue);
}, [open, initialValue]);
const disabled = pending || value.trim().length === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<form
onSubmit={(e) => {
e.preventDefault();
if (!disabled) onSubmit(value.trim());
}}
className="space-y-3"
>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="space-y-1.5">
<Label>{label}</Label>
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={pending}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={pending}
>
Cancel
</Button>
<Button type="submit" disabled={disabled}>
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,30 @@
import { Navigate, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { Skeleton } from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js';
import type { Role } from '@vector/shared';
interface RequireAuthProps {
children: ReactNode;
role?: Role;
}
export function RequireAuth({ children, role }: RequireAuthProps) {
const { user, status } = useAuth();
const location = useLocation();
if (status === 'loading') {
return (
<div className="flex min-h-screen items-center justify-center">
<Skeleton className="h-20 w-72" />
</div>
);
}
if (status === 'anonymous' || !user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (role && user.role !== role) {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Boxes, LayoutDashboard, MapPinned, Package, Wrench } from 'lucide-react';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@vector/ui';
interface PaletteItem {
id: string;
label: string;
to: string;
icon: React.ComponentType<{ className?: string }>;
group: 'Navigate' | 'Actions';
}
// Stub: nav-only entries for Phase 4. Phase 5+ will merge in recent-parts + saved-views.
const ITEMS: PaletteItem[] = [
{ id: 'nav-dashboard', label: 'Dashboard', to: '/', icon: LayoutDashboard, group: 'Navigate' },
{ id: 'nav-parts', label: 'Parts', to: '/parts', icon: Package, group: 'Navigate' },
{ id: 'nav-locations', label: 'Locations', to: '/locations', icon: MapPinned, group: 'Navigate' },
{ id: 'nav-manufacturers', label: 'Manufacturers', to: '/manufacturers', icon: Boxes, group: 'Navigate' },
{ id: 'nav-repairs', label: 'Repairs', to: '/repairs', icon: Wrench, group: 'Navigate' },
];
export interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const navigate = useNavigate();
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
onOpenChange(!open);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onOpenChange]);
const grouped = ITEMS.reduce<Record<string, PaletteItem[]>>((acc, i) => {
(acc[i.group] ||= []).push(i);
return acc;
}, {});
return (
<CommandDialog open={open} onOpenChange={onOpenChange}>
<CommandInput placeholder="Search pages, parts, actions…" />
<CommandList>
<CommandEmpty>No results.</CommandEmpty>
{Object.entries(grouped).map(([group, items], idx) => (
<CommandGroup key={group} heading={group}>
{idx > 0 && <CommandSeparator />}
{items.map((item) => (
<CommandItem
key={item.id}
value={item.label}
onSelect={() => {
navigate(item.to);
onOpenChange(false);
}}
>
<item.icon className="h-4 w-4 opacity-70" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}
// Convenience hook: colocate open-state + keyboard trigger for AppShell.
export function useCommandPalette() {
const [open, setOpen] = useState(false);
return { open, setOpen, openPalette: () => setOpen(true) };
}
@@ -0,0 +1,380 @@
import { useMemo, useState, type ReactNode } from 'react';
import {
flexRender,
getCoreRowModel,
useReactTable,
type ColumnDef,
type OnChangeFn,
type Row,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import {
parseAsInteger,
parseAsString,
useQueryState,
useQueryStates,
type ParserBuilder,
} from 'nuqs';
import { ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, ChevronUp, Search } from 'lucide-react';
import type { PaginatedResponse } from '@vector/shared';
import {
Button,
Checkbox,
Input,
Skeleton,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
cn,
} from '@vector/ui';
// Common shape the DataTable forwards to the consumer's queryFn.
export interface DataTableQueryParams<TFilters> {
page: number;
pageSize: number;
sort?: string; // "field:asc" or "field:desc"
q?: string;
filters: TFilters;
}
export type FilterParsers<TFilters> = {
[K in keyof TFilters]: ParserBuilder<TFilters[K]>;
};
export interface DataTableProps<TData, TFilters extends Record<string, unknown>> {
columns: ColumnDef<TData, unknown>[];
queryKey: (params: DataTableQueryParams<TFilters>) => readonly unknown[];
queryFn: (params: DataTableQueryParams<TFilters>) => Promise<PaginatedResponse<TData>>;
/** How to get a stable string id per row (usually `(r) => r.id`). */
getRowId: (row: TData) => string;
/** nuqs parsers for resource-specific filters; each becomes a URL query param. */
filterParsers?: FilterParsers<TFilters>;
/** Default page size; the user may still adjust. */
defaultPageSize?: number;
searchPlaceholder?: string;
enableSearch?: boolean;
enableSelection?: boolean;
/** Rendered when at least one row is selected. Receives the selected row IDs. */
bulkActions?: (selectedIds: string[], clear: () => void) => ReactNode;
/**
* Rendered above the table on the right. Either a node, or a render prop that receives the
* current filter state + a setter so consumers can drive URL-synced filters.
*/
toolbar?:
| ReactNode
| ((helpers: {
filters: TFilters;
setFilter: <K extends keyof TFilters>(name: K, value: TFilters[K] | null) => void;
}) => ReactNode);
emptyState?: ReactNode;
className?: string;
}
// Parse "field:dir" into a TanStack sorting state. Returns [] when empty.
function parseSortState(sort: string | null): SortingState {
if (!sort) return [];
const [id, dir = 'asc'] = sort.split(':');
if (!id) return [];
return [{ id, desc: dir === 'desc' }];
}
function serializeSortState(state: SortingState): string | null {
if (state.length === 0) return null;
const [first] = state;
return `${first.id}:${first.desc ? 'desc' : 'asc'}`;
}
export function DataTable<TData, TFilters extends Record<string, unknown>>({
columns,
queryKey,
queryFn,
getRowId,
filterParsers,
defaultPageSize = 20,
searchPlaceholder = 'Search…',
enableSearch = true,
enableSelection = false,
bulkActions,
toolbar,
emptyState,
className,
}: DataTableProps<TData, TFilters>) {
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [pageSize, setPageSize] = useQueryState(
'pageSize',
parseAsInteger.withDefault(defaultPageSize),
);
const [q, setQ] = useQueryState(
'q',
parseAsString.withDefault('').withOptions({ throttleMs: 300 }),
);
const [sort, setSort] = useQueryState('sort', parseAsString);
// Resource-specific filters. When filterParsers is omitted, we still render but with no URL state.
const [filters, setFilters] = useQueryStates(
(filterParsers ?? ({} as FilterParsers<TFilters>)) as Record<string, ParserBuilder<unknown>>,
);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const sortingState = useMemo(() => parseSortState(sort), [sort]);
const handleSortingChange: OnChangeFn<SortingState> = (updater) => {
const next = typeof updater === 'function' ? updater(sortingState) : updater;
void setSort(serializeSortState(next));
};
const params: DataTableQueryParams<TFilters> = {
page,
pageSize,
sort: sort ?? undefined,
q: q || undefined,
filters: filters as TFilters,
};
const query = useQuery({
queryKey: queryKey(params),
queryFn: () => queryFn(params),
placeholderData: keepPreviousData,
staleTime: 10_000,
});
const rows = query.data?.data ?? [];
const total = query.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
const selectionColumn: ColumnDef<TData, unknown> | null = enableSelection
? {
id: 'select',
header: ({ table }) => (
<Checkbox
aria-label="Select all"
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? 'indeterminate'
: false
}
onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(v) => row.toggleSelected(!!v)}
/>
),
enableSorting: false,
size: 32,
}
: null;
const tableColumns = useMemo(
() => (selectionColumn ? [selectionColumn, ...columns] : columns),
[selectionColumn, columns],
);
const table = useReactTable({
data: rows,
columns: tableColumns,
getRowId,
state: { sorting: sortingState, rowSelection },
onSortingChange: handleSortingChange,
onRowSelectionChange: setRowSelection,
manualSorting: true,
manualPagination: true,
enableRowSelection: enableSelection,
getCoreRowModel: getCoreRowModel(),
pageCount,
});
const selectedIds = Object.keys(rowSelection);
const clearSelection = () => setRowSelection({});
const setFilter = <K extends keyof TFilters>(name: K, value: TFilters[K] | null) => {
void setFilters(
(prev) => ({ ...(prev as object), [name]: value } as Partial<typeof filters>),
);
void setPage(1);
};
const toolbarNode =
typeof toolbar === 'function'
? toolbar({ filters: filters as TFilters, setFilter })
: toolbar;
return (
<div className={cn('flex flex-col gap-3', className)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
{enableSearch && (
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => {
void setQ(e.target.value || null);
void setPage(1);
}}
placeholder={searchPlaceholder}
className="h-8 w-64 pl-8"
/>
</div>
)}
</div>
<div className="flex items-center gap-2">{toolbarNode}</div>
</div>
{enableSelection && selectedIds.length > 0 && (
<div className="flex items-center justify-between rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
<span className="text-muted-foreground">
{selectedIds.length} selected
</span>
<div className="flex items-center gap-2">
{bulkActions?.(selectedIds, clearSelection)}
<Button variant="ghost" size="sm" onClick={clearSelection}>
Clear
</Button>
</div>
</div>
)}
<div className="overflow-hidden rounded-lg border border-border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const canSort = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
return (
<TableHead key={header.id} style={{ width: header.getSize() || undefined }}>
{header.isPlaceholder ? null : canSort ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
className="inline-flex items-center gap-1 text-left text-xs font-medium text-muted-foreground hover:text-foreground"
>
{flexRender(header.column.columnDef.header, header.getContext())}
{sortDir === 'asc' ? (
<ChevronUp className="h-3.5 w-3.5" />
) : sortDir === 'desc' ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)}
</button>
) : (
flexRender(header.column.columnDef.header, header.getContext())
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{query.isPending ? (
<SkeletonRows columns={tableColumns.length} pageSize={pageSize} />
) : query.isError ? (
<TableRow>
<TableCell
colSpan={tableColumns.length}
className="py-10 text-center text-sm text-destructive"
>
{(query.error as Error).message ?? 'Failed to load'}
</TableCell>
</TableRow>
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={tableColumns.length} className="py-12 text-center">
{emptyState ?? (
<div className="text-sm text-muted-foreground">No results.</div>
)}
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => <DataRow key={row.id} row={row} />)
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<div>
{total === 0 ? '0 rows' : `${(page - 1) * pageSize + 1}${Math.min(page * pageSize, total)} of ${total}`}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page <= 1 || query.isFetching}
onClick={() => void setPage(page - 1)}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2">
Page {page} of {pageCount}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={page >= pageCount || query.isFetching}
onClick={() => void setPage(page + 1)}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
<select
value={pageSize}
onChange={(e) => {
void setPageSize(Number(e.target.value));
void setPage(1);
}}
className="ml-2 h-7 rounded-md border border-input bg-transparent px-2 text-xs"
>
{[10, 20, 50, 100].map((s) => (
<option key={s} value={s}>
{s} / page
</option>
))}
</select>
</div>
</div>
</div>
);
}
function DataRow<TData>({ row }: { row: Row<TData> }) {
return (
<TableRow data-state={row.getIsSelected() ? 'selected' : undefined}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
))}
</TableRow>
);
}
function SkeletonRows({ columns, pageSize }: { columns: number; pageSize: number }) {
return (
<>
{Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: columns }).map((_, j) => (
<TableCell key={j}>
<Skeleton className="h-4 w-full" />
</TableCell>
))}
</TableRow>
))}
</>
);
}
@@ -0,0 +1,149 @@
import { useEffect } from 'react';
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 { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
Textarea,
} from '@vector/ui';
import { createHost, 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({
name: z.string().min(1, 'Required').max(128),
location: z.string().max(256).optional(),
notes: z.string().max(4096).optional(),
});
type Values = z.infer<typeof Schema>;
interface HostFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
host?: Host | null;
}
export function HostFormDialog({ open, onOpenChange, host }: HostFormDialogProps) {
const editing = Boolean(host);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', location: '', notes: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
name: host?.name ?? '',
location: host?.location ?? '',
notes: host?.notes ?? '',
});
}, [open, host, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
name: values.name,
location: values.location ? values.location : null,
notes: values.notes ? values.notes : null,
};
return editing && host ? updateHost(host.id, payload) : createHost(payload);
},
onSuccess: () => {
toast.success(editing ? 'Host updated' : 'Host created');
queryClient.invalidateQueries({ queryKey: queryKeys.hosts.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 host' : 'New host'}</DialogTitle>
<DialogDescription>
Hosts are the machines or racks where parts get installed for repair jobs.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="location"
render={({ field }) => (
<FormItem>
<FormLabel>Location</FormLabel>
<FormControl>
<Input placeholder="Rack B3, Lab 2" {...field} />
</FormControl>
<FormMessage />
</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>
);
}
@@ -0,0 +1,24 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Sidebar } from './Sidebar.js';
import { TopBar } from './TopBar.js';
import { CommandPalette, useCommandPalette } from '../command/CommandPalette.js';
import { cn } from '@vector/ui';
export function AppShell() {
const [collapsed, setCollapsed] = useState(false);
const palette = useCommandPalette();
return (
<div className="min-h-screen bg-background text-foreground">
<Sidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
<div className={cn('transition-[padding] duration-200', collapsed ? 'pl-14' : 'pl-64')}>
<TopBar onOpenCommand={palette.openPalette} />
<main className="p-4 sm:p-6">
<Outlet />
</main>
</div>
<CommandPalette open={palette.open} onOpenChange={palette.setOpen} />
</div>
);
}
@@ -0,0 +1,41 @@
import { Link, useLocation } from 'react-router-dom';
import { ChevronRight } from 'lucide-react';
// Humanize a URL slug: "admin/users" -> "Users", "part-detail" -> "Part detail".
function humanize(slug: string): string {
const cleaned = slug.replace(/-/g, ' ');
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
}
export function Breadcrumbs() {
const { pathname } = useLocation();
const parts = pathname.split('/').filter(Boolean);
if (parts.length === 0) {
return <div className="text-sm font-medium text-foreground">Dashboard</div>;
}
return (
<nav aria-label="Breadcrumb" className="flex items-center gap-1 text-sm">
<Link to="/" className="text-muted-foreground hover:text-foreground">
Home
</Link>
{parts.map((part, i) => {
const to = '/' + parts.slice(0, i + 1).join('/');
const isLast = i === parts.length - 1;
return (
<span key={to} className="flex items-center gap-1">
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
{isLast ? (
<span className="font-medium text-foreground">{humanize(part)}</span>
) : (
<Link to={to} className="text-muted-foreground hover:text-foreground">
{humanize(part)}
</Link>
)}
</span>
);
})}
</nav>
);
}
@@ -0,0 +1,50 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from '@vector/ui';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
override state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo) {
// eslint-disable-next-line no-console
console.error('ErrorBoundary caught', error, info.componentStack);
}
handleReset = () => this.setState({ error: null });
override render() {
if (!this.state.error) return this.props.children;
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Something broke</CardTitle>
<CardDescription>An unrecoverable error bubbled up.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<pre className="max-h-48 overflow-auto rounded-md bg-muted/40 p-3 text-xs text-muted-foreground">
{this.state.error.message}
</pre>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => window.location.reload()}>
Reload
</Button>
<Button onClick={this.handleReset}>Try again</Button>
</div>
</CardContent>
</Card>
</div>
);
}
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
description?: string;
actions?: ReactNode;
}
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description && (
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
)}
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { NavLink } from 'react-router-dom';
import {
Boxes,
ChevronsLeft,
ChevronsRight,
LayoutDashboard,
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';
interface NavItem {
to: string;
label: string;
icon: LucideIcon;
adminOnly?: boolean;
}
const NAV_ITEMS: NavItem[] = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/parts', label: 'Parts', icon: Package },
{ to: '/locations', label: 'Locations', icon: MapPinned },
{ to: '/manufacturers', label: 'Manufacturers', icon: Boxes },
{ to: '/repairs', label: 'Repairs', icon: Wrench },
{ to: '/hosts', label: 'Hosts', icon: Server },
{ to: '/admin/users', label: 'Users', icon: UsersIcon, adminOnly: true },
{ to: '/admin/webhooks', label: 'Webhooks', icon: Webhook, adminOnly: true },
];
export interface SidebarProps {
collapsed: boolean;
onToggle: () => void;
}
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
const { user } = useAuth();
const items = NAV_ITEMS.filter((i) => !i.adminOnly || user?.role === 'ADMIN');
return (
<aside
className={cn(
'fixed inset-y-0 left-0 z-40 flex flex-col border-r border-border bg-card transition-[width] duration-200',
collapsed ? 'w-14' : 'w-64',
)}
>
<div className="flex h-13 items-center gap-2 border-b border-border px-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-brand text-brand-foreground font-semibold">
V
</div>
{!collapsed && <span className="truncate text-sm font-semibold">Vector</span>}
</div>
<nav className="flex-1 space-y-0.5 p-2">
{items.map((item) => (
<NavItemLink key={item.to} item={item} collapsed={collapsed} />
))}
</nav>
<div className="border-t border-border p-2">
<Button
variant="ghost"
size="icon"
className={cn('w-full', !collapsed && 'justify-start gap-2 px-2')}
onClick={onToggle}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
{!collapsed && <span className="text-xs text-muted-foreground">Collapse</span>}
</Button>
</div>
</aside>
);
}
function NavItemLink({ item, collapsed }: { item: NavItem; collapsed: boolean }) {
const content = (
<NavLink
to={item.to}
end={item.to === '/'}
className={({ isActive }) =>
cn(
'flex items-center gap-3 rounded-md px-2.5 py-2 text-sm transition-colors',
isActive
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/60 hover:text-foreground',
collapsed && 'justify-center px-0',
)
}
>
<item.icon className="h-4 w-4 shrink-0" />
{!collapsed && <span className="truncate">{item.label}</span>}
</NavLink>
);
if (!collapsed) return content;
return (
<Tooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent>
</Tooltip>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useNavigate } from 'react-router-dom';
import { LogOut, Search } from 'lucide-react';
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Badge,
} from '@vector/ui';
import { useAuth } from '../../contexts/AuthContext.js';
import { Breadcrumbs } from './Breadcrumbs.js';
import { toast } from 'sonner';
// Minimal avatar fallback since we haven't built the Avatar primitive yet. Inline circle.
function InitialsAvatar({ name }: { name: string }) {
const initials = name
.split(/\s+|\./)
.filter(Boolean)
.slice(0, 2)
.map((s) => s[0]?.toUpperCase() ?? '')
.join('');
return (
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-xs font-semibold text-foreground">
{initials || '?'}
</div>
);
}
export interface TopBarProps {
onOpenCommand: () => void;
}
export function TopBar({ onOpenCommand }: TopBarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const handleLogout = async () => {
try {
await logout();
toast.success('Signed out');
navigate('/login', { replace: true });
} catch {
toast.error('Could not sign out');
}
};
return (
<header className="sticky top-0 z-30 flex h-13 items-center justify-between gap-3 border-b border-border bg-background/80 px-4 backdrop-blur">
<Breadcrumbs />
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onOpenCommand}
className="h-8 gap-2 text-muted-foreground"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Search</span>
<kbd className="ml-2 hidden rounded bg-muted px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground sm:inline">
K
</kbd>
</Button>
{user && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<InitialsAvatar name={user.username} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{user.username}</span>
<span className="text-xs text-muted-foreground">{user.email}</span>
<Badge variant="outline" className="mt-1 w-fit">
{user.role}
</Badge>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</header>
);
}
@@ -0,0 +1,196 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Archive, MoreHorizontal, Plus, Trash2 } from 'lucide-react';
import {
Button,
Card,
CardContent,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Skeleton,
} from '@vector/ui';
import { createBin, deleteBin, listBins, updateBin } from '../../lib/api/bins.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 { BinWithPath } from '../../lib/api/types.js';
interface BinGridProps {
roomId: string | null;
canEdit: boolean;
}
export function BinGrid({ roomId, canEdit }: BinGridProps) {
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<BinWithPath | null>(null);
const [deleting, setDeleting] = useState<BinWithPath | null>(null);
const bins = useQuery({
queryKey: queryKeys.bins.list({ roomId, pageSize: 100 }),
queryFn: () => listBins({ roomId: roomId!, pageSize: 100 }),
enabled: Boolean(roomId),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: queryKeys.bins.all });
const createMutation = useMutation({
mutationFn: (name: string) => createBin({ name, roomId: roomId! }),
onSuccess: () => {
toast.success('Bin created');
invalidate();
setCreating(false);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Create failed'),
});
const renameMutation = useMutation({
mutationFn: (vars: { id: string; name: string }) => updateBin(vars.id, { name: vars.name }),
onSuccess: () => {
toast.success('Bin renamed');
invalidate();
setRenaming(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Rename failed'),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteBin(id),
onSuccess: () => {
toast.success('Bin deleted');
invalidate();
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
setDeleting(null);
},
onError: (err) =>
toast.error(err instanceof ApiRequestError ? err.body.message : 'Delete failed'),
});
if (!roomId) {
return (
<div className="flex h-full items-center justify-center p-8 text-sm text-muted-foreground">
Select a room to see its bins.
</div>
);
}
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between px-4 py-2">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Bins
</h2>
{canEdit && (
<Button variant="outline" size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
New bin
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 pt-0">
{bins.isPending ? (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
) : bins.isError ? (
<p className="text-sm text-destructive">Failed to load bins.</p>
) : bins.data && bins.data.data.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-muted-foreground">
<Archive className="h-6 w-6" />
<span className="text-sm">No bins in this room</span>
{canEdit && (
<Button variant="outline" size="sm" onClick={() => setCreating(true)}>
<Plus className="h-4 w-4" />
Create first bin
</Button>
)}
</div>
) : (
<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">
<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>
<p className="truncate font-mono text-[10px] text-muted-foreground">
{b.fullPath}
</p>
</div>
{canEdit && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 transition-opacity 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(b)}>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => setDeleting(b)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</CardContent>
</Card>
))}
</div>
)}
</div>
<NamePromptDialog
open={creating}
onOpenChange={setCreating}
title="New bin"
label="Bin name"
confirmLabel="Create"
pending={createMutation.isPending}
onSubmit={(name) => createMutation.mutate(name)}
/>
<NamePromptDialog
open={Boolean(renaming)}
onOpenChange={(o) => !o && setRenaming(null)}
title="Rename bin"
label="Bin 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 bin?"
description={
deleting
? `Remove ${deleting.name}. Parts in this bin become unassigned.`
: undefined
}
confirmLabel="Delete"
destructive
pending={deleteMutation.isPending}
onConfirm={() => deleting && deleteMutation.mutate(deleting.id)}
/>
</div>
);
}
@@ -0,0 +1,204 @@
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>
);
}
@@ -0,0 +1,195 @@
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,157 @@
import { useEffect } from 'react';
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 { toast } from 'sonner';
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
Input,
} from '@vector/ui';
import {
createManufacturer,
updateManufacturer,
} from '../../lib/api/manufacturers.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
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>;
interface ManufacturerFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
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,
manufacturer,
}: ManufacturerFormDialogProps) {
const editing = Boolean(manufacturer);
const queryClient = useQueryClient();
const form = useForm<Values>({
resolver: zodResolver(Schema),
defaultValues: { name: '', eolDate: '' },
});
useEffect(() => {
if (!open) return;
form.reset({
name: manufacturer?.name ?? '',
eolDate: isoToDateInput(manufacturer?.eolDate ?? null),
});
}, [open, manufacturer, form]);
const mutation = useMutation({
mutationFn: async (values: Values) => {
const payload = {
name: values.name,
eolDate: values.eolDate ? values.eolDate : null,
};
return editing && manufacturer
? updateManufacturer(manufacturer.id, payload)
: createManufacturer(payload);
},
onSuccess: () => {
toast.success(editing ? 'Manufacturer updated' : 'Manufacturer created');
queryClient.invalidateQueries({ queryKey: queryKeys.manufacturers.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 manufacturer' : 'New manufacturer'}</DialogTitle>
<DialogDescription>
{editing
? 'Update this manufacturer. EOL drives replacement alerts on parts.'
: 'Add a manufacturer. Names must be unique.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((v) => mutation.mutate(v))} className="space-y-3">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input autoFocus {...field} />
</FormControl>
<FormMessage />
</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"
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>
);
}
@@ -0,0 +1,210 @@
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import type { PartState } from '@vector/shared';
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@vector/ui';
import { bulkUpdateParts } from '../../lib/api/bulk-parts.js';
import { ApiRequestError } from '../../lib/api/client.js';
import { queryKeys } from '../../lib/queryKeys.js';
import { listTags } from '../../lib/api/tags.js';
import { partStateOptions } from './PartStateBadge.js';
interface PartBulkStateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
partIds: string[];
onDone: () => void;
}
const UNSET = '__unset__';
export function PartBulkStateDialog({
open,
onOpenChange,
partIds,
onDone,
}: PartBulkStateDialogProps) {
const [state, setState] = useState<string>(UNSET);
const [addTagIds, setAddTagIds] = useState<Set<string>>(new Set());
const [removeTagIds, setRemoveTagIds] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const tagsQuery = useQuery({
queryKey: queryKeys.tags.list({ pageSize: 200 }),
queryFn: () => listTags({ pageSize: 200 }),
enabled: open,
});
useEffect(() => {
if (!open) {
setState(UNSET);
setAddTagIds(new Set());
setRemoveTagIds(new Set());
}
}, [open]);
const mutation = useMutation({
mutationFn: async () => {
return bulkUpdateParts({
ids: partIds,
state: state !== UNSET ? (state as PartState) : undefined,
addTagIds: addTagIds.size > 0 ? [...addTagIds] : undefined,
removeTagIds: removeTagIds.size > 0 ? [...removeTagIds] : undefined,
});
},
onSuccess: (res) => {
toast.success(
`Updated ${res.updated} part${res.updated === 1 ? '' : 's'}`,
);
queryClient.invalidateQueries({ queryKey: queryKeys.parts.all });
onDone();
onOpenChange(false);
},
onError: (err) => {
toast.error(
err instanceof ApiRequestError ? err.body.message : 'Bulk update failed',
);
},
});
const toggleAdd = (id: string) => {
setAddTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else {
next.add(id);
setRemoveTagIds((r) => {
const rnext = new Set(r);
rnext.delete(id);
return rnext;
});
}
return next;
});
};
const toggleRemove = (id: string) => {
setRemoveTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else {
next.add(id);
setAddTagIds((a) => {
const anext = new Set(a);
anext.delete(id);
return anext;
});
}
return next;
});
};
const nothingToDo =
state === UNSET && addTagIds.size === 0 && removeTagIds.size === 0;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Bulk edit parts</DialogTitle>
<DialogDescription>
Update {partIds.length} selected part{partIds.length === 1 ? '' : 's'}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>State</Label>
<Select value={state} onValueChange={setState}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={UNSET}>Leave unchanged</SelectItem>
{partStateOptions.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Tags</Label>
{tagsQuery.isPending ? (
<p className="text-xs text-muted-foreground">Loading tags</p>
) : !tagsQuery.data || tagsQuery.data.data.length === 0 ? (
<p className="text-xs text-muted-foreground">No tags defined yet.</p>
) : (
<div className="max-h-48 space-y-1 overflow-y-auto rounded-md border border-border p-2">
{tagsQuery.data.data.map((tag) => (
<div
key={tag.id}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: tag.color ?? 'currentColor' }}
/>
{tag.name}
</span>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<label className="flex items-center gap-1">
<Checkbox
checked={addTagIds.has(tag.id)}
onCheckedChange={() => toggleAdd(tag.id)}
/>
Add
</label>
<label className="flex items-center gap-1">
<Checkbox
checked={removeTagIds.has(tag.id)}
onCheckedChange={() => toggleRemove(tag.id)}
/>
Remove
</label>
</div>
</div>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={mutation.isPending}
>
Cancel
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || nothingToDo}
>
{mutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Apply
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,141 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ArrowRight,
CheckCircle2,
MapPin,
Package,
Pencil,
Tag,
Wrench,
type LucideIcon,
} from 'lucide-react';
import type { PartEventType } from '@vector/shared';
import { Button, Skeleton } from '@vector/ui';
import { listPartEvents } from '../../lib/api/parts.js';
import { queryKeys } from '../../lib/queryKeys.js';
const EVENT_ICON: Record<PartEventType, LucideIcon> = {
CREATED: Package,
STATE_CHANGED: CheckCircle2,
LOCATION_CHANGED: MapPin,
FIELD_UPDATED: Pencil,
REPAIR_STARTED: Wrench,
REPAIR_COMPLETED: Wrench,
TAG_ADDED: Tag,
TAG_REMOVED: Tag,
};
const EVENT_TITLE: Record<PartEventType, string> = {
CREATED: 'Created',
STATE_CHANGED: 'State changed',
LOCATION_CHANGED: 'Location changed',
FIELD_UPDATED: 'Field updated',
REPAIR_STARTED: 'Repair started',
REPAIR_COMPLETED: 'Repair completed',
TAG_ADDED: 'Tag added',
TAG_REMOVED: 'Tag removed',
};
function formatWhen(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function PartEventTimeline({ partId }: { partId: string }) {
const [page, setPage] = useState(1);
const pageSize = 20;
const query = useQuery({
queryKey: queryKeys.parts.events(partId, { page, pageSize }),
queryFn: () => listPartEvents(partId, 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 events = query.data?.data ?? [];
const total = query.data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pageSize));
if (events.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">
{events.map((e) => {
const Icon = EVENT_ICON[e.type];
return (
<li key={e.id} 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>
<div className="flex flex-wrap items-center gap-x-2 text-sm">
<span className="font-medium text-foreground">{EVENT_TITLE[e.type]}</span>
{e.field && (
<span className="text-xs text-muted-foreground">· {e.field}</span>
)}
{(e.oldValue || e.newValue) && (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<span className="font-mono">{e.oldValue ?? '—'}</span>
<ArrowRight className="h-3 w-3" />
<span className="font-mono text-foreground">{e.newValue ?? '—'}</span>
</span>
)}
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{formatWhen(e.createdAt)}
{e.user?.username ? ` · ${e.user.username}` : ''}
</div>
</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>
);
}

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