mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-06-05 04:52:59 +00:00
feat(ui): implement comprehensive dashboard and enhance frontend experience
This commit introduces a major overhaul of the user interface, transitioning from a basic structure to a feature-rich dashboard system. Key improvements include:
- **Dashboard Implementation**: Added a complete dashboard routing system with dedicated pages for Overview, Bookings, Customers, Zones, Billing, and Settings.
- **New UI Components**: Introduced a variety of high-quality components including `AnimatedList`, `FloatingDock`, `HoverFeatureCards`, `VideoPlayer` (with ambient glow effect), `PinnedList`, and `DashboardMockup`.
- **Enhanced Dashboard Features**:
- Integrated real-time KPI cards and activity timelines.
- Implemented a multi-view calendar system.
- Added customer and booking management interfaces with filtering and search capabilities.
- Added a zone/location management view with map integration.
- **Branding & Visuals**: Updated the application with new SVG logos (horizontal and vertical variants) and implemented dark/light mode optimized branding.
- **Internationalization**: Expanded i18n support with comprehensive Czech and English translations for the new dashboard and integration modules.
- **Integration Tools**: Added a new `IntegrationModal` allowing users to easily embed Bookra widgets via HTML, React, SolidJS, or PHP.
- **Backend Support**: Updated the booking service to provide comprehensive dashboard summary data, including historical booking records for charts.
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
import { Show, createSignal, createMemo } from "solid-js";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { getInitials } from "../../components/dashboard/types";
|
||||
import { CalendarDaysIcon, PlusIcon, XIcon, EyeIcon } from "../../components/dashboard/icons";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function BookingsPage() {
|
||||
const data = useDashboardData();
|
||||
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
|
||||
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [showNewBooking, setShowNewBooking] = createSignal(false);
|
||||
const [creatingBooking, setCreatingBooking] = createSignal(false);
|
||||
const [pinnedBookingIds, setPinnedBookingIds] = createSignal<Set<string>>(new Set());
|
||||
|
||||
const [newBookingCustomer, setNewBookingCustomer] = createSignal("");
|
||||
const [newBookingEmail, setNewBookingEmail] = createSignal("");
|
||||
const [newBookingService, setNewBookingService] = createSignal("");
|
||||
const [newBookingDate, setNewBookingDate] = createSignal("");
|
||||
const [newBookingTime, setNewBookingTime] = createSignal("");
|
||||
const [newBookingNotes, setNewBookingNotes] = createSignal("");
|
||||
|
||||
const resolvedAllBookings = () => data.resolvedSummary()?.allBookings ?? data.normalizedAllBookings() ?? [];
|
||||
|
||||
const filteredBookings = createMemo(() => {
|
||||
let bookings = resolvedAllBookings();
|
||||
if (filterStatus() !== "all") bookings = bookings.filter((b: any) => b.status === filterStatus());
|
||||
const now = new Date();
|
||||
if (filterDateRange() === "today") bookings = bookings.filter((b: any) => new Date(b.startsAt).toDateString() === now.toDateString());
|
||||
else if (filterDateRange() === "week") {
|
||||
const weekEnd = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= weekEnd; });
|
||||
} else if (filterDateRange() === "month") {
|
||||
const monthEnd = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000);
|
||||
bookings = bookings.filter((b: any) => { const d = new Date(b.startsAt); return d >= now && d <= monthEnd; });
|
||||
}
|
||||
if (searchQuery().trim()) {
|
||||
const q = searchQuery().toLowerCase();
|
||||
bookings = bookings.filter((b: any) =>
|
||||
b.customerName?.toLowerCase().includes(q) ||
|
||||
b.service?.toLowerCase().includes(q) ||
|
||||
b.reference?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return bookings.sort((a: any, b: any) => {
|
||||
const aPinned = pinnedBookingIds().has(a.id) ? 1 : 0;
|
||||
const bPinned = pinnedBookingIds().has(b.id) ? 1 : 0;
|
||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
||||
return new Date(b.startsAt).getTime() - new Date(a.startsAt).getTime();
|
||||
});
|
||||
});
|
||||
|
||||
const bookingStats = createMemo(() => {
|
||||
const bookings = resolvedAllBookings();
|
||||
return {
|
||||
total: bookings.length,
|
||||
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
|
||||
pending: bookings.filter((b: any) => b.status === "pending").length,
|
||||
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
|
||||
completed: bookings.filter((b: any) => b.status === "completed").length,
|
||||
};
|
||||
});
|
||||
|
||||
const handleCreateBooking = async () => {
|
||||
const bearer = data.token();
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo rezimu nelze vytvaret rezervace." : "Cannot create bookings in demo mode.");
|
||||
setShowNewBooking(false); return;
|
||||
}
|
||||
setCreatingBooking(true);
|
||||
try {
|
||||
await (apiClient as any).POST("/v1/catalog/bookings", {
|
||||
headers: { Authorization: `Bearer ${bearer}` },
|
||||
body: {
|
||||
customerName: newBookingCustomer(), customerEmail: newBookingEmail(), service: newBookingService(),
|
||||
startsAt: new Date(`${newBookingDate()}T${newBookingTime()}`).toISOString(), notes: newBookingNotes()
|
||||
}
|
||||
});
|
||||
setNewBookingCustomer(""); setNewBookingEmail(""); setNewBookingService(""); setNewBookingDate(""); setNewBookingTime(""); setNewBookingNotes("");
|
||||
setShowNewBooking(false);
|
||||
} catch { data.setDemoNotice(data.i18n.locale() === "cs" ? "Vytvoreni rezervace selhalo." : "Failed to create booking."); }
|
||||
finally { setCreatingBooking(false); }
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
|
||||
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
<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 font-display">{data.i18n.t("dashboard.bookingManagement")}</h1>
|
||||
<p class="text-ink-muted mt-1">{bookingStats().total} {data.i18n.t("dashboard.totalBookings")}</p>
|
||||
</div>
|
||||
<button onClick={() => setShowNewBooking(true)} class="btn-primary text-sm shrink-0">
|
||||
<PlusIcon /> {data.i18n.t("dashboard.newBooking")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{[
|
||||
{ key: "confirmed", label: data.i18n.t("dashboard.confirmed"), value: bookingStats().confirmed },
|
||||
{ key: "pending", label: data.i18n.t("dashboard.pending"), value: bookingStats().pending },
|
||||
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
|
||||
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
|
||||
].map((s) => (
|
||||
<button onClick={() => setFilterStatus(s.key as any)} class={`surface-card p-4 text-left transition-all hover:shadow-md ${filterStatus() === s.key ? "ring-2 ring-accent" : ""}`}>
|
||||
<p class="text-xs text-ink-muted uppercase tracking-wider">{s.label}</p>
|
||||
<p class="text-2xl font-bold text-ink mt-1 font-display">{s.value}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<Input type="text" placeholder={data.i18n.t("dashboard.search.bookings")} value={searchQuery()} onInput={(e) => setSearchQuery(e.currentTarget.value)} />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Select value={filterStatus()} onChange={(v) => setFilterStatus(v as any)} options={[
|
||||
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
|
||||
{ value: "confirmed", label: data.i18n.t("dashboard.confirmed") },
|
||||
{ value: "pending", label: data.i18n.t("dashboard.pending") },
|
||||
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
|
||||
{ value: "completed", label: data.i18n.t("dashboard.completed") },
|
||||
]} />
|
||||
<Select value={filterDateRange()} onChange={(v) => setFilterDateRange(v as any)} options={[
|
||||
{ value: "all", label: data.i18n.t("dashboard.filter.all") },
|
||||
{ value: "today", label: data.i18n.t("dashboard.filter.today") },
|
||||
{ value: "week", label: data.i18n.t("dashboard.filter.week") },
|
||||
{ value: "month", label: data.i18n.t("dashboard.filter.month") },
|
||||
]} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bookings List */}
|
||||
<div class="surface-card overflow-hidden">
|
||||
<Show when={filteredBookings().length > 0} fallback={
|
||||
<div class="p-12 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-canvas-subtle flex items-center justify-center mx-auto mb-4">
|
||||
<CalendarDaysIcon />
|
||||
</div>
|
||||
<p class="text-ink-muted font-medium">{data.i18n.t("dashboard.empty.title")}</p>
|
||||
<p class="text-sm text-ink-subtle mt-1">{data.i18n.t("dashboard.empty.bookingsDesc")}</p>
|
||||
</div>
|
||||
}>
|
||||
<div class="divide-y divide-border/60">
|
||||
{filteredBookings().map((booking: any) => {
|
||||
const isPinned = () => pinnedBookingIds().has(booking.id);
|
||||
const togglePin = (e: Event) => {
|
||||
e.stopPropagation();
|
||||
setPinnedBookingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(booking.id)) next.delete(booking.id);
|
||||
else next.add(booking.id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div class={`flex items-center gap-4 p-4 hover:bg-canvas-subtle/30 transition-colors group ${isPinned() ? "bg-accent-soft/40" : ""}`}>
|
||||
<div class={`w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${
|
||||
booking.status === "confirmed" ? "bg-accent text-canvas" : "bg-canvas-muted text-ink-muted"
|
||||
}`}>
|
||||
{getInitials(booking.customerName)}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-ink">{booking.customerName}</p>
|
||||
{isPinned() && <span class="w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
</div>
|
||||
<p class="text-sm text-ink-muted">{booking.service} • {new Date(booking.startsAt).toLocaleDateString()} {new Date(booking.startsAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={togglePin}
|
||||
class={`flex h-6 w-6 items-center justify-center rounded-full transition-all duration-200 ${
|
||||
isPinned()
|
||||
? "bg-accent text-canvas hover:bg-accent-hover"
|
||||
: "bg-canvas-subtle border border-border text-ink-subtle hover:border-accent/40 hover:text-accent"
|
||||
}`}
|
||||
aria-label={isPinned() ? "Unpin" : "Pin"}
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||
<circle cx="5" cy="5" r="4" class={isPinned() ? "" : "opacity-0"} />
|
||||
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" stroke-width="1" class={isPinned() ? "opacity-0" : ""} />
|
||||
</svg>
|
||||
</button>
|
||||
<span class={`inline-flex items-center px-2.5 py-1 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"
|
||||
}`}>
|
||||
{statusLabels[booking.status]}
|
||||
</span>
|
||||
<button onClick={() => data.openBookingDetail(booking)} class="p-2 text-ink-muted hover:text-accent hover:bg-accent-subtle rounded-lg transition-colors" title={data.i18n.t("dashboard.details")}>
|
||||
<EyeIcon />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* New Booking Modal */}
|
||||
<Show when={showNewBooking()}>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="absolute inset-0 bg-ink/50 backdrop-blur-sm" onClick={() => setShowNewBooking(false)} />
|
||||
<div class="relative bg-canvas rounded-2xl shadow-2xl w-full max-w-lg animate-scale-in">
|
||||
<div class="p-6 border-b border-border flex items-center justify-between">
|
||||
<h3 class="text-xl font-bold text-ink">{data.i18n.t("dashboard.newBooking")}</h3>
|
||||
<button onClick={() => setShowNewBooking(false)} class="p-2 hover:bg-canvas-subtle rounded-lg transition-colors"><XIcon /></button>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<Input type="text" label={data.i18n.t("booking.customer.name")} value={newBookingCustomer()} onInput={(e) => setNewBookingCustomer(e.currentTarget.value)} />
|
||||
<Input type="email" label={data.i18n.t("booking.customer.email")} value={newBookingEmail()} onInput={(e) => setNewBookingEmail(e.currentTarget.value)} />
|
||||
<Input type="text" label={data.i18n.t("dashboard.bookingModal.service")} value={newBookingService()} onInput={(e) => setNewBookingService(e.currentTarget.value)} />
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input type="date" label={data.i18n.t("dashboard.bookingModal.dateTime")} value={newBookingDate()} onInput={(e) => setNewBookingDate(e.currentTarget.value)} />
|
||||
<Input type="time" label={data.i18n.t("dashboard.bookingModal.duration")} value={newBookingTime()} onInput={(e) => setNewBookingTime(e.currentTarget.value)} />
|
||||
</div>
|
||||
<Textarea label={data.i18n.t("booking.customer.notes")} value={newBookingNotes()} onInput={(e) => setNewBookingNotes(e.currentTarget.value)} rows={3} resize="none" />
|
||||
</div>
|
||||
<div class="p-6 border-t border-border flex gap-3">
|
||||
<button onClick={() => setShowNewBooking(false)} class="flex-1 px-4 py-2.5 border border-border rounded-xl text-ink hover:bg-canvas-subtle transition-colors">{data.i18n.t("common.cancel")}</button>
|
||||
<button onClick={handleCreateBooking} disabled={creatingBooking() || !newBookingCustomer() || !newBookingEmail() || !newBookingDate() || !newBookingTime()} class="flex-1 px-4 py-2.5 bg-accent text-canvas rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
|
||||
<Show when={creatingBooking()}><span class="inline-block w-4 h-4 border-2 border-canvas/30 border-t-canvas rounded-full animate-spin" /></Show>
|
||||
{creatingBooking() ? data.i18n.t("dashboard.creating") : data.i18n.t("dashboard.createBooking")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingsRoute() {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<BookingsPage />
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user