@ -443,125 +443,176 @@ function QuoteContent(props: { module: Module; isOwner: boolean; onSaved: () =>
}
/ * *
* D21 "Fields" editor — where a module DEFINES ITSELF ( config over code ) : the list of
* operational fields ( key / label / type / options / required / help ) that every client of this
* module then fills in on its service card . Owner edits ; staff read a compact list .
* A blank options list on a ` select ` and a bad key are rejected server - side ( validateFieldSpec ) .
* D21 "Fields" — where a module DEFINES ITSELF ( config over code ) : the details every
* client of this module then fills in on its service card . Friendly UX ( 2026 - 07 ) : existing
* fields are cards ; Add / Edit go through a plain - language dialog ; the internal key is
* auto - derived from the field name and never shown to the user . Each add / edit / remove / reorder
* persists immediately ( patchModule ) , so there ' s no separate "save" step .
* /
interface FieldDraft { key : string ; label : string ; type : FieldType ; options : string ; required : boolean ; help : string }
const toDraft = ( f : FieldDef ) : FieldDraft = > ( {
key : f.key , label : f.label , type : f . type ,
options : ( f . options ? ? [ ] ) . join ( ', ' ) , required : f.required === true , help : f.help ? ? '' ,
} )
type FriendlyType = FieldType
const TYPE_LABEL : Record < FriendlyType , string > = {
text : 'Text' , number : 'Number' , date : 'Date' , boolean : 'Yes / No' ,
select : 'Choice from a list' , secret : 'Secret (hidden, revealable)' ,
}
const TYPE_HINT : Record < FriendlyType , string > = {
text : 'Any words or value — names, IDs, notes.' ,
number : 'A number only.' ,
date : 'A calendar date.' ,
boolean : 'A simple yes or no.' ,
select : 'Pick one from a fixed list you set.' ,
secret : 'A password/key — stored hidden, shown only on request.' ,
}
/** Make a valid lower_snake_case key from a human label, unique against the taken set. */
function keyFromLabel ( label : string , taken : Set < string > ) : string {
let base = label . trim ( ) . toLowerCase ( ) . replace ( /[^a-z0-9]+/g , '_' ) . replace ( /^_+|_+$/g , '' )
if ( base === '' || ! /^[a-z]/ . test ( base ) ) base = ` field_ ${ base } ` . replace ( /_+$/g , '' )
let key = base , n = 2
while ( taken . has ( key ) ) { key = ` ${ base } _ ${ n ++ } ` }
return key
}
interface FieldEdit { index : number | null ; key : string ; label : string ; type : FriendlyType ; options : string ; required : boolean ; help : string }
const EMPTY_EDIT : FieldEdit = { index : null , key : '' , label : '' , type : 'text' , options : '' , required : false , help : '' }
function FieldSpecEditor ( props : { module : Module ; isOwner : boolean ; onSaved : ( ) = > void } ) {
const toast = useToast ( )
const [ rows , setRows ] = useState < FieldDraft [ ] > ( ( ) = > props . module .fieldSpec.map ( toDraft ) )
const [ fields , setFields ] = useState < FieldDef [ ] > ( ( ) = > [ . . . props . module .fieldSpec ] )
const [ edit , setEdit ] = useState < FieldEdit | null > ( null )
const [ busy , setBusy ] = useState ( false )
const [ error , setError ] = useState < string | undefined > ( )
const [ saving , setSaving ] = useState ( false )
const setRow = ( i : number , patch : Partial < FieldDraft > ) = >
setRows ( ( prev ) = > prev . map ( ( r , j ) = > ( j === i ? { . . . r , . . . patch } : r ) ) )
const move = ( i : number , dir : - 1 | 1 ) = >
setRows ( ( prev ) = > {
/** Persist a new ordered field list, re-stamping sort to match on-screen order. */
const persist = ( next : FieldDef [ ] ) = > {
const spec = next . map ( ( f , i ) = > ( { . . . f , sort : i } ) )
setBusy ( true ) ; setError ( undefined )
patchModule ( props . module .id , { fieldSpec : spec } )
. then ( ( ) = > { setFields ( spec ) ; props . onSaved ( ) } )
. catch ( ( e : Error ) = > { setError ( e . message ) ; toast . err ( e . message ) } )
. finally ( ( ) = > setBusy ( false ) )
}
const move = ( i : number , dir : - 1 | 1 ) = > {
const j = i + dir
if ( j < 0 || j >= prev . length ) return prev
const next = [ . . . prev ]
if ( j < 0 || j >= fields . length ) return
const next = [ . . . fields ]
; [ next [ i ] , next [ j ] ] = [ next [ j ] ! , next [ i ] ! ]
return next
} )
persist ( next )
}
const remove = ( i : number ) = > persist ( fields . filter ( ( _f , j ) = > j !== i ) )
const save = ( ) = > {
if ( saving ) return
const cleaned = rows . filter ( ( r ) = > r . key . trim ( ) !== '' || r . label . trim ( ) !== '' )
// sort follows on-screen order so the service card renders top-to-bottom as arranged.
const spec = cleaned . map ( ( r , i ) : FieldDef = > ( {
key : r.key.trim ( ) , label : r.label.trim ( ) , type : r . type , sort : i ,
. . . ( r . type === 'select'
? { options : r.options.split ( ',' ) . map ( ( o ) = > o . trim ( ) ) . filter ( ( o ) = > o !== '' ) }
: { } ) ,
. . . ( r . required ? { required : true } : { } ) ,
. . . ( r . help . trim ( ) !== '' ? { help : r.help.trim ( ) } : { } ) ,
} ) )
const openAdd = ( ) = > { setError ( undefined ) ; setEdit ( { . . . EMPTY_EDIT } ) }
const openEdit = ( f : FieldDef , i : number ) = > {
setError ( undefined )
setSaving ( true )
patchModule ( props . module .id , { fieldSpec : spec } )
. then ( ( ) = > { toast . ok ( 'Fields saved' ) ; setRows ( spec . map ( toDraft ) ) ; props . onSaved ( ) } )
. catch ( ( e : Error ) = > setError ( e . message ) )
. finally ( ( ) = > setSaving ( false ) )
setEdit ( { index : i , key : f.key , label : f.label , type : f . type , options : ( f . options ? ? [ ] ) . join ( ', ' ) , required : f.required === true , help : f.help ? ? '' } )
}
const saveEdit = ( ) = > {
if ( edit === null ) return
const label = edit . label . trim ( )
if ( label === '' ) { setError ( 'Give the field a name' ) ; return }
if ( edit . type === 'select' && edit . options . split ( ',' ) . map ( ( o ) = > o . trim ( ) ) . filter ( Boolean ) . length === 0 ) {
setError ( 'A “Choice from a list” field needs at least one option' ) ; return
}
// New fields get an auto-derived key; existing fields keep theirs (it ties to stored data).
const taken = new Set ( fields . filter ( ( _f , j ) = > j !== edit . index ) . map ( ( f ) = > f . key ) )
const key = edit . index === null ? keyFromLabel ( label , taken ) : edit . key
const field : FieldDef = {
key , label , type : edit . type , sort : 0 ,
. . . ( edit . type === 'select' ? { options : edit.options.split ( ',' ) . map ( ( o ) = > o . trim ( ) ) . filter ( Boolean ) } : { } ) ,
. . . ( edit . required ? { required : true } : { } ) ,
. . . ( edit . help . trim ( ) !== '' ? { help : edit.help.trim ( ) } : { } ) ,
}
const next = edit . index === null ? [ . . . fields , field ] : fields . map ( ( f , j ) = > ( j === edit . index ? field : f ) )
setEdit ( null )
persist ( next )
}
return (
< >
< h3 style = { { marginTop : 20 } } > Fields < span style = { { fontWeight : 400 , fontSize : 13 , opacity : 0.65 } } > — the details you record for each client on this module < / span > < / h3 >
{ ! props . isOwner ? (
props . module .fieldSpec.length === 0 ? < EmptyState > No fields defined . < / EmptyState > : (
< DataTable
columns = { [
{ key : 'label' , label : 'Label' } , { key : 'k' , label : 'Key' , mono : true } ,
{ key : 'type' , label : 'Type' } , { key : 'req' , label : 'Required' } ,
{ key : 'options' , label : 'Options' } , { key : 'help' , label : 'Help' } ,
] }
rows = { props . module .fieldSpec.map ( ( f ) = > ( {
label : f.label , k : f.key , type : f . type ,
req : f.required === true ? < Badge tone = "accent" > required < / Badge > : '—' ,
options : f.type === 'select' ? ( f . options ? ? [ ] ) . join ( ', ' ) : '—' ,
help : f.help !== undefined && f . help !== '' ? f . help : '—' ,
} ) ) }
/ >
)
{ fields . length === 0 ? (
< EmptyState > No fields yet { props . isOwner ? ' — add the first with the button below.' : '.' } < / EmptyState >
) : (
< div className = "wf-card" >
< p style = { { margin : '0 0 10px' , fontSize : 12 , color : 'var(--text-dim)' } } >
The operational fields every client of this module fills in — a new field shows up on
every service card immediately , no code change .
< / p >
{ rows . length === 0 ? < EmptyState > No fields yet — add the first below . < / EmptyState > : rows . map ( ( r , i ) = > (
< div key = { i } style = { { display : 'flex' , gap : 8 , alignItems : 'center' , flexWrap : 'wrap' , marginBottom : 8 } } >
< span style = { { display : 'flex' , flexDirection : 'column' , gap : 2 } } >
< Button disabled = { i === 0 } aria - label = "Move field up" onClick = { ( ) = > move ( i , - 1 ) } > ↑ < / Button >
< Button disabled = { i === rows . length - 1 } aria - label = "Move field down" onClick = { ( ) = > move ( i , 1 ) } > ↓ < / Button >
< div style = { { display : 'flex' , flexDirection : 'column' , gap : 8 } } >
{ fields . map ( ( f , i ) = > (
< div key = { f . key } className = "wf-card" style = { { padding : '10px 14px' , display : 'flex' , alignItems : 'center' , gap : 12 , flexWrap : 'wrap' } } >
< div style = { { flex : 1 , minWidth : 200 } } >
< div style = { { fontWeight : 600 } } >
{ f . label }
{ f . required === true && < Badge tone = "accent" > required < / Badge > }
< / div >
< div style = { { fontSize : 12 , color : 'var(--text-dim)' , marginTop : 2 } } >
{ TYPE_LABEL [ f . type ] }
{ f . type === 'select' && ( f . options ? ? [ ] ) . length > 0 ? ` · ${ ( f . options ? ? [ ] ) . join ( ', ' ) } ` : '' }
{ f . help !== undefined && f . help !== '' ? ` · ${ f . help } ` : '' }
< / div >
< / div >
{ props . isOwner && (
< span style = { { display : 'flex' , gap : 6 , alignItems : 'center' } } >
< Button disabled = { busy || i === 0 } aria - label = "Move up" onClick = { ( ) = > move ( i , - 1 ) } > ↑ < / Button >
< Button disabled = { busy || i === fields . length - 1 } aria - label = "Move down" onClick = { ( ) = > move ( i , 1 ) } > ↓ < / Button >
< Button disabled = { busy } onClick = { ( ) = > openEdit ( f , i ) } > Edit < / Button >
< Button tone = "danger" disabled = { busy } aria - label = { ` Remove ${ f . label } ` } onClick = { ( ) = > remove ( i ) } > Remove < / Button >
< / span >
< input
className = "wf" placeholder = "Label" style = { { width : 160 } } aria - label = "Field label"
value = { r . label } onChange = { ( e ) = > setRow ( i , { label : e.target.value } ) }
/ >
< input
className = "wf mono" placeholder = "key" style = { { width : 130 } } aria - label = "Field key"
value = { r . key } onChange = { ( e ) = > setRow ( i , { key : e.target.value } ) }
/ >
< select
className = "wf" style = { { width : 110 } } aria - label = "Field type"
value = { r . type } onChange = { ( e ) = > setRow ( i , { type : e . target . value as FieldType } ) }
>
{ FIELD_TYPES . map ( ( t ) = > < option key = { t } value = { t } > { t } < / option > ) }
< / select >
{ r . type === 'select' && (
< input
className = "wf" placeholder = "options, comma-separated" style = { { width : 200 } } aria - label = "Select options"
value = { r . options } onChange = { ( e ) = > setRow ( i , { options : e.target.value } ) }
/ >
) }
< input
className = "wf" placeholder = "Help (optional)" style = { { width : 180 } } aria - label = "Field help"
value = { r . help } onChange = { ( e ) = > setRow ( i , { help : e.target.value } ) }
/ >
< label style = { { display : 'flex' , gap : 4 , alignItems : 'center' , fontSize : 13 } } >
< input type = "checkbox" checked = { r . required } onChange = { ( e ) = > setRow ( i , { required : e.target.checked } ) } / >
required
< / label >
< Button tone = "danger" aria - label = "Remove field" onClick = { ( ) = > setRows ( ( prev ) = > prev . filter ( ( _x , j ) = > j !== i ) ) } > ✕ < / Button >
< / div >
) ) }
< / div >
) }
{ props . isOwner && (
< Toolbar >
< Button onClick = { ( ) = > setRows ( ( prev ) = > [ . . . prev , { key : '' , label : '' , type : 'text' , options : '' , required : false , help : '' } ] ) } >
+ Add field
< / Button >
< span style = { { flex : 1 } } / >
< Button tone = "primary" disabled = { saving } onClick = { save } > { saving ? 'Saving…' : 'Save fields' } < / Button >
< Button tone = "primary" disabled = { busy } onClick = { openAdd } > + Add a field < / Button >
< span style = { { fontSize : 12 , color : 'var(--text-dim)' } } >
{ busy ? 'Saving…' : 'Changes save as you go. New fields appear on every client of this module.' }
< / span >
< / Toolbar >
) }
{ error !== undefined && edit === null && < Notice tone = "err" > { error } < / Notice > }
{ edit !== null && (
< Dialog
open
onClose = { ( ) = > setEdit ( null ) }
title = { edit . index === null ? 'Add a field' : ` Edit field — ${ edit . label || '' } ` }
footer = {
< >
< Button pill onClick = { ( ) = > setEdit ( null ) } > Cancel < / Button >
< Button pill tone = "primary" onClick = { saveEdit } > { edit . index === null ? 'Add field' : 'Save field' } < / Button >
< / >
}
>
< FormGrid >
< FormField label = "Field name" wide >
< input className = "wf" autoFocus placeholder = "e.g. Installation date, Admin username…"
value = { edit . label } onChange = { ( e ) = > setEdit ( { . . . edit , label : e.target.value } ) } / >
< / FormField >
< FormField label = "What kind of information?" wide >
< select className = "wf" value = { edit . type } onChange = { ( e ) = > setEdit ( { . . . edit , type : e . target . value as FriendlyType } ) } >
{ FIELD_TYPES . map ( ( t ) = > < option key = { t } value = { t } > { TYPE_LABEL [ t ] } < / option > ) }
< / select >
< span style = { { display : 'block' , fontSize : 12 , opacity : 0.6 , marginTop : 4 } } > { TYPE_HINT [ edit . type ] } < / span >
< / FormField >
{ edit . type === 'select' && (
< FormField label = "Options (one per comma)" wide >
< input className = "wf" placeholder = "e.g. Paid, Pending, Overdue"
value = { edit . options } onChange = { ( e ) = > setEdit ( { . . . edit , options : e.target.value } ) } / >
< / FormField >
) }
< FormField label = "Hint (optional)" wide >
< input className = "wf" placeholder = "A note shown under the field to whoever fills it in"
value = { edit . help } onChange = { ( e ) = > setEdit ( { . . . edit , help : e.target.value } ) } / >
< / FormField >
< FormField label = "Required?" >
< label style = { { display : 'inline-flex' , gap : 6 , alignItems : 'center' } } >
< input type = "checkbox" checked = { edit . required } onChange = { ( e ) = > setEdit ( { . . . edit , required : e.target.checked } ) } / >
Must be filled in
< / label >
< / FormField >
< / FormGrid >
{ error !== undefined && < Notice tone = "err" > { error } < / Notice > }
< / div >
< / Dialog >
) }
< / >
)