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.

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.

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, '').

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.

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 (createClientReservationisLocationOpenForOnlineBooking(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.

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.

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).