console: renewal filters as plain dropdowns

The add-a-chip builder read as a stray pill button. Replace it with three
labelled dropdowns above the table — Stage, Time to expiry, Premium range — plus
a Clear when any is set. Empty option means no filter on that field; they narrow
together. Standard, quiet table-filter UI instead of a build-your-own control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Yashas 2026-09-09 16:33:35 +05:30
parent b46faa70e2
commit f31d6cb866
2 changed files with 89 additions and 211 deletions

View File

@ -57,47 +57,13 @@ const EXPO_BUCKETS = {
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' }
/* The renewal-exposure table is filterable on the columns it shows. Each field /* 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. */ knows how to match a row and how to describe itself as a chip. */
const FILTER_FIELDS = [ const PREM_RANGES = [
{ key: 'stage', label: 'Stage' }, ['lt10', 'Under ₹10,000', (p) => p > 0 && p < 10000],
{ key: 'expiry', label: 'Time to expiry' }, ['10-25', '₹10,000 25,000', (p) => p >= 10000 && p < 25000],
{ key: 'premium', label: 'Premium' }, ['25-50', '₹25,000 50,000', (p) => p >= 25000 && p < 50000],
{ key: 'name', label: 'Lead name' }, ['gt50', 'Over ₹50,000', (p) => p >= 50000],
] ]
function startFilter(field) { const PREM_MATCH = Object.fromEntries(PREM_RANGES.map(([k, , fn]) => [k, fn]))
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'
@ -115,10 +81,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)
// User-built filters on the renewal-exposure table, AND-combined, plus the // Renewal-exposure dropdown filters. Empty string = no filter on that field.
// one being composed in the add row. const [fStage, setFStage] = useState('')
const [filters, setFilters] = useState([]) const [fExpiry, setFExpiry] = useState('')
const [draft, setDraft] = useState(null) // { field, op?, value } | null const [fPrem, setFPrem] = useState('')
const state = usePortfolio() const state = usePortfolio()
const m = useMemo(() => { const m = useMemo(() => {
@ -216,8 +182,13 @@ export default function Overview() {
// Stages present in the exposure set, for the Stage filter's options. // Stages present in the exposure set, for the Stage filter's options.
const expoStages = [...new Set(m.attention.map((r) => r.current_state_name).filter(Boolean))] const expoStages = [...new Set(m.attention.map((r) => r.current_state_name).filter(Boolean))]
// The exposure table, narrowed by every active filter (AND). const anyFilter = Boolean(fStage || fExpiry || fPrem)
const shown = m.attention.filter((r) => filters.every((f) => matchExposure(f, r))) // The exposure table, narrowed by whichever dropdowns are set (AND).
const shown = m.attention.filter((r) =>
(!fStage || r.current_state_name === fStage) &&
(!fExpiry || (EXPO_BUCKETS[fExpiry] ? EXPO_BUCKETS[fExpiry](r._d) : true)) &&
(!fPrem || (PREM_MATCH[fPrem] ? PREM_MATCH[fPrem](num(r.quoted_premium)) : true)),
)
if (state.status === 'error') { if (state.status === 'error') {
const said = describeError(state.error) const said = describeError(state.error)
@ -369,78 +340,37 @@ export default function Overview() {
<div className="bucket bucket--soon"><strong>{m.exposure.month.length}</strong><span>8 to 30 days</span></div> <div className="bucket bucket--soon"><strong>{m.exposure.month.length}</strong><span>8 to 30 days</span></div>
</div> </div>
{/* Build-your-own filters: pick a field, give it a value, add it. Each {/* Plain dropdown filters over the columns the table shows. Empty
lands as a removable chip and they AND together. */} option = no filter on that field; they narrow together. */}
<div className="xf"> <div className="xfd">
<div className="xf__row"> <label className="xfd__f">
{filters.map((f, i) => ( <span>Stage</span>
<span key={i} className="xf__chip"> <select value={fStage} onChange={(e) => setFStage(e.target.value)}>
{describeFilter(f)} <option value="">All stages</option>
<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>
))}
{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>)} {expoStages.map((st) => <option key={st} value={st}>{st}</option>)}
</select> </select>
) : draft.field === 'expiry' ? ( </label>
<select className="xf__sel" value={draft.value} aria-label="Time to expiry" <label className="xfd__f">
onChange={(e) => setDraft({ ...draft, value: e.target.value })}> <span>Time to expiry</span>
<option value="">Choose window</option> <select value={fExpiry} onChange={(e) => setFExpiry(e.target.value)}>
<option value="">Any time</option>
{Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)} {Object.entries(EXPO_LABEL).map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select> </select>
) : draft.field === 'premium' ? ( </label>
<> <label className="xfd__f">
<select className="xf__sel" value={draft.op} aria-label="Comparator" <span>Premium</span>
onChange={(e) => setDraft({ ...draft, op: e.target.value })}> <select value={fPrem} onChange={(e) => setFPrem(e.target.value)}>
<option value="gte">at least</option> <option value="">Any premium</option>
<option value="lte">at most</option> {PREM_RANGES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select> </select>
<input className="xf__in" type="number" inputMode="numeric" placeholder="₹ amount" </label>
value={draft.value} aria-label="Amount" {anyFilter ? (
onChange={(e) => setDraft({ ...draft, value: e.target.value })} /> <button type="button" className="xfd__clear"
</> onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>
) : ( Clear
<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> </button>
) : null} ) : null}
</div> </div>
</div>
{shown.length ? ( {shown.length ? (
<div className="gridwrap"> <div className="gridwrap">
@ -449,7 +379,7 @@ export default function Overview() {
<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>
{shown.slice(0, filters.length ? 100 : 8).map((r) => { {shown.slice(0, anyFilter ? 100 : 8).map((r) => {
const id = r.instance_id ?? r.id const id = r.instance_id ?? r.id
return ( return (
<tr <tr
@ -480,7 +410,7 @@ export default function Overview() {
</div> </div>
) : ( ) : (
<p className="empty"> <p className="empty">
{filters.length ? 'No open leads match these filters.' : 'No renewals due within 30 days.'} {anyFilter ? 'No open leads match these filters.' : 'No renewals due within 30 days.'}
</p> </p>
)} )}
</div> </div>

View File

@ -1888,108 +1888,56 @@
color: var(--zk-grey); color: var(--zk-grey);
} }
/* ---- renewal-exposure custom filters ---- */ /* ---- renewal-exposure dropdown filters ---- */
.xf { margin-top: 12px; } .xfd {
.xf__row { margin-top: 12px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: flex-end;
gap: 8px; gap: 14px;
} }
.xf__chip { .xfd__f {
display: inline-flex; display: flex;
align-items: center; flex-direction: column;
gap: 6px; gap: 5px;
padding: 4px 6px 4px 12px; }
border-radius: var(--r-pill); .xfd__f > span {
background: var(--zk-tint-blue); font-size: var(--fs-3xs);
border: 1px solid var(--zk-blue-light, var(--zk-line));
font-size: var(--fs-2xs);
font-weight: 500; font-weight: 500;
color: var(--zk-blue-dark); letter-spacing: 0.06em;
} text-transform: uppercase;
.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); color: var(--zk-grey);
cursor: pointer;
} }
.xf__cancel:hover, .xfd__f select {
.xf__clearall:hover { color: var(--zk-blue-dark); text-decoration: underline; } font: inherit;
font-size: var(--fs-2xs);
.xf__new { padding: 8px 30px 8px 11px;
display: inline-flex; border: 1px solid var(--zk-line);
align-items: center; border-radius: var(--r-sm);
gap: 6px; background: var(--zk-white)
padding: 6px 12px 6px 10px; url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'><path d='M2.5 4.5 6 8l3.5-3.5' fill='none' stroke='%23837a90' stroke-width='1.3' stroke-linecap='round' stroke-linejoin='round'/></svg>")
border: 1px dashed var(--zk-line); no-repeat right 10px center;
border-radius: var(--r-pill); color: var(--zk-ink);
background: var(--zk-white);
cursor: pointer; cursor: pointer;
min-width: 150px;
-webkit-appearance: none;
appearance: none;
transition: border-color var(--t-fast), box-shadow var(--t-fast);
}
.xfd__f select:hover { border-color: var(--zk-blue-mid); }
.xfd__f select:focus-visible { outline: none; border-color: var(--zk-blue); box-shadow: var(--ring); }
.xfd__clear {
font: inherit; font: inherit;
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
font-weight: 500; font-weight: 500;
padding: 8px 12px;
border: 1px solid var(--zk-line);
border-radius: var(--r-sm);
background: var(--zk-white);
color: var(--zk-blue-dark); color: var(--zk-blue-dark);
cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast); transition: background var(--t-fast), border-color var(--t-fast);
} }
.xf__new svg { width: 13px; height: 13px; } .xfd__clear:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
.xf__new:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); } .xfd__clear:focus-visible { outline: none; box-shadow: var(--ring); }
.xf__new:focus-visible { outline: none; box-shadow: var(--ring); }