Files
qms/init-source/qms-update-2/pages/admin/forms.tsx
T
2026-06-15 16:21:53 -05:00

356 lines
19 KiB
TypeScript
Raw 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 { useState, useEffect } from 'react'
import Layout from '@/components/layout/Layout'
import { Card, EmptyState, Btn, Modal, Field, Input, Select, Textarea, showToast, Tag, StatusDot, Table } from '@/components/ui'
import { FormFieldList, FormFieldDef } from '@/components/forms/FieldRenderer'
import { FieldType } from '@/types'
const FIELD_TYPES: { value: FieldType; label: string }[] = [
{ value: 'SHORT_TEXT', label: 'Short text' },
{ value: 'LONG_TEXT', label: 'Long text' },
{ value: 'NUMBER', label: 'Number' },
{ value: 'DATE', label: 'Date' },
{ value: 'SINGLE_CHOICE', label: 'Single choice' },
{ value: 'MULTI_CHOICE', label: 'Multi-choice' },
{ value: 'RATING', label: 'Rating (15)' },
{ value: 'PHOTO', label: 'Photo / file' },
]
const STATUS_COLOR: Record<string, string> = {
DRAFT: 'purple', ACTIVE: 'green', SUSPENDED: 'amber',
REVIEW_READY: 'blue', STANDARD_SET: 'teal', ARCHIVED: 'gray',
}
interface BuilderField extends FormFieldDef {
order: number
}
export default function FormBuilderPage() {
const [forms, setForms] = useState<any[]>([])
const [loading, setLoading] = useState(true)
// Builder modal
const [builderOpen, setBuilderOpen] = useState(false)
const [builderMode, setBuilderMode] = useState<'create' | 'edit'>('create')
const [editingId, setEditingId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [formMeta, setFormMeta] = useState({ name: '', product: '', description: '', minSubmissions: 10 })
const [fields, setFields] = useState<BuilderField[]>([])
const [newField, setNewField] = useState({ label: '', type: 'SHORT_TEXT' as FieldType, hint: '', required: false, options: '' })
// Preview modal
const [previewOpen, setPreviewOpen] = useState(false)
const [previewFields, setPreviewFields] = useState<FormFieldDef[]>([])
const [previewTitle, setPreviewTitle] = useState('')
const [previewSubtitle, setPreviewSubtitle] = useState('')
const [previewAnswers, setPreviewAnswers] = useState<Record<string, any>>({})
// Active vs Archived view
const [tab, setTab] = useState<'active' | 'archived'>('active')
useEffect(() => { loadForms() }, [])
async function loadForms() {
setLoading(true)
const res = await fetch('/api/forms')
if (res.ok) {
const { data } = await res.json()
setForms(data || [])
}
setLoading(false)
}
// ── Builder open/close ──────────────────────────────────────────────────
function openCreate() {
setFormMeta({ name: '', product: '', description: '', minSubmissions: 10 })
setFields([])
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
setEditingId(null)
setBuilderMode('create')
setBuilderOpen(true)
}
function openEdit(form: any) {
setFormMeta({
name: form.name,
product: form.product || '',
description: form.description || '',
minSubmissions: form.minSubmissions,
})
setFields((form.fields || []).map((f: any) => ({
id: f.id, label: f.label, type: f.type, hint: f.hint || '',
required: !!f.required, options: f.options || [], order: f.order ?? 0,
})))
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
setEditingId(form.id)
setBuilderMode('edit')
setBuilderOpen(true)
}
// ── Field add/remove ─────────────────────────────────────────────────────
function addField() {
if (!newField.label) return
const opts = newField.options.split('\n').map(o => o.trim()).filter(Boolean)
setFields(f => [...f, { ...newField, options: opts, order: f.length, id: `new_${Date.now()}` }])
setNewField({ label: '', type: 'SHORT_TEXT', hint: '', required: false, options: '' })
}
function removeField(index: number) {
setFields(fs => fs.filter((_, i) => i !== index))
}
// ── Save (create or edit) ───────────────────────────────────────────────
async function saveForm() {
if (!formMeta.name) return
setSaving(true)
const url = builderMode === 'edit' ? `/api/forms/${editingId}` : '/api/forms'
const method = builderMode === 'edit' ? 'PATCH' : 'POST'
const res = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...formMeta, fields }),
})
setSaving(false)
if (res.ok) {
setBuilderOpen(false)
showToast(builderMode === 'edit' ? 'Form updated' : 'Form created — saved as draft')
loadForms()
} else {
showToast('Failed to save form', 'error')
}
}
// ── Status transitions ──────────────────────────────────────────────────
async function setStatus(id: string, status: string, successMsg: string) {
const res = await fetch(`/api/forms/${id}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status }),
})
if (res.ok) { showToast(successMsg); loadForms() }
else showToast('Update failed', 'error')
}
// ── Clone ────────────────────────────────────────────────────────────────
async function cloneForm(id: string) {
const res = await fetch(`/api/forms/${id}/clone`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
showToast(`Cloned as "${data.name}" — edit to customize`)
loadForms()
} else {
showToast('Clone failed', 'error')
}
}
// ── Preview ──────────────────────────────────────────────────────────────
function openPreview(fieldsToShow: FormFieldDef[], title: string, subtitle?: string) {
setPreviewFields(fieldsToShow)
setPreviewTitle(title || 'Untitled form')
setPreviewSubtitle(subtitle || '')
setPreviewAnswers({})
setPreviewOpen(true)
}
const activeForms = forms.filter(f => f.status !== 'ARCHIVED')
const archivedForms = forms.filter(f => f.status === 'ARCHIVED')
const displayedForms = tab === 'active' ? activeForms : archivedForms
function tabBtnStyle(isActive: boolean): React.CSSProperties {
return {
padding: '8px 4px', fontSize: '12px', fontWeight: isActive ? 500 : 400,
color: isActive ? '#534AB7' : '#888', background: 'none', border: 'none',
borderBottom: isActive ? '2px solid #534AB7' : '2px solid transparent',
cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', gap: '6px',
}
}
const needsOptions = ['SINGLE_CHOICE', 'MULTI_CHOICE'].includes(newField.type)
return (
<Layout title="Form builder">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
<div>
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: 0 }}>First build form builder</h2>
<p style={{ fontSize: '11px', color: '#aaa', margin: '2px 0 0' }}>Admin only design, edit, suspend, clone, and archive data collection forms</p>
</div>
<Btn onClick={openCreate}>+ New form</Btn>
</div>
<div style={{ display: 'flex', gap: '20px', borderBottom: '0.5px solid #eee' }}>
<button onClick={() => setTab('active')} style={tabBtnStyle(tab === 'active')}>
Active forms
<span style={{ fontSize: '10px', padding: '1px 6px', borderRadius: '9px', background: tab === 'active' ? '#EEEDFE' : '#f5f5f5', color: tab === 'active' ? '#3C3489' : '#999', fontWeight: 500 }}>{activeForms.length}</span>
</button>
<button onClick={() => setTab('archived')} style={tabBtnStyle(tab === 'archived')}>
Archived
<span style={{ fontSize: '10px', padding: '1px 6px', borderRadius: '9px', background: tab === 'archived' ? '#EEEDFE' : '#f5f5f5', color: tab === 'archived' ? '#3C3489' : '#999', fontWeight: 500 }}>{archivedForms.length}</span>
</button>
</div>
<Card style={{ borderTopLeftRadius: 0, borderTopRightRadius: 0 }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '32px', color: '#aaa', fontSize: '12px' }}>Loading</div>
) : displayedForms.length === 0 ? (
tab === 'archived' ? (
<EmptyState
title="No archived forms"
message="Forms you archive are moved here, out of the way — their data and submission history stay intact and they can be restored anytime."
/>
) : (
<EmptyState
title="No forms yet"
message="Create your first data collection form. Production teams will fill it out on the shop floor to help establish quality standards."
action={{ label: '+ Build first form', onClick: openCreate }}
/>
)
) : (
<div style={{ overflowX: 'auto' }}>
<Table headers={tab === 'archived'
? ['Form name', 'Product', 'Fields', 'Submissions', 'Archived', 'Actions']
: ['Form name', 'Product', 'Fields', 'Submissions', 'Target', 'Status', 'Actions']}>
{displayedForms.map((f: any) => (
<tr key={f.id}>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5' }}>
<div style={{ fontWeight: '500', fontSize: '12px' }}>{f.name}</div>
{f.clonedFromName && <div style={{ fontSize: '10px', color: '#aaa', marginTop: '1px' }}>Cloned from {f.clonedFromName}</div>}
</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>{f.product || '—'}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px' }}>{f._count?.fields || 0}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', fontWeight: '500', color: f._count?.submissions >= f.minSubmissions ? '#1D9E75' : '#333' }}>{f._count?.submissions || 0}</td>
{tab === 'archived' ? (
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>
{f.archivedAt ? new Date(f.archivedAt).toLocaleDateString() : '—'}
</td>
) : (
<>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', fontSize: '12px', color: '#888' }}>{f.minSubmissions}</td>
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', whiteSpace: 'nowrap' }}>
<StatusDot status={f.status}/>
<Tag color={STATUS_COLOR[f.status]}>{f.status.replace('_', ' ')}</Tag>
</td>
</>
)}
<td style={{ padding: '8px 6px', borderBottom: '0.5px solid #f5f5f5', minWidth: '220px' }}>
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
<Btn size="sm" variant="ghost" onClick={() => openPreview(f.fields || [], f.name, f.product)}>Preview</Btn>
<Btn size="sm" variant="ghost" onClick={() => openEdit(f)}>Edit</Btn>
<Btn size="sm" variant="ghost" onClick={() => cloneForm(f.id)}>Clone</Btn>
{tab === 'archived' ? (
<Btn size="sm" onClick={() => setStatus(f.id, 'DRAFT', 'Form restored to draft')}>Restore</Btn>
) : (
<>
{f.status === 'DRAFT' && (
<Btn size="sm" onClick={() => setStatus(f.id, 'ACTIVE', 'Form published — production team can now fill it')}>Publish</Btn>
)}
{f.status === 'ACTIVE' && (
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'SUSPENDED', 'Form suspended')}>Suspend</Btn>
)}
{f.status === 'SUSPENDED' && (
<Btn size="sm" onClick={() => setStatus(f.id, 'ACTIVE', 'Form reactivated')}>Reactivate</Btn>
)}
<Btn size="sm" variant="ghost" onClick={() => setStatus(f.id, 'ARCHIVED', 'Form archived')}>Archive</Btn>
</>
)}
</div>
</td>
</tr>
))}
</Table>
</div>
)}
</Card>
{/* Builder Modal (create / edit) */}
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title={builderMode === 'edit' ? `Edit form — ${formMeta.name || ''}` : 'Build new form'} width={640}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px' }}>
<div>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px' }}>Form details</div>
<Field label="Form name" required><Input value={formMeta.name} onChange={e => setFormMeta(m => ({ ...m, name: e.target.value }))} placeholder="First Build Data Sheet — Line 3"/></Field>
<Field label="Product / assembly"><Input value={formMeta.product} onChange={e => setFormMeta(m => ({ ...m, product: e.target.value }))} placeholder="Widget A — Rev 2"/></Field>
<Field label="Description"><Textarea value={formMeta.description} onChange={e => setFormMeta(m => ({ ...m, description: e.target.value }))} style={{ minHeight: '52px' }} placeholder="What data does this form collect?"/></Field>
<Field label="Min. submissions before QC review"><Input type="number" value={formMeta.minSubmissions} min={1} onChange={e => setFormMeta(m => ({ ...m, minSubmissions: parseInt(e.target.value) || 1 }))}/></Field>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px', marginTop: '6px' }}>Add field</div>
<Field label="Field label" required><Input value={newField.label} onChange={e => setNewField(f => ({ ...f, label: e.target.value }))} placeholder="e.g. Torque reading (Nm)"/></Field>
<Field label="Field type">
<Select value={newField.type} onChange={e => setNewField(f => ({ ...f, type: e.target.value as FieldType }))}>
{FIELD_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
</Select>
</Field>
{needsOptions && (
<Field label="Options (one per line)">
<Textarea value={newField.options} onChange={e => setNewField(f => ({ ...f, options: e.target.value }))} style={{ minHeight: '60px' }} placeholder={'Pass\nFail\nRework'}/>
</Field>
)}
<Field label="Helper hint"><Input value={newField.hint} onChange={e => setNewField(f => ({ ...f, hint: e.target.value }))} placeholder="Instruction shown to filler"/></Field>
<label style={{ display: 'flex', alignItems: 'center', gap: '7px', fontSize: '12px', cursor: 'pointer', marginBottom: '10px' }}>
<input type="checkbox" checked={newField.required} onChange={e => setNewField(f => ({ ...f, required: e.target.checked }))} style={{ accentColor: '#534AB7' }}/>
Required field
</label>
<Btn variant="ghost" onClick={addField} style={{ width: '100%', justifyContent: 'center' }}>+ Add field</Btn>
</div>
<div>
<div style={{ fontSize: '11px', fontWeight: '500', color: '#aaa', textTransform: 'uppercase', letterSpacing: '0.6px', marginBottom: '10px' }}>Fields ({fields.length})</div>
{fields.length === 0 ? (
<div style={{ border: '0.5px dashed #ddd', borderRadius: '8px', padding: '24px', textAlign: 'center', color: '#bbb', fontSize: '11px' }}>
Add fields from the left
</div>
) : fields.map((f, i) => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: '8px',
padding: '8px 10px', border: `0.5px solid ${f.required ? '#AFA9EC' : '#eee'}`,
borderRadius: '8px', marginBottom: '5px', background: f.required ? '#FAFAFE' : 'white'
}}>
<span style={{ fontSize: '10px', color: '#bbb', width: '16px', textAlign: 'center' }}>{i + 1}</span>
<div style={{ flex: 1 }}>
<div style={{ fontSize: '12px', fontWeight: '500' }}>{f.label}{f.required && <span style={{ color: '#E24B4A', marginLeft: '2px' }}>*</span>}</div>
<div style={{ fontSize: '10px', color: '#aaa' }}>{FIELD_TYPES.find(t => t.value === f.type)?.label}</div>
</div>
<button onClick={() => removeField(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#ddd', fontSize: '16px' }} aria-label="Remove field">×</button>
</div>
))}
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '16px', paddingTop: '14px', borderTop: '0.5px solid #eee' }}>
<Btn
variant="ghost"
onClick={() => openPreview(fields, formMeta.name || 'Untitled form', formMeta.product)}
disabled={fields.length === 0}
>
Preview form
</Btn>
<div style={{ display: 'flex', gap: '8px' }}>
<Btn variant="ghost" onClick={() => setBuilderOpen(false)}>Cancel</Btn>
<Btn onClick={saveForm} disabled={saving || !formMeta.name}>
{saving ? 'Saving…' : builderMode === 'edit' ? 'Save changes' : 'Create form (saved as draft)'}
</Btn>
</div>
</div>
</Modal>
{/* Preview Modal */}
<Modal open={previewOpen} onClose={() => setPreviewOpen(false)} title={`Preview — ${previewTitle}`} width={480}>
<div style={{ background: '#EEEDFE', borderRadius: '8px', padding: '9px 12px', fontSize: '11px', color: '#3C3489', marginBottom: '14px' }}>
Preview mode interact freely. Nothing here is saved. This is exactly what the production team will see.
</div>
{previewSubtitle && <div style={{ fontSize: '11px', color: '#aaa', marginBottom: '12px' }}>{previewSubtitle}</div>}
{previewFields.length === 0 ? (
<EmptyState title="No fields yet" message="Add fields in the builder, then preview here."/>
) : (
<FormFieldList
fields={previewFields}
values={previewAnswers}
onChange={(fieldId, value) => setPreviewAnswers(a => ({ ...a, [fieldId]: value }))}
/>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '0.5px solid #eee' }}>
<Btn variant="ghost" onClick={() => setPreviewAnswers({})}>Reset answers</Btn>
<Btn variant="ghost" onClick={() => setPreviewOpen(false)} style={{ marginLeft: 'auto' }}>Close preview</Btn>
</div>
</Modal>
</Layout>
)
}