diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4d5e512 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..34165f3 --- /dev/null +++ b/.env.example @@ -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" diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..3d499eb --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..acef266 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..7f4c450 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..165e9ed --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..ddd6cab --- /dev/null +++ b/README.md @@ -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//: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://: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://: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. diff --git a/components/ComingSoon.tsx b/components/ComingSoon.tsx new file mode 100644 index 0000000..49c1bdd --- /dev/null +++ b/components/ComingSoon.tsx @@ -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 ( + +
+
+ + + +
+

{title}

+

+ {blurb || 'This module is part of the QMS roadmap and is not built yet. The data model and navigation are already in place.'} +

+ + Coming soon + +
+
+ ) +} diff --git a/components/forms/FieldRenderer.tsx b/components/forms/FieldRenderer.tsx new file mode 100644 index 0000000..b6c1b20 --- /dev/null +++ b/components/forms/FieldRenderer.tsx @@ -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 onChange(e.target.value)} placeholder={field.hint || ''}/> + + case 'LONG_TEXT': + return