Stała rezerwacja kortu
Stała rezerwacja to ten sam kort, ten sam dzień tygodnia i ta sama godzina przez wybrany okres sezonu — domyślnie od października do kwietnia. Cena pochodzi z osobnego cennika stałych rezerwacji, niezależnego od cennika kortu.
Dokument opisuje pierwszy etap funkcji (AP-1036): włączenie w lokalizacji, zakładanie, zasady okresu, święta i cennik. Płatność z góry za miesiąc, blokada samodzielnych zmian, zwolnienie terminu „na sprzedaż" i podział kosztu między graczy dochodzą w kolejnych etapach (AP-1037–AP-1039).
👤 Instrukcja
Włączenie w lokalizacji (administrator)
Ścieżka: Dashboard ➔ Ustawienia rezerwacji ➔ sekcja „Stałe rezerwacje"
Ustawienia działają per zakres — domyślny, miasto albo lokalizacja — tak jak reszta ustawień rezerwacji.
| Ustawienie | Domyślnie | Co robi |
|---|---|---|
| Włącz stałe rezerwacje | wyłączone | Bez tego przełącznika nikt — ani recepcja, ani klient — nie zobaczy opcji stałej rezerwacji. Nie da się go zapisać bez stawki większej od zera |
| Klient może założyć stałą rezerwację sam | włączone | Po wyłączeniu stałą rezerwację zakłada tylko recepcja |
| Początek sezonu / Koniec sezonu | 10-01/04-30 | Okno, w którym musi zmieścić się cały okres rezerwacji (format MM-DD) |
| Minimalna długość (pełne miesiące) | 1 | Ile pełnych miesięcy kalendarzowych musi objąć rezerwacja |
| Typ cennika, Stawka godzinowa, reguły | podstawowy | Cena za godzinę gry; w cenniku zaawansowanym — reguły godzinowe, weekendowe i per ulica |
Cennik zaawansowany używa tego samego edytora reguł co cennik kortu: godziny od–do, zakres dat, „tylko weekendy" i opcjonalnie ulica. Stawka bazowa obowiązuje w godzinach, których nie obejmuje żadna reguła.
Zakładanie przez recepcję
- W Grafiku kliknij wolne okienko na korcie — w dniu i o godzinie, od której ma zacząć się rezerwacja.
- Dodaj klienta.
- Włącz przełącznik Stała rezerwacja. Zwykłe ustawienia serii cyklicznej znikają, a Koniec okresu podpowiada się jako ostatni dzień sezonu. Możesz go skrócić.
- Sprawdź cenę. Gdy wszystkie terminy kosztują tyle samo, karta pokazuje Cenę za termin i sumę za okres. Gdy cennik ma różne stawki w różnych miesiącach (np. osobna reguła na wrzesień i na październik), karta rozpisuje terminy per miesiąc — „wrzesień 2026: 1 × 60.00 PLN”, „październik 2026: 5 × 70.00 PLN” — i podaje Razem. Każdy termin jest wyceniany według swojej daty, tak samo przy zapisie. Jeśli wpiszesz Cenę niestandardową, zastąpi ona cenę z cennika we wszystkich terminach.
- Jeśli w okresie wypadają święta, pojawi się lista Święta w okresie. Zaznacz terminy, które mają zostać zarezerwowane mimo święta — niezaznaczone zostaną pominięte. Dni, w których kort jest zamknięty, pomijane są zawsze.
- Zapisz. Terminy zajęte przez kogoś innego pokażą się jak w każdej serii — możesz utworzyć tylko wolne albo zrezygnować.
Zakładanie przez klienta
Gdy administrator zostawił włączone Klient może założyć stałą rezerwację sam, klient zakłada ją z zakładki Rezerwacje:
- Klika Utwórz rezerwację. Zamiast od razu kalendarza terminów pojawia się pytanie Jaką rezerwację chcesz utworzyć? z dwiema opcjami: Jednorazowa i Stała. Gdy stałe rezerwacje są w lokalizacji niedostępne (wyłączone, bez stawki albo tylko dla recepcji), pytania nie ma i przycisk działa jak dotąd.
- Jednorazowa otwiera zwykłe okno rezerwacji — bez zmian.
- Stała prosi o dzień pierwszego terminu. Kalendarz pozwala wybrać dzień od dziś (albo od początku najbliższego sezonu) do końca sezonu — niezależnie od tego, na ile dni naprzód można rezerwować jednorazowo.
- Po Kontynuuj otwiera się formularz z włączoną Stałą rezerwacją: klient wybiera kort i godzinę, sprawdza koniec okresu, cenę i święta, i zapisuje.
Można też, jak wcześniej, kliknąć wolny termin w widoku grafiku i włączyć Stała rezerwacja w formularzu. Klient widzi cenę z cennika i nie może jej zmienić. Obowiązują te same zasady co przy rezerwacji online — blokada przy zaległościach i czas na opłacenie, jeśli lokalizacja go wymaga.
Zasady okresu
Okres musi zmieścić się w sezonie. Przy sezonie 01.10–30.04 rezerwacja od 5 marca do 28 maja zostanie odrzucona, bo wychodzi poza 30 kwietnia.
Liczą się pełne miesiące kalendarzowe. Miesiąc jest pełny, gdy rezerwacja obejmuje każdy termin w tym miesiącu w danym dniu tygodnia. Przy minimum 1 miesiąca:
| Okres (czwartki) | Wynik | Dlaczego |
|---|---|---|
| 1 października – 31 paźdz | ✅ przyjęta | Obejmuje wszystkie czwartki października |
| 2 października – 30 paźdz | ✅ przyjęta | Dla piątków: 2 października to pierwszy piątek miesiąca, więc miesiąc pełny |
| 15 października – 14 list | ❌ odrzucona | Październik zaczęty w połowie, listopad nieskończony — brak pełnego miesiąca |
| 15 października – 30 list | ✅ przyjęta | Październik niepełny, ale listopad pełny |
Rezerwacja zaczęta w trakcie miesiąca musi więc objąć jeszcze kolejny pełny miesiąc. Te same zasady sprawdza formularz i serwer — dla recepcji i dla klienta.
Liczą się terminy faktycznie zarezerwowane, nie sam zakres dat. W pełnym miesiącu każdy czwartek musi być zarezerwowany albo wypadać w dzień, w którym kortu nie da się zarezerwować — święto albo zamknięcie kortu. Termin pominięty dlatego, że był zajęty przez kogoś innego („utwórz tylko wolne"), przerywa miesiąc. Przykład: seria listopadowa bez 12 listopada, bo tego dnia kort był zajęty, nie ma pełnego miesiąca; seria bez 19 listopada, bo to święto, ma.
Bez stawki nie ma stałych rezerwacji. Lokalizacja z włączonymi stałymi rezerwacjami, ale bez stawki w cenniku, nie pokaże przełącznika ani recepcji, ani klientowi — inaczej każdy termin kosztowałby 0 zł.
🛠️ Dokumentacja techniczna
Model danych
Stała rezerwacja nie jest nowym bytem — to tryb serii rezerwacji kortu (recurring_game_series + game + booking). Grafik, konflikty, PIN-y do drzwi, hardware sync i faktury działają bez zmian.
Migracja migrations/0279_add_standing_reservations.sql:
| Tabela | Kolumna | Znaczenie |
|---|---|---|
recurring_game_series | series_kind ('recurring' | 'standing') | Jedyny przełącznik trybu; domyślnie 'recurring', więc istniejące serie bez zmian |
recurring_game_series | standing_price | Cena pierwszego terminu (informacyjnie) |
recurring_game_series | standing_price_source ('pricelist'|'manual') | Czy cenę wzięto z cennika, czy wpisała ją recepcja |
recurring_game_series | owner_player_id | Klient główny — pod podział kosztu w Etapie 4 |
booking_settings | standing_enabled, standing_allow_online | Dostępność w lokalizacji |
booking_settings | standing_season_start, standing_season_end | Okno sezonu jako MM-DD |
booking_settings | standing_min_months | Minimum pełnych miesięcy |
booking_settings | standing_pricing_type, standing_price, standing_pricing_rules | Cennik: basic/advanced, stawka godzinowa, JSON PricingRule[] |
Cena każdego terminu ląduje w game.custom_price i booking.custom_price, a booking.applied_pricing_rule_id zostaje NULL — reguła cennika stałych rezerwacji nie jest regułą typu aktywności. Dzięki temu płatności, faktury i szczegóły rezerwacji czytają cenę tak jak przy każdej ręcznie wycenionej rezerwacji.
Bramka series_kind = 'standing'
Żadna reguła stałej rezerwacji nie może wyciec na zwykłe serie cykliczne ani na serie instruktorów. Dlatego generateRecurringReservations (lib/actions/booking.ts) czyta tryb z wiersza serii w bazie, a nie z payloadu, i dopiero dla 'standing' uruchamia resolveStandingSeriesTerms:
Wszystkie odmowy padają przed zapisem pierwszego terminu, więc ReservationForm usuwa pustą serię (SERIES_REFUSED_BEFORE_WRITE zawiera STANDING_REFUSALS). Ustawienia lokalizacji są pobierane na serwerze z kortu — bookingSettings z formularza nie decyduje o dostępie. Hold płatności, blokada przy zaległościach i requiresImmediateCourtPayment (instruktor bez holdu) działają bez zmian dla obu trybów.
Cena wpisana przez klienta jest ignorowana, bo seriesCustomPrice jest już null dla ról spoza ADMIN/BACKOFFICE. Indywidualny cennik klienta (applyClientPricingOverride) nie dotyczy stałej rezerwacji — cena każdego terminu przychodzi jako custom_price, który ma pierwszeństwo w createSystemPaymentForGameParticipation.
Reguły w lib/standing-reservation.ts
Moduł jest czysty (bez bazy), używany przez serwer i formularz:
standingSeasonFor(dateISO, settings)— sezon zawierający datę; obsługuje okno przechodzące przez Nowy Rok.standingMonths(periodStart, periodEnd)— miesiące okresu z flagąfullyCovered: miesiąc jest pełny, gdy okres obejmuje pierwszy i ostatni termin danego dnia tygodnia w tym miesiącu. Pod Etap 2 (płatność miesięczna).validateStandingPeriod(periodStart, periodEnd, settings, coverage?)—STANDING_OUTSIDE_SEASONalboSTANDING_PERIOD_TOO_SHORT. Bezcoveragesprawdza sam zakres dat (podpowiedź w formularzu, zanim terminy są znane). Zcoverage = { bookedDays, excusedDays }miesiąc liczy się tylko wtedy, gdy każdy dzień tygodnia serii w tym miesiącu jest zarezerwowany albo usprawiedliwiony — tak sprawdza serwer i formularz przy zapisie. Bez tego spreparowany payload (seria „na listopad" z jednym terminem) dostawał cenę stałej rezerwacji.standingUnbookedDays(periodStart, periodEnd, bookedDays)— dni tygodnia serii w pełnych miesiącach okresu, których seria nie rezerwuje. Tylko te sprawdzafindUnbookableDayswlib/actions/booking.ts: święta zgetHolidays(court.city)(jedna lista z cache,affects_booking = 1) i zamknięcia kortu (findClosureForInstanto godzinie serii), bez zapytania na dzień.isStandingPriced(settings)— stawka większa od zera; warunekcanCreateStandingReservationi walidacji wupdateBookingSettings(INVALID_STANDING_SETTINGS) oraz formularza ustawień (standingPriceRequired).resolveStandingPrice(settings, start, end, street)— buduje syntetyczny typ aktywności zstanding_*(w trybiebasicbez reguł, wadvancedz regułami przefiltrowanymi po ulicy kortu) i liczy cenę przezcalculateActivityPrice, więc weekendy i podział godzin działają jak w cenniku kortu.standingPriceBreakdown(settings, occurrences, street)— wycenia każdy termin przezresolveStandingPricei grupuje je po miesiącu w strefieEurope/Warsaw(toPolandMonthKey) i cenie; zwracalines,total(sumowany w groszach),occurrenceCountiuniformPrice(null, gdy ceny się różnią). Formularz liczy go z tej samej listy terminów, która idzie do zapisu (freeOccurrencesprzy pomijaniu kolizji, inaczejplannedOccurrences), dopiero po sprawdzeniu kolizji — wcześniej karta pokazuje cenę pierwszego terminu.recurring_game_series.standing_pricenadal trzyma tylko cenę pierwszego terminu.canCreateStandingReservation,holidayOccurrencesToBook,sortOccurrencesByStart,formatSeasonDay.
Daty okresu to dni w strefie Europe/Warsaw (toPolandDateISO z series.start_date i series.end_date).
Święta
generateRecurringGameOccurrencesShared nadal pomija święta i zamknięcia, ale każdy pominięty termin niesie teraz day oraz start_time/end_time, które by zajął. ReservationForm w trybie stałej rezerwacji:
- pokazuje święta z
skippedDatesjako listę z checkboxami (StandingReservationSettings), - zamienia wybrane dni na terminy przez
holidayOccurrencesToBook, - sprawdza je osobnym hookiem
useStandingHolidayConflicts(checkRecurringConflicts), bo zwykłyuseRecurringConflictsnigdy ich nie widzi, - dokleja je do
plannedOccurrences/freeOccurrences/recurringConflicts, więc ostrzeżenie o konfliktach, licznik terminów i zapis działają na pełnej liście.
Zamknięcia kortu nie da się zaznaczyć — generateRecurringReservations i tak odrzuca termin w zamknięciu (COURT_CLOSED).
Kody odmów
| Kod | Kiedy |
|---|---|
STANDING_NOT_ALLOWED | Stałe rezerwacje wyłączone lub bez stawki w lokalizacji albo klient przy standing_allow_online = 0 |
STANDING_OUTSIDE_SEASON | Początek poza sezonem albo koniec po końcu sezonu |
STANDING_PERIOD_TOO_SHORT | Mniej pełnych miesięcy niż standing_min_months, licząc tylko miesiące z kompletem zarezerwowanych (lub usprawiedliwionych) terminów |
STANDING_INVALID_PERIOD | Seria bez dat albo terminy poza okresem lub w innym dniu tygodnia (spreparowany payload) |
Komunikaty: reservationsView.standing.errors.*.
Testy
lib/standing-reservation.test.ts— okres (start 15. dnia, dokładnie miesiąc, poza sezonem, przełom roku, sezon letni), cennik (basic, advanced, weekend, ulica), dostęp, święta.lib/actions/booking.standingReservation.test.ts— odmowy dla recepcji i klienta, cena z cennika, cena ręczna recepcji, cena klienta ignorowana, wierszbooking; regresja: czterotygodniowa seria w czerwcu i seria instruktora bez holdu.lib/utils/recurring-occurrences.test.ts— okno terminu pominiętego święta.__tests__/lib/standing-reservation.integration.test.ts— na bazie SQLite w pamięci z kompletem migracji: zapis serii, terminów, rezerwacji i płatności z kwotami; odmowy bez zapisanych terminów; konflikt terminu; hold i link płatności klienta; święto wybrane przez recepcję i święto pominięte; seria „na miesiąc" z jednym terminem i z dziurą po konflikcie; zapis ustawień, w tym odmowa włączenia bez stawki; regresja zwykłej serii i serii instruktora.