Przejdź do głównej zawartości

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łoSkąd się bierzeCo z tym zrobić
Zadania własneRęcznie założone zadania i zgłoszenia od klientów (np. „brak pasującego terminu")Pełna edycja: status, priorytet, etykiety, przypisanie
KlienciUczestnicy bez umówionych próbnych albo po próbnych bez przypisanej grupyKliknij kartę → przypisz rodzaj zajęć, dopisz notatkę
IndywidualneZgłoszenia na zajęcia indywidualne od klientówKliknij 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:

BacklogDo zrobieniaW trakcieZapisanyRezygnacjaUkoń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 kartaKluczTabelaAkcja
Uczestnik z kartoteki (player)player.idplayer_board_statusupdatePlayerBoardStatus
Konto bez potwierdzonego telefonu (user)user.id (player.client_id)unverified_user_board_statusupdateUnverifiedUserBoardStatus

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 kartaKluczTabelaAkcja
Uczestnik z kartoteki (player)player.idplayer_task_notesupdatePlayerTaskNote
Konto bez potwierdzonego telefonu (user)user.id (player.client_id)unverified_user_task_notesupdateUnverifiedUserTaskNote

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

PlikRola
task-cards.tsAdaptery, filtrowanie, grupowanie po kolumnach (czyste funkcje)
tasks-content.tsxZakładki, filtry źródeł, wyszukiwarka, dialogi
tickets/tasks-board-view.tsxPobiera trzy źródła i skleja karty
tickets/ticket-board.tsxKanban; obsługuje karty przeciągalne i statyczne
derived-task-card.tsxWygląd kart klienta i zgłoszenia indywidualnego
player-task-dialog.tsxPrzypisanie rodzajów zajęć + notatki
individual-request-dialog.tsxZatwierdzenie / 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ą.