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:
parent
b46faa70e2
commit
f31d6cb866
@ -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,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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
) : 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>
|
||||
</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>
|
||||
) : 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>
|
||||
</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>
|
||||
<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
|
||||
</label>
|
||||
{anyFilter ? (
|
||||
<button type="button" className="xfd__clear"
|
||||
onClick={() => { setFStage(''); setFExpiry(''); setFPrem('') }}>
|
||||
Clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{shown.length ? (
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@ -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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user