Skip to main content

Rezerwacje kortów przez instruktorów

Instrukcja

Dla instruktora

Instruktor rezerwuje kort dla siebie tak samo jak klient — z pozycji Rezerwacje w menu panelu.

  1. Wejdź w Rezerwacje. Zobaczysz widok kalendarza z wolnymi terminami swojego klubu.
  2. Kliknij wolne okienko, wybierz godzinę i zatwierdź.
  3. Rezerwacja jest gotowa od razu — nie ma 10-minutowego okna na płatność i rezerwacja nie przepadnie z powodu braku opłaty na czas.
  4. 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.attendeesplayer.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.ts
    • ensureInstructorPlayer(account) — idempotentnie zakłada lub odświeża rekord player (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') oraz updateUser przy każdej aktualizacji konta instruktora i przy odejściu z roli.
  • Kolumna player.is_instructor (migracja 0235) — osobna kolumna zamiast nowej wartości created_type, bo tamta ma CHECK, a poszerzenie go w SQLite oznacza przebudowę tabeli. Migracja oznacza istniejące rekordy (dopasowanie po client_id, nie po adresie — dziecko zapisane na e-mail trenera to nie trener) i dokłada brakujące.
  • door_pin w backfillu zostaje NULL; PIN nadaje się leniwie przez ensurePlayerDoorPin przy 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.
  • ensureInstructorPlayer nie rusza archived ani activity_status istnieją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 w createPlayer poró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_id i tak zapewnia unikalność.

Płatność

lib/utils/immediate-payment.tsrequiresImmediateCourtPayment 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:

klientinstruktor
immediate_payment_online (sam rezerwuje)obowiązujeignorowane
immediate_payment_employee (recepcja rezerwuje)obowiązujeignorowane
blokada slotu na czas płatnościtak, gdy wymagananigdy
atrybut draft na uczestnikutak, gdy wymagananigdy
należność w paymenttaktak (przez createGame)
kanał zapłatybramka / portfelportfel (/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.jsonapp/(dashboard)/dashboard/reservations jest teraz kompilowane do obu workerów. Bez tego link z menu na domenie panelu kończyłby się przeskokiem cross-worker-handoff na domenę klienta i okrojoną nawigacją.

  • route_access ustawiane jest ręcznie w bazie, bez migracji. Rola INSTRUCTOR musi trafić do allowed_roles dla dashboardReservations, dashboardReservationsAddGame, dashboardReservationsCancel oraz dashboardPayments i dashboardPaymentsSuccess — to ostatnie dlatego, że tam żyje przycisk zapłaty z portfela (/api/wallet/pay rolę dopuszczał już wcześniej). Bez tego pozycje nie pojawią się instruktorowi w menu.

    UPDATE route_access
    SET 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.