Centralize seat capacity accounting and the payment provider registry.
Replace manualProviders.ts with a paymentProviders.ts registry (automatic vs manual settlement) and move all seat counting into capacity.ts as the single source of truth: only paid/checked-in tickets and pending_approval payments hold a seat, so abandoned checkouts never block sales. Admins can now knowingly approve a payment over capacity (allowOverCapacity), with the booking and admin UIs updated to match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c9a600b6d6
commit
71c277045b
@@ -15,7 +15,7 @@ import {
|
||||
UserGroupIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { parseDate } from '@/lib/utils';
|
||||
import { parseDate, eventSpotsLeft, isEventSoldOut } from '@/lib/utils';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
@@ -112,16 +112,15 @@ export default function AdminDashboardPage() {
|
||||
<Card className="p-6">
|
||||
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Low capacity warnings */}
|
||||
{/* Low capacity warnings (availableSeats accounts for paid + claimed seats) */}
|
||||
{data?.upcomingEvents
|
||||
.filter(event => {
|
||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
||||
return percentFull >= 80 && spotsLeft > 0;
|
||||
const spotsLeft = eventSpotsLeft(event);
|
||||
return event.capacity > 0 && spotsLeft > 0 && spotsLeft / event.capacity <= 0.2;
|
||||
})
|
||||
.map(event => {
|
||||
const spotsLeft = Math.max(0, event.capacity - (event.bookedCount || 0));
|
||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
||||
const spotsLeft = eventSpotsLeft(event);
|
||||
const percentFull = Math.round(((event.capacity - spotsLeft) / event.capacity) * 100);
|
||||
return (
|
||||
<Link
|
||||
key={event.id}
|
||||
@@ -142,7 +141,7 @@ export default function AdminDashboardPage() {
|
||||
|
||||
{/* Sold out events */}
|
||||
{data?.upcomingEvents
|
||||
.filter(event => Math.max(0, event.capacity - (event.bookedCount || 0)) === 0)
|
||||
.filter(event => isEventSoldOut(event))
|
||||
.map(event => (
|
||||
<Link
|
||||
key={event.id}
|
||||
@@ -160,16 +159,30 @@ export default function AdminDashboardPage() {
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{data && data.stats.pendingPayments > 0 && (
|
||||
<Link
|
||||
{/* Actionable: customer says they paid, needs verification */}
|
||||
{data && (data.stats.awaitingApprovalPayments ?? 0) > 0 && (
|
||||
<Link
|
||||
href="/admin/payments"
|
||||
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
||||
<span className="text-sm">Pending payments</span>
|
||||
<span className="text-sm">Payments awaiting verification</span>
|
||||
</div>
|
||||
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
|
||||
<span className="badge badge-warning">{data.stats.awaitingApprovalPayments}</span>
|
||||
</Link>
|
||||
)}
|
||||
{/* Informational: opened checkouts that never paid — hold no seats */}
|
||||
{data && data.stats.pendingPayments > 0 && (
|
||||
<Link
|
||||
href="/admin/payments"
|
||||
className="flex items-center justify-between p-3 bg-gray-50 rounded-btn hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-gray-400" />
|
||||
<span className="text-sm text-gray-500">Unpaid started bookings</span>
|
||||
</div>
|
||||
<span className="badge badge-info">{data.stats.pendingPayments}</span>
|
||||
</Link>
|
||||
)}
|
||||
{data && data.stats.newContacts > 0 && (
|
||||
@@ -186,10 +199,11 @@ export default function AdminDashboardPage() {
|
||||
)}
|
||||
|
||||
{/* No alerts */}
|
||||
{data &&
|
||||
data.stats.pendingPayments === 0 &&
|
||||
data.stats.newContacts === 0 &&
|
||||
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
|
||||
{data &&
|
||||
data.stats.pendingPayments === 0 &&
|
||||
(data.stats.awaitingApprovalPayments ?? 0) === 0 &&
|
||||
data.stats.newContacts === 0 &&
|
||||
!data.upcomingEvents.some(e => e.capacity > 0 && eventSpotsLeft(e) / e.capacity <= 0.2) && (
|
||||
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -218,7 +232,12 @@ export default function AdminDashboardPage() {
|
||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{event.bookedCount || 0}/{event.capacity}
|
||||
{(event.bookedCount || 0) + (event.claimedCount || 0)}/{event.capacity}
|
||||
{(event.claimedCount || 0) > 0 && (
|
||||
<span className="text-xs text-yellow-600 block text-right">
|
||||
{event.claimedCount} pending approval
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user