feat(hq-web): relationship-pulse ribbon on Client 360 overview

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 5 days ago
parent eaecaedd3f
commit 08bb25abfc

@ -120,3 +120,42 @@
.hq-content { overflow-x: hidden; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; margin: 16px 0;
}
.pulse-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.pulse-head h3 { margin: 0; font-size: 15px; }
.pulse-legend { margin-left: auto; display: flex; gap: 12px; font-size: 9.5px; letter-spacing: 0.06em; color: var(--text-dim); }
.pulse-legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.pulse-grid { display: grid; gap: 2px 0; margin-top: 12px; }
.pulse-lane {
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-dim); padding: 6px 8px 6px 0; white-space: nowrap;
}
.pulse-cell {
border-left: 1px solid var(--border);
min-height: 26px; display: flex; align-items: center; gap: 3px; padding: 2px 3px;
flex-wrap: wrap;
}
.pulse-dot {
width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
transition: transform 80ms;
}
.pulse-dot:hover { transform: scale(1.5); }
.pulse-more { font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); }
.pulse-month {
border-left: 1px solid var(--border);
font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); padding: 3px;
}
.pulse-readout {
margin-top: 10px; border-left: 2px solid var(--accent);
background: var(--bg-inset); border-radius: var(--radius-sm);
padding: 7px 10px; font-size: 12.5px; color: var(--text);
min-height: 32px; display: flex; align-items: center; gap: 6px;
}
.pulse-open { color: var(--accent-strong); margin-left: auto; font-size: 11.5px; }
@media (max-width: 900px) { .pulse-grid { display: none; } .pulse-readout { display: none; }
.pulse::after { content: 'Timeline hidden on small screens.'; color: var(--text-dim); font-size: 12px; } }

@ -0,0 +1,71 @@
import { useState } from 'react'
import { groupPulse, LANES, lastMonths, type PulseEvent } from '../pulse'
const TONE_VAR: Record<PulseEvent['tone'], string> = {
accent: 'var(--accent)', ok: 'var(--ok)', warn: 'var(--warn)', err: 'var(--err)',
}
/**
* Relationship pulse last 12 months (H-04's timeline ribbon).
* Four lanes on a shared month axis; hover fills the readout bar; click opens
* the record. Plain DOM dots no chart library.
*/
export function PulseRibbon(props: {
events: PulseEvent[]
todayIso: string
onOpen: (ev: PulseEvent) => void
}) {
const months = lastMonths(props.todayIso)
const cells = groupPulse(props.events, months)
const [focus, setFocus] = useState<PulseEvent | undefined>()
return (
<div className="pulse">
<div className="pulse-head">
<h3>Relationship pulse last 12 months</h3>
<div className="pulse-legend">
{LANES.map((lane, i) => (
<span key={lane}><span className="dot" style={{ background: TONE_VAR[i === 0 ? 'accent' : i === 1 ? 'ok' : i === 2 ? 'warn' : 'err'] }} />{lane.toUpperCase()}</span>
))}
</div>
</div>
<div className="pulse-grid" style={{ gridTemplateColumns: `110px repeat(${months.length}, 1fr)` }}>
{LANES.map((lane, li) => (
<div key={lane} style={{ display: 'contents' }}>
<div className="pulse-lane">{lane}</div>
{months.map((m, mi) => {
const evs = cells.get(`${li}:${mi}`) ?? []
return (
<div key={m} className="pulse-cell">
{evs.slice(0, 3).map((ev) => (
<button
key={ev.id}
type="button"
className="pulse-dot"
style={{ background: TONE_VAR[ev.tone] }}
title={ev.label}
onMouseEnter={() => setFocus(ev)}
onFocus={() => setFocus(ev)}
onClick={() => props.onOpen(ev)}
aria-label={ev.label}
/>
))}
{evs.length > 3 && <span className="pulse-more">+{evs.length - 3}</span>}
</div>
)
})}
</div>
))}
<div className="pulse-lane" />
{months.map((m) => (
<div key={m} className="pulse-month">{m.slice(5)}/{m.slice(2, 4)}</div>
))}
</div>
<div className="pulse-readout">
{focus !== undefined
? <><span className="mono">{focus.date}</span> · {focus.label} <span className="pulse-open">open </span></>
: 'Hover an event · click to open it'}
</div>
</div>
)
}

@ -20,6 +20,8 @@ import {
AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect,
LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate,
} from './client-forms'
import { PulseRibbon } from '../components/PulseRibbon'
import { type PulseEvent } from '../pulse'
const inr = (p: number) => formatINR(p, { symbol: false })
@ -64,6 +66,41 @@ export function ClientDetail() {
const activeModules = (cms.data ?? []).filter((m) => m.active)
const lastInteraction = interactions.data?.[0]?.onDate
const todayIso = new Date().toISOString().slice(0, 10)
const DOC_PULSE_TONE: Record<string, PulseEvent['tone']> = {
paid: 'ok', part_paid: 'warn', lost: 'err', cancelled: 'err',
}
const pulseEvents: PulseEvent[] = [
...docs.map((d): PulseEvent => ({
id: `doc:${d.id}`, lane: 0, date: d.docDate,
label: `${d.docType} ${d.docNo ?? '(draft)'}${formatINR(d.payablePaise)} · ${d.status}`,
tone: DOC_PULSE_TONE[d.status] ?? 'accent',
})),
...(ledger.data?.payments ?? []).map((p): PulseEvent => ({
id: `pay:${p.id}`, lane: 1, date: p.receivedOn,
label: `Payment ${formatINR(p.amountPaise)} · ${p.mode}`, tone: 'ok',
})),
...(interactions.data ?? []).map((i): PulseEvent => ({
id: `int:${i.id}`, lane: 2, date: i.onDate,
label: `${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? `${i.notes.slice(0, 60)}` : ''}`,
tone: i.outcome === 'negative' ? 'err' : i.outcome === 'positive' ? 'ok' : 'warn',
})),
...(amc.data ?? []).flatMap((a): PulseEvent[] => [
{ id: `amc-from:${a.id}`, lane: 3, date: a.periodFrom, label: `AMC starts — ${a.coverage !== '' ? a.coverage : 'contract'}`, tone: 'accent' },
{ id: `amc-to:${a.id}`, lane: 3, date: a.periodTo, label: `AMC renewal — ${a.paidStatus}`, tone: a.paidStatus === 'unpaid' ? 'err' : 'warn' },
]),
...activeModules.filter((cm) => cm.nextRenewal !== null).map((cm): PulseEvent => ({
id: `ren:${cm.id}`, lane: 3, date: cm.nextRenewal!,
label: `${moduleName(cm.moduleId)} renews`, tone: 'warn',
})),
]
const openPulse = (ev: PulseEvent) => {
if (ev.id.startsWith('doc:')) nav(`/documents/${ev.id.slice(4)}`)
else if (ev.id.startsWith('pay:')) setTab('payments')
else if (ev.id.startsWith('int:')) setTab('interactions')
else setTab('amc')
}
return (
<div className="wf-page">
<div className="wf-crumbs"><Link to="/clients">Clients</Link><span>/</span><span>{c.code}</span></div>
@ -133,7 +170,7 @@ export function ClientDetail() {
<StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
<StatCard label="Last interaction" value={lastInteraction ?? '—'} />
</Stats>
{/* Pulse ribbon lands here in the next task. */}
<PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
<h3 style={{ marginTop: 18 }}>Recent documents</h3>
{docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : (
<DataTable

@ -0,0 +1,41 @@
/** Pure math for the relationship-pulse ribbon (Client 360 overview). */
export interface PulseEvent {
id: string
lane: number
date: string // ISO yyyy-mm-dd
label: string
tone: 'accent' | 'ok' | 'warn' | 'err'
}
export const LANES = ['Documents', 'Payments', 'Interactions', 'AMC & renewals'] as const
/** n month keys ('YYYY-MM') ending at todayIso's month, oldest first. */
export function lastMonths(todayIso: string, n = 12): string[] {
const year = Number(todayIso.slice(0, 4))
const month = Number(todayIso.slice(5, 7)) // 1-based
const out: string[] = []
for (let i = n - 1; i >= 0; i--) {
const total = year * 12 + (month - 1) - i
const y = Math.floor(total / 12)
const m = total % 12 + 1
out.push(`${y}-${String(m).padStart(2, '0')}`)
}
return out
}
/** Bucket events into `${lane}:${monthIndex}` cells; out-of-window events are dropped. */
export function groupPulse(events: PulseEvent[], months: string[]): Map<string, PulseEvent[]> {
const index = new Map<string, number>()
months.forEach((m, i) => index.set(m, i))
const out = new Map<string, PulseEvent[]>()
for (const ev of events) {
const mi = index.get(ev.date.slice(0, 7))
if (mi === undefined) continue
const key = `${ev.lane}:${mi}`
const cell = out.get(key)
if (cell !== undefined) cell.push(ev)
else out.set(key, [ev])
}
return out
}

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { groupPulse, lastMonths, type PulseEvent } from '../src/pulse'
describe('lastMonths', () => {
it('returns 12 months ending at the given date, oldest first', () => {
const m = lastMonths('2026-07-17')
expect(m).toHaveLength(12)
expect(m[0]).toBe('2025-08')
expect(m[11]).toBe('2026-07')
})
it('crosses year boundaries correctly', () => {
expect(lastMonths('2026-01-05', 3)).toEqual(['2025-11', '2025-12', '2026-01'])
})
})
describe('groupPulse', () => {
const ev = (lane: number, date: string, id: string): PulseEvent =>
({ id, lane, date, label: id, tone: 'accent' })
const months = lastMonths('2026-07-17')
it('buckets events by lane and month', () => {
const g = groupPulse([ev(0, '2026-07-01', 'a'), ev(0, '2026-07-30', 'b'), ev(2, '2025-08-09', 'c')], months)
expect(g.get('0:11')?.map((e) => e.id)).toEqual(['a', 'b'])
expect(g.get('2:0')?.map((e) => e.id)).toEqual(['c'])
})
it('drops events outside the window', () => {
const g = groupPulse([ev(1, '2024-01-01', 'old'), ev(1, '2027-01-01', 'future')], months)
expect(g.size).toBe(0)
})
})
Loading…
Cancel
Save