|
|
|
|
@ -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<{ moduleCode?: string } | undefined>()
|
|
|
|
|
@ -317,8 +320,9 @@ export function ClientDetail() {
|
|
|
|
|
<details key={cm.id} open className="mod-section">
|
|
|
|
|
<summary>
|
|
|
|
|
<span className="mod-name">{moduleName(cm.moduleId)}</span>
|
|
|
|
|
<Badge tone={cm.status === 'live' ? 'ok' : undefined}>{cm.status}</Badge>
|
|
|
|
|
<Badge>{KIND_LABEL[cm.kind]}</Badge>
|
|
|
|
|
{/* The editable select below is the one status control — a read-only badge
|
|
|
|
|
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={moduleName(cm.moduleId)}
|
|
|
|
|
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.moduleCode !== ''
|
|
|
|
|
? <Link to={`/clients/${id}?tab=modules`}>{t.moduleCode}</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() {
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{/* Raised either from the Tickets tab or from a module block ("Raise ticket for this
|
|
|
|
|
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?.moduleCode !== undefined ? { initialModuleCode: ticketDialog.moduleCode } : {})}
|
|
|
|
|
/>
|
|
|
|
|
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
|
|
|
|
|
onClose={() => setTicketDialog(undefined)}
|
|
|
|
|
onCreated={() => tickets.reload()}
|
|
|
|
|
kinds={tickets.data?.kinds ?? []}
|
|
|
|
|
initialClient={{ id: c.id, code: c.code, name: c.name }}
|
|
|
|
|
{...(ticketDialog.moduleCode !== 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 modules 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-modules 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
|
|
|
|
|
/** Reloads the parent's client-modules list — every tick path calls this so the module
|
|
|
|
|
* 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>
|
|
|
|
|
{/* The linked payment/document itself — otherwise a done payment/quotation
|
|
|
|
|
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>
|
|
|
|
|
|