@ -65,7 +65,9 @@ export function ClientDetail() {
const branches = useData ( ( ) = > getBranches ( id ) , [ id ] )
const [ ticketPage , setTicketPage ] = useState ( 1 )
const tickets = useData ( ( ) = > getTickets ( { clientId : id , page : ticketPage , pageSize : 50 } ) , [ id , ticketPage ] )
const [ ticketDialog , setTicketDialog ] = useState ( false )
// Undefined = closed. `moduleCode` pre-fills the module when the ticket is raised from
// a module block ("Raise ticket for this module") rather than the Tickets tab button.
const [ ticketDialog , setTicketDialog ] = useState < { module Code? : string } | undefined > ( )
const isOwner = role ( ) === 'owner'
const canRoute = isManagerial ( )
const toast = useToast ( )
@ -88,6 +90,11 @@ export function ClientDetail() {
const [ credsDialog , setCredsDialog ] = useState < { cm : ClientModule ; key : string ; label : string } | undefined > ( )
const [ savingCreds , setSavingCreds ] = useState ( false )
const [ credsError , setCredsError ] = useState < string | undefined > ( )
// Ticking the quotation step opens this document picker instead of a bare toggle — same
// state shape as the payment picker above (the ledger is already loaded here).
const [ docPicker , setDocPicker ] = useState < { clientModuleId : string ; key : string ; label : string } | undefined > ( )
const [ linkingDoc , setLinkingDoc ] = useState ( false )
const [ docLinkError , setDocLinkError ] = useState < string | undefined > ( )
const rawTab = sp . get ( 'tab' ) ? ? 'overview'
const tab : TabKey = ( TAB_KEYS as readonly string [ ] ) . includes ( rawTab ) ? ( rawTab as TabKey ) : 'overview'
@ -312,8 +319,14 @@ export function ClientDetail() {
< span className = "mod-name" > { module Name ( cm . module Id ) } < / span >
< Badge tone = { cm . status === 'live' ? 'ok' : undefined } > { cm . status } < / Badge >
< Badge > { KIND_LABEL [ cm . kind ] } < / Badge >
< span onClick = { ( e ) = > e . stopPropagation ( ) } >
< span onClick = { ( e ) = > e . stopPropagation ( ) } style = { { display : 'inline-flex' , gap : 6 } } >
< ClientModuleStatusSelect cm = { cm } onPatched = { ( ) = > cms . reload ( ) } onError = { toast . err } / >
{ /* Support calls arrive per module — raise one without leaving the block. */ }
< Button onClick = { ( ) = > setTicketDialog ( {
module Code : module s.data?.find ( ( m ) = > m . id === cm . module Id ) ? . code ? ? '' ,
} ) } >
Raise ticket for this module
< / Button >
< / span >
< / summary >
< div className = "mod-section-body" >
@ -329,6 +342,7 @@ export function ClientDetail() {
refreshToken = { milestonesVersion }
onTickPayment = { ( key , label ) = > { setLinkError ( undefined ) ; setPaymentPicker ( { clientModuleId : cm.id , key , label } ) } }
onTickCreds = { ( key , label ) = > { setCredsError ( undefined ) ; setCredsDialog ( { cm , key , label } ) } }
onTickDocument = { ( key , label ) = > { setDocLinkError ( undefined ) ; setDocPicker ( { clientModuleId : cm.id , key , label } ) } }
/ >
< ModuleDocuments
cm = { cm }
@ -345,7 +359,7 @@ export function ClientDetail() {
{ tab === 'tickets' && (
< >
< Toolbar >
< Button tone = "primary" onClick = { ( ) = > setTicketDialog ( true ) } > New ticket < / Button >
< Button tone = "primary" onClick = { ( ) = > setTicketDialog ( { } ) } > New ticket < / Button >
< span style = { { flex : 1 } } / >
{ tickets . data !== undefined && < Badge > { tickets . data . total } total < / Badge > }
< / Toolbar >
@ -365,7 +379,10 @@ export function ClientDetail() {
rows = { tickets . data . tickets . map ( ( t ) = > ( {
opened : t.openedOn ,
branch : t.branchName ? ? '—' ,
module : t.moduleCode ? ? '—' ,
// Deep-link to this client's Modules tab — the project behind the ticket.
module : t.moduleCode !== null && t . module Code !== ''
? < Link to = { ` /clients/ ${ id } ?tab=modules ` } > { t . module Code } < / Link >
: '—' ,
kind : t.kind !== '' ? t . kind : '—' ,
desc : < TicketDescription text = { t . description } / > ,
assigned : t.assignedName ? ? '—' ,
@ -383,13 +400,6 @@ export function ClientDetail() {
) }
< / >
) }
< NewTicketDialog
open = { ticketDialog }
onClose = { ( ) = > setTicketDialog ( false ) }
onCreated = { ( ) = > tickets . reload ( ) }
kinds = { tickets . data ? . kinds ? ? [ ] }
initialClient = { { id : c.id , code : c.code , name : c.name } }
/ >
< / >
) }
@ -677,6 +687,40 @@ export function ClientDetail() {
onRecordNew = { ( ) = > { setPaymentPicker ( undefined ) ; setTab ( 'payments' ) } }
/ >
) }
{ docPicker !== undefined && (
< DocumentPickerDialog
open
stepLabel = { docPicker . label }
docs = { docsNewest . filter ( ( d ) = > d . docType === 'QUOTATION' || d . docType === 'PROFORMA' ) }
saving = { linkingDoc }
error = { docLinkError }
onClose = { ( ) = > { if ( ! linkingDoc ) setDocPicker ( undefined ) } }
onPick = { ( documentId ) = > {
if ( linkingDoc ) return
setLinkingDoc ( true )
setDocLinkError ( undefined )
setMilestone ( docPicker . clientModuleId , docPicker . key , true , undefined , undefined , documentId )
. then ( ( ) = > {
toast . ok ( 'Document linked' )
setMilestonesVersion ( ( v ) = > v + 1 )
setDocPicker ( undefined )
} )
. catch ( ( e : Error ) = > setDocLinkError ( e . message ) )
. finally ( ( ) = > setLinkingDoc ( false ) )
} }
onCreateNew = { ( ) = > { setDocPicker ( undefined ) ; nav ( ` /documents/new?client= ${ id } ` ) } }
/ >
) }
{ / * R a i s e d e i t h e r f r o m t h e T i c k e t s t a b o r f r o m a m o d u l e b l o c k ( " R a i s e t i c k e t f o r t h i s
module " ) , so it lives outside the tab switch — the module code pre - fills . * / }
< NewTicketDialog
open = { ticketDialog !== undefined }
onClose = { ( ) = > setTicketDialog ( undefined ) }
onCreated = { ( ) = > tickets . reload ( ) }
kinds = { tickets . data ? . kinds ? ? [ ] }
initialClient = { { id : c.id , code : c.code , name : c.name } }
{ . . . ( ticketDialog ? . module Code !== undefined ? { initialModuleCode : ticketDialog.moduleCode } : { } ) }
/ >
{ credsDialog !== undefined && (
< CredentialsDialog
open
@ -1344,17 +1388,28 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* project ' s milestones as a point - by - point track ( ` .cdr-track ` ) — a connector line
* joins ordered dots , the current ( first not - done ) step is ringed ( "now" ) , done steps
* show a check and their date . Ticking a plain step stamps today ' s date and drives the
* module status ( installed / live ) exactly as before ; ticking the advance / balance payment
* steps instead calls ` onTickPayment ` so the parent can open a picker that links a real
* ledger payment ( ` ClientDetail ` ' s ` PaymentPickerDialog ` ) rather than a bare date .
* module status ( installed / live ) exactly as before . Three steps instead hand off to a
* dialog the parent ( ` ClientDetail ` ) owns , because each needs data this component does
* not hold :
* - the advance / balance * * payment * * steps call ` onTickPayment ` → ` PaymentPickerDialog ` ,
* which links a real ledger payment ( ` setMilestone(..., paymentId) ` ) rather than
* stamping a bare date ;
* - the ` account_creation ` * * credentials * * step calls ` onTickCreds ` →
* ` CredentialsDialog ` , which captures the module ' s provider / username / password
* ( managerial gate ) at the moment the account actually exists ;
* - the ` quotation_sent ` * * document * * step calls ` onTickDocument ` →
* ` DocumentPickerDialog ` , which links the quotation / proforma that was sent
* ( ` setMilestone(..., documentId) ` ) from the client ' s already - loaded ledger .
* Un - ticking any of them toggles directly ( the server clears the link ) .
* "+ Add step" appends a project - specific milestone . Every change is one audited POST
* that returns the fresh list ; ` refreshToken ` lets the parent force a refetch after a
* payment link lands from outside this component .
* link lands from outside this component .
* /
function StatusLine ( props : {
clientModuleId : string ; name : string ; refreshToken : number
onTickPayment : ( key : string , label : string ) = > void
onTickCreds : ( key : string , label : string ) = > void
onTickDocument : ( key : string , label : string ) = > void
} ) {
const toast = useToast ( )
const list = useData ( ( ) = > getMilestones ( props . clientModuleId ) , [ props . clientModuleId , props . refreshToken ] )
@ -1386,15 +1441,18 @@ function StatusLine(props: {
const currentKey = ms ? . find ( ( m ) = > ! m . done ) ? . key
const isPaymentStep = ( key : string ) = > key === 'advance_payment' || key === 'balance_payment'
const isCredsStep = ( key : string ) = > key === 'account_creation'
const isDocumentStep = ( key : string ) = > key === 'quotation_sent'
// Ticking ON a payment step opens the picker (parent owns that dialog — it needs the
// client's ledger + the Payments tab); ticking ON account_creation opens the parent's
// credentials dialog (it needs the module's provider/username/password + managerial
// gate). Un-ticking and every other step toggle directly.
// gate); ticking ON quotation_sent opens the document picker (it needs the ledger's
// documents). Un-ticking and every other step toggle directly.
const onDotClick = ( m : Milestone ) = > {
if ( busyKey !== '' ) return
if ( ! m . done && isPaymentStep ( m . key ) ) { props . onTickPayment ( m . key , m . label ) ; return }
if ( ! m . done && isCredsStep ( m . key ) ) { props . onTickCreds ( m . key , m . label ) ; return }
if ( ! m . done && isDocumentStep ( m . key ) ) { props . onTickDocument ( m . key , m . label ) ; return }
toggle ( m , ! m . done )
}
@ -1435,6 +1493,7 @@ function StatusLine(props: {
m . done ? 'Click to un-tick'
: isPaymentStep ( m . key ) ? 'Click to link a payment'
: isCredsStep ( m . key ) ? 'Click to set account credentials'
: isDocumentStep ( m . key ) ? 'Click to link the quotation / proforma'
: 'Click to mark done (today)'
}
onClick = { ( ) = > onDotClick ( m ) }
@ -1550,6 +1609,55 @@ function PaymentPickerDialog(props: {
)
}
/ * *
* Quotation - step document picker ( D38 follow - up ) : ticking the "Quotation sent" status
* step opens this instead of stamping a bare date — it links the quotation / proforma that
* was actually sent ( ` setMilestone(..., documentId) ` ) so the status line and the document
* register agree . Mirrors ` PaymentPickerDialog ` ' s structure / props exactly , reading the
* client ' s already - loaded ledger ( no extra fetch ) ; "Create new document" defers to the
* composer rather than inventing a document here .
* /
function DocumentPickerDialog ( props : {
open : boolean ; onClose : ( ) = > void ; stepLabel : string
docs : Doc [ ] ; saving : boolean ; error? : string
onPick : ( documentId : string ) = > void ; onCreateNew : ( ) = > void
} ) {
return (
< Dialog
open = { props . open }
onClose = { ( ) = > { if ( ! props . saving ) props . onClose ( ) } }
title = { ` Link a document — ${ props . stepLabel } ` }
size = "sm"
footer = { < Button onClick = { props . onClose } disabled = { props . saving } > Cancel < / Button > }
>
{ props . docs . length === 0
? < EmptyState > No quotation or proforma on record for this client yet . < / EmptyState >
: (
< DataTable
columns = { [
{ key : 'no' , label : 'No' , mono : true } , { key : 'type' , label : 'Type' } ,
{ key : 'date' , label : 'Date' } , { key : 'payable' , label : 'Payable' , numeric : true } ,
{ key : 'status' , label : 'Status' } , { key : 'act' , label : '' } ,
] }
rows = { props . docs . map ( ( d ) = > ( {
no : d.docNo ? ? 'draft' ,
type : < Badge tone = { DOC_TYPE_TONE [ d . docType ] } > { DOC_TYPE_LABEL [ d . docType ] } < / Badge > ,
date : d.docDate ,
payable : inr ( d . payablePaise ) ,
status : < Badge tone = { DOC_TONE [ d . status ] } > { d . status } < / Badge > ,
act : < Button tone = "primary" disabled = { props . saving } onClick = { ( ) = > props . onPick ( d . id ) } > Use < / Button > ,
} ) ) }
/ >
) }
< Toolbar >
< span style = { { flex : 1 } } / >
< Button disabled = { props . saving } onClick = { props . onCreateNew } > Create new document … < / Button >
< / Toolbar >
{ props . error !== undefined && < Notice tone = "err" > { props . error } < / Notice > }
< / Dialog >
)
}
/ * *
* Account - creation credentials dialog ( D38 follow - up ) : ticking the " Account creation &
* registration " status - line step opens this instead of a bare toggle — mirrors