Add ability to unassign a donation from a registration
Assigning a donation to a registration was one-way: the refund flow could reverse the money but left the donation's "leg" payment in place, permanently locking that portion of the donation as used even though it had been refunded back out. Adds POST /api/payments/unassign-donation, which deletes the leg, reverts the registration's status/tickets the same way a refund downgrade already does, and notifies the registrant. New "Assigned donations" list on the supervisor Payments page surfaces existing legs with an Unassign action, since no such list existed before. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -771,6 +771,10 @@ function PaymentsContent() {
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }}
|
||||
/>
|
||||
{loadingUsers && <div className="text-xs text-gray-500">Loading users…</div>}
|
||||
<AssignedDonationsList
|
||||
payments={payments}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1009,6 +1013,92 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
);
|
||||
}
|
||||
|
||||
// Lists every donation-assignment leg (isDonationLeg) across all donations, with an Unassign
|
||||
// action per row. Legs aren't shown anywhere else in the UI — "Recent payments" and the "Today"
|
||||
// stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) —
|
||||
// so this is the only place staff can see and reverse an assignment.
|
||||
type AssignedDonationsListProps = {
|
||||
payments: any[];
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps) {
|
||||
const { token } = useAuth();
|
||||
const [unassigningId, setUnassigningId] = useState<string | null>(null);
|
||||
const [err, setErr] = useDismissingState<string | null>(null);
|
||||
|
||||
const legs = useMemo(() => {
|
||||
return payments
|
||||
.filter(isDonationLeg)
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [payments]);
|
||||
|
||||
const donationById = useMemo(() => {
|
||||
const m = new Map<string, any>();
|
||||
payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); });
|
||||
return m;
|
||||
}, [payments]);
|
||||
|
||||
const unassign = async (leg: any) => {
|
||||
if (!token) return;
|
||||
setErr(null);
|
||||
const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked.");
|
||||
if (!ok) return;
|
||||
try {
|
||||
setUnassigningId(leg.id);
|
||||
await apiFetch('/api/payments/unassign-donation', {
|
||||
method: 'POST',
|
||||
authToken: token,
|
||||
body: { legId: leg.id }
|
||||
});
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to unassign donation');
|
||||
} finally {
|
||||
setUnassigningId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<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>}
|
||||
{legs.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No donations have been assigned to registrations yet.</div>
|
||||
) : (
|
||||
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
|
||||
{legs.map((leg: any) => {
|
||||
const donation = donationById.get(leg.originalPaymentId);
|
||||
return (
|
||||
<li key={leg.id} className="border rounded p-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div>
|
||||
<div className="font-medium">R {(leg.amount || 0).toFixed(2)} — {leg.registration?.user?.name || leg.registration?.userId || 'Registrant'}</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
Registration: #{String(leg.registrationId).slice(0,8)}{leg.registration?.event?.title ? ` — ${leg.registration.event.title}` : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
From donation by {donation?.user?.name || donation?.userId || 'Donor'} — #{String(leg.originalPaymentId).slice(0,8)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">{new Date(leg.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
<button
|
||||
disabled={unassigningId === leg.id}
|
||||
onClick={() => unassign(leg)}
|
||||
className="px-2 py-1 text-xs rounded bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 shrink-0"
|
||||
>
|
||||
{unassigningId === leg.id ? 'Unassigning…' : 'Unassign'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type DonationAssignSectionProps = {
|
||||
payments: any[];
|
||||
allUsers: any[];
|
||||
|
||||
Reference in New Issue
Block a user