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 { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton';
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
||||||
|
import { Separator } from '$lib/components/ui/separator';
|
||||||
|
|
||||||
type GdprData = {
|
type GdprData = {
|
||||||
user_profile: {
|
user_profile: {
|
||||||
@@ -265,6 +266,171 @@
|
|||||||
gdprData?.payments?.some((p) => p.vat_amount && Number(p.vat_amount) > 0) ?? false
|
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() {
|
function downloadJson() {
|
||||||
if (!gdprData) return;
|
if (!gdprData) return;
|
||||||
const blob = new Blob([JSON.stringify(gdprData, null, 2)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(gdprData, null, 2)], { type: 'application/json' });
|
||||||
@@ -371,6 +537,88 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- User Profile -->
|
||||||
<Card.Root class="mb-4">
|
<Card.Root class="mb-4">
|
||||||
<Card.Header><Card.Title>User Profile</Card.Title></Card.Header>
|
<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.is_default ? 'Yes' : 'No'}</td>
|
||||||
<td class="px-4 py-3">{c.deleted_at ? 'Deleted' : 'Active'}</td>
|
<td class="px-4 py-3">{c.deleted_at ? 'Deleted' : 'Active'}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-gray-500"
|
<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"
|
<td class="px-4 py-3 whitespace-nowrap text-gray-500"
|
||||||
>{fmtDate(c.created_at)}</td
|
>{fmtDate(c.created_at)}</td
|
||||||
|
|||||||
Reference in New Issue
Block a user