feat(frontend): add booking stats summary section to GDPR page
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
|
||||
type GdprData = {
|
||||
user_profile: {
|
||||
@@ -265,6 +266,171 @@
|
||||
gdprData?.payments?.some((p) => p.vat_amount && Number(p.vat_amount) > 0) ?? false
|
||||
);
|
||||
|
||||
type BookingStats = {
|
||||
totalBookings: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
noShow: number;
|
||||
pending: number;
|
||||
totalSpent: number;
|
||||
totalRefunded: number;
|
||||
netSpent: number;
|
||||
avgBookingValue: number;
|
||||
memberSince: string;
|
||||
totalLoyaltyStamps: number;
|
||||
stampsRedeemed: number;
|
||||
totalDiscounts: number;
|
||||
topServices: Array<{ name: string; count: number; total: number }>;
|
||||
paymentMethods: Array<{ method: string; count: number; total: number }>;
|
||||
forgivenNoShows: number;
|
||||
editRequests: number;
|
||||
patchTests: number;
|
||||
savedCards: number;
|
||||
referredBy: string | null;
|
||||
referredCount: number;
|
||||
};
|
||||
|
||||
type SecondaryStat = {
|
||||
label: string;
|
||||
value: string;
|
||||
subtitle: string;
|
||||
highlight?: boolean;
|
||||
};
|
||||
|
||||
function computeSecondaryStats(stats: BookingStats, data: GdprData): SecondaryStat[] {
|
||||
const candidates: SecondaryStat[] = [
|
||||
{
|
||||
label: 'Loyalty Stamps',
|
||||
value: `${stats.totalLoyaltyStamps} total`,
|
||||
subtitle: `${stats.stampsRedeemed} redeemed, ${data.user_profile.loyalty_stamps} remaining`,
|
||||
},
|
||||
{
|
||||
label: 'Discounts Received',
|
||||
value: fmt(stats.totalDiscounts),
|
||||
subtitle: `${data.booking_discounts?.length ?? 0} discount(s) applied`,
|
||||
highlight: true,
|
||||
},
|
||||
{
|
||||
label: 'Patch Tests',
|
||||
value: `${stats.patchTests}`,
|
||||
subtitle: 'completed',
|
||||
},
|
||||
{
|
||||
label: 'Edits or Reschedules',
|
||||
value: `${stats.editRequests}`,
|
||||
subtitle: 'submitted',
|
||||
},
|
||||
{
|
||||
label: 'Saved Cards',
|
||||
value: `${stats.savedCards}`,
|
||||
subtitle: 'on file',
|
||||
},
|
||||
];
|
||||
|
||||
const nonZero = candidates.filter((c) => {
|
||||
const num = parseInt(c.value.replace(/[^0-9.-]+/g, ''), 10);
|
||||
return num > 0;
|
||||
});
|
||||
const zero = candidates.filter((c) => {
|
||||
const num = parseInt(c.value.replace(/[^0-9.-]+/g, ''), 10);
|
||||
return num <= 0;
|
||||
});
|
||||
|
||||
const result = [...nonZero, ...zero];
|
||||
return result.slice(0, 3);
|
||||
}
|
||||
|
||||
function computeBookingStats(data: GdprData | null): BookingStats | null {
|
||||
if (!data) return null;
|
||||
const bookings = data.bookings ?? [];
|
||||
const payments = data.payments ?? [];
|
||||
const refunds = data.refunds ?? [];
|
||||
const discounts = data.booking_discounts ?? [];
|
||||
const redemptions = data.loyalty_redemptions ?? [];
|
||||
const noShows = data.forgiven_no_shows ?? [];
|
||||
const edits = data.edit_requests ?? [];
|
||||
const patchTests = data.patch_tests ?? [];
|
||||
|
||||
const completedBookings = bookings.filter((b) => b.status === 'completed');
|
||||
const cancelledBookings = bookings.filter(
|
||||
(b) =>
|
||||
b.status === 'client_cancelled' ||
|
||||
b.status === 'we_cancelled' ||
|
||||
b.status === 'no_show'
|
||||
);
|
||||
const noShowBookings = bookings.filter((b) => b.status === 'no_show');
|
||||
const pendingBookings = bookings.filter(
|
||||
(b) => b.status === 'pending' || b.status === 'confirmed'
|
||||
);
|
||||
|
||||
const totalSpent = payments
|
||||
.filter((p) => p.status === 'completed')
|
||||
.reduce((sum, p) => sum + Number(p.amount), 0);
|
||||
const totalRefunded = refunds
|
||||
.filter((r) => r.status === 'completed')
|
||||
.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
|
||||
const serviceMap = new Map<string, { count: number; total: number }>();
|
||||
for (const b of bookings) {
|
||||
for (const s of b.services ?? []) {
|
||||
const existing = serviceMap.get(s.name) ?? { count: 0, total: 0 };
|
||||
existing.count++;
|
||||
existing.total += Number(s.price);
|
||||
serviceMap.set(s.name, existing);
|
||||
}
|
||||
}
|
||||
const topServices = Array.from(serviceMap.entries())
|
||||
.map(([name, data]) => ({ name, ...data }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
|
||||
const methodMap = new Map<string, { count: number; total: number }>();
|
||||
for (const p of payments.filter((p) => p.status === 'completed')) {
|
||||
if (p.payment_method === 'discount') continue;
|
||||
const method = p.payment_method.replace(/_/g, ' ');
|
||||
const existing = methodMap.get(method) ?? { count: 0, total: 0 };
|
||||
existing.count++;
|
||||
existing.total += Number(p.amount);
|
||||
methodMap.set(method, existing);
|
||||
}
|
||||
const paymentMethods = Array.from(methodMap.entries())
|
||||
.map(([method, data]) => ({ method, ...data }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const totalDiscounts = discounts.reduce(
|
||||
(sum, d) => sum + Number(d.discount_amount), 0
|
||||
);
|
||||
const stampsRedeemed = redemptions.reduce(
|
||||
(sum, r) => sum + Number(r.stamps_redeemed), 0
|
||||
);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
completed: completedBookings.length,
|
||||
cancelled: cancelledBookings.length,
|
||||
noShow: noShowBookings.length,
|
||||
pending: pendingBookings.length,
|
||||
totalSpent,
|
||||
totalRefunded,
|
||||
netSpent: totalSpent - totalRefunded,
|
||||
avgBookingValue: completedBookings.length > 0
|
||||
? completedBookings.reduce((s, b) => s + Number(b.total_price), 0) / completedBookings.length
|
||||
: 0,
|
||||
memberSince: data.user_profile?.created_at ?? '',
|
||||
totalLoyaltyStamps: (data.user_profile?.loyalty_stamps ?? 0) + stampsRedeemed,
|
||||
stampsRedeemed,
|
||||
totalDiscounts,
|
||||
topServices,
|
||||
paymentMethods,
|
||||
forgivenNoShows: noShows.length,
|
||||
editRequests: edits.length,
|
||||
patchTests: patchTests.length,
|
||||
savedCards: (data.saved_cards ?? []).length,
|
||||
referredBy: data.referrals?.referred_by?.referrer_name ?? null,
|
||||
referredCount: data.referrals?.referred_users?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function downloadJson() {
|
||||
if (!gdprData) return;
|
||||
const blob = new Blob([JSON.stringify(gdprData, null, 2)], { type: 'application/json' });
|
||||
@@ -371,6 +537,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
{#if computeBookingStats(gdprData)}
|
||||
{@const stats = computeBookingStats(gdprData)}
|
||||
<h2 class="mb-3 text-xl font-bold">Summary</h2>
|
||||
<div class="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">Member Since</span>
|
||||
<p class="mt-1 text-lg font-semibold">{fmtDate(stats.memberSince)}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">Total Bookings</span>
|
||||
<p class="mt-1 text-lg font-semibold">{stats.totalBookings}</p>
|
||||
<p class="text-xs text-gray-400">
|
||||
{stats.completed} completed, {stats.cancelled} cancelled
|
||||
{#if stats.noShow > 0}, {stats.noShow} no-show{/if}
|
||||
{#if stats.pending > 0}, {stats.pending} pending{/if}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">Total Spent</span>
|
||||
<p class="mt-1 text-lg font-semibold">{fmt(stats.totalSpent)}</p>
|
||||
{#if stats.totalRefunded > 0}
|
||||
<p class="text-xs text-red-500">−{fmt(stats.totalRefunded)} refunded</p>
|
||||
<p class="text-xs text-gray-400">net {fmt(stats.netSpent)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">Avg Booking</span>
|
||||
<p class="mt-1 text-lg font-semibold">{fmt(stats.avgBookingValue)}</p>
|
||||
<p class="text-xs text-gray-400">per completed booking</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stats.topServices.length > 0}
|
||||
<div class="mb-6 rounded-xl border bg-card p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-500">Most Booked Services</h3>
|
||||
<div class="space-y-2">
|
||||
{#each stats.topServices as svc (svc.name)}
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-fuchsia-100 text-xs font-semibold text-fuchsia-700">
|
||||
{svc.count}
|
||||
</span>
|
||||
<span class="text-sm">{svc.name}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500">{fmt(svc.total)} total</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats.paymentMethods.length > 0}
|
||||
<div class="mb-6 rounded-xl border bg-card p-4">
|
||||
<h3 class="mb-3 text-sm font-semibold text-gray-500">Payment Methods</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each stats.paymentMethods as pm (pm.method)}
|
||||
<div class="flex items-center gap-2 rounded-lg bg-gray-50 px-3 py-2">
|
||||
<span class="text-sm font-medium">{pm.method}</span>
|
||||
<span class="text-xs text-gray-400">({pm.count}×, {fmt(pm.total)})</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{@const secondary = computeSecondaryStats(stats, gdprData)}
|
||||
<div class="mb-6 grid grid-cols-3 gap-3">
|
||||
{#each secondary as s (s.label)}
|
||||
<div class="rounded-xl border bg-card p-4">
|
||||
<span class="text-xs text-gray-400">{s.label}</span>
|
||||
<p class="mt-1 text-lg font-semibold {s.highlight ? 'text-green-600' : ''}">{s.value}</p>
|
||||
<p class="text-xs text-gray-400">{s.subtitle}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Separator class="mb-6" />
|
||||
{/if}
|
||||
|
||||
<h2 class="mb-4 text-xl font-bold">Personal Data</h2>
|
||||
|
||||
<!-- User Profile -->
|
||||
<Card.Root class="mb-4">
|
||||
<Card.Header><Card.Title>User Profile</Card.Title></Card.Header>
|
||||
@@ -693,7 +941,7 @@
|
||||
<td class="px-4 py-3">{c.is_default ? 'Yes' : 'No'}</td>
|
||||
<td class="px-4 py-3">{c.deleted_at ? 'Deleted' : 'Active'}</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-gray-500"
|
||||
>{c.retained_until ? fmtDate(c.retained_until) : '—'}</td
|
||||
>{c.retained_until ? fmtDate(c.retained_until) : '7 years after expiry'}</td
|
||||
>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-gray-500"
|
||||
>{fmtDate(c.created_at)}</td
|
||||
|
||||
Reference in New Issue
Block a user