Rezerwacje kortów przez instruktorów
Instrukcja
Dla instruktora
Instruktor rezerwuje kort dla siebie tak samo jak klient — z pozycji Rezerwacje w menu panelu.
- Wejdź w Rezerwacje. Zobaczysz widok kalendarza z wolnymi terminami swojego klubu.
- Kliknij wolne okienko, wybierz godzinę i zatwierdź.
- Rezerwacja jest gotowa od razu — nie ma 10-minutowego okna na płatność i rezerwacja nie przepadnie z powodu braku opłaty na czas.
- Za kort powstaje należność widoczna w zakładce Płatności. Opłacasz ją z portfela, który doładowuje recepcja.
Instruktor nie płaci kartą przy rezerwacji. Jeśli w portfelu nie ma środków, należność czeka — trzeba poprosić recepcję o doładowanie.
Instruktor może też korzystać z publicznego kalendarza (/book/...) bez logowania, tak jak każdy gość. Warto wtedy podać ten sam numer telefonu lub adres e-mail, co na koncie — inaczej powstanie osobna kartoteka gościa.
Dla recepcji
Instruktorzy pojawiają się w wyszukiwarce uczestników przy tworzeniu rezerwacji, obok klientów. Rozpoznasz ich po zielonej plakietce Instruktor przy nazwisku.
Doładowanie portfela instruktora działa jak dla klienta — z kartoteki uczestnika.
Dokumentacja techniczna
Skąd brał się problem
Rezerwacja kortu jest zapisywana jako uczestnik gry (game.attendees → player.id). Konta instruktorów nigdy nie miały rekordu w player: rejestracja tworzy go tylko dla roli CLIENT, a updateUser pomija synchronizację kartoteki dla wszystkich ról pracowniczych. Bez player konto było niewidoczne dla getCurrentUserPlayerIds, dla listy własnych rezerwacji i dla wyszukiwarki uczestników.
Do tego widok /dashboard/reservations był kompilowany wyłącznie do workera client (AP-773/774), a instruktor jako isStaffRole trafia na workera admin — pozycja menu była wycinana przez collectUnavailableRoutes.
Kartoteka instruktora
lib/instructor-player.tsensureInstructorPlayer(account)— idempotentnie zakłada lub odświeża rekordplayer(client_id= id konta,is_instructor = 1), odarchiwizowuje istniejący i dopisuje zmienione imię/nazwisko/telefon. Nigdy nie rzuca wyjątku — konto instruktora ma powstać nawet gdy kartoteka się nie uda.clearInstructorPlayerMark(userId)— zdejmuje znacznik przy zmianie roli. Rekord zostaje, bo może trzymać rezerwacje i płatności.
- Wywołania:
createUser(gałąźrole === 'INSTRUCTOR') orazupdateUserprzy każdej aktualizacji konta instruktora i przy odejściu z roli. - Kolumna
player.is_instructor(migracja0235) — osobna kolumna zamiast nowej wartościcreated_type, bo tamta maCHECK, a poszerzenie go w SQLite oznacza przebudowę tabeli. Migracja oznacza istniejące rekordy (dopasowanie poclient_id, nie po adresie — dziecko zapisane na e-mail trenera to nie trener) i dokłada brakujące. door_pinw backfillu zostajeNULL; PIN nadaje się leniwie przezensurePlayerDoorPinprzy pierwszej rezerwacji.- Backfill zakłada kartotekę tylko w tym klubie, w którym instruktor faktycznie prowadzi zajęcia (
game.instructor_id). Tabela kont nie ma tenanta, więc bez tego drugi tenant dostałby kopię każdego trenera. Przy jednym tenancie warunek się nie stosuje. ensureInstructorPlayernie ruszaarchivedaniactivity_statusistniejącego rekordu. To pole należy do przełącznika uczestnictwa w profilu —setInstructorParticipation(false)archiwizuje kartotekę — a odarchiwizowanie stąd cofałoby tę decyzję przy pierwszej lepszej edycji konta.- Nowa kartoteka powstaje z
skip_duplicate_check. Kontrola duplikatów wcreatePlayerporównuje imię, nazwisko i datę urodzenia, a rekord instruktora zakładany jest z pustą datą — bez tego trener o nazwisku zbieżnym z którymkolwiek z ~170 rekordów bez daty urodzenia w ogóle nie dostałby kartoteki.client_idi tak zapewnia unikalność.
Płatność
lib/utils/immediate-payment.ts → requiresImmediateCourtPayment decyduje, czy rezerwacja wymaga zapłaty od ręki. Dla roli INSTRUCTOR odpowiedź brzmi zawsze „nie” — także przy zaległościach na koncie, bo pieniądze są należne temu samemu portfelowi, a nie bramce płatniczej. Skutki w createClientReservation:
| klient | instruktor | |
|---|---|---|
immediate_payment_online (sam rezerwuje) | obowiązuje | ignorowane |
immediate_payment_employee (recepcja rezerwuje) | obowiązuje | ignorowane |
| blokada slotu na czas płatności | tak, gdy wymagana | nigdy |
atrybut draft na uczestniku | tak, gdy wymagana | nigdy |
należność w payment | tak | tak (przez createGame) |
| kanał zapłaty | bramka / portfel | portfel (/api/wallet/pay) |
Wyjątek obowiązuje niezależnie od tego, kto wypełnia formularz. createClientReservation czyta rolę z sesji, bo instruktor rezerwuje sam dla siebie. createAdminReservation czyta player.is_instructor uczestnika, bo przy ladzie sesja należy do recepcji — liczy się ten, kto wychodzi na kort, nie ten, kto klika. Bez tego rezerwacja założona instruktorowi przez recepcję dostawała 10-minutowy hold z immediate_payment_employee i przepadała, mimo że nie miał czym zapłacić od ręki.
Komunikaty w UI liczą się przez ten sam predykat, żeby nie obiecywały okna płatności, którego rezerwacja nie dostanie:
components/schedule/court-booking-dialog.tsx— ostrzeżenie „wymaga natychmiastowej płatności" w podsumowaniu terminu.ReservationForm.tsx— to samo ostrzeżenie w formularzu.CourtReservationPaymentMessage— baner nieopłaconych rezerwacji ma dla instruktora własny tekst (userActivities.reservationPaymentIssueDescriptionInstructor): kieruje do portfela zamiast straszyć natychmiastową spłatą zaległości.
Ostrzeżenie o zaległościach (arrearsWarning) gaśnie samo — getCurrentUserOverdueInfo zwraca dla instruktora zerowe kwoty, bo nie jest zawieszony.
Stawka za kort bierze się z applyClientPricingOverride, dopasowanego po owner_email. Klub, który chce dać instruktorom inną cenę (albo zero), ustawia ją w ustawieniach klienta dla ich adresu — nie trzeba do tego osobnego cennika. Przy cenie 0 createGame w ogóle nie zakłada należności.
Zawieszenie za zaległości
Instruktor jest wyjęty spod block_clients_with_overdue. Zaległość jest wobec portfela, który doładowuje recepcja — spóźnione doładowanie to sprawa klubu i nie może odciąć trenera od rezerwacji.
Realizacja: OwnerOverdueStats.suspensionExempt (lib/utils/suspension.ts). evaluateSuspensionCriteria zwraca false, gdy flaga jest ustawiona — dzięki temu wszystkie kilkanaście miejsc liczących zawieszenie (widok rezerwacji, checkOverdueBlock, wyszukiwarka uczestników, kalendarz publiczny, dialogi zapisów) dziedziczy wyjątek bez zmian u siebie.
Wyjątek jest wszystko-albo-nic i liczony po należnościach, nie po adresie: obowiązuje tylko wtedy, gdy każda zaległość na koncie należy do kartoteki instruktora. Rodziny współdzielą tu jeden owner_email (1101 z 1532 aktywnych graczy siedzi na wspólnym adresie), więc odpowiedź „adres należy do instruktora" zwalniałaby z blokady całe gospodarstwo. Trener, którego dziecko zalega za zajęcia, jest zawieszany jak każdy inny rodzic.
Obie ścieżki liczą to identycznie: getOwnerOverdueStats przez COALESCE(pl.is_instructor, 0) na wierszu płatności, appendSuspensionStatusBatch przez MIN(...) w zapytaniu zbiorczym. Inaczej lista klientów pokazywałaby co innego niż blokada przy rezerwacji.
Kwoty zaległości zostają widoczne — wyjęte jest samo zawieszenie, nie dług.
Routing
-
scripts/route-variants.json—app/(dashboard)/dashboard/reservationsjest teraz kompilowane do obu workerów. Bez tego link z menu na domenie panelu kończyłby się przeskokiemcross-worker-handoffna domenę klienta i okrojoną nawigacją. -
route_accessustawiane jest ręcznie w bazie, bez migracji. RolaINSTRUCTORmusi trafić doallowed_rolesdladashboardReservations,dashboardReservationsAddGame,dashboardReservationsCancelorazdashboardPaymentsidashboardPaymentsSuccess— to ostatnie dlatego, że tam żyje przycisk zapłaty z portfela (/api/wallet/payrolę dopuszczał już wcześniej). Bez tego pozycje nie pojawią się instruktorowi w menu.UPDATE route_accessSET allowed_roles = json_insert(COALESCE(allowed_roles, '[]'),'$[#]','INSTRUCTOR'),updated_at = strftime('%Y-%m-%dT%H:%M:%f', 'now', 'subsec') || 'Z'WHERE key IN ('dashboardReservations','dashboardReservationsAddGame','dashboardReservationsCancel','dashboardPayments','dashboardPaymentsSuccess')AND NOT EXISTS (SELECT 1 FROM json_each(COALESCE(route_access.allowed_roles, '[]'))WHERE json_each.value = 'INSTRUCTOR');Zapytanie jest idempotentne i nie nadpisuje list zawężonych ręcznie — dokłada rolę tylko tam, gdzie jej nie ma.
Plakietka w wyszukiwarce
components/instructor-badge.tsx renderuje się obok CreatedTypeIcon w AtendeeAutocomplete. Dane biorą się z p.* w getPlayersByName — zapytanie nie wymagało zmiany.
Znane ograniczenia
Wyłączenie „uczestnictwa w zajęciach" w profilu archiwizuje kartotekę instruktora, a zarchiwizowany rekord nie obsłuży rezerwacji kortu. Instruktor, który się wypisał, musi włączyć przełącznik z powrotem, żeby móc rezerwować. Rozdzielenie tych dwóch znaczeń kartoteki to osobna decyzja produktowa.
/dashboard/reservations po dołożeniu do buildu admina jest widoczne w menu również dla roli ADMIN i nie da się tego wyłączyć przez route_access.
getRoutesForSession zwraca dla ADMIN-a wyłącznie wiersze, których allowed_roles zawiera ADMIN. Wiersz rezerwacji go nie zawiera, więc w ogóle nie trafia do skompilowanego zbioru, checkCompiledRouteAccess odpowiada null, a null uruchamia domyślną regułę „ADMIN widzi wszystko” w RouteAccessProvider.checkRouteAccess. Innymi słowy: usunięcie ADMIN-a z listy ról to dokładnie ten stan, który tę widoczność włącza — nie ma zapisu „zabroń ADMIN-owi”.
Wcześniej pozycja znikała tylko dlatego, że trasa była wycięta z buildu workera admin. Strona działa w wariancie administracyjnym, więc nic się nie psuje — to wyłącznie nowa pozycja w menu.