Files
jason ad499f6782
Build and Push Docker Image / build (push) Successful in 1m12s
Assemble QMS app + SQLite refactor + Unraid single-container deploy
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>
2026-06-15 16:58:47 -05:00

244 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 }}/>