356 lines
19 KiB
TypeScript
356 lines
19 KiB
TypeScript
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 (1–5)' },
|
||
{ 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>
|
||
)
|
||
}
|