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
+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 }}/>