Files
Bookra/apps/frontend/src/routes/dashboard/overview-page.tsx
T
Tomas DvorakandDevin f2f7439050 refactor(db): replace sqlc with handwritten pgx repos
- Finish Paddle-to-Stripe billing migration; drop paddle/stripe libs
- Renumber migrations; add 00012 memberships+manage_token,
  00013 location_type_capacity
- Add tests: admin, auth, bookings customer service
- Remove graphify-out artifacts and stale env files

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-17 20:21:34 +02:00

223 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { A, useNavigate } from "@solidjs/router";
import { Show } from "solid-js";
import { CalendarView } from "../../components/dashboard/calendar-view";
import { RevenueChart } from "../../components/dashboard/revenue-chart";
import { KpiCard } from "../../components/dashboard/kpi-card";
import { ActivityTimeline } from "../../components/dashboard/activity-timeline";
import { NotificationDropdown } from "../../components/dashboard/notification-dropdown";
import { getInitials } from "../../components/dashboard/types";
import { ChevronRightIcon, MailIcon, SparklesIcon } from "../../components/dashboard/icons";
import { PinnedList } from "../../components/pinned-list";
import { DashboardLayout, useDashboardData } from "./layout";
function OverviewPage() {
const data = useDashboardData();
const navigate = useNavigate();
return (
<div class="space-y-6">
{/* Header */}
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl sm:text-3xl font-bold text-ink tracking-tight font-display">
{data.i18n.t("dashboard.welcome")} <span class="text-accent">{data.auth.session()?.user?.name}</span>
</h1>
<p class="text-ink-muted mt-1">{data.i18n.t("dashboard.overviewFor")} {data.resolvedBootstrap()?.tenantName}</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<button onClick={() => data.setShowIntegrationModal(true)} class="btn-secondary text-sm">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
{data.i18n.t("dashboard.shareManage")}
</button>
<span class="text-sm text-ink-subtle hidden sm:inline">{new Date().toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "long", month: "long", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
<NotificationDropdown bookings={data.normalizedAllBookings()} billing={data.resolvedBilling()} />
</div>
</div>
{/* KPI Cards */}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{data.normalizedKpis().map((kpi: any, index: number) => (
<KpiCard kpi={kpi} index={index} />
))}
</div>
{/* Setup Guide + Trend */}
<div class="grid lg:grid-cols-5 gap-6">
<div class="lg:col-span-2 surface-card p-5">
<div class="flex items-center gap-3 mb-4">
<div class="w-9 h-9 rounded-lg bg-accent-subtle flex items-center justify-center text-accent">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5"/></svg>
</div>
<div>
<h3 class="font-semibold text-ink">{data.i18n.locale() === "cs" ? "Začínáme" : "Getting Started"}</h3>
<p class="text-xs text-ink-muted">{data.i18n.locale() === "cs" ? "Dokončete nastavení" : "Complete your setup"}</p>
</div>
</div>
<PinnedList
items={[
{
id: "setup-locations",
name: data.i18n.locale() === "cs" ? "Přidat lokace" : "Add locations",
subtitle: data.i18n.locale() === "cs" ? "Salon, klinika, studio" : "Salon, clinic, studio",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/></svg>,
href: "/dashboard/zones",
},
{
id: "setup-hours",
name: data.i18n.locale() === "cs" ? "Nastavit provozní dobu" : "Set business hours",
subtitle: data.i18n.locale() === "cs" ? "Po–Ne, svátky" : "Mon–Sun, holidays",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>,
href: "/dashboard/zones",
},
{
id: "setup-services",
name: data.i18n.locale() === "cs" ? "Definovat služby" : "Define services",
subtitle: data.i18n.locale() === "cs" ? "Ceník, délka, kapacita" : "Pricing, duration, capacity",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-widget",
name: data.i18n.locale() === "cs" ? "Vložit rezervační widget" : "Embed booking widget",
subtitle: data.i18n.locale() === "cs" ? "Web, Instagram, email" : "Website, Instagram, email",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-team",
name: data.i18n.locale() === "cs" ? "Přidat členy týmu" : "Add team members",
subtitle: data.i18n.locale() === "cs" ? "Role, oprávnění" : "Roles, permissions",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
href: "/dashboard/settings",
},
{
id: "setup-branding",
name: data.i18n.locale() === "cs" ? "Upravit branding" : "Customize branding",
subtitle: data.i18n.locale() === "cs" ? "Barva, logo, jazyk" : "Color, logo, language",
icon: <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.01 17.461 2 12 2z"/></svg>,
href: "/dashboard/settings",
},
]}
pinnedLabel={data.i18n.locale() === "cs" ? "Připnuto" : "Pinned"}
allLabel={data.i18n.locale() === "cs" ? "Zbývá" : "Remaining"}
initialPinned={["setup-locations"]}
/>
</div>
<div class="lg:col-span-3">
<RevenueChart bookings={data.normalizedAllBookings()} />
</div>
</div>
{/* SMS Usage */}
{(() => {
const usage = data.smsUsage();
if (!usage || usage.messageCount === 0) return null;
return (
<div class="surface-card p-5 border-l-4 border-accent">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-full bg-accent-subtle flex items-center justify-center text-accent">
<MailIcon />
</div>
<div>
<p class="font-semibold text-ink">SMS {data.i18n.t("dashboard.billing.planUsage")}</p>
<p class="text-sm text-ink-muted">{usage.messageCount} messages &mdash; {(usage.totalCostCents / 100).toFixed(2)} Kc</p>
</div>
</div>
<A href="/dashboard/settings" class="text-sm font-medium text-accent hover:text-accent-hover transition-colors">
{data.i18n.t("dashboard.settings")}
</A>
</div>
</div>
);
})()}
{/* Plan Limit Warning */}
{(() => {
const entitlements = data.resolvedBilling()?.entitlements;
const limit = entitlements?.maxLocations ?? -1;
const count = data.locationData() ?? 0;
const isNear = limit > 0 && count >= limit * 0.8;
const isAt = limit > 0 && count >= limit;
if (!isNear && !isAt) return null;
return (
<div class={`p-4 rounded-xl border ${isAt ? "bg-canvas-subtle border-ink/10" : "bg-accent/5 border-accent/20"}`}>
<div class="flex items-center gap-3">
<div class={`w-10 h-10 rounded-full flex items-center justify-center ${isAt ? "bg-canvas-muted" : "bg-accent/10"}`}>
<SparklesIcon />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-ink">{isAt ? data.i18n.t("dashboard.locationLimitReached") : data.i18n.t("dashboard.nearingLocationLimit")}</p>
<p class="text-sm text-ink-muted">{data.i18n.t("dashboard.locationsUsed")} {count}/{limit === -1 ? "\u221e" : limit}</p>
</div>
<button onClick={() => void data.openCheckout()} class="px-4 py-2 text-sm font-medium rounded-lg bg-accent text-canvas hover:bg-accent-hover transition-colors shrink-0">
{data.i18n.t("dashboard.upgrade")}
</button>
</div>
</div>
);
})()}
{/* Calendar + Activity */}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2">
<CalendarView bookings={data.normalizedAllBookings()} locale={data.i18n.locale()} onBookingClick={data.openBookingDetail} />
</div>
<div class="space-y-6">
<ActivityTimeline activities={data.normalizedRecentActivity()} />
</div>
</div>
{/* Upcoming Bookings */}
<div class="surface-card p-6 shadow-sm">
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-lg font-semibold text-ink">{data.i18n.t("dashboard.upcomingBookings")}</h3>
<p class="text-sm text-ink-muted mt-0.5">{data.normalizedUpcomingBookings().length} total</p>
</div>
<A href="/dashboard/bookings" class="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-accent hover:text-accent-hover hover:bg-accent-subtle rounded-xl transition-all">
{data.i18n.t("dashboard.viewAll")} <ChevronRightIcon />
</A>
</div>
<div class="space-y-3">
{data.normalizedUpcomingBookings().slice(0, 5).map((booking: any) => (
<button
onClick={() => data.openBookingDetail(booking)}
class="w-full text-left flex items-center gap-4 p-4 rounded-xl border border-border/60 hover:border-accent/30 hover:bg-accent-subtle/20 transition-all group"
>
<div class="w-11 h-11 rounded-full bg-accent-subtle flex items-center justify-center text-sm font-bold text-accent shrink-0">
{getInitials(booking.customerName)}
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-ink group-hover:text-accent transition-colors">{booking.customerName}</p>
<p class="text-sm text-ink-muted">{booking.service} &bull; {booking.duration}</p>
</div>
<div class="text-right shrink-0">
<p class="text-sm font-medium text-ink">{new Date(booking.startsAt).toLocaleDateString(data.i18n.locale() === "cs" ? "cs-CZ" : "en-US", { weekday: "short", month: "short", day: "numeric" })}</p>
<span class={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
booking.status === "confirmed" ? "bg-accent/10 text-accent"
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
: "bg-canvas-subtle text-ink-subtle"
}`}>
{data.i18n.t(`dashboard.${booking.status}`)}
</span>
</div>
</button>
))}
</div>
</div>
</div>
);
}
export default function OverviewRoute() {
return (
<DashboardLayout>
<OverviewPage />
</DashboardLayout>
);
}