Assemble QMS app + SQLite refactor + Unraid single-container deploy
Build and Push Docker Image / build (push) Successful in 1m12s
Build and Push Docker Image / build (push) Successful in 1m12s
Reconstruct the full app from init-source overlays (base + fix-1..6 + update-1..3, last-wins) at the repo root, complete the missing pieces so it builds and runs, and stage the Unraid deployment. App completion: - types/index.ts: former Prisma enums as string-literal unions + AppUser - pages/_app.tsx + styles/globals.css (mount AppProvider/ToastProvider) - API routes: auth/login, auth/me, users, submissions (+REVIEW_READY notify), forms (list/create), notifications - scripts/create-admin.js: idempotent first-admin bootstrap - 14 unbuilt nav targets stubbed via ComingSoon placeholder SQLite refactor (single-container, no external DB): - schema provider -> sqlite; enums -> String; Json -> String; FormField.options String[] -> JSON-encoded String - lib/forms.ts (de)serialises options at the DB boundary - drop mode:"insensitive" (unsupported on SQLite) - enum imports repointed from @prisma/client to @/types Deploy: - multi-stage Dockerfile (next build -> prod runner), docker-entrypoint.sh (prisma db push -> create-admin -> next start), .dockerignore - docker-compose.yml: br0 10.2.0.x, /mnt/user/appdata/qms -> /data volume - README rewritten for the Unraid/Gitea Actions flow; .env scrubbed of the live Supabase credential; vercel.json removed Verified: next build clean (41 routes); live SQLite round-trip of login/session, form options array, and submission -> REVIEW_READY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCAudits() {
|
||||
return <ComingSoon title="Audits" blurb="Internal, supplier, and external audits with findings management." />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCCapas() {
|
||||
return <ComingSoon title="CAPA" blurb="Corrective and preventive actions with timeline and progress tracking." />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCDocuments() {
|
||||
return <ComingSoon title="Documents" blurb="Controlled documents: SOPs, policies, forms, and work instructions." />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCDashboard() {
|
||||
return <ComingSoon title="QC dashboard" blurb="CAPA, NCR, audit, and supplier health for the quality team." />
|
||||
}
|
||||
@@ -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,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCRisk() {
|
||||
return <ComingSoon title="Risk register" blurb="Likelihood/impact scoring, controls, and review scheduling." />
|
||||
}
|
||||
@@ -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,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCStandards() {
|
||||
return <ComingSoon title="Standards" blurb="Quality standards derived from first-build form submissions." />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import ComingSoon from '@/components/ComingSoon'
|
||||
|
||||
export default function QCSuppliers() {
|
||||
return <ComingSoon title="Suppliers" blurb="Approved supplier list, tiers, scores, and audit scheduling." />
|
||||
}
|
||||
Reference in New Issue
Block a user