ad499f6782
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>
244 lines
11 KiB
TypeScript
244 lines
11 KiB
TypeScript
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 }}/>
|