mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 07:41:16 +00:00
- 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>
223 lines
13 KiB
TypeScript
223 lines
13 KiB
TypeScript
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 — {(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} • {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>
|
||
);
|
||
}
|