diff --git a/frontend/src/routes/gdpr/+page.svelte b/frontend/src/routes/gdpr/+page.svelte index 4cda041..088ffe3 100644 --- a/frontend/src/routes/gdpr/+page.svelte +++ b/frontend/src/routes/gdpr/+page.svelte @@ -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(); + 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(); + 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 @@ + + {#if computeBookingStats(gdprData)} + {@const stats = computeBookingStats(gdprData)} +

Summary

+
+
+ Member Since +

{fmtDate(stats.memberSince)}

+
+
+ Total Bookings +

{stats.totalBookings}

+

+ {stats.completed} completed, {stats.cancelled} cancelled + {#if stats.noShow > 0}, {stats.noShow} no-show{/if} + {#if stats.pending > 0}, {stats.pending} pending{/if} +

+
+
+ Total Spent +

{fmt(stats.totalSpent)}

+ {#if stats.totalRefunded > 0} +

−{fmt(stats.totalRefunded)} refunded

+

net {fmt(stats.netSpent)}

+ {/if} +
+
+ Avg Booking +

{fmt(stats.avgBookingValue)}

+

per completed booking

+
+
+ + {#if stats.topServices.length > 0} +
+

Most Booked Services

+
+ {#each stats.topServices as svc (svc.name)} +
+
+ + {svc.count} + + {svc.name} +
+ {fmt(svc.total)} total +
+ {/each} +
+
+ {/if} + + {#if stats.paymentMethods.length > 0} +
+

Payment Methods

+
+ {#each stats.paymentMethods as pm (pm.method)} +
+ {pm.method} + ({pm.count}×, {fmt(pm.total)}) +
+ {/each} +
+
+ {/if} + + {@const secondary = computeSecondaryStats(stats, gdprData)} +
+ {#each secondary as s (s.label)} +
+ {s.label} +

{s.value}

+

{s.subtitle}

+
+ {/each} +
+ + + {/if} + +

Personal Data

+ User Profile @@ -693,7 +941,7 @@ {c.is_default ? 'Yes' : 'No'} {c.deleted_at ? 'Deleted' : 'Active'} {c.retained_until ? fmtDate(c.retained_until) : '—'}{c.retained_until ? fmtDate(c.retained_until) : '7 years after expiry'} {fmtDate(c.created_at)}