Assemble QMS app + SQLite refactor + Unraid single-container deploy
Build and Push Docker Image / build (push) Successful in 1m12s
Build and Push Docker Image / build (push) Successful in 1m12s
Reconstruct the full app from init-source overlays (base + fix-1..6 + update-1..3, last-wins) at the repo root, complete the missing pieces so it builds and runs, and stage the Unraid deployment. App completion: - types/index.ts: former Prisma enums as string-literal unions + AppUser - pages/_app.tsx + styles/globals.css (mount AppProvider/ToastProvider) - API routes: auth/login, auth/me, users, submissions (+REVIEW_READY notify), forms (list/create), notifications - scripts/create-admin.js: idempotent first-admin bootstrap - 14 unbuilt nav targets stubbed via ComingSoon placeholder SQLite refactor (single-container, no external DB): - schema provider -> sqlite; enums -> String; Json -> String; FormField.options String[] -> JSON-encoded String - lib/forms.ts (de)serialises options at the DB boundary - drop mode:"insensitive" (unsupported on SQLite) - enum imports repointed from @prisma/client to @/types Deploy: - multi-stage Dockerfile (next build -> prod runner), docker-entrypoint.sh (prisma db push -> create-admin -> next start), .dockerignore - docker-compose.yml: br0 10.2.0.x, /mnt/user/appdata/qms -> /data volume - README rewritten for the Unraid/Gitea Actions flow; .env scrubbed of the live Supabase credential; vercel.json removed Verified: next build clean (41 routes); live SQLite round-trip of login/session, form options array, and submission -> REVIEW_READY. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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,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 }}/>
|
||||
Reference in New Issue
Block a user