console: replace bucket-click filter with a build-your-own filter

The renewal-exposure buckets are back to plain counts. Above the table is now
a filter builder: press Add filter, pick a field (Stage, Time to expiry,
Premium, or Lead name), give it a value, and add it. Each condition lands as a
removable chip and they AND together; Clear all drops them.

- Stage: choose from the stages actually present in the exposure set.
- Time to expiry: lapsed / within 7 days / 8-30 days.
- Premium: at least / at most an amount (a lead with no premium never matches).
- Lead name: substring over name and reference.

One matcher and one describer per field, so the chip label and the filtering
cannot drift. The table shows more rows once any filter is on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 16:29:11 +05:30
parent e0d51159f6
commit b46faa70e2
2 changed files with 236 additions and 69 deletions

View File

@ -55,7 +55,49 @@ const EXPO_BUCKETS = {
month: (d) => d > 7 && d <= 30, month: (d) => d > 7 && d <= 30,
} }
const EXPO_LABEL = { lapsed: 'Lapsed', week: 'Within 7 days', month: '8 to 30 days' } const EXPO_LABEL = { lapsed: 'Lapsed', week: 'Within 7 days', month: '8 to 30 days' }
function expoLabel(k) { return EXPO_LABEL[k] || '' } /* The renewal-exposure table is filterable on the columns it shows. Each field
knows how to match a row and how to describe itself as a chip. */
const FILTER_FIELDS = [
{ key: 'stage', label: 'Stage' },
{ key: 'expiry', label: 'Time to expiry' },
{ key: 'premium', label: 'Premium' },
{ key: 'name', label: 'Lead name' },
]
function startFilter(field) {
return field === 'premium' ? { field, op: 'gte', value: '' } : { field, value: '' }
}
function filterReady(d) {
if (!d) return false
if (d.field === 'premium') return String(d.value).trim() !== '' && Number.isFinite(Number(d.value))
return String(d.value).trim() !== ''
}
function matchExposure(f, r) {
switch (f.field) {
case 'stage': return r.current_state_name === f.value
case 'expiry': return EXPO_BUCKETS[f.value] ? EXPO_BUCKETS[f.value](r._d) : true
case 'premium': {
const v = Number(f.value)
if (!Number.isFinite(v)) return true
const p = num(r.quoted_premium)
if (!p) return false // a lead with no premium cannot satisfy a premium test
return f.op === 'lte' ? p <= v : p >= v
}
case 'name': {
const hay = ((r.customer_name || '') + ' ' + (r.lead_ref || '')).toLowerCase()
return hay.includes(String(f.value).toLowerCase())
}
default: return true
}
}
function describeFilter(f) {
switch (f.field) {
case 'stage': return 'Stage: ' + f.value
case 'expiry': return 'Expiry: ' + (EXPO_LABEL[f.value] || f.value)
case 'premium': return 'Premium ' + (f.op === 'lte' ? '\u2264 ' : '\u2265 ') + inr(Number(f.value))
case 'name': return 'Lead: \u201c' + f.value + '\u201d'
default: return ''
}
}
function expiryTone(d) { function expiryTone(d) {
if (d === null) return 'later' if (d === null) return 'later'
@ -73,8 +115,10 @@ export default function Overview() {
// Filing a lead happens over this page, not instead of it see // Filing a lead happens over this page, not instead of it see
// NewLeadDialog. The overview behind it keeps its counts and its poll. // NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
// Which renewal-exposure bucket the table is narrowed to, or null for all. // User-built filters on the renewal-exposure table, AND-combined, plus the
const [expo, setExpo] = useState(null) // one being composed in the add row.
const [filters, setFilters] = useState([])
const [draft, setDraft] = useState(null) // { field, op?, value } | null
const state = usePortfolio() const state = usePortfolio()
const m = useMemo(() => { const m = useMemo(() => {
@ -170,9 +214,10 @@ export default function Overview() {
const mine = new Set(visibleStages(roles).map((s) => s.uid)) const mine = new Set(visibleStages(roles).map((s) => s.uid))
// The exposure table, narrowed to the chosen bucket (or the soonest-due // Stages present in the exposure set, for the Stage filter's options.
// across all buckets when no filter is set). const expoStages = [...new Set(m.attention.map((r) => r.current_state_name).filter(Boolean))]
const expoRows = expo ? m.attention.filter((r) => EXPO_BUCKETS[expo](r._d)) : m.attention // The exposure table, narrowed by every active filter (AND).
const shown = m.attention.filter((r) => filters.every((f) => matchExposure(f, r)))
if (state.status === 'error') { if (state.status === 'error') {
const said = describeError(state.error) const said = describeError(state.error)
@ -318,43 +363,93 @@ export default function Overview() {
</div> </div>
<h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2> <h2 className="sec">Renewal exposure<span className="sec__hint">open leads by time to expiry</span></h2>
{/* The three buckets double as the filter: click one to narrow the <div className="buckets">
table to it, click again (or Clear) to show every open renewal. A <div className="bucket bucket--lapsed"><strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span></div>
bucket with nothing in it is not clickable there is nothing to <div className="bucket bucket--urgent"><strong>{m.exposure.week.length}</strong><span>Within 7 days</span></div>
show. */} <div className="bucket bucket--soon"><strong>{m.exposure.month.length}</strong><span>8 to 30 days</span></div>
<div className="buckets" role="group" aria-label="Filter by time to expiry">
{[
['lapsed', 'lapsed', m.exposure.lapsed.length, 'Lapsed'],
['week', 'urgent', m.exposure.week.length, 'Within 7 days'],
['month', 'soon', m.exposure.month.length, '8 to 30 days'],
].map(([key, variant, count, label]) => (
<button
key={key}
type="button"
className={`bucket bucket--${variant}` + (expo === key ? ' is-active' : '')}
aria-pressed={expo === key}
disabled={!count}
onClick={() => setExpo(expo === key ? null : key)}
>
<strong>{count}</strong><span>{label}</span>
</button>
))}
</div> </div>
{expo ? ( {/* Build-your-own filters: pick a field, give it a value, add it. Each
<button type="button" className="expo__clear" onClick={() => setExpo(null)}> lands as a removable chip and they AND together. */}
Showing {expoLabel(expo)} only · Clear filter <div className="xf">
</button> <div className="xf__row">
) : null} {filters.map((f, i) => (
<span key={i} className="xf__chip">
{describeFilter(f)}
<button type="button" aria-label="Remove filter"
onClick={() => setFilters(filters.filter((_, j) => j !== i))}>
<svg viewBox="0 0 12 12" aria-hidden="true">
<path d="M3 3l6 6M9 3l-6 6" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</button>
</span>
))}
{expoRows.length ? ( {draft ? (
<span className="xf__add">
<select className="xf__sel" value={draft.field} aria-label="Filter field"
onChange={(e) => setDraft(startFilter(e.target.value))}>
{FILTER_FIELDS.map((fd) => <option key={fd.key} value={fd.key}>{fd.label}</option>)}
</select>
{draft.field === 'stage' ? (
<select className="xf__sel" value={draft.value} aria-label="Stage"
onChange={(e) => setDraft({ ...draft, value: e.target.value })}>
<option value="">Choose stage</option>
{expoStages.map((st) => <option key={st} value={st}>{st}</option>)}
</select>
) : draft.field === 'expiry' ? (
<select className="xf__sel" value={draft.value} aria-label="Time to expiry"
onChange={(e) => setDraft({ ...draft, value: e.target.value })}>
<option value="">Choose window</option>
{Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
) : draft.field === 'premium' ? (
<>
<select className="xf__sel" value={draft.op} aria-label="Comparator"
onChange={(e) => setDraft({ ...draft, op: e.target.value })}>
<option value="gte">at least</option>
<option value="lte">at most</option>
</select>
<input className="xf__in" type="number" inputMode="numeric" placeholder="₹ amount"
value={draft.value} aria-label="Amount"
onChange={(e) => setDraft({ ...draft, value: e.target.value })} />
</>
) : (
<input className="xf__in" type="text" placeholder="name or ref…"
value={draft.value} aria-label="Lead name"
onChange={(e) => setDraft({ ...draft, value: e.target.value })} />
)}
<button type="button" className="xf__apply" disabled={!filterReady(draft)}
onClick={() => { setFilters([...filters, draft]); setDraft(null) }}>Add</button>
<button type="button" className="xf__cancel" onClick={() => setDraft(null)}>Cancel</button>
</span>
) : (
<button type="button" className="xf__new" onClick={() => setDraft(startFilter('stage'))}>
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M7 3v8M3 7h8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
Add filter
</button>
)}
{filters.length ? (
<button type="button" className="xf__clearall" onClick={() => { setFilters([]); setDraft(null) }}>
Clear all
</button>
) : null}
</div>
</div>
{shown.length ? (
<div className="gridwrap"> <div className="gridwrap">
<table className="grid grid--tight"> <table className="grid grid--tight">
<thead> <thead>
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr> <tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
</thead> </thead>
<tbody> <tbody>
{expoRows.slice(0, expo ? 50 : 8).map((r) => { {shown.slice(0, filters.length ? 100 : 8).map((r) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
return ( return (
<tr <tr
@ -385,7 +480,7 @@ export default function Overview() {
</div> </div>
) : ( ) : (
<p className="empty"> <p className="empty">
{expo ? 'No open leads in this bucket.' : 'No renewals due within 30 days.'} {filters.length ? 'No open leads match these filters.' : 'No renewals due within 30 days.'}
</p> </p>
)} )}
</div> </div>

View File

@ -1059,41 +1059,7 @@
.bucket--soon { border-left-color: var(--zk-blue-mid); } .bucket--soon { border-left-color: var(--zk-blue-mid); }
.bucket--soon strong { color: var(--zk-blue-dark); } .bucket--soon strong { color: var(--zk-blue-dark); }
/* The buckets are filter toggles. Reset the button chrome, keep the card look,
and add hover / pressed / disabled states. */
.bucket {
width: 100%;
font: inherit;
text-align: left;
color: inherit;
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast),
box-shadow var(--t-fast), transform var(--t-fast);
}
.bucket:hover:not(:disabled) { box-shadow: var(--sh-sm); }
.bucket:active:not(:disabled) { transform: translateY(1px); }
.bucket:focus-visible { outline: none; box-shadow: var(--ring); }
.bucket:disabled { cursor: default; opacity: 0.55; }
/* Pressed: fill with the bucket's own tint so the active filter is unmistakable. */
.bucket--lapsed.is-active { background: var(--zk-danger-tint); border-color: var(--zk-danger-line); border-left-color: var(--zk-danger); }
.bucket--urgent.is-active { background: var(--zk-amber-tint); border-color: var(--zk-amber-line); border-left-color: var(--zk-amber); }
.bucket--soon.is-active { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); border-left-color: var(--zk-blue-mid); }
/* The clear affordance under the buckets when a filter is on. */
.expo__clear {
margin: 10px 0 0;
padding: 0;
border: 0;
background: none;
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue-dark);
}
.expo__clear:hover { text-decoration: underline; }
.expo__clear:focus-visible { outline: none; text-decoration: underline; box-shadow: var(--ring); border-radius: var(--r-xs); }
/* Stage distribution. A bar per stage, scaled to the largest enough to see /* Stage distribution. A bar per stage, scaled to the largest enough to see
where the book is banked without pulling in a charting library. */ where the book is banked without pulling in a charting library. */
@ -1921,3 +1887,109 @@
text-transform: uppercase; text-transform: uppercase;
color: var(--zk-grey); color: var(--zk-grey);
} }
/* ---- renewal-exposure custom filters ---- */
.xf { margin-top: 12px; }
.xf__row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.xf__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 12px;
border-radius: var(--r-pill);
background: var(--zk-tint-blue);
border: 1px solid var(--zk-blue-light, var(--zk-line));
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue-dark);
}
.xf__chip button {
display: grid;
place-items: center;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 50%;
background: rgba(195, 15, 104, 0.10);
color: var(--zk-blue-dark);
cursor: pointer;
}
.xf__chip button svg { width: 10px; height: 10px; }
.xf__chip button:hover { background: rgba(195, 15, 104, 0.20); }
.xf__add {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 5px 6px;
border: 1px solid var(--zk-line);
border-radius: var(--r-md);
background: var(--zk-white);
}
.xf__sel,
.xf__in {
font: inherit;
font-size: var(--fs-2xs);
padding: 5px 8px;
border: 1px solid var(--zk-line);
border-radius: var(--r-sm);
background: var(--zk-white);
color: var(--zk-ink);
}
.xf__in { width: 130px; }
.xf__sel:focus-visible,
.xf__in:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.xf__apply {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
padding: 6px 12px;
border: 0;
border-radius: var(--r-sm);
background: var(--zk-blue);
color: var(--zk-white);
cursor: pointer;
}
.xf__apply:hover:not(:disabled) { background: var(--zk-blue-dark); }
.xf__apply:disabled { opacity: 0.5; cursor: default; }
.xf__cancel,
.xf__clearall {
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
padding: 6px 8px;
border: 0;
background: none;
color: var(--zk-grey);
cursor: pointer;
}
.xf__cancel:hover,
.xf__clearall:hover { color: var(--zk-blue-dark); text-decoration: underline; }
.xf__new {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px 6px 10px;
border: 1px dashed var(--zk-line);
border-radius: var(--r-pill);
background: var(--zk-white);
cursor: pointer;
font: inherit;
font-size: var(--fs-2xs);
font-weight: 500;
color: var(--zk-blue-dark);
transition: background var(--t-fast), border-color var(--t-fast);
}
.xf__new svg { width: 13px; height: 13px; }
.xf__new:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
.xf__new:focus-visible { outline: none; box-shadow: var(--ring); }