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,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>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user