mirror of
https://github.com/Dvorinka/Bookra.git
synced 2026-10-28 15:51:15 +00:00
feat(bookings): customer self-service rebooking with real slot picker
CustomerBookingView now carries tenantSlug/serviceId/staffId/ classSessionId so the manage page can query live availability; class bookings resolve their session title as the display label. Reschedule rejects waitlisted/completed bookings and session-bound class bookings (400), collapsing a dead branch in the appointment path. The manage page reschedule dialog replaces free-form date/time inputs with a date → availability-slot picker filtered to appointment mode, so customers can only move to a genuinely free slot with the correct duration; 409 surfaces "slot just taken". Actions are status-gated: reschedule for confirmed/pending appointments, cancel for live bookings, explanatory note for class bookings. 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
1cd5b1ba91
commit
796c06ae66
7 files changed
+310
-86
No files matched your search
@@ -73,6 +73,12 @@ func (s *CustomerService) managementURL(booking db.BookingRecord) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) {
|
func (s *CustomerService) descriptors(ctx context.Context, booking db.BookingRecord) (serviceName string, locationName string) {
|
||||||
|
if booking.ServiceID == nil && booking.ClassSessionID != nil {
|
||||||
|
// Class bookings carry no service; the session title is the label.
|
||||||
|
if session, err := s.repo.GetClassSessionByID(ctx, *booking.ClassSessionID); err == nil {
|
||||||
|
serviceName = session.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
if booking.ServiceID != nil {
|
if booking.ServiceID != nil {
|
||||||
if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil {
|
if services, err := s.repo.ListServicesByTenant(ctx, booking.TenantID); err == nil {
|
||||||
for _, service := range services {
|
for _, service := range services {
|
||||||
@@ -119,6 +125,10 @@ func (s *CustomerService) GetBookingByReference(ctx context.Context, reference s
|
|||||||
CustomerEmail: booking.CustomerEmail,
|
CustomerEmail: booking.CustomerEmail,
|
||||||
Service: serviceName,
|
Service: serviceName,
|
||||||
BusinessName: tenant.Name,
|
BusinessName: tenant.Name,
|
||||||
|
TenantSlug: tenant.Slug,
|
||||||
|
ServiceID: booking.ServiceID,
|
||||||
|
StaffID: booking.StaffID,
|
||||||
|
ClassSessionID: booking.ClassSessionID,
|
||||||
StartsAt: booking.StartsAt,
|
StartsAt: booking.StartsAt,
|
||||||
EndsAt: booking.EndsAt,
|
EndsAt: booking.EndsAt,
|
||||||
Location: locationName,
|
Location: locationName,
|
||||||
@@ -142,6 +152,18 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
|
|||||||
return ErrBookingCancelled
|
return ErrBookingCancelled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only live bookings may move: waitlisted entries sit in a queue tied to
|
||||||
|
// their session, and completed bookings are history.
|
||||||
|
if booking.Status == "waitlisted" || booking.Status == "completed" {
|
||||||
|
return ErrInvalidReschedule
|
||||||
|
}
|
||||||
|
|
||||||
|
// Class bookings are bound to their session window; moving to another
|
||||||
|
// session means cancelling and booking again.
|
||||||
|
if booking.ClassSessionID != nil {
|
||||||
|
return ErrInvalidReschedule
|
||||||
|
}
|
||||||
|
|
||||||
newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt)
|
newStartsAt, err := time.Parse(time.RFC3339, req.NewStartsAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ErrInvalidReschedule
|
return ErrInvalidReschedule
|
||||||
@@ -174,16 +196,14 @@ func (s *CustomerService) RescheduleBooking(ctx context.Context, reference strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Appointments must land inside bookable hours and off blocked days;
|
// Appointments must land inside bookable hours and off blocked days.
|
||||||
// class bookings keep their session-defined window.
|
// Class bookings never reach this point — rejected above.
|
||||||
if booking.ClassSessionID == nil {
|
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
||||||
tenant, err := s.repo.GetTenantByID(ctx, booking.TenantID)
|
if err != nil {
|
||||||
if err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
}
|
if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil {
|
||||||
if err := validateAppointmentTarget(ctx, s.repo, tenant, booking.StaffID, booking.LocationID, newStartsAt, newEndsAt); err != nil {
|
return ErrInvalidReschedule
|
||||||
return ErrInvalidReschedule
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
|
if err := s.repo.RescheduleBooking(ctx, booking.ID, newStartsAt, newEndsAt); err != nil {
|
||||||
|
|||||||
@@ -111,3 +111,41 @@ func TestRescheduleRejectsConflictingSlot(t *testing.T) {
|
|||||||
t.Fatalf("expected ErrBookingConflict, got %v", err)
|
t.Fatalf("expected ErrBookingConflict, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRescheduleRejectsClassWaitlistedAndCompleted(t *testing.T) {
|
||||||
|
repo := db.NewMemoryRepository()
|
||||||
|
svc := NewCustomerService(repo, nil, "http://localhost:3000")
|
||||||
|
sessionID := "4bf74c12-44dd-45ca-86bb-b104f16f2435"
|
||||||
|
future := time.Now().Add(48 * time.Hour)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
status string
|
||||||
|
sessionID *string
|
||||||
|
}{
|
||||||
|
{"class booking", "confirmed", &sessionID},
|
||||||
|
{"waitlisted booking", "waitlisted", &sessionID},
|
||||||
|
{"completed booking", "completed", nil},
|
||||||
|
}
|
||||||
|
for i, tc := range cases {
|
||||||
|
_, err := repo.CreateBooking(context.Background(), db.CreateBookingParams{
|
||||||
|
TenantID: "5d6b3551-0a3e-4b86-bdf0-e9df20a47148",
|
||||||
|
ClassSessionID: tc.sessionID,
|
||||||
|
StartsAt: future,
|
||||||
|
EndsAt: future.Add(time.Hour),
|
||||||
|
Status: tc.status,
|
||||||
|
Reference: "BOK-RS-" + string(rune('A'+i)),
|
||||||
|
ManageToken: "tok",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: create booking: %v", tc.name, err)
|
||||||
|
}
|
||||||
|
err = svc.RescheduleBooking(context.Background(), "BOK-RS-"+string(rune('A'+i)), domain.RescheduleBookingRequest{
|
||||||
|
NewStartsAt: future.Add(2 * time.Hour).Format(time.RFC3339),
|
||||||
|
NewEndsAt: future.Add(3 * time.Hour).Format(time.RFC3339),
|
||||||
|
}, "tok")
|
||||||
|
if err != ErrInvalidReschedule {
|
||||||
|
t.Fatalf("%s: expected ErrInvalidReschedule, got %v", tc.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -490,6 +490,12 @@ type CustomerBookingView struct {
|
|||||||
CustomerEmail string `json:"customerEmail"`
|
CustomerEmail string `json:"customerEmail"`
|
||||||
Service string `json:"service"`
|
Service string `json:"service"`
|
||||||
BusinessName string `json:"businessName"`
|
BusinessName string `json:"businessName"`
|
||||||
|
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||||
|
ServiceID *string `json:"serviceId,omitempty"`
|
||||||
|
StaffID *string `json:"staffId,omitempty"`
|
||||||
|
// ClassSessionID identifies session-bound bookings; they cannot be
|
||||||
|
// rescheduled to arbitrary times (see RescheduleBooking).
|
||||||
|
ClassSessionID *string `json:"classSessionId,omitempty"`
|
||||||
StartsAt time.Time `json:"startsAt"`
|
StartsAt time.Time `json:"startsAt"`
|
||||||
EndsAt time.Time `json:"endsAt"`
|
EndsAt time.Time `json:"endsAt"`
|
||||||
Location string `json:"location"`
|
Location string `json:"location"`
|
||||||
|
|||||||
@@ -346,6 +346,10 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Booking details
|
description: Booking details
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CustomerBookingView"
|
||||||
"403":
|
"403":
|
||||||
description: Invalid manage token
|
description: Invalid manage token
|
||||||
"404":
|
"404":
|
||||||
@@ -360,29 +364,37 @@ paths:
|
|||||||
required: true
|
required: true
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- in: query
|
||||||
|
name: token
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Manage token issued at booking time
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [token, startsAt]
|
required: [newStartsAt, newEndsAt]
|
||||||
properties:
|
properties:
|
||||||
token:
|
newStartsAt:
|
||||||
type: string
|
|
||||||
startsAt:
|
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
endsAt:
|
newEndsAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Booking rescheduled
|
description: Booking rescheduled
|
||||||
|
"400":
|
||||||
|
description: Invalid new window, non-reschedulable status, or class booking
|
||||||
"403":
|
"403":
|
||||||
description: Invalid manage token
|
description: Invalid manage token
|
||||||
"409":
|
"409":
|
||||||
description: New slot conflicts
|
description: New slot conflicts or booking already cancelled
|
||||||
/v1/public/bookings/{reference}/cancel:
|
/v1/public/bookings/{reference}/cancel:
|
||||||
post:
|
post:
|
||||||
tags: [Public Booking]
|
tags: [Public Booking]
|
||||||
@@ -1770,6 +1782,46 @@ components:
|
|||||||
waitlistPosition:
|
waitlistPosition:
|
||||||
type: integer
|
type: integer
|
||||||
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||||
|
CustomerBookingView:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
reference:
|
||||||
|
type: string
|
||||||
|
customerName:
|
||||||
|
type: string
|
||||||
|
customerEmail:
|
||||||
|
type: string
|
||||||
|
service:
|
||||||
|
type: string
|
||||||
|
businessName:
|
||||||
|
type: string
|
||||||
|
tenantSlug:
|
||||||
|
type: string
|
||||||
|
serviceId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
staffId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
classSessionId:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: Present for session-bound bookings, which cannot be rescheduled to arbitrary times
|
||||||
|
startsAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
endsAt:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
location:
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
notes:
|
||||||
|
type: string
|
||||||
|
waitlistPosition:
|
||||||
|
type: integer
|
||||||
|
description: 1-based queue spot for waitlisted class bookings; absent otherwise
|
||||||
DashboardBookingRequest:
|
DashboardBookingRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [customerName, customerEmail, startsAt]
|
required: [customerName, customerEmail, startsAt]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useParams, useSearchParams } from "@solidjs/router";
|
import { useParams, useSearchParams } from "@solidjs/router";
|
||||||
import { createSignal, createResource, Show } from "solid-js";
|
import { createSignal, createResource, For, Show } from "solid-js";
|
||||||
import { apiClient } from "../lib/api-client";
|
import { apiClient } from "../lib/api-client";
|
||||||
import { useI18n } from "../providers/i18n-provider";
|
import { useI18n } from "../providers/i18n-provider";
|
||||||
import { BookraCharacter } from "../components/bookra-character";
|
import { BookraCharacter } from "../components/bookra-character";
|
||||||
@@ -12,7 +12,8 @@ export function BookingManageRoute() {
|
|||||||
const [isRescheduling, setIsRescheduling] = createSignal(false);
|
const [isRescheduling, setIsRescheduling] = createSignal(false);
|
||||||
const [isCancelling, setIsCancelling] = createSignal(false);
|
const [isCancelling, setIsCancelling] = createSignal(false);
|
||||||
const [newDate, setNewDate] = createSignal("");
|
const [newDate, setNewDate] = createSignal("");
|
||||||
const [newTime, setNewTime] = createSignal("");
|
const [selectedSlot, setSelectedSlot] = createSignal<{ startsAt: string; endsAt: string } | null>(null);
|
||||||
|
const [rescheduleBusy, setRescheduleBusy] = createSignal(false);
|
||||||
const [message, setMessage] = createSignal("");
|
const [message, setMessage] = createSignal("");
|
||||||
const [actionSuccess, setActionSuccess] = createSignal<string | null>(null);
|
const [actionSuccess, setActionSuccess] = createSignal<string | null>(null);
|
||||||
const [actionError, setActionError] = createSignal<string | null>(null);
|
const [actionError, setActionError] = createSignal<string | null>(null);
|
||||||
@@ -20,7 +21,7 @@ export function BookingManageRoute() {
|
|||||||
const token = () => searchParams.token || "";
|
const token = () => searchParams.token || "";
|
||||||
const reference = () => params.reference || "";
|
const reference = () => params.reference || "";
|
||||||
|
|
||||||
const [booking] = createResource(token, async (t) => {
|
const [booking, { refetch: refetchBooking }] = createResource(token, async (t) => {
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
const ref = reference();
|
const ref = reference();
|
||||||
if (!ref) return null;
|
if (!ref) return null;
|
||||||
@@ -49,12 +50,30 @@ export function BookingManageRoute() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [slots] = createResource(
|
||||||
|
() => [booking()?.tenantSlug, newDate(), booking()?.serviceId, booking()?.staffId] as const,
|
||||||
|
async ([slug, date, serviceId, staffId]) => {
|
||||||
|
if (!slug || !date) return [];
|
||||||
|
const query = new URLSearchParams({ date });
|
||||||
|
if (serviceId) query.set("serviceId", serviceId);
|
||||||
|
if (staffId) query.set("staffId", staffId);
|
||||||
|
try {
|
||||||
|
const response = await (apiClient as any).GET(`/v1/public/tenants/${slug}/availability?${query}`);
|
||||||
|
const list = (response.data?.slots ?? []) as any[];
|
||||||
|
return list.filter((s) => s.mode === "appointment");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const handleReschedule = async () => {
|
const handleReschedule = async () => {
|
||||||
setActionSuccess(null);
|
setActionSuccess(null);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
|
|
||||||
if (!newDate() || !newTime()) {
|
const slot = selectedSlot();
|
||||||
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím nové datum a čas' : 'Please select a new date and time');
|
if (!slot) {
|
||||||
|
setActionError(i18n.locale() === 'cs' ? 'Vyberte prosím volný termín' : 'Please pick an available slot');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,32 +84,36 @@ export function BookingManageRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate new start and end times
|
setRescheduleBusy(true);
|
||||||
const newStart = new Date(`${newDate()}T${newTime()}`);
|
|
||||||
const newEnd = new Date(newStart.getTime() + 60 * 60 * 1000); // Default 1 hour duration
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/reschedule`, {
|
const response = await (apiClient as any).POST(`/v1/public/bookings/${ref}/reschedule`, {
|
||||||
params: { query: { token: t } },
|
params: { query: { token: t } },
|
||||||
body: {
|
body: {
|
||||||
newStartsAt: newStart.toISOString(),
|
newStartsAt: slot.startsAt,
|
||||||
newEndsAt: newEnd.toISOString(),
|
newEndsAt: slot.endsAt,
|
||||||
reason: message()
|
reason: message()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) {
|
if (response.error) {
|
||||||
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
|
setActionError(
|
||||||
|
(response as any).response?.status === 409
|
||||||
|
? (i18n.locale() === 'cs' ? 'Tento termín byl právě obsazen, vyberte jiný' : 'That slot was just taken — please pick another')
|
||||||
|
: (i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed')
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActionSuccess(i18n.locale() === 'cs' ? 'Rezervace byla přeplánována' : 'Booking rescheduled successfully');
|
setActionSuccess(i18n.locale() === 'cs' ? 'Rezervace byla přeplánována' : 'Booking rescheduled successfully');
|
||||||
setIsRescheduling(false);
|
setIsRescheduling(false);
|
||||||
setNewDate("");
|
setNewDate("");
|
||||||
setNewTime("");
|
setSelectedSlot(null);
|
||||||
setMessage("");
|
setMessage("");
|
||||||
|
void refetchBooking();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
|
setActionError(i18n.locale() === 'cs' ? 'Změna termínu selhala' : 'Reschedule failed');
|
||||||
|
} finally {
|
||||||
|
setRescheduleBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -313,33 +336,44 @@ export function BookingManageRoute() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div class="flex flex-col sm:flex-row gap-3 pt-4">
|
<Show when={b().status === 'confirmed' || b().status === 'pending' || b().status === 'waitlisted'}>
|
||||||
<Button
|
<div class="flex flex-col sm:flex-row gap-3 pt-4">
|
||||||
variant="secondary"
|
<Show when={!b().classSessionId && b().status !== 'waitlisted'}>
|
||||||
class="flex-1"
|
<Button
|
||||||
onClick={() => setIsRescheduling(true)}
|
variant="secondary"
|
||||||
>
|
class="flex-1"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
onClick={() => setIsRescheduling(true)}
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
>
|
||||||
<line x1="16" y1="2" x2="16" y2="6"/>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
||||||
<line x1="8" y1="2" x2="8" y2="6"/>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"/>
|
||||||
<line x1="3" y1="10" x2="21" y2="10"/>
|
<line x1="16" y1="2" x2="16" y2="6"/>
|
||||||
</svg>
|
<line x1="8" y1="2" x2="8" y2="6"/>
|
||||||
{i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
|
<line x1="3" y1="10" x2="21" y2="10"/>
|
||||||
</Button>
|
</svg>
|
||||||
<Button
|
{i18n.locale() === 'cs' ? 'Změnit termín' : 'Reschedule'}
|
||||||
variant="secondary"
|
</Button>
|
||||||
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
|
</Show>
|
||||||
onClick={() => setIsCancelling(true)}
|
<Button
|
||||||
>
|
variant="secondary"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
class="flex-1 text-[hsl(var(--error))] hover:bg-[hsl(var(--error-soft))]"
|
||||||
<circle cx="12" cy="12" r="10"/>
|
onClick={() => setIsCancelling(true)}
|
||||||
<line x1="15" y1="9" x2="9" y2="15"/>
|
>
|
||||||
<line x1="9" y1="9" x2="15" y2="15"/>
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="mr-2">
|
||||||
</svg>
|
<circle cx="12" cy="12" r="10"/>
|
||||||
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
|
<line x1="15" y1="9" x2="9" y2="15"/>
|
||||||
</Button>
|
<line x1="9" y1="9" x2="15" y2="15"/>
|
||||||
</div>
|
</svg>
|
||||||
|
{i18n.locale() === 'cs' ? 'Zrušit rezervaci' : 'Cancel Booking'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Show when={b().classSessionId}>
|
||||||
|
<p class="text-sm text-ink-muted pt-1">
|
||||||
|
{i18n.locale() === 'cs'
|
||||||
|
? 'Termín lekce je pevně daný. Pro jiný termín rezervaci zrušte a objednejte jinou lekci.'
|
||||||
|
: 'Class times are fixed. To switch sessions, cancel this booking and book another class.'}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -393,28 +427,54 @@ export function BookingManageRoute() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4 mt-4">
|
<div class="space-y-4 mt-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div>
|
||||||
<div>
|
<label class="block text-sm font-medium text-ink-muted mb-1.5">
|
||||||
<label class="block text-sm font-medium text-ink-muted mb-1.5">
|
{i18n.locale() === 'cs' ? 'Nové datum' : 'New date'}
|
||||||
{i18n.locale() === 'cs' ? 'Nové datum' : 'New Date'}
|
</label>
|
||||||
</label>
|
<Input
|
||||||
<Input
|
type="date"
|
||||||
type="date"
|
value={newDate()}
|
||||||
value={newDate()}
|
min={new Date().toISOString().slice(0, 10)}
|
||||||
onInput={(e) => setNewDate(e.currentTarget.value)}
|
onInput={(e) => { setNewDate(e.currentTarget.value); setSelectedSlot(null); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-ink-muted mb-1.5">
|
|
||||||
{i18n.locale() === 'cs' ? 'Nový čas' : 'New Time'}
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="time"
|
|
||||||
value={newTime()}
|
|
||||||
onInput={(e) => setNewTime(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Show when={newDate()}>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-ink-muted mb-1.5">
|
||||||
|
{i18n.locale() === 'cs' ? 'Volné termíny' : 'Available times'}
|
||||||
|
</label>
|
||||||
|
<Show when={!slots.loading} fallback={
|
||||||
|
<div class="p-4 text-center text-sm text-ink-muted animate-pulse">…</div>
|
||||||
|
}>
|
||||||
|
<Show when={(slots() ?? []).length > 0} fallback={
|
||||||
|
<p class="p-4 text-sm text-ink-muted bg-canvas-subtle rounded-xl text-center">
|
||||||
|
{i18n.locale() === 'cs' ? 'V tento den nejsou volné termíny.' : 'No available times on this day.'}
|
||||||
|
</p>
|
||||||
|
}>
|
||||||
|
<div class="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||||
|
<For each={slots() ?? []}>
|
||||||
|
{(slot: any) => {
|
||||||
|
const isSelected = () => selectedSlot()?.startsAt === slot.startsAt;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedSlot({ startsAt: slot.startsAt, endsAt: slot.endsAt })}
|
||||||
|
class={`px-2 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||||
|
isSelected()
|
||||||
|
? 'bg-accent text-canvas border-accent'
|
||||||
|
: 'bg-canvas-subtle text-ink border-border hover:border-accent hover:text-accent'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{formatTime(slot.startsAt)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-ink-muted mb-1.5">
|
<label class="block text-sm font-medium text-ink-muted mb-1.5">
|
||||||
{i18n.locale() === 'cs' ? 'Zpráva pro podnik (volitelné)' : 'Message to business (optional)'}
|
{i18n.locale() === 'cs' ? 'Zpráva pro podnik (volitelné)' : 'Message to business (optional)'}
|
||||||
@@ -430,8 +490,10 @@ export function BookingManageRoute() {
|
|||||||
<Button variant="secondary" class="flex-1" onClick={() => setIsRescheduling(false)}>
|
<Button variant="secondary" class="flex-1" onClick={() => setIsRescheduling(false)}>
|
||||||
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
|
{i18n.locale() === 'cs' ? 'Zrušit' : 'Cancel'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button class="flex-1" onClick={handleReschedule}>
|
<Button class="flex-1" onClick={handleReschedule} disabled={!selectedSlot() || rescheduleBusy()}>
|
||||||
{i18n.locale() === 'cs' ? 'Potvrdit změnu' : 'Confirm Change'}
|
{rescheduleBusy()
|
||||||
|
? '…'
|
||||||
|
: (i18n.locale() === 'cs' ? 'Potvrdit změnu' : 'Confirm Change')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1120,6 +1120,32 @@ export interface components {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
};
|
};
|
||||||
|
CustomerBookingView: {
|
||||||
|
businessName?: string;
|
||||||
|
/**
|
||||||
|
* Format: uuid
|
||||||
|
* @description Present for session-bound bookings, which cannot be rescheduled to arbitrary times
|
||||||
|
*/
|
||||||
|
classSessionId?: string;
|
||||||
|
customerEmail?: string;
|
||||||
|
customerName?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
endsAt?: string;
|
||||||
|
location?: string;
|
||||||
|
notes?: string;
|
||||||
|
reference?: string;
|
||||||
|
service?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
serviceId?: string;
|
||||||
|
/** Format: uuid */
|
||||||
|
staffId?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
startsAt?: string;
|
||||||
|
status?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
|
/** @description 1-based queue spot for waitlisted class bookings; absent otherwise */
|
||||||
|
waitlistPosition?: number;
|
||||||
|
};
|
||||||
DashboardBookingRequest: {
|
DashboardBookingRequest: {
|
||||||
/** Format: email */
|
/** Format: email */
|
||||||
customerEmail: string;
|
customerEmail: string;
|
||||||
@@ -3186,7 +3212,9 @@ export interface operations {
|
|||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content?: never;
|
content: {
|
||||||
|
"application/json": components["schemas"]["CustomerBookingView"];
|
||||||
|
};
|
||||||
};
|
};
|
||||||
/** @description Invalid manage token */
|
/** @description Invalid manage token */
|
||||||
403: {
|
403: {
|
||||||
@@ -3239,7 +3267,10 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
rescheduleBooking: {
|
rescheduleBooking: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query: {
|
||||||
|
/** @description Manage token issued at booking time */
|
||||||
|
token: string;
|
||||||
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
reference: string;
|
reference: string;
|
||||||
@@ -3250,10 +3281,10 @@ export interface operations {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
endsAt?: string;
|
newEndsAt: string;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
startsAt: string;
|
newStartsAt: string;
|
||||||
token: string;
|
reason?: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -3265,6 +3296,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Invalid new window, non-reschedulable status, or class booking */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Invalid manage token */
|
/** @description Invalid manage token */
|
||||||
403: {
|
403: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -3272,7 +3310,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
/** @description New slot conflicts */
|
/** @description New slot conflicts or booking already cancelled */
|
||||||
409: {
|
409: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
|
|||||||
+9
-1
@@ -138,6 +138,14 @@
|
|||||||
- demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing
|
- demo mode ships a seeded session with a populated roster; mutation attempts surface the demo notice instead of failing
|
||||||
- verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console
|
- verified live in demo mode: seeded a full session via the public booking API, roster listed 4 confirmed + 1 waitlisted, promote rejected with 409, `allowOverCapacity` promoted and drained the queue; browser-checked CS/EN rendering with a clean console
|
||||||
- remaining: live staging validation on Neon + pgx repository path
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
- `[done/partial]` Milestone 21: Customer self-service rebooking
|
||||||
|
- `CustomerBookingView` now carries `tenantSlug`, `serviceId`, `staffId`, `classSessionId` — everything the manage page needs to query real availability; class bookings resolve the session title as their service label instead of rendering blank
|
||||||
|
- `RescheduleBooking` hardened: rejects `waitlisted`/`completed` bookings and any class booking (session-bound times cannot move to arbitrary windows; switching sessions is cancel-and-rebook); the previously dead `ClassSessionID == nil` branch collapsed into a straight appointment validation
|
||||||
|
- manage page reschedule dialog replaced free-form date+time inputs with a real slot picker: pick a date → `GET /v1/public/tenants/{slug}/availability` (filtered by the booking's service/staff) → time buttons; the slot carries its own duration so the hardcoded 60-minute assumption is gone; 409 surfaces "slot just taken" instead of a generic failure
|
||||||
|
- action buttons are now status-gated: reschedule shows only for confirmed/pending appointments, cancel for any live booking, and class bookings get an explanatory note instead of a broken button
|
||||||
|
- `CustomerBookingView` schema added to OpenAPI and referenced from `GET /v1/public/bookings/{reference}`; the reschedule contract documents the class-booking 400 and slot-conflict 409
|
||||||
|
- verified live: booked an appointment via public API, rescheduled through the real manage page in a browser (slot grid → 200 → card shows new time); class booking reschedule returns 400
|
||||||
|
- remaining: live staging validation on Neon + pgx repository path
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
Bookra is a Czech-first booking SaaS for small local service businesses. It targets salons, massage therapists, small clinics, repair shops, and small studios/clubs that need a simple public booking flow, internal schedule management, reminders, and basic business reporting without becoming a full ERP.
|
||||||
@@ -388,7 +396,7 @@ Key entities:
|
|||||||
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
|
Shortlist of worthwhile directions once v1 stabilizes — kept deliberately small, in line with the "no feature sprawl" principle:
|
||||||
|
|
||||||
- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability.
|
- **Activity/audit log**: a per-tenant feed of who changed what (booking created/cancelled/promoted, settings edits). Natural fit for the notification dropdown and useful for multi-staff accountability.
|
||||||
- **Customer self-service rebooking**: let customers move to a different free slot from the manage page instead of cancel-and-rebook; the reschedule path already validates targets, so this is mostly UI plus a picker.
|
- **Session-to-session move for classes**: M21 covered appointment rebooking; class customers currently must cancel and book another session. A "move to another session of the same template" flow (target session picker + capacity/waitlist handling) would close the gap.
|
||||||
- **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed.
|
- **Calendar integrations**: ICS feed per tenant/staff and optional Google Calendar sync; high demand for salons, moderate complexity. Start with a read-only `.ics` subscription URL — zero third-party auth needed.
|
||||||
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
- **Recurring class schedules**: templates currently schedule sessions one at a time; a weekly recurrence rule with per-session overrides would cut setup time for studios.
|
||||||
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
- **Booking notes intake**: structured intake fields per service (e.g. "first visit?", "injuries?") instead of the single free-text notes field — still schema-light.
|
||||||
|
|||||||
Reference in new issue
Block a user