@ -14,10 +14,10 @@ import {
getAmc , deactivateAmc , generateAmcRenewalInvoice ,
getInteractions , getInteractionTypes , updateInteraction ,
getClientAwsUsage , getCompanyProfile ,
getBranches , createBranch , patchBranch , getTickets ,
getBranches , createBranch , patchBranch , getTickets , getTicketTypes ,
getMilestones , setMilestone , addMilestone ,
generateBulkSmsPurchase ,
CLIENT_STATUSES , KIND_LABEL ,
CLIENT_STATUSES , KIND_LABEL , TICKET_TYPE_FALLBACK ,
type AmcContract , type AmcPaidStatus , type Branch , type Client , type ClientModule ,
type ClientStatus , type Doc , type FieldDef , type Interaction , type Ledger , type Milestone , type Outcome ,
type Payment , type RecurringPlan , type ServiceDetail ,
@ -25,7 +25,7 @@ import {
import { buildStatement } from '../statement'
import { CLIENT_TONE , DOC_TONE , DOC_TYPE_TONE , DOC_TYPE_LABEL , useData } from './Clients'
import {
NewTicketDialog , TicketActions , TicketDescription , TICKET_STATUS_LABEL , TICKET_TONE ,
NewTicketDialog , TicketActions , TicketDescription , TICKET_STATUS_LABEL , TICKET_TONE , ticketTypeLabel ,
} from './Tickets'
import {
AccountOwnerSelect , AmcDialog , AssignModuleForm , ClientModuleStatusSelect ,
@ -65,6 +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 ] )
// Same classification list the Tickets desk uses, so the Type column reads identically here.
const ticketTypes = useData ( getTicketTypes , [ ] )
const ticketTypeOptions = ticketTypes . data ? ? TICKET_TYPE_FALLBACK
// 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 > ( )
@ -317,8 +320,9 @@ export function ClientDetail() {
< details key = { cm . id } open className = "mod-section" >
< summary >
< 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 >
{ / * T h e e d i t a b l e s e l e c t b e l o w i s t h e o n e s t a t u s c o n t r o l — a r e a d - o n l y b a d g e
next to it would just repeat the same value ( audit fix : no dual status ) . * / }
< 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. */ }
@ -340,9 +344,15 @@ export function ClientDetail() {
clientModuleId = { cm . id }
name = { module Name ( cm . module Id ) }
refreshToken = { milestonesVersion }
payments = { ledger . data ? . payments ? ? [ ] }
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 } ) } }
// The server auto-advances client_module.status on a tick — reload so the
// block header / summary table / status select never go stale (audit fix).
onChanged = { ( ) = > cms . reload ( ) }
onOpenDocument = { ( documentId ) = > nav ( ` /documents/ ${ documentId } ` ) }
onOpenPayments = { ( ) = > setTab ( 'payments' ) }
/ >
< ModuleDocuments
cm = { cm }
@ -372,7 +382,8 @@ export function ClientDetail() {
< DataTable
columns = { [
{ key : 'opened' , label : 'Opened' } , { key : 'branch' , label : 'Branch' } ,
{ key : 'module' , label : 'Module' , mono : true } , { key : 'kind' , label : 'Kind' } ,
{ key : 'module' , label : 'Module' , mono : true } , { key : 'type' , label : 'Type' } ,
{ key : 'kind' , label : 'Kind' } ,
{ key : 'desc' , label : 'Description' } , { key : 'assigned' , label : 'Assigned' } ,
{ key : 'status' , label : 'Status' } , { key : 'act' , label : '' } ,
] }
@ -383,6 +394,8 @@ export function ClientDetail() {
module : t.moduleCode !== null && t . module Code !== ''
? < Link to = { ` /clients/ ${ id } ?tab=modules ` } > { t . module Code } < / Link >
: '—' ,
// Same data + rendering as the Tickets desk (D-ticket-type parity).
type : ticketTypeLabel ( ticketTypeOptions , t . type ) ,
kind : t.kind !== '' ? t . kind : '—' ,
desc : < TicketDescription text = { t . description } / > ,
assigned : t.assignedName ? ? '—' ,
@ -679,6 +692,9 @@ export function ClientDetail() {
. then ( ( ) = > {
toast . ok ( 'Payment linked' )
setMilestonesVersion ( ( v ) = > v + 1 )
// The server auto-advances client_module.status on a milestone tick — reload
// so the module block header / summary table / status select don't go stale.
cms . reload ( )
setPaymentPicker ( undefined )
} )
. catch ( ( e : Error ) = > setLinkError ( e . message ) )
@ -703,6 +719,7 @@ export function ClientDetail() {
. then ( ( ) = > {
toast . ok ( 'Document linked' )
setMilestonesVersion ( ( v ) = > v + 1 )
cms . reload ( )
setDocPicker ( undefined )
} )
. catch ( ( e : Error ) = > setDocLinkError ( e . message ) )
@ -712,15 +729,19 @@ export function ClientDetail() {
/ >
) }
{ / * 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 . * / }
module " ) , so it lives outside the tab switch — the module code pre - fills . Mounted
only while actually open — it otherwise fires getModules + getTicketTypes on every
Client 360 load even though it ' s never opened . * / }
{ ticketDialog !== undefined && (
< NewTicketDialog
open = { ticketDialog !== undefined }
open
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 } : { } ) }
{ . . . ( ticketDialog . module Code !== undefined ? { initialModuleCode : ticketDialog.moduleCode } : { } ) }
/ >
) }
{ credsDialog !== undefined && (
< CredentialsDialog
open
@ -755,6 +776,7 @@ export function ClientDetail() {
. then ( ( ) = > {
toast . ok ( 'Step marked done' )
setMilestonesVersion ( ( v ) = > v + 1 )
cms . reload ( )
setCredsDialog ( undefined )
} )
. catch ( ( e : Error ) = > setCredsError ( e . message ) )
@ -1392,9 +1414,10 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* dialog the parent ( ` ClientDetail ` ) owns , because each needs data this component does
* not hold :
* - the * * payment * * step ( ` payment_received ` , plus the legacy ` advance_payment ` /
* ` balance_payment ` keys on un - migrated projects ) calls ` onTickPayment ` →
* ` PaymentPickerDialog ` , which links a real ledger payment
* ( ` setMilestone(..., paymentId) ` ) rather than stamping a bare date ;
* ` balance_payment ` keys on un - migrated projects , AND the older ` advance_paid ` /
* ` balance_paid ` keys the generic fallback template uses on module s with no seeded
* per - module tail ) calls ` 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 ;
@ -1402,15 +1425,27 @@ function SecretField(props: { cm: ClientModule; field: FieldDef; managerial: boo
* ` 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 ) .
* A done step carrying ` paymentId ` / ` documentId ` also renders a small reference under its
* date ( ` onOpenDocument ` / ` onOpenPayments ` ) — otherwise a linked step looks identical to a
* bare - toggled one .
* "+ 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
* link lands from outside this component .
* link lands from outside this component . ` onChanged ` additionally reloads the parent ' s
* client - module s list after every tick ( plain toggle included ) so the server ' s
* auto - advanced ` client_module.status ` never goes stale in the block header / summary
* table / status select .
* /
function StatusLine ( props : {
clientModuleId : string ; name : string ; refreshToken : number
payments : Payment [ ]
onTickPayment : ( key : string , label : string ) = > void
onTickCreds : ( key : string , label : string ) = > void
onTickDocument : ( key : string , label : string ) = > void
/ * * R e l o a d s t h e p a r e n t ' s c l i e n t - m o d u l e s l i s t — e v e r y t i c k p a t h c a l l s t h i s s o t h e m o d u l e
* block header / summary table / status select never show a stale status ( audit fix ) . * /
onChanged : ( ) = > void
onOpenDocument : ( documentId : string ) = > void
onOpenPayments : ( ) = > void
} ) {
const toast = useToast ( )
const list = useData ( ( ) = > getMilestones ( props . clientModuleId ) , [ props . clientModuleId , props . refreshToken ] )
@ -1423,7 +1458,7 @@ function StatusLine(props: {
if ( busyKey !== '' ) return
setBusyKey ( m . key )
setMilestone ( props . clientModuleId , m . key , done , doneOn )
. then ( ( ) = > list . reload ( ) )
. then ( ( ) = > { list . reload ( ) ; props . onChanged ( ) } )
. catch ( ( e : Error ) = > toast . err ( e . message ) )
. finally ( ( ) = > setBusyKey ( '' ) )
}
@ -1440,10 +1475,15 @@ function StatusLine(props: {
const done = ms ? . filter ( ( m ) = > m . done ) . length ? ? 0
// current = first not-done step
const currentKey = ms ? . find ( ( m ) = > ! m . done ) ? . key
// `payment_received` is the key every shipped template uses; advance_/balance_payment are
// legacy keys that still exist on projects not yet run through the onboarding migration.
// `payment_received` is the key every shipped (seeded) template uses; `advance_payment` /
// `balance_payment` are legacy keys still on projects not yet run through the onboarding
// migration; `advance_paid` / `balance_paid` (note: `_paid`, not `_payment`) are the OLDER
// legacy vocabulary from the generic fallback template — the one modules with no seeded
// per-module tail (WHATSAPP, ATM, AMC, …) actually get. Without these two, ticking the
// payment step on those modules silently stamped a bare date instead of opening the picker.
const isPaymentStep = ( key : string ) = >
key === 'payment_received' || key === 'advance_payment' || key === 'balance_payment'
|| key === 'advance_paid' || key === 'balance_paid'
const isCredsStep = ( key : string ) = > key === 'account_creation'
const isDocumentStep = ( key : string ) = > key === 'quotation_sent'
@ -1506,6 +1546,27 @@ function StatusLine(props: {
< / button >
< span className = "nm" > { m . label } < / span >
< span className = "dt" > { m . done ? ( m . doneOn ? ? '—' ) : m . key === currentKey ? 'now' : '—' } < / span >
{ / * T h e l i n k e d p a y m e n t / d o c u m e n t i t s e l f — o t h e r w i s e a d o n e p a y m e n t / q u o t a t i o n
step shows only a date , with no evidence the link actually landed . * / }
{ m . done && m . documentId !== null && (
< button
type = "button" className = "ref" title = "Open the linked document"
onClick = { ( ) = > props . onOpenDocument ( m . documentId ! ) }
>
view document →
< / button >
) }
{ m . done && m . paymentId !== null && ( ( ) = > {
const p = props . payments . find ( ( x ) = > x . id === m . paymentId )
return (
< button
type = "button" className = "ref" title = "The linked payment — open Payments & plans"
onClick = { ( ) = > props . onOpenPayments ( ) }
>
{ p !== undefined ? ` ${ p . receivedOn } · ${ inr ( p . amountPaise ) } ` : 'payment linked' }
< / button >
)
} ) ( ) }
< / div >
) ) }
< / div > < / div >