|
|
|
|
@ -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<{ moduleCode?: 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">{moduleName(cm.moduleId)}</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({
|
|
|
|
|
moduleCode: modules.data?.find((m) => m.id === cm.moduleId)?.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.moduleCode !== ''
|
|
|
|
|
? <Link to={`/clients/${id}?tab=modules`}>{t.moduleCode}</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}`) }}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
{/* 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 } : {})}
|
|
|
|
|
/>
|
|
|
|
|
{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
|
|
|
|
|
|