init-source

This commit is contained in:
2026-06-15 16:21:53 -05:00
parent a12a3fc72e
commit 631890c5bd
1010 changed files with 107132 additions and 0 deletions
+107
View File
@@ -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<Role, readonly string[]> = {
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)
}
+1
View File
@@ -0,0 +1 @@
declare module 'cookie'
+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>
</>
)
}
+44
View File
@@ -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"
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"regions": ["pdx1"]
}
@@ -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' }}>
QMS Admin · {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>
</>
)
}
@@ -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>
))}
</>
)
}
@@ -0,0 +1,309 @@
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>>({})
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 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>
<Card>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : forms.length === 0 ? (
<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={['Form name', 'Product', 'Fields', 'Submissions', 'Target', 'Status', 'Actions']}>
{forms.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>
<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>
{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>
)}
{f.status === 'ARCHIVED' ? (
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'DRAFT', 'Form restored to draft')}>Restore</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>
)
}
@@ -0,0 +1,112 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { prisma } from '@/lib/prisma'
import { requireAuth, logAction } from '@/lib/auth'
import { FormStatus } from '@prisma/client'
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: 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: 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: 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: 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()
}
@@ -0,0 +1,49 @@
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, ['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: clone })
}
@@ -0,0 +1,148 @@
import { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Tag, showToast } from '@/components/ui'
import { FormFieldList } from '@/components/forms/FieldRenderer'
import { useApp } from '@/lib/context'
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)
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 }))
}
return (
<Layout title="My forms">
{!selected ? (
<>
<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>
)}
</>
) : 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>
)
}
@@ -0,0 +1,433 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── USERS & AUTH ─────────────────────────────────────────────────────────────
enum Role {
ADMIN
QC
PRODUCTION
MANAGEMENT
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role Role @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 Json?
after Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([entity, entityId])
@@index([userId])
@@index([createdAt])
}
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
enum NotifType {
CAPA_OVERDUE
CAPA_ASSIGNED
AUDIT_DUE
NCR_ESCALATED
FORM_REVIEW_READY
DOC_EXPIRING
STANDARD_APPROVED
}
model Notification {
id String @id @default(cuid())
userId String
type NotifType
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 ─────────────────────────────────────────────────────────────────────
enum CAPAPriority {
CRITICAL
HIGH
MEDIUM
LOW
}
enum CAPAStatus {
OPEN
IN_PROGRESS
OVERDUE
CLOSED
}
model CAPA {
id String @id @default(cuid())
ref String @unique
title String
description String?
priority CAPAPriority @default(MEDIUM)
status CAPAStatus @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[]
@@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 ───────────────────────────────────────────────────────────────────
enum AuditType {
INTERNAL
SUPPLIER
EXTERNAL
}
enum AuditStatus {
SCHEDULED
IN_PROGRESS
COMPLETED
CANCELLED
}
model Audit {
id String @id @default(cuid())
ref String @unique
name String
type AuditType @default(INTERNAL)
status AuditStatus @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])
}
enum FindingSeverity {
OBSERVATION
MINOR
MAJOR
CRITICAL
}
enum FindingStatus {
OPEN
IN_PROGRESS
CLOSED
}
model Finding {
id String @id @default(cuid())
auditId String
description String
severity FindingSeverity @default(MINOR)
status FindingStatus @default(OPEN)
category String?
dueDate DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
audit Audit @relation(fields: [auditId], references: [id], onDelete: Cascade)
@@index([auditId])
}
// ─── NCR ──────────────────────────────────────────────────────────────────────
enum NCRSeverity {
OBSERVATION
MINOR
MAJOR
}
enum NCRStatus {
OPEN
INVESTIGATING
ESCALATED
RESOLVED
}
model NCR {
id String @id @default(cuid())
ref String @unique
description String
source String?
severity NCRSeverity @default(MINOR)
status NCRStatus @default(OPEN)
raisedById String
resolvedAt DateTime?
resolution String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
raisedBy User @relation("NCRRaisedBy", fields: [raisedById], references: [id])
@@index([status])
@@index([createdAt])
}
// ─── DOCUMENTS ────────────────────────────────────────────────────────────────
enum DocStatus {
CURRENT
PENDING_REVIEW
EXPIRED
ARCHIVED
}
enum DocCategory {
SOP
POLICY
FORM
WORK_INSTRUCTION
RECORD
}
model Document {
id String @id @default(cuid())
ref String @unique
title String
category DocCategory @default(SOP)
revision String @default("A")
status DocStatus @default(CURRENT)
fileUrl String?
ownerId String?
reviewDate DateTime?
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([category])
}
// ─── RISK REGISTER ────────────────────────────────────────────────────────────
enum RiskLevel {
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 RiskLevel @default(LOW)
owner String?
controls String?
reviewDate DateTime?
accepted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([level])
}
// ─── SUPPLIERS ────────────────────────────────────────────────────────────────
enum SupplierStatus {
APPROVED
UNDER_REVIEW
SUSPENDED
INACTIVE
}
enum SupplierTier {
TIER_1
TIER_2
TIER_3
}
model Supplier {
id String @id @default(cuid())
name String
category String?
tier SupplierTier @default(TIER_2)
status SupplierStatus @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 ────────────────────────────────────────────────────────
enum FieldType {
SHORT_TEXT
LONG_TEXT
NUMBER
DATE
SINGLE_CHOICE
MULTI_CHOICE
RATING
PHOTO
}
enum FormStatus {
DRAFT
ACTIVE
SUSPENDED
REVIEW_READY
STANDARD_SET
ARCHIVED
}
model BuildForm {
id String @id @default(cuid())
name String
product String?
description String?
status FormStatus @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 FieldType @default(SHORT_TEXT)
hint String?
options String[]
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 Json
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 Json
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
}
@@ -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>
)
}
@@ -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>
</>
)
}
+107
View File
@@ -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 = {
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)
}
+92
View File
@@ -0,0 +1,92 @@
import nodemailer from 'nodemailer'
import { prisma } from './prisma'
import { NotifType } from '@prisma/client'
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>`
}
@@ -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 '@prisma/client'
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()
}
@@ -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 '@prisma/client'
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()
}
@@ -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 '@prisma/client'
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()
}
@@ -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 } })
}
@@ -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 '@prisma/client'
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()
}
@@ -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, mode: 'insensitive' } },
{ resolution: { contains: search as string, mode: 'insensitive' } },
{ category: { contains: search as string, mode: 'insensitive' } },
]
}
const resolutions = await prisma.resolution.findMany({ where, orderBy: { createdAt: 'desc' } })
return res.json({ data: resolutions })
}
@@ -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 })
}
@@ -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()
}
@@ -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, mode: 'insensitive' } },
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 })
}
@@ -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()
}
@@ -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>
)
}
@@ -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>
)
}
+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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -0,0 +1,549 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── USERS & AUTH ─────────────────────────────────────────────────────────────
enum Role {
ADMIN
QC
PRODUCTION
PRODUCTION_LEAD
LOGISTICS_LEAD
MANAGEMENT
}
model User {
id String @id @default(cuid())
email String @unique
name String
password String
role Role @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 Json?
after Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id])
@@index([entity, entityId])
@@index([userId])
@@index([createdAt])
}
// ─── NOTIFICATIONS ────────────────────────────────────────────────────────────
enum NotifType {
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 NotifType
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 ─────────────────────────────────────────────────────────────────────
enum CAPAPriority {
CRITICAL
HIGH
MEDIUM
LOW
}
enum CAPAStatus {
OPEN
IN_PROGRESS
OVERDUE
CLOSED
}
model CAPA {
id String @id @default(cuid())
ref String @unique
title String
description String?
priority CAPAPriority @default(MEDIUM)
status CAPAStatus @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 ───────────────────────────────────────────────────────────────────
enum AuditType {
INTERNAL
SUPPLIER
EXTERNAL
}
enum AuditStatus {
SCHEDULED
IN_PROGRESS
COMPLETED
CANCELLED
}
model Audit {
id String @id @default(cuid())
ref String @unique
name String
type AuditType @default(INTERNAL)
status AuditStatus @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])
}
enum FindingSeverity {
OBSERVATION
MINOR
MAJOR
CRITICAL
}
enum FindingStatus {
OPEN
IN_PROGRESS
CLOSED
}
model Finding {
id String @id @default(cuid())
auditId String
description String
severity FindingSeverity @default(MINOR)
status FindingStatus @default(OPEN)
category String?
dueDate DateTime?
closedAt DateTime?
createdAt DateTime @default(now())
audit Audit @relation(fields: [auditId], references: [id], onDelete: Cascade)
@@index([auditId])
}
// ─── NCR ──────────────────────────────────────────────────────────────────────
enum NCRSeverity {
OBSERVATION
MINOR
MAJOR
}
enum NCRStatus {
OPEN
INVESTIGATING
ESCALATED
RESOLVED
}
model NCR {
id String @id @default(cuid())
ref String @unique
description String
source String?
severity NCRSeverity?
status NCRStatus @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 ────────────────────────────────────────────────────────────────
enum DocStatus {
CURRENT
PENDING_REVIEW
EXPIRED
ARCHIVED
}
enum DocCategory {
SOP
POLICY
FORM
WORK_INSTRUCTION
RECORD
}
model Document {
id String @id @default(cuid())
ref String @unique
title String
category DocCategory @default(SOP)
revision String @default("A")
status DocStatus @default(CURRENT)
fileUrl String?
ownerId String?
reviewDate DateTime?
approvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([category])
}
// ─── RISK REGISTER ────────────────────────────────────────────────────────────
enum RiskLevel {
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 RiskLevel @default(LOW)
owner String?
controls String?
reviewDate DateTime?
accepted Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([level])
}
// ─── SUPPLIERS ────────────────────────────────────────────────────────────────
enum SupplierStatus {
APPROVED
UNDER_REVIEW
SUSPENDED
INACTIVE
}
enum SupplierTier {
TIER_1
TIER_2
TIER_3
}
model Supplier {
id String @id @default(cuid())
name String
category String?
tier SupplierTier @default(TIER_2)
status SupplierStatus @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 ────────────────────────────────────────────────────────
enum FieldType {
SHORT_TEXT
LONG_TEXT
NUMBER
DATE
SINGLE_CHOICE
MULTI_CHOICE
RATING
PHOTO
}
enum FormStatus {
DRAFT
ACTIVE
SUSPENDED
REVIEW_READY
STANDARD_SET
ARCHIVED
}
model BuildForm {
id String @id @default(cuid())
name String
product String?
description String?
status FormStatus @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 FieldType @default(SHORT_TEXT)
hint String?
options String[]
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 Json
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 Json
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.
enum ShipmentItemType {
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 ShipmentItemType @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.
// Report access: PRODUCTION_LEAD, LOGISTICS_LEAD, ADMIN.
enum EscapeStatus {
OPEN
INVESTIGATING
RESOLVED
ESCALATED
}
model QualityEscape {
id String @id @default(cuid())
ref String @unique
shipmentId String
description String
contact String?
severity NCRSeverity?
status EscapeStatus @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())
}
+29
View File
@@ -0,0 +1,29 @@
# ─── Database ────────────────────────────────────────────────────────────────
# PostgreSQL connection string
# Free options: Supabase (supabase.com), Railway, Neon
DATABASE_URL="postgresql://postgres.ntdredkfbvqiapibnuop:YC%23Q%25Y%25PetW38j-@aws-1-us-west-2.pooler.supabase.com:5432/postgres?schema=public"
# ─── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Change to your production domain when deploying:
# NEXT_PUBLIC_APP_URL="https://qms.yourcompany.com"
# ─── Email (SMTP) ─────────────────────────────────────────────────────────────
# Recommended: Resend (resend.com) - free for 3,000 emails/month
# SMTP_HOST="smtp.resend.com"
# SMTP_PORT="587"
# SMTP_USER="resend"
# SMTP_PASS="re_your_api_key_here"
# EMAIL_FROM="qms@yourcompany.com"
# For SendGrid:
# SMTP_HOST="smtp.sendgrid.net"
# SMTP_PORT="587"
# SMTP_USER="apikey"
# SMTP_PASS="SG.your_sendgrid_key_here"
# EMAIL_FROM="qms@yourcompany.com"
# ─── File storage (optional - for document/photo uploads) ────────────────────
# Supabase Storage (if using Supabase for DB, storage is included free)
# NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
# NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
# SUPABASE_SERVICE_KEY="your-service-key"
+30
View File
@@ -0,0 +1,30 @@
# ─── Database ────────────────────────────────────────────────────────────────
# PostgreSQL connection string
# Free options: Supabase (supabase.com), Railway, Neon
DATABASE_URL="postgresql://user:password@host:5432/v11qms?schema=public"
# ─── App ─────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# Change to your production domain when deploying:
# NEXT_PUBLIC_APP_URL="https://qms.yourcompany.com"
# ─── Email (SMTP) ─────────────────────────────────────────────────────────────
# Recommended: Resend (resend.com) - free for 3,000 emails/month
# SMTP_HOST="smtp.resend.com"
# SMTP_PORT="587"
# SMTP_USER="resend"
# SMTP_PASS="re_your_api_key_here"
# EMAIL_FROM="qms@yourcompany.com"
# For SendGrid:
# SMTP_HOST="smtp.sendgrid.net"
# SMTP_PORT="587"
# SMTP_USER="apikey"
# SMTP_PASS="SG.your_sendgrid_key_here"
# EMAIL_FROM="qms@yourcompany.com"
# ─── File storage (optional - for document/photo uploads) ────────────────────
# Supabase Storage (if using Supabase for DB, storage is included free)
# NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
# NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
# SUPABASE_SERVICE_KEY="your-service-key"
+1
View File
@@ -0,0 +1 @@
.vercel
+38
View File
@@ -0,0 +1,38 @@
{
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [
"static/chunks/react-refresh.js"
],
"ampDevFiles": [],
"lowPriorityFiles": [
"static/development/_buildManifest.js",
"static/development/_ssgManifest.js"
],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pages": {
"/_app": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
],
"/admin/audit-log": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/admin/audit-log.js"
],
"/admin/settings": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/admin/settings.js"
]
},
"ampFirstPages": []
}
+1
View File
@@ -0,0 +1 @@
{"encryption.key":"sQ7G58aDT1oKFtkXDCk8M+RQ4Td9RjWAar9o9XQrnoE=","encryption.expire_at":1782743898400}
+1
View File
@@ -0,0 +1 @@
{}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"type": "commonjs"}
@@ -0,0 +1,11 @@
{
"version": 4,
"routes": {},
"dynamicRoutes": {},
"notFoundRoutes": [],
"preview": {
"previewModeId": "f17947d9b2a95420b084ce27d8fe63e4",
"previewModeSigningKey": "608bf240c645e129544b4b369ee7325e9098705a1b203460d425fe8f97453643",
"previewModeEncryptionKey": "1bb2858e88bc3d4f429ebde5ff4fd67546ee5c93f4a127257dc7f994292e1733"
}
}
@@ -0,0 +1,14 @@
{
"node_modules\\next\\dist\\client\\index.js -> ../pages/_app": {
"id": "node_modules\\next\\dist\\client\\index.js -> ../pages/_app",
"files": [
"static/chunks/_pages-dir-browser_node_modules_next_dist_pages__app_js.js"
]
},
"node_modules\\next\\dist\\client\\index.js -> ../pages/_error": {
"id": "node_modules\\next\\dist\\client\\index.js -> ../pages/_error",
"files": [
"static/chunks/_pages-dir-browser_node_modules_next_dist_pages__error_js.js"
]
}
}
@@ -0,0 +1 @@
{"version":3,"caseSensitive":false,"basePath":"","rewrites":{"beforeFiles":[],"afterFiles":[],"fallback":[]},"redirects":[{"source":"/:path+/","destination":"/:path+","permanent":true,"internal":true,"regex":"^(?:\\/((?:[^\\/]+?)(?:\\/(?:[^\\/]+?))*))\\/$"}],"headers":[]}
@@ -0,0 +1 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
@@ -0,0 +1,40 @@
globalThis.__BUILD_MANIFEST = {
"polyfillFiles": [
"static/chunks/polyfills.js"
],
"devFiles": [
"static/chunks/react-refresh.js"
],
"ampDevFiles": [],
"lowPriorityFiles": [],
"rootMainFiles": [],
"rootMainFilesTree": {},
"pages": {
"/_app": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_app.js"
],
"/_error": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/_error.js"
],
"/admin/audit-log": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/admin/audit-log.js"
],
"/admin/settings": [
"static/chunks/webpack.js",
"static/chunks/main.js",
"static/chunks/pages/admin/settings.js"
]
},
"ampFirstPages": []
};
globalThis.__BUILD_MANIFEST.lowPriorityFiles = [
"/static/" + process.env.__NEXT_BUILD_ID + "/_buildManifest.js",
,"/static/" + process.env.__NEXT_BUILD_ID + "/_ssgManifest.js",
];
@@ -0,0 +1,6 @@
{
"version": 3,
"middleware": {},
"functions": {},
"sortedMiddleware": []
}
@@ -0,0 +1 @@
self.__REACT_LOADABLE_MANIFEST="{\"node_modules\\\\next\\\\dist\\\\client\\\\index.js -> ../pages/_app\":{\"id\":\"node_modules\\\\next\\\\dist\\\\client\\\\index.js -> ../pages/_app\",\"files\":[\"static/chunks/_pages-dir-browser_node_modules_next_dist_pages__app_js.js\"]},\"node_modules\\\\next\\\\dist\\\\client\\\\index.js -> ../pages/_error\":{\"id\":\"node_modules\\\\next\\\\dist\\\\client\\\\index.js -> ../pages/_error\",\"files\":[\"static/chunks/_pages-dir-browser_node_modules_next_dist_pages__error_js.js\"]}}"
@@ -0,0 +1 @@
self.__NEXT_FONT_MANIFEST="{\"pages\":{},\"app\":{},\"appUsingSizeAdjust\":false,\"pagesUsingSizeAdjust\":false}"
@@ -0,0 +1 @@
{"pages":{},"app":{},"appUsingSizeAdjust":false,"pagesUsingSizeAdjust":false}
@@ -0,0 +1,8 @@
{
"/_app": "pages/_app.js",
"/_document": "pages/_document.js",
"/_error": "pages/_error.js",
"/admin/audit-log": "pages/admin/audit-log.js",
"/admin/settings": "pages/admin/settings.js",
"/admin/forms": "pages/admin/forms.js"
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
"use strict";
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
(() => {
var exports = {};
exports.id = "pages/_document";
exports.ids = ["pages/_document"];
exports.modules = {
/***/ "next/dist/compiled/next-server/pages.runtime.dev.js":
/*!**********************************************************************!*\
!*** external "next/dist/compiled/next-server/pages.runtime.dev.js" ***!
\**********************************************************************/
/***/ ((module) => {
module.exports = require("next/dist/compiled/next-server/pages.runtime.dev.js");
/***/ }),
/***/ "path":
/*!***********************!*\
!*** external "path" ***!
\***********************/
/***/ ((module) => {
module.exports = require("path");
/***/ }),
/***/ "react":
/*!************************!*\
!*** external "react" ***!
\************************/
/***/ ((module) => {
module.exports = require("react");
/***/ }),
/***/ "react/jsx-runtime":
/*!************************************!*\
!*** external "react/jsx-runtime" ***!
\************************************/
/***/ ((module) => {
module.exports = require("react/jsx-runtime");
/***/ })
};
;
// load runtime
var __webpack_require__ = require("../webpack-runtime.js");
__webpack_require__.C(exports);
var __webpack_exec__ = (moduleId) => (__webpack_require__(__webpack_require__.s = moduleId))
var __webpack_exports__ = __webpack_require__.X(0, ["vendor-chunks/next"], () => (__webpack_exec__("(pages-dir-node)/./node_modules/next/dist/pages/_document.js")));
module.exports = __webpack_exports__;
})();
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,237 @@
/*
* ATTENTION: An "eval-source-map" devtool has been used.
* This devtool is neither made for production nor for readable output files.
* It uses "eval()" calls to create a separate source file with attached SourceMaps in the browser devtools.
* If you are trying to read the output file, select a different devtool (https://webpack.js.org/configuration/devtool/)
* or disable the default devtool with "devtool: false".
* If you are looking for production-ready output files, see mode: "production" (https://webpack.js.org/configuration/mode/).
*/
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ var __webpack_modules__ = ({});
/************************************************************************/
/******/ // The module cache
/******/ var __webpack_module_cache__ = {};
/******/
/******/ // The require function
/******/ function __webpack_require__(moduleId) {
/******/ // Check if module is in cache
/******/ var cachedModule = __webpack_module_cache__[moduleId];
/******/ if (cachedModule !== undefined) {
/******/ return cachedModule.exports;
/******/ }
/******/ // Create a new module (and put it into the cache)
/******/ var module = __webpack_module_cache__[moduleId] = {
/******/ // no module.id needed
/******/ // no module.loaded needed
/******/ exports: {}
/******/ };
/******/
/******/ // Execute the module function
/******/ var threw = true;
/******/ try {
/******/ __webpack_modules__[moduleId](module, module.exports, __webpack_require__);
/******/ threw = false;
/******/ } finally {
/******/ if(threw) delete __webpack_module_cache__[moduleId];
/******/ }
/******/
/******/ // Return the exports of the module
/******/ return module.exports;
/******/ }
/******/
/******/ // expose the modules object (__webpack_modules__)
/******/ __webpack_require__.m = __webpack_modules__;
/******/
/************************************************************************/
/******/ /* webpack/runtime/async module */
/******/ (() => {
/******/ var webpackQueues = typeof Symbol === "function" ? Symbol("webpack queues") : "__webpack_queues__";
/******/ var webpackExports = typeof Symbol === "function" ? Symbol("webpack exports") : "__webpack_exports__";
/******/ var webpackError = typeof Symbol === "function" ? Symbol("webpack error") : "__webpack_error__";
/******/ var resolveQueue = (queue) => {
/******/ if(queue && queue.d < 1) {
/******/ queue.d = 1;
/******/ queue.forEach((fn) => (fn.r--));
/******/ queue.forEach((fn) => (fn.r-- ? fn.r++ : fn()));
/******/ }
/******/ }
/******/ var wrapDeps = (deps) => (deps.map((dep) => {
/******/ if(dep !== null && typeof dep === "object") {
/******/ if(dep[webpackQueues]) return dep;
/******/ if(dep.then) {
/******/ var queue = [];
/******/ queue.d = 0;
/******/ dep.then((r) => {
/******/ obj[webpackExports] = r;
/******/ resolveQueue(queue);
/******/ }, (e) => {
/******/ obj[webpackError] = e;
/******/ resolveQueue(queue);
/******/ });
/******/ var obj = {};
/******/ obj[webpackQueues] = (fn) => (fn(queue));
/******/ return obj;
/******/ }
/******/ }
/******/ var ret = {};
/******/ ret[webpackQueues] = x => {};
/******/ ret[webpackExports] = dep;
/******/ return ret;
/******/ }));
/******/ __webpack_require__.a = (module, body, hasAwait) => {
/******/ var queue;
/******/ hasAwait && ((queue = []).d = -1);
/******/ var depQueues = new Set();
/******/ var exports = module.exports;
/******/ var currentDeps;
/******/ var outerResolve;
/******/ var reject;
/******/ var promise = new Promise((resolve, rej) => {
/******/ reject = rej;
/******/ outerResolve = resolve;
/******/ });
/******/ promise[webpackExports] = exports;
/******/ promise[webpackQueues] = (fn) => (queue && fn(queue), depQueues.forEach(fn), promise["catch"](x => {}));
/******/ module.exports = promise;
/******/ body((deps) => {
/******/ currentDeps = wrapDeps(deps);
/******/ var fn;
/******/ var getResult = () => (currentDeps.map((d) => {
/******/ if(d[webpackError]) throw d[webpackError];
/******/ return d[webpackExports];
/******/ }))
/******/ var promise = new Promise((resolve) => {
/******/ fn = () => (resolve(getResult));
/******/ fn.r = 0;
/******/ var fnQueue = (q) => (q !== queue && !depQueues.has(q) && (depQueues.add(q), q && !q.d && (fn.r++, q.push(fn))));
/******/ currentDeps.map((dep) => (dep[webpackQueues](fnQueue)));
/******/ });
/******/ return fn.r ? promise : getResult();
/******/ }, (err) => ((err ? reject(promise[webpackError] = err) : outerResolve(exports)), resolveQueue(queue)));
/******/ queue && queue.d < 0 && (queue.d = 0);
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/compat get default export */
/******/ (() => {
/******/ // getDefaultExport function for compatibility with non-harmony modules
/******/ __webpack_require__.n = (module) => {
/******/ var getter = module && module.__esModule ?
/******/ () => (module['default']) :
/******/ () => (module);
/******/ __webpack_require__.d(getter, { a: getter });
/******/ return getter;
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/define property getters */
/******/ (() => {
/******/ // define getter functions for harmony exports
/******/ __webpack_require__.d = (exports, definition) => {
/******/ for(var key in definition) {
/******/ if(__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) {
/******/ Object.defineProperty(exports, key, { enumerable: true, get: definition[key] });
/******/ }
/******/ }
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/ensure chunk */
/******/ (() => {
/******/ __webpack_require__.f = {};
/******/ // This file contains only the entry chunk.
/******/ // The chunk loading function for additional chunks
/******/ __webpack_require__.e = (chunkId) => {
/******/ return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => {
/******/ __webpack_require__.f[key](chunkId, promises);
/******/ return promises;
/******/ }, []));
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/get javascript chunk filename */
/******/ (() => {
/******/ // This function allow to reference async chunks and sibling chunks for the entrypoint
/******/ __webpack_require__.u = (chunkId) => {
/******/ // return url for filenames based on template
/******/ return "" + chunkId + ".js";
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/hasOwnProperty shorthand */
/******/ (() => {
/******/ __webpack_require__.o = (obj, prop) => (Object.prototype.hasOwnProperty.call(obj, prop))
/******/ })();
/******/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/******/ /* webpack/runtime/startup entrypoint */
/******/ (() => {
/******/ __webpack_require__.X = (result, chunkIds, fn) => {
/******/ // arguments: chunkIds, moduleId are deprecated
/******/ var moduleId = chunkIds;
/******/ if(!fn) chunkIds = result, fn = () => (__webpack_require__(__webpack_require__.s = moduleId));
/******/ chunkIds.map(__webpack_require__.e, __webpack_require__)
/******/ var r = fn();
/******/ return r === undefined ? result : r;
/******/ }
/******/ })();
/******/
/******/ /* webpack/runtime/require chunk loading */
/******/ (() => {
/******/ // no baseURI
/******/
/******/ // object to store loaded chunks
/******/ // "1" means "loaded", otherwise not loaded yet
/******/ var installedChunks = {
/******/ "webpack-api-runtime": 1
/******/ };
/******/
/******/ // no on chunks loaded
/******/
/******/ var installChunk = (chunk) => {
/******/ var moreModules = chunk.modules, chunkIds = chunk.ids, runtime = chunk.runtime;
/******/ for(var moduleId in moreModules) {
/******/ if(__webpack_require__.o(moreModules, moduleId)) {
/******/ __webpack_require__.m[moduleId] = moreModules[moduleId];
/******/ }
/******/ }
/******/ if(runtime) runtime(__webpack_require__);
/******/ for(var i = 0; i < chunkIds.length; i++)
/******/ installedChunks[chunkIds[i]] = 1;
/******/
/******/ };
/******/
/******/ // require() chunk loading for javascript
/******/ __webpack_require__.f.require = (chunkId, promises) => {
/******/ // "1" is the signal for "already loaded"
/******/ if(!installedChunks[chunkId]) {
/******/ if("webpack-api-runtime" != chunkId) {
/******/ installChunk(require("./" + __webpack_require__.u(chunkId)));
/******/ } else installedChunks[chunkId] = 1;
/******/ }
/******/ };
/******/
/******/ module.exports = __webpack_require__;
/******/ __webpack_require__.C = installChunk;
/******/
/******/ // no HMR
/******/
/******/ // no HMR manifest
/******/ })();
/******/
/************************************************************************/
/******/
/******/
/******/ })()
;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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