Assemble QMS app + SQLite refactor + Unraid single-container deploy
Build and Push Docker Image / build (push) Successful in 1m12s

Reconstruct the full app from init-source overlays (base + fix-1..6 +
update-1..3, last-wins) at the repo root, complete the missing pieces so it
builds and runs, and stage the Unraid deployment.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jason
2026-06-15 16:57:15 -05:00
parent 631890c5bd
commit ad499f6782
70 changed files with 8045 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
import Layout from '@/components/layout/Layout'
// Placeholder for QMS modules that are on the roadmap but not yet implemented.
// Keeps navigation links and role redirects valid instead of 404-ing.
export default function ComingSoon({ title, blurb }: { title: string; blurb?: string }) {
return (
<Layout title={title}>
<div style={{ maxWidth: '520px', margin: '48px auto', textAlign: 'center' }}>
<div style={{
width: '52px', height: '52px', borderRadius: '14px', background: '#EEEDFE',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center', marginBottom: '16px'
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#534AB7" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<h2 style={{ fontSize: '18px', fontWeight: 600, margin: '0 0 8px' }}>{title}</h2>
<p style={{ fontSize: '13px', color: '#888', lineHeight: 1.6, margin: '0 0 18px' }}>
{blurb || 'This module is part of the QMS roadmap and is not built yet. The data model and navigation are already in place.'}
</p>
<span style={{
fontSize: '11px', fontWeight: 500, color: '#3C3489', background: '#EEEDFE',
padding: '4px 12px', borderRadius: '12px'
}}>
Coming soon
</span>
</div>
</Layout>
)
}
+96
View File
@@ -0,0 +1,96 @@
import React from 'react'
import { Input, Textarea } from '@/components/ui'
import { FieldType } from '@/types'
export interface FormFieldDef {
id: string
label: string
type: FieldType
hint?: string | null
options: string[]
required: boolean
}
export function FieldRenderer({ field, value, onChange }: {
field: FormFieldDef
value: any
onChange: (v: any) => void
}) {
switch (field.type) {
case 'SHORT_TEXT':
return <Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder={field.hint || ''}/>
case 'LONG_TEXT':
return <Textarea value={value || ''} onChange={e => onChange(e.target.value)} placeholder={field.hint || ''}/>
case 'NUMBER':
return <Input type="number" value={value ?? ''} onChange={e => onChange(e.target.value)} style={{ maxWidth: '160px' }}/>
case 'DATE':
return <Input type="date" value={value || ''} onChange={e => onChange(e.target.value)} style={{ maxWidth: '180px' }}/>
case 'SINGLE_CHOICE':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{field.options.map(opt => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', cursor: 'pointer' }}>
<input type="radio" name={field.id} checked={value === opt} onChange={() => onChange(opt)} style={{ accentColor: '#534AB7' }}/>
{opt}
</label>
))}
</div>
)
case 'MULTI_CHOICE':
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
{field.options.map(opt => (
<label key={opt} style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '12px', cursor: 'pointer' }}>
<input type="checkbox" checked={(value || []).includes(opt)}
onChange={e => {
const cur = value || []
onChange(e.target.checked ? [...cur, opt] : cur.filter((x: string) => x !== opt))
}} style={{ accentColor: '#534AB7' }}/>
{opt}
</label>
))}
</div>
)
case 'RATING':
return (
<div style={{ display: 'flex', gap: '4px' }}>
{[1, 2, 3, 4, 5].map(n => (
<span key={n} onClick={() => onChange(n)} style={{ fontSize: '24px', cursor: 'pointer', color: (value || 0) >= n ? '#EF9F27' : '#ddd' }}></span>
))}
</div>
)
case 'PHOTO':
return <Input type="file" accept="image/*" onChange={e => onChange(e.target.files?.[0]?.name || '')}/>
default:
return null
}
}
export function FormFieldList({ fields, values, onChange }: {
fields: FormFieldDef[]
values: Record<string, any>
onChange: (fieldId: string, value: any) => void
}) {
return (
<>
{fields.map(field => (
<div key={field.id} style={{ marginBottom: '14px' }}>
<label style={{ display: 'block', fontSize: '12px', fontWeight: '500', marginBottom: '4px' }}>
{field.label}
{field.required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}
</label>
{field.hint && <div style={{ fontSize: '10px', color: '#aaa', marginBottom: '4px' }}>{field.hint}</div>}
<FieldRenderer field={field} value={values[field.id]} onChange={v => onChange(field.id, v)}/>
</div>
))}
</>
)
}
+266
View File
@@ -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>
</>
)
}
+243
View File
@@ -0,0 +1,243 @@
import React, { useEffect, useState } from 'react'
// ── Empty state ───────────────────────────────────────────────────────────────
export function EmptyState({ icon, title, message, action }: {
icon?: string; title: string; message?: string
action?: { label: string; onClick: () => void }
}) {
return (
<div style={{ textAlign: 'center', padding: '48px 24px', color: '#aaa' }}>
{icon && <div style={{ fontSize: '32px', marginBottom: '10px', opacity: 0.4 }}>{icon}</div>}
<div style={{ fontSize: '14px', fontWeight: '500', color: '#666', marginBottom: '6px' }}>{title}</div>
{message && <div style={{ fontSize: '12px', color: '#aaa', marginBottom: '16px', maxWidth: '300px', margin: '0 auto 16px' }}>{message}</div>}
{action && (
<button onClick={action.onClick} style={{
padding: '8px 16px', fontSize: '12px', fontWeight: '500',
background: '#534AB7', color: 'white', border: 'none',
borderRadius: '8px', cursor: 'pointer'
}}>{action.label}</button>
)}
</div>
)
}
// ── Stat card ─────────────────────────────────────────────────────────────────
export function StatCard({ label, value, sub, color }: {
label: string; value: string | number | null; sub?: string; color?: string
}) {
return (
<div style={{
background: 'white', border: '0.5px solid #eee',
borderRadius: '10px', padding: '12px 14px'
}}>
<div style={{ fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.6px', color: '#aaa', marginBottom: '4px' }}>
{label}
</div>
<div style={{ fontSize: '24px', fontWeight: '500', color: color || '#1a1a1a', lineHeight: '1.1' }}>
{value === null ? '—' : value}
</div>
{sub && <div style={{ fontSize: '10px', color: '#aaa', marginTop: '3px' }}>{sub}</div>}
</div>
)
}
// ── Tag / badge ───────────────────────────────────────────────────────────────
const TAG_STYLES: Record<string, { bg: string; color: string }> = {
green: { bg: '#EAF3DE', color: '#27500A' },
amber: { bg: '#FAEEDA', color: '#633806' },
red: { bg: '#FCEBEB', color: '#791F1F' },
blue: { bg: '#E6F1FB', color: '#0C447C' },
purple: { bg: '#EEEDFE', color: '#3C3489' },
gray: { bg: '#F1EFE8', color: '#444441' },
teal: { bg: '#E1F5EE', color: '#085041' },
}
export function Tag({ children, color = 'gray' }: { children: React.ReactNode; color?: string }) {
const s = TAG_STYLES[color] || TAG_STYLES.gray
return (
<span style={{
fontSize: '10px', padding: '2px 7px', borderRadius: '9px',
fontWeight: '500', background: s.bg, color: s.color,
whiteSpace: 'nowrap', display: 'inline-block'
}}>
{children}
</span>
)
}
// ── Status dot ────────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
OPEN: '#378ADD', IN_PROGRESS: '#EF9F27', OVERDUE: '#E24B4A',
CLOSED: '#1D9E75', SCHEDULED: '#378ADD', COMPLETED: '#1D9E75',
CANCELLED: '#aaa', INVESTIGATING: '#EF9F27', ESCALATED: '#E24B4A',
RESOLVED: '#1D9E75', APPROVED: '#1D9E75', UNDER_REVIEW: '#EF9F27',
SUSPENDED: '#E24B4A', DRAFT: '#aaa', ACTIVE: '#1D9E75',
REVIEW_READY: '#EF9F27', STANDARD_SET: '#1D9E75', ARCHIVED: '#aaa',
CURRENT: '#1D9E75', PENDING_REVIEW: '#EF9F27', EXPIRED: '#E24B4A',
}
export function StatusDot({ status }: { status: string }) {
return (
<span style={{
width: '6px', height: '6px', borderRadius: '50%',
background: STATUS_COLORS[status] || '#aaa',
display: 'inline-block', marginRight: '5px', flexShrink: 0, verticalAlign: 'middle'
}}/>
)
}
// ── Card ──────────────────────────────────────────────────────────────────────
export function Card({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }) {
return (
<div style={{
background: 'white', border: '0.5px solid #eee',
borderRadius: '12px', padding: '14px 16px',
...style
}}>
{children}
</div>
)
}
// ── Table ─────────────────────────────────────────────────────────────────────
export function Table({ headers, children, empty }: {
headers: string[]; children: React.ReactNode; empty?: boolean
}) {
return (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '12px' }}>
<thead>
<tr>
{headers.map(h => (
<th key={h} style={{
textAlign: 'left', fontWeight: '500', fontSize: '10px',
textTransform: 'uppercase', letterSpacing: '0.5px',
color: '#aaa', padding: '5px 6px',
borderBottom: '0.5px solid #eee'
}}>{h}</th>
))}
</tr>
</thead>
<tbody>{children}</tbody>
</table>
)
}
// ── Toast ─────────────────────────────────────────────────────────────────────
type ToastType = 'success' | 'error' | 'info'
let toastCallback: ((msg: string, type: ToastType) => void) | null = null
export function showToast(msg: string, type: ToastType = 'success') {
toastCallback?.(msg, type)
}
export function ToastProvider() {
const [toasts, setToasts] = useState<{ id: number; msg: string; type: ToastType }[]>([])
let counter = 0
useEffect(() => {
toastCallback = (msg, type) => {
const id = ++counter
setToasts(t => [...t, { id, msg, type }])
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3500)
}
return () => { toastCallback = null }
}, [])
const colors = { success: '#1D9E75', error: '#E24B4A', info: '#534AB7' }
return (
<div style={{ position: 'fixed', bottom: '20px', right: '20px', zIndex: 1000, display: 'flex', flexDirection: 'column', gap: '8px' }}>
{toasts.map(t => (
<div key={t.id} className="toast-enter" style={{
background: 'white', border: `0.5px solid ${colors[t.type]}33`,
borderLeft: `3px solid ${colors[t.type]}`,
borderRadius: '8px', padding: '10px 14px',
fontSize: '12px', color: '#333',
minWidth: '220px', maxWidth: '320px',
}}>
{t.msg}
</div>
))}
</div>
)
}
// ── Button ────────────────────────────────────────────────────────────────────
export function Btn({ children, onClick, variant = 'primary', size = 'md', disabled, type = 'button', style }: {
children: React.ReactNode; onClick?: () => void
variant?: 'primary' | 'ghost' | 'danger'; size?: 'sm' | 'md'
disabled?: boolean; type?: 'button' | 'submit'; style?: React.CSSProperties
}) {
const base: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: '5px',
border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
fontFamily: 'inherit', fontWeight: '500', transition: 'all 0.1s',
opacity: disabled ? 0.6 : 1,
padding: size === 'sm' ? '5px 10px' : '7px 14px',
fontSize: size === 'sm' ? '11px' : '12px',
borderRadius: '8px',
}
const variants = {
primary: { background: '#534AB7', color: 'white', border: 'none' },
ghost: { background: 'transparent', color: '#444', border: '0.5px solid #ddd' },
danger: { background: '#FCEBEB', color: '#791F1F', border: '0.5px solid #F09595' },
}
return (
<button type={type} onClick={onClick} disabled={disabled} style={{ ...base, ...variants[variant], ...style }}>
{children}
</button>
)
}
// ── Modal ─────────────────────────────────────────────────────────────────────
export function Modal({ open, onClose, title, children, width = 400 }: {
open: boolean; onClose: () => void; title: string
children: React.ReactNode; width?: number
}) {
if (!open) return null
return (
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 500, padding: '20px'
}} onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{
background: 'white', borderRadius: '12px',
border: '0.5px solid #e0e0e0',
padding: '20px', width: '100%', maxWidth: `${width}px`
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div style={{ fontSize: '14px', fontWeight: '500' }}>{title}</div>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#aaa', fontSize: '18px' }}>×</button>
</div>
{children}
</div>
</div>
)
}
// ── Form helpers ──────────────────────────────────────────────────────────────
export function Field({ label, children, required }: { label: string; children: React.ReactNode; required?: boolean }) {
return (
<div style={{ marginBottom: '12px' }}>
<label style={{ display: 'block', fontSize: '11px', color: '#666', marginBottom: '4px', fontWeight: '500' }}>
{label}{required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}
</label>
{children}
</div>
)
}
const inputStyle: React.CSSProperties = {
width: '100%', padding: '7px 10px', fontSize: '12px',
border: '0.5px solid #ddd', borderRadius: '8px',
background: 'transparent', color: '#1a1a1a', fontFamily: 'inherit', outline: 'none'
}
export const Input = (props: React.InputHTMLAttributes<HTMLInputElement>) =>
<input {...props} style={{ ...inputStyle, ...props.style }}/>
export const Select = (props: React.SelectHTMLAttributes<HTMLSelectElement>) =>
<select {...props} style={{ ...inputStyle, background: 'white', ...props.style }}/>
export const Textarea = (props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) =>
<textarea {...props} style={{ ...inputStyle, resize: 'vertical', minHeight: '70px', lineHeight: '1.5', ...props.style }}/>