Tablica zadań
Widok Dashboard ➔ Zadania to jedna tablica kanban, na której lądują wszystkie sprawy do
obsłużenia — niezależnie od tego, skąd pochodzą. Wcześniej były to cztery osobne zakładki
(„Nowy klient", „Zajęcia", „Indywidualne", „Inne"); teraz jest jedna tablica i filtry.
Checklisty pozostają osobną zakładką — Checklisty.
O tym, dlaczego uczestnik może w ogóle nie trafić na tablicę, mówi Typ uczestnika.
👤 Instrukcja dla recepcji i biura
Co jest na tablicy
Trzy źródła, rozróżniane kolorem lewej krawędzi karty i etykietą:
| Źródło | Skąd się bierze | Co z tym zrobić |
|---|---|---|
| Zadania własne | Ręcznie założone zadania i zgłoszenia od klientów (np. „brak pasującego terminu") | Pełna edycja: status, priorytet, etykiety, przypisanie |
| Klienci | Uczestnicy bez umówionych próbnych albo po próbnych bez przypisanej grupy | Kliknij kartę → przypisz rodzaj zajęć, dopisz notatkę |
| Indywidualne | Zgłoszenia na zajęcia indywidualne od klientów | Kliknij kartę → zatwierdź (z terminem, kortem i trenerem) albo odrzuć |
Filtry źródeł
Nad tablicą są trzy odznaki. Domyślnie żadna nie jest zaznaczona i widać wszystko. Kliknięcie zawęża widok — np. sama odznaka Klienci odtwarza dawną zakładkę „Nowy klient". Filtry można łączyć, a Wyczyść filtry wraca do pełnej listy.
Kolumny
Tablica ma sześć kolumn, w tej kolejności:
Backlog → Do zrobienia → W trakcie → Zapisany → Rezygnacja → Ukończone.
„Zapisany" i „Rezygnacja" to etapy obsługi klienta: pierwsza dla kogoś, kto już się zapisał, druga dla kogoś, kto odmówił. Trzymają kartę na widoku zamiast wypychać ją do Backlogu, dzięki czemu widać, czym skończyła się rozmowa.
Przeciąganie kart
Zadania własne przeciąga się między wszystkimi sześcioma kolumnami — mają własny status.
Karty klientów przeciąga się między Do zrobienia, Backlog, W trakcie, Zapisany i Rezygnacja. Backlog to miejsce na leada, o którym na razie nic nie wiadomo; W trakcie — na tego, z kim recepcja właśnie się kontaktuje; Rezygnacja — na tego, kto odmówił (np. przez telefon); Zapisany — na tego, kto już wszedł na zajęcia. Działa tak samo dla klienta z kartoteką, jak i dla osoby, która założyła konto i nie potwierdziła numeru telefonu. Przeciągnięcie z powrotem do Do zrobienia kasuje odłożenie.
Karta klienta odłożona ręcznie zostaje w swojej kolumnie — także wtedy, gdy normalnie zeszłaby z tablicy (dostała rodzaj zajęć i termin). Znika dopiero, gdy ktoś przeciągnie ją z powrotem do Do zrobienia.
Do Ukończone karty klienta nie trafiają — kartę klienta „kończy" przypisanie rodzaju zajęć w jej oknie, co zdejmuje ją z tablicy, a nie przeniesienie do kolumny.
Zgłoszenia indywidualne stoją w kolumnie wynikającej ze swojego stanu — Do zrobienia gdy oczekuje, Gotowe po zatwierdzeniu, Backlog po odrzuceniu — i nie są przeciągalne. Przeniesienie do „Gotowe" nie miałoby jak wymyślić kortu, trenera i godziny; zgłoszenie zatwierdza się w jego własnym oknie, które przy okazji tworzy zajęcia.
Wyszukiwarka i „tylko moje"
Wyszukiwarka przeszukuje wszystkie źródła naraz: tytuły i opisy zadań, nazwiska, e-maile i telefony klientów, treść zgłoszeń.
Przycisk Tylko moje zadania pokazuje zadania przypisane do Ciebie. Karty klientów i zgłoszeń indywidualnych wtedy znikają — nie mają przypisanej osoby.
Widok tabeli
Przełącznik tabeli pokazuje wyłącznie zadania własne, za to z pełną tabelą: zaznaczanie wielu naraz i edycja przypisań w miejscu. Klienci i zgłoszenia indywidualne są dostępni na tablicy, która jest widokiem domyślnym.
🛠 Dokumentacja techniczna
Model odczytu
Źródła nie są materializowane do wspólnej tabeli — byłby to duplikat prawdy i pewny
rozjazd. Zamiast tego components/tables/tasks-table/task-cards.ts sprowadza je do
jednego typu z dyskryminatorem:
type TaskCard =
| { source: 'ticket'; id: `ticket:${number}`; draggable: true; ticket: Ticket }
| { source: 'player'; id: `player:${number}`; draggable: true; player: TaskPlayer }
| { source: 'individual'; id: `itr:${number}`; draggable: false; request: … };
Identyfikatory są stringami z prefiksem źródła — same liczby kolidowałyby między tabelami.
draggable decyduje, czy karta trafia do SortableContext.
Odłożenie karty klienta do kolumny
Karty klientów są wyliczane przy każdym wczytaniu tablicy, więc nie ma na czym zapisać
kolumny — brak wiersza znaczy todo, a wiersz trzyma jedną z wartości
PLAYER_CARD_STATUSES (backlog, in_progress, enrolled, resigned). Karta klienta
ma dwa źródła i każde ma własną tabelę pamięci:
| Skąd karta | Klucz | Tabela | Akcja |
|---|---|---|---|
Uczestnik z kartoteki (player) | player.id | player_board_status | updatePlayerBoardStatus |
Konto bez potwierdzonego telefonu (user) | user.id (player.client_id) | unverified_user_board_status | updateUnverifiedUserBoardStatus |
Rozdzielenie nie jest kosmetyczne: karty z drugiego źródła nie mają wiersza w player,
a getTasksData numeruje je ujemnym indeksem listy (id: -index - 1). Taki numer zmienia
się przy każdym wczytaniu i nie przejdzie klucza obcego, więc jedyne stabilne, co można
zapisać, to id konta. updatePlayerBoardStatus odrzuca playerId <= 0 twardym błędem,
żeby ta ścieżka nie mogła wrócić bokiem.
Zapisany wiersz jest też warunkiem widoczności: getPlayersWithoutActivityTypeAndGame
(i jego licznik) puszcza gracza na tablicę dodatkowo, gdy board_status IS NOT NULL —
bez tego karta odłożona do „Zapisany" znikałaby przy najbliższym odświeżeniu, bo ma już
rodzaj zajęć i termin.
Notatki na karcie klienta
Notatkę pisze się w oknie karty i zapisuje osobno od reszty okna — przycisk „Zapisz zmiany" na dole dotyczy rodzajów zajęć, notatka zapisuje się własnym haczykiem. Notatka ma dwie tabele, tak samo rozdzielone jak odłożenie do kolumny:
| Skąd karta | Klucz | Tabela | Akcja |
|---|---|---|---|
Uczestnik z kartoteki (player) | player.id | player_task_notes | updatePlayerTaskNote |
Konto bez potwierdzonego telefonu (user) | user.id (player.client_id) | unverified_user_task_notes | updateUnverifiedUserTaskNote |
Obie wracają na kartę pod tym samym polem task_notes: dla uczestnika JOIN-em
w getPlayersWithoutActivityTypeAndGame, dla leada — attachUnverifiedBoardFields
w getTasksData, jednym UNION ALL razem z odłożeniem do kolumny, żeby wczytanie
tablicy nie płaciło dwóch zapytań za to samo konto. Okno czyta task_notes, nie player.notes; to drugie jest polem
kartoteki i nigdy nie było tym, co zapisuje karta — czytanie go sprawiało, że notatka
wyglądała na niezapisaną, choć siedziała w player_task_notes.
Jedna osoba, jedna karta klienta
Karta „Nowy klient" powstaje z dwóch źródeł, które łatwo pokazują tę samą osobę dwa razy:
z konta bez potwierdzonego telefonu i z wiersza w player. Dlatego getTasksData
odrzuca kartę konta, gdy na ten sam owner_email istnieje niezarchiwizowany uczestnik —
wtedy pracą jest karta uczestnika, bo to z niej umawia się próbne. Bez tego klient,
który dokończył rejestrację i zapisał się na próbne, zostawał na tablicy dwa razy:
jako lead „do umówienia" i jako umówione próbne.
Drugim warunkiem jest to, żeby konto w ogóle przestawało być „niezweryfikowane".
provisionAccountForDraft stempluje phoneVerified = 1 we wszystkich trzech
ścieżkach — nowe konto, konto z zalogowanej sesji i konto, które już wcześniej należało
do tego adresu. Ta trzecia ścieżka pomijała stempel, więc powracający opiekun zostawał na
liście niezweryfikowanych na zawsze, mimo że przed chwilą przeszedł ten sam kod SMS.
Konta, które zdążyły utknąć przed poprawką, prostuje migracja
0248_backfill_phone_verified_from_trial_drafts.sql. Stempluje tylko to, co OTP
faktycznie potwierdził: adres z draftem mającym phone_verified_at, numer zgodny
z kontem (albo pusty — wtedy go uzupełnia) i wyłącznie gdy ten numer wskazuje jedno
konto. Ostatni warunek nie jest kosmetyczny — findUserByVerifiedPhone
(lib/phone-login.ts) bierze pierwszy wiersz przez LIMIT 1 bez ORDER BY, więc numer
oznaczony jako zweryfikowany na kilku kontach sprawiłby, że logowanie telefonem trafia
w przypadkowe z nich.
Zdublowany uczestnik w kartotece
Najczęstsza przyczyna dwóch kart na jedną osobę leżała poza tablicą — w kartotece.
Formularz uczestnika zapisywał datę urodzenia przez FORMATS.iso
(yyyy-MM-dd HH:mm:ss), a wszystkie pozostałe ścieżki przez yyyy-MM-dd. Obie blokady
duplikatu — createPlayer i provisionPlayers — porównywały kolumnę dokładnie, więc
dziecko już obecne w kartotece było dla nich niewidoczne i zakładały drugi wiersz. Potem
jeden z nich dostawał próbne, a drugi zostawał jako „Nowy klient — do umówienia".
Trzy zmiany naraz: formularze zapisują FORMATS.dateISO, obie blokady porównują samą
część datową (SUBSTR(…, 1, 10)), a migracja
0249_normalise_player_date_of_birth.sql obcina czas w istniejących wierszach.
Porównanie po części datowej zostaje na stałe — dane sprzed poprawki mają wracać jako
duplikaty także wtedy, gdy migracja gdzieś nie doszła.
Migracja nie scala wierszy, które już powstały. Scalenie pociąga płatności, zapisy i historię, więc jest decyzją człowieka, nie sweepa.
Domykanie „Niedokończonej rejestracji"
Ticket „Niedokończona rejestracja" powstaje w tym samym przebiegu, który tablica odpala
przy każdym otwarciu (createTicketsForAbandonedRegistrations). Ten sam przebieg go
zamyka, gdy rejestracja przestała być niedokończona — nic innego do tych ticketów nie
wraca, więc klient, który dokończył zapis po wystawieniu ticketu, zostawał na tablicie
dwa razy: jako „oddzwoń, utknął" i jako umówione próbne.
Liczą się dwa warianty dokończenia. Klient wrócił do tego samego draftu i go skończył
(completed_at), albo — częściej, bo mail z przypomnieniem niesie świeży link — zaczął
od nowa i skończył inny draft na ten sam adres. Dopasowanie tylko po pierwszym
zostawiałoby otwarte tickety dla wszystkich, którzy zaczęli od zera.
Zamknięcie idzie surowym UPDATE, nie przez updateTicket, bo ten stempluje zmianę
bieżącą sesją — przebieg odpala się przy okazji tego, kto akurat otworzył tablicę, a on
tej pracy nie wykonał. W historii ticketu autorem jest system:abandoned-registration.
Tickety ręcznie anulowane (cancelled) i już zamknięte (done) są pomijane.
Przebieg wybiera drafty przez NOT EXISTS, a wstawia osobnym zapytaniem — między
odczytem a zapisem nie ma transakcji, więc prefetch Next.js i właściwa nawigacja (albo
dwie osoby otwierające tablicę naraz) potrafią wejść w ten sam draft. Rozstrzyga
częściowy indeks unikalny idx_tickets_registration_draft_source (migracja 0205), a
createSystemTicket wstawia z ON CONFLICT … DO NOTHING: przegrany przebieg nic nie
zapisuje i dostaje null zamiast id. Zwrócone null trzeba sprawdzić — po pominiętym
INSERT last_row_id wskazuje na poprzedni wiersz tego połączenia, więc wpis do
ticket_history trafiłby na cudzy ticket.
Statusy ticketów
Kolumny „Zapisany" i „Rezygnacja" to również statusy ticketu (enrolled, resigned
w TicketStatus). W bazie pilnuje ich CHECK na tickets.status, którego SQLite nie umie
zmienić w miejscu — migracja 0241 przebudowuje tabelę. Robi to okrężnie, bo
ticket_assignees, ticket_history i ticket_label_assignments wiszą na tickets
z ON DELETE CASCADE, a DROP TABLE odpala te kaskady (defer_foreign_keys odracza
sprawdzenia, nie akcje). Dlatego migracja najpierw kopiuje dzieci do tabel
zapasowych, a po odtworzeniu rodzica wstawia je z powrotem z tymi samymi id.
Listy statusów w UI biorą się z Object.keys(statusConfig) (ticket-card.tsx,
ticket-table.tsx), więc kolejność kluczy w tych mapach jest kolejnością pozycji
w menu — nowe statusy stoją przed done.
Pliki
| Plik | Rola |
|---|---|
task-cards.ts | Adaptery, filtrowanie, grupowanie po kolumnach (czyste funkcje) |
tasks-content.tsx | Zakładki, filtry źródeł, wyszukiwarka, dialogi |
tickets/tasks-board-view.tsx | Pobiera trzy źródła i skleja karty |
tickets/ticket-board.tsx | Kanban; obsługuje karty przeciągalne i statyczne |
derived-task-card.tsx | Wygląd kart klienta i zgłoszenia indywidualnego |
player-task-dialog.tsx | Przypisanie rodzajów zajęć + notatki |
individual-request-dialog.tsx | Zatwierdzenie / odrzucenie zgłoszenia |
Gracze są pobierani serwerowo w app/(dashboard)/dashboard/tasks/page.tsx
(getTasksData), żeby zachować filtr miasta i nie powtarzać kosztownego zapytania w
przeglądarce. Zadania i zgłoszenia indywidualne tablica dociąga sama.
resolveTaskReason (lib/utils/task-reason.ts) rozstrzyga, czy klient jest „nowy", czy
„po próbnych bez grupy". Funkcja leży poza lib/actions/players.ts, bo tamten moduł jest
'use server' — każdy jego eksport musi być funkcją asynchroniczną.
Kompatybilność linków
Stare adresy z ?tab=newClient|classes|individual|other prowadzą na scaloną tablicę —
resolveTab mapuje wszystko poza checklist na tasks, żeby zakładki w przeglądarkach
recepcji nie kończyły się pustą stroną.