mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(bookings): dashboard cancellation and waitlist promotion
- POST /v1/catalog/bookings/{id}/cancel lets businesses cancel
tenant-owned bookings; two-step confirm in the detail modal
- Shared PromoteWaitlist helper promotes earliest waitlisted bookings
when a class seat frees, on both dashboard and customer cancel paths;
promoted customers get a confirmation email, waitlist ledger marked
resolved
- waitlisted status surfaced in bookings UI: stats card, filter, warning
badge, demo seed, CS+EN strings
- BookingRecord gains created_at for fair ordering; GetBookingByID and
ResolveWaitlistEntry added to the repository surface
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
0405106587
commit
668a54ae1d
15 files changed
+563
-5
No files matched your search
@@ -309,6 +309,8 @@ const dictionaries = {
|
||||
"dashboard.totalBookings": "celkem rezervací",
|
||||
"dashboard.newBooking": "Nová rezervace",
|
||||
"dashboard.bookingDetails": "Detail rezervace",
|
||||
"dashboard.booking.cancelBooking": "Zrušit rezervaci",
|
||||
"dashboard.booking.confirmCancel": "Potvrdit zrušení?",
|
||||
"dashboard.customerDetails": "Detail zákazníka",
|
||||
"dashboard.close": "Zavřít",
|
||||
"dashboard.edit": "Upravit",
|
||||
@@ -324,6 +326,7 @@ const dictionaries = {
|
||||
"dashboard.nextMonth": "Další měsíc",
|
||||
"dashboard.confirmed": "Potvrzeno",
|
||||
"dashboard.pending": "Čeká",
|
||||
"dashboard.waitlisted": "Čekací listina",
|
||||
"dashboard.cancelled": "Zrušeno",
|
||||
"dashboard.completed": "Dokončeno",
|
||||
"dashboard.welcome.title": "Vítejte v Bookra",
|
||||
@@ -878,6 +881,8 @@ const dictionaries = {
|
||||
"dashboard.totalBookings": "total bookings",
|
||||
"dashboard.newBooking": "New Booking",
|
||||
"dashboard.bookingDetails": "Booking Details",
|
||||
"dashboard.booking.cancelBooking": "Cancel booking",
|
||||
"dashboard.booking.confirmCancel": "Confirm cancellation?",
|
||||
"dashboard.customerDetails": "Customer Details",
|
||||
"dashboard.close": "Close",
|
||||
"dashboard.edit": "Edit",
|
||||
@@ -893,6 +898,7 @@ const dictionaries = {
|
||||
"dashboard.nextMonth": "Next month",
|
||||
"dashboard.confirmed": "Confirmed",
|
||||
"dashboard.pending": "Pending",
|
||||
"dashboard.waitlisted": "Waitlisted",
|
||||
"dashboard.cancelled": "Cancelled",
|
||||
"dashboard.completed": "Completed",
|
||||
"dashboard.welcome.title": "Welcome to Bookra",
|
||||
|
||||
@@ -9,7 +9,7 @@ import { DashboardLayout, useDashboardData } from "./layout";
|
||||
|
||||
function BookingsPage() {
|
||||
const data = useDashboardData();
|
||||
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "cancelled" | "completed">("all");
|
||||
const [filterStatus, setFilterStatus] = createSignal<"all" | "confirmed" | "pending" | "waitlisted" | "cancelled" | "completed">("all");
|
||||
const [filterDateRange, setFilterDateRange] = createSignal<"all" | "today" | "week" | "month">("all");
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [showNewBooking, setShowNewBooking] = createSignal(false);
|
||||
@@ -81,6 +81,7 @@ function BookingsPage() {
|
||||
total: bookings.length,
|
||||
confirmed: bookings.filter((b: any) => b.status === "confirmed").length,
|
||||
pending: bookings.filter((b: any) => b.status === "pending").length,
|
||||
waitlisted: bookings.filter((b: any) => b.status === "waitlisted").length,
|
||||
cancelled: bookings.filter((b: any) => b.status === "cancelled").length,
|
||||
completed: bookings.filter((b: any) => b.status === "completed").length,
|
||||
};
|
||||
@@ -111,6 +112,7 @@ function BookingsPage() {
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
confirmed: data.i18n.t("dashboard.confirmed"), pending: data.i18n.t("dashboard.pending"),
|
||||
waitlisted: data.i18n.t("dashboard.waitlisted"),
|
||||
cancelled: data.i18n.t("dashboard.cancelled"), completed: data.i18n.t("dashboard.completed")
|
||||
};
|
||||
|
||||
@@ -130,7 +132,7 @@ function BookingsPage() {
|
||||
<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: "waitlisted", label: data.i18n.t("dashboard.waitlisted"), value: bookingStats().waitlisted },
|
||||
{ key: "cancelled", label: data.i18n.t("dashboard.cancelled"), value: bookingStats().cancelled },
|
||||
{ key: "completed", label: data.i18n.t("dashboard.completed"), value: bookingStats().completed },
|
||||
].map((s) => (
|
||||
@@ -151,6 +153,7 @@ function BookingsPage() {
|
||||
{ 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: "waitlisted", label: data.i18n.t("dashboard.waitlisted") },
|
||||
{ value: "cancelled", label: data.i18n.t("dashboard.cancelled") },
|
||||
{ value: "completed", label: data.i18n.t("dashboard.completed") },
|
||||
]} />
|
||||
@@ -218,6 +221,7 @@ function BookingsPage() {
|
||||
</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 === "waitlisted" ? "bg-warning-subtle text-warning"
|
||||
: booking.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
||||
: "bg-canvas-subtle text-ink-subtle"
|
||||
}`}>
|
||||
|
||||
@@ -48,6 +48,7 @@ function createDemoData(i18n: ReturnType<typeof useI18n>) {
|
||||
{ id: "b6", customerName: customers[0].name, customerEmail: customers[0].email, service: services[1], status: "confirmed", startsAt: fmt(at(3, 10)), endsAt: fmt(at(3, 11)), reference: "BOK-006", location: salonA, staffName: staff1, notes: "" },
|
||||
{ id: "b8", customerName: customers[2].name, customerEmail: customers[2].email, service: services[3], status: "pending", startsAt: fmt(at(4, 9)), endsAt: fmt(at(4, 10)), reference: "BOK-008", location: salonA, staffName: staff2, notes: "" },
|
||||
{ id: "b9", customerName: customers[3].name, customerEmail: customers[3].email, service: services[0], status: "confirmed", startsAt: fmt(at(5, 11)), endsAt: fmt(at(5, 12)), reference: "BOK-009", location: salonA, staffName: staff1, notes: "" },
|
||||
{ id: "b13", customerName: customers[0].name, customerEmail: customers[0].email, service: cs ? "Jóga pro začátečníky" : "Beginner yoga", status: "waitlisted", startsAt: fmt(at(3, 18)), endsAt: fmt(at(3, 19)), reference: "BOK-013", location: salonA, staffName: "", notes: "" },
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
UserCircleIcon, MapPinIcon, ScissorsIcon, UsersIcon
|
||||
} from "../../components/dashboard/icons";
|
||||
import { DashboardContext, useDashboardData, DashboardDataProvider } from "./dashboard-data";
|
||||
import { apiClient } from "../../lib/api-client";
|
||||
|
||||
export { useDashboardData };
|
||||
|
||||
@@ -30,6 +31,31 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
const theme = useTheme();
|
||||
const location = useLocation();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = createSignal(false);
|
||||
const [cancelConfirm, setCancelConfirm] = createSignal<"idle" | "armed" | "busy">("idle");
|
||||
|
||||
const handleCancelBooking = async () => {
|
||||
if (cancelConfirm() === "busy") return;
|
||||
if (cancelConfirm() === "idle") { setCancelConfirm("armed"); return; }
|
||||
const booking = data.selectedBooking();
|
||||
const bearer = data.token();
|
||||
if (!booking) return;
|
||||
if (!bearer || bearer.startsWith("demo.")) {
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "V demo režimu nelze rušit rezervace." : "Cannot cancel bookings in demo mode.");
|
||||
setCancelConfirm("idle");
|
||||
return;
|
||||
}
|
||||
setCancelConfirm("busy");
|
||||
try {
|
||||
const response = await (apiClient as any).POST(`/v1/catalog/bookings/${booking.id}/cancel`, { headers: { Authorization: `Bearer ${bearer}` } });
|
||||
if (response.error) throw new Error("cancel failed");
|
||||
data.setShowBookingDetail(false);
|
||||
void data.refetchSummary();
|
||||
} catch {
|
||||
data.setDemoNotice(data.i18n.locale() === "cs" ? "Zrušení rezervace selhalo." : "Failed to cancel booking.");
|
||||
} finally {
|
||||
setCancelConfirm("idle");
|
||||
}
|
||||
};
|
||||
|
||||
const activeSection = () => {
|
||||
const p = location.pathname;
|
||||
@@ -242,6 +268,7 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
</div>
|
||||
<span class={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${
|
||||
data.selectedBooking()?.status === "confirmed" ? "bg-accent/10 text-accent"
|
||||
: data.selectedBooking()?.status === "waitlisted" ? "bg-warning-subtle text-warning"
|
||||
: data.selectedBooking()?.status === "pending" ? "bg-canvas-muted text-ink-muted"
|
||||
: "bg-canvas-subtle text-ink-subtle"
|
||||
}`}>
|
||||
@@ -255,6 +282,25 @@ function DashboardShell(props: { children: JSX.Element }) {
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={data.selectedBooking()?.status !== "cancelled"}>
|
||||
<div class="p-6 border-t border-border">
|
||||
<button
|
||||
onClick={() => void handleCancelBooking()}
|
||||
disabled={cancelConfirm() === "busy"}
|
||||
class={`w-full px-4 py-2.5 rounded-xl text-sm font-medium transition-colors disabled:opacity-60 ${
|
||||
cancelConfirm() === "armed"
|
||||
? "bg-red-600 text-canvas hover:bg-red-700"
|
||||
: "border border-border text-red-600 hover:bg-red-50"
|
||||
}`}
|
||||
>
|
||||
{cancelConfirm() === "busy"
|
||||
? "…"
|
||||
: cancelConfirm() === "armed"
|
||||
? data.i18n.t("dashboard.booking.confirmCancel")
|
||||
: data.i18n.t("dashboard.booking.cancelBooking")}
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
Reference in new issue
Block a user