diff --git a/src/screens/Overview.jsx b/src/screens/Overview.jsx index f680db3..d71aed8 100644 --- a/src/screens/Overview.jsx +++ b/src/screens/Overview.jsx @@ -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() {

Renewal exposureopen leads by time to expiry

- {/* 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. */} -
- {[ - ['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]) => ( - - ))} +
+
{m.exposure.lapsed.length}Lapsed
+
{m.exposure.week.length}Within 7 days
+
{m.exposure.month.length}8 to 30 days
- {expo ? ( - - ) : null} + {/* Build-your-own filters: pick a field, give it a value, add it. Each + lands as a removable chip and they AND together. */} +
+
+ {filters.map((f, i) => ( + + {describeFilter(f)} + + + ))} - {expoRows.length ? ( + {draft ? ( + + + + {draft.field === 'stage' ? ( + + ) : draft.field === 'expiry' ? ( + + ) : draft.field === 'premium' ? ( + <> + + setDraft({ ...draft, value: e.target.value })} /> + + ) : ( + setDraft({ ...draft, value: e.target.value })} /> + )} + + + + + ) : ( + + )} + + {filters.length ? ( + + ) : null} +
+
+ + {shown.length ? (
- {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 ( ) : (

- {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.'}

)} diff --git a/src/screens/screens.css b/src/screens/screens.css index 09f5885..c68b120 100644 --- a/src/screens/screens.css +++ b/src/screens/screens.css @@ -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); }
LeadExpiryStagePremium