Assemble QMS app + SQLite refactor + Unraid single-container deploy
Build and Push Docker Image / build (push) Successful in 1m12s

Reconstruct the full app from init-source overlays (base + fix-1..6 +
update-1..3, last-wins) at the repo root, complete the missing pieces so it
builds and runs, and stage the Unraid deployment.

App completion:
- types/index.ts: former Prisma enums as string-literal unions + AppUser
- pages/_app.tsx + styles/globals.css (mount AppProvider/ToastProvider)
- API routes: auth/login, auth/me, users, submissions (+REVIEW_READY notify),
  forms (list/create), notifications
- scripts/create-admin.js: idempotent first-admin bootstrap
- 14 unbuilt nav targets stubbed via ComingSoon placeholder

SQLite refactor (single-container, no external DB):
- schema provider -> sqlite; enums -> String; Json -> String;
  FormField.options String[] -> JSON-encoded String
- lib/forms.ts (de)serialises options at the DB boundary
- drop mode:"insensitive" (unsupported on SQLite)
- enum imports repointed from @prisma/client to @/types

Deploy:
- multi-stage Dockerfile (next build -> prod runner), docker-entrypoint.sh
  (prisma db push -> create-admin -> next start), .dockerignore
- docker-compose.yml: br0 10.2.0.x, /mnt/user/appdata/qms -> /data volume
- README rewritten for the Unraid/Gitea Actions flow; .env scrubbed of the
  live Supabase credential; vercel.json removed

Verified: next build clean (41 routes); live SQLite round-trip of
login/session, form options array, and submission -> REVIEW_READY.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason
2026-06-15 16:57:15 -05:00
parent 631890c5bd
commit ad499f6782
70 changed files with 8045 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
node_modules
.next
.git
.gitea
init-source
*.db
*.db-journal
prisma/dev.db*
.env
.env.*
!.env.example
npm-debug.log*
Dockerfile
docker-compose.yml
README.md
.claude
+20
View File
@@ -0,0 +1,20 @@
# ─── Database (SQLite — single-file, no external DB server) ───────────────────
# Local dev: file:./dev.db (created under prisma/)
# Container: file:/data/qms.db (set in docker-compose; /data is the mapped Unraid volume)
DATABASE_URL="file:./dev.db"
# ─── App ──────────────────────────────────────────────────────────────────────
# Set this to the LAN URL/hostname the app is reached at on Unraid (used in notification links).
NEXT_PUBLIC_APP_URL="http://10.2.0.x:3000"
# ─── First admin (auto-created on container start only if no admin exists) ────
ADMIN_EMAIL="admin@yourcompany.com"
ADMIN_PASSWORD="change-me-on-first-login"
ADMIN_NAME="Administrator"
# ─── Email (SMTP) — optional; emails are skipped entirely when SMTP_HOST unset ─
# SMTP_HOST="smtp.resend.com"
# SMTP_PORT="587"
# SMTP_USER="resend"
# SMTP_PASS="re_your_api_key_here"
# EMAIL_FROM="qms@yourcompany.com"
+25
View File
@@ -0,0 +1,25 @@
name: Build and Push Docker Image
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: registry.alwisp.com
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and Push
run: |
docker build -t registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest .
docker push registry.alwisp.com/${{ gitea.repository_owner }}/${{ gitea.repository }}:latest
+22
View File
@@ -0,0 +1,22 @@
# dependencies
/node_modules
# next.js
/.next
/out
# database (SQLite — runtime only)
*.db
*.db-journal
/prisma/dev.db*
/data
# env / secrets
.env
.env.local
.env.*.local
# misc
.DS_Store
*.log
.vercel
+1
View File
@@ -0,0 +1 @@
legacy-peer-deps=true
+43
View File
@@ -0,0 +1,43 @@
# syntax=docker/dockerfile:1
# V11 Enterprise QMS — single-container image (Next.js + Prisma + SQLite)
# ─── Builder ──────────────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# Install deps (legacy-peer-deps comes from .npmrc — needed for React 19)
COPY package.json package-lock.json .npmrc ./
RUN npm ci
# Build (prisma generate && next build). DATABASE_URL is only needed at runtime;
# a dummy value satisfies prisma generate during the image build.
COPY . .
ENV DATABASE_URL="file:/data/qms.db"
RUN npm run build
# ─── Runner ───────────────────────────────────────────────────────────────────
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV DATABASE_URL="file:/data/qms.db"
# Production deps only (keeps next + prisma CLI + @prisma/client; drops build tooling)
COPY package.json package-lock.json .npmrc ./
RUN npm ci --omit=dev
# Prisma schema + generated client for the prod node_modules
COPY prisma ./prisma
RUN npx prisma generate
# App build output and runtime files
COPY --from=builder /app/.next ./.next
COPY next.config.js ./
COPY scripts ./scripts
COPY docker-entrypoint.sh ./
# /data holds the SQLite file and is mounted from the Unraid host
RUN mkdir -p /data && chmod +x docker-entrypoint.sh
EXPOSE 3000
ENTRYPOINT ["./docker-entrypoint.sh"]
+101
View File
@@ -0,0 +1,101 @@
# V11 Enterprise QMS
Full-stack quality management system — Next.js 15 (pages router) + Prisma + **SQLite**,
packaged as a **single Docker container** for self-hosting on Unraid. Starts from zero
data and learns as your team uses it.
> **Status — partial build.** The implemented modules are: authentication, the first-build
> **Form builder** (admin), **Fill / Report an issue** (production), **Nonconformances + Resolutions
> library** (QC), **Client release / Shipments**, **Client issues / Quality escapes**, and the
> **living Shipping standard**. The remaining nav items (Admin dashboard, Users, Audit trail,
> Settings, QC dashboard, CAPA, Audits, Documents, Risk, Suppliers, Standards, My submissions,
> Management dashboard & Reports) are wired into navigation but render a "Coming soon" placeholder —
> the data model for them already exists in `prisma/schema.prisma`.
## Roles & access
| Role | Access |
|------|--------|
| **Admin** | Everything: form builder, users, settings, audit trail |
| **QC** | CAPA, audits, NCRs, resolutions, documents, risk, suppliers, standards, shipping standard |
| **Production** | Fill first-build forms, report issues |
| **Production lead** | Production + client release, client issues, shipping standard, NCRs |
| **Logistics lead** | Client release, client issues, shipping standard, NCRs |
| **Management** | Read-only dashboards and reports |
Only **Admin / Production lead / Logistics lead** can email a client release package.
---
## Deploy on Unraid (the intended setup)
CI/CD: push to Gitea → Gitea Actions builds the image → pushes to `registry.alwisp.com/jason/qms:latest`
→ install/Force-update from the Unraid Docker GUI.
### 1. Build & push (automatic)
`.gitea/workflows/docker-build.yml` builds and pushes the image to
`registry.alwisp.com/<owner>/<repo>:latest` on every push to `main`. It needs two repo secrets:
`REGISTRY_USER` and `REGISTRY_TOKEN`.
### 2. Run on Unraid
Either `docker compose up -d` with the bundled [`docker-compose.yml`](docker-compose.yml), or add a
container in the Unraid Docker GUI with the equivalent settings:
| Setting | Value |
|---|---|
| Repository | `registry.alwisp.com/jason/qms:latest` |
| Network | **Custom: br0** — assign a free IP in `10.2.0.0/24` |
| Volume | `/mnt/user/appdata/qms``/data` |
| `DATABASE_URL` | `file:/data/qms.db` |
| `NEXT_PUBLIC_APP_URL` | `http://<the-br0-ip>:3000` |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | first-run admin (created only if no admin exists) |
| `SMTP_*` (optional) | enables outbound email; omit to keep notifications in-app only |
On first start the container runs `prisma db push` (creates `/data/qms.db`), then `create-admin`
(creates the admin from `ADMIN_*` only if none exists), then serves on port `3000`. The SQLite file
lives entirely in the mapped volume, so the container is disposable — update by pulling a new image.
The app is reached at `http://<the-br0-ip>:3000`. Sign in with the admin credentials and change the
password.
---
## Local development
```bash
npm install
cp .env.example .env # DATABASE_URL defaults to file:./dev.db
npm run db:push # create the SQLite schema
npm run create-admin # seed the first admin from ADMIN_* in .env
npm run dev # http://localhost:3000
```
Useful scripts: `npm run build`, `npm run start`, `npm run db:studio`.
---
## Why SQLite (not Postgres)
This deploy targets a single self-contained container, so the schema was converted from PostgreSQL to
SQLite. SQLite (via Prisma) supports neither native enums nor `Json`/scalar-list columns, so:
- former enums are stored as `String` and validated in app code (union types in [`types/index.ts`](types/index.ts));
- former `Json` columns (`AuditLog.before/after`, `FormSubmission.data`, `QualityStandard.specs`) and the
`FormField.options` list are stored as **JSON-encoded strings**, (de)serialised at the DB boundary
(see [`lib/forms.ts`](lib/forms.ts)).
## Project structure
```
/pages
/api ← backend API routes (auth, forms, submissions, ncrs, escapes, shipments, …)
/admin /qc /fill /management ← role-scoped pages
/components ← layout shell, shared UI kit, form field renderer
/lib ← prisma client, auth/session, email, form (de)serialisation helpers
/prisma ← schema.prisma (SQLite)
/scripts ← create-admin.js (first-run bootstrap)
Dockerfile · docker-compose.yml · docker-entrypoint.sh · .gitea/workflows/build.yml
```
> `init-source/` holds the original delivery (base snapshot + fix/update overlays) for provenance;
> it is excluded from the build and the image.
+30
View File
@@ -0,0 +1,30 @@
import Layout from '@/components/layout/Layout'
// Placeholder for QMS modules that are on the roadmap but not yet implemented.
// Keeps navigation links and role redirects valid instead of 404-ing.
export default function ComingSoon({ title, blurb }: { title: string; blurb?: string }) {
return (
<Layout title={title}>
<div style={{ maxWidth: '520px', margin: '48px auto', textAlign: 'center' }}>
<div style={{
width: '52px', height: '52px', borderRadius: '14px', background: '#EEEDFE',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', marginBottom: '16px'
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#534AB7" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: '0 0 8px' }}>{title}</h2>
<p style={{ fontSize: '13px', color: '#888', lineHeight: 1.6, margin: '0 0 18px' }}>
{blurb || 'This module is part of the QMS roadmap and is not built yet. The data model and navigation are already in place.'}
</p>
<span style={{
fontSize: '11px', fontWeight: 500, color: '#3C3489', background: '#EEEDFE',
padding: '4px 12px', borderRadius: '12px'
}}>
Coming soon
</span>
</div>
</Layout>
)
}
+96
View File
@@ -0,0 +1,96 @@
import React from 'react'
import { Input, Textarea } from '@/components/ui'
import { FieldType } from '@/types'
export interface FormFieldDef {
id: string
label: string
type: FieldType
hint?: string | null
options: string[]
required: boolean
}
export function FieldRenderer({ field, value, onChange }: {
field: FormFieldDef
value: any
onChange: (v: any) => void
}) {
switch (field.type) {
case 'SHORT_TEXT':
return <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder={field.hint || ''}/>
case 'LONG_TEXT':
return <Textarea value={value || ''} onChange={e => onChange(e.target.value)} placeholder={field.hint || ''}/>
case 'NUMBER':
return <Input type="number" value={value ?? ''} onChange={e => onChange(e.target.value)} style={{ maxWidth: '160px' }}/>
case 'DATE':
return <Input type="date" value={value || ''} onChange={e => onChange(e.target.value)} style={{ maxWidth: '180px' }}/>
case 'SINGLE_CHOICE':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{field.options.map(opt => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', cursor: 'pointer' }}>
<input type="radio" name={field.id} checked={value === opt} onChange={() => onChange(opt)} style={{ accentColor: '#534AB7' }}/>
{opt}
</label>
))}
</div>
)
case 'MULTI_CHOICE':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{field.options.map(opt => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', cursor: 'pointer' }}>
<input type="checkbox" checked={(value || []).includes(opt)}
onChange={e => {
const cur = value || []
onChange(e.target.checked ? [...cur, opt] : cur.filter((x: string) => x !== opt))
}} style={{ accentColor: '#534AB7' }}/>
{opt}
</label>
))}
</div>
)
case 'RATING':
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[1, 2, 3, 4, 5].map(n => (
<span key={n} onClick={() => onChange(n)} style={{ fontSize: '24px', cursor: 'pointer', color: (value || 0) >= n ? '#EF9F27' : '#ddd' }}></span>
))}
</div>
)
case 'PHOTO':
return <Input type="file" accept="image/*" onChange={e => onChange(e.target.files?.[0]?.name || '')}/>
default:
return null
}
}
export function FormFieldList({ fields, values, onChange }: {
fields: FormFieldDef[]
values: Record<string, any>
onChange: (fieldId: string, value: any) => void
}) {
return (
<>
{fields.map(field => (
<div key={field.id} style={{ marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
{field.label}
{field.required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}
</label>
{field.hint && <div style={{ fontSize: '10px', color: '#aaa', marginBottom: '4px' }}>{field.hint}</div>}
<FieldRenderer field={field} value={values[field.id]} onChange={v => onChange(field.id, v)}/>
</div>
))}
</>
)
}
+266
View File
@@ -0,0 +1,266 @@
import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import { useApp } from '@/lib/context'
import { Role } from '@/types'
const NAV_BY_ROLE: Record<Role, { href: string; label: string; icon: string }[]> = {
ADMIN: [
{ href: '/admin', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/admin/users', label: 'Users', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
{ href: '/admin/forms', label: 'Form builder', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
{ href: '/admin/audit-log', label: 'Audit trail', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
{ href: '/admin/settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
],
QC: [
{ href: '/qc', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/qc/capas', label: 'CAPA', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
{ href: '/qc/audits', label: 'Audits', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
{ href: '/qc/ncr', label: 'Nonconformances', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
{ href: '/qc/shipments', label: 'Client release', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
{ href: '/qc/escapes', label: 'Client issues', icon: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z' },
{ href: '/qc/shipping-standard', label: 'Shipping standard', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 14l2 2 4-4' },
{ href: '/qc/documents', label: 'Documents', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
{ href: '/qc/risk', label: 'Risk register', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
{ href: '/qc/suppliers', label: 'Suppliers', icon: 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4' },
{ href: '/qc/standards', label: 'Standards', icon: 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z' },
],
PRODUCTION: [
{ href: '/fill', label: 'My forms', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
{ href: '/fill/submissions', label: 'My submissions', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2m0 0l2.5 2.5M9 12h6' },
],
PRODUCTION_LEAD: [
{ href: '/fill', label: 'My forms', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
{ href: '/fill/submissions', label: 'My submissions', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2m0 0l2.5 2.5M9 12h6' },
{ href: '/qc/ncr', label: 'Nonconformances', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
{ href: '/qc/shipments', label: 'Client release', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
{ href: '/qc/escapes', label: 'Client issues', icon: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z' },
{ href: '/qc/shipping-standard', label: 'Shipping standard', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 14l2 2 4-4' },
],
LOGISTICS_LEAD: [
{ href: '/qc/shipments', label: 'Client release', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
{ href: '/qc/escapes', label: 'Client issues', icon: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z' },
{ href: '/qc/shipping-standard', label: 'Shipping standard', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 14l2 2 4-4' },
{ href: '/qc/ncr', label: 'Nonconformances', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
],
MANAGEMENT: [
{ href: '/management', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
{ href: '/management/reports', label: 'Reports', icon: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
],
}
const ROLE_COLORS: Record<Role, string> = {
ADMIN: '#534AB7', QC: '#1D9E75', PRODUCTION: '#EF9F27', PRODUCTION_LEAD: '#D98324', LOGISTICS_LEAD: '#2A9D8F', MANAGEMENT: '#378ADD'
}
export default function Layout({ children, title }: { children: React.ReactNode; title?: string }) {
const { user, logout } = useApp()
const router = useRouter()
const [collapsed, setCollapsed] = useState(false)
const [unread, setUnread] = useState(0)
const [notifOpen, setNotifOpen] = useState(false)
const [notifs, setNotifs] = useState<any[]>([])
const [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
fetchNotifs()
const interval = setInterval(fetchNotifs, 60000)
return () => clearInterval(interval)
}, [])
async function fetchNotifs() {
try {
const res = await fetch('/api/notifications')
if (res.ok) {
const { data, unread: u } = await res.json()
setNotifs(data)
setUnread(u)
}
} catch {}
}
async function markRead() {
await fetch('/api/notifications', { method: 'PATCH' })
setUnread(0)
setNotifs(n => n.map(x => ({ ...x, read: true })))
}
if (!user) return null
const nav = NAV_BY_ROLE[user.role] || []
const roleColor = ROLE_COLORS[user.role]
const pageTitle = title ? `${title} — V11 QMS` : 'V11 QMS'
return (
<>
<Head><title>{pageTitle}</title></Head>
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: '#F8F7FD' }}>
{/* Sidebar */}
<aside style={{
width: collapsed ? '52px' : '220px',
minWidth: collapsed ? '52px' : '220px',
background: 'white',
borderRight: '0.5px solid #eee',
display: 'flex', flexDirection: 'column',
transition: 'width 0.2s, min-width 0.2s',
overflow: 'hidden',
position: 'relative',
zIndex: 10,
}}>
{/* Logo */}
<div style={{ padding: '16px 14px 14px', borderBottom: '0.5px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
{!collapsed && (
<div>
<div style={{ fontSize: '14px', fontWeight: '600', color: roleColor }}>V11 QMS</div>
<div style={{ fontSize: '10px', color: '#aaa', marginTop: '1px' }}>Enterprise Quality OS</div>
</div>
)}
<button onClick={() => setCollapsed(c => !c)} style={{
background: 'none', border: 'none', cursor: 'pointer',
color: '#aaa', padding: '4px', borderRadius: '6px', marginLeft: collapsed ? 'auto' : 0
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
{collapsed
? <path d="M13 5l7 7-7 7M6 12h14"/>
: <path d="M11 5l-7 7 7 7M18 12H4"/>}
</svg>
</button>
</div>
{/* Nav */}
<nav style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
{nav.map(item => {
const active = router.pathname === item.href || (item.href !== '/qc' && item.href !== '/admin' && item.href !== '/management' && router.pathname.startsWith(item.href))
return (
<a key={item.href} href={item.href} style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: collapsed ? '8px 14px' : '7px 10px',
margin: '1px 6px', borderRadius: '8px',
color: active ? roleColor : '#666',
background: active ? `${roleColor}14` : 'transparent',
fontWeight: active ? '500' : '400',
fontSize: '13px', textDecoration: 'none',
whiteSpace: 'nowrap', transition: 'all 0.1s',
justifyContent: collapsed ? 'center' : 'flex-start',
}}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" style={{ flexShrink: 0 }}>
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon}/>
</svg>
{!collapsed && item.label}
</a>
)
})}
</nav>
{/* User */}
<div style={{ padding: '10px 12px', borderTop: '0.5px solid #eee', display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
<div style={{
width: '28px', height: '28px', borderRadius: '50%',
background: roleColor + '22', color: roleColor,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '10px', fontWeight: '600', flexShrink: 0
}}>
{user.name.slice(0, 2).toUpperCase()}
</div>
{!collapsed && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: '12px', fontWeight: '500', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.name}</div>
<div style={{ fontSize: '10px', color: '#aaa' }}>{user.role}</div>
</div>
)}
{!collapsed && (
<button onClick={logout} title="Sign out" style={{
background: 'none', border: 'none', cursor: 'pointer', color: '#bbb', padding: '2px'
}}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
</button>
)}
</div>
</aside>
{/* Main */}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
{/* Topbar */}
<div style={{
background: 'white', borderBottom: '0.5px solid #eee',
padding: '0 20px', height: '52px',
display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0
}}>
<div style={{ flex: 1, fontSize: '14px', fontWeight: '500' }}>
{title || nav.find(n => router.pathname === n.href || router.pathname.startsWith(n.href))?.label || 'Dashboard'}
</div>
{/* Notifications */}
<div style={{ position: 'relative' }}>
<button onClick={() => { setNotifOpen(o => !o); if (unread) markRead() }} style={{
background: 'none', border: '0.5px solid #e8e8e8',
borderRadius: '8px', padding: '6px', cursor: 'pointer', color: '#666',
display: 'flex', alignItems: 'center', position: 'relative'
}}>
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
</svg>
{unread > 0 && (
<span style={{
position: 'absolute', top: '3px', right: '3px',
width: '7px', height: '7px', borderRadius: '50%',
background: '#E24B4A', border: '1.5px solid white'
}}/>
)}
</button>
{notifOpen && (
<div style={{
position: 'absolute', top: '42px', right: 0,
width: '280px', background: 'white',
border: '0.5px solid #e0e0e0', borderRadius: '12px',
zIndex: 200, overflow: 'hidden',
boxShadow: '0 4px 16px rgba(0,0,0,0.08)'
}}>
<div style={{ padding: '10px 14px', fontSize: '12px', fontWeight: '500', borderBottom: '0.5px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
<span>Notifications</span>
<span style={{ color: roleColor, cursor: 'pointer', fontSize: '11px' }} onClick={markRead}>Mark all read</span>
</div>
{notifs.length === 0 ? (
<div style={{ padding: '20px', textAlign: 'center', fontSize: '12px', color: '#aaa' }}>All caught up</div>
) : notifs.slice(0, 6).map(n => (
<div key={n.id} style={{
padding: '10px 14px', borderBottom: '0.5px solid #f5f5f5',
background: n.read ? 'transparent' : '#FAFAFE',
borderLeft: n.read ? 'none' : `2px solid ${roleColor}`,
cursor: 'pointer',
}}>
<div style={{ fontSize: '12px', fontWeight: '500' }}>{n.title}</div>
<div style={{ fontSize: '11px', color: '#888', marginTop: '2px', lineHeight: '1.3' }}>{n.body}</div>
<div style={{ fontSize: '10px', color: '#bbb', marginTop: '3px' }}>
{new Date(n.createdAt).toLocaleDateString()}
</div>
</div>
))}
</div>
)}
</div>
<div style={{
width: '28px', height: '28px', borderRadius: '50%',
background: roleColor + '22', color: roleColor,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '10px', fontWeight: '600'
}}>
{user.name.slice(0, 2).toUpperCase()}
</div>
</div>
{/* Content */}
<main style={{ flex: 1, overflowY: 'auto', padding: '20px' }}
onClick={() => notifOpen && setNotifOpen(false)}>
{children}
</main>
</div>
</div>
</>
)
}
+243
View File
@@ -0,0 +1,243 @@
import React, { useEffect, useState } from 'react'
// ── Empty state ───────────────────────────────────────────────────────────────
export function EmptyState({ icon, title, message, action }: {
icon?: string; title: string; message?: string
action?: { label: string; onClick: () => void }
}) {
return (
<div style={{ textAlign: 'center', padding: '48px 24px', color: '#aaa' }}>
{icon && <div style={{ fontSize: '32px', marginBottom: '10px', opacity: 0.4 }}>{icon}</div>}
<div style={{ fontSize: '14px', fontWeight: '500', color: '#666', marginBottom: '6px' }}>{title}</div>
{message && <div style={{ fontSize: '12px', color: '#aaa', marginBottom: '16px', maxWidth: '300px', margin: '0 auto 16px' }}>{message}</div>}
{action && (
<button onClick={action.onClick} style={{
padding: '8px 16px', fontSize: '12px', fontWeight: '500',
background: '#534AB7', color: 'white', border: 'none',
borderRadius: '8px', cursor: 'pointer'
}}>{action.label}</button>
)}
</div>
)
}
// ── Stat card ─────────────────────────────────────────────────────────────────
export function StatCard({ label, value, sub, color }: {
label: string; value: string | number | null; sub?: string; color?: string
}) {
return (
<div style={{
background: 'white', border: '0.5px solid #eee',
borderRadius: '10px', padding: '12px 14px'
}}>
<div style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.6px', color: '#aaa', marginBottom: '4px' }}>
{label}
</div>
<div style={{ fontSize: '24px', fontWeight: '500', color: color || '#1a1a1a', lineHeight: '1.1' }}>
{value === null ? '—' : value}
</div>
{sub && <div style={{ fontSize: '10px', color: '#aaa', marginTop: '3px' }}>{sub}</div>}
</div>
)
}
// ── Tag / badge ───────────────────────────────────────────────────────────────
const TAG_STYLES: Record<string, { bg: string; color: string }> = {
green: { bg: '#EAF3DE', color: '#27500A' },
amber: { bg: '#FAEEDA', color: '#633806' },
red: { bg: '#FCEBEB', color: '#791F1F' },
blue: { bg: '#E6F1FB', color: '#0C447C' },
purple: { bg: '#EEEDFE', color: '#3C3489' },
gray: { bg: '#F1EFE8', color: '#444441' },
teal: { bg: '#E1F5EE', color: '#085041' },
}
export function Tag({ children, color = 'gray' }: { children: React.ReactNode; color?: string }) {
const s = TAG_STYLES[color] || TAG_STYLES.gray
return (
<span style={{
fontSize: '10px', padding: '2px 7px', borderRadius: '9px',
fontWeight: '500', background: s.bg, color: s.color,
whiteSpace: 'nowrap', display: 'inline-block'
}}>
{children}
</span>
)
}
// ── Status dot ────────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
OPEN: '#378ADD', IN_PROGRESS: '#EF9F27', OVERDUE: '#E24B4A',
CLOSED: '#1D9E75', SCHEDULED: '#378ADD', COMPLETED: '#1D9E75',
CANCELLED: '#aaa', INVESTIGATING: '#EF9F27', ESCALATED: '#E24B4A',
RESOLVED: '#1D9E75', APPROVED: '#1D9E75', UNDER_REVIEW: '#EF9F27',
SUSPENDED: '#E24B4A', DRAFT: '#aaa', ACTIVE: '#1D9E75',
REVIEW_READY: '#EF9F27', STANDARD_SET: '#1D9E75', ARCHIVED: '#aaa',
CURRENT: '#1D9E75', PENDING_REVIEW: '#EF9F27', EXPIRED: '#E24B4A',
}
export function StatusDot({ status }: { status: string }) {
return (
<span style={{
width: '6px', height: '6px', borderRadius: '50%',
background: STATUS_COLORS[status] || '#aaa',
display: 'inline-block', marginRight: '5px', flexShrink: 0, verticalAlign: 'middle'
}}/>
)
}
// ── Card ──────────────────────────────────────────────────────────────────────
export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: 'white', border: '0.5px solid #eee',
borderRadius: '12px', padding: '14px 16px',
...style
}}>
{children}
</div>
)
}
// ── Table ─────────────────────────────────────────────────────────────────────
export function Table({ headers, children, empty }: {
headers: string[]; children: React.ReactNode; empty?: boolean
}) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr>
{headers.map(h => (
<th key={h} style={{
textAlign: 'left', fontWeight: '500', fontSize: '10px',
textTransform: 'uppercase', letterSpacing: '0.5px',
color: '#aaa', padding: '5px 6px',
borderBottom: '0.5px solid #eee'
}}>{h}</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
)
}
// ── Toast ─────────────────────────────────────────────────────────────────────
type ToastType = 'success' | 'error' | 'info'
let toastCallback: ((msg: string, type: ToastType) => void) | null = null
export function showToast(msg: string, type: ToastType = 'success') {
toastCallback?.(msg, type)
}
export function ToastProvider() {
const [toasts, setToasts] = useState<{ id: number; msg: string; type: ToastType }[]>([])
let counter = 0
useEffect(() => {
toastCallback = (msg, type) => {
const id = ++counter
setToasts(t => [...t, { id, msg, type }])
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3500)
}
return () => { toastCallback = null }
}, [])
const colors = { success: '#1D9E75', error: '#E24B4A', info: '#534AB7' }
return (
<div style={{ position: 'fixed', bottom: '20px', right: '20px', zIndex: 1000, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{toasts.map(t => (
<div key={t.id} className="toast-enter" style={{
background: 'white', border: `0.5px solid ${colors[t.type]}33`,
borderLeft: `3px solid ${colors[t.type]}`,
borderRadius: '8px', padding: '10px 14px',
fontSize: '12px', color: '#333',
minWidth: '220px', maxWidth: '320px',
}}>
{t.msg}
</div>
))}
</div>
)
}
// ── Button ────────────────────────────────────────────────────────────────────
export function Btn({ children, onClick, variant = 'primary', size = 'md', disabled, type = 'button', style }: {
children: React.ReactNode; onClick?: () => void
variant?: 'primary' | 'ghost' | 'danger'; size?: 'sm' | 'md'
disabled?: boolean; type?: 'button' | 'submit'; style?: React.CSSProperties
}) {
const base: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: '5px',
border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
fontFamily: 'inherit', fontWeight: '500', transition: 'all 0.1s',
opacity: disabled ? 0.6 : 1,
padding: size === 'sm' ? '5px 10px' : '7px 14px',
fontSize: size === 'sm' ? '11px' : '12px',
borderRadius: '8px',
}
const variants = {
primary: { background: '#534AB7', color: 'white', border: 'none' },
ghost: { background: 'transparent', color: '#444', border: '0.5px solid #ddd' },
danger: { background: '#FCEBEB', color: '#791F1F', border: '0.5px solid #F09595' },
}
return (
<button type={type} onClick={onClick} disabled={disabled} style={{ ...base, ...variants[variant], ...style }}>
{children}
</button>
)
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export function Modal({ open, onClose, title, children, width = 400 }: {
open: boolean; onClose: () => void; title: string
children: React.ReactNode; width?: number
}) {
if (!open) return null
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 500, padding: '20px'
}} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{
background: 'white', borderRadius: '12px',
border: '0.5px solid #e0e0e0',
padding: '20px', width: '100%', maxWidth: `${width}px`
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ fontSize: '14px', fontWeight: '500' }}>{title}</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', fontSize: '18px' }}>×</button>
</div>
{children}
</div>
</div>
)
}
// ── Form helpers ──────────────────────────────────────────────────────────────
export function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
return (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>
{label}{required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}
</label>
{children}
</div>
)
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '7px 10px', fontSize: '12px',
border: '0.5px solid #ddd', borderRadius: '8px',
background: 'transparent', color: '#1a1a1a', fontFamily: 'inherit', outline: 'none'
}
export const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) =>
<input {...props} style={{ ...inputStyle, ...props.style }}/>
export const Select = (props: React.SelectHTMLAttributes<HTMLSelectElement>) =>
<select {...props} style={{ ...inputStyle, background: 'white', ...props.style }}/>
export const Textarea = (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) =>
<textarea {...props} style={{ ...inputStyle, resize: 'vertical', minHeight: '70px', lineHeight: '1.5', ...props.style }}/>
+36
View File
@@ -0,0 +1,36 @@
# V11 Enterprise QMS — Unraid deployment
# Single container, SQLite persisted to the Unraid appdata share, on the br0 LAN.
#
# 1. Pick a free IP in 10.2.0.0/24 and set it below (ipv4_address) + NEXT_PUBLIC_APP_URL.
# 2. Set ADMIN_* and (optionally) SMTP_* below.
# 3. docker compose up -d (or install via the Unraid Docker GUI with the same values)
services:
qms:
image: registry.alwisp.com/jason/qms:latest
container_name: qms
restart: unless-stopped
environment:
# SQLite file inside the mapped volume — do not change the path
DATABASE_URL: "file:/data/qms.db"
# LAN URL the app is reached at (used in notification email links)
NEXT_PUBLIC_APP_URL: "http://10.2.0.30:3000"
# First-run admin (created only if no admin exists; change the password after first login)
ADMIN_EMAIL: "admin@yourcompany.com"
ADMIN_PASSWORD: "change-me-on-first-login"
ADMIN_NAME: "Administrator"
# Optional SMTP — leave unset to disable outbound email (notifications still appear in-app)
# SMTP_HOST: "smtp.resend.com"
# SMTP_PORT: "587"
# SMTP_USER: "resend"
# SMTP_PASS: "re_xxxxxxxx"
# EMAIL_FROM: "qms@yourcompany.com"
volumes:
- /mnt/user/appdata/qms:/data
networks:
br0:
ipv4_address: 10.2.0.30 # <-- set to a free IP on your LAN
networks:
br0:
external: true
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
echo "[entrypoint] Syncing database schema (prisma db push)…"
npx prisma db push --skip-generate
echo "[entrypoint] Ensuring an admin user exists…"
node scripts/create-admin.js || echo "[entrypoint] create-admin skipped/failed (continuing)"
echo "[entrypoint] Starting Next.js on 0.0.0.0:${PORT:-3000}"
exec node_modules/.bin/next start -H 0.0.0.0 -p "${PORT:-3000}"
+108
View File
@@ -0,0 +1,108 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from './prisma'
import { Role } from '@/types'
import bcrypt from 'bcryptjs'
import { v4 as uuid } from 'uuid'
export const SESSION_COOKIE = 'qms_session'
export const SESSION_EXPIRY_DAYS = 7
export async function hashPassword(password: string) {
return bcrypt.hash(password, 12)
}
export async function verifyPassword(password: string, hash: string) {
return bcrypt.compare(password, hash)
}
export async function createSession(userId: string) {
const token = uuid()
const expiresAt = new Date()
expiresAt.setDate(expiresAt.getDate() + SESSION_EXPIRY_DAYS)
await prisma.session.create({
data: { userId, token, expiresAt },
})
return token
}
export async function getSessionUser(req: NextApiRequest) {
const token = req.cookies[SESSION_COOKIE]
if (!token) return null
const session = await prisma.session.findUnique({
where: { token },
include: { user: true },
})
if (!session || session.expiresAt < new Date()) {
if (session) await prisma.session.delete({ where: { token } })
return null
}
return session.user
}
export async function requireAuth(
req: NextApiRequest,
res: NextApiResponse,
allowedRoles?: Role[]
) {
const user = await getSessionUser(req)
if (!user || !user.active) {
res.status(401).json({ error: 'Unauthorised' })
return null
}
if (allowedRoles && !allowedRoles.includes(user.role as Role)) {
res.status(403).json({ error: 'Forbidden' })
return null
}
return user
}
export async function logAction(
userId: string,
action: string,
entity: string,
entityId: string,
before?: unknown,
after?: unknown
) {
await prisma.auditLog.create({
data: {
userId,
action,
entity,
entityId,
before: before === undefined ? null : JSON.stringify(before),
after: after === undefined ? null : JSON.stringify(after),
},
})
}
export function generateRef(prefix: string, count: number) {
return `${prefix}-${String(count + 1).padStart(3, '0')}`
}
// Role permission helpers
export const ROLE_PERMISSIONS = {
ADMIN: ['*'],
QC: ['capa', 'audits', 'ncr', 'resolutions', 'documents', 'risk', 'suppliers', 'standards', 'shipping-standard', 'dashboard', 'reports', 'export'],
PRODUCTION: ['fill', 'my-submissions', 'report-issue'],
PRODUCTION_LEAD: ['fill', 'my-submissions', 'report-issue', 'shipments', 'escapes', 'shipping-standard', 'ncr', 'dashboard'],
LOGISTICS_LEAD: ['shipments', 'escapes', 'shipping-standard', 'ncr', 'dashboard'],
MANAGEMENT: ['dashboard', 'reports', 'export'],
} as const
// Only these roles may batch-email a client release package
export const SHIPMENT_SEND_ROLES: Role[] = ['ADMIN', 'PRODUCTION_LEAD', 'LOGISTICS_LEAD']
export function canAccess(role: string, module: string): boolean {
const perms = ROLE_PERMISSIONS[role as Role] as readonly string[] | undefined
if (!perms) return false
return perms.includes('*') || perms.includes(module)
}
+62
View File
@@ -0,0 +1,62 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
import { AppUser } from '@/types'
interface AppContextValue {
user: AppUser | null
loading: boolean
login: (email: string, password: string) => Promise<string | null>
logout: () => Promise<void>
refreshUser: () => Promise<void>
}
const AppContext = createContext<AppContextValue>({} as AppContextValue)
export function AppProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<AppUser | null>(null)
const [loading, setLoading] = useState(true)
const refreshUser = useCallback(async () => {
try {
const res = await fetch('/api/auth/me')
if (res.ok) {
const { user } = await res.json()
setUser(user)
} else {
setUser(null)
}
} catch {
setUser(null)
}
}, [])
useEffect(() => {
refreshUser().finally(() => setLoading(false))
}, [refreshUser])
const login = async (email: string, password: string): Promise<string | null> => {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const data = await res.json()
if (res.ok) {
setUser(data.user)
return null
}
return data.error || 'Login failed'
}
const logout = async () => {
await fetch('/api/auth/me', { method: 'DELETE' })
setUser(null)
}
return (
<AppContext.Provider value={{ user, loading, login, logout, refreshUser }}>
{children}
</AppContext.Provider>
)
}
export const useApp = () => useContext(AppContext)
+92
View File
@@ -0,0 +1,92 @@
import nodemailer from 'nodemailer'
import { prisma } from './prisma'
import { NotifType } from '@/types'
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT) || 587,
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
})
const FROM = process.env.EMAIL_FROM || 'qms@acmequality.com'
const APP_URL = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000'
export async function sendEmail(to: string, subject: string, html: string) {
if (!process.env.SMTP_HOST) {
console.log('[EMAIL SKIPPED - no SMTP config]', { to, subject })
return
}
await transporter.sendMail({ from: FROM, to, subject, html })
}
export async function createNotification(
userId: string,
type: NotifType,
title: string,
body: string,
link?: string
) {
return prisma.notification.create({
data: { userId, type, title, body, link },
})
}
export async function notifyCAPAOverdue(capaRef: string, capaTitle: string, ownerEmail: string, ownerId: string) {
const link = `${APP_URL}/qc/capas`
await createNotification(ownerId, 'CAPA_OVERDUE', `${capaRef} is overdue`, `${capaTitle} has passed its due date.`, link)
await sendEmail(ownerEmail, `[V11 QMS] CAPA ${capaRef} overdue`, `
<p>Hello,</p>
<p>CAPA <strong>${capaRef}: ${capaTitle}</strong> has passed its due date and requires immediate attention.</p>
<p><a href="${link}">View CAPA →</a></p>
`)
}
export async function notifyNCREscalated(ncrRef: string, ncrDesc: string, managerEmail: string, managerId: string) {
const link = `${APP_URL}/qc/ncr`
await createNotification(managerId, 'NCR_ESCALATED', `${ncrRef} escalated`, ncrDesc, link)
await sendEmail(managerEmail, `[V11 QMS] NCR ${ncrRef} escalated`, `
<p>NCR <strong>${ncrRef}</strong> has been escalated: ${ncrDesc}</p>
<p><a href="${link}">View NCR →</a></p>
`)
}
export async function notifyFormReviewReady(formName: string, submissionCount: number, adminEmail: string, adminId: string) {
const link = `${APP_URL}/admin/forms`
await createNotification(adminId, 'FORM_REVIEW_READY', `${formName} ready for review`, `${submissionCount} submissions collected. QC review can begin.`, link)
await sendEmail(adminEmail, `[V11 QMS] Form ready for QC review: ${formName}`, `
<p><strong>${formName}</strong> has reached ${submissionCount} submissions and is ready for QC standard review.</p>
<p><a href="${link}">Review now →</a></p>
`)
}
export async function notifySolutionConfirmed(ncrRef: string, ncrDesc: string, reporterEmail: string, reporterId: string) {
const link = `${APP_URL}/qc/ncr`
await createNotification(reporterId, 'SOLUTION_CONFIRMED', `Fix confirmed for ${ncrRef}`, `The issue you reported (${ncrDesc}) has been resolved. No action needed from you.`, link)
await sendEmail(reporterEmail, `[V11 QMS] Fixed: ${ncrRef}`, `
<p>Good news — the issue you reported has been resolved:</p>
<p style="color:#666">${ncrDesc}</p>
<p>No further action is needed from you.</p>
`)
}
export function emailTemplate(title: string, body: string) {
return `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>${title}</title></head>
<body style="font-family:sans-serif;max-width:600px;margin:0 auto;padding:24px;color:#333">
<div style="border-bottom:2px solid #534AB7;padding-bottom:12px;margin-bottom:20px">
<span style="font-weight:600;font-size:16px;color:#534AB7">V11 QMS</span>
</div>
<h2 style="font-size:18px;font-weight:500;margin-bottom:12px">${title}</h2>
${body}
<div style="border-top:1px solid #eee;margin-top:32px;padding-top:12px;font-size:11px;color:#999">
This is an automated message from V11 Enterprise QMS. Do not reply.
</div>
</body>
</html>`
}
+30
View File
@@ -0,0 +1,30 @@
// Helpers for the FormField.options column, which is stored as a JSON-encoded
// string in SQLite (no native scalar lists). The client/API contract keeps
// `options` as a real string[] — these convert at the DB boundary.
export function parseOptions(raw: unknown): string[] {
if (Array.isArray(raw)) return raw as string[]
if (typeof raw !== 'string' || raw.length === 0) return []
try {
const v = JSON.parse(raw)
return Array.isArray(v) ? v.map(String) : []
} catch {
return []
}
}
export function serializeOptions(opts: unknown): string {
return JSON.stringify(Array.isArray(opts) ? opts : [])
}
// Returns a shallow copy of a form with each field's `options` parsed to string[].
export function withParsedFields<T extends { fields?: any[] | null }>(form: T | null): T | null {
if (!form) return form
if (Array.isArray(form.fields)) {
return {
...form,
fields: form.fields.map(f => ({ ...f, options: parseOptions(f.options) })),
}
}
return form
}
+15
View File
@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
export default prisma
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
+10
View File
@@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: { unoptimized: true },
webpack: config => {
config.parallelism = 128
return config
},
}
module.exports = nextConfig
+3511
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "v11-enterprise-qms",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "prisma generate && next build",
"start": "next start",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"create-admin": "node scripts/create-admin.js"
},
"dependencies": {
"next": "^15.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next-auth": "^4.24.0",
"@prisma/client": "^5.10.0",
"prisma": "^5.10.0",
"bcryptjs": "^2.4.3",
"zod": "^3.22.0",
"lucide-react": "^0.469.0",
"nodemailer": "^6.9.0",
"recharts": "^2.12.0",
"date-fns": "^3.3.0",
"clsx": "^2.1.0",
"@react-pdf/renderer": "^3.4.0",
"csv-parse": "^5.5.0",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/nodemailer": "^6.4.14",
"@types/uuid": "^9.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.7.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
import type { AppProps } from 'next/app'
import { AppProvider } from '@/lib/context'
import { ToastProvider } from '@/components/ui'
import '@/styles/globals.css'
export default function App({ Component, pageProps }: AppProps) {
return (
<AppProvider>
<Component {...pageProps} />
<ToastProvider />
</AppProvider>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function AdminAuditLog() {
return <ComingSoon title="Audit trail" blurb="Immutable, append-only record of every create, update, and delete." />
}
+355
View File
@@ -0,0 +1,355 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Modal, Field, Input, Select, Textarea, showToast, Tag, StatusDot, Table } from '@/components/ui'
import { FormFieldList, FormFieldDef } from '@/components/forms/FieldRenderer'
import { FieldType } from '@/types'
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: 'SHORT_TEXT', label: 'Short text' },
{ value: 'LONG_TEXT', label: 'Long text' },
{ value: 'NUMBER', label: 'Number' },
{ value: 'DATE', label: 'Date' },
{ value: 'SINGLE_CHOICE', label: 'Single choice' },
{ value: 'MULTI_CHOICE', label: 'Multi-choice' },
{ value: 'RATING', label: 'Rating (15)' },
{ value: 'PHOTO', label: 'Photo / file' },
]
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'purple', ACTIVE: 'green', SUSPENDED: 'amber',
REVIEW_READY: 'blue', STANDARD_SET: 'teal', ARCHIVED: 'gray',
}
interface BuilderField extends FormFieldDef {
order: number
}
export default function FormBuilderPage() {
const [forms, setForms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
// Builder modal
const [builderOpen, setBuilderOpen] = useState(false)
const [builderMode, setBuilderMode] = useState<'create' | 'edit'>('create')
const [editingId, setEditingId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [formMeta, setFormMeta] = useState({ name: '', product: '', description: '', minSubmissions: 10 })
const [fields, setFields] = useState<BuilderField[]>([])
const [newField, setNewField] = useState({ label: '', type: 'SHORT_TEXT' as FieldType, hint: '', required: false, options: '' })
// Preview modal
const [previewOpen, setPreviewOpen] = useState(false)
const [previewFields, setPreviewFields] = useState<FormFieldDef[]>([])
const [previewTitle, setPreviewTitle] = useState('')
const [previewSubtitle, setPreviewSubtitle] = useState('')
const [previewAnswers, setPreviewAnswers] = useState<Record<string, any>>({})
// Active vs Archived view
const [tab, setTab] = useState<'active' | 'archived'>('active')
useEffect(() => { loadForms() }, [])
async function loadForms() {
setLoading(true)
const res = await fetch('/api/forms')
if (res.ok) {
const { data } = await res.json()
setForms(data || [])
}
setLoading(false)
}
// ── Builder open/close ──────────────────────────────────────────────────
function openCreate() {
setFormMeta({ name: '', product: '', description: '', minSubmissions: 10 })
setFields([])
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
setEditingId(null)
setBuilderMode('create')
setBuilderOpen(true)
}
function openEdit(form: any) {
setFormMeta({
name: form.name,
product: form.product || '',
description: form.description || '',
minSubmissions: form.minSubmissions,
})
setFields((form.fields || []).map((f: any) => ({
id: f.id, label: f.label, type: f.type, hint: f.hint || '',
required: !!f.required, options: f.options || [], order: f.order ?? 0,
})))
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
setEditingId(form.id)
setBuilderMode('edit')
setBuilderOpen(true)
}
// ── Field add/remove ─────────────────────────────────────────────────────
function addField() {
if (!newField.label) return
const opts = newField.options.split('\n').map(o => o.trim()).filter(Boolean)
setFields(f => [...f, { ...newField, options: opts, order: f.length, id: `new_${Date.now()}` }])
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
}
function removeField(index: number) {
setFields(fs => fs.filter((_, i) => i !== index))
}
// ── Save (create or edit) ───────────────────────────────────────────────
async function saveForm() {
if (!formMeta.name) return
setSaving(true)
const url = builderMode === 'edit' ? `/api/forms/${editingId}` : '/api/forms'
const method = builderMode === 'edit' ? 'PATCH' : 'POST'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...formMeta, fields }),
})
setSaving(false)
if (res.ok) {
setBuilderOpen(false)
showToast(builderMode === 'edit' ? 'Form updated' : 'Form created — saved as draft')
loadForms()
} else {
showToast('Failed to save form', 'error')
}
}
// ── Status transitions ──────────────────────────────────────────────────
async function setStatus(id: string, status: string, successMsg: string) {
const res = await fetch(`/api/forms/${id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
if (res.ok) { showToast(successMsg); loadForms() }
else showToast('Update failed', 'error')
}
// ── Clone ────────────────────────────────────────────────────────────────
async function cloneForm(id: string) {
const res = await fetch(`/api/forms/${id}/clone`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
showToast(`Cloned as "${data.name}" — edit to customize`)
loadForms()
} else {
showToast('Clone failed', 'error')
}
}
// ── Preview ──────────────────────────────────────────────────────────────
function openPreview(fieldsToShow: FormFieldDef[], title: string, subtitle?: string) {
setPreviewFields(fieldsToShow)
setPreviewTitle(title || 'Untitled form')
setPreviewSubtitle(subtitle || '')
setPreviewAnswers({})
setPreviewOpen(true)
}
const activeForms = forms.filter(f => f.status !== 'ARCHIVED')
const archivedForms = forms.filter(f => f.status === 'ARCHIVED')
const displayedForms = tab === 'active' ? activeForms : archivedForms
function tabBtnStyle(isActive: boolean): React.CSSProperties {
return {
padding: '8px 4px', fontSize: '12px', fontWeight: isActive ? 500 : 400,
color: isActive ? '#534AB7' : '#888', background: 'none', border: 'none',
borderBottom: isActive ? '2px solid #534AB7' : '2px solid transparent',
cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', gap: '6px',
}
}
const needsOptions = ['SINGLE_CHOICE', 'MULTI_CHOICE'].includes(newField.type)
return (
<Layout title="Form builder">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>First build form builder</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Admin only design, edit, suspend, clone, and archive data collection forms</p>
</div>
<Btn onClick={openCreate}>+ New form</Btn>
</div>
<div style={{ display: 'flex', gap: '20px', borderBottom: '0.5px solid #eee' }}>
<button onClick={() => setTab('active')} style={tabBtnStyle(tab === 'active')}>
Active forms
<span style={{ fontSize: '10px', padding: '1px 6px', borderRadius: '9px', background: tab === 'active' ? '#EEEDFE' : '#f5f5f5', color: tab === 'active' ? '#3C3489' : '#999', fontWeight: 500 }}>{activeForms.length}</span>
</button>
<button onClick={() => setTab('archived')} style={tabBtnStyle(tab === 'archived')}>
Archived
<span style={{ fontSize: '10px', padding: '1px 6px', borderRadius: '9px', background: tab === 'archived' ? '#EEEDFE' : '#f5f5f5', color: tab === 'archived' ? '#3C3489' : '#999', fontWeight: 500 }}>{archivedForms.length}</span>
</button>
</div>
<Card style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : displayedForms.length === 0 ? (
tab === 'archived' ? (
<EmptyState
title="No archived forms"
message="Forms you archive are moved here, out of the way — their data and submission history stay intact and they can be restored anytime."
/>
) : (
<EmptyState
title="No forms yet"
message="Create your first data collection form. Production teams will fill it out on the shop floor to help establish quality standards."
action={{ label: '+ Build first form', onClick: openCreate }}
/>
)
) : (
<div style={{ overflowX: 'auto' }}>
<Table headers={tab === 'archived'
? ['Form name', 'Product', 'Fields', 'Submissions', 'Archived', 'Actions']
: ['Form name', 'Product', 'Fields', 'Submissions', 'Target', 'Status', 'Actions']}>
{displayedForms.map((f: any) => (
<tr key={f.id}>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}>
<div style={{ fontWeight: '500', fontSize: '12px' }}>{f.name}</div>
{f.clonedFromName && <div style={{ fontSize: '10px', color: '#aaa', marginTop: '1px' }}>Cloned from {f.clonedFromName}</div>}
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>{f.product || '—'}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px' }}>{f._count?.fields || 0}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', fontWeight: '500', color: f._count?.submissions >= f.minSubmissions ? '#1D9E75' : '#333' }}>{f._count?.submissions || 0}</td>
{tab === 'archived' ? (
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>
{f.archivedAt ? new Date(f.archivedAt).toLocaleDateString() : '—'}
</td>
) : (
<>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>{f.minSubmissions}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', whiteSpace: 'nowrap' }}>
<StatusDot status={f.status}/>
<Tag color={STATUS_COLOR[f.status]}>{f.status.replace('_', ' ')}</Tag>
</td>
</>
)}
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', minWidth: '220px' }}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<Btn size="sm" variant="ghost" onClick={() => openPreview(f.fields || [], f.name, f.product)}>Preview</Btn>
<Btn size="sm" variant="ghost" onClick={() => openEdit(f)}>Edit</Btn>
<Btn size="sm" variant="ghost" onClick={() => cloneForm(f.id)}>Clone</Btn>
{tab === 'archived' ? (
<Btn size="sm" onClick={() => setStatus(f.id, 'DRAFT', 'Form restored to draft')}>Restore</Btn>
) : (
<>
{f.status === 'DRAFT' && (
<Btn size="sm" onClick={() => setStatus(f.id, 'ACTIVE', 'Form published — production team can now fill it')}>Publish</Btn>
)}
{f.status === 'ACTIVE' && (
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'SUSPENDED', 'Form suspended')}>Suspend</Btn>
)}
{f.status === 'SUSPENDED' && (
<Btn size="sm" onClick={() => setStatus(f.id, 'ACTIVE', 'Form reactivated')}>Reactivate</Btn>
)}
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'ARCHIVED', 'Form archived')}>Archive</Btn>
</>
)}
</div>
</td>
</tr>
))}
</Table>
</div>
)}
</Card>
{/* Builder Modal (create / edit) */}
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title={builderMode === 'edit' ? `Edit form — ${formMeta.name || ''}` : 'Build new form'} width={640}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px' }}>Form details</div>
<Field label="Form name" required><Input value={formMeta.name} onChange={e => setFormMeta(m => ({ ...m, name: e.target.value }))} placeholder="First Build Data Sheet — Line 3"/></Field>
<Field label="Product / assembly"><Input value={formMeta.product} onChange={e => setFormMeta(m => ({ ...m, product: e.target.value }))} placeholder="Widget A — Rev 2"/></Field>
<Field label="Description"><Textarea value={formMeta.description} onChange={e => setFormMeta(m => ({ ...m, description: e.target.value }))} style={{ minHeight: '52px' }} placeholder="What data does this form collect?"/></Field>
<Field label="Min. submissions before QC review"><Input type="number" value={formMeta.minSubmissions} min={1} onChange={e => setFormMeta(m => ({ ...m, minSubmissions: parseInt(e.target.value) || 1 }))}/></Field>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px', marginTop: '6px' }}>Add field</div>
<Field label="Field label" required><Input value={newField.label} onChange={e => setNewField(f => ({ ...f, label: e.target.value }))} placeholder="e.g. Torque reading (Nm)"/></Field>
<Field label="Field type">
<Select value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value as FieldType }))}>
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</Field>
{needsOptions && (
<Field label="Options (one per line)">
<Textarea value={newField.options} onChange={e => setNewField(f => ({ ...f, options: e.target.value }))} style={{ minHeight: '60px' }} placeholder={'Pass\nFail\nRework'}/>
</Field>
)}
<Field label="Helper hint"><Input value={newField.hint} onChange={e => setNewField(f => ({ ...f, hint: e.target.value }))} placeholder="Instruction shown to filler"/></Field>
<label style={{ display: 'flex', alignItems: 'center', gap: '7px', fontSize: '12px', cursor: 'pointer', marginBottom: '10px' }}>
<input type="checkbox" checked={newField.required} onChange={e => setNewField(f => ({ ...f, required: e.target.checked }))} style={{ accentColor: '#534AB7' }}/>
Required field
</label>
<Btn variant="ghost" onClick={addField} style={{ width: '100%', justifyContent: 'center' }}>+ Add field</Btn>
</div>
<div>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px' }}>Fields ({fields.length})</div>
{fields.length === 0 ? (
<div style={{ border: '0.5px dashed #ddd', borderRadius: '8px', padding: '24px', textAlign: 'center', color: '#bbb', fontSize: '11px' }}>
Add fields from the left
</div>
) : fields.map((f, i) => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 10px', border: `0.5px solid ${f.required ? '#AFA9EC' : '#eee'}`,
borderRadius: '8px', marginBottom: '5px', background: f.required ? '#FAFAFE' : 'white'
}}>
<span style={{ fontSize: '10px', color: '#bbb', width: '16px', textAlign: 'center' }}>{i + 1}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', fontWeight: '500' }}>{f.label}{f.required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}</div>
<div style={{ fontSize: '10px', color: '#aaa' }}>{FIELD_TYPES.find(t => t.value === f.type)?.label}</div>
</div>
<button onClick={() => removeField(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ddd', fontSize: '16px' }} aria-label="Remove field">×</button>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '16px', paddingTop: '14px', borderTop: '0.5px solid #eee' }}>
<Btn
variant="ghost"
onClick={() => openPreview(fields, formMeta.name || 'Untitled form', formMeta.product)}
disabled={fields.length === 0}
>
Preview form
</Btn>
<div style={{ display: 'flex', gap: '8px' }}>
<Btn variant="ghost" onClick={() => setBuilderOpen(false)}>Cancel</Btn>
<Btn onClick={saveForm} disabled={saving || !formMeta.name}>
{saving ? 'Saving…' : builderMode === 'edit' ? 'Save changes' : 'Create form (saved as draft)'}
</Btn>
</div>
</div>
</Modal>
{/* Preview Modal */}
<Modal open={previewOpen} onClose={() => setPreviewOpen(false)} title={`Preview — ${previewTitle}`} width={480}>
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '14px' }}>
Preview mode interact freely. Nothing here is saved. This is exactly what the production team will see.
</div>
{previewSubtitle && <div style={{ fontSize: '11px', color: '#aaa', marginBottom: '12px' }}>{previewSubtitle}</div>}
{previewFields.length === 0 ? (
<EmptyState title="No fields yet" message="Add fields in the builder, then preview here."/>
) : (
<FormFieldList
fields={previewFields}
values={previewAnswers}
onChange={(fieldId, value) => setPreviewAnswers(a => ({ ...a, [fieldId]: value }))}
/>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '0.5px solid #eee' }}>
<Btn variant="ghost" onClick={() => setPreviewAnswers({})}>Reset answers</Btn>
<Btn variant="ghost" onClick={() => setPreviewOpen(false)} style={{ marginLeft: 'auto' }}>Close preview</Btn>
</div>
</Modal>
</Layout>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function AdminDashboard() {
return <ComingSoon title="Admin dashboard" blurb="Org-wide KPIs, open items, and audit-trail health at a glance." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function AdminSettings() {
return <ComingSoon title="Settings" blurb="Org details, email/SMTP, reference-number prefixes, and defaults." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function AdminUsers() {
return <ComingSoon title="Users" blurb="Create and manage user accounts, roles, and department access." />
}
+26
View File
@@ -0,0 +1,26 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { verifyPassword, createSession, SESSION_COOKIE, SESSION_EXPIRY_DAYS } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') return res.status(405).end()
const { email, password } = req.body || {}
if (!email || !password) return res.status(400).json({ error: 'Email and password are required' })
const user = await prisma.user.findUnique({ where: { email: String(email).trim().toLowerCase() } })
if (!user || !user.active) return res.status(401).json({ error: 'Invalid email or password' })
const ok = await verifyPassword(password, user.password)
if (!ok) return res.status(401).json({ error: 'Invalid email or password' })
const token = await createSession(user.id)
const maxAge = SESSION_EXPIRY_DAYS * 24 * 60 * 60
res.setHeader(
'Set-Cookie',
`${SESSION_COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`
)
const { password: _pw, ...safe } = user
return res.json({ user: safe })
}
+23
View File
@@ -0,0 +1,23 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { getSessionUser, SESSION_COOKIE } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const user = await getSessionUser(req)
if (!user) return res.status(401).json({ user: null })
const { password: _pw, ...safe } = user
return res.json({ user: safe })
}
if (req.method === 'DELETE') {
const token = req.cookies[SESSION_COOKIE]
if (token) {
await prisma.session.deleteMany({ where: { token } })
}
res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)
return res.json({ ok: true })
}
res.status(405).end()
}
+96
View File
@@ -0,0 +1,96 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
import { NCRSeverity, EscapeStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
const { id } = req.query as { id: string }
const escape = await prisma.qualityEscape.findUnique({
where: { id },
include: { shipment: true, capa: { select: { ref: true } } },
})
if (!escape) return res.status(404).json({ error: 'Escape not found' })
if (req.method === 'GET') {
return res.json({ data: escape })
}
if (req.method === 'PATCH') {
const before = { ...escape }
const { severity, status, resolution, category, standardItem, escalate, capaForm } = req.body as {
severity?: NCRSeverity
status?: EscapeStatus
resolution?: string
category?: string
standardItem?: string
escalate?: boolean
capaForm?: { title: string; priority: string; ownerId: string; dueDate: string }
}
const updateData: any = {}
if (severity !== undefined) updateData.severity = severity
if (status !== undefined) updateData.status = status
if (resolution !== undefined && category !== undefined) {
updateData.resolution = resolution
updateData.category = category
updateData.status = 'RESOLVED'
updateData.resolvedAt = new Date()
}
if (standardItem !== undefined) {
updateData.standardItemAdded = standardItem || '—'
if (standardItem) {
const maxOrder = await prisma.shippingStandardItem.count()
await prisma.shippingStandardItem.create({
data: { text: standardItem, source: escape.ref, order: maxOrder },
})
}
}
let capaRef: string | undefined
if (escalate && capaForm) {
const count = await prisma.cAPA.count()
capaRef = generateRef('CAPA', count)
const capa = await prisma.cAPA.create({
data: {
ref: capaRef, title: capaForm.title,
description: `Client-reported quality escape ${escape.ref} (${escape.shipment.ref}): ${escape.description}`,
priority: capaForm.priority as any,
ownerId: capaForm.ownerId, raisedById: user.id,
dueDate: new Date(capaForm.dueDate),
},
})
await prisma.cAPAEvent.create({
data: { capaId: capa.id, event: 'CAPA raised', note: `Escalated from quality escape ${escape.ref} by ${user.name}` }
})
updateData.status = 'ESCALATED'
updateData.capaId = capa.id
}
const updated = await prisma.qualityEscape.update({
where: { id },
data: updateData,
include: { shipment: true, capa: { select: { ref: true } } },
})
// File to resolutions library
if (resolution !== undefined && category !== undefined) {
await prisma.resolution.create({
data: {
title: escape.description.length > 80 ? escape.description.slice(0, 80) + '…' : escape.description,
category, resolution, linkedRef: escape.ref,
}
})
}
await logAction(user.id, 'UPDATE', 'QualityEscape', id, before, updated)
return res.json({ data: updated })
}
res.status(405).end()
}
+47
View File
@@ -0,0 +1,47 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef, SHIPMENT_SEND_ROLES } from '@/lib/auth'
import { EscapeStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const { status } = req.query
const where: any = {}
if (status) where.status = status as EscapeStatus
const escapes = await prisma.qualityEscape.findMany({
where,
include: { shipment: true, capa: { select: { ref: true } } },
orderBy: { createdAt: 'desc' },
})
return res.json({ data: escapes })
}
if (req.method === 'POST') {
// Report access: Production leads, Logistics lead, Admin
const gated = await requireAuth(req, res, SHIPMENT_SEND_ROLES)
if (!gated) return
const { shipmentId, description, contact } = req.body
if (!shipmentId || !description) return res.status(400).json({ error: 'shipmentId and description required' })
const shipment = await prisma.shipment.findUnique({ where: { id: shipmentId } })
if (!shipment) return res.status(404).json({ error: 'Shipment not found' })
const count = await prisma.qualityEscape.count()
const ref = generateRef('ESC', count)
const escape = await prisma.qualityEscape.create({
data: { ref, shipmentId, description, contact },
include: { shipment: true },
})
await logAction(user.id, 'CREATE', 'QualityEscape', escape.id, null, { ref, shipmentRef: shipment.ref })
return res.status(201).json({ data: escape })
}
res.status(405).end()
}
+113
View File
@@ -0,0 +1,113 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { serializeOptions, withParsedFields } from '@/lib/forms'
import { FormStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
const { id } = req.query as { id: string }
if (req.method === 'GET') {
const form = await prisma.buildForm.findUnique({
where: { id },
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
})
if (!form) return res.status(404).json({ error: 'Not found' })
return res.json({ data: withParsedFields(form) })
}
if (req.method === 'PATCH') {
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
const before = await prisma.buildForm.findUnique({ where: { id }, include: { fields: true } })
if (!before) return res.status(404).json({ error: 'Not found' })
const { fields, status, ...rest } = req.body as {
fields?: Array<{ id?: string; label: string; type: string; hint?: string; options?: string[]; required?: boolean; trackStd?: boolean }>
status?: FormStatus
name?: string; product?: string; description?: string; minSubmissions?: number; publishedAt?: string
}
// ── Full field-level edit (name/product/description/minSubmissions + fields array) ──
if (fields !== undefined) {
const existingIds = new Set(before.fields.map(f => f.id))
const keepIds = new Set(fields.filter(f => f.id && existingIds.has(f.id)).map(f => f.id as string))
const toDeleteIds = before.fields.filter(f => !keepIds.has(f.id)).map(f => f.id)
const ops: any[] = []
if (toDeleteIds.length > 0) {
ops.push(prisma.formField.deleteMany({ where: { id: { in: toDeleteIds } } }))
}
fields.forEach((f, i) => {
const data = {
label: f.label,
type: f.type as any,
hint: f.hint || null,
options: serializeOptions(f.options),
required: !!f.required,
trackStd: f.trackStd !== false,
order: i,
}
if (f.id && existingIds.has(f.id)) {
ops.push(prisma.formField.update({ where: { id: f.id }, data }))
} else {
ops.push(prisma.formField.create({ data: { ...data, formId: id } }))
}
})
const formUpdate: any = {}
if (rest.name !== undefined) formUpdate.name = rest.name
if (rest.product !== undefined) formUpdate.product = rest.product
if (rest.description !== undefined) formUpdate.description = rest.description
if (rest.minSubmissions !== undefined) formUpdate.minSubmissions = rest.minSubmissions
if (Object.keys(formUpdate).length > 0) {
ops.push(prisma.buildForm.update({ where: { id }, data: formUpdate }))
}
await prisma.$transaction(ops)
const updated = await prisma.buildForm.findUnique({
where: { id },
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
})
await logAction(user.id, 'UPDATE', 'BuildForm', id, before, updated)
return res.json({ data: withParsedFields(updated) })
}
// ── Status transitions (publish / suspend / reactivate / archive / restore) ──
const updateData: any = { ...rest }
if (status !== undefined) {
updateData.status = status
if (status === 'ACTIVE' && before.status === 'DRAFT') updateData.publishedAt = new Date()
if (status === 'ACTIVE' && before.status === 'SUSPENDED') updateData.suspendedAt = null
if (status === 'SUSPENDED') updateData.suspendedAt = new Date()
if (status === 'ARCHIVED') updateData.archivedAt = new Date()
if (status === 'DRAFT' && before.status === 'ARCHIVED') updateData.archivedAt = null
}
const updated = await prisma.buildForm.update({
where: { id },
data: updateData,
include: { fields: { orderBy: { order: 'asc' } }, _count: { select: { submissions: true, fields: true } } },
})
await logAction(user.id, 'UPDATE', 'BuildForm', id, { status: before.status }, { status: updated.status })
return res.json({ data: withParsedFields(updated) })
}
if (req.method === 'DELETE') {
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
await prisma.buildForm.delete({ where: { id } })
await logAction(user.id, 'DELETE', 'BuildForm', id, null, null)
return res.json({ ok: true })
}
res.status(405).end()
}
+50
View File
@@ -0,0 +1,50 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { withParsedFields } from '@/lib/forms'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN'])
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { id } = req.query as { id: string }
const source = await prisma.buildForm.findUnique({
where: { id },
include: { fields: { orderBy: { order: 'asc' } } },
})
if (!source) return res.status(404).json({ error: 'Form not found' })
const clone = await prisma.buildForm.create({
data: {
name: `${source.name} (Copy)`,
product: source.product,
description: source.description,
minSubmissions: source.minSubmissions,
status: 'DRAFT',
createdById: user.id,
clonedFromId: source.id,
clonedFromName: source.name,
fields: {
create: source.fields.map(f => ({
label: f.label,
type: f.type,
hint: f.hint,
options: f.options,
required: f.required,
trackStd: f.trackStd,
order: f.order,
})),
},
},
include: {
fields: { orderBy: { order: 'asc' } },
_count: { select: { submissions: true, fields: true } },
},
})
await logAction(user.id, 'CREATE', 'BuildForm', clone.id, null, { clonedFrom: source.id, name: clone.name })
return res.status(201).json({ data: withParsedFields(clone) })
}
+71
View File
@@ -0,0 +1,71 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { serializeOptions, withParsedFields } from '@/lib/forms'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const { status } = req.query
const where: any = {}
if (status) where.status = String(status)
const forms = await prisma.buildForm.findMany({
where,
include: {
fields: { orderBy: { order: 'asc' } },
_count: { select: { submissions: true, fields: true } },
},
orderBy: { createdAt: 'desc' },
})
return res.json({ data: forms.map(f => withParsedFields(f)) })
}
if (req.method === 'POST') {
if (user.role !== 'ADMIN') return res.status(403).json({ error: 'Admin only' })
const { name, product, description, minSubmissions, fields } = req.body as {
name?: string
product?: string
description?: string
minSubmissions?: number
fields?: Array<{ label: string; type: string; hint?: string; options?: string[]; required?: boolean; trackStd?: boolean }>
}
if (!name) return res.status(400).json({ error: 'Form name is required' })
const form = await prisma.buildForm.create({
data: {
name,
product: product || null,
description: description || null,
minSubmissions: minSubmissions ?? 10,
status: 'DRAFT',
createdById: user.id,
fields: {
create: (fields || []).map((f, i) => ({
label: f.label,
type: f.type,
hint: f.hint || null,
options: serializeOptions(f.options),
required: !!f.required,
trackStd: f.trackStd !== false,
order: i,
})),
},
},
include: {
fields: { orderBy: { order: 'asc' } },
_count: { select: { submissions: true, fields: true } },
},
})
await logAction(user.id, 'CREATE', 'BuildForm', form.id, null, { name })
return res.status(201).json({ data: withParsedFields(form) })
}
res.status(405).end()
}
+78
View File
@@ -0,0 +1,78 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { notifySolutionConfirmed } from '@/lib/email'
import { NCRSeverity, NCRStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
const { id } = req.query as { id: string }
const ncr = await prisma.nCR.findUnique({
where: { id },
include: { raisedBy: { select: { id: true, name: true, email: true } }, capa: { select: { ref: true } } },
})
if (!ncr) return res.status(404).json({ error: 'NCR not found' })
if (req.method === 'GET') {
return res.json({ data: ncr })
}
if (req.method === 'PATCH') {
const before = { ...ncr }
const { severity, status, resolution, category, confirmNotify } = req.body as {
severity?: NCRSeverity
status?: NCRStatus
resolution?: string
category?: string
confirmNotify?: boolean
}
const updateData: any = {}
if (severity !== undefined) updateData.severity = severity
if (status !== undefined) updateData.status = status
if (resolution !== undefined && category !== undefined) {
// Resolving — requires both resolution notes and a category, files to library
updateData.resolution = resolution
updateData.category = category
updateData.status = 'RESOLVED'
updateData.resolvedAt = new Date()
}
if (confirmNotify) {
updateData.notified = true
updateData.notifiedAt = new Date()
}
const updated = await prisma.nCR.update({
where: { id },
data: updateData,
include: { raisedBy: { select: { id: true, name: true, email: true } }, capa: { select: { ref: true } } },
})
// File to resolutions library
if (resolution !== undefined && category !== undefined) {
await prisma.resolution.create({
data: {
title: ncr.description.length > 80 ? ncr.description.slice(0, 80) + '…' : ncr.description,
category, resolution, linkedRef: ncr.ref,
}
})
}
// Confirm-and-notify: emails/notifies whoever raised the NCR
if (confirmNotify && ncr.raisedBy) {
await notifySolutionConfirmed(ncr.ref, ncr.description, ncr.raisedBy.email, ncr.raisedBy.id)
}
await logAction(user.id, 'UPDATE', 'NCR', id, before, updated)
return res.json({ data: updated })
}
res.status(405).end()
}
+48
View File
@@ -0,0 +1,48 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { id } = req.query as { id: string }
const { title, priority, ownerId, dueDate } = req.body
if (!title || !ownerId || !dueDate) {
return res.status(400).json({ error: 'title, ownerId, dueDate required' })
}
const ncr = await prisma.nCR.findUnique({ where: { id } })
if (!ncr) return res.status(404).json({ error: 'NCR not found' })
const count = await prisma.cAPA.count()
const ref = generateRef('CAPA', count)
const capa = await prisma.cAPA.create({
data: {
ref, title,
description: `Escalated from ${ncr.ref}: ${ncr.description}`,
priority: priority || 'MEDIUM',
ownerId, raisedById: user.id,
dueDate: new Date(dueDate),
},
include: { owner: { select: { id: true, name: true, email: true } } },
})
await prisma.cAPAEvent.create({
data: { capaId: capa.id, event: 'CAPA raised', note: `Escalated from ${ncr.ref} by ${user.name}` }
})
const updatedNcr = await prisma.nCR.update({
where: { id },
data: { status: 'ESCALATED', capaId: capa.id },
include: { raisedBy: { select: { name: true } }, capa: { select: { ref: true } } },
})
await logAction(user.id, 'UPDATE', 'NCR', id, { status: ncr.status }, { status: 'ESCALATED', capaRef: capa.ref })
await logAction(user.id, 'CREATE', 'CAPA', capa.id, null, { ref: capa.ref, fromNcr: ncr.ref })
return res.status(201).json({ data: { ncr: updatedNcr, capa } })
}
+41
View File
@@ -0,0 +1,41 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
import { NCRStatus } from '@/types'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const { status } = req.query
const where: any = {}
if (status) where.status = status as NCRStatus
const ncrs = await prisma.nCR.findMany({
where,
include: { raisedBy: { select: { name: true } }, capa: { select: { ref: true } } },
orderBy: { createdAt: 'desc' },
})
return res.json({ data: ncrs })
}
if (req.method === 'POST') {
// Any authenticated user can report an issue (production intake)
const { description, source } = req.body
if (!description) return res.status(400).json({ error: 'description required' })
const count = await prisma.nCR.count()
const ref = generateRef('NCR', count)
const ncr = await prisma.nCR.create({
data: { ref, description, source, raisedById: user.id },
include: { raisedBy: { select: { name: true } } },
})
await logAction(user.id, 'CREATE', 'NCR', ncr.id, null, { ref, description })
return res.status(201).json({ data: ncr })
}
res.status(405).end()
}
+30
View File
@@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const [data, unread] = await Promise.all([
prisma.notification.findMany({
where: { userId: user.id },
orderBy: { createdAt: 'desc' },
take: 20,
}),
prisma.notification.count({ where: { userId: user.id, read: false } }),
])
return res.json({ data, unread })
}
if (req.method === 'PATCH') {
await prisma.notification.updateMany({
where: { userId: user.id, read: false },
data: { read: true },
})
return res.json({ ok: true })
}
res.status(405).end()
}
+42
View File
@@ -0,0 +1,42 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
const STOPWORDS = new Set(['from', 'with', 'during', 'this', 'that', 'were', 'found', 'units', 'batch', 'final', 'client', 'reported'])
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method !== 'GET') return res.status(405).end()
const { category, search, similarTo } = req.query
// Similar-fix matching: given a description, find resolutions sharing keywords
if (similarTo) {
const words = (similarTo as string)
.toLowerCase()
.match(/\b\w{4,}\b/g)?.filter(w => !STOPWORDS.has(w)) || []
if (words.length === 0) return res.json({ data: [] })
const all = await prisma.resolution.findMany({ orderBy: { createdAt: 'desc' } })
const matches = all.filter(r => {
const text = (r.title + ' ' + r.resolution).toLowerCase()
return words.some(w => text.includes(w))
})
return res.json({ data: matches.slice(0, 3) })
}
const where: any = {}
if (category) where.category = category as string
if (search) {
where.OR = [
{ title: { contains: search as string} },
{ resolution: { contains: search as string} },
{ category: { contains: search as string} },
]
}
const resolutions = await prisma.resolution.findMany({ where, orderBy: { createdAt: 'desc' } })
return res.json({ data: resolutions })
}
+35
View File
@@ -0,0 +1,35 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, SHIPMENT_SEND_ROLES } from '@/lib/auth'
import { sendEmail, emailTemplate } from '@/lib/email'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, SHIPMENT_SEND_ROLES)
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { id } = req.query as { id: string }
const { clientEmail, subject, message } = req.body
if (!clientEmail || !subject || !message) {
return res.status(400).json({ error: 'clientEmail, subject, message required' })
}
const shipment = await prisma.shipment.findUnique({ where: { id }, include: { items: true } })
if (!shipment) return res.status(404).json({ error: 'Shipment not found' })
const itemsHtml = shipment.items.filter(i => i.included).map(i => `<li>${i.label}</li>`).join('')
await sendEmail(clientEmail, subject, emailTemplate('Quality Release Package', `
<p>${message.replace(/\n/g, '<br>')}</p>
<ul>${itemsHtml}</ul>
`))
const updated = await prisma.shipment.update({
where: { id },
data: { sentAt: new Date(), sentTo: clientEmail },
})
await logAction(user.id, 'UPDATE', 'Shipment', id, { sentAt: null }, { sentAt: updated.sentAt, sentTo: clientEmail })
return res.json({ data: updated })
}
+45
View File
@@ -0,0 +1,45 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction, generateRef } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const shipments = await prisma.shipment.findMany({
include: { items: { orderBy: { order: 'asc' } }, _count: { select: { escapes: true } } },
orderBy: { createdAt: 'desc' },
})
return res.json({ data: shipments })
}
if (req.method === 'POST') {
const { product, batch, client, clientEmail, shippedAt, items } = req.body
if (!product || !batch || !client || !shippedAt) {
return res.status(400).json({ error: 'product, batch, client, shippedAt required' })
}
const count = await prisma.shipment.count()
const ref = generateRef('REL', count)
const shipment = await prisma.shipment.create({
data: {
ref, product, batch, client, clientEmail,
shippedAt: new Date(shippedAt),
createdById: user.id,
items: {
create: (items || []).map((it: any, i: number) => ({
label: it.label, type: it.type || 'OTHER', included: it.included !== false, order: i,
}))
}
},
include: { items: { orderBy: { order: 'asc' } } },
})
await logAction(user.id, 'CREATE', 'Shipment', shipment.id, null, { ref, product, batch })
return res.status(201).json({ data: shipment })
}
res.status(405).end()
}
+40
View File
@@ -0,0 +1,40 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
// Auto-suggests release items tied to a product: recent first-build form
// submission counts for that product, plus recently resolved NCR fixes.
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method !== 'GET') return res.status(405).end()
const { product } = req.query as { product?: string }
const items: { label: string; type: string; included: boolean }[] = []
if (product) {
const forms = await prisma.buildForm.findMany({
where: { product: { contains: product} },
include: { _count: { select: { submissions: true } } },
})
for (const f of forms) {
if (f._count.submissions > 0) {
items.push({ label: `${f.name}${f._count.submissions} submissions`, type: 'FORM_DATA', included: true })
}
}
}
// Recently resolved NCRs (last 90 days) as candidate fixes to include
const since = new Date()
since.setDate(since.getDate() - 90)
const recentNcrs = await prisma.nCR.findMany({
where: { status: 'RESOLVED', resolvedAt: { gte: since } },
orderBy: { resolvedAt: 'desc' },
take: 5,
})
for (const n of recentNcrs) {
items.push({ label: `${n.ref} fix — ${n.category || 'resolved'}`, type: 'NCR_FIX', included: false })
}
return res.json({ data: items })
}
+29
View File
@@ -0,0 +1,29 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method === 'GET') {
const items = await prisma.shippingStandardItem.findMany({ orderBy: { order: 'asc' } })
return res.json({ data: items })
}
if (req.method === 'POST') {
if (user.role !== 'ADMIN' && user.role !== 'QC') return res.status(403).json({ error: 'Admin/QC only' })
const { text, source } = req.body
if (!text) return res.status(400).json({ error: 'text required' })
const maxOrder = await prisma.shippingStandardItem.count()
const item = await prisma.shippingStandardItem.create({
data: { text, source: source || 'Baseline', order: maxOrder },
})
await logAction(user.id, 'CREATE', 'ShippingStandardItem', item.id, null, { text })
return res.status(201).json({ data: item })
}
res.status(405).end()
}
+39
View File
@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { notifyFormReviewReady } from '@/lib/email'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res)
if (!user) return
if (req.method !== 'POST') return res.status(405).end()
const { formId, data } = req.body || {}
if (!formId || data === undefined) {
return res.status(400).json({ error: 'formId and data are required' })
}
const form = await prisma.buildForm.findUnique({ where: { id: formId } })
if (!form) return res.status(404).json({ error: 'Form not found' })
if (form.status !== 'ACTIVE') return res.status(400).json({ error: 'Form is not accepting submissions' })
await prisma.formSubmission.create({
data: { formId, submittedBy: user.id, data: JSON.stringify(data) },
})
const submissionCount = await prisma.formSubmission.count({ where: { formId } })
await logAction(user.id, 'CREATE', 'FormSubmission', formId, null, { submissionCount })
// First time the target is reached: flip to REVIEW_READY and notify admins.
if (submissionCount === form.minSubmissions) {
await prisma.buildForm.update({ where: { id: formId }, data: { status: 'REVIEW_READY' } })
const admins = await prisma.user.findMany({ where: { role: 'ADMIN', active: true } })
for (const admin of admins) {
await notifyFormReviewReady(form.name, submissionCount, admin.email, admin.id)
}
}
return res.status(201).json({ submissionCount })
}
+19
View File
@@ -0,0 +1,19 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth } from '@/lib/auth'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const user = await requireAuth(req, res, ['ADMIN', 'QC'])
if (!user) return
if (req.method === 'GET') {
const users = await prisma.user.findMany({
where: { active: true },
select: { id: true, name: true, email: true, role: true, department: true },
orderBy: { name: 'asc' },
})
return res.json({ data: users })
}
res.status(405).end()
}
+253
View File
@@ -0,0 +1,253 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Tag, Field, Textarea, showToast } from '@/components/ui'
import { FormFieldList } from '@/components/forms/FieldRenderer'
import { useApp } from '@/lib/context'
const ISSUE_AREAS = [
{ value: 'Production', icon: 'M9 22V12h6v10M3 9l9-7 9 7' },
{ value: 'Goods-In', icon: 'M3 3h18v18H3zM3 9h18M9 21V9' },
{ value: 'Warehouse', icon: 'M21 8V7l-3-4H6L3 7v1m18 0v12H3V8m18 0H3m9 4v4' },
{ value: 'QA Lab', icon: 'M9 2v6l-5 8a2 2 0 002 3h12a2 2 0 002-3l-5-8V2M8.5 13h7' },
{ value: 'Shipping', icon: 'M16 16V8a2 2 0 00-2-2H4a2 2 0 00-2 2v8a2 2 0 002 2h10a2 2 0 002-2zm0-3h2.5a2 2 0 011.6.8l1.4 1.87a2 2 0 01.4 1.2V16a1 1 0 01-1 1h-3z' },
{ value: 'Other', icon: 'M12 5v.01M12 12v.01M12 19v.01' },
]
export default function FillPage() {
const { user } = useApp()
const [forms, setForms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<any>(null)
const [answers, setAnswers] = useState<Record<string, any>>({})
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
// Report an issue
const [pageTab, setPageTab] = useState<'forms' | 'report'>('forms')
const [reportDesc, setReportDesc] = useState('')
const [reportArea, setReportArea] = useState('')
const [reportSubmitting, setReportSubmitting] = useState(false)
const [reportRef, setReportRef] = useState('')
useEffect(() => { loadForms() }, [])
async function loadForms() {
setLoading(true)
const res = await fetch('/api/forms?status=ACTIVE')
if (res.ok) {
const { data } = await res.json()
setForms(data || [])
}
setLoading(false)
}
function selectForm(form: any) {
setSelected(form)
setAnswers({})
setSubmitted(false)
}
async function submitForm() {
const required = selected.fields.filter((f: any) => f.required)
for (const f of required) {
if (!answers[f.id]) {
showToast(`"${f.label}" is required`, 'error'); return
}
}
setSubmitting(true)
const res = await fetch('/api/submissions', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ formId: selected.id, data: answers }),
})
setSubmitting(false)
if (res.ok) {
const { submissionCount } = await res.json()
setSubmitted(true)
showToast(`Submitted — you are contributor #${submissionCount}`)
} else {
showToast('Submission failed', 'error')
}
}
function setAnswer(fieldId: string, value: any) {
setAnswers(a => ({ ...a, [fieldId]: value }))
}
async function submitReport() {
if (!reportDesc.trim()) { showToast('Please describe what you noticed', 'error'); return }
if (!reportArea) { showToast('Please pick where', 'error'); return }
setReportSubmitting(true)
const res = await fetch('/api/ncrs', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: reportDesc, source: reportArea }),
})
setReportSubmitting(false)
if (res.ok) {
const { data } = await res.json()
setReportRef(data.ref)
setReportDesc('')
setReportArea('')
} else {
showToast('Could not submit report', 'error')
}
}
function resetReport() {
setReportRef('')
setReportDesc('')
setReportArea('')
}
return (
<Layout title="My forms">
{!selected ? (
<>
<div style={{ display: 'flex', gap: '20px', borderBottom: '0.5px solid #eee', marginBottom: '16px' }}>
{(['forms', 'report'] as const).map(t => (
<button key={t} onClick={() => setPageTab(t)} style={{
padding: '8px 4px', fontSize: '12px', fontWeight: pageTab === t ? 500 : 400,
color: pageTab === t ? '#534AB7' : '#888', background: 'none', border: 'none',
borderBottom: pageTab === t ? '2px solid #534AB7' : '2px solid transparent',
cursor: 'pointer', fontFamily: 'inherit',
}}>{t === 'forms' ? 'First build forms' : 'Report an issue'}</button>
))}
</div>
{pageTab === 'forms' ? (
<>
<div style={{ marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>First build forms</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Select a form to fill out your data helps set quality standards</p>
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '40px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : forms.length === 0 ? (
<EmptyState
title="No active forms"
message="Your admin hasn't published any data collection forms yet. Check back soon."
/>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '12px' }}>
{forms.map((form: any) => (
<div key={form.id} onClick={() => selectForm(form)} style={{
background: 'white', border: '0.5px solid #eee', borderRadius: '12px',
padding: '16px', cursor: 'pointer', transition: 'border-color 0.1s'
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: '8px' }}>
<div style={{ width: '32px', height: '32px', borderRadius: '8px', background: '#EEEDFE', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#534AB7" strokeWidth="2">
<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
</svg>
</div>
<Tag color="green">Active</Tag>
</div>
<div style={{ fontSize: '13px', fontWeight: '500', marginBottom: '4px' }}>{form.name}</div>
{form.product && <div style={{ fontSize: '11px', color: '#aaa', marginBottom: '8px' }}>{form.product}</div>}
<div style={{ fontSize: '11px', color: '#888' }}>{form.fields?.length || 0} fields · {form._count?.submissions || 0} submissions so far</div>
<div style={{ marginTop: '12px' }}>
<Btn size="sm" style={{ width: '100%', justifyContent: 'center' }}>Fill out </Btn>
</div>
</div>
))}
</div>
)}
</>
) : (
<div style={{ maxWidth: '420px', margin: '0 auto', textAlign: 'center', padding: '8px 0' }}>
{reportRef ? (
<Card style={{ padding: '32px' }}>
<div style={{ width: '48px', height: '48px', borderRadius: '50%', background: '#EAF3DE', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1D9E75" strokeWidth="2.5"><path d="M5 13l4 4L19 7"/></svg>
</div>
<div style={{ fontSize: '15px', fontWeight: '500', marginBottom: '6px' }}>Reported thanks</div>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '20px' }}>
QC will review <span style={{ color: '#534AB7', fontWeight: '500' }}>{reportRef}</span>. You'll be notified automatically once a fix is confirmed — nothing else to do.
</div>
<Btn onClick={resetReport}>Report another</Btn>
</Card>
) : (
<Card>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#bbb" strokeWidth="2" style={{ margin: '0 auto 8px', display: 'block' }}><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>
<h3 style={{ fontSize: '16px', fontWeight: '500', marginBottom: '6px' }}>Report an issue</h3>
<p style={{ fontSize: '12px', color: '#888', marginBottom: '18px' }}>Notice something off? Let QC know — takes 30 seconds. No quality background needed.</p>
<Field label="What did you notice?">
<Textarea value={reportDesc} onChange={e => setReportDesc(e.target.value)} placeholder="e.g. Found 3 units with loose end caps during inspection on Line 2" style={{ textAlign: 'left' }}/>
</Field>
<div style={{ textAlign: 'left', marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>Where?</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: '6px' }}>
{ISSUE_AREAS.map(a => (
<div key={a.value} onClick={() => setReportArea(a.value)} style={{
border: `0.5px solid ${reportArea === a.value ? '#534AB7' : '#ddd'}`,
background: reportArea === a.value ? '#EEEDFE' : 'transparent',
color: reportArea === a.value ? '#534AB7' : '#666',
borderRadius: '8px', padding: '9px 6px', textAlign: 'center', cursor: 'pointer', fontSize: '11px',
fontWeight: reportArea === a.value ? 500 : 400,
}}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ display: 'block', margin: '0 auto 4px' }}>
<path strokeLinecap="round" strokeLinejoin="round" d={a.icon}/>
</svg>
{a.value}
</div>
))}
</div>
</div>
<Btn onClick={submitReport} disabled={reportSubmitting} style={{ width: '100%', justifyContent: 'center' }}>
{reportSubmitting ? 'Submitting' : 'Submit report'}
</Btn>
</Card>
)}
</div>
)}
</>
) : submitted ? (
<Card style={{ maxWidth: '480px', margin: '40px auto', textAlign: 'center', padding: '32px' }}>
<div style={{ width: '48px', height: '48px', borderRadius: '50%', background: '#EAF3DE', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#1D9E75" strokeWidth="2.5"><path d="M5 13l4 4L19 7"/></svg>
</div>
<div style={{ fontSize: '16px', fontWeight: '500', marginBottom: '6px' }}>Submitted</div>
<div style={{ fontSize: '12px', color: '#888', marginBottom: '20px' }}>Your data has been recorded and will contribute to setting quality standards for {selected.product || selected.name}.</div>
<div style={{ display: 'flex', gap: '8px', justifyContent: 'center' }}>
<Btn variant="ghost" onClick={() => { setSubmitted(false); setAnswers({}) }}>Fill again</Btn>
<Btn onClick={() => setSelected(null)}>Back to forms</Btn>
</div>
</Card>
) : (
<div style={{ maxWidth: '560px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '20px' }}>
<button onClick={() => setSelected(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', padding: '4px', display: 'flex' }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5m7-7l-7 7 7 7"/></svg>
</button>
<div>
<h2 style={{ fontSize: '15px', fontWeight: '500', margin: 0 }}>{selected.name}</h2>
{selected.product && <div style={{ fontSize: '11px', color: '#aaa' }}>{selected.product}</div>}
</div>
</div>
<Card>
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '16px' }}>
Fill in each field from your direct observation. Your submission is anonymous to QC — it's just your data point.
</div>
<FormFieldList
fields={(selected.fields || []).slice().sort((a: any, b: any) => a.order - b.order)}
values={answers}
onChange={setAnswer}
/>
<div style={{ display: 'flex', gap: '8px', marginTop: '20px', paddingTop: '16px', borderTop: '0.5px solid #eee' }}>
<Btn variant="ghost" onClick={() => { setAnswers({}); }}>Clear</Btn>
<Btn onClick={submitForm} disabled={submitting} style={{ flex: 1, justifyContent: 'center' }}>
{submitting ? 'Submitting…' : 'Submit build data'}
</Btn>
</div>
</Card>
</div>
)}
</Layout>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function MySubmissions() {
return <ComingSoon title="My submissions" blurb="Your submitted first-build form data." />
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, FormEvent } from 'react'
import { useRouter } from 'next/router'
import { useApp } from '@/lib/context'
import Head from 'next/head'
export default function LoginPage() {
const { login, user, loading } = useApp()
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
if (!loading && user) {
const dest = { ADMIN: '/admin', QC: '/qc', PRODUCTION: '/fill', PRODUCTION_LEAD: '/fill', LOGISTICS_LEAD: '/qc/shipments', MANAGEMENT: '/management' }
router.replace(dest[user.role] || '/qc')
return null
}
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setError('')
const err = await login(email, password)
if (err) { setError(err); setBusy(false) }
}
return (
<>
<Head><title>Sign in V11 QMS</title></Head>
<div style={{
minHeight: '100vh', background: '#F8F7FD',
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '24px'
}}>
<div style={{ width: '100%', maxWidth: '380px' }}>
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{
width: '44px', height: '44px', borderRadius: '12px',
background: '#534AB7', display: 'inline-flex', alignItems: 'center',
justifyContent: 'center', marginBottom: '12px'
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
</div>
<h1 style={{ fontSize: '20px', fontWeight: '600', color: '#1a1a1a', margin: '0 0 4px' }}>
V11 Enterprise QMS
</h1>
<p style={{ fontSize: '13px', color: '#888', margin: 0 }}>Sign in to your account</p>
</div>
<form onSubmit={submit} style={{
background: 'white', borderRadius: '12px',
border: '0.5px solid #e8e8e8', padding: '24px'
}}>
{error && (
<div style={{
background: '#FCEBEB', color: '#791F1F', border: '0.5px solid #F09595',
borderRadius: '8px', padding: '9px 12px', fontSize: '12px', marginBottom: '16px'
}}>
{error}
</div>
)}
<div style={{ marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>
Email address
</label>
<input
type="email" required value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@company.com"
style={{
width: '100%', padding: '9px 11px', fontSize: '13px',
border: '0.5px solid #ddd', borderRadius: '8px',
outline: 'none', fontFamily: 'inherit', background: 'transparent'
}}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>
Password
</label>
<input
type="password" required value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
style={{
width: '100%', padding: '9px 11px', fontSize: '13px',
border: '0.5px solid #ddd', borderRadius: '8px',
outline: 'none', fontFamily: 'inherit', background: 'transparent'
}}
/>
</div>
<button
type="submit" disabled={busy}
style={{
width: '100%', padding: '10px', fontSize: '13px', fontWeight: '500',
background: busy ? '#AFA9EC' : '#534AB7', color: 'white',
border: 'none', borderRadius: '8px', cursor: busy ? 'not-allowed' : 'pointer',
fontFamily: 'inherit', transition: 'background 0.1s'
}}
>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p style={{ textAlign: 'center', fontSize: '11px', color: '#aaa', marginTop: '16px' }}>
No account? Ask your system administrator.
</p>
</div>
</div>
</>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function MgmtDashboard() {
return <ComingSoon title="Management dashboard" blurb="Read-only executive view of quality performance." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function MgmtReports() {
return <ComingSoon title="Reports" blurb="Read-only quality reports and exports." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCAudits() {
return <ComingSoon title="Audits" blurb="Internal, supplier, and external audits with findings management." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCCapas() {
return <ComingSoon title="CAPA" blurb="Corrective and preventive actions with timeline and progress tracking." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCDocuments() {
return <ComingSoon title="Documents" blurb="Controlled documents: SOPs, policies, forms, and work instructions." />
}
+330
View File
@@ -0,0 +1,330 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Modal, Field, Input, Select, Textarea, showToast, Tag, StatusDot, Table, StatCard } from '@/components/ui'
import { useApp } from '@/lib/context'
import { SHIPMENT_SEND_ROLES } from '@/lib/auth'
const SEV_COLOR: Record<string, string> = { OBSERVATION: 'gray', MINOR: 'amber', MAJOR: 'red' }
const STATUS_COLOR: Record<string, string> = { OPEN: 'blue', INVESTIGATING: 'amber', ESCALATED: 'red', RESOLVED: 'green' }
const CAT_COLOR: Record<string, string> = { Sealing: 'purple', Packaging: 'amber', Calibration: 'gray', Supplier: 'red', Process: 'green', Training: 'gray', Other: 'gray' }
const PRIORITY_FROM_SEVERITY: Record<string, string> = { OBSERVATION: 'LOW', MINOR: 'MEDIUM', MAJOR: 'HIGH' }
const CATEGORIES = ['Sealing', 'Packaging', 'Calibration', 'Supplier', 'Process', 'Training', 'Other']
export default function EscapesPage() {
const { user } = useApp()
const [escapes, setEscapes] = useState<any[]>([])
const [shipments, setShipments] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<any>(null)
const [similar, setSimilar] = useState<any[]>([])
const [resNote, setResNote] = useState('')
const [resCategory, setResCategory] = useState('')
const [stdText, setStdText] = useState('')
const [users, setUsers] = useState<any[]>([])
// Report modal
const [reportOpen, setReportOpen] = useState(false)
const [reportForm, setReportForm] = useState({ shipmentId: '', contact: '', description: '' })
// Escalate modal
const [escalateOpen, setEscalateOpen] = useState(false)
const [escForm, setEscForm] = useState({ title: '', priority: 'MEDIUM', ownerId: '', dueDate: '' })
const canReport = user && (SHIPMENT_SEND_ROLES as readonly string[]).includes(user.role)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
const [er, sr] = await Promise.all([fetch('/api/escapes'), fetch('/api/shipments')])
if (er.ok) { const { data } = await er.json(); setEscapes(data || []) }
if (sr.ok) { const { data } = await sr.json(); setShipments(data || []) }
setLoading(false)
}
async function loadSimilar(description: string) {
const res = await fetch(`/api/resolutions?similarTo=${encodeURIComponent(description)}`)
if (res.ok) { const { data } = await res.json(); setSimilar(data || []) }
}
function openDetail(e: any) {
setSelected(e)
setResNote(e.resolution || '')
setResCategory(e.category || '')
setStdText('')
setSimilar([])
if (e.status !== 'RESOLVED') loadSimilar(e.description)
}
async function submitReport() {
if (!reportForm.shipmentId || !reportForm.description.trim()) {
showToast('Shipment and description required', 'error'); return
}
const res = await fetch('/api/escapes', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(reportForm),
})
if (res.ok) {
const { data } = await res.json()
setReportOpen(false)
setReportForm({ shipmentId: '', contact: '', description: '' })
showToast(`${data.ref} logged`)
load()
openDetail(data)
} else {
showToast('Failed to log issue', 'error')
}
}
async function patch(body: any) {
const res = await fetch(`/api/escapes/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) {
const { data } = await res.json()
setSelected(data)
load()
return data
}
return null
}
async function classify(severity: string) { await patch({ severity }) }
async function setStatus(status: string) { await patch({ status }) }
async function resolve() {
if (!resNote.trim()) { showToast('Resolution notes required', 'error'); return }
if (!resCategory) { showToast('Category required for filing', 'error'); return }
const data = await patch({ resolution: resNote, category: resCategory })
if (data) showToast('Resolved and filed to resolutions library')
}
async function addToStandard() {
if (!stdText.trim()) { showToast('Describe the new check', 'error'); return }
const data = await patch({ standardItem: stdText })
if (data) showToast('Shipping standard updated')
}
async function skipStandard() {
await patch({ standardItem: '' })
}
function useFix(r: any) {
setResNote(`Reapplied previous fix: ${r.resolution}`)
setResCategory(r.category)
}
function openEscalate() {
setEscForm({
title: selected.description,
priority: PRIORITY_FROM_SEVERITY[selected.severity] || 'HIGH',
ownerId: '', dueDate: '',
})
setEscalateOpen(true)
}
async function confirmEscalate() {
if (!escForm.ownerId || !escForm.dueDate) { showToast('Owner and due date required', 'error'); return }
const data = await patch({ escalate: true, capaForm: escForm })
if (data) {
showToast(`${data.capa?.ref} created and linked`)
setEscalateOpen(false)
}
}
useEffect(() => { fetch('/api/users').then(r => r.ok && r.json()).then(d => d && setUsers(d.data || [])) }, [])
const kpi = {
total: escapes.length,
open: escapes.filter(e => e.status === 'OPEN' || e.status === 'INVESTIGATING').length,
res: escapes.filter(e => e.status === 'RESOLVED').length,
std: escapes.filter(e => e.standardItemAdded && e.standardItemAdded !== '—').length,
}
return (
<Layout title="Client issues">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>Client issues quality escapes</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Defects that passed QC and reached a client investigated like an NCR, and can update the shipping standard</p>
</div>
{canReport && shipments.length > 0 && <Btn onClick={() => setReportOpen(true)}>+ Report client issue</Btn>}
</div>
<div style={{ background: '#F1EFE8', borderRadius: '10px', padding: '10px 14px', fontSize: '11px', color: '#444', marginBottom: '16px', display: 'flex', alignItems: 'center', gap: '7px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2a3 3 0 00-1.5-2.598M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2a3 3 0 011.5-2.598M9 7a3 3 0 116 0 3 3 0 01-6 0z"/></svg>
Report access: Production leads · Logistics lead · Admin
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '10px', marginBottom: '16px' }}>
<StatCard label="Total escapes" value={kpi.total}/>
<StatCard label="Open" value={kpi.open}/>
<StatCard label="Resolved" value={kpi.res}/>
<StatCard label="Standards updated" value={kpi.std}/>
</div>
<Card>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : escapes.length === 0 ? (
<EmptyState title="No client-reported issues yet" message="That's good. If a client reports a problem with a shipped product, log it here — it goes through the same investigation flow as an NCR."/>
) : (
<Table headers={['Ref', 'Shipment', 'Description', 'Severity', 'Status', 'Reported', '']}>
{escapes.map((e: any) => (
<tr key={e.id} onClick={() => openDetail(e)} style={{ cursor: 'pointer' }}>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', color: '#534AB7', fontWeight: '500', fontSize: '12px', whiteSpace: 'nowrap' }}>{e.ref}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px' }}>
{e.shipment.product} Batch {e.shipment.batch}
<div style={{ fontSize: '10px', color: '#aaa' }}>{e.shipment.client}</div>
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', maxWidth: '240px' }}>{e.description.length > 60 ? e.description.slice(0, 60) + '…' : e.description}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}>
{e.severity ? <Tag color={SEV_COLOR[e.severity]}>{e.severity.charAt(0) + e.severity.slice(1).toLowerCase()}</Tag> : <Tag color="purple">Needs triage</Tag>}
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', whiteSpace: 'nowrap' }}><StatusDot status={e.status}/>{e.status.charAt(0) + e.status.slice(1).toLowerCase()}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '11px', color: '#aaa', whiteSpace: 'nowrap' }}>{new Date(e.createdAt).toLocaleDateString()}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}><span style={{ fontSize: '11px', color: '#534AB7' }}>View</span></td>
</tr>
))}
</Table>
)}
</Card>
{/* Report modal */}
<Modal open={reportOpen} onClose={() => setReportOpen(false)} title="Report client issue" width={460}>
<div style={{ background: '#FCEBEB', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#791F1F', marginBottom: '14px' }}>
This logs a quality escape a defect that reached the client after passing QC and links it to the shipment.
</div>
<Field label="Which shipment?" required>
<Select value={reportForm.shipmentId} onChange={e => setReportForm(f => ({ ...f, shipmentId: e.target.value }))}>
<option value="">Select shipment</option>
{shipments.map((s: any) => <option key={s.id} value={s.id}>{s.product} Batch {s.batch} shipped {new Date(s.shippedAt).toLocaleDateString()} ({s.client})</option>)}
</Select>
</Field>
<Field label="Client contact (optional)"><Input value={reportForm.contact} onChange={e => setReportForm(f => ({ ...f, contact: e.target.value }))} placeholder="e.g. Acme QA team"/></Field>
<Field label="What did the client report?" required><Textarea value={reportForm.description} onChange={e => setReportForm(f => ({ ...f, description: e.target.value }))} placeholder="Describe the issue exactly as the client described it"/></Field>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
<Btn variant="ghost" onClick={() => setReportOpen(false)}>Cancel</Btn>
<Btn onClick={submitReport}>Log quality escape</Btn>
</div>
</Modal>
{/* Detail modal */}
{selected && (
<Modal open={!!selected} onClose={() => setSelected(null)} title={selected.ref} width={500}>
<div style={{ background: '#f8f8f8', borderRadius: '8px', padding: '9px 12px', fontSize: '12px', marginBottom: '12px' }}>
{selected.shipment.product} Batch {selected.shipment.batch} shipped {new Date(selected.shipment.shippedAt).toLocaleDateString()} to <strong>{selected.shipment.client}</strong>
{selected.contact && <> · contact: {selected.contact}</>}
</div>
<div style={{ background: '#FCEBEB', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#791F1F', marginBottom: '12px' }}>
Quality escape this passed QC and reached the client before the defect was found. Treat as priority.
</div>
<div style={{ marginBottom: '10px' }}>
{selected.severity ? <Tag color={SEV_COLOR[selected.severity]}>{selected.severity.charAt(0) + selected.severity.slice(1).toLowerCase()}</Tag> : <Tag color="purple">Needs triage</Tag>}
{' '}<Tag color={STATUS_COLOR[selected.status]}><StatusDot status={selected.status}/>{selected.status.charAt(0) + selected.status.slice(1).toLowerCase()}</Tag>
</div>
<p style={{ fontSize: '13px', lineHeight: '1.5', marginBottom: '12px' }}>{selected.description}</p>
{selected.capa && (
<div style={{ background: '#f8f8f8', borderRadius: '8px', padding: '10px 12px', fontSize: '12px', marginBottom: '12px' }}>
Escalated to <strong style={{ color: '#534AB7' }}>{selected.capa.ref}</strong> root cause investigation continues there.
</div>
)}
{selected.status !== 'RESOLVED' && similar.length > 0 && (
<div style={{ background: '#EAF3DE', borderRadius: '8px', padding: '10px 12px', marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#27500A', marginBottom: '5px' }}>Similar issue fixed before</div>
<div style={{ fontSize: '12px', lineHeight: '1.5', marginBottom: '6px' }}>{similar[0].resolution}</div>
<div style={{ fontSize: '10px', color: '#888', marginBottom: '6px' }}>From <strong style={{ color: '#534AB7' }}>{similar[0].linkedRef}</strong> · category: {similar[0].category}</div>
<span onClick={() => useFix(similar[0])} style={{ fontSize: '11px', color: '#27500A', fontWeight: '500', cursor: 'pointer' }}>Use this fix</span>
</div>
)}
{!selected.severity && (
<>
<div style={{ borderTop: '0.5px solid #eee', margin: '12px 0' }}/>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#666', marginBottom: '6px' }}>Classify severity</div>
<div style={{ display: 'flex', gap: '6px', marginBottom: '12px' }}>
{['OBSERVATION', 'MINOR', 'MAJOR'].map(s => (
<Btn key={s} size="sm" variant="ghost" onClick={() => classify(s)} style={{ flex: 1, justifyContent: 'center' }}>{s.charAt(0) + s.slice(1).toLowerCase()}</Btn>
))}
</div>
</>
)}
{selected.status === 'RESOLVED' ? (
<>
<div style={{ borderTop: '0.5px solid #eee', margin: '12px 0' }}/>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#666', marginBottom: '4px' }}>
Resolution <Tag color={CAT_COLOR[selected.category] || 'gray'}>{selected.category}</Tag>
</div>
<div style={{ fontSize: '12px', color: '#666', lineHeight: '1.5', marginBottom: '12px' }}>{selected.resolution}</div>
{selected.standardItemAdded ? (
selected.standardItemAdded === '—' ? (
<div style={{ fontSize: '11px', color: '#aaa' }}>Shipping standard not updated for this one.</div>
) : (
<div style={{ background: '#EAF3DE', borderRadius: '8px', padding: '10px 12px' }}>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#27500A', marginBottom: '5px' }}>Shipping standard updated</div>
<div style={{ fontSize: '12px', lineHeight: '1.5' }}>{selected.standardItemAdded}</div>
</div>
)
) : (
<div style={{ background: '#f8f8f8', borderRadius: '8px', padding: '10px 12px' }}>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#444', marginBottom: '4px' }}>Update shipping standard?</div>
<div style={{ fontSize: '11px', color: '#aaa', marginBottom: '8px' }}>This escaped QC once add a check so it can't happen again.</div>
<Textarea value={stdText} onChange={e => setStdText(e.target.value)} placeholder="e.g. Add visual check for [defect] before packaging" style={{ minHeight: '50px' }}/>
<div style={{ display: 'flex', gap: '6px' }}>
<Btn size="sm" onClick={addToStandard}>Add to shipping standard</Btn>
<Btn size="sm" variant="ghost" onClick={skipStandard}>Skip</Btn>
</div>
</div>
)}
</>
) : (
<>
<div style={{ borderTop: '0.5px solid #eee', margin: '12px 0' }}/>
<Field label="Resolution notes"><Textarea value={resNote} onChange={e => setResNote(e.target.value)} placeholder="What fixed it? This gets filed for next time."/></Field>
<Field label="Category (for filing)">
<Select value={resCategory} onChange={e => setResCategory(e.target.value)}>
<option value="">Select category…</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</Select>
</Field>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
{selected.status === 'OPEN' && <Btn size="sm" variant="ghost" onClick={() => setStatus('INVESTIGATING')}>Start investigation</Btn>}
<Btn size="sm" onClick={resolve}>Mark resolved</Btn>
<Btn size="sm" variant="danger" onClick={openEscalate}>Escalate to CAPA</Btn>
</div>
</>
)}
</Modal>
)}
{/* Escalate to CAPA modal */}
<Modal open={escalateOpen} onClose={() => setEscalateOpen(false)} title="Escalate to CAPA">
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '14px' }}>
Creates a new CAPA pre-filled from this client escape, and links the two records together.
</div>
<Field label="CAPA title"><Input value={escForm.title} onChange={e => setEscForm(f => ({ ...f, title: e.target.value }))}/></Field>
<Field label="Priority">
<Select value={escForm.priority} onChange={e => setEscForm(f => ({ ...f, priority: e.target.value }))}>
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(p => <option key={p} value={p}>{p}</option>)}
</Select>
</Field>
<Field label="Owner" required>
<Select value={escForm.ownerId} onChange={e => setEscForm(f => ({ ...f, ownerId: e.target.value }))}>
<option value="">Select owner…</option>
{users.map(u => <option key={u.id} value={u.id}>{u.name} ({u.role})</option>)}
</Select>
</Field>
<Field label="Due date" required><Input type="date" value={escForm.dueDate} onChange={e => setEscForm(f => ({ ...f, dueDate: e.target.value }))}/></Field>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '14px' }}>
<Btn variant="ghost" onClick={() => setEscalateOpen(false)}>Cancel</Btn>
<Btn onClick={confirmEscalate}>Create CAPA and link</Btn>
</div>
</Modal>
</Layout>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCDashboard() {
return <ComingSoon title="QC dashboard" blurb="CAPA, NCR, audit, and supplier health for the quality team." />
}
+366
View File
@@ -0,0 +1,366 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Modal, Field, Input, Select, Textarea, showToast, Tag, StatusDot, Table, StatCard } from '@/components/ui'
import { useApp } from '@/lib/context'
const SEV_COLOR: Record<string, string> = { OBSERVATION: 'gray', MINOR: 'amber', MAJOR: 'red' }
const STATUS_COLOR: Record<string, string> = { OPEN: 'blue', INVESTIGATING: 'amber', ESCALATED: 'red', RESOLVED: 'green' }
const CAT_COLOR: Record<string, string> = { Sealing: 'purple', Packaging: 'amber', Calibration: 'gray', Supplier: 'red', Process: 'green', Training: 'gray', Other: 'gray' }
const PRIORITY_FROM_SEVERITY: Record<string, string> = { OBSERVATION: 'LOW', MINOR: 'MEDIUM', MAJOR: 'HIGH' }
const CATEGORIES = ['Sealing', 'Packaging', 'Calibration', 'Supplier', 'Process', 'Training', 'Other']
export default function NCRPage() {
const { user } = useApp()
const [tab, setTab] = useState<'register' | 'library'>('register')
// Register
const [ncrs, setNcrs] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('ALL')
const [selected, setSelected] = useState<any>(null)
const [similar, setSimilar] = useState<any[]>([])
const [resNote, setResNote] = useState('')
const [resCategory, setResCategory] = useState('')
const [users, setUsers] = useState<any[]>([])
// Escalate modal
const [escalateOpen, setEscalateOpen] = useState(false)
const [escForm, setEscForm] = useState({ title: '', priority: 'MEDIUM', ownerId: '', dueDate: '' })
// Library
const [resolutions, setResolutions] = useState<any[]>([])
const [libSearch, setLibSearch] = useState('')
const [libCategory, setLibCategory] = useState('ALL')
useEffect(() => { loadNcrs() }, [filter])
useEffect(() => { if (tab === 'library') loadLibrary() }, [tab, libSearch, libCategory])
useEffect(() => { fetch('/api/users').then(r => r.ok && r.json()).then(d => d && setUsers(d.data || [])) }, [])
async function loadNcrs() {
setLoading(true)
const params = filter !== 'ALL' ? `?status=${filter}` : ''
const res = await fetch(`/api/ncrs${params}`)
if (res.ok) { const { data } = await res.json(); setNcrs(data || []) }
setLoading(false)
}
async function loadLibrary() {
const params = new URLSearchParams()
if (libCategory !== 'ALL') params.set('category', libCategory)
if (libSearch) params.set('search', libSearch)
const res = await fetch(`/api/resolutions?${params}`)
if (res.ok) { const { data } = await res.json(); setResolutions(data || []) }
}
async function loadSimilar(description: string) {
const res = await fetch(`/api/resolutions?similarTo=${encodeURIComponent(description)}`)
if (res.ok) { const { data } = await res.json(); setSimilar(data || []) }
}
function openDetail(ncr: any) {
setSelected(ncr)
setResNote(ncr.resolution || '')
setResCategory(ncr.category || '')
setSimilar([])
if (ncr.status !== 'RESOLVED') loadSimilar(ncr.description)
}
async function classify(severity: string) {
const res = await fetch(`/api/ncrs/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ severity }),
})
if (res.ok) {
const { data } = await res.json()
setSelected(data)
loadNcrs()
}
}
async function setStatus(status: string) {
const res = await fetch(`/api/ncrs/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
if (res.ok) {
const { data } = await res.json()
setSelected(data)
loadNcrs()
}
}
async function resolve() {
if (!resNote.trim()) { showToast('Resolution notes required', 'error'); return }
if (!resCategory) { showToast('Category required for filing', 'error'); return }
const res = await fetch(`/api/ncrs/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolution: resNote, category: resCategory }),
})
if (res.ok) {
const { data } = await res.json()
setSelected(data)
showToast('Resolved and filed to resolutions library')
loadNcrs()
}
}
async function confirmSolution() {
const res = await fetch(`/api/ncrs/${selected.id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ confirmNotify: true }),
})
if (res.ok) {
const { data } = await res.json()
setSelected(data)
showToast(`${data.raisedBy?.name || 'Reporter'} notified — fix confirmed`)
loadNcrs()
}
}
function useFix(r: any) {
setResNote(`Reapplied previous fix: ${r.resolution}`)
setResCategory(r.category)
}
function openEscalate() {
setEscForm({
title: selected.description,
priority: PRIORITY_FROM_SEVERITY[selected.severity] || 'MEDIUM',
ownerId: '', dueDate: '',
})
setEscalateOpen(true)
}
async function confirmEscalate() {
if (!escForm.ownerId || !escForm.dueDate) { showToast('Owner and due date required', 'error'); return }
const res = await fetch(`/api/ncrs/${selected.id}/escalate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(escForm),
})
if (res.ok) {
const { data } = await res.json()
showToast(`${data.capa.ref} created and linked`)
setEscalateOpen(false)
setSelected(data.ncr)
loadNcrs()
} else {
showToast('Escalation failed', 'error')
}
}
const filters = ['ALL', 'OPEN', 'INVESTIGATING', 'ESCALATED', 'RESOLVED']
const kpi = {
open: ncrs.filter(n => n.status === 'OPEN').length,
inv: ncrs.filter(n => n.status === 'INVESTIGATING').length,
esc: ncrs.filter(n => n.status === 'ESCALATED').length,
res: ncrs.filter(n => n.status === 'RESOLVED').length,
}
return (
<Layout title="Nonconformances">
<div style={{ display: 'flex', gap: '20px', borderBottom: '0.5px solid #eee', marginBottom: '16px' }}>
{(['register', 'library'] as const).map(t => (
<button key={t} onClick={() => setTab(t)} style={{
padding: '8px 4px', fontSize: '12px', fontWeight: tab === t ? 500 : 400,
color: tab === t ? '#534AB7' : '#888', background: 'none', border: 'none',
borderBottom: tab === t ? '2px solid #534AB7' : '2px solid transparent',
cursor: 'pointer', fontFamily: 'inherit',
}}>
{t === 'register' ? 'NCR register' : `Resolutions library (${resolutions.length || ''})`}
</button>
))}
</div>
{tab === 'register' ? (
<>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4,1fr)', gap: '10px', marginBottom: '16px' }}>
<StatCard label="Open" value={kpi.open}/>
<StatCard label="Investigating" value={kpi.inv}/>
<StatCard label="Escalated → CAPA" value={kpi.esc}/>
<StatCard label="Resolved" value={kpi.res}/>
</div>
<Card>
<div style={{ display: 'flex', gap: '6px', marginBottom: '14px', flexWrap: 'wrap' }}>
{filters.map(f => (
<button key={f} onClick={() => setFilter(f)} style={{
padding: '4px 11px', fontSize: '11px', border: '0.5px solid #ddd', borderRadius: '16px',
cursor: 'pointer', fontFamily: 'inherit',
background: filter === f ? '#534AB7' : 'transparent', color: filter === f ? 'white' : '#666',
}}>{f === 'ALL' ? 'All' : f.charAt(0) + f.slice(1).toLowerCase()}</button>
))}
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : ncrs.length === 0 ? (
<EmptyState title="No nonconformances yet" message="Issues reported by production, or raised directly by QC, will appear here."/>
) : (
<Table headers={['Ref', 'Description', 'Source', 'Severity', 'Status', 'Raised', '']}>
{ncrs.map(n => (
<tr key={n.id} onClick={() => openDetail(n)} style={{ cursor: 'pointer' }}>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', color: '#534AB7', fontWeight: '500', whiteSpace: 'nowrap', fontSize: '12px' }}>{n.ref}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', maxWidth: '280px' }}>
{n.description.length > 80 ? n.description.slice(0, 80) + '…' : n.description}
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888', whiteSpace: 'nowrap' }}>{n.source || '—'}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}>
{n.severity ? <Tag color={SEV_COLOR[n.severity]}>{n.severity.charAt(0) + n.severity.slice(1).toLowerCase()}</Tag> : <Tag color="purple">Needs triage</Tag>}
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', whiteSpace: 'nowrap' }}>
<StatusDot status={n.status}/>{n.status.charAt(0) + n.status.slice(1).toLowerCase()}
{n.status === 'RESOLVED' && (
<span style={{ marginLeft: '5px', color: n.notified ? '#1D9E75' : '#EF9F27' }} title={n.notified ? 'Reporter notified' : 'Awaiting admin confirmation'}>
{n.notified ? '●✓' : '●'}
</span>
)}
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '11px', color: '#aaa', whiteSpace: 'nowrap' }}>{new Date(n.createdAt).toLocaleDateString()}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}><span style={{ fontSize: '11px', color: '#534AB7' }}>View</span></td>
</tr>
))}
</Table>
)}
</Card>
</>
) : (
<Card>
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px', flexWrap: 'wrap' }}>
<input value={libSearch} onChange={e => setLibSearch(e.target.value)} placeholder="Search past fixes — e.g. 'caps', 'supplier', 'torque'…"
style={{ flex: 1, minWidth: '200px', padding: '6px 10px', fontSize: '12px', border: '0.5px solid #ddd', borderRadius: '8px', outline: 'none', fontFamily: 'inherit' }}/>
</div>
<div style={{ display: 'flex', gap: '6px', marginBottom: '14px', flexWrap: 'wrap' }}>
{['ALL', ...CATEGORIES].map(c => (
<button key={c} onClick={() => setLibCategory(c)} style={{
padding: '4px 11px', fontSize: '11px', border: '0.5px solid #ddd', borderRadius: '16px',
cursor: 'pointer', fontFamily: 'inherit',
background: libCategory === c ? '#534AB7' : 'transparent', color: libCategory === c ? 'white' : '#666',
}}>{c === 'ALL' ? 'All' : c}</button>
))}
</div>
{resolutions.length === 0 ? (
<EmptyState title="No filed fixes yet" message="When an NCR or client issue is resolved with a category, it gets filed here — searchable for next time."/>
) : resolutions.map(r => (
<div key={r.id} style={{ border: '0.5px solid #eee', borderRadius: '8px', padding: '11px 13px', marginBottom: '8px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '8px' }}>
<div style={{ fontWeight: '500', fontSize: '13px' }}>{r.title}</div>
<Tag color={CAT_COLOR[r.category] || 'gray'}>{r.category}</Tag>
</div>
<div style={{ fontSize: '12px', color: '#666', lineHeight: '1.5', margin: '6px 0' }}>{r.resolution}</div>
<div style={{ fontSize: '10px', color: '#aaa' }}>Filed from <span style={{ color: '#534AB7', fontWeight: '500' }}>{r.linkedRef}</span> · {new Date(r.createdAt).toLocaleDateString()}</div>
</div>
))}
</Card>
)}
{/* Detail modal */}
{selected && (
<Modal open={!!selected} onClose={() => setSelected(null)} title={selected.ref} width={500}>
<div style={{ marginBottom: '10px' }}>
{selected.severity ? <Tag color={SEV_COLOR[selected.severity]}>{selected.severity.charAt(0) + selected.severity.slice(1).toLowerCase()}</Tag> : <Tag color="purple">Needs triage</Tag>}
{' '}<Tag color={STATUS_COLOR[selected.status]}><StatusDot status={selected.status}/>{selected.status.charAt(0) + selected.status.slice(1).toLowerCase()}</Tag>
</div>
<p style={{ fontSize: '13px', lineHeight: '1.5', marginBottom: '12px' }}>{selected.description}</p>
<div style={{ fontSize: '11px', color: '#aaa', marginBottom: '12px' }}>
Source: {selected.source || '—'} · Raised by {selected.raisedBy?.name} · {new Date(selected.createdAt).toLocaleDateString()}
</div>
{selected.capa && (
<div style={{ background: '#f8f8f8', borderRadius: '8px', padding: '10px 12px', fontSize: '12px', marginBottom: '12px' }}>
Escalated to <strong style={{ color: '#534AB7' }}>{selected.capa.ref}</strong> root cause investigation continues there.
</div>
)}
{selected.status !== 'RESOLVED' && similar.length > 0 && (
<div style={{ background: '#EAF3DE', borderRadius: '8px', padding: '10px 12px', marginBottom: '12px' }}>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#27500A', marginBottom: '5px' }}>Similar issue fixed before</div>
<div style={{ fontSize: '12px', lineHeight: '1.5', marginBottom: '6px' }}>{similar[0].resolution}</div>
<div style={{ fontSize: '10px', color: '#888', marginBottom: '6px' }}>From <strong style={{ color: '#534AB7' }}>{similar[0].linkedRef}</strong> · category: {similar[0].category}</div>
<span onClick={() => useFix(similar[0])} style={{ fontSize: '11px', color: '#27500A', fontWeight: '500', cursor: 'pointer' }}>Use this fix</span>
</div>
)}
{!selected.severity && (
<>
<div style={{ borderTop: '0.5px solid #eee', margin: '12px 0' }}/>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#666', marginBottom: '6px' }}>Classify severity</div>
<div style={{ display: 'flex', gap: '6px', marginBottom: '12px' }}>
{['OBSERVATION', 'MINOR', 'MAJOR'].map(s => (
<Btn key={s} size="sm" variant="ghost" onClick={() => classify(s)} style={{ flex: 1, justifyContent: 'center' }}>{s.charAt(0) + s.slice(1).toLowerCase()}</Btn>
))}
</div>
</>
)}
{selected.status === 'RESOLVED' ? (
<>
<div style={{ borderTop: '0.5px solid #eee', margin: '12px 0' }}/>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#666', marginBottom: '4px' }}>
Resolution <Tag color={CAT_COLOR[selected.category] || 'gray'}>{selected.category}</Tag>
</div>
<div style={{ fontSize: '12px', color: '#666', lineHeight: '1.5', marginBottom: '8px' }}>{selected.resolution}</div>
<div style={{ fontSize: '11px', color: '#aaa', marginBottom: '12px' }}>Filed to resolutions library for future reference.</div>
{selected.notified ? (
<div style={{ background: '#EAF3DE', borderRadius: '8px', padding: '10px 12px', fontSize: '12px', color: '#27500A', display: 'flex', alignItems: 'center', gap: '7px' }}>
{selected.raisedBy?.name} notified solution confirmed.
</div>
) : (
<div style={{ background: '#FAEEDA', borderRadius: '8px', padding: '10px 12px' }}>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#633806', marginBottom: '6px' }}>Admin review</div>
<div style={{ fontSize: '11px', color: '#666', lineHeight: '1.5', marginBottom: '8px' }}>QC has filed a fix. Confirm it's solved and notify {selected.raisedBy?.name} — they don't need to follow up themselves.</div>
<Btn size="sm" onClick={confirmSolution}>Confirm solution found notify {selected.raisedBy?.name}</Btn>
</div>
)}
</>
) : (
<>
<div style={{ borderTop: '0.5px solid #eee', margin: '12px 0' }}/>
<Field label="Resolution notes"><Textarea value={resNote} onChange={e => setResNote(e.target.value)} placeholder="What fixed it? This gets filed for next time."/></Field>
<Field label="Category (for filing)">
<Select value={resCategory} onChange={e => setResCategory(e.target.value)}>
<option value="">Select category</option>
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</Select>
</Field>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap', marginTop: '8px' }}>
{selected.status === 'OPEN' && <Btn size="sm" variant="ghost" onClick={() => setStatus('INVESTIGATING')}>Start investigation</Btn>}
<Btn size="sm" onClick={resolve}>Mark resolved</Btn>
<Btn size="sm" variant="danger" onClick={openEscalate}>Escalate to CAPA</Btn>
</div>
{selected.severity === 'MAJOR' && !selected.capa && (
<div style={{ background: '#FCEBEB', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#791F1F', marginTop: '10px' }}>
Major severity this is likely CAPA material.
</div>
)}
</>
)}
</Modal>
)}
{/* Escalate to CAPA modal */}
<Modal open={escalateOpen} onClose={() => setEscalateOpen(false)} title="Escalate to CAPA">
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '14px' }}>
Creates a new CAPA pre-filled from this NCR, and links the two records together.
</div>
<Field label="CAPA title"><Input value={escForm.title} onChange={e => setEscForm(f => ({ ...f, title: e.target.value }))}/></Field>
<Field label="Priority">
<Select value={escForm.priority} onChange={e => setEscForm(f => ({ ...f, priority: e.target.value }))}>
{['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'].map(p => <option key={p} value={p}>{p}</option>)}
</Select>
</Field>
<Field label="Owner" required>
<Select value={escForm.ownerId} onChange={e => setEscForm(f => ({ ...f, ownerId: e.target.value }))}>
<option value="">Select owner</option>
{users.map(u => <option key={u.id} value={u.id}>{u.name} ({u.role})</option>)}
</Select>
</Field>
<Field label="Due date" required><Input type="date" value={escForm.dueDate} onChange={e => setEscForm(f => ({ ...f, dueDate: e.target.value }))}/></Field>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '14px' }}>
<Btn variant="ghost" onClick={() => setEscalateOpen(false)}>Cancel</Btn>
<Btn onClick={confirmEscalate}>Create CAPA and link</Btn>
</div>
</Modal>
</Layout>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCRisk() {
return <ComingSoon title="Risk register" blurb="Likelihood/impact scoring, controls, and review scheduling." />
}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Modal, Field, Input, Textarea, showToast, Tag } from '@/components/ui'
import { useApp } from '@/lib/context'
import { SHIPMENT_SEND_ROLES } from '@/lib/auth'
const TYPE_TAG: Record<string, string> = { FORM_DATA: 'purple', NCR_FIX: 'green', AUDIT: 'amber', OTHER: 'gray' }
const TYPE_LABEL: Record<string, string> = { FORM_DATA: 'Form data', NCR_FIX: 'NCR fix', AUDIT: 'Audit', OTHER: 'Other' }
export default function ShipmentsPage() {
const { user } = useApp()
const [shipments, setShipments] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [openId, setOpenId] = useState<string | null>(null)
const [items, setItems] = useState<Record<string, boolean>>({})
// New shipment modal
const [newOpen, setNewOpen] = useState(false)
const [newForm, setNewForm] = useState({ product: '', batch: '', client: '', clientEmail: '', shippedAt: '' })
const [suggested, setSuggested] = useState<any[]>([])
const [creating, setCreating] = useState(false)
// Compose modal
const [composeShipment, setComposeShipment] = useState<any>(null)
const [composeForm, setComposeForm] = useState({ email: '', subject: '', message: '' })
const [sending, setSending] = useState(false)
const canSend = user && (SHIPMENT_SEND_ROLES as readonly string[]).includes(user.role)
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
const res = await fetch('/api/shipments')
if (res.ok) { const { data } = await res.json(); setShipments(data || []) }
setLoading(false)
}
async function suggestItems(product: string) {
if (!product) { setSuggested([]); return }
const res = await fetch(`/api/shipments/suggest?product=${encodeURIComponent(product)}`)
if (res.ok) { const { data } = await res.json(); setSuggested(data || []) }
}
async function createShipment() {
if (!newForm.product || !newForm.batch || !newForm.client || !newForm.shippedAt) {
showToast('Product, batch, client, and ship date required', 'error'); return
}
setCreating(true)
const res = await fetch('/api/shipments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...newForm, items: suggested }),
})
setCreating(false)
if (res.ok) {
setNewOpen(false)
setNewForm({ product: '', batch: '', client: '', clientEmail: '', shippedAt: '' })
setSuggested([])
showToast('Shipment recorded')
load()
} else {
showToast('Failed to create shipment', 'error')
}
}
function toggleShipment(s: any) {
if (openId === s.id) { setOpenId(null); return }
setOpenId(s.id)
const sel: Record<string, boolean> = {}
s.items.forEach((it: any) => { sel[it.id] = it.included })
setItems(sel)
}
function openCompose(s: any) {
const selected = s.items.filter((it: any) => items[it.id])
const lines = selected.map((it: any) => `- ${it.label}`).join('\n')
setComposeShipment(s)
setComposeForm({
email: s.clientEmail || '',
subject: `Quality Release Package — ${s.product} — Batch ${s.batch}`,
message: `Hello,\n\nPlease find confirmation that the following items have passed QC standards and the product has been cleared for shipment:\n\n${lines}\n\nIf you have any questions, just reply to this email.\n\nThanks,\nQuality team`,
})
}
async function sendCompose() {
if (!composeForm.email) { showToast('Client email required', 'error'); return }
setSending(true)
const res = await fetch(`/api/shipments/${composeShipment.id}/send`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientEmail: composeForm.email, subject: composeForm.subject, message: composeForm.message }),
})
setSending(false)
if (res.ok) {
showToast(`Package sent to ${composeForm.email}`)
setComposeShipment(null)
load()
} else {
showToast('Failed to send', 'error')
}
}
return (
<Layout title="Client release">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>Client release packages</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Shipments grouped by product, batch, and date send "good status" confirmation to clients</p>
</div>
<Btn onClick={() => setNewOpen(true)}>+ Record shipment</Btn>
</div>
<div style={{ background: '#F1EFE8', borderRadius: '10px', padding: '10px 14px', fontSize: '11px', color: '#444', marginBottom: '14px', display: 'flex', alignItems: 'center', gap: '7px' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2a3 3 0 00-1.5-2.598M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2a3 3 0 011.5-2.598M9 7a3 3 0 116 0 3 3 0 01-6 0z"/></svg>
Send access: Production leads · Logistics lead · Admin
</div>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : shipments.length === 0 ? (
<EmptyState title="No shipments recorded yet" message="Record a shipment to start tracking what's been sent to clients, and to report any future quality escapes against it." action={{ label: '+ Record first shipment', onClick: () => setNewOpen(true) }}/>
) : shipments.map((s: any) => (
<Card key={s.id} style={{ marginBottom: '10px', padding: 0, overflow: 'hidden' }}>
<div onClick={() => toggleShipment(s)} style={{ padding: '13px 16px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'pointer' }}>
<div>
<div style={{ fontSize: '13px', fontWeight: '500' }}>{s.product} Batch {s.batch}</div>
<div style={{ fontSize: '11px', color: '#aaa', marginTop: '2px' }}>
{s.ref} · shipped {new Date(s.shippedAt).toLocaleDateString()} · {s.client} · {s.items.filter((i: any) => i.included).length} records included
{s.sentAt && <span style={{ color: '#1D9E75', fontWeight: 500 }}> · sent to {s.sentTo}</span>}
{s._count?.escapes > 0 && <span style={{ color: '#E24B4A', fontWeight: 500 }}> · {s._count.escapes} client issue{s._count.escapes > 1 ? 's' : ''}</span>}
</div>
</div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#aaa" strokeWidth="2" style={{ transform: openId === s.id ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }}><path d="M6 9l6 6 6-6"/></svg>
</div>
{openId === s.id && (
<div style={{ padding: '0 16px 14px', borderTop: '0.5px solid #eee' }}>
{s.items.map((it: any) => (
<label key={it.id} style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '8px 0', fontSize: '12px', borderBottom: '0.5px solid #f5f5f5', cursor: 'pointer' }}>
<input type="checkbox" checked={items[it.id] ?? it.included} onChange={e => setItems(i => ({ ...i, [it.id]: e.target.checked }))} style={{ accentColor: '#534AB7' }}/>
<span style={{ flex: 1 }}>{it.label}</span>
<Tag color={TYPE_TAG[it.type]}>{TYPE_LABEL[it.type]}</Tag>
</label>
))}
{canSend ? (
<Btn size="sm" onClick={() => openCompose(s)} style={{ marginTop: '12px' }}>Email selected to client</Btn>
) : (
<div style={{ fontSize: '11px', color: '#aaa', marginTop: '12px' }}>Only Production leads, Logistics lead, or Admin can send this package.</div>
)}
</div>
)}
</Card>
))}
{/* New shipment modal */}
<Modal open={newOpen} onClose={() => setNewOpen(false)} title="Record shipment" width={460}>
<Field label="Product" required>
<Input value={newForm.product} onChange={e => { setNewForm(f => ({ ...f, product: e.target.value })); suggestItems(e.target.value) }} placeholder="Widget A Rev 2"/>
</Field>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px' }}>
<Field label="Batch" required><Input value={newForm.batch} onChange={e => setNewForm(f => ({ ...f, batch: e.target.value }))} placeholder="B-2024-06"/></Field>
<Field label="Ship date" required><Input type="date" value={newForm.shippedAt} onChange={e => setNewForm(f => ({ ...f, shippedAt: e.target.value }))}/></Field>
</div>
<Field label="Client" required><Input value={newForm.client} onChange={e => setNewForm(f => ({ ...f, client: e.target.value }))} placeholder="Acme Distribution"/></Field>
<Field label="Client email (optional)"><Input type="email" value={newForm.clientEmail} onChange={e => setNewForm(f => ({ ...f, clientEmail: e.target.value }))} placeholder="qa@acme.com"/></Field>
{suggested.length > 0 && (
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '12px' }}>
Auto-suggested {suggested.length} record{suggested.length !== 1 ? 's' : ''} for this product adjust after creating.
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '14px' }}>
<Btn variant="ghost" onClick={() => setNewOpen(false)}>Cancel</Btn>
<Btn onClick={createShipment} disabled={creating}>{creating ? 'Saving…' : 'Record shipment'}</Btn>
</div>
</Modal>
{/* Compose modal */}
<Modal open={!!composeShipment} onClose={() => setComposeShipment(null)} title="Send quality release package" width={480}>
<Field label="Client email" required><Input type="email" value={composeForm.email} onChange={e => setComposeForm(f => ({ ...f, email: e.target.value }))} placeholder="client@company.com"/></Field>
<Field label="Subject"><Input value={composeForm.subject} onChange={e => setComposeForm(f => ({ ...f, subject: e.target.value }))}/></Field>
<Field label="Message"><Textarea value={composeForm.message} onChange={e => setComposeForm(f => ({ ...f, message: e.target.value }))} style={{ minHeight: '180px' }}/></Field>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '8px', marginTop: '14px' }}>
<Btn variant="ghost" onClick={() => setComposeShipment(null)}>Cancel</Btn>
<Btn onClick={sendCompose} disabled={sending}>{sending ? 'Sending…' : 'Send package'}</Btn>
</div>
</Modal>
</Layout>
)
}
+77
View File
@@ -0,0 +1,77 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Field, Input, showToast } from '@/components/ui'
import { useApp } from '@/lib/context'
export default function ShippingStandardPage() {
const { user } = useApp()
const [items, setItems] = useState<any[]>([])
const [loading, setLoading] = useState(true)
const [newText, setNewText] = useState('')
const [adding, setAdding] = useState(false)
const canEdit = user && (user.role === 'ADMIN' || user.role === 'QC')
useEffect(() => { load() }, [])
async function load() {
setLoading(true)
const res = await fetch('/api/shipping-standard')
if (res.ok) { const { data } = await res.json(); setItems(data || []) }
setLoading(false)
}
async function addItem() {
if (!newText.trim()) return
setAdding(true)
const res = await fetch('/api/shipping-standard', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: newText, source: 'Baseline' }),
})
setAdding(false)
if (res.ok) {
setNewText('')
showToast('Added to shipping standard')
load()
} else {
showToast('Failed to add', 'error')
}
}
return (
<Layout title="Shipping standard">
<div style={{ marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>Living shipping standard</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>What must be true before a product ships. Updates automatically when a client-reported quality escape is resolved.</p>
</div>
<Card>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : items.length === 0 ? (
<EmptyState title="No standard items yet" message="Add baseline checks below, or resolve a client issue with 'update shipping standard' to add one automatically."/>
) : items.map((item: any, i: number) => (
<div key={item.id} style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', border: '0.5px solid #eee', borderRadius: '8px', padding: '11px 13px', marginBottom: '8px' }}>
<div style={{ width: '22px', height: '22px', borderRadius: '50%', background: '#f5f5f5', color: '#aaa', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '11px', fontWeight: '500', flexShrink: 0 }}>{i + 1}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '13px', lineHeight: '1.5' }}>{item.text}</div>
<div style={{ fontSize: '10px', color: '#aaa', marginTop: '3px' }}>
Source: {item.source === 'Baseline' ? 'Baseline' : <span style={{ color: '#534AB7', fontWeight: '500' }}>{item.source}</span>}
{item.source !== 'Baseline' && <> · {new Date(item.createdAt).toLocaleDateString()}</>}
</div>
</div>
</div>
))}
{canEdit && (
<div style={{ display: 'flex', gap: '8px', marginTop: '12px', borderTop: '0.5px solid #eee', paddingTop: '14px' }}>
<div style={{ flex: 1 }}>
<Input value={newText} onChange={e => setNewText(e.target.value)} placeholder="Add a baseline check…"/>
</div>
<Btn onClick={addItem} disabled={adding}>{adding ? 'Adding…' : 'Add'}</Btn>
</div>
)}
</Card>
</Layout>
)
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCStandards() {
return <ComingSoon title="Standards" blurb="Quality standards derived from first-build form submissions." />
}
+5
View File
@@ -0,0 +1,5 @@
import ComingSoon from '@/components/ComingSoon'
export default function QCSuppliers() {
return <ComingSoon title="Suppliers" blurb="Approved supplier list, tiers, scores, and audit scheduling." />
}
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
plugins: { tailwindcss: {}, autoprefixer: {} }
}
+431
View File
@@ -0,0 +1,431 @@
// V11 Enterprise QMS — SQLite schema
// NOTE: SQLite (Prisma connector) supports neither native enums nor the Json type.
// All former enums are stored as String (validated in app code; union types live in @/types).
// All former Json columns are stored as String (JSON-encoded; (de)serialised in app code).
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
// ─── USERS & AUTH ─────────────────────────────────────────────────────────────
// Role: ADMIN | QC | PRODUCTION | PRODUCTION_LEAD | LOGISTICS_LEAD | MANAGEMENT
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role String @default("PRODUCTION")
department String?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
capasOwned CAPA[] @relation("CAPAOwner")
capasRaised CAPA[] @relation("CAPARaisedBy")
auditsLed Audit[] @relation("AuditLead")
ncrsRaised NCR[] @relation("NCRRaisedBy")
submissions FormSubmission[]
auditLogs AuditLog[]
notifications Notification[]
}
model Session {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
// ─── AUDIT TRAIL ──────────────────────────────────────────────────────────────
model AuditLog {
id String @id @default(cuid())
userId String
action String
entity String
entityId String
before String? // JSON-encoded
after String? // JSON-encoded
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([entity, entityId])
@@index([userId])
@@index([createdAt])
}
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
// type: CAPA_OVERDUE | CAPA_ASSIGNED | AUDIT_DUE | NCR_ESCALATED |
// FORM_REVIEW_READY | DOC_EXPIRING | STANDARD_APPROVED | SOLUTION_CONFIRMED
model Notification {
id String @id @default(cuid())
userId String
type String
title String
body String
read Boolean @default(false)
link String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, read])
}
// ─── CAPA ─────────────────────────────────────────────────────────────────────
// priority: CRITICAL | HIGH | MEDIUM | LOW
// status: OPEN | IN_PROGRESS | OVERDUE | CLOSED
model CAPA {
id String @id @default(cuid())
ref String @unique
title String
description String?
priority String @default("MEDIUM")
status String @default("OPEN")
progress Int @default(0)
ownerId String
raisedById String
dueDate DateTime
closedAt DateTime?
rootCause String?
corrAction String?
prevAction String?
department String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation("CAPAOwner", fields: [ownerId], references: [id])
raisedBy User @relation("CAPARaisedBy", fields: [raisedById], references: [id])
timeline CAPAEvent[]
ncrs NCR[]
escapes QualityEscape[]
@@index([status])
@@index([ownerId])
@@index([dueDate])
}
model CAPAEvent {
id String @id @default(cuid())
capaId String
event String
note String?
createdAt DateTime @default(now())
capa CAPA @relation(fields: [capaId], references: [id], onDelete: Cascade)
@@index([capaId])
}
// ─── AUDITS ───────────────────────────────────────────────────────────────────
// type: INTERNAL | SUPPLIER | EXTERNAL
// status: SCHEDULED | IN_PROGRESS | COMPLETED | CANCELLED
model Audit {
id String @id @default(cuid())
ref String @unique
name String
type String @default("INTERNAL")
status String @default("SCHEDULED")
leadId String
scope String?
scheduledAt DateTime
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lead User @relation("AuditLead", fields: [leadId], references: [id])
findings Finding[]
@@index([status])
@@index([scheduledAt])
}
// severity: OBSERVATION | MINOR | MAJOR | CRITICAL
// status: OPEN | IN_PROGRESS | CLOSED
model Finding {
id String @id @default(cuid())
auditId String
description String
severity String @default("MINOR")
status String @default("OPEN")
category String?
dueDate DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
audit Audit @relation(fields: [auditId], references: [id], onDelete: Cascade)
@@index([auditId])
}
// ─── NCR ──────────────────────────────────────────────────────────────────────
// severity: OBSERVATION | MINOR | MAJOR (null = needs triage)
// status: OPEN | INVESTIGATING | ESCALATED | RESOLVED
model NCR {
id String @id @default(cuid())
ref String @unique
description String
source String?
severity String?
status String @default("OPEN")
raisedById String
resolvedAt DateTime?
resolution String?
category String?
notified Boolean @default(false)
notifiedAt DateTime?
capaId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
raisedBy User @relation("NCRRaisedBy", fields: [raisedById], references: [id])
capa CAPA? @relation(fields: [capaId], references: [id])
@@index([status])
@@index([createdAt])
}
// ─── RESOLUTIONS LIBRARY ──────────────────────────────────────────────────────
// Filed whenever an NCR or quality escape is resolved with a category.
// Searched for "similar past fix" matching on new issues.
model Resolution {
id String @id @default(cuid())
title String
category String
resolution String
linkedRef String
createdAt DateTime @default(now())
@@index([category])
}
// ─── DOCUMENTS ────────────────────────────────────────────────────────────────
// status: CURRENT | PENDING_REVIEW | EXPIRED | ARCHIVED
// category: SOP | POLICY | FORM | WORK_INSTRUCTION | RECORD
model Document {
id String @id @default(cuid())
ref String @unique
title String
category String @default("SOP")
revision String @default("A")
status String @default("CURRENT")
fileUrl String?
ownerId String?
reviewDate DateTime?
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([category])
}
// ─── RISK REGISTER ────────────────────────────────────────────────────────────
// level: LOW | MEDIUM | HIGH | CRITICAL
model Risk {
id String @id @default(cuid())
title String
description String?
likelihood Int @default(1)
impact Int @default(1)
score Int @default(1)
level String @default("LOW")
owner String?
controls String?
reviewDate DateTime?
accepted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([level])
}
// ─── SUPPLIERS ────────────────────────────────────────────────────────────────
// status: APPROVED | UNDER_REVIEW | SUSPENDED | INACTIVE
// tier: TIER_1 | TIER_2 | TIER_3
model Supplier {
id String @id @default(cuid())
name String
category String?
tier String @default("TIER_2")
status String @default("UNDER_REVIEW")
score Float?
contact String?
email String?
lastAudit DateTime?
nextAudit DateTime?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
}
// ─── FIRST BUILD FORMS ────────────────────────────────────────────────────────
// FieldType: SHORT_TEXT | LONG_TEXT | NUMBER | DATE | SINGLE_CHOICE | MULTI_CHOICE | RATING | PHOTO
// FormStatus: DRAFT | ACTIVE | SUSPENDED | REVIEW_READY | STANDARD_SET | ARCHIVED
model BuildForm {
id String @id @default(cuid())
name String
product String?
description String?
status String @default("DRAFT")
minSubmissions Int @default(10)
createdById String?
publishedAt DateTime?
suspendedAt DateTime?
archivedAt DateTime?
clonedFromId String?
clonedFromName String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
fields FormField[]
submissions FormSubmission[]
standard QualityStandard?
@@index([status])
}
model FormField {
id String @id @default(cuid())
formId String
label String
type String @default("SHORT_TEXT")
hint String?
options String @default("[]") // JSON-encoded string[] (SQLite has no scalar lists)
required Boolean @default(false)
trackStd Boolean @default(true)
order Int @default(0)
form BuildForm @relation(fields: [formId], references: [id], onDelete: Cascade)
@@index([formId])
}
model FormSubmission {
id String @id @default(cuid())
formId String
submittedBy String
data String // JSON-encoded answers
createdAt DateTime @default(now())
form BuildForm @relation(fields: [formId], references: [id])
user User @relation(fields: [submittedBy], references: [id])
@@index([formId])
@@index([createdAt])
}
model QualityStandard {
id String @id @default(cuid())
formId String @unique
title String
specs String // JSON-encoded
status String @default("DRAFT")
approvedBy String?
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
form BuildForm @relation(fields: [formId], references: [id])
}
// ─── SETTINGS ─────────────────────────────────────────────────────────────────
model Setting {
key String @id
value String
updatedAt DateTime @updatedAt
}
// ─── CLIENT RELEASE / SHIPMENTS ────────────────────────────────────────────────
// "Good status" packages — grouped by product/batch/date — that get
// batch-emailed to clients once everything has passed QC.
// Send access: PRODUCTION_LEAD, LOGISTICS_LEAD, ADMIN.
// ShipmentItem.type: FORM_DATA | NCR_FIX | AUDIT | OTHER
model Shipment {
id String @id @default(cuid())
ref String @unique
product String
batch String
client String
clientEmail String?
shippedAt DateTime
sentAt DateTime?
sentTo String?
createdById String?
createdAt DateTime @default(now())
items ShipmentItem[]
escapes QualityEscape[]
@@index([product, batch])
}
model ShipmentItem {
id String @id @default(cuid())
shipmentId String
label String
type String @default("OTHER")
included Boolean @default(true)
order Int @default(0)
shipment Shipment @relation(fields: [shipmentId], references: [id], onDelete: Cascade)
@@index([shipmentId])
}
// ─── CLIENT ISSUES (QUALITY ESCAPES) ──────────────────────────────────────────
// A defect that passed QC and reached the client. Reuses the NCR
// investigation/resolution flow, but linked to a shipment, and resolving
// one can add an entry to the living shipping standard.
// severity: OBSERVATION | MINOR | MAJOR (null = needs triage)
// status: OPEN | INVESTIGATING | RESOLVED | ESCALATED
model QualityEscape {
id String @id @default(cuid())
ref String @unique
shipmentId String
description String
contact String?
severity String?
status String @default("OPEN")
resolution String?
category String?
capaId String?
standardItemAdded String?
createdAt DateTime @default(now())
resolvedAt DateTime?
shipment Shipment @relation(fields: [shipmentId], references: [id])
capa CAPA? @relation(fields: [capaId], references: [id])
@@index([status])
}
// ─── LIVING SHIPPING STANDARD ─────────────────────────────────────────────────
// What must be true before a product gets "good status". Baseline items
// plus items added automatically when a quality escape is resolved.
model ShippingStandardItem {
id String @id @default(cuid())
text String
source String @default("Baseline")
order Int @default(0)
createdAt DateTime @default(now())
}
+32
View File
@@ -0,0 +1,32 @@
// Creates the first admin from ADMIN_EMAIL / ADMIN_PASSWORD / ADMIN_NAME.
// Idempotent: does nothing if any ADMIN user already exists, so container
// restarts never reset the password. Run via `npm run create-admin`.
const { PrismaClient } = require('@prisma/client')
const bcrypt = require('bcryptjs')
const prisma = new PrismaClient()
async function main() {
const email = (process.env.ADMIN_EMAIL || 'admin@local').trim().toLowerCase()
const password = process.env.ADMIN_PASSWORD || 'changeme'
const name = process.env.ADMIN_NAME || 'Administrator'
const existing = await prisma.user.findFirst({ where: { role: 'ADMIN' } })
if (existing) {
console.log('[create-admin] An admin user already exists — skipping.')
return
}
const password_hash = await bcrypt.hash(password, 12)
await prisma.user.create({
data: { email, name, password: password_hash, role: 'ADMIN', active: true },
})
console.log(`[create-admin] Created admin: ${email}`)
}
main()
.catch(e => {
console.error('[create-admin] failed:', e)
process.exit(1)
})
.finally(() => prisma.$disconnect())
+45
View File
@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--accent: #534AB7;
}
* {
box-sizing: border-box;
}
html,
body,
#__next {
height: 100%;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #1a1a1a;
background: #f8f7fd;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
/* Toast slide-in (referenced by ToastProvider) */
@keyframes toast-enter {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.toast-enter {
animation: toast-enter 0.18s ease-out;
}
+23
View File
@@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./pages/**/*.{js,ts,jsx,tsx}', './components/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
purple: {
50: '#EEEDFE', 100: '#CECBF6', 200: '#AFA9EC',
400: '#7F77DD', 600: '#534AB7', 800: '#3C3489', 900: '#26215C',
},
teal: {
50: '#E1F5EE', 100: '#9FE1CB', 200: '#5DCAA5',
400: '#1D9E75', 600: '#0F6E56', 800: '#085041', 900: '#04342C',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'monospace'],
},
},
},
plugins: [],
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules", "init-source"]
}
+1
View File
@@ -0,0 +1 @@
declare module 'cookie'
+58
View File
@@ -0,0 +1,58 @@
// Shared domain types.
// SQLite (Prisma) supports no native enums, so these former enums are string-literal
// unions kept here as the single source of truth for both client and server code.
export type Role =
| 'ADMIN'
| 'QC'
| 'PRODUCTION'
| 'PRODUCTION_LEAD'
| 'LOGISTICS_LEAD'
| 'MANAGEMENT'
export type FieldType =
| 'SHORT_TEXT'
| 'LONG_TEXT'
| 'NUMBER'
| 'DATE'
| 'SINGLE_CHOICE'
| 'MULTI_CHOICE'
| 'RATING'
| 'PHOTO'
export type FormStatus =
| 'DRAFT'
| 'ACTIVE'
| 'SUSPENDED'
| 'REVIEW_READY'
| 'STANDARD_SET'
| 'ARCHIVED'
export type NCRSeverity = 'OBSERVATION' | 'MINOR' | 'MAJOR'
export type NCRStatus = 'OPEN' | 'INVESTIGATING' | 'ESCALATED' | 'RESOLVED'
export type EscapeStatus = 'OPEN' | 'INVESTIGATING' | 'RESOLVED' | 'ESCALATED'
export type CAPAPriority = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'
export type CAPAStatus = 'OPEN' | 'IN_PROGRESS' | 'OVERDUE' | 'CLOSED'
export type NotifType =
| 'CAPA_OVERDUE'
| 'CAPA_ASSIGNED'
| 'AUDIT_DUE'
| 'NCR_ESCALATED'
| 'FORM_REVIEW_READY'
| 'DOC_EXPIRING'
| 'STANDARD_APPROVED'
| 'SOLUTION_CONFIRMED'
export type ShipmentItemType = 'FORM_DATA' | 'NCR_FIX' | 'AUDIT' | 'OTHER'
// The authenticated user as exposed to the client (no password/session fields).
export interface AppUser {
id: string
email: string
name: string
role: Role
department?: string | null
active?: boolean
}