Files
qms/init-source/qms-update-3/pages/qc/escapes.tsx
T
2026-06-15 16:21:53 -05:00

331 lines
18 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'
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>
)
}