From 99025ce4b3a02863fe6c757e3ca6b72c1d12f658 Mon Sep 17 00:00:00 2001 From: Khalim Conn-Kowlessar Date: Tue, 14 Jul 2026 11:34:37 +0000 Subject: [PATCH] feat(tags): explicit select-all-matching in bulk tag mode The header checkbox only selects loaded rows, so bulk-tagging a portfolio with more properties than are loaded (250-row window) silently tagged just the page. Bulk mode is now explicit (Gmail pattern): nothing selected = no target (Choose a tag is disabled), so a tag can't be applied to everything by accident. Tick rows to target them; once every loaded row is ticked and more match, a "Select all N matching" escalation switches the target to the whole matching set, resolved server-side via the existing assignments filter mode (no backend change). Any manual checkbox change drops the escalation back to the explicit selection; changing filters / leaving the mode clears it. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[slug]/components/PropertyTable.tsx | 55 ++++++++--- .../[slug]/components/TagActionBar.tsx | 99 ++++++++++++++----- 2 files changed, 115 insertions(+), 39 deletions(-) diff --git a/src/app/portfolio/[slug]/components/PropertyTable.tsx b/src/app/portfolio/[slug]/components/PropertyTable.tsx index 6cbf399c..92419d1a 100644 --- a/src/app/portfolio/[slug]/components/PropertyTable.tsx +++ b/src/app/portfolio/[slug]/components/PropertyTable.tsx @@ -327,6 +327,21 @@ export default function PropertyTable({ // Row selection for bulk tagging — keyed by property id (see DataTable getRowId). const [rowSelection, setRowSelection] = useState({}); const selectedIds = Object.keys(rowSelection).filter((id) => rowSelection[id]); + // Deliberate "select all matching" escalation (whole set, not just loaded rows). + const [selectAllMatching, setSelectAllMatching] = useState(false); + + const clearSelection = () => { + setRowSelection({}); + setSelectAllMatching(false); + }; + // Any manual checkbox change drops the all-matching escalation back to the + // explicit (possibly now-smaller) row selection. + const handleRowSelectionChange = ( + updater: Updater, + ) => { + setSelectAllMatching(false); + setRowSelection(updater); + }; // Bulk-tag flows launched from the Tags menu: a contextual assign/remove mode, // and the file-upload modal. @@ -456,6 +471,16 @@ export default function PropertyTable({ // loaded rows (cheap; avoids a hook per project convention): exact for a // fully-loaded portfolio, a floor when more rows lazy-load beyond view. const unmodelledInView = tableData.filter((p) => p.planId == null).length; + + // Bulk-select scope: how many properties match the current view, whether every + // loaded row is ticked, and whether more match than are loaded (so "select all + // matching" is worth offering). matchingCount is filtered-total when a filter + // is active, else the whole portfolio. + const matchingCount = hasActiveFilters ? filteredTotal : totalCount; + const allLoadedSelected = + selectedIds.length > 0 && selectedIds.length === tableData.length; + const moreMatchThanLoaded = tableData.length < matchingCount; + // The not-modelled banner owns the Run-modelling CTA in this state, so the // toolbar must NOT also show one — otherwise two identical navy buttons appear. const showModelNudge = @@ -477,7 +502,7 @@ export default function PropertyTable({ if (pagination.pageIndex !== 0) setPagination((p) => ({ ...p, pageIndex: 0 })); // The selection is filter-scoped — a changed filter invalidates it. - if (selectedIds.length > 0) setRowSelection({}); + if (selectedIds.length > 0 || selectAllMatching) clearSelection(); } const [isFetchingMore, setIsFetchingMore] = useState(false); @@ -586,11 +611,13 @@ export default function PropertyTable({ properties )} - {selectedIds.length > 0 && ( + {(selectedIds.length > 0 || selectAllMatching) && ( - {selectedIds.length.toLocaleString()} selected + {selectAllMatching + ? `All ${matchingCount.toLocaleString()} selected` + : `${selectedIds.length.toLocaleString()} selected`} + )} + {selectAllMatching && ( + + )} +