@@ -0,0 +1,355 @@
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 >
)
}