Files
qms/pages/qc/ncr.tsx
T
jason ad499f6782
Build and Push Docker Image / build (push) Successful in 1m12s
Assemble QMS app + SQLite refactor + Unraid single-container deploy
Reconstruct the full app from init-source overlays (base + fix-1..6 +
update-1..3, last-wins) at the repo root, complete the missing pieces so it
builds and runs, and stage the Unraid deployment.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 16:58:47 -05:00

367 lines
20 KiB
TypeScript

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>
)
}