267 lines
16 KiB
TypeScript
267 lines
16 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import Head from 'next/head'
|
|
import { useApp } from '@/lib/context'
|
|
import { Role } from '@/types'
|
|
|
|
const NAV_BY_ROLE: Record<Role, { href: string; label: string; icon: string }[]> = {
|
|
ADMIN: [
|
|
{ href: '/admin', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
|
{ href: '/admin/users', label: 'Users', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z' },
|
|
{ href: '/admin/forms', label: 'Form builder', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
|
{ href: '/admin/audit-log', label: 'Audit trail', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
|
{ href: '/admin/settings', label: 'Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
|
],
|
|
QC: [
|
|
{ href: '/qc', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
|
{ href: '/qc/capas', label: 'CAPA', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
|
{ href: '/qc/audits', label: 'Audits', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
|
{ href: '/qc/ncr', label: 'Nonconformances', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
|
|
{ href: '/qc/shipments', label: 'Client release', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
|
|
{ href: '/qc/escapes', label: 'Client issues', icon: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z' },
|
|
{ href: '/qc/shipping-standard', label: 'Shipping standard', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 14l2 2 4-4' },
|
|
{ href: '/qc/documents', label: 'Documents', icon: 'M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z' },
|
|
{ href: '/qc/risk', label: 'Risk register', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
|
{ href: '/qc/suppliers', label: 'Suppliers', icon: 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4' },
|
|
{ href: '/qc/standards', label: 'Standards', icon: 'M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z' },
|
|
],
|
|
PRODUCTION: [
|
|
{ href: '/fill', label: 'My forms', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
|
{ href: '/fill/submissions', label: 'My submissions', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2m0 0l2.5 2.5M9 12h6' },
|
|
],
|
|
PRODUCTION_LEAD: [
|
|
{ href: '/fill', label: 'My forms', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2' },
|
|
{ href: '/fill/submissions', label: 'My submissions', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2m0 0l2.5 2.5M9 12h6' },
|
|
{ href: '/qc/ncr', label: 'Nonconformances', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
|
|
{ href: '/qc/shipments', label: 'Client release', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
|
|
{ href: '/qc/escapes', label: 'Client issues', icon: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z' },
|
|
{ href: '/qc/shipping-standard', label: 'Shipping standard', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 14l2 2 4-4' },
|
|
],
|
|
LOGISTICS_LEAD: [
|
|
{ href: '/qc/shipments', label: 'Client release', icon: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4' },
|
|
{ href: '/qc/escapes', label: 'Client issues', icon: 'M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z' },
|
|
{ href: '/qc/shipping-standard', label: 'Shipping standard', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M9 14l2 2 4-4' },
|
|
{ href: '/qc/ncr', label: 'Nonconformances', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
|
|
],
|
|
MANAGEMENT: [
|
|
{ href: '/management', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6' },
|
|
{ href: '/management/reports', label: 'Reports', icon: 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' },
|
|
],
|
|
}
|
|
|
|
const ROLE_COLORS: Record<Role, string> = {
|
|
ADMIN: '#534AB7', QC: '#1D9E75', PRODUCTION: '#EF9F27', PRODUCTION_LEAD: '#D98324', LOGISTICS_LEAD: '#2A9D8F', MANAGEMENT: '#378ADD'
|
|
}
|
|
|
|
export default function Layout({ children, title }: { children: React.ReactNode; title?: string }) {
|
|
const { user, logout } = useApp()
|
|
const router = useRouter()
|
|
const [collapsed, setCollapsed] = useState(false)
|
|
const [unread, setUnread] = useState(0)
|
|
const [notifOpen, setNotifOpen] = useState(false)
|
|
const [notifs, setNotifs] = useState<any[]>([])
|
|
const [mobileOpen, setMobileOpen] = useState(false)
|
|
|
|
useEffect(() => {
|
|
fetchNotifs()
|
|
const interval = setInterval(fetchNotifs, 60000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
async function fetchNotifs() {
|
|
try {
|
|
const res = await fetch('/api/notifications')
|
|
if (res.ok) {
|
|
const { data, unread: u } = await res.json()
|
|
setNotifs(data)
|
|
setUnread(u)
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
async function markRead() {
|
|
await fetch('/api/notifications', { method: 'PATCH' })
|
|
setUnread(0)
|
|
setNotifs(n => n.map(x => ({ ...x, read: true })))
|
|
}
|
|
|
|
if (!user) return null
|
|
|
|
const nav = NAV_BY_ROLE[user.role] || []
|
|
const roleColor = ROLE_COLORS[user.role]
|
|
const pageTitle = title ? `${title} — V11 QMS` : 'V11 QMS'
|
|
|
|
return (
|
|
<>
|
|
<Head><title>{pageTitle}</title></Head>
|
|
<div style={{ display: 'flex', height: '100vh', overflow: 'hidden', background: '#F8F7FD' }}>
|
|
|
|
{/* Sidebar */}
|
|
<aside style={{
|
|
width: collapsed ? '52px' : '220px',
|
|
minWidth: collapsed ? '52px' : '220px',
|
|
background: 'white',
|
|
borderRight: '0.5px solid #eee',
|
|
display: 'flex', flexDirection: 'column',
|
|
transition: 'width 0.2s, min-width 0.2s',
|
|
overflow: 'hidden',
|
|
position: 'relative',
|
|
zIndex: 10,
|
|
}}>
|
|
{/* Logo */}
|
|
<div style={{ padding: '16px 14px 14px', borderBottom: '0.5px solid #eee', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
|
{!collapsed && (
|
|
<div>
|
|
<div style={{ fontSize: '14px', fontWeight: '600', color: roleColor }}>V11 QMS</div>
|
|
<div style={{ fontSize: '10px', color: '#aaa', marginTop: '1px' }}>Enterprise Quality OS</div>
|
|
</div>
|
|
)}
|
|
<button onClick={() => setCollapsed(c => !c)} style={{
|
|
background: 'none', border: 'none', cursor: 'pointer',
|
|
color: '#aaa', padding: '4px', borderRadius: '6px', marginLeft: collapsed ? 'auto' : 0
|
|
}}>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
{collapsed
|
|
? <path d="M13 5l7 7-7 7M6 12h14"/>
|
|
: <path d="M11 5l-7 7 7 7M18 12H4"/>}
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Nav */}
|
|
<nav style={{ flex: 1, overflowY: 'auto', padding: '8px 0' }}>
|
|
{nav.map(item => {
|
|
const active = router.pathname === item.href || (item.href !== '/qc' && item.href !== '/admin' && item.href !== '/management' && router.pathname.startsWith(item.href))
|
|
return (
|
|
<a key={item.href} href={item.href} style={{
|
|
display: 'flex', alignItems: 'center', gap: '8px',
|
|
padding: collapsed ? '8px 14px' : '7px 10px',
|
|
margin: '1px 6px', borderRadius: '8px',
|
|
color: active ? roleColor : '#666',
|
|
background: active ? `${roleColor}14` : 'transparent',
|
|
fontWeight: active ? '500' : '400',
|
|
fontSize: '13px', textDecoration: 'none',
|
|
whiteSpace: 'nowrap', transition: 'all 0.1s',
|
|
justifyContent: collapsed ? 'center' : 'flex-start',
|
|
}}>
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" style={{ flexShrink: 0 }}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d={item.icon}/>
|
|
</svg>
|
|
{!collapsed && item.label}
|
|
</a>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* User */}
|
|
<div style={{ padding: '10px 12px', borderTop: '0.5px solid #eee', display: 'flex', alignItems: 'center', gap: '8px', flexShrink: 0 }}>
|
|
<div style={{
|
|
width: '28px', height: '28px', borderRadius: '50%',
|
|
background: roleColor + '22', color: roleColor,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '10px', fontWeight: '600', flexShrink: 0
|
|
}}>
|
|
{user.name.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
{!collapsed && (
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ fontSize: '12px', fontWeight: '500', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{user.name}</div>
|
|
<div style={{ fontSize: '10px', color: '#aaa' }}>{user.role}</div>
|
|
</div>
|
|
)}
|
|
{!collapsed && (
|
|
<button onClick={logout} title="Sign out" style={{
|
|
background: 'none', border: 'none', cursor: 'pointer', color: '#bbb', padding: '2px'
|
|
}}>
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path strokeLinecap="round" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main */}
|
|
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
|
{/* Topbar */}
|
|
<div style={{
|
|
background: 'white', borderBottom: '0.5px solid #eee',
|
|
padding: '0 20px', height: '52px',
|
|
display: 'flex', alignItems: 'center', gap: '12px', flexShrink: 0
|
|
}}>
|
|
<div style={{ flex: 1, fontSize: '14px', fontWeight: '500' }}>
|
|
{title || nav.find(n => router.pathname === n.href || router.pathname.startsWith(n.href))?.label || 'Dashboard'}
|
|
</div>
|
|
|
|
{/* Notifications */}
|
|
<div style={{ position: 'relative' }}>
|
|
<button onClick={() => { setNotifOpen(o => !o); if (unread) markRead() }} style={{
|
|
background: 'none', border: '0.5px solid #e8e8e8',
|
|
borderRadius: '8px', padding: '6px', cursor: 'pointer', color: '#666',
|
|
display: 'flex', alignItems: 'center', position: 'relative'
|
|
}}>
|
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
|
</svg>
|
|
{unread > 0 && (
|
|
<span style={{
|
|
position: 'absolute', top: '3px', right: '3px',
|
|
width: '7px', height: '7px', borderRadius: '50%',
|
|
background: '#E24B4A', border: '1.5px solid white'
|
|
}}/>
|
|
)}
|
|
</button>
|
|
|
|
{notifOpen && (
|
|
<div style={{
|
|
position: 'absolute', top: '42px', right: 0,
|
|
width: '280px', background: 'white',
|
|
border: '0.5px solid #e0e0e0', borderRadius: '12px',
|
|
zIndex: 200, overflow: 'hidden',
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.08)'
|
|
}}>
|
|
<div style={{ padding: '10px 14px', fontSize: '12px', fontWeight: '500', borderBottom: '0.5px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
|
|
<span>Notifications</span>
|
|
<span style={{ color: roleColor, cursor: 'pointer', fontSize: '11px' }} onClick={markRead}>Mark all read</span>
|
|
</div>
|
|
{notifs.length === 0 ? (
|
|
<div style={{ padding: '20px', textAlign: 'center', fontSize: '12px', color: '#aaa' }}>All caught up</div>
|
|
) : notifs.slice(0, 6).map(n => (
|
|
<div key={n.id} style={{
|
|
padding: '10px 14px', borderBottom: '0.5px solid #f5f5f5',
|
|
background: n.read ? 'transparent' : '#FAFAFE',
|
|
borderLeft: n.read ? 'none' : `2px solid ${roleColor}`,
|
|
cursor: 'pointer',
|
|
}}>
|
|
<div style={{ fontSize: '12px', fontWeight: '500' }}>{n.title}</div>
|
|
<div style={{ fontSize: '11px', color: '#888', marginTop: '2px', lineHeight: '1.3' }}>{n.body}</div>
|
|
<div style={{ fontSize: '10px', color: '#bbb', marginTop: '3px' }}>
|
|
QMS Admin · {new Date(n.createdAt).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{
|
|
width: '28px', height: '28px', borderRadius: '50%',
|
|
background: roleColor + '22', color: roleColor,
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
fontSize: '10px', fontWeight: '600'
|
|
}}>
|
|
{user.name.slice(0, 2).toUpperCase()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<main style={{ flex: 1, overflowY: 'auto', padding: '20px' }}
|
|
onClick={() => notifOpen && setNotifOpen(false)}>
|
|
{children}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|