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:
parent
e0d51159f6
commit
b46faa70e2
@ -55,7 +55,49 @@ const EXPO_BUCKETS = {
|
||||
month: (d) => d > 7 && d <= 30,
|
||||
}
|
||||
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) {
|
||||
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
|
||||
// NewLeadDialog. The overview behind it keeps its counts and its poll.
|
||||
const [adding, setAdding] = useState(false)
|
||||
// Which renewal-exposure bucket the table is narrowed to, or null for all.
|
||||
const [expo, setExpo] = useState(null)
|
||||
// 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
|
||||
const state = usePortfolio()
|
||||
|
||||
const m = useMemo(() => {
|
||||
@ -170,9 +214,10 @@ export default function Overview() {
|
||||
|
||||
const mine = new Set(visibleStages(roles).map((s) => s.uid))
|
||||
|
||||
// The exposure table, narrowed to the chosen bucket (or the soonest-due
|
||||
// across all buckets when no filter is set).
|
||||
const expoRows = expo ? m.attention.filter((r) => EXPO_BUCKETS[expo](r._d)) : m.attention
|
||||
// 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)))
|
||||
|
||||
if (state.status === 'error') {
|
||||
const said = describeError(state.error)
|
||||
@ -318,43 +363,93 @@ export default function Overview() {
|
||||
</div>
|
||||
|
||||
<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
|
||||
table to it, click again (or Clear) to show every open renewal. A
|
||||
bucket with nothing in it is not clickable — there is nothing to
|
||||
show. */}
|
||||
<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 className="buckets">
|
||||
<div className="bucket bucket--lapsed"><strong>{m.exposure.lapsed.length}</strong><span>Lapsed</span></div>
|
||||
<div className="bucket bucket--urgent"><strong>{m.exposure.week.length}</strong><span>Within 7 days</span></div>
|
||||
<div className="bucket bucket--soon"><strong>{m.exposure.month.length}</strong><span>8 to 30 days</span></div>
|
||||
</div>
|
||||
|
||||
{expo ? (
|
||||
<button type="button" className="expo__clear" onClick={() => setExpo(null)}>
|
||||
Showing {expoLabel(expo)} only · Clear filter
|
||||
</button>
|
||||
) : null}
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{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">
|
||||
<table className="grid grid--tight">
|
||||
<thead>
|
||||
<tr><th>Lead</th><th>Expiry</th><th>Stage</th><th className="num">Premium</th></tr>
|
||||
</thead>
|
||||
<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
|
||||
return (
|
||||
<tr
|
||||
@ -385,7 +480,7 @@ export default function Overview() {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1059,41 +1059,7 @@
|
||||
.bucket--soon { border-left-color: var(--zk-blue-mid); }
|
||||
.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
|
||||
where the book is banked without pulling in a charting library. */
|
||||
@ -1921,3 +1887,109 @@
|
||||
text-transform: uppercase;
|
||||
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); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user