From 6710f53d200d131824f17565d72e1a887343071d Mon Sep 17 00:00:00 2001 From: Andre Levesque <0sniffs_scaled@icloud.com> Date: Fri, 17 Jul 2026 10:24:03 -0400 Subject: [PATCH] feat: surface inactive band profiles in the admin roster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deactivated profiles (e.g. a band retired after a farewell show) were invisible in the very tool that manages them — the admin all-bands query filtered is_active = 1 while the public archive kept showing them. The roster now returns all profiles; RosterTab renders an Inactive badge and gains an All/Active/Inactive filter (default All). The lineup builder's artist picker filters retired bands back out client-side so they never become schedulable options. Closes #619 Co-Authored-By: Claude Fable 5 --- frontend/src/admin/LineupTab.jsx | 12 ++- frontend/src/admin/RosterTab.jsx | 71 ++++++++++++---- .../src/admin/__tests__/RosterTab.test.jsx | 80 +++++++++++++++++++ functions/api/admin/bands.js | 38 +++++---- .../api/admin/bands/__tests__/bands.test.js | 22 +++-- 5 files changed, 185 insertions(+), 38 deletions(-) create mode 100644 frontend/src/admin/__tests__/RosterTab.test.jsx diff --git a/frontend/src/admin/LineupTab.jsx b/frontend/src/admin/LineupTab.jsx index 224dd31b..4f69cf34 100644 --- a/frontend/src/admin/LineupTab.jsx +++ b/frontend/src/admin/LineupTab.jsx @@ -126,6 +126,16 @@ export default function LineupTab({ selectedEventId, selectedEvent, events, show } }, [selectedEventId, showToast]) + // The roster API now returns inactive/retired profiles too (#619, so + // RosterTab can manage them) — filter them back out here so the lineup + // builder never offers a retired band as a schedulable option. Origin/genre + // suggestion lists below intentionally keep using the unfiltered `allBands` + // — reusing a retired band's origin/genre value is harmless. + const activeBands = useMemo( + () => allBands.filter(band => band.is_active !== 0 && band.is_active !== false), + [allBands] + ) + const originCitySuggestions = useMemo(() => { const values = new Set() allBands.forEach(band => { @@ -690,7 +700,7 @@ export default function LineupTab({ selectedEventId, selectedEvent, events, show {viewMode === 'picker' && !readOnly && ( setViewMode('list')} diff --git a/frontend/src/admin/RosterTab.jsx b/frontend/src/admin/RosterTab.jsx index a33500e2..4067cec1 100644 --- a/frontend/src/admin/RosterTab.jsx +++ b/frontend/src/admin/RosterTab.jsx @@ -159,6 +159,25 @@ function SortIcon({ col, sortConfig }) { ) } +// The roster API now returns inactive/retired profiles alongside active ones +// (#619) — `is_active` comes straight off the D1 row (INTEGER NOT NULL +// DEFAULT 1), so it's always 0 or 1, but check both the number and legacy +// boolean shape defensively rather than relying on falsiness of `is_active`. +function isInactive(band) { + return band.is_active === 0 || band.is_active === false +} + +// Same pill styling as the existing Status column below — reused here so an +// inactive profile is visible right next to its name too, not just in a +// column that can scroll out of view. +function InactiveBadge() { + return ( + + Inactive + + ) +} + /** * RosterTab - Manage Global Artist Roster (Band Profiles) * @@ -174,6 +193,10 @@ export default function RosterTab({ showToast, readOnly = false }) { const [editingId, setEditingId] = useState(null) const [sortConfig, setSortConfig] = useState({ key: 'name', direction: 'asc' }) const [searchTerm, setSearchTerm] = useState('') + // 'all' | 'active' | 'inactive' — defaults to 'all' (#619) so a retired + // profile is never hidden by default; the admin roster must be able to see + // and edit everything it manages. + const [statusFilter, setStatusFilter] = useState('all') // Selection state for bulk actions const [selectedIds, setSelectedIds] = useState(new Set()) @@ -250,9 +273,11 @@ export default function RosterTab({ showToast, readOnly = false }) { } const filteredBands = useMemo(() => { - if (!searchTerm.trim()) return bands const query = searchTerm.trim().toLowerCase() return bands.filter(band => { + if (statusFilter === 'active' && isInactive(band)) return false + if (statusFilter === 'inactive' && !isInactive(band)) return false + if (!query) return true const originText = formatOrigin(band) return ( band.name?.toLowerCase().includes(query) || @@ -261,15 +286,15 @@ export default function RosterTab({ showToast, readOnly = false }) { band.contact_email?.toLowerCase().includes(query) ) }) - }, [bands, searchTerm]) + }, [bands, searchTerm, statusFilter]) const sortedBands = useMemo(() => { if (!sortConfig.key) return filteredBands return [...filteredBands].sort((a, b) => { if (sortConfig.key === 'is_active') { - const aVal = a.is_active === 0 || a.is_active === false ? 0 : 1 - const bVal = b.is_active === 0 || b.is_active === false ? 0 : 1 + const aVal = isInactive(a) ? 0 : 1 + const bVal = isInactive(b) ? 0 : 1 return sortConfig.direction === 'asc' ? aVal - bVal : bVal - aVal } if (sortConfig.key === 'origin') { @@ -565,6 +590,16 @@ export default function RosterTab({ showToast, readOnly = false }) { placeholder="Search name, origin, genre" className="min-h-[44px] px-3 py-2 rounded bg-bg-navy text-white border border-white/10 focus:border-accent-500 focus:outline-hidden w-64" /> + {!showAddForm && !editingId && !readOnly && (