init-source
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user