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' }
/* 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' },
const PREM_RANGES = [
['lt10', 'Under ₹10,000', (p) => p > 0 && p < 10000],
['10-25', '₹10,000 25,000', (p) => p >= 10000 && p < 25000],
['25-50', '₹25,000 50,000', (p) => p >= 25000 && p < 50000],
['gt50', 'Over ₹50,000', (p) => p >= 50000],
]
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 ''
}
}
const PREM_MATCH = Object.fromEntries(PREM_RANGES.map(([k, , fn]) => [k, fn]))
function expiryTone(d) {
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
// NewLeadDialog. The overview behind it keeps its counts and its poll.
const [adding, setAdding] = useState(false)
// User-built filters on the renewal-exposure table, AND-combined, plus the
// one being composed in the add row.
const [filters, setFilters] = useState([])
const [draft, setDraft] = useState(null) // { field, op?, value } | null
// Renewal-exposure dropdown filters. Empty string = no filter on that field.
const [fStage, setFStage] = useState('')
const [fExpiry, setFExpiry] = useState('')
const [fPrem, setFPrem] = useState('')
const state = usePortfolio()
const m = useMemo(() => {
@ -216,8 +182,13 @@ export default function Overview() {
// 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))]
// The exposure table, narrowed by every active filter (AND).
const shown = m.attention.filter((r) => filters.every((f) => matchExposure(f, r)))
const anyFilter = Boolean(fStage || fExpiry || fPrem)
// 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') {
const said = describeError(state.error)
@ -369,77 +340,36 @@ export default function Overview() {
<div className="bucket bucket--soon"><strong>{m.exposure.month.length}</strong><span>8 to 30 days</span></div>
</div>
{/* Build-your-own filters: pick a field, give it a value, add it. Each
lands as a removable chip and they AND together. */}
<div className="xf">
<div className="xf__row">
{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>
))}
{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>
{/* Plain dropdown filters over the columns the table shows. Empty
option = no filter on that field; they narrow together. */}
<div className="xfd">
<label className="xfd__f">
<span>Stage</span>
<select value={fStage} onChange={(e) => setFStage(e.target.value)}>
<option value="">All stages</option>
{expoStages.map((st) => <option key={st} value={st}>{st}</option>)}
</select>
</label>
<label className="xfd__f">
<span>Time to expiry</span>
<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>)}
</select>
</label>
<label className="xfd__f">
<span>Premium</span>
<select value={fPrem} onChange={(e) => setFPrem(e.target.value)}>
<option value="">Any premium</option>
{PREM_RANGES.map(([k, l]) => <option key={k} value={k}>{l}</option>)}
</select>
</label>
{anyFilter ? (
<button type="button" className="xfd__clear"
onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>
Clear
</button>
) : null}
</div>
{shown.length ? (
@ -449,7 +379,7 @@ export default function Overview() {
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
</thead>
<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
return (
<tr
@ -480,7 +410,7 @@ export default function Overview() {
</div>
) : (
<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>
)}
</div>

View File

@ -1888,108 +1888,56 @@
color: var(--zk-grey);
}
/* ---- renewal-exposure custom filters ---- */
.xf { margin-top: 12px; }
.xf__row {
/* ---- renewal-exposure dropdown filters ---- */
.xfd {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
align-items: flex-end;
gap: 14px;
}
.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);
.xfd__f {
display: flex;
flex-direction: column;
gap: 5px;
}
.xfd__f > span {
font-size: var(--fs-3xs);
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;
letter-spacing: 0.06em;
text-transform: uppercase;
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);
.xfd__f select {
font: inherit;
font-size: var(--fs-2xs);
padding: 8px 30px 8px 11px;
border: 1px solid var(--zk-line);
border-radius: var(--r-sm);
background: var(--zk-white)
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>")
no-repeat right 10px center;
color: var(--zk-ink);
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-size: var(--fs-2xs);
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);
cursor: pointer;
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); }
.xfd__clear:hover { background: var(--zk-tint-blue); border-color: var(--zk-blue-mid); }
.xfd__clear:focus-visible { outline: none; box-shadow: var(--ring); }