Add search and event filter to the Assigned donations list

Finding a specific leg to unassign meant scanning the whole list by
eye. Adds a text search (matches registrant, donor, or event) and an
event dropdown (built from events that actually have assigned legs)
above the list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 01:15:45 +02:00
co-authored by Claude Sonnet 5
parent 34f9826829
commit 47b79181b7
2 changed files with 55 additions and 3 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Added ### Added
- Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied. - Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied. The list can be searched (registrant, donor, or event) and filtered by event.
### Fixed ### Fixed
@@ -1026,8 +1026,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
const { token } = useAuth(); const { token } = useAuth();
const [unassigningId, setUnassigningId] = useState<string | null>(null); const [unassigningId, setUnassigningId] = useState<string | null>(null);
const [err, setErr] = useDismissingState<string | null>(null); const [err, setErr] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [eventFilter, setEventFilter] = useState("");
const legs = useMemo(() => { const allLegs = useMemo(() => {
return payments return payments
.filter(isDonationLeg) .filter(isDonationLeg)
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); .sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -1039,6 +1041,36 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
return m; return m;
}, [payments]); }, [payments]);
// Events with at least one assigned leg — built from the legs themselves so the dropdown
// never shows an event with nothing to filter down to.
const eventOptions = useMemo(() => {
const m = new Map<string, string>();
allLegs.forEach((leg: any) => {
const ev = leg.registration?.event;
if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id));
});
return Array.from(m.entries()).sort((a, b) => a[1].localeCompare(b[1], undefined, { sensitivity: 'base' }));
}, [allLegs]);
const legs = useMemo(() => {
const q = query.trim().toLowerCase();
return allLegs.filter((leg: any) => {
if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false;
if (!q) return true;
const donation = donationById.get(leg.originalPaymentId);
const haystack = [
leg.registration?.user?.name,
leg.registration?.user?.email,
leg.registration?.event?.title,
donation?.user?.name,
donation?.user?.email,
leg.registrationId,
leg.originalPaymentId,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [allLegs, donationById, query, eventFilter]);
const unassign = async (leg: any) => { const unassign = async (leg: any) => {
if (!token) return; if (!token) return;
setErr(null); setErr(null);
@@ -1063,8 +1095,28 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
<div className="border rounded-xl p-4 bg-white shadow-sm"> <div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Assigned donations</div> <div className="text-lg font-semibold mb-3">Assigned donations</div>
{err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>} {err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>}
{legs.length === 0 ? ( {allLegs.length > 0 && (
<div className="flex flex-col sm:flex-row gap-2 mb-3">
<input
className="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Search by registrant, donor, event…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<select
className="w-full sm:w-56 border rounded px-3 py-2 text-sm shrink-0"
value={eventFilter}
onChange={e => setEventFilter(e.target.value)}
>
<option value="">All events</option>
{eventOptions.map(([id, title]) => (<option key={id} value={id}>{title}</option>))}
</select>
</div>
)}
{allLegs.length === 0 ? (
<div className="text-sm text-gray-500">No donations have been assigned to registrations yet.</div> <div className="text-sm text-gray-500">No donations have been assigned to registrations yet.</div>
) : legs.length === 0 ? (
<div className="text-sm text-gray-500">No assigned donations match your search.</div>
) : ( ) : (
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2"> <ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
{legs.map((leg: any) => { {legs.map((leg: any) => {