diff --git a/init-source/qms-fix-1/lib/auth.ts b/init-source/qms-fix-1/lib/auth.ts new file mode 100644 index 0000000..4cfd9db --- /dev/null +++ b/init-source/qms-fix-1/lib/auth.ts @@ -0,0 +1,107 @@ +import { NextApiRequest, NextApiResponse } from 'next' +import { prisma } from './prisma' +import { Role } from '@prisma/client' +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)) { + 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 as any, + after: after as any, + }, + }) +} + +export function generateRef(prefix: string, count: number) { + return `${prefix}-${String(count + 1).padStart(3, '0')}` +} + +// Role permission helpers +export const ROLE_PERMISSIONS: Record = { + 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: Role, module: string): boolean { + const perms = ROLE_PERMISSIONS[role] + return perms.includes('*') || perms.includes(module as any) +} diff --git a/init-source/qms-fix-2/types/cookie.d.ts b/init-source/qms-fix-2/types/cookie.d.ts new file mode 100644 index 0000000..ea74a46 --- /dev/null +++ b/init-source/qms-fix-2/types/cookie.d.ts @@ -0,0 +1 @@ +declare module 'cookie' diff --git a/init-source/qms-fix-3/pages/index.tsx b/init-source/qms-fix-3/pages/index.tsx new file mode 100644 index 0000000..e7b6008 --- /dev/null +++ b/init-source/qms-fix-3/pages/index.tsx @@ -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 ( + <> + Sign in — V11 QMS +
+
+
+
+ + + +
+

+ V11 Enterprise QMS +

+

Sign in to your account

+
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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' + }} + /> +
+ +
+ + 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' + }} + /> +
+ + +
+ +

+ No account? Ask your system administrator. +

+
+
+ + ) +} diff --git a/init-source/qms-fix-4/package.json b/init-source/qms-fix-4/package.json new file mode 100644 index 0000000..bd940d1 --- /dev/null +++ b/init-source/qms-fix-4/package.json @@ -0,0 +1,44 @@ +{ + "name": "v11-enterprise-qms", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "prisma generate && next build", + "start": "next start", + "db:push": "npx prisma db push", + "db:studio": "npx prisma studio", + "db:seed": "ts-node --project tsconfig.seed.json prisma/seed.ts" + }, + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "next-auth": "^4.24.0", + "@prisma/client": "^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", + "prisma": "^5.10.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0", + "ts-node": "^10.9.0" + } +} diff --git a/init-source/qms-fix-5/vercel.json b/init-source/qms-fix-5/vercel.json new file mode 100644 index 0000000..f2ffddd --- /dev/null +++ b/init-source/qms-fix-5/vercel.json @@ -0,0 +1,3 @@ +{ + "regions": ["pdx1"] +} diff --git a/init-source/qms-fix-6/components/layout/Layout.tsx b/init-source/qms-fix-6/components/layout/Layout.tsx new file mode 100644 index 0000000..c879c5f --- /dev/null +++ b/init-source/qms-fix-6/components/layout/Layout.tsx @@ -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 = { + 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 = { + 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([]) + 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 ( + <> + {pageTitle} +
+ + {/* Sidebar */} + + + {/* Main */} +
+ {/* Topbar */} +
+
+ {title || nav.find(n => router.pathname === n.href || router.pathname.startsWith(n.href))?.label || 'Dashboard'} +
+ + {/* Notifications */} +
+ + + {notifOpen && ( +
+
+ Notifications + Mark all read +
+ {notifs.length === 0 ? ( +
All caught up
+ ) : notifs.slice(0, 6).map(n => ( +
+
{n.title}
+
{n.body}
+
+ QMS Admin · {new Date(n.createdAt).toLocaleDateString()} +
+
+ ))} +
+ )} +
+ +
+ {user.name.slice(0, 2).toUpperCase()} +
+
+ + {/* Content */} +
notifOpen && setNotifOpen(false)}> + {children} +
+
+
+ + ) +} diff --git a/init-source/qms-update-1/components/forms/FieldRenderer.tsx b/init-source/qms-update-1/components/forms/FieldRenderer.tsx new file mode 100644 index 0000000..b6c1b20 --- /dev/null +++ b/init-source/qms-update-1/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