Skip to main content

Ustawienia rezerwacji (miasto i lokalizacja)

Przewodnik po konfiguracji wynajmu kortów: zasad rezerwacji, czasu trwania, płatności oraz rezerwacji online. Ustawienia mogą być definiowane na trzech poziomach: globalnym, miasta oraz konkretnej lokalizacji (ulicy).

👤 Instrukcja dla pracownika (Administrator)​

Ścieżka: Dashboard ➔ Ustawienia rezerwacji

Punkt odniesienia (konfiguracja domyślna)​

W bazie istnieje jeden zestaw ustawień bez przypisanego miasta i lokalizacji — to punkt odniesienia (restore point), który obowiązuje wszędzie tam, gdzie nie utworzono wyjątku. Dopóki recepcja/administrator nie utworzą osobnej konfiguracji dla miasta lub lokalizacji, brana jest pod uwagę właśnie ta konfiguracja domyślna.

Wybór zakresu (miasto / ulica)​

Na górnym pasku aplikacji (po prawej stronie, tak jak na innych ekranach, np. w grafiku) znajdują się te same dwa standardowe selektory:

  • Miasto – domyślnie wybrane jest miasto domyślne aplikacji. Na górze listy jest też opcja „Wszystkie miasta", która pozwala edytować sam punkt odniesienia.
  • Lokalizacja (ulica) – pojawia się po wybraniu miasta, które ma więcej niż jedną lokalizację; domyślnie wskazuje ulicę domyślną. Zawiera opcję „Wszystkie lokalizacje" (= poziom miasta) oraz poszczególne ulice.

Po wejściu na stronę masz więc od razu wybrane realne miasto i lokalizację. Jeśli nie mają one jeszcze własnej konfiguracji, formularz pokazuje wartości dziedziczone z punktu odniesienia, a zapisanie tworzy nową konfigurację dla wybranego miasta i ulicy.

System rozwiązuje ustawienia „od najbardziej szczegółowego": ulica → miasto → domyślne.

Tworzenie wyjątku i powrót do wartości domyślnych​

Nad formularzem widnieje baner informujący o aktualnym poziomie:

Kolor baneraZnaczenieDziałanie
🔵 NiebieskiEdytujesz konfigurację domyślną (wszystkie miasta i lokalizacje).Zapisanie aktualizuje ustawienia domyślne.
🟡 ŻółtyWybrane miasto/lokalizacja korzysta z ustawień nadrzędnych.Edytuj i zapisz, aby utworzyć osobną konfigurację.
🟢 ZielonyWybrane miasto/lokalizacja ma własną konfigurację.Przywróć ustawienia poziomu nadrzędnego.
  • Aby zmienić ustawienia domyślne: zostaw selektor na „Domyślna (wszystkie miasta)", edytuj pola i zapisz.
  • Aby utworzyć wyjątek dla miasta lub lokalizacji: wybierz je w selektorze, zmień wartości i Zapisz — system automatycznie utworzy osobną konfigurację dla tego zakresu, nie naruszając konfiguracji domyślnej.
  • Przywróć – usuwa konfigurację wybranego zakresu; od tej chwili ponownie obowiązują ustawienia poziomu nadrzędnego (miasta lub domyślne).

Co można skonfigurować​

Formularz obejmuje m.in.: cenę i cennik zaawansowany wynajmu, minimalny/maksymalny czas trwania i krok, wyprzedzenie rezerwacji, blokadę rezerwacji przed startem, zasady anulowania przez pracownika, rezerwację online (w tym cykliczną), sposób kontaktu w publicznym formularzu rezerwacji, wymóg numeru telefonu oraz limity czasu na płatność online i przez pracownika.

Stałe rezerwacje mają osobną sekcję: włączenie w lokalizacji, zgodę na zakładanie przez klienta, okno sezonu, minimalną liczbę pełnych miesięcy i własny cennik (podstawowy albo z regułami). Szczegóły: Stała rezerwacja kortu.

Sposób kontaktu w formularzu publicznym (public_contact_method) decyduje, czym klient potwierdza rezerwację na publicznej stronie: telefonem (kod SMS), e-mailem, albo dowolnym z nich do wyboru klienta (domyślnie). Szczegóły: Publiczny kalendarz rezerwacji.

Gdy wyłączysz rezerwacje online dla miasta lub konkretnej ulicy, klient po wejściu w Rezerwacje zobaczy komunikat „Rezerwacje w tej lokalizacji nie są jeszcze dostępne”. Zakładki Grafik i Dostępność oraz przycisk tworzenia rezerwacji znikają — zostaje wyłącznie lista Moje rezerwacje, żeby klient nadal widział i mógł obsłużyć terminy zapisane wcześniej w tej lokalizacji. Pracownicy widzą wszystkie zakładki niezależnie od tego ustawienia.

Kolejność wyboru: najpierw godzina, potem długość​

Klient w panelu (Rezerwacje → Utwórz rezerwację) wybiera po kolei: lokalizację i kort (jeśli jest z czego wybierać), potem dzień, potem godzinę gry, a długość rezerwacji dopiero na końcu — pod listą godzin, w sekcji „Wybierz długość gry". Wcześniej trzeba było zadeklarować czas trwania, zanim w ogóle zobaczyło się wolne godziny: kto chciał zagrać o 18:00, musiał zgadywać, przy której długości ta godzina się pojawi.

Co z tego wynika w praktyce:

  • Lista godzin pokazuje wszystko, co klub sprzedaje — każdą godzinę, od której da się zarezerwować co najmniej minimalny czas rezerwacji.
  • Długości są liczone dla wybranej godziny: o 22:45 przy zamknięciu o 23:00 zostaje wyłącznie 15 min, a o 13:00 — pełna lista aż do maksimum. Nie trzeba już szukać po omacku, która długość „odblokuje" daną godzinę.
  • Zmiana długości nie chowa pozostałych godzin. Przestawienie na 1,5 godz. przelicza tylko wybraną godzinę (koniec, cenę i listę kortów); reszta listy zostaje na miejscu, więc można wrócić do godziny, która tej długości nie przyjmuje.
  • Ceny kortów są przeliczane na wybraną długość, więc kwota pod nazwą kortu i kwota w podsumowaniu zawsze dotyczą tego, co klient właśnie rezerwuje.
  • Gdy nie ma czego wybierać na pierwszym kroku — jedna lokalizacja i jeden kort — dialog otwiera się od razu na kalendarzu.
  • Przy braku limitu czasu długości nie są wypisywane jako kafelki (byłoby ich kilkadziesiąt), tylko jako pole z „−" i „+" — zakres pod polem pochodzi z tej konkretnej godziny, a wartość, której godzina nie przyjmuje, jest dociągana do najbliższej dozwolonej w stronę, w którą klient szedł: z 2 godz. „+" przeskakuje pominięte 2,5 godz. i zatrzymuje się na 3 godz.

Reguła „bez niesprzedawalnych przerw" nie znika: godzina, którą blokowałaby przy danej długości, po prostu nie ma tej długości na liście. Osobna podpowiedź „więcej godzin przy innej długości gry" przestała być potrzebna.

Kto i kiedy może anulować rezerwację kortu​

RolaRezerwacja przyszłaRezerwacja trwająca lub zakończona
KlientTak, o ile mieści się w limicie godzin przed startem (ustawienie typu aktywności).Nie
Pracownik (recepcja)Tak, jeśli włączone „Anulowanie przez pracownika” i limit godzin jest zachowany.Tak
AdministratorTak, bez limitów godzinowych.Tak

Pracownik i administrator mogą więc wykreślić z grafiku termin, który już się rozpoczął albo minął — przydaje się to przy porządkowaniu historii (np. klient się nie stawił, a rezerwacja została błędnie wprowadzona). Limity „na ile godzin przed startem wolno anulować” dotyczą wyłącznie terminów przyszłych; dla terminu, który już wystartował, nie mają zastosowania. Wyłączone ustawienie Anulowanie przez pracownika nadal blokuje recepcję również dla terminów przeszłych — to przełącznik uprawnienia, nie okna czasowego.

Klient przy rezerwacji, która już się rozpoczęła, nie widzi akcji anulowania; jeśli trafi na ekran anulowania bezpośrednio z linku, zobaczy komunikat „Nie można anulować zakończonych gier”.

Anulowanie terminu przeszłego uruchamia tę samą procedurę co zwykłe anulowanie: zwrot środków do Wirtualnego Portfela (jeśli pracownik zaznaczy tę opcję) oraz powiadomienie klienta. Szczegóły rozliczeń: Odwoływanie zajęć.

Czas trwania bez limitu​

Nad polami Minimalny czas / Maksymalny czas / Krok czasowy znajduje się przełącznik Bez limitu czasu rezerwacji. Włączony gasi pole maksymalnego czasu i znosi górne ograniczenie: rezerwacja może trwać aż do najbliższego zajętego terminu na tym korcie albo do godziny zamknięcia — tego, co wypadnie wcześniej.

Co to zmienia w miejscach, gdzie powstaje rezerwacja:

  • Dialog „Zarezerwuj kort" (panel klienta i pracownika) — pod wybraną godziną, zamiast listy gotowych długości, pojawia się pole na liczbę godzin z przyciskami „−" i „+" (DurationHoursInput). Zakres bierze się z tej godziny: od najkrótszej rezerwacji do końca wolnego okna kortu (najbliższy zajęty termin albo godzina zamknięcia), krokiem czasowym klubu.
  • Publiczny kalendarz rezerwacji — nie ma już filtru „Czas gry"; to samo pole z „−" i „+" pojawia się w dialogu długości pod klikniętą godziną, a wybór zatwierdza przycisk Dalej.
  • Grafik pracownika (tworzenie i edycja rezerwacji) — lista czasów trwania sięga do najbliższej zajętej rezerwacji na tym korcie, a godzina końca nadal nie wychodzi poza godziny otwarcia. Działa to także wtedy, gdy limit jest ustawiony: limit i najbliższy zajęty termin obowiązują jednocześnie, wiąże ten bardziej restrykcyjny. Godziny startu pozostają pełną listą — po zajętym terminie można zacząć rezerwację jak dotąd.

Wyszukiwarka terminów nie zaproponuje długości, która nie mieści się między istniejącymi rezerwacjami — przy dłuższym czasie gry lista wolnych godzin po prostu maleje.

Krok czasowy​

Krok czasowy (duration_step_minutes) wyznacza siatkę godzin, na której można stawiać rezerwacje — zarówno dopuszczalne godziny startu i końca, jak i skok długości rezerwacji. Przy kroku 30 min dostępne są godziny 11:00, 11:30, 12:00; przy kroku 15 min dochodzą 11:15 i 11:45; przy kroku 60 min zostają wyłącznie pełne godziny.

Siatka liczona jest od godziny otwarcia kortu, nie od północy. Kort otwarty od 07:00 z krokiem 45 min daje 07:00, 07:45, 08:30, 09:15 itd. Ma to znaczenie tylko dla kroków niedzielących godziny — przy 15, 20, 30 czy 60 min obie kotwice dają ten sam wynik. Kotwicą jest efektywna godzina otwarcia dla danej daty. Kort może mieć kilka wpisów godzin otwarcia na różne zakresy dat (np. inny grafik letni), a każdy z nich daje własną siatkę.

Ustawienie obowiązuje wszędzie tam, gdzie powstaje lub zmienia się rezerwacja kortu:

  • grafik pracownika — tworzenie i edycja rezerwacji oraz podział rezerwacji,
  • panel klienta (/dashboard/reservations) — wyszukiwanie terminów, siatka widoku dostępności, przenoszenie rezerwacji,
  • publiczny kalendarz rezerwacji.

Krok nie dotyczy zajęć (treningów, zajęć grupowych) — formularze zajęć w grafiku pracują na stałej siatce 30-minutowej niezależnie od tego ustawienia.

Przy podziale rezerwacji siatka kotwiczona jest na godzinie startu dzielonej rezerwacji, a nie na godzinie otwarcia — segmenty leżą na tej samej siatce co suwak podziału.

Jeśli krok nie dzieli czasu otwarcia równo, ostatnie minuty przed zamknięciem mogą zostać niewykorzystane. Kort 07:00–23:00 z krokiem 45 min ma ostatni termin 22:00–22:45; kwadransa do zamknięcia nie da się zarezerwować, bo nie mieści się w nim pełny krok. Formularz sam dociąga godziny do najbliższego punktu siatki, więc nie zaproponuje terminu wychodzącego poza godziny otwarcia.

Zmiana kroku nie rusza istniejących rezerwacji. Termin założony przy poprzednim kroku pozostaje ważny i można nadal edytować jego cenę, notatki czy uczestnika; walidacja siatki uruchamia się dopiero przy faktycznej zmianie godzin.

🛠️ Dokumentacja techniczna​

Model danych​

Ustawienia przechowywane są w dwóch tabelach, każda z kolumnami city oraz street (obie NULL = poziom globalny):

  • activity_types (rekord typu booking) – cena/cennik i parametry aktywności wynajmu.
  • booking_settings – pozostałe parametry rezerwacji.

Poziomy zakresu:

  • Globalny: city IS NULL (oraz street IS NULL).
  • Miasto: city = ? AND street IS NULL.
  • Lokalizacja: city = ? AND street = ?.

Migracja: migrations/0166_add_street_to_reservation_settings.sql dodaje kolumnę street do obu tabel oraz unikalne indeksy uwzględniające IFNULL(street, '').

Kolumny standing_* w booking_settings (migracja 0279_add_standing_reservations.sql) przechowują ustawienia stałych rezerwacji, w tym cennik jako JSON PricingRule[] w standing_pricing_rules. Są kopiowane razem z resztą przy tworzeniu wyjątku (BOOKING_SETTINGS_COLUMNS), a updateBookingSettings odrzuca dzień sezonu spoza formatu MM-DD i minimum poniżej 1 miesiąca (INVALID_STANDING_SETTINGS). Opis: Stała rezerwacja kortu.

Rozwiązywanie ustawień (fallback)​

getBookingSettingsWithInfo(city, street) oraz getActivityTypesWithInfo(city, street) zwracają najbardziej szczegółowy istniejący rekord wraz z polem level ('global' | 'city' | 'street'). Kolejność: ulica → miasto → globalne. getBookingActivityTypes(city, street) stosuje tę samą kolejność przy wyborze typu wynajmu.

Funkcje są wstecznie kompatybilne — wywołanie bez street zachowuje dotychczasowe zachowanie na poziomie miasta. Parametr street jest przekazywany w przepływach grafiku, rezerwacji, historii oraz w logice anulowania/dzielenia rezerwacji (lib/actions/booking.ts), dzięki czemu konfiguracja lokalizacji faktycznie obowiązuje.

Cennik wynajmu — jedno źródło kaskady​

Kaskadę ulica → miasto → globalny dla rekordu activity_types typu booking realizuje jedna funkcja: queryBookingActivityTypes(db, tenantId, city, street) w lib/utils/booking-activity-type.ts. Zwraca rekordy najbardziej szczegółowego istniejącego poziomu (pomijając zarchiwizowane) z rozpakowanym JSON-em pricing_rules; pickBookingActivityType() wybiera z nich rekord wynajmu.

Korzystają z niej wszystkie ścieżki wyceny kortu:

ŚcieżkaWejście
getBookingActivityTypes(city, street) (lib/actions/activity-types.ts)panel pracownika i klienta; wynik cache'owany per tenant + miasto + ulica
getBookingActivityTypeForLocation(city, street)pojedynczy rekord wynajmu dla lokalizacji
getAvailableCourtSlots(input) (lib/actions/booking.ts)ceny terminów — rozwiązywane per ulica kortu, nie raz dla całej listy
getBookingActivityType(city, street) (lib/actions/public-booking.ts)kwota naliczana przy rezerwacji publicznej, wg ulicy przydzielonego kortu

Dwie konsekwencje, o których trzeba pamiętać przy zmianach:

  • Wyszukiwanie po całym mieście zwraca korty z różnych ulic. Lista terminów wycenia każdy z nich cennikiem jego własnej lokalizacji, dlatego zapytanie o korty pobiera kolumnę street. Zapytanie bez tej kolumny (albo pojedynczy SELECT ... LIMIT 1) wycenia wszystkie miasta pierwszym lepszym rekordem z tabeli.

  • Ulicę w kreatorze klienta wybiera się dopiero w dialogu, po wyrenderowaniu strony. CourtBookingDialog dociąga więc dla wybranej lokalizacji zarówno cennik, jak i booking_settings (loadLocationConfig), i to one — a nie propsy z poziomu strony — zasilają minimalny czas rezerwacji użyty w wyszukiwaniu, okno wyprzedzenia, wycenę wybranej długości, komunikat o czasie na płatność oraz wywołanie createClientReservation. Propsy pozostają wyłącznie wartością wyjściową, zanim odpowiedź dotrze.

    Dialog otwiera się na ulicy wskazanej przez globalny selektor lokalizacji (initialStreet z ReservationsClient), a nie „bez ulicy". Bez tego pracownik z dostępem do kilku lokalizacji startował z pustym wyborem, więc czasy trwania, cennik i okno wyprzedzenia pochodziły z poziomu miasta lub z konfiguracji domyślnej — nie z lokalizacji widocznej w nagłówku strony. Wskazanie ulicy tylko zasiewa stan: tam, gdzie oferowana jest więcej niż jedna lokalizacja, można ją nadal zmienić w dialogu.

    Pobrana konfiguracja jest oznaczona ulicą, dla której powstała, i używana tylko dopóki zgadza się z aktualnym wyborem — wolniejsza odpowiedź dla poprzedniej ulicy nie może przeżyć zmiany selektora. Wyszukiwanie terminów dociąga konfigurację ponownie, bo to ostatni moment przed ekranem potwierdzenia. Zmiana lokalizacji uruchamia wyszukiwanie od nowa, więc długości pochodzą już z jej ustawień.

Reguły anulowania rezerwacji​

Jedynym źródłem prawdy jest canCancelReservation(bookingId) w lib/actions/booking.ts; cancelReservation wywołuje ją przed jakąkolwiek modyfikacją, więc żadna ścieżka UI nie omija reguł. Kolejność sprawdzeń:

  1. ALREADY_CANCELLED / RESERVATION_EXPIRED – rezerwacja anulowana lub po wygaśnięciu blokady płatności.

  2. GAME_IN_PAST – zwracane tylko dla ról spoza ADMIN/BACKOFFICE, gdy game_start_time <= now. Warunek obejmuje także rezerwacje trwające, nie tylko zakończone.

  3. Limity godzinowe (activity_cancellation_hours_before, booking_settings.employee_cancellation_hours_limit) – liczone od startu terminu, więc stosowane wyłącznie gdy termin jeszcze nie wystartował (hasGameStarted === false). ADMIN pomija limit aktywności.

    Oba limity — a także indywidualne nadpisanie klienta (client_settings.cancellation_hours_before, brane pod uwagę w checkCancellationEligibility) — ustawia się jako parę godziny + minuty i mogą być ułamkowe: 23 godz. 45 min zapisuje się jako 23.75. Porównania działają na godzinach zmiennoprzecinkowych, więc nie wymagały zmian; konwersją i prezentacją zajmuje się lib/utils/cancellation-lead-time.ts, a wspólnym polem formularza components/forms/cancellation-lead-time-input.tsx. Limit pracownika renderuje to pole z allowEmpty, bo dwa puste pola oznaczają tam brak limitu (NULL), a nie zero.

  4. EMPLOYEE_CANCELLATION_DISABLED – allow_employee_cancellation = 0 blokuje BACKOFFICE niezależnie od tego, czy termin się rozpoczął.

Kopie tej logiki po stronie klienta (canCancelReservationClient w bookings-table-columns.tsx oraz ReservationsOverviewMobileList.tsx) muszą być zmieniane razem z wersją serwerową — służą wyłącznie do wygaszania akcji i podpowiedzi w tooltipie.

Widoczność akcji w listach: getClientBookingsColumns(onMoveReservation, canCancelStartedReservations) i MobileBookingsList przyjmują flagę canCancelStartedReservations, którą ReservationsClient ustawia na isAdminOrBackoffice. Akcja Przenieś rezerwację pozostaje ograniczona do terminów przyszłych (moveReservation odrzuca terminy rozpoczęte). W grafiku (EditReservationForm) przycisk anulowania sterowany jest wyłącznie wynikiem serwerowego canCancel; isPastGame nadal blokuje podział rezerwacji.

Rezerwacja w innym mieście niż preferowane​

Klient ma selektor miasta w nagłówku wyłącznie na /dashboard/reservations — dzięki temu może zarezerwować kort, gdy akurat jest poza swoim miastem. Wybór jest jednorazowy i nie „przykleja się" do konta: po przejściu na inną zakładkę wszystko wraca do miasta z profilu. Na pozostałych stronach selektor miasta jest dla klienta ukryty, tak jak dotychczas. Pracownicy mają selektor wszędzie i ich wybór obowiązuje między zakładkami bez zmian.

Technicznie pilnują tego trzy rzeczy: clientCityPaths w GlobalCitySelectClient (gdzie selektor w ogóle się pokazuje), pominięcie ciasteczka lokalizacji dla pożyczonego miasta (isBorrowedCity → cookie zostaje na mieście z profilu, bo czytają je operacje spoza rezerwacji) oraz CityLink, który nie dokleja ?city= do linków nawigacji, gdy miasto w URL-u nie jest „lepkim" miastem użytkownika (StickyCityProvider w DashboardLayoutWrapper, hook useStickyCity). Sama rezerwacja i tak jest walidowana po korcie (createClientReservation → isLocationOpenForOnlineBooking(court.city, court.street)), więc pożyczone miasto nie omija reguł lokalizacji. Ręcznie wpisany ?city= na innej zakładce nadal działa jak wcześniej — ochrona dotyczy nawigacji, nie autoryzacji, bo strony klienta i tak pokazują wyłącznie jego własne dane.

Lista ulic jest związana z miastem, dla którego ją pobrano (loadedStreets.city). Dopóki nowe miasto się nie doczyta, selektor nie oferuje ulic poprzedniego, a ?street= nienależący do wybranego miasta jest usuwany z URL-a — inaczej po zmianie miasta zostawałaby ulica, której tam nie ma, i listy filtrowałyby się do zera.

Lokalizacje dostępne przy rezerwacji klienta​

Tworzenie rezerwacji przez klienta (CourtBookingDialog, widoki graph / availability, przycisk „Utwórz rezerwację”) respektuje lokalizację z górnego selektora. Przycisk i klikalne sloty są dostępne tylko gdy aktualnie wybrana lokalizacja jest otwarta na rezerwacje online. Lokalizacja jest zamknięta, gdy:

  1. w booking_settings dla tego zakresu allow_online_booking = 0, albo
  2. w app_settings klucz reservation_enabled ma wartość 0 (na poziomie ulicy lub miasta).

Przy wybranej konkretnej ulicy dialog zawęża listę do tej ulicy (o ile jest otwarta). Selektor ulicy klienta na /dashboard/reservations nie ma opcji „Wszystkie lokalizacje” — zawsze wymuszana jest konkretna ulica (domyślna lub pierwsza dostępna); ?street=ALL jest zastępowane. Logika otwartych lokalizacji: getBookableLocationOptions(city) / isLocationOpenForOnlineBooking(city, street) w lib/actions/booking-settings.ts. getBookableLocationOptions ładuje settings i reservation_enabled dwoma zapytaniami (nie N× na ulicę), a regułę street → city → global rozwiązuje w pamięci. Ta sama kontrola jest egzekwowana w createClientReservation oraz w modalu add-game (redirect z powrotem do listy). Publiczny kalendarz (getPublicLocations) stosuje analogiczne reguły.

Gdy wybrana lokalizacja jest zamknięta, klient nie dostaje pustego grafiku: page.tsx wylicza onlineBookingAvailable i wymusza effectiveView = 'table' (nawet dla ?view=graph), pomijając zapytanie o games. ReservationsClient ustawia wtedy bookingUnavailable — ukrywa przełącznik widoków (desktop i mobile, przez onViewChange={undefined} w MobileBookingsList), blokuje powrót do grafiku z URL-a i renderuje alert reservationsView.bookingUnavailable*. Lista Moje rezerwacje zostaje dostępna, bo klient może mieć w tej lokalizacji wcześniejsze terminy. Dla isAdminOrBackoffice flaga jest zawsze false, więc pracownik zachowuje pełny widok.

Klient wybiera lokalizację jednym przyciskiem w górnej belce (lista wszystkich obiektów z plakietką, gdzie rezerwacje online są otwarte), a na telefonie dodatkowo dostaje ten wybór przy wejściu w Rezerwacje — patrz Wybór obiektu w zakładce Rezerwacje.

Nawigacja kalendarza klienta (grafik / dostępność / ulica) idzie przez router.push + RSC w page.tsx — dane (games, settings, streets) zawsze z serwera. Segment nie ma loading.tsx, żeby zmiana searchParams nie zastępowała widoku skeletonem; feedback to LoadingOverlay na useTransition.

Operacje zapisu​

Formularz wyznacza zakres docelowy z wybranych city/street:

  • Zakres domyślny (brak miasta): updateActivityType i updateBookingSettings zapisują rekord city = NULL, street = NULL (punkt odniesienia pozostaje czysty).
  • Zakres miasta/lokalizacji, gdy nie ma jeszcze własnej konfiguracji: zapisanie najpierw tworzy rekordy dla tego zakresu (createCitySpecificActivityType(sourceId, city, street?) z poziomu nadrzędnego jako źródła), a następnie zapisuje w nich wartości. Dzięki temu konfiguracja domyślna nigdy nie jest modyfikowana przy edycji wyjątku. Decyzja „twórz vs. aktualizuj" dla rekordu aktywności opiera się na faktycznym city/street rekordu, a nie na poziomie scalonym — co jest odporne na niespójne dane.
  • deleteCitySpecificBookingSettings(city, street?) – usuwa rekord danego poziomu (przy poziomie miasta i braku globalnego konwertuje rekord na globalny). „Przywróć" archiwizuje rekord aktywności i usuwa rekord ustawień danego zakresu.
  • updateBookingSettings({ city, street, ... }) – upsert: aktualizuje istniejący rekord zakresu lub wstawia nowy.

Brak limitu czasu trwania​

booking_settings.max_duration_minutes NULL oznacza brak limitu — typ BookingSettings.max_duration_minutes to number | null, a UpdateBookingSettingsInput przepuszcza null przez nullableNum w mergeBookingSettings. Formularz ustawień trzyma to w wirtualnym polu max_duration_unlimited (przełącznik), które przy zapisie zamienia wartość pola minutowego na null.

Migracja migrations/0260_allow_unlimited_booking_duration.sql wypełnia 180 w wierszach, które miały NULL zanim brak wartości zaczął znaczyć „bez limitu" — inaczej stara, niedopisana konfiguracja stałaby się nagle nieograniczona.

Granica, gdy limitu nie ma, liczona jest po stronie klienta z godzin otwarcia (getEffectiveHoursRange):

  • CourtBookingDialog — nie liczy jej wcale: długości przychodzą z serwera per godzina startu (availableDurations), a max_duration_minutes: null oznacza w getAvailableCourtSlots sufit 24 * 60 przycięty wolnym oknem kortu.
  • PublicBookingApp — również nie liczy jej po stronie klienta: dzień czytany jest na min_duration_minutes z withDurations=1, a długości niesie każdy termin (PublicSlot.availableDurations); pierwsza i ostatnia z nich są granicami pola.

Wartość spoza listy dozwolonych długości dociąga snapToAllowedDuration z lib/utils/slot-durations.ts — kierunkowo, więc „+" i „−" przeskakują dziury po regule przerw zamiast się o nie zatrzymywać. Arytmetyka długości siedzi w lib/utils/duration-step.ts (clampDurationToStep, minutesToHours, formatDurationHours) i jest pokryta testami w __tests__/lib/duration-step.test.ts; wspólne pole na godziny to components/ui/duration-hours-input.tsx (DurationHoursInput).

Po stronie serwera publiczna rezerwacja przyjmuje MAX_UNLIMITED_DURATION_MINUTES = 24 * 60 jako sufit bezpieczeństwa (clampDuration i walidacja w reservePublicSlot); realnym ograniczeniem pozostają godziny otwarcia i zajęte terminy sprawdzane przez getAvailableCourtSlots oraz assertSlotFree.

Najbliższy zajęty termin wyznacza getNextOccupiedStartAfter(startTime, games, baseDate) z app/(dashboard)/dashboard/schedule/utils/time-slot-utils.ts. ReservationForm i EditReservationForm liczą z niego availableDurationMinutes i podają je pickerowi jako maxDurationMinutes — nie jako maxEndTime, bo z maxEndTime picker buduje również listę godzin startu i zajęty termin obcinałby starty po nim. EditReservationForm pomija przy tym edytowaną rezerwację.

Godziny startu, na których kort jest już zajęty, nie są oferowane. getOccupiedTimeRanges(games, baseDate) z tego samego pliku podaje zajęte przedziały dnia jako zegarowe, a ReservationTimePicker dostaje je propem busyRanges: godzina, w której nie zmieściłaby się nawet najkrótsza rezerwacja (krok czasowy), jest wygaszona wśród skrótów i w ogóle nie trafia na rozwijaną listę. Wcześniej dało się ją wybrać i dopiero wtedy pojawiał się czerwony komunikat „Ten przedział czasowy koliduje z istniejącą rezerwacją" — getNextOccupiedStartAfter pomija bowiem rezerwacje zaczynające się przed wybranym startem albo dokładnie na nim, więc dla startu w środku cudzej rezerwacji długości nie były niczym ograniczone. Sam komunikat zostaje jako ostatnia linia obrony (ktoś zarezerwuje termin w międzyczasie, zmiana kortu), ale w normalnym przepływie nie ma prawa się pokazać. Testy: __tests__/lib/occupied-time-ranges.test.ts i __tests__/components/reservation-time-picker.test.tsx.

Bez niesprzedawalnych przerw​

Po co to jest. Klient mógł zarezerwować tak, że między jego terminem a sąsiednim zostawało pół godziny — okno zbyt krótkie, żeby ktokolwiek je kupił. Reguła działa jak numeracja miejsc w kinie: nie pozwala zostawić pojedynczej „dziury", której i tak nikt nie weźmie.

Jak to wygląda dla klienta. Godzina nie znika z kalendarza — zmienia kolor na bursztynowy i mówi wprost, na ile można ją wziąć. Przykład: kort zajęty od 14:30, klient patrzy na 13:00 przy czasie gry 60 min. Kafelek 13:00 pokazuje „Rezerwuj na 0,5 lub 1,5 godz."; po kliknięciu dialog podaje krótko „Ta godzina jest dostępna w innej długości gry" i daje przyciski 0,5 godz. i 1,5 godz. Kliknięcie przestawia czas gry i od razu otwiera formularz rezerwacji na tę godzinę w wybranej długości — klient nie wraca do siatki. Nie dostaje wykładu o przerwach, tylko wybór. Bez tego widziałby martwe pole i nie domyśliłby się, że wystarczy inna długość.

Godziny, których żadna długość nie ratuje — bo to początek zostawiałby przerwę przed sobą (np. 13:30, gdy okno zaczyna się o 13:00) — nadal nie są oferowane. Właściwy start jest wtedy widoczny bezpośrednio nad nimi.

Ustawienia. W sekcji „Rezerwacje online", per zakres (domyślny / miasto / lokalizacja):

UstawienieKolumnaDomyślnie
„Nie zostawiaj niesprzedawalnych przerw" (przełącznik)booking_settings.prevent_orphan_gapswłączone
„Najkrótsza użyteczna przerwa" (minuty)booking_settings.min_usable_gap_minutes60

Migracja migrations/0265_add_orphan_gap_settings.sql. Próg jest niezależny od minimalnej rezerwacji: klub może wynajmować kort od 30 minut i mimo to nie zostawiać półgodzinnych dziur.

Zakres. Wyłącznie rezerwacja online — publiczny kalendarz /book/[tenant] oraz CourtBookingDialog na /dashboard/reservations. Pracownik (ADMIN / BACKOFFICE) nie jest ograniczony: w grafiku wstawia dowolny termin, a CourtBookingDialog dostaje wtedy enforceOrphanGapRule={false}.

Implementacja. Logika jest czysta i siedzi w lib/utils/orphan-gap.ts:

  • getFreeIntervals(open, close, busy) — wolne okna dnia jednego kortu w minutach od północy; busy to zajęte terminy plus wyłączenia (getClosureMinuteRanges z lib/utils/opening-hours.ts),
  • buildOrphanGapGuard(freeIntervals, rule) — zwraca leavesUnbookableGap(start, end) oraz allowedDurations(start), czyli długości, które ta godzina jeszcze przyjmie (pusta lista = start nie do uratowania).

Reguła ustępuje tam, gdzie nie da się jej spełnić: jeśli w danym wolnym oknie żadne dopuszczalne ułożenie (start na siatce kroku, długość z zakresu min…max) nie kończy się bez sierocej przerwy — np. okno 90 minut, gdy klub wynajmuje najwyżej na godzinę — okno nie jest pilnowane wcale, zamiast stać się niemożliwe do zarezerwowania.

Egzekwowanie i podpowiedzi:

WarstwaMiejsceEfekt
Generowanie terminówgetAvailableCourtSlots (preventOrphanGaps, minUsableGapMinutes, includeGapBlocked)termin wraca z gapBlocked: true i allowedDurations zamiast wypaść z listy
Generowanie terminów (kolejność „godzina → długość")getAvailableCourtSlots (includeDurationOptions)każdy start niesie availableDurations — długości, które ta godzina jeszcze przyjmie; start bez żadnej wypada z listy, a gapBlocked nie jest wtedy potrzebne
Publiczny kalendarzPublicGridView → PublicDurationDialoggodzinę wybiera się przed długością, więc lista długości pod klikniętą godziną zawiera tylko te bez sierocej przerwy; dalej ReservationDialog (desktop) lub PublicTimeDialog (mobile, bo kort nie jest jeszcze wybrany)
Panel klientaCourtBookingDialog → SlotPickerCalendar (durationPicker)reguła nie potrzebuje osobnej podpowiedzi: długość wybiera się po godzinie, a lista pod nią zawiera wyłącznie długości bez sierocej przerwy (patrz Kolejność wyboru)
Publiczny endpointcreatePublicReservation → checkOrphanGapleaves_unbookable_gap (409) wraz z allowedDurations
Grafik / dostępność klienta (klik w slot)ReservationForm → ReservationTimePicker (allowedDurationMinutes)lista czasu gry pokazuje wyłącznie długości bez sierocej przerwy, a wstępnie wybrana długość jest do nich dociągana
Rezerwacja klienta w panelucreateClientReservation → assertReservationLeavesNoOrphanGapLEAVES_UNBOOKABLE_GAP, w formularzu tłumaczone na ten sam komunikat co online

Długości poniżej godziny podawane są w minutach (30 min), a od godziny w górę w godzinach (1,5 godz. — przez useFormatter i common.hoursShort, więc przecinek dziesiętny idzie za locale). Tak samo w panelu i w publicznym kalendarzu.

Kotwica siatki to godzina otwarcia kortu — ta sama co przy kroku czasowym. Testy: __tests__/lib/orphan-gap.test.ts, __tests__/lib/slot-durations.test.ts (przeliczanie wybranej godziny na wybraną długość) i __tests__/components/court-booking-duration-after-hour.test.tsx (kolejność wyboru w dialogu).

ReservationForm liczy to samo po stronie klienta — buildOrphanGapGuard jest czystą funkcją, więc te same wolne okna wychodzą z existingGames, godzin otwarcia i wyłączeń kortu, bez dodatkowego zapytania. Pracownik (ADMIN / BACKOFFICE) nie dostaje żadnego ograniczenia.

Poza zakresem zostaje MoveReservationDialog — przeniesienie zwalnia stary termin, więc bilans wolnych okien liczy się inaczej niż przy nowej rezerwacji.

Egzekwowanie kroku czasowego​

Wspólne funkcje pomocnicze żyją w lib/utils/duration-step.ts:

  • normalizeDurationStepMinutes(step) – podstawia DEFAULT_SLOT_RESERVATION_DURATION_MINUTES (30) za null/undefined/wartości niedodatnie,
  • isTimeAlignedToStep(time, step, anchor?) – sprawdza HH:mm względem kotwicy; bez kotwicy liczy od północy,
  • roundUpToStep(time, step, anchor?) / roundDownToStep(...) – najbliższy punkt siatki nie wcześniejszy / nie późniejszy niż time,
  • areMinutesAlignedToStep, timeToMinutesOfDay, minutesOfDayToTime – warianty niskopoziomowe.

Kotwica musi odpowiadać temu, jak siatka jest generowana. getAvailableCourtSlots iteruje for (let startMinutes = courtOpenMinutes; …; startMinutes += durationStep), a ReservationTimePicker buduje opcje od minStartTime — obie od godziny otwarcia. Walidacja liczona od północy odrzucałaby wtedy komplet oferowanych godzin (przy kroku 45 i otwarciu 07:00 — wszystkie 21 opcji), dlatego kotwica jest przekazywana jawnie wszędzie, gdzie się waliduje.

Warstwa UI: ReservationForm i EditReservationForm czytają bookingSettings.duration_step_minutes i przekazują krok oraz kotwicę do pickera i do getReservationFormSchema(..., durationStepMinutes, stepAnchorTime). W ReservationForm kotwica zależy od wybranego kortu, który jest wyznaczany po utworzeniu formularza, więc schemat przyjmuje ją jako funkcję rozwiązywaną dopiero w momencie walidacji (StepAnchor); ref jest odświeżany przy każdym renderze, a control._options = props w react-hook-form gwarantuje, że resolver jest aktualny. clampAndSyncTimes dociąga wartość startową na siatkę przez roundUpToStep, bo domyślne godziny powstają zanim kort (a z nim kotwica) jest znany.

Godzina zamknięcia i limit max_duration_minutes są zwykłymi ograniczeniami, a nie punktami siatki, więc samo przyciśnięcie do nich dawało koniec poza siatką (krok 45, zamknięcie 23:00 → 23:00, gdy ostatni punkt to 22:45). clampAndSyncTimes ogranicza dlatego start do ostatniego punktu, przed którym mieści się pełny krok — tak jak startTimeOptions w pickerze — a koniec sprowadza w dół przez roundDownToStep, z dolną granicą start + krok. EditReservationForm robi to samo w efekcie pilnującym max_duration_minutes. Komunikat formValidation.timeMustBeMultipleOfSlotDuration przyjmuje {minutes}.

Warstwa serwera: assertReservationAlignedToStep w lib/actions/booking.ts pobiera ustawienia dla miasta/ulicy kortu, wyznacza kotwicę przez getEffectiveOpeningHours dla daty rezerwacji i sprawdza start oraz koniec w strefie Europe/Warsaw, zgłaszając INVALID_RESERVATION_STEP. Guard działa w createClientReservation, createAdminReservation oraz — tylko gdy godziny faktycznie się zmieniają — w updateReservation (a przez nią w moveReservation). Warunek jest istotny: bez niego edycja ceny czy notatki w rezerwacji założonej przy poprzednim kroku byłaby niemożliwa.

Podział rezerwacji (splitReservation) kotwiczy siatkę na godzinie startu dzielonej rezerwacji — tak samo jak availableSplitPoints w dialogu — i zgłasza INVALID_SEGMENT_TIME. Publiczny formularz sprawdza krok w reservePublicSlot dopiero po wyłonieniu kortu przez assertSlotFree, bo dopiero wtedy zna właściwe godziny otwarcia; obok istniejącego duration_out_of_range zwraca invalid_time.

Selektor zakresu​

Strona używa standardowego selektora miasta + ulicy w górnym pasku aplikacji (components/layout/global-city-select-client.tsx), tak samo jak grafik i inne widoki dla pracowników — /dashboard/reservations-settings jest w employeeStreetPaths (pokazuje selektor ulicy) i nie jest w limitedCityPaths (dzięki czemu dostępna jest opcja „Wszystkie miasta"). Mapowanie na zakres w page.tsx: ?city=ALL → city = null (punkt odniesienia / konfiguracja domyślna), ?street=ALL → street = null (poziom miasta). Bez parametrów strona domyślnie wybiera DEFAULT_CITY oraz ulicę domyślną (getDefaultStreetForCity).