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