diff --git a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx index 2efc19e6..f487d95d 100644 --- a/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx +++ b/packages/web/src/components/modals/instanceSettingsPanels/RegistrationPanel.tsx @@ -1,5 +1,5 @@ import { createPortal } from 'react-dom'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { InviteLinkSummary, InviteRedemption, InviteStatus } from '@backspace/shared'; import { api } from '../../../api/client'; import { useSettingsStore } from '../../../stores/settingsStore'; @@ -69,6 +69,182 @@ function inviteStatusLabel(status: InviteStatus): string { } } +// --------------------------------------------------------------------------- +// Sort / filter types +// --------------------------------------------------------------------------- + +type ActiveSort = 'recent' | 'oldest' | 'name' | 'mostUsed' | 'expiringSoonest'; +type ArchivedSort = 'recent' | 'oldest' | 'name'; +type ArchivedStatus = 'expired' | 'exhausted' | 'revoked'; + +// --------------------------------------------------------------------------- +// Sort / filter pure functions +// Sort/filter applied client-side. At ~500+ invites in a single bucket, +// move to server-side: add `?sort=` and `?status=` params to /admin/invites, +// page through results. Today the test instances have <50 invites total. +// --------------------------------------------------------------------------- + +function sortInvites( + list: InviteLinkSummary[], + sortKey: ActiveSort | ArchivedSort, +): InviteLinkSummary[] { + const arr = [...list]; + switch (sortKey) { + case 'recent': + return arr.sort((a, b) => b.createdAt - a.createdAt); + case 'oldest': + return arr.sort((a, b) => a.createdAt - b.createdAt); + case 'name': + return arr.sort((a, b) => a.name.localeCompare(b.name)); + case 'mostUsed': + return arr.sort((a, b) => b.usedCount - a.usedCount); + case 'expiringSoonest': + return arr.sort((a, b) => { + // null expiresAt (no expiration) sorts last + if (a.expiresAt === null && b.expiresAt === null) return 0; + if (a.expiresAt === null) return 1; + if (b.expiresAt === null) return -1; + return a.expiresAt - b.expiresAt; + }); + } +} + +function filterInvitesByStatus( + list: InviteLinkSummary[], + statuses: Set, +): InviteLinkSummary[] { + // ArchivedStatus excludes 'active' by construction — this filter is only + // applied on the archived tab where all invites have a non-active status. + return list.filter((inv) => statuses.has(inv.status as ArchivedStatus)); +} + +// --------------------------------------------------------------------------- +// FilterDropdown +// --------------------------------------------------------------------------- + +interface FilterDropdownProps { + view: 'active' | 'archived'; + activeSort: ActiveSort; + onActiveSortChange: (s: ActiveSort) => void; + archivedSort: ArchivedSort; + onArchivedSortChange: (s: ArchivedSort) => void; + archivedStatusFilter: Set; + onArchivedStatusToggle: (s: ArchivedStatus) => void; +} + +function FilterDropdown({ + view, + activeSort, + onActiveSortChange, + archivedSort, + onArchivedSortChange, + archivedStatusFilter, + onArchivedStatusToggle, +}: FilterDropdownProps) { + const [open, setOpen] = useState(false); + + const activeSortOptions: Array<{ key: ActiveSort; label: string }> = [ + { key: 'recent', label: 'Most recent' }, + { key: 'oldest', label: 'Oldest' }, + { key: 'name', label: 'Name (A–Z)' }, + { key: 'mostUsed', label: 'Most used' }, + { key: 'expiringSoonest', label: 'Expiring soonest' }, + ]; + + const archivedSortOptions: Array<{ key: ArchivedSort; label: string }> = [ + { key: 'recent', label: 'Most recent' }, + { key: 'oldest', label: 'Oldest' }, + { key: 'name', label: 'Name (A–Z)' }, + ]; + + const archivedStatusOptions: ArchivedStatus[] = ['expired', 'exhausted', 'revoked']; + + const handleArchivedStatusToggle = (s: ArchivedStatus) => { + // Prevent deselecting the last selected status — always keep at least one. + if (archivedStatusFilter.has(s) && archivedStatusFilter.size === 1) return; + onArchivedStatusToggle(s); + }; + + return ( +
+ + + {open && ( + <> +
setOpen(false)} /> +
+ {view === 'archived' && ( + <> +
+ Status +
+ {archivedStatusOptions.map((s) => ( + + ))} +
+ + )} +
+ Sort by +
+ {view === 'active' + ? activeSortOptions.map((opt) => ( + + )) + : archivedSortOptions.map((opt) => ( + + ))} +
+ + )} +
+ ); +} + type ExpiryPresetId = '1h' | '24h' | '7d' | '30d' | 'never' | 'custom'; /** Edit modal additionally supports a 'keep' option (don't change expiry on PATCH). */ @@ -1305,6 +1481,23 @@ export function RegistrationPanel() { // that scrolls out of the visible list keeps stale state. const [expandedInviteId, setExpandedInviteId] = useState(null); + // Sort / filter state — independent per tab so switching tabs preserves each + // tab's last selection. + const [activeSort, setActiveSort] = useState('recent'); + const [archivedSort, setArchivedSort] = useState('recent'); + const [archivedStatusFilter, setArchivedStatusFilter] = useState>( + () => new Set(['expired', 'exhausted', 'revoked']), + ); + + const toggleArchivedStatus = (s: ArchivedStatus) => { + setArchivedStatusFilter((prev) => { + const next = new Set(prev); + if (next.has(s)) next.delete(s); + else next.add(s); + return next; + }); + }; + const handleTabSwitch = (next: 'active' | 'archived') => { setTab(next); setExpandedInviteId(null); @@ -1367,6 +1560,18 @@ export function RegistrationPanel() { fetchInvites(tab); }, [tab, fetchInvites]); + // Derived sorted + filtered list. Tab badge counts (`activeCount`/`archivedCount`) + // are NOT derived from this — they reflect the full unfiltered bucket size. + // Must be declared before any conditional early-return to satisfy rules-of-hooks. + const displayInvites = useMemo(() => { + let list = invites; + if (tab === 'archived') { + list = filterInvitesByStatus(list, archivedStatusFilter); + } + list = sortInvites(list, tab === 'active' ? activeSort : archivedSort); + return list; + }, [invites, tab, activeSort, archivedSort, archivedStatusFilter]); + if (!draft) return
Loading settings...
; const hasChanges = !!instanceSettings && ( @@ -1465,7 +1670,7 @@ export function RegistrationPanel() {
- {/* Row 2: tab strip (left) + filter placeholder (right — Commit 4) */} + {/* Row 2: tab strip (left) + FilterDropdown (right) */}
-
+
{invitesLoading ? ( @@ -1496,9 +1709,13 @@ export function RegistrationPanel() {
{tab === 'active' ? 'No active invite links.' : 'No archived invite links.'}
+ ) : displayInvites.length === 0 ? ( +
+ No invites match the current filter. +
) : (
- {invites.map((inv) => ( + {displayInvites.map((inv) => (