@ -8,6 +8,7 @@ import {
import {
assignClientModule , getClient , getClientModules , getLedger , getModules ,
patchClient , patchClientModule , revealDbPassword , revealModulePassword ,
setModuleFields , setModuleSecret , revealModuleSecret ,
role , isManagerial , getEmployees , setClientOwner ,
getRecurringPlans , deactivateRecurringPlan ,
getAmc , deactivateAmc , generateAmcRenewalInvoice ,
@ -16,7 +17,7 @@ import {
getBranches , createBranch , patchBranch , getTickets ,
CLIENT_STATUSES , KIND_LABEL ,
type AmcContract , type AmcPaidStatus , type Branch , type Client , type ClientModule ,
type ClientStatus , type Interaction, type Outcome , type RecurringPlan , type ServiceDetail ,
type ClientStatus , type FieldDef, type Interaction, type Outcome , type RecurringPlan , type ServiceDetail ,
} from '../api'
import { CLIENT_TONE , DOC_TONE , useData } from './Clients'
import {
@ -275,7 +276,13 @@ export function ClientDetail() {
) }
{ ( cms . data ? ? [ ] ) . length > 0 && < h3 style = { { marginTop : 20 } } > Service data < / h3 > }
{ ( cms . data ? ? [ ] ) . map ( ( cm ) = > (
< ServiceDataCard key = { cm . id } cm = { cm } name = { module Name ( cm . module Id ) } onChanged = { ( ) = > cms . reload ( ) } / >
< ServiceDataCard
key = { cm . id }
cm = { cm }
name = { module Name ( cm . module Id ) }
fieldSpec = { module s.data?.find ( ( m ) = > m . id === cm . module Id ) ? . fieldSpec ? ? [ ] }
onChanged = { ( ) = > cms . reload ( ) }
/ >
) ) }
< / >
) }
@ -750,7 +757,7 @@ function BranchList(props: { clientId: string; branches: Branch[]; onChanged: ()
* switching provider / username / remark + the labeled details into inputs — one
* audited PATCH saves the lot .
* /
function ServiceDataCard ( props : { cm : ClientModule ; name : string ; onChanged: ( ) = > void } ) {
function ServiceDataCard ( props : { cm : ClientModule ; name : string ; fieldSpec: FieldDef [ ] ; onChanged: ( ) = > void } ) {
const toast = useToast ( )
const managerial = isManagerial ( )
const cm = props . cm
@ -841,7 +848,8 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; onChanged: ()
}
return (
< div className = "wf-card" style = { { display : 'flex' , alignItems : 'center' , flexWrap : 'wrap' , gap : 8 , padding : '10px 14px' } } >
< div className = "wf-card" style = { { padding : '10px 14px' } } >
< div style = { { display : 'flex' , alignItems : 'center' , flexWrap : 'wrap' , gap : 8 } } >
< span style = { { fontWeight : 600 , marginRight : 10 } } > { props . name } < / span >
{ cell ( 'Provider' , cm . provider ? ? undefined ) }
{ cell ( 'Username' , cm . username ? ? undefined , true , true ) }
@ -867,5 +875,161 @@ function ServiceDataCard(props: { cm: ClientModule; name: string; onChanged: ()
< span style = { { flex : 1 } } / >
< Button onClick = { startEdit } > Edit < / Button >
< / div >
{ /* D21: dynamic form from the owning module's fieldSpec, below the D20 block. */ }
{ props . fieldSpec . length > 0 && (
< >
< div style = { { borderTop : '1px solid var(--border)' , margin : '12px 0 10px' } } / >
< ModuleFieldsForm cm = { cm } fieldSpec = { props . fieldSpec } onChanged = { props . onChanged } / >
< / >
) }
< / div >
)
}
/ * *
* D21 module - defined fields ( config over code ) : renders one input per field the owning
* module DECLARES in its fieldSpec . Non - secret edits collect into a single audited PUT
* ( setModuleFields ) ; secret fields never leave the server unrevealed — • • • • with a
* managerial audited Reveal + Set / Update ( setModuleSecret ) , exactly like the portal
* password . Values come from cm . fieldValues ; secret set - state from cm . secretKeys .
* /
function ModuleFieldsForm ( props : { cm : ClientModule ; fieldSpec : FieldDef [ ] ; onChanged : ( ) = > void } ) {
const toast = useToast ( )
const managerial = isManagerial ( )
const cm = props . cm
const sorted = [ . . . props . fieldSpec ] . sort ( ( a , b ) = > ( a . sort ? ? 0 ) - ( b . sort ? ? 0 ) )
const nonSecret = sorted . filter ( ( f ) = > f . type !== 'secret' )
const secrets = sorted . filter ( ( f ) = > f . type === 'secret' )
const seed = ( ) : Record < string , string > = >
Object . fromEntries ( nonSecret . map ( ( f ) = > [ f . key , cm . fieldValues [ f . key ] ? ? '' ] ) )
const [ draft , setDraft ] = useState < Record < string , string > > ( seed )
const [ busy , setBusy ] = useState ( false )
const dirty = nonSecret . some ( ( f ) = > ( draft [ f . key ] ? ? '' ) !== ( cm . fieldValues [ f . key ] ? ? '' ) )
const missingRequired = nonSecret . find ( ( f ) = > f . required === true && ( draft [ f . key ] ? ? '' ) . trim ( ) === '' )
const save = ( ) = > {
if ( busy ) return
if ( missingRequired !== undefined ) { toast . err ( ` ${ missingRequired . label } is required ` ) ; return }
setBusy ( true )
// Send only the changed keys; '' clears server-side.
const values = Object . fromEntries (
nonSecret
. filter ( ( f ) = > ( draft [ f . key ] ? ? '' ) !== ( cm . fieldValues [ f . key ] ? ? '' ) )
. map ( ( f ) = > [ f . key , draft [ f . key ] ? ? '' ] ) ,
)
setModuleFields ( cm . id , values )
. then ( ( updated ) = > {
toast . ok ( 'Fields saved' )
setDraft ( Object . fromEntries ( nonSecret . map ( ( f ) = > [ f . key , updated . fieldValues [ f . key ] ? ? '' ] ) ) )
props . onChanged ( )
} )
. catch ( ( e : Error ) = > toast . err ( e . message ) )
. finally ( ( ) = > setBusy ( false ) )
}
const control = ( f : FieldDef ) = > {
const v = draft [ f . key ] ? ? ''
const set = ( val : string ) = > setDraft ( ( p ) = > ( { . . . p , [ f . key ] : val } ) )
if ( f . type === 'boolean' ) {
return (
< select className = "wf" style = { { width : 120 } } value = { v } onChange = { ( e ) = > set ( e . target . value ) } >
< option value = "" > — < / option >
< option value = "true" > true < / option >
< option value = "false" > false < / option >
< / select >
)
}
if ( f . type === 'select' ) {
return (
< select className = "wf" style = { { width : 180 } } value = { v } onChange = { ( e ) = > set ( e . target . value ) } >
< option value = "" > — < / option >
{ ( f . options ? ? [ ] ) . map ( ( o ) = > < option key = { o } value = { o } > { o } < / option > ) }
< / select >
)
}
if ( f . type === 'number' ) {
return < input className = "wf num" type = "number" style = { { width : 140 } } value = { v } onChange = { ( e ) = > set ( e . target . value ) } / >
}
if ( f . type === 'date' ) {
return < input className = "wf" type = "date" style = { { width : 160 } } value = { v } onChange = { ( e ) = > set ( e . target . value ) } / >
}
return < input className = "wf" style = { { width : 200 } } value = { v } onChange = { ( e ) = > set ( e . target . value ) } / >
}
return (
< div >
{ nonSecret . length > 0 && (
< >
< div style = { { display : 'flex' , gap : 14 , flexWrap : 'wrap' , alignItems : 'flex-end' } } >
{ nonSecret . map ( ( f ) = > (
< Field key = { f . key } label = { f . required === true ? ` ${ f . label } * ` : f . label } >
{ control ( f ) }
< / Field >
) ) }
< / div >
< Toolbar >
< Button tone = "primary" disabled = { busy || ! dirty } onClick = { save } > { busy ? 'Saving…' : 'Save fields' } < / Button >
< / Toolbar >
< / >
) }
{ secrets . map ( ( f ) = > (
< SecretField key = { f . key } cm = { cm } field = { f } managerial = { managerial } onChanged = { props . onChanged } / >
) ) }
< / div >
)
}
/** One `secret` module field: •••• with managerial audited Reveal/Copy/Hide + Set/Update. */
function SecretField ( props : { cm : ClientModule ; field : FieldDef ; managerial : boolean ; onChanged : ( ) = > void } ) {
const toast = useToast ( )
const { cm , field , managerial } = props
const has = cm . secretKeys . includes ( field . key )
const [ revealed , setRevealed ] = useState < string | undefined > ( )
const [ setting , setSetting ] = useState ( false )
const [ val , setVal ] = useState ( '' )
const [ busy , setBusy ] = useState ( false )
const copy = ( text : string ) = > {
navigator . clipboard . writeText ( text ) . then ( ( ) = > toast . ok ( ` ${ field . label } copied ` ) ) . catch ( ( ) = > toast . err ( 'Copy failed' ) )
}
const reveal = ( ) = > {
if ( busy ) return
setBusy ( true )
revealModuleSecret ( cm . id , field . key )
. then ( ( v ) = > setRevealed ( v ) )
. catch ( ( e : Error ) = > toast . err ( e . message ) )
. finally ( ( ) = > setBusy ( false ) )
}
const save = ( ) = > {
if ( busy ) return
setBusy ( true )
setModuleSecret ( cm . id , field . key , val )
. then ( ( ) = > { toast . ok ( ` ${ field . label } stored (encrypted) ` ) ; setSetting ( false ) ; setVal ( '' ) ; setRevealed ( undefined ) ; props . onChanged ( ) } )
. catch ( ( e : Error ) = > toast . err ( e . message ) )
. finally ( ( ) = > setBusy ( false ) )
}
return (
< div style = { { display : 'inline-flex' , alignItems : 'baseline' , gap : 6 , marginTop : 8 , marginRight : 18 } } >
< span style = { { fontSize : 11 , textTransform : 'uppercase' , letterSpacing : 0.5 , opacity : 0.6 } } >
{ field . required === true ? ` ${ field . label } * ` : field . label }
< / span >
{ revealed !== undefined
? < > < span className = "mono" > { revealed } < / span > < Button onClick = { ( ) = > copy ( revealed ) } > Copy < / Button > < Button onClick = { ( ) = > setRevealed ( undefined ) } > Hide < / Button > < / >
: has
? < > < span className = "mono" > • • • • • • • • < / span > { managerial && < Button onClick = { reveal } > { busy ? '…' : 'Reveal' } < / Button > } < / >
: < span style = { { opacity : 0.6 } } > not set < / span > }
{ managerial && ! setting && < Button onClick = { ( ) = > setSetting ( true ) } > { has ? 'Update' : 'Set…' } < / Button > }
{ managerial && setting && (
< >
< input className = "wf mono" type = "password" style = { { width : 160 } } placeholder = { field . label }
value = { val } onChange = { ( e ) = > setVal ( e . target . value ) } / >
< Button tone = "primary" disabled = { busy } onClick = { save } > { busy ? 'Saving…' : 'Save' } < / Button >
< Button onClick = { ( ) = > { setSetting ( false ) ; setVal ( '' ) } } > Cancel < / Button >
< / >
) }
< / div >
)
}