= SOKS - System Obslugi Klubu Strzeleckiego =
= Historia zmian =

== 1.5.29 (2026-10-23) - Klasy sportowe: automat przypisan konkurencji; MP i PP z komunikatow PZSS rozpoznawane automatycznie ==
<!-- ks-aliasy-automat-2026-10-23 -->
<!-- ks-kal-z-folderu-2026-10-23 -->

NOWE - automat przypisan nazw konkurencji (klasy sportowe):
- Start jest oceniany, gdy nazwa jego konkurencji wskazuje konkurencje z tabeli norm PZSS. Dotad kazda nazwe
  przypisywal administrator. Teraz nazwy PEWNE przypisuje automat (co godzine albo przyciskiem "Przypisz pewne
  nazwy teraz" w SOKS -> Klasy sportowe -> Nazwy konkurencji):
  - znane konkurencje bez normy (TRAP 10, 3+10 - takze pisane bez spacji, KSP 20/40, PSP 30, Pst 2x30 open, IPSC,
    MIX) -> "bez normy",
  - nazwy, w ktorych odleglosc, bron, liczba strzalow (albo kategoria doroslych/juniorow: seniorki, kobiety,
    mezczyzni, juniorki), postawa i kategoria wskazuja dokladnie jedna konkurencje z tabeli norm.
- Niepewne nazwy (brak liczby strzalow, kilka pasujacych konkurencji, konkurencje paraolimpijskie SH1/SH2,
  konkurencje klubowe) czekaja na decyzje administratora - przy kazdej powod.
- Przypisania automatu sa w tabeli z dopiskiem "automat"; mozna je zmienic albo usunac (usunietej nazwy automat juz
  nie przypisze). Automat mozna wylaczyc. Klase nadaje nadal czlowiek ("Potwierdz nadanie").
- Lepsze rozpoznawanie nazw w podpowiedziach: "60L" (60 lezac), jawna liczba strzalow ("5 strzalow"), "juniorzy
  mlodsi", "JUNIORKI ML.".

NAPRAWIONE - Mistrzostwa i Puchar Polski z komunikatow PZSS:
- Starty dopisane z komunikatow PZSS przed 1.5.25 nie mialy numeru zawodow w kalendarzu PZSS, wiec automat norm
  nie uznawal ich jako MP/PP (wszystko "zawody bez decyzji"). Automat startow obcych uzupelnia teraz numer i kategorie
  kalendarza z folderu komunikatu (/assets/files/kalendarz/<rok>/<numer>/) - tylko w pustych polach, gdy numer jest
  na listach kalendarza PZSS - i przekazuje te starty do ponownej oceny klas.

PO AKTUALIZACJI:
- Uzupelnienie numerow kalendarza nastepuje przy najblizszym przebiegu automatu startow obcych, przypisania nazw -
  przy najblizszym przebiegu automatu klas (co godzine) albo od razu przyciskiem.
- Wyniki sprzed wdrozenia modulu klas zapisuja sie dopiero po zatwierdzeniu przegladu wstecz (bez zmian).

== 1.5.28 (2026-10-23) - Rejestracje na zawody a anulowane i usuniete zamowienia ==
<!-- ks-rej-zamowienie-2026-10-23 -->

NAPRAWIONE - rejestracja "zawieszona" po nieudanej platnosci:
- Nieoplacone zamowienie z zapisem na zawody nie jest juz anulowane automatycznie przez WooCommerce po czasie
  "Wstrzymaj magazyn" (od WooCommerce 10.6 ten automat obejmuje tez kase blokowa, czyli kazdy zapis na zawody).
  Zamowienie zostaje "Oczekujace na platnosc" i zawodnik moze ponowic platnosc z panelu ("Zaplac teraz").
- Anulowanie nieoplaconego zamowienia (panel klubu, WooCommerce) usuwa jego nieoplacone rejestracje na zawody.
  Zawodnik moze od razu zapisac sie ponownie; w zamowieniu zostaje notatka. Nie dotyczy zamowien z potwierdzona
  wplata (numer transakcji zapisany po weryfikacji wplaty albo KP w kasie klubu) - to zwrot, decyduje
  administrator. Sam numer zamowienia Przelewy24 nie wystarcza - bramka zapisuje go juz przy zleceniu BLIK.
- Usuniecie zamowienia (na stale albo do kosza) usuwa calkowicie jego NIEOPLACONE rejestracje - dawniej zostawaly
  na liscie rejestracji jako "Anulowane". Oplacone zostaja jako "Anulowane" z adnotacja "usunieto zamowienie #..."
  (slad wplaty do zwrotu).
- Gdy zamowienie wroci do zycia (pozna wplata z bramki dla anulowanego zamowienia, zmiana statusu, przywrocenie
  z kosza), rejestracje odtwarzaja sie z danych zamowienia - zawodnik nie zostaje z wplata bez zapisu.
- Wplata za zamowienie, ktorego rejestracje zniknely (drugie zamowienie na ten sam start po anulowaniu pierwszego,
  rejestracja usunieta recznie), odtwarza je przed oznaczeniem wplaty. Strona podziekowania nie przywraca juz
  rejestracji zamowienia anulowanego ani lezacego w koszu.
- Kosz, a potem usuniecie na stale (albo podwojne wywolanie przy WooCommerce bez HPOS) nie kasuje juz sladu
  oplaconej rejestracji.

PO AKTUALIZACJI:
- Nieoplacone zamowienia z zapisem na zawody nie znikaja same po godzinie. Porzucone anuluje albo usuwa
  administrator - nieoplacone rejestracje zwalniaja sie wtedy same.

== 1.5.27 (2026-10-21) - Kompensaty (KOM) tylko w rozrachunkach, nie w kasie; typ broni: PCC = karabin, rewolwer = pistolet ==
<!-- ks-kom-poza-kasa-2026-10-21 -->
<!-- ks-typ-broni-pcc-rewolwer-2026-10-21 -->

ZMIENIONE - kompensaty (KOM) poza kasa:
- Kompensata i jej korekty to rozrachunek z kontrahentem, nie operacja kasowa, i nie maja wplywu na stan kasy.
  Dokument KOM wyslany z SOKS Desktop zapisuje sie tylko w dokumentach finansowych (kasa_transakcja_id = 0);
  nie powstaje dla niego wiersz w kasie strony. Zmiana i usuniecie takiego dokumentu z Desktopu kasy nie ruszaja.
- Lista "Kasa" na stronie, synchronizacja zmian i GET /transactions dla Desktopu nie pokazuja i nie wysylaja KOM.
- Strona rozrachunkow czyta kompensaty z dokumentow finansowych (stary KOM z kasy - tylko gdy dokumentu o tym
  numerze nie ma). Z opisu KOM brane sa wylacznie numery pozycji rozrachunkow z przesunieciem 1000000; liczby
  bez przesuniecia to numery faktur SOKS Desktop i nie zamykaja juz zobowiazania o tym samym numerze.

PO AKTUALIZACJI:
- Wiersze KOM zapisane w kasie przed 1.5.27 zostaja w tabeli, ale nie sa pokazywane ani wysylane do Desktopu.
  Mozna je usunac osobnym skryptem (z kopia) - dokumenty finansowe i rozrachunki zostaja bez zmian.
- SOKS Desktop 1.0.66 nie pokazuje KOM na ekranie Kasa i w eksporcie CSV kasy.

STARTY OBCE - typ broni (decyduje o licencji):
- Karabinek PCC liczy sie jako Karabin, takze przy nazwie "PCC PISTOLET, klasa ..." (dawniej Pistolet albo puste).
- Rewolwer liczy sie jako Pistolet (dawniej puste - start nie liczyl sie do zadnej dyscypliny).

== 1.5.26 (2026-10-20) - Praca na wielu stanowiskach: rejestr stanowisk i puls, dane wspolne szyfrowane po stronie Desktopa, jedna numeracja dokumentow, blokady edycji, wiadomosci i polecenia zdalne ==
<!-- ks-v14-stanowiska-2026-10-20 -->
<!-- ks-koszyk-zawody-2026-10-20 -->
<!-- ks-lokata-z-finalu-2026-10-20 -->

PO AKTUALIZACJI:
- Funkcja "Praca na wielu stanowiskach" jest WYLACZONA. Wlacza sie ja w SOKS -> Moduly, kategoria
  "Desktop". Dopoki jest wylaczona, wtyczka dziala dokladnie jak 1.5.25: nowe tabele nie powstaja,
  puls odpowiada "funkcja_wlaczona: false" i nic nie zapisuje.
- Osiem nowych tabel i cztery nowe kolumny zakladaja sie SAME, przy pierwszym wejsciu na strone po
  wlaczeniu funkcji. Nie ma zadnego SQL-a do wklejania do phpMyAdmina. Kazda migracja ma bramke na
  opcji z numerem wersji schematu, wiec powtorne wejscie na strone niczego nie powtarza.
- Zero tabel przy WYLACZONEJ funkcji jest odpowiedzia poprawna, nie usterka.
- Liczniki numeracji sa zasiewane raz, z dokumentow juz istniejacych - numeracja nie startuje od
  jedynki. Po aktualizacji sprawdz pierwszy wystawiony dokument kazdej serii.
- SOKS Desktop w wersji starszej niz 1.0.65 dziala jak dotad: nowych tras nie wola i nic o nich nie
  wie. Desktop 1.0.65 podlaczony do wtyczki starszej niz 1.5.26 dostaje 404 na pulsie, wylacza
  funkcje u siebie i dziala jak 1.0.64.
- Liczbe stanowisk klubu ustawia sie w panelu serwera licencji (pole "Limit aktywacji"), nie we
  wtyczce. Nowe licencje maja 1.

- Starty obce zapisane przed aktualizacja z lokata finalisty wzieta z kwalifikacji NIE sa poprawiane
  automatycznie (automat nie nadpisuje zapisanej lokaty). Jesli klub mial finalistow w zawodach
  z komunikatow PZSS, sprawdz ich lokaty recznie.

NOWE - rejestr stanowisk i puls obecnosci:
- Tabela stanowisk klubu: identyfikator stanowiska (UUID), nazwa, operator, wersja Desktopa, klucz
  publiczny, status (oczekuje / zatwierdzone / usuniete), czas ostatniego pulsu. Nazwa komputera,
  login Windows ani sciezki na dysku nie trafiaja na strone.
- Puls: jedna trasa, ktora oddaje komplet obrazu klubu - kto jest online, numer ostatniej zmiany,
  blokady innych stanowisk, polecenia dla pytajacego, koperty klucza klubu do odebrania, stanowiska
  czekajace na zatwierdzenie i stan rotacji klucza.
- Nowe stanowisko wchodzi ze statusem "oczekuje" i musi zostac zatwierdzone przez stanowisko juz
  dzialajace, po porownaniu 8-cyfrowego kodu zgodnosci na obu ekranach. Puls oddaje klucze publiczne
  stanowisk zatwierdzonych, bo bez nich kodu zgodnosci NIE DA SIE policzyc na ekranie stanowiska
  dolaczajacego - a wtedy ochrona przed podstawieniem klucza przez przejeta strone nie istnieje.
- Pierwsze stanowisko klubu dostaje status "zatwierdzone" tylko wtedy, gdy zatwierdzonych nie ma
  jeszcze ani jednego. Drugie zgloszenie z ta sama flaga laduje jako "oczekuje".
- Skrzynka zawinietych kluczy klubu: strona przechowuje koperty z kluczem klubu zaszyfrowane dla
  konkretnego stanowiska i NIE UMIE ich odwinac. Klucza klubu strona nie zna i nigdy nie dostaje.
- Trasa dostarczenia klucza stanowisku JUZ ZATWIERDZONEMU (rotacja po usunieciu stanowiska). Bez
  niej usuniecie jednego stanowiska odcinalo WSZYSTKIE pozostale od odczytu i zapisu danych
  wspolnych, przy komunikacie "stanowisko usuniete".

NOWE - magazyn danych szyfrowanych (faktury, bron, magazyn amunicji, wiadomosci, polecenia):
- Tabela rekordow szyfrowanych: strona przechowuje wylacznie szyfrogram i metadane. Jawne sa:
  nazwa encji, identyfikator rekordu, wersja, pokolenie klucza, rozmiar szyfrogramu, czas,
  stanowisko, numer dokumentu (konieczny do jednej numeracji) i nieodwracalny skrot numeru
  seryjnego broni. Tresc faktury, kontrahent, pozycje, kwoty, numery seryjne, dane wypozyczajacego
  (w tym PESEL i numer dowodu), stany i ruchy amunicji, tresc wiadomosci i parametry polecen -
  wylacznie w kopercie.
- Trzy klucze unikalne na tej tabeli: po rekordzie, po numerze dokumentu i po skrocie numeru
  seryjnego. Druga faktura o tym samym numerze i drugi egzemplarz broni o tym samym numerze
  seryjnym sa odrzucane przez baze, a nie przez sprawdzenie w kodzie.
- Bramka statusu stanowiska na trasach danych szyfrowanych i na odsylaniu wyniku polecenia: obsluga
  WYLACZNIE dla stanowiska o statusie "zatwierdzone". Status "oczekuje", "usuniete" i brak wiersza
  w rejestrze dostaja odmowe "stanowisko_niezatwierdzone" i ZERO rekordow. Dotad trasy pytaly tylko,
  czy naglowek stanowiska jest poprawnym UUID-em, wiec stanowisko usuniete z nadal waznym haslem
  aplikacji pobieralo cala tabele szyfrogramow i dalej zapisywalo wlasne rekordy. Pulsu bramka NIE
  obejmuje - brak siebie na liscie stanowisk jest jedynym sygnalem, po ktorym usuniete stanowisko
  poznaje, ze zostalo usuniete.
- Czego ta bramka nie daje, powiedziane wprost: kto ma dostep do bazy albo plikow hostingu, ustawi
  sobie status "zatwierdzone" jednym UPDATE-em. Przed tym chroni wylacznie kryptografia.
- Rotacja klucza klubu bez okna przejscia: od chwili rozpoczecia rotacji zapis starym pokoleniem
  klucza jest odrzucany z podaniem pokolenia docelowego. Rekord zapisany w oknie przejscia bylby
  czytelny takze dla stanowiska USUNIETEGO - czyli rotacja po kradziezy laptopa jeszcze przez
  kilkadziesiat sekund dopisywalaby dane czytelne dla zlodzieja. Zapis odrzucony nie ginie: czeka
  w kolejce na stanowisku i jedzie ponownie nowym kluczem.
- Usuniecie rekordu niesie wlasna koperte kryptograficzna (nagrobek). Dotad "usuniety" bylo jawna
  kolumna w bazie strony, wiec kto mial dostep do bazy albo plikow hostingu, kasowal dowolny rekord
  na WSZYSTKICH stanowiskach - a podbita wersja czynila utrate trwala. Dotyczylo to faktur
  i ewidencji broni, czyli dokumentow, ktore klub ma obowiazek przechowywac. Nagrobki zapisane
  przed ta wersja (bez koperty) NIE SA wykonywane po cichu - rekord zostaje.

NOWE - jedna numeracja dokumentow dla calego klubu:
- Tabela licznikow numeracji z rezerwacja atomowa (klucz glowny seria + okres). Dwa stanowiska
  wystawiajace dokument w tej samej sekundzie dostaja dwa rozne, kolejne numery.
- Numery nadaje strona takze panelowi kasy NA STRONIE - dotad kasa liczyla numer jako MAX()+1, wiec
  numer z kasy i numer z Desktopa mogly sie zderzyc.
- Serie i formaty: FV (reset miesieczny), FZ, FVK, FZK, KP, KW, PO, PW, BP, KOM, RK, WB (reset
  roczny, trzy cyfry) oraz dokumenty magazynowe PZ, WZ, RW, BO, REM, MM, INW (reset roczny, cztery
  cyfry). Symbol PW jest wspolny dla kasowego i magazynowego PW, wiec licznik jest jeden. MM i INW
  sa w rejestrze serii, ale Desktop 1.0.64 nie ma dla nich galezi tworzacej dokument.

NOWE - blokady edycji i koniec cichego nadpisywania:
- Tabela blokad z czasem zycia 120 sekund, odnawianym pulsem. Rekord zajety przez inne stanowisko
  oddaje odmowe z nazwa stanowiska, etykieta operatora i czasem wygasniecia - drugi operator widzi
  "Edytuje: <stanowisko> (<operator>)", zanim cokolwiek wpisze. Program ubity nie blokuje rekordu
  na zawsze: blokada wygasa sama.
- Kolumna "wersja" w czterech tabelach edytowanych z wielu stanowisk: transakcje kasowe, dokumenty
  finansowe, zobowiazania i kontrahenci. Zapis z niezgodna wersja dostaje odmowe z aktualnym
  rekordem, zamiast cicho nadpisac cudza prace. Zapis BEZ pola wersji (stary Desktop, panel WWW)
  zachowuje sie jak dotad.
- Odpowiedz na udany zapis (PUT) tych czterech encji niesie teraz aktualny numer wersji rekordu.
  Wczesniej uchwyty oddawaly samo potwierdzenie, wiec okno programu nie mialo skad wziac wersji
  podbitej przez wlasny zapis i DRUGI zapis z tego samego otwartego okna dostawalby odmowe 409
  przy rekordzie, ktorego nikt inny nie ruszyl. Pole doklada filtr odpowiedzi REST o priorytecie
  20 - wyzszym niz podbicie wersji w dzienniku zmian (priorytet 10), inaczej oddawana wartosc
  bylaby o jeden za mala. Filtr niczego nie waliduje i pomija GET, DELETE, encje spoza tej
  czworki, odpowiedzi bledne i baze bez kolumny "wersja". Stary Desktop dostaje jedno pole
  liczbowe wiecej i go nie czyta.

NOWE - dziennik zmian dla stanowisk:
- Tabela dziennika zmian: kazdy zapis przez REST I przez panel WWW dopisuje wiersz (encja, rekord,
  operacja, stanowisko, czas). Zmiana z panelu WWW ma puste pole stanowiska - to jest najszybszy
  odczyt kontrolny po aktualizacji.
- Zapisy panelu WWW sa lapane jednym centralnym punktem przechwytu, a nie wstawka w kazdym miejscu
  zapisu z osobna. Operacja spoza listy dozwolonych nie trafia do dziennika.
- Trasa pobierania zmian od podanego numeru porzadkowego, z informacja "kursor za stary".
  Retencja 90 dni (WP-Cron).

NOWE - wiadomosci i polecenia zdalne:
- Tabela polecen z biala lista czterech typow: synchronizuj_teraz, odswiez_widok, drukuj_dokument,
  pokaz_komunikat. Inny typ jest odrzucany. Polecenie wygasa po 10 minutach.
- drukuj_dokument WYMAGA potwierdzenia operatora na stanowisku docelowym. Brak odpowiedzi to
  ODMOWA, nie zgoda.
- Wiadomosci miedzy operatorami sa rekordami szyfrowanymi (encja "wiadomosc") - strona nie widzi
  ani tresci, ani adresata. Adresat jest wylacznie w kopercie, a filtrowanie jest obowiazkiem
  odbiorcy. Retencja wiadomosci i polecen: 90 dni (WP-Cron).

NOWE - klucz idempotencji przy tworzeniu zasobow:
- Tabela idempotencji: powtorzone zadanie utworzenia tego samego zasobu oddaje ten sam wynik zamiast
  drugiego rekordu. Rozwiazuje kolizje identyfikatorow przy kolejce offline Desktopa.
- Kolumna numeru KSeF z kluczem unikalnym w tabeli zobowiazan: ten sam numer KSeF wyslany z dwoch
  stanowisk daje jedno zobowiazanie.

NOWE - tabele, kolumny i opcje zakladane przy wlaczeniu funkcji (kolejnosc wykonania):
1. ks_stanowiska - rejestr stanowisk (opcja ks_stanowiska_db_version = 1.0.0).
2. ks_klucze_zawiniete - skrzynka kopert z kluczem klubu (ta sama opcja).
3. ks_dziennik_zmian - dziennik zmian (opcja ks_dziennik_zmian_db_version = 1.0.0).
4. Zadanie WP-Cron retencji dziennika, 90 dni (znacznik ks_dziennik_zmian_skasowano_do).
5. ks_e2e_rekordy - magazyn szyfrogramow, trzy klucze unikalne (opcja ks_e2e_db_version = 1.0.0).
6. ks_numeracja - liczniki numeracji, klucz glowny (seria, okres).
7. Zasiew licznikow numeracji z istniejacych dokumentow, jednorazowo (opcja ks_numeracja_zasiew = 1).
8. ks_blokady - blokady edycji, czas zycia 120 s (opcja ks_blokady_db_version = 1.0.0).
9. Kolumna "wersja" (int, NOT NULL, domyslnie 1) w tabelach: ks_kasa_transakcje,
   ks_dokumenty_finansowe, ks_zobowiazania, ks_kontrahenci. Tabele nieistniejacej migracja pomija,
   kolumny istniejacej nie dubluje.
10. ks_polecenia - polecenia zdalne, czas zycia 600 s (opcja ks_polecenia_db_version = 1.0.0).
11. Zadanie WP-Cron utrzymania polecen: wygasanie i retencja 90 dni.
12. ks_idempotencja - klucze idempotencji (opcja ks_idem_db_version = 1.0.0).
13. Kolumna rekord_uuid z kluczem unikalnym w tabelach: ks_kasa_transakcje, ks_zawody,
    ks_zawody_rejestracje, ks_kontrahenci, ks_zobowiazania. Tabeli nieistniejacej nie zaklada -
    klub moze miec wylaczony modul rozrachunkow albo zawodow.
14. Kolumna ksef_numer z kluczem unikalnym w tabeli ks_zobowiazania.
15. Wlacznik funkcji w opcji ks_features_state pod kluczem "stanowiska"; zapasowo opcja
    ks_stanowiska_wlaczone (0/1).

    Zadna z tych migracji nie kasuje ani nie przepisuje istniejacych danych - wszystkie tylko
    dokladaja. Jedyny zapis do danych zastanych to zasiew licznikow numeracji z punktu 7, ktory
    pisze wylacznie do nowej tabeli ks_numeracja.

ZMIANY - Kasa:
- Numery dokumentow kasowych na stronie pochodza z rejestru numeracji, nie z MAX()+1. Przy
  wylaczonej funkcji "Praca na wielu stanowiskach" kasa dziala jak w 1.5.25.

ZMIANY - REST dla SOKS Desktop:
- Nowe trasy stanowisk uzywaja tego samego uwierzytelnienia co dotad (haslo aplikacji WordPress
  plus naglowek SOKS) i tego samego sprawdzenia uprawnien. Doszedl jeden naglowek z identyfikatorem
  stanowiska.
- Odpowiedz 401 albo 403 na puls przy POPRAWNYM hasle aplikacji jest objawem zlej kolejnosci
  ladowania modulow w soks.php, nie zlego hasla.

POPRAWKI - zawody i starty obce:
- Rejestracja na zawody (konto czlonka i formularz goscia): oplata za konkurencje, ktorej produkt
  WooCommerce jest wspolny z zawodami juz rozegranymi, nie znika z koszyka przed kasa ("Twoj koszyk
  jest pusty"). Sprzatanie koszyka sprawdza zawody z danych rejestracji; bez nich (wejscie
  ?add-to-cart) produkt jest "po zawodach" dopiero, gdy minely wszystkie zawody, ktore go uzywaja.
- Odtwarzanie rejestracji z zamowienia bez danych rejestracji (przy platnosci i w narzedziach
  "odtworz rejestracje") wybiera zawody wedlug daty zamowienia, a nie najnowsza konkurencje z tym
  produktem.
- Starty obce z komunikatow PZSS: finalista dostaje lokate z finalu ("REZULTATY FINALU" / "FINAL
  RESULTS"), wynik zostaje z kwalifikacji, a lokata i wynik kwalifikacji ida do uwag - tak samo jak
  dla SCloud. Finaliste rozpoznajemy po numerze startowym z kontrola nazwiska; final tej samej
  konkurencji i dnia.
- Gdy finalu nie da sie przypisac pewnie (numer startowy z innym nazwiskiem, kilka miejsc,
  nieczytelna lista finalu), start jest zapisywany bez lokaty z uwaga "Lokata do sprawdzenia
  (final)"; zawodnik wysoko w kwalifikacji, ktorego nie ma na liscie finalu, zachowuje lokate
  z kwalifikacji z ta sama uwaga. Automat wypisuje takie starty w wyniku przebiegu.

POPRAWKI - z testu na kopii bazy klubu (przed wydaniem):
- Tabela skrzynki kluczy (ks_klucze_zawiniete) nie zakladala sie: kolumna "blob" to slowo
  zastrzezone MySQL/MariaDB (blad 1064), a wersja schematu i tak zapisywala sie jako "zrobione" -
  stanowiska nie dostalyby klucza klubu. Teraz nazwa kolumny w cudzyslowie wstecznym, a wersja
  schematu zapisuje sie dopiero, gdy obie tabele naprawde istnieja; inaczej wpis w dzienniku bledow
  z trescia bledu bazy i ponowienie przy nastepnym zadaniu.
- Klucz idempotencji, dopasowanie kontrahenta po NIP-ie i zobowiazania po numerze KSeF NIE dzialaly
  w prawdziwym WordPressie: zapisana odpowiedz oddawana z filtra rest_request_before_callbacks nie
  zatrzymuje uchwytu trasy (zatrzymuje go tylko blad). Powtorzone zadanie zakladalo drugi rekord,
  a kontrahent z istniejacym NIP-em konczyl sie bledem bazy. Zapora siedzi teraz na filtrze
  rest_dispatch_request (po sprawdzeniu uprawnien): powtorka oddaje to samo id.
- Rotacja klucza klubu konczy sie po przeszyfrowaniu danych. Licznik rekordow starym kluczem liczyl
  takze wiadomosci i polecenia, ktorych Desktop z zalozenia nie przeszyfrowuje (wiadomosc znika po
  90 dniach, polecenie po 10 minutach) - pasek "Trwa rotacja klucza klubu" wisial na wszystkich
  stanowiskach.
- Zarzadzanie zgloszeniami startow w innych klubach: przyciski Oczekujace / Zatwierdzone /
  Odrzucone / Wszystkie mialy linki wzgledne, ktore gubily reszte adresu strony - przy adresie
  z ?page_id= albo ?p= klikniecie prowadzilo na strone glowna. Teraz zachowuja adres strony i nie
  powtarzaja akcji zatwierdz/odrzuc z adresu.

CZEGO TA WERSJA NIE ZAWIERA - powiedziane wprost:
- Migracje i trasy sprawdzone na KOPII bazy klubu (MariaDB 10.4, WordPress 7.1), nie na bazie
  produkcyjnej: 8/8 tabel, wszystkie kolumny i opcje wersji, dziennik zmian z panelu WWW, numeracja
  atomowa. Do tego trzy stanowiska SOKS Desktop 1.0.65 na tej kopii: dolaczanie z kodem zgodnosci,
  wspolna faktura, ewidencja broni i magazyn amunicji, blokady i ekran konfliktu w czterech oknach
  edycji, wiadomosci, polecenia, usuniecie stanowiska z rotacja klucza. Na serwerze leza wylacznie
  szyfrogramy (sprawdzone wyszukiwaniem tresci testowej we wszystkich tabelach stanowisk).
- Dane, ktore juz dzis leza na stronie (czlonkowie, kasa, zobowiazania, kontrahenci, zawody), NIE
  sa szyfrowane i nie beda - korzysta z nich sama strona. Szyfrowanie obejmuje wylacznie nowe dane
  wspolne: faktury sprzedazy, ewidencje broni, magazyn amunicji, wiadomosci i parametry polecen.

== 1.5.25 (2026-10-16) - Skladki miesieczne mlodziezy (status, Kasa, WooCommerce, zestawienie), klasy sportowe - normy PZSS, protokol wynikow a rejestracje ==
<!-- ks-v11z-wydanie-2026-10-14 -->
<!-- ks-data-startu-2026-10-14 -->
<!-- ks-v13a-dopiski-1525-2026-10-16 -->

PO AKTUALIZACJI:
- Baza dziennika przechodzi na schemat 1.5.22 (nowe kolumny wplat, tabele historii statusu, stawek i
  rezerwacji) sama, przy pierwszym zadaniu po aktualizacji; brak ktoregos z nowych plikow skladek nie
  zatrzymuje strony (skladki dzialaja wtedy po staremu). Modul klas sportowych ma wlasna wersje schematu.
- Skladka miesieczna mlodziezy jest nalezna za kazdy miesiac ze statusem "aktywny", od miesiaca startu
  naliczania w ustawieniach klubu - bez wzgledu na obecnosci w dzienniku. Przy stawce wiekszej od zera
  nieoplacony miesiac aktywnego zawodnika staje sie zalegly w nastepnym miesiacu i - jak od 1.5.23 - blokuje
  wniosek o przedluzenie licencji. Po aktualizacji sprawdz start naliczania, stawke, zwolnienia i daty
  odejscia w kartotekach.
- Platnosc skladek miesiecznych przez WooCommerce wymaga produktu skladki miesiecznej: SOKS -> Formularze ->
  Skladki (wybor ID albo "Utworz produkt"); bez niego formularz "Zaplac" sie nie pokazuje.
- Produkt skladki poczatkowej mozna wskazac w SOKS -> Konfiguracja skladek ("Produkt skladki poczatkowej");
  bez ustawienia produkt jest rozpoznawany po nazwie, jak w 1.5.22.
- Klasy sportowe: starty sprzed instalacji nie daja zapisow norm, dopoki administrator nie zatwierdzi
  przegladu wstecz (SOKS -> Klasy sportowe -> Przeglad wstecz: najpierw raport na sucho, zapis dopiero po
  zatwierdzeniu).
- Raz po aktualizacji dokumenty PO i KP z SOKS Desktop sprzed 1.5.25 sa dowiazywane do istniejacych zamowien
  WooCommerce po numerach zamowien z ich tresci - bez zmiany statusow zamowien i bez nowych dokumentow.
- Raz po aktualizacji modul startow obcych sprawdza zapisane starty z data spoza zakresu (rok przed 2000 albo
  dalej niz przyszly rok): numery wpisow trafiaja do dziennika modulu, administrator dostaje komunikat.

NOWE - skladki miesieczne mlodziezy (naliczanie i dane):
- Skladka nalezy sie za kazdy miesiac, w ktorym zawodnik ma status "aktywny" (od miesiaca startu naliczania w
  ustawieniach klubu); obecnosc na zajeciach nie ma juz znaczenia. Nowe statusy miesiaca: "Nieaktywny", "Przed
  startem" (dotad "Nie dotyczy") i "Bez oplaty" (stawka miesiaca 0 - nic do zaplaty, miesiac nie jest zalegly
  i nie blokuje licencji).
- Data odejscia w kartotece konczy naliczanie: od miesiaca odejscia skladka nie jest nalezna (przy
  aktualizacji, przy zapisie kartoteki i przy nowej kartotece).
- Zawodnik sekcji mlodziezowej ustawiony jako "Nieaktywny" nie placi zadnej skladki - ani miesiecznej, ani
  rocznej - dopoki jest nieaktywny, i zostaje w sekcji mlodziezowej; po aktywacji wraca do skladek
  miesiecznych.
- Historia statusu aktywny/nieaktywny zawodnika od miesiaca: zmiana od biezacego albo nastepnego miesiaca (bez
  dzialania wstecz), zmiana zaplanowana stosowana automatycznie, miesiace minione poprawia administrator
  (korekta z powodem). Kartoteka (aktywny/zawieszony) pokazuje stan biezacego miesiaca. Status zmieniaja
  trener zawodnika, administrator i Zarzad (Zarzad konczy tez zwolnienia); kazda zmiana trafia do dziennika
  (kto, kiedy, co).
- Historia stawek klubu od miesiaca i stala stawka indywidualna zawodnika (np. rodzenstwo); zaleglosci licza
  stawke obowiazujaca w danym miesiacu. Stawki ustala administrator i Zarzad, trener ma podglad. Stawka w
  ustawieniach skladek jest stawka biezaca (warunek licencji bez zmian).
- Wplaty skladek miesiecznych moga pochodzic z dokumentu kasy (KP/PO/BP) albo zamowienia WooCommerce (wiazanie
  po identyfikatorze dokumentu, kontrola daty jego utworzenia). Wpis reczny trenera nie nadpisuje miesiaca
  oplaconego dokumentem ani zamowieniem, nie wchodzi w miesiac czekajacego zamowienia; przedplata najdalej do
  grudnia biezacego roku; wpisy reczne mozna wylaczyc ustawieniem klubu.
- Konta mlodziezy bez kartoteki dziennika: automatyczne powiazanie z kartoteka (ta sama data urodzenia, imie i
  nazwisko) albo zalozenie minimalnej kartoteki (imie, nazwisko, data urodzenia, plec; bez PESEL, adresu i
  telefonu); imiennik z inna data urodzenia i inne przypadki niejednoznaczne - do decyzji administratora, bez
  drugiej kartoteki tej samej osoby (raz po aktualizacji i codziennie; tryb "raport" w ustawieniach).
  Kartoteka zalozona z konta nalicza od biezacego miesiaca, nie wstecz od rejestracji konta.
- Dane do zestawienia skladek: rok I-XII z kolumna "wczesniej", zawodnicy z wplatami w roku takze po usunieciu
  kartoteki, filtr grupy wg przynaleznosci w danym roku, rejestr wplat wg daty dokumentu, uzgodnienie
  (dokumenty usuniete poza Kasa, przekroczenia kwoty dokumentu, oplacone mimo zwolnienia lub nieaktywnosci,
  dokumenty "skladka miesieczna" bez miesiecy). Odczyt imienny Zarzadu zapisywany w dzienniku RODO.
- Anonimizacja kartoteki: wpisy reczne skladek usuwane; wplaty z dokumentow kasy i zamowien zostaja bez uwag;
  rezerwacje, stawki indywidualne i historia statusu usuwane; automat kont nie zaklada ponownie kartoteki dla
  tego konta.

NOWE - skladki miesieczne w Kasie (panel kasy na stronie i SOKS Desktop):
- Sekcja "Skladki miesieczne" w formularzu KP/PO (nowy dokument) i KP/PO/BP (edycja): do 10 zawodnikow na
  dokumencie (rodzenstwo), 12 miesiecy roku ze stanem z kartoteki, kwota domyslnie = stawka miesiaca
  (indywidualna albo klubu), "zaznacz zalegle" takze z lat wczesniejszych, propozycja tytulu z imieniem i
  nazwiskiem dziecka i kontrola 255 znakow. Gdy stan ktoregos zawodnika sie nie wczytal, miesiace nie ida z
  dokumentem bez wyraznego potwierdzenia.
- Platnik = opiekun dziecka (domyslnie opiekun z kontaktem priorytetowym; z kontem - konto czlonka, bez konta
  - wpisany wplacajacy); PO mozna wystawic na wplacajacego bez konta WP.
- Plan skladek liczony przed zapisem dokumentu - brak stawki, suma miesiecy i skladki rocznej wieksza od
  kwoty, ulga bez potwierdzenia albo tytul ponad 255 znakow = dokument nie powstaje (komunikat zamiast "Blad
  SQL").
- Miesiac zwolniony, nieaktywny, ze stawka 0, zarezerwowany zamowieniem WooCommerce, oplacony innym
  dokumentem, przed startem naliczania albo po grudniu biezacego roku jest pomijany z komunikatem; przejecie
  wpisu recznego przez dokument tylko po potwierdzeniu.
- Zmiana dokumentu (panel, stary ekran, REST) przenosi date na oplacone miesiace, zmiana na wyplyw odpina
  miesiace, kwota nizsza od sumy miesiecy daje ostrzezenie; usuniecie dokumentu odpina miesiace i przywraca
  wpis reczny sprzed przejecia - takze przy wylaczonym module Dziennik.
- Dokument BP (SOKS Desktop) edytowalny w panelu; znacznik "mies." przy dokumentach z oplaconymi miesiacami;
  odnosnik do zestawienia skladek; formularz otwierany z zestawienia z zawodnikiem i zaznaczonymi miesiacami.
- Tytul dokumentu ze skladkami miesiecznymi (imie i nazwisko dziecka) widza w pelni tylko osoby z dostepem do
  tego zawodnika (trener jego grupy, Zarzad, administrator); inni z dostepem do Kasy widza tytul zamaskowany -
  na listach dokumentow, w raportach, podgladzie PDF i druku masowym (formularz edycji dokumentu bez maski).
- Adres "Wystaw KP/PO" skladki rocznej wybiera czlonka takze bez modulu Dziennik.
- REST: nowe trasy GET /skladki-miesieczne/zawodnicy, /stan, /rok i POST /skladki-miesieczne/plan (bez
  zapisu); POST i PUT /financial-docs przyjmuja klucz skladki_miesieczne (blad = 422 i nic nie zapisane), PUT
  przenosi do wiersza kasy tylko pola zmienione w SOKS Desktop (zmiany z panelu zostaja) i synchronizuje
  miesiace, DELETE /financial-docs?usun_z_kasy=1 usuwa tez wiersz kasy i cofa miesiace oraz lata. GET /stan
  niczego nie zapisuje.
- Baza: kolumna ks_dokumenty_finansowe.kasa_transakcja_id - dokument SOKS Desktop powiazany z wierszem kasy po
  ID (uzupelnienie istniejacych po numerze tylko w migracji; poza nia wiersz kasy nie jest szukany po
  numerze).
- REST POST /transactions (Rozrachunki SOKS Desktop): dokument rozliczajacy zamowienia WooCommerce dostaje
  numer zamowienia (wc_order_id, dla PO dowiazania zamowien z tytulu i opisu) - bez zmiany statusu zamowien.
- Skladka roczna mlodziezy klubowej nie jest oznaczana z synchronizacji i PUT /members starszego Desktopu, z
  wykrywania produktu w Kasie, z "Oznacz jako skladke", z "Dodaj KP" w starym ekranie kasy ani z wplaty
  recznej w panelu (licznik i ostrzezenie w odpowiedzi).
- Skladka roczna: kwota dokumentu dla lat pomniejszona o skladki miesieczne z tego samego dokumentu (wspolny
  limit).

NOWE - skladki miesieczne przez WooCommerce (Moje konto -> Wplaty):
- Zawodnik z kartoteka sekcji placi wybrane miesiace jednym zamowieniem: zalegle (takze z lat poprzednich),
  biezacy i z gory najwyzej do grudnia biezacego roku. Kwote liczy serwer ze stawki miesiaca (takze stalej
  stawki indywidualnej) i zamraza ja w zamowieniu. Miesiace bez stawki, zwolnione, nieaktywne i sprzed startu
  naliczania nie sa oferowane. Platnosc od razu na stronie zamowienia (bez koszyka), metody platnosci =
  wlaczone w sklepie.
- Produkt skladki miesiecznej: SOKS -> Formularze -> Skladki (wybor ID albo "Utworz produkt"). Walidacja:
  prosty, wirtualny, bez roku i bez slow skladki poczatkowej w nazwie, inny niz produkty skladki rocznej;
  ostrzezenie, gdy widoczny w katalogu. Produkt nie trafia do koszyka.
- Blokada podwojnej platnosci: miesiace zamowienia sa zarezerwowane do zaplaty albo anulowania (takze w stanie
  "Nieudane", ktore WooCommerce nadal pozwala oplacic - panel pokazuje "Dokoncz platnosc" i "Anuluj
  zamowienie"); drugie zamowienie na te same miesiace jest odrzucane z numerem pierwszego.
- Miesiace oznaczane jako oplacone dopiero po potwierdzeniu platnosci: bramka online z potwierdzeniem
  operatora (takze wlasny status "oplacone" bramki, np. Przelewy24 "processing-p24"; klub moze dopisac status
  filtrem ks_wc_statusy_oplacone), "W kasie Klubu" i przelew tradycyjny po KP/PO albo recznym "Zrealizowane".
  Miesiac w miedzyczasie oplacony, zwolniony albo nieaktywny nie jest ksiegowany - notatka w zamowieniu
  "nadplata za MM.RRRR - do rozliczenia przez zarzad".
- Dokument kasy zamowienia powstaje RAZ: bramka online - PO przy rozliczeniu "Naleznosci od operatorow" (bez
  KP); "W kasie Klubu" - KP z automatu po "Zrealizowane", gdy zamowienia nie pokrywa jeszcze KP kasjera;
  przelew tradycyjny oznaczony recznie "Zrealizowane" - bez dokumentu z automatu, PO przy rozliczeniu przelewu
  z wyciagu (jak dotad). Dokument zamowienia nie oznacza miesiecy drugi raz.
- Anulowanie, pelny zwrot i nieudana platnosc cofaja miesiace i rezerwacje; zwrot czesciowy - tylko notatka.
  Kosz i trwale usuniecie OPLACONEGO zamowienia nie cofaja miesiecy (cofa tylko anulowanie i zwrot); kosz
  nieoplaconego zwalnia rezerwacje. Oplacone, potem "Nieudane": miesiace cofniete i zarezerwowane od nowa.
  Nieoplacone ("Oczekujace na platnosc" i "Nieudane") zamowienia skladek miesiecznych sa anulowane po N dniach
  (domyslnie 7, ustawienie); "Wstrzymane" i "W trakcie realizacji" nigdy.
- Zamowienie z dokumentem wplywu w kasie (KP/PO) anulowane albo zwrocone, a z KP (z automatu, z Rozrachunkow
  albo z formularza Kasy) takze przeniesione do kosza albo usuniete: dokument ZOSTAJE (dowod przyjecia
  gotowki, bez automatycznego KW); notatka w zamowieniu z numerem dokumentu (przywroc "Zrealizowane" albo
  wystaw KW), przy KP takze komunikat w panelu klubu i w wp-admin: jesli zwracasz pieniadze, wystaw KW. Taki
  dokument mozna nadal powiazac w Kasie z miesiacami.
- Panel opiekuna: konto opiekuna powiazane w dzienniku z dziecmi widzi w "Wplatach" skladki miesieczne dzieci
  (rok, numery dokumentow i zamowien) - tylko podglad.
- Administrator na cudzym koncie: zamiast "Oplac" i formularza koszyka (produkt trafial do koszyka
  administratora) przyciski "Wystaw KP/PO" i "Wyslij link do zaplaty" - zamowienie na koncie czlonka i e-mail
  sklepu z linkiem platnosci (konto bez e-maila: link do skopiowania). Dla skladki rocznej i miesiecznej. Przy
  zamowieniu skladki rocznej czekajacym na wplate "W kasie Klubu" - ten sam link zamiast drugiego zamowienia
  (albo odmowa z numerem zamowienia, gdy nie da sie go oplacic online).

NOWE - Strefa trenera, kartoteka i panel czlonka (skladki miesieczne):
- Strefa trenera: nowa zakladka "Skladki" (trener i instruktor - swoje grupy, Zarzad i administrator - cala
  sekcja): zestawienie skladek miesiecznych za rok kalendarzowy I-XII z wyborem roku i kolumna "wczesniej"
  (zaleglosci z lat poprzednich), filtry grupy (przynaleznosc w roku) i statusu, wyszukiwanie po nazwisku,
  sumy na zawodnika i miesiac, wydruk i eksport CSV.
- Lista zawodnikow i karty grupy: przycisk Aktywny/Nieaktywny - zmiana od biezacego albo od nastepnego
  miesiaca (miesiac z etykiety formularza; po przelomie miesiaca zapis jest odrzucany), bez dzialania wstecz;
  zaplanowana zmiane mozna cofnac.
- Zarzad: dostep do ekranow skladek miesiecznych (tylko dane skladkowe, bez uwag wpisow recznych; kazdy odczyt
  imienny w dzienniku RODO); pozostale ekrany kartoteki nadal zamkniete.
- Karta skladek zawodnika: wplaty ze zrodlem (dokument kasy, zamowienie WooCommerce, wpis reczny "bez
  dokumentu"), wiersze z dokumentem tylko do odczytu, stala stawka indywidualna od miesiaca (Zarzad i
  administrator), historia statusu z korekta administratora, zmiana statusu i konczenie zwolnien takze przez
  Zarzad.
- Ustawienia skladek: historia stawki klubu "od miesiaca" z informacja o zmienianych zaleglosciach, start
  naliczania i waluta; opcje administratora: wpisy reczne trenerow, tryb kont mlodziezy bez kartoteki.
- Rejestr wplat po dacie dokumentu (ekran, wydruk, CSV) i uzgodnienie: sieroty z przyciskiem "Cofnij",
  przekroczenia kwoty dokumentu, oplacone mimo zwolnienia albo nieaktywnosci, dokumenty bez miesiecy, konta
  mlodziezy bez kartoteki z "Wykonaj zaznaczone".
- Kartoteka zawodnika: informacja "Skladka naliczana od..." i pole "Konto czlonka" z wyszukiwarka kont (jedno
  konto = jedna kartoteka).
- Opiekun: wyszukiwarka kont zamiast listy 500 kont (same cyfry = dokladny numer ID konta; rocznik tylko przy
  imiennikach); zapis formularza nie kasuje juz powiazania z kontem spoza listy.
- Panel czlonka: skladki miesieczne jako kafel roku ze statusem "nieaktywny" i numerami dokumentow; na cudzym
  koncie skladki dziecka widac tylko w zakresie dostepu ogladajacego (trener - swoje grupy; odczyt Zarzadu w
  dzienniku RODO).

NOWE - klasy sportowe: normy PZSS z wynikow:
- Nowy modul (ladowany zawsze): wtyczka wykrywa normy klas sportowych PZSS z wynikow - starty obce (konta
  doroslych i mlodziez), wpisy trenerow, most zawodow klubowych i wyniki kont z protokolow zawodow klubu.
  Zapisuje "norma uzyskana" (wynik, prog, zestaw norm, zawody, dowod; wazna do 31.12 nastepnego roku); stan
  "nadana" ustawia tylko czlowiek ("Potwierdz nadanie": data i numer decyzji WZSS/PZSS). Klub sam nie nadaje
  zadnej klasy.
- Zestawy norm PZSS 2025 (wersja z 14.03.2025) i 2026 w paczce jako dane (adres PDF zrodla i sha256),
  sprawdzane walidatorem; administrator moze importowac kolejne zestawy, takze poprawiona w trakcie roku
  wersje norm dla roku z zestawem w paczce (import z "zastepuje"). Wyniki z roku bez zestawu norm nie sa
  oceniane.
- Nazwy konkurencji przypisuje do tabeli norm tylko administrator (alias z rodzajem broni, takze "bez normy";
  nazwy konkurencji niestandardowych - np. Pst 2x30 open, MIX, KSP 40 - z ostrzezeniem zamiast podpowiedzi
  normy); zawody uprawniaja do norm dopiero po decyzji administratora (M/I: mistrzostwa Polski, Puchar Polski,
  zawody PZSS z Delegatem Technicznym; II/III: trzy warunki WZSS) - domyslnie nie uprawniaja.
- Mistrzostwa Polski i Puchar Polski z kalendarza PZSS daja "norme uzyskana" automatycznie, z kontrola: MP z
  mniej niz 4 sklasyfikowanymi albo niejednoznaczna nazwa lub kategoria - "do sprawdzenia". Nadanie dalej
  potwierdza czlowiek. Kategoria kalendarza jest znana takze dla zawodow juz zakonczonych (listy zawodow
  minionych PZSS), wiec wynik MP i PP z komunikatu opublikowanego po zawodach nie trafia z tego powodu do "do
  sprawdzenia".
- Wynik rowny progowi daje norme. Wynik dziesietny przy normie w pelnych pierscieniach (i odwrotnie, takze
  ponizej progu), niejednoznaczny wiersz normy, brak plci, dowod inny niz komunikat wynikow PZSS - "do
  sprawdzenia" zamiast cichej decyzji; "Akceptuj" nie omija braku dowodu (dowod spoza PZSS uznaje
  administrator). Duble (wpis trenera i start obcy z tym samym wynikiem) liczone raz; wiersz odrzucony nie
  jest wiodacy.
- Dwa progi tej samej konkurencji w normach PZSS (wiersz z przecinkiem i bez przecinka - w normach 2025: 40
  strzalow lezac open oraz Kpn 40 open): wiersz z przecinkiem obowiazuje wynik liczony dziesietnie, wiersz bez
  przecinka - wynik w pelnych pierscieniach; rodzaj oceny bierze sie z komunikatu albo z korekty przy normie.
  Rodzaj oceny nieznany - jak dotad "do sprawdzenia" (wybor wiersza albo rodzaju oceny).
- Komunikat bez kategorii (klasyfikacji) zawodnika: kategoria liczona rocznikowo z daty urodzenia (kartoteka,
  bez niej konto; jak w dzienniku) wybiera wiersz norm - mlodziczki/mlodzicy wiersz swojej kategorii (np. Psp
  30+30, Kpn 40), pozostali wiersz ogolny albo open. Bez daty urodzenia - "do sprawdzenia". Klasyfikacja z
  komunikatu i korekta przy normie maja pierwszenstwo; szczegoly normy podaja, skad kategoria (komunikat,
  rocznik, korekta).
- Dyskwalifikacja, DNS i DNF (panel i REST SOKS Desktop) od razu poprawiaja ocene normy z tego wyniku.
- Przeglad wstecz: najpierw raport na sucho, zapis dopiero po zatwierdzeniu; raport traci waznosc po zmianie
  aliasow, decyzji o zawodach albo zestawow norm; zadanie cron co godzine wychwytuje zapisy z SOKS Desktop i
  usuniete starty. Na przeglad czekaja tylko starty z dniem zawodow przed wdrozeniem; wynik zawodow z dnia
  wdrozenia albo pozniejszych jest oceniany od razu, takze gdy rejestracja powstala wczesniej.
- Rejestr klas kartoteki mlodziezy: nowe kolumny data normy, id normy, zawody normy; posiadanie klasy liczone
  od dnia uzyskania normy (Zbior Regulaminow PZSS, rozdz. 6), wpisy bez daty normy - jak dotad.
- Konta doroslych: nadane klasy w osobnym rejestrze (nadanie przy koncie bez kartoteki potwierdza tylko
  administrator); automatyczne scalenie, gdy konto zostanie powiazane z kartoteka mlodziezy (recznie, gdy
  konto ma juz nadania). Nadania z rejestru kartoteki widoczne od razu w REST, na koncie i we wskazniku "Norma
  min. III".
- Anonimizacja kartoteki zamraza normy i czysci dane identyfikujace w tej samej transakcji, takze w raporcie
  przegladu wstecz; usuniecie konta WordPress usuwa jego normy.
- Starty obce: zapisywane sa doslowna nazwa zawodow i tekst wyniku z komunikatu, numer kalendarza PZSS i
  kategoria (nowe kolumny, wypelniane tylko gdy puste); kategorie kalendarza PZSS raz na 7 dni z biezacej
  listy i z list zawodow minionych (rok biezacy i poprzedni), mapa laczona - numer zawodow znika dopiero po 2
  kolejnych pelnych odswiezeniach bez niego; blad sieci albo strona zaporowa niczego nie usuwa (limit czasu
  pobrania i przebiegu, reszta list w nastepnym przebiegu).

NOWE - klasy sportowe: ekrany, wydruki, REST:
- Panel administratora SOKS -> Klasy sportowe z zakladkami Normy PZSS, Nazwy konkurencji, Zawody, Do
  sprawdzenia, Do zgloszenia, Przeglad wstecz.
- Normy PZSS: lista zestawow norm (2025 v2 i 2026) z adresem PDF PZSS i sha256, podglad progow M/I/II/III,
  import nowego zestawu JSON zawsze najpierw na sucho, ostrzezenie o starej tablicy progow dziennika.
- Nazwy konkurencji: przypisanie nazw z komunikatow i dziennika do konkurencji z tabeli norm albo oznaczenie
  "to nie jest konkurencja z tabeli norm" (np. PSP 30 - czesc szybka).
- Zawody: decyzja administratora, czy zawody uprawniaja do norm (M, I, II, III albo tylko II i III po trzech
  warunkach WZSS), podpowiedzi z nazwy i kategorii kalendarza PZSS, ranga wpisana przy startach, konflikty
  rangi, decyzja zbiorcza; oznaczenie zawodow ocenianych automatycznie (MP, PP).
- Strefa trenera: nowy ekran "Klasy sportowe" (Do sprawdzenia, Do zgloszenia) tylko dla zawodnikow trenera;
  Zarzad nadal bez dostepu.
- Kartoteka zawodnika: sekcja "Normy uzyskane (wykryte z wynikow)" z przyciskami Potwierdz nadanie, Odrzuc,
  Odrzuc ten dubel, Popraw, Dodaj adres komunikatu; informacja "pokryta" nadaniem w tej samej konkurencji;
  wskaznik "Norma min. III" (wariant FOOM - edycja roku).
- Potwierdz nadanie (pojedynczo i zbiorczo): najpierw sprawdzenie na sucho z dniem normy i terminem waznosci;
  normy kolidujace w rejestrze klas laczone w jeden wpis (dzien normy - najwczesniejszy, waznosc - od
  najpozniejszej); norm po terminie waznosci nie da sie potwierdzic (osobny blok "Normy po terminie waznosci",
  poza "Do zgloszenia" i wydrukiem). Norma nie jest klasa - klase nadaje WZSS (II, III) albo PZSS (I, M, MM).
- Reczny wpis klasy w kartotece: nowe pole "Konkurencja z tabeli norm PZSS"; zapisywany dzien uzyskania normy.
- Lista wynikow zawodow w dzienniku: stan normy przy wyniku zamiast starej podpowiedzi klasy.
- Konto czlonka: zakladka "Klasy sportowe" (nadane klasy, normy, Norma min. III) tylko do odczytu;
  administrator na cudzym koncie potwierdza nadania i wpisuje nadania reczne doroslych.
- Wydruki i CSV: zestawienie dla WZSS (klasy II i III) i wniosek o zaswiadczenie do PZSS (I, M, MM) z polami
  ze Zbioru Regulaminow PZSS, bez PESEL, adresu i telefonu.
- REST: pole tylko do odczytu meta.klasy_sportowe (wersja 1) dla SOKS Desktop 1.0.63; zmiana klas konta
  oznacza czlonka do synchronizacji; pole odeslane przez Desktop nie jest zapisywane.

ZMIANY - zawody, protokol wynikow a rejestracje (konta z rejestracja; goscie bez konta bez zmian):
- Usuniecie albo anulowanie rejestracji (takze zmiana konkurencji lub rundy rejestracji, usuniecie karnetu,
  kosz albo usuniecie zamowienia WooCommerce, PUT i DELETE /registrations z SOKS Desktop) usuwa wiersz
  zawodnika z recznego protokolu tej konkurencji i rundy; miejsca pozostalych sa przeliczane, pusty protokol
  jest usuwany. Potwierdzenia "Anuluj" i "Usun" mowia, ze wynik w protokole recznym zniknie (przywrocenie
  rejestracji go nie przywroci), potwierdzenie usuniecia karnetu podaje liczbe protokolow recznych z wynikami,
  a komunikat po usunieciu z protokolu podaje wynik do ponownego wpisania. Dotad wiersz zostawal w protokole,
  w komunikacie PDF i w klasyfikacji cyklu (ekran wynikow tylko ostrzegal "Do weryfikacji"), a ponowny zapis
  formularza "Wpisz wyniki recznie" usuwal go po cichu.
- Start nieoplacony (rejestracja nieanulowana, bez daty oplaty i nie bezplatna): wynik nie jest kasowany,
  tylko wstrzymany (nowa tabela ks_zawody_wyniki_wstrzymane) - nie trafia do komunikatu PDF, klasyfikacji,
  rankingow, panelu zawodnika, historii startow, dziennika ani synchronizacji z Desktopem; miejsca pozostalych
  sa przeliczane (protokol reczny, importowany i IPSC). Po oplaceniu (panel, platnosc zbiorcza
  KP/PO/kontrahent, WooCommerce) albo przy oplacie 0 zl wynik wraca z tym samym ID. Ekran wynikow: "Do
  weryfikacji - nieoplacony start" z przyciskiem "Usun z protokolu"; formularz reczny oznacza wiersz takiego
  konta znakiem pauzy.
- DSQ, DNS, DNF i "Cofnij status" (ekran wynikow w kazdym zakresie i REST POST /results/dsq) zmieniaja tez
  wynik wstrzymany: DNF zostawia wpisany wynik, DSQ i DNS go zeruja; po oplaceniu wynik wraca z tym statusem.
  Odpowiedz REST ma pole "wstrzymane".
- Protokol importowany: DSQ i DNS zawsze bez miejsca przy przenumerowaniu; poprawka miejsca z SOKS Desktop
  (PUT /results/row) przebudowuje mape miejsc. Przywracanie i wstrzymywanie wyniku pod blokada bazy -
  rownolegle zadania nie dubluja wiersza.
- Przepisanie rejestracji na inne konto (ta sama konkurencja i runda) przenosi wynik na nowe konto; gdy nowe
  konto ma juz wlasny wiersz, zostaje on, a wiersz poprzedniego konta jest usuwany (komunikat).
- Protokoly importowane i IPSC: bez automatycznego usuwania; przycisk "Usun z protokolu" przy kazdej osobie
  spoza listy rejestracji.
- REST PUT /registrations/{id} przyjmuje paid_at (SOKS Desktop wysyla je przy wplacie); status "paid" bez daty
  oplaty dostaje date biezaca.

ZMIANY - Kasa:
- Lista produktow w formularzu dokumentu pokazuje tylko skladki (roczne, poczatkowa, miesieczna mlodziezy);
  oplaty startowe i inne produkty rozlicza sie przez rejestracje i rozrachunki. Produkt dokumentu = pierwsza
  pozycja z produktem skladki (po zmianie lub usunieciu pozycji przeliczany, takze przy zapisie). Edycja
  dokumentu nie zmienia zapisanego produktu.

POPRAWKI - skladka roczna i naleznosc przez WooCommerce:
- Skladka roczna oznaczana dopiero po potwierdzeniu platnosci: zamowienie z bramki online zamkniete recznie
  bez potwierdzenia nie oznacza juz roku (dotad oznaczalo, mimo cofniecia zamowienia do "Oczekujace na
  platnosc"). Wlasny status "oplacone" bramki (np. Przelewy24 "processing-p24") jest uznawany, a takie
  zamowienie przechodzi w "Zrealizowane" i trafia do "Naleznosci od operatorow".
- Anulowanie, pelny zwrot i nieudana platnosc cofaja skladke roczna do stanu sprzed zamowienia (rok oplacony
  takze dokumentem KP zostaje oplacony); wpis w historii wplat dostaje status anulowane/zwrocone. Dotad po
  zwrocie rok zostawal "oplacona". Kosz i trwale usuniecie oplaconego zamowienia nie cofaja roku.
- Skladka roczna oplacona juz innym zamowieniem albo dokumentem kasy: druga wplata zostawia w zamowieniu
  notatke "nadplata - do rozliczenia przez zarzad"; status roku bez zmian.
- Konto mlodziezy (skladki miesieczne) nie dostaje skladki rocznej z zamowienia - notatka w zamowieniu.
- Pozycja skladki miesiecznej nie jest dopasowywana do roku skladki rocznej (takze po nazwie produktu ze
  slowem "Skladka").
- Skladka roczna "W kasie Klubu" oznaczona recznie "Zrealizowane": KP nie powstaje z automatu - skarbnik
  wystawia KP recznie w Kasie (jak dotad).
- Naleznosc skladkowa przez WooCommerce ksiegowana dopiero po potwierdzeniu platnosci. KP z automatu tylko "W
  kasie Klubu" i dopiero po "Zrealizowane" (dotad KP powstawal przy zlozeniu zamowienia, przed wplata
  gotowki), gdy zamowienia nie pokrywa juz KP/PO kasjera (wtedy wplata dostaje numer tego dokumentu); bramka
  online - bez KP, numer PO z rozliczenia "Naleznosci od operatorow" dopisywany do wplaty; przelew tradycyjny
  i czek - bez KP z automatu.

POPRAWKI - Kasa:
- KP/PO z produktem "Skladka poczatkowa" znow oznacza skladke roczna (regresja 1.5.23): rok wg daty dokumentu
  (do 31.10 ten rok, od 1.11 nastepny), kwota roku = cena skladki rocznej, reszta dokumentu opisana jako
  wpisowe; ta sama regula w REST /financial-docs (wc_product_id bez lata_skladki) i w zamowieniach WooCommerce
  (listopad-grudzien = rok nastepny); nowe ustawienie "Produkt skladki poczatkowej" (SOKS -> Konfiguracja
  skladek).
- "Naleznosci od operatorow": lista bierze tylko zamowienia, bez zwrotow WooCommerce (zwrot nie ma metody
  platnosci - zakladka konczyla sie bledem krytycznym).
- Stary ekran kasy: ostrzezenie PHP "Undefined property typ_wplaty" przy kazdej zmianie dokumentu.

POPRAWKI - Dziennik:
- Pierwsze wlaczenie modulu Dziennik (np. swieza instalacja) zakladalo schemat bez tabel treningow wlasnych
  czlonka: kroki migracji 1.5.0 i 1.5.1 byly pomijane i juz nie wracaly, zakladka "Moje treningi" pokazywala
  brak tabel. Teraz kroki wykonuja sie przy wlaczeniu modulu, a instalacja, ktorej tych tabel brakuje, dostaje
  je przy aktualizacji do 1.5.25 (dane i pozostale tabele bez zmian).

ZMIANY - REST dla SOKS Desktop:
- GET /orders (Rozrachunki): kazde zamowienie ma pole "fees" - oplaty zamowienia z nazwa i kwota brutto (name,
  total; pusta lista, gdy zamowienie nie ma oplat). SOKS Desktop 1.0.62 dopisuje nazwy oplat do opisu PO tak
  jak rozliczenie "Naleznosci od operatorow" na stronie (najpierw produkty, potem oplaty).

POPRAWKI - logowanie SOKS Desktop:
- Haslo aplikacji wyslane polami formularza albo naglowkiem X-SOKS-Auth (gdy hosting obcina naglowek
  Authorization) jest sprawdzane tak jak w rdzeniu WordPressa: bez wszystkich znakow poza literami i cyframi,
  wiec przechodzi takze z myslnikami albo kropkami miedzy grupami (dotad usuwane byly tylko spacje). Token
  niebedacy tekstem nie daje ostrzezenia PHP.

POPRAWKI - starty obce:
- Komunikat z data konkurencji bez sensu (Mistrzostwa Polski Mlodzikow 2026, wyniki z SIUS:
  "CZW. 4 STY 0001") dawal start z data 0001-01-04 - poza licznikiem startow do licencji
  i poza historia startow za rok zawodow. Data z rokiem przed 2000 albo dalszym niz przyszly
  rok nie jest data konkurencji: parser bierze date z innych tabel tej samej konkurencji
  w komunikacie (gdy wszystkie podaja jedna date; adnotacja w uwagach wpisu), a gdy jej
  nie ma - start nie powstaje.
- Start obcy z data spoza zakresu (rok przed 2000 albo dalej niz przyszly rok) nie jest
  zapisywany: automat startow obcych pomija go z powodem w dzienniku modulu, formularze
  czlonka i administratora pokazuja komunikat, REST /starty-obce zwraca blad 400.
- Start bez daty (data bez sensu, a innej daty tej konkurencji w komunikacie nie ma) nie znika bez sladu:
  automat startow obcych (kalendarz PZSS i SCloud) melduje go z powodem "nie zapisane (brak daty albo data
  spoza zakresu)".
- Wpis zapisany przed ta poprawka z data spoza zakresu (np. 0001-01-04): ponowny odczyt komunikatu uzupelnia
  date wpisu automatu o zgodnym wyniku (dopisek w uwagach), inny wynik - konflikt z dopiskiem; dotad zapis
  odbijal sie od klucza unikalnego bez sladu.
- REST /starty-obce: odmowa 400 "zla_data" niesie pole, wartosc, zakres_od, zakres_do i zapisano=false (dla
  SOKS Desktop 1.0.63); przy PUT komunikat mowi, ze nie zapisano zadnego pola.

POPRAWKI - telefon zamiast PESEL:
- Pole telefonu (telefon klubowy, telefon WooCommerce do rachunku i dostawy, telefon kontaktowy) nie przyjmuje numeru,
  ktory wyglada na PESEL: 11 cyfr (po usunieciu spacji, myslnikow, kropek i nawiasow) z poprawna suma kontrolna,
  bez "+" na poczatku i nie od 48 (numer z kierunkowym Polski przechodzi zawsze). Rejestracja, panel czlonka,
  profil w panelu WP, kasa i adresy WooCommerce pokazuja komunikat; REST PUT/POST /members zwraca ostrzezenie,
  /sync - liczbe pominietych pol (telefon_pesel_odrzucone). Zapis inna droga (import, inne wtyczki) tez jest
  zatrzymany, z wpisem w dzienniku audytu RODO (bez numeru). Dotad PESEL wpisany w telefon lezal jawnie, poza
  szyfrowaniem, i trafial do kopii zapasowych.

POPRAWKI - migracja PESEL, krok 6 (kopie zapasowe):
- Kopia zrobiona po kroku 4, w ktorej sprawdzanie znalazlo jawny PESEL, jest usuwana przyciskiem "Usun kopie"
  razem z kopiami sprzed szyfrowania, gdy istnieje nowsza pelna kopia bez jawnego PESEL (punkt powrotu). Dotad
  zostawala na zawsze z dopiskiem "UWAGA". Kopia z PESEL nowsza od punktu powrotu zostaje (raport mowi, ze
  usuniecie bedzie mozliwe po nowszej czystej kopii).
- Raport sprawdzania kopii podaje klucze pol kont (np. billing_phone), w ktorych znalazl numer - wiadomo, gdzie
  szukac numeru w bazie.

POPRAWKI - logowanie SOKS Desktop (2):
- Haslo aplikacji polami formularza albo naglowkiem X-SOKS-Auth jest sprawdzane tylko przy zadaniach REST
  (/wp-json/... albo ?rest_route=...), jak hasla aplikacji w rdzeniu WordPressa. Dotad te pola logowaly konto przy
  kazdym zadaniu strony. SOKS Desktop laczy sie tylko przez REST - bez zmian dla niego.

POPRAWKI - inne:
- Sprawdzanie licencji jest planowane po zaladowaniu WordPressa (init): bez Notice "_load_textdomain_just_in_time"
  dla domeny soks w dzienniku PHP (WordPress 6.7+).
- Deklaracja zgodnosci z tabelami zamowien WooCommerce (HPOS): wtyczka korzysta z zamowien wylacznie przez
  WooCommerce (sprawdzone w 1.5.25), wiec WooCommerce nie oznacza jej jako niezgodnej.

ZMIANY - Rozrachunki (prowizje operatorow platnosci):
- Akcja "Oznacz prowizje jako rozliczone" jest wylaczona: zamykala prowizje bez dokumentu kompensaty (KOM) i bez
  rozliczenia faktury operatora - faktura zostawala otwarta. Prowizje z faktura operatora rozlicza sie w SOKS
  Desktop (Rozrachunki -> Kompensata; 1.0.63: dokument KOM z jedna kwota po obu stronach).

== 1.5.24 (2026-10-16) - PESEL szyfrowany osobnym kluczem klubu (bez niego kopia jawna zostaje), szyfrowanie v2, narzedzie migracji ==
<!-- ks-v10-pesel-2026-10-13 -->
<!-- ks-v10b-zawody-2026-10-14 -->
<!-- ks-v10b-zawody-2026-10-14-cz2 -->
<!-- ks-v10b-zawody-2026-10-14-cz3 -->
<!-- ks-v10c-poprawki-2026-10-14 -->

POPRAWKI - zawody:
- Swieza instalacja: aktywacja konczyla sie ostrzezeniem PHP "Undefined variable $table_rundy"
  i bledem skladni SQL przy tabeli wynikow zawodow (nazwa tabeli rund uzyta przed jej
  zdefiniowaniem). Tabele zawodow sa definiowane i tworzone w kolejnosci kluczy obcych.
- Swieza instalacja: pierwsze zadanie po aktywacji przebudowywalo tabele rund (migracja
  struktury z wersji 2.2) i usuwalo kolumny data_do i wylaczone_konkurencje - zapis rundy
  konczyl sie bledem bazy "Unknown column 'data_do'". Przebudowa tylko dla starej struktury.
  Instalacje zalozone od zera przed 1.5.24 dostaja brakujace kolumny przy pierwszym zadaniu
  (data_do = data rundy). Bez zmian kluczy obcych na istniejacych tabelach.
- Wyniki zawodow (wp-admin, Dodaj wynik): na najstarszych instalacjach wynik tej samej
  konkurencji w kolejnej rundzie konczyl sie bledem "Duplicate entry" - w tabeli wynikow
  zostal dawny klucz unikalny (konkurencja, zawodnik). Klucz jest zdejmowany przy pierwszym
  zadaniu; obowiazuje klucz (runda, zawodnik, konkurencja).
- Usuwanie zawodow (wp-admin, panel klubu na stronie, SOKS Desktop) nie zalezy od kluczy
  obcych w bazie: wyniki, zapisy (start z karnetu wraca do puli - teraz takze przy usuwaniu
  z wp-admin i z panelu klubu), rundy i konkurencje sa usuwane jawnie, w jednej transakcji
  (tabele InnoDB). Wczesniej zostawaly osierocone konkurencje, wyniki albo zapisy -
  zaleznie od tego, ktore tabele mialy klucze obce (swieza instalacja: zadna z tabel
  z create_tables.sql).
- Usuwanie konkurencji: jej wyniki sa usuwane jawnie. Konkurencji z zapisanymi zawodnikami
  nie mozna usunac (jak rundy): na stronie konkurencja i zapisy zostaja bez zmian, panel
  pokazuje komunikat z nazwa konkurencji i liczba zapisanych, a REST (SOKS Desktop)
  odpowiada bledem 409 z tym samym komunikatem. SOKS Desktop 1.0.61 tego bledu nie pokazuje
  i usuwa konkurencje tylko u siebie; zawody dostaja nowa date zmiany, wiec najblizsza
  synchronizacja przywraca konkurencje w Desktopie. Tak samo przy rundzie z zapisanymi
  zawodnikami (komunikat z numerem rundy).
- Swieza instalacja: przy aktywacji blad bazy "Illegal mix of collations" (migracja 4.8.0:
  dokumenty finansowe -> kasa nic nie kopiowala). Ranking zawodow: dopasowanie zawodnika po
  nazwisku nie dzialalo, gdy tabela zapisow i tabele WordPressa maja rozne collation. Oba
  zapytania porownuja teksty w jednym, jawnie podanym collation (utf8mb4_unicode_ci).
- Tabele zawodow skasowane przy aktywnej wtyczce: tabele odtwarzane przy pierwszym zadaniu
  dostaja pelny biezacy schemat (kolumny i tabele z migracji, m.in. strzaly_per_seria,
  tabele wynikow i IPSC). Wczesniej dodanie konkurencji konczylo sie bledem
  "Unknown column 'strzaly_per_seria'". Wersja migracji bazy (ks_db_version) nie jest
  zerowana - powtarzane sa tylko migracje zakladajace kolumny i tabele zawodow.
- WordPress 6.7+: bez komunikatu "_load_textdomain_just_in_time ... soks" przy pierwszym
  zadaniu po aktywacji (tlumaczenie nazwy harmonogramu cron przed init).
- Wyniki zawodow (wp-admin, Dodaj wynik): wynik drugiej konkurencji tego samego zawodnika
  w tej samej rundzie nadpisywal wynik pierwszej (i zostawal zapisany pod pierwsza
  konkurencja). Wynik rundy jest teraz rozrozniany takze po konkurencji.
- PHP 8.1+: bez komunikatu "json_decode(): Passing null" na liscie zawodow (wp-admin i panel
  klubu), gdy konkurencja nie ma zapisanego rodzaju strzelectwa.
- Usuwanie zawodow usuwa takze dane modulu wynikow: funkcyjnych, pliki wynikow (HTML,
  wyniki reczne, wyniki IPSC) z wynikami szczegolowymi, tresc komunikatu oraz tory, cele
  i wyniki IPSC - wczesniej zostawaly w bazie. Usuniete pliki wynikow trafiaja na liste
  usunietych wynikow w synchronizacji, wiec SOKS Desktop usuwa je u siebie przy najblizszej
  synchronizacji.
- Serwery z lower_case_table_names=1 (domyslnie MySQL/MariaDB na Windows) i prefiksem tabel
  z wielkimi literami: istnienie tabel jest sprawdzane bez rozrozniania wielkosci liter.
  Wczesniej migracje zawodow i bazy uznawaly istniejace tabele za brakujace - rundy nie
  dostawaly kolumn data_do, wylaczone_konkurencje i kategorie_wiekowe, zapisy kolumny
  zawodnik_id, a reczne uruchomienie migracji zawodow przebudowywalo tabele rund i rundy
  znikaly. Przebudowa tabeli rund (migracja struktury z wersji 2.2) tylko po wykryciu starej
  struktury (kolumna konkurencja_id); kazdy inny wynik sprawdzenia nie usuwa tabeli.

POPRAWKI - inne:
- PHP 8.4: eksport i import CSV (czlonkowie, lista startowa Practiscore, statystyki,
  import czlonkow i skladek) bez ostrzezenia E_DEPRECATED o pominietym parametrze $escape -
  parametr podany jawnie, z dotychczasowa wartoscia (pliki CSV bez zmian).
- Ustawienie nowego hasla (formularz z linku): przy kazdym zadaniu bez metody HTTP (WP-CLI,
  cron uruchamiany z konsoli) ostrzezenie PHP: Undefined array key "REQUEST_METHOD".
- Komunikator: filtr statusu skladki odpytywal nieistniejaca tabele ks_transakcje (blad bazy
  przy kazdym czlonku bez oplaconej skladki); status skladki tylko z danych czlonka - jak na
  liscie czlonkow (kasa i sklep zapisuja go wlasnie tam). Zrodlo odbiorcow "zarejestrowani
  na zawody" czytalo nieistniejaca tabele ks_rejestracje (zawsze 0 odbiorcow) - teraz
  zapisy na zawody.
- Kontrahenci (REST, SOKS Desktop): pusty NIP zapisywany jako brak wartosci (NULL), nie pusty
  napis - drugi kontrahent bez NIP konczyl sie bledem 500 "Duplicate entry '' for key
  'unique_nip'" i nie trafial na strone. Istniejace puste NIP-y zamieniane jednorazowo przy
  pierwszym zadaniu po aktualizacji (bez zmiany daty modyfikacji kontrahenta).
- Serwery z lower_case_table_names=1 i prefiksem tabel z wielkimi literami (jak w punkcie
  o zawodach wyzej): pozostale moduly (synchronizacja wynikow z SOKS Desktop, dziennik,
  dokumenty, RODO, kasa, skladki) tez sprawdzaja istnienie tabel bez rozrozniania wielkosci
  liter - na swiezej instalacji z takim prefiksem powstaja te same kolumny co z prefiksem
  malymi literami. Instalacja zalozona z takim prefiksem przed 1.5.24 dostaje brakujace
  kolumny i indeksy jednorazowo przy pierwszym wejsciu administratora do wp-admin po
  aktualizacji: migracje schematu zapisane wczesniej jako wykonane (m.in. updated_at kasy
  i plikow wynikow, dogrywka, liczba celow IPSC, kolumny dziennika - telefon zawodnika
  szyfrowany, oceny i godzina obecnosci, starty, zawody.external_id) sa powtarzane;
  migracje danych - nie. Migracja klasyfikacji "Senior od 22 lat" (1.5.23) usuwa
  "Mlodziezowiec" z list kategorii zawodow i rund takze tam, gdzie wczesniej uznala
  tabele za brakujaca. Kolumny rund i zapisow na zawody - jak wyzej (punkt o zawodach).
- WooCommerce: zwrot zamowienia (status "Zwrocone" albo zwrot z ekranu zamowienia) tworzy osobny obiekt
  zwrotu, ktory listy zamowien SOKS braly za zamowienie - zakladka "Zamowienia WC" panelu klubu (takze
  wyszukiwarka zamowien), jej raport miesieczny, "Naleznosci od operatorow" w Rozrachunkach i REST
  GET /orders (SOKS Desktop) konczyly sie bledem krytycznym. Teraz pobierane sa tylko zamowienia.

ZMIANY:
- POWROT DO 1.5.23 - przeczytaj przed aktualizacja i przed krokami 2, 4 i 6 narzedzia migracji. 1.5.23 nie
  czyta formatu v2. Bezpieczny powrot to paczka 1.5.23 RAZEM z kopia bazy zrobiona przed instalacja 1.5.24
  (zmiany wprowadzone pozniej trzeba wtedy powtorzyc). Sama paczka 1.5.23, bez tej kopii bazy:
  * od instalacji 1.5.24: numer PESEL dodany albo zmieniony w 1.5.24 (panel czlonka, rejestracja, panel
    klubu, import CSV, SOKS Desktop, przywracanie) przy osobnym kluczu klubu (plik klucza albo stala
    KS_ENCRYPTION_KEY) jest w 1.5.23 pusty - zapis usuwa wtedy kopie jawna tego numeru; bez osobnego
    klucza kopia jawna zostaje z nowym numerem i 1.5.23 go widzi. Numery niezmienione zostaja czytelne
    (ich kopia jawna zostaje do kroku 4). Kartoteka
    dziennika zapisana w 1.5.24 (zawodnik albo opiekun, takze zapis bez zmiany tych pol) ma PESEL, adres
    i telefon zawodnika albo telefon i e-mail opiekuna w formacie v2 - w 1.5.23 te pola sa puste;
  * po kroku 2 (Szyfruj): wszystkie kolumny szyfrowane dziennika sa w formacie v2 - w 1.5.23 puste; numery
    kont z kopia jawna nadal czytelne;
  * po kroku 4 (Usun jawne): 1.5.23 nie widzi zadnego numeru PESEL kont (wnioski PZSS, eksporty, SOKS
    Desktop, wyszukiwanie);
  * po kroku 6: nie ma juz kopii bazy sprzed szyfrowania - powrotu do 1.5.23 z czytelnymi numerami nie ma.
  Po powrocie do 1.5.23 bez kopii bazy NIE zapisuj formularzy zawodnika ani opiekuna w dzienniku (wp-admin
  i Strefa trenera): 1.5.23 pokazuje pola v2 jako puste, a puste pole zapisuje jako NULL - dane znikaja
  takze dla 1.5.24. Kroki 2-6 wykonuj dopiero po kilku dniach pracy na 1.5.24, gdy powrot nie bedzie
  potrzebny.
- SOKS - RODO: ramka "Powrot do 1.5.23" i ostrzezenia przy krokach 2, 4 i 6 (takze w oknach potwierdzenia) -
  co przestaje dzialac w 1.5.23 po danym kroku.
- Nowy format szyfrogramu v2: AES-256-GCM z uwierzytelnieniem (podmiana albo uszkodzenie
  szyfrogramu konczy sie bledem, nie przypadkowym tekstem). Stary format (AES-256-CBC) jest
  nadal czytany.
- Klucz szyfrowania: stala KS_ENCRYPTION_KEY w wp-config.php albo plik .ks-klucz-szyfrowania
  w katalogu nad katalogiem WordPressa (sciezke zmienia stala KS_ENCRYPTION_KEY_FILE albo filtr
  ks_rodo_klucz_plik_sciezka). SOKS sprawdza, czy ten katalog nie jest serwowany przez WWW
  (DOCUMENT_ROOT, ABSPATH, adres ze sciezka, nazwa katalogu, pliki strony): nie tworzy tam pliku
  klucza, a plik juz tam lezacy czyta, ale pokazuje alarm i blokuje usuwanie kopii jawnych.
- Osobny klucz klubu tylko w formacie generatora: dokladnie 32 losowe bajty jako 44 znaki base64 zakonczone
  "=" (przycisk "Utworz plik klucza", pesel-kopia.php klucz-szyfrowania, openssl rand -base64 32) albo 64
  znaki hex (openssl rand -hex 32). Kazdy inny napis - takze dlugie haslo lub zdanie, base64 bez "=", klucz
  dluzszy niz 32 B - jest "slabym kluczem": zapis zostawia kopie jawna, krok 4 (a przez niego krok 6) jest
  zablokowany. SOKS nie rozpozna napisu przepisanego recznie w tym formacie - klucz musi pochodzic z
  generatora.
- Plik klucza zapisany z BOM UTF-8 albo w UTF-16 z BOM (PowerShell) jest czytany poprawnie. Inne
  kodowanie daje czytelny blad zamiast cichej zmiany klucza.
- Bez konfiguracji klucz pochodzi z LOGGED_IN_KEY, a bez niego (albo z wartoscia z wp-config-sample)
  z DB_PASSWORD+AUTH_KEY, jak w 1.5.23. Odczyt po zmianie klucza: stala KS_ENCRYPTION_KEY_POPRZEDNI.
  Kolumny szyfrowane dziennika uzywaja tego samego klucza. Kluczy z DB_PASSWORD SOKS - RODO, REST i komunikaty
  alarmu nie pokazuja z odciskiem, tylko z nazwa zrodla (odcisk samego hasla bazy pozwalalby je zgadywac).
- Sole dopisane do wp-config.php pozniej (wartosci z wp-config-sample albo brak stalych -> prawdziwe
  wartosci): dane zaszyfrowane dawnym zapasem DB_PASSWORD+AUTH_KEY zostaja czytelne (dawny zapas jest
  w kluczach strony tylko do odczytu) i przeszyfrowuja sie w tle - wczesniej alarm klucza wstrzymywal zapis
  numerow, rejestracje i dziennik. Zmiana hasla bazy albo wymiana prawdziwego AUTH_KEY nadal daje alarm;
  komunikat alarmu radzi wtedy wpisac dawne DB_PASSWORD i zaraz po nim dawny AUTH_KEY jako stala
  KS_ENCRYPTION_KEY_POPRZEDNI (wczesniej radzil przywrocic plik klucza albo stala KS_ENCRYPTION_KEY, ktorych
  klub bez osobnego klucza nie mial).
- Narzedzie pesel-kopia.php: nowe polecenie "ocena <plik-klucza>" (sila klucza; kod 3 = slaby).
  Odczyt pliku klucza jak we wtyczce (BOM, UTF-16).
- Jedna droga zapisu numeru (ks_rodo_save_pesel): panel czlonka, rejestracja, panel klubu,
  import CSV, SOKS Desktop (/sync, PUT i POST /members), przywracanie kopii, synchronizacja
  kluczy meta. Zapis usuwa aliasy (PESEL, pesel, ks_pesel) oraz dawne ks_PESEL_hash
  i ks_PESEL_last4; kopie jawna ks_PESEL - tylko przy osobnym kluczu klubu (punkt nizej). Ten sam
  numer = brak zapisu (synchronizacja z Desktopem nie zmienia daty modyfikacji konta).
- Bez osobnego klucza klubu (plik klucza poza katalogiem WWW albo stala KS_ENCRYPTION_KEY) zapis
  numeru PESEL zostawia kopie jawna z nowym numerem, jak w 1.5.23; tryb "bez kopii jawnej" dziala
  dopiero z osobnym kluczem. Wymiana kluczy WordPressa (LOGGED_IN_KEY) nie zabiera numerow.
- Nowa wartosc PESEL, ktora po normalizacji nie jest 11 cyframi, jest odrzucana (blad format, bez
  zapisu); zla suma kontrolna - zapis z ostrzezeniem.
- Zapis numeru z warunkiem poprzedniej wartosci: numer zapisany w miedzyczasie (czlonek, SOKS
  Desktop) nie jest nadpisywany przez krok 2, przebieg w tle ani straznika.
- Rownolegle zapisy numeru PESEL tego samego konta (czlonek, SOKS Desktop, import, krok 2, przebieg w tle,
  straznik, krok 4, przywracanie) ida pod blokada konta w bazie (GET_LOCK): drugi zapis czeka do 5 s i czyta
  stan po pierwszym, przebiegi zbiorcze odkladaja zajete konto, zapis czlonka po konflikcie jest ponawiany.
  Wczesniej zapis czlonka albo SOKS Desktop mogl przepasc bez sladu, gdy krok 2 zapisal konto w tej samej
  chwili.
- Wyszukiwanie pelnego numeru przez HMAC-SHA256 z kluczem wyprowadzonym z klucza
  szyfrowania (bez stalej soli w kodzie). Listy czlonkow w panelu klubu i w wp-admin:
  numer tylko pelny, wysylany formularzem POST (w adresie strony tylko token); ogolne pole
  wyszukiwania nie przeszukuje PESEL.
- Wyszukiwanie po PESEL: tylko pelny numer, tylko z prawem podgladu numeru (zarzad), limit 10 prob
  na 15 minut na konto, odmowy i limit w dzienniku RODO.
- Kanarek klucza: przy niezgodnym albo brakujacym kluczu ALARM w panelu WordPressa
  i w panelu klubu na stronie; usuwanie kopii jawnych jest wtedy zablokowane. Bledy
  odszyfrowania sa liczone i widoczne na stronie SOKS - RODO (wczesniej pusty napis bez sladu).
- Alarm klucza szyfrowania wstrzymuje zapis nowych numerow PESEL; nowa akcja "Uznaj biezacy klucz"
  (potwierdzenie UTRATA, wpis w dzienniku RODO) po trwalej utracie dawnego klucza.
- Alarm klucza (klucz glowny nie pasuje do zapisanych danych) wstrzymuje takze zapis danych szyfrowanych
  dziennika (PESEL, adres i telefon zawodnika, telefon i e-mail opiekuna): ks_rodo_encrypt() zwraca blad
  (false) i wpis w liczniku bledow RODO zamiast szyfrogramu kluczem, ktorego po naprawie zabraknie; zapisane
  wartosci zostaja bez zmian. Komunikat alarmu (wp-admin, panel klubu, Strefa trenera) mowi o PESEL i o
  dzienniku.
- Klub bez osobnego klucza (alarm po wymianie kluczy WordPressa), przed krokiem 4: numer PESEL zapisuje sie
  w alarmie z kopia jawna, jak w 1.5.23 (wczesniej odmowa konczyla sie utrata wpisanego numeru) - takze
  z formularza rejestracji, ktory dotad odmawial przy kazdym alarmie klucza. Przy zgubionym osobnym kluczu
  i po kroku 4 zapis numeru i rejestracja nadal sa wstrzymane.
- Kanarek nie jest odnawiany, dopoki sa dane w formacie v1 (przed krokiem 2 "Szyfruj"); ostrzezenie
  mowi, by do tego czasu nie zmieniac kluczy WordPressa.
- Kanarek klucza (kontrola, czy klucz szyfrowania pasuje do zapisanych danych) powstaje przy aktywacji i po
  aktualizacji wtyczki oraz przed pierwszym szyfrowaniem i odczytem numeru w kazdym kontekscie (SOKS Desktop
  przez REST, WP-Cron, formularze na stronie, WP-CLI) - wczesniej dopiero przy pierwszym wejsciu do
  wp-admin, wiec podmieniony albo zgubiony plik klucza nie dawal alarmu, numer zapisywal sie obcym kluczem i
  po przywroceniu wlasciwego klucza byl nieczytelny. Gdy kanarka nie ma, a w bazie sa juz zaszyfrowane dane,
  kanarek powstaje dopiero po sprawdzeniu, ze klucze strony je odczytuja; inaczej alarm klucza (wyjscie:
  przywrocenie klucza albo "Uznaj biezacy klucz"). Po zmianie klucza dochodzi kanarek nowym kluczem - jego
  podmiana albo utrata w czasie przeszyfrowania w tle tez daje alarm, takze gdy tym kluczem nic jeszcze nie
  zaszyfrowano (np. plik klucza utworzony i zaraz usuniety). Zwykle zadanie nie przeglada wtedy danych, a kanarek
  nie znika sam: usuwa go tylko administrator w SOKS - RODO przyciskiem "Usun kanarek klucza wycofanego",
  dostepnym, gdy pelne sprawdzenie wszystkich szyfrogramow na zadanie ("Sprawdz wszystkie szyfrogramy") nie
  znajdzie nieczytelnego i od pierwszego wykrycia minelo co najmniej 15 minut. Usuniecie idzie pod blokada
  przebiegu w tle, z ponownym sprawdzeniem w chwili klikniecia i wpisem w dzienniku audytu RODO. Kopie zapasowe
  zrobione, gdy ten klucz istnial, moga zawierac dane nim zaszyfrowane - SOKS kopii nie sprawdza.
- Narzedzie migracji (SOKS - RODO, takze REST soks/v1/rodo-pesel/<krok>, tylko administrator):
  na sucho (raport, STOP przy niezgodnosci), szyfruj (v2 + HMAC, kopie jawne zostaja),
  kopia (numery i kolumny szyfrowane dziennika, zaszyfrowane kluczem PUBLICZNYM administratora,
  sodium_crypto_box_seal; odczyt narzedziem offline pesel-kopia.php),
  usun jawne (tylko konta, ktorych szyfrogram daje ten sam numer; wymaga osobnego klucza,
  dzialajacego kanarka, kopii z ostatnich 24 h i potwierdzenia), kontrola; utworzenie pliku
  klucza i przywrocenie numerow z odszyfrowanej kopii.
- Zaszyfrowana kopia numerow z kroku 3 nie jest przechowywana w bazie (tylko plik poza katalogiem
  WWW; ponowne pobranie czyta plik).
- Krok 4 usuwa puste wiersze PESEL i osierocone skroty kont bez numeru; liczniki jawnych numerow
  (krok 4, 5, 6, panel) licza tylko wartosci.
- Przywracanie z kopii z kroku 3 pomija konta i zawodnikow zanonimizowanych, nie nadpisuje numerow
  zmienionych ani usunietych po kopii i raportuje pominiete; usuniety po kopii wiersz dziennika nie
  jest bledem (alarm moze zostac zdjety).
- Krok 6 narzedzia migracji: usuniecie kopii zapasowych bazy sprzed szyfrowania (zawieraja
  jawny PESEL). Najpierw sprawdzenie na sucho: lista kopii z wp-content/ks-backups/
  i uploads/ks-backups/ (nazwa, rozmiar, data) z podzialem na kopie sprzed i po zakonczeniu
  kroku 4 oraz liczba jawnych numerow PESEL w kazdej kopii (jedna kopia na zadanie, ZIP czytany
  strumieniowo; w raporcie same liczby). Usuwane sa tylko kopie sprzed zakonczenia kroku 4,
  tylko zwykle pliki w katalogach kopii, i tylko gdy: krok 4 zakonczony, kontrola (krok 5)
  po nim nie widzi jawnych numerow, jest pelna kopia bazy zrobiona po kroku 4 bez jawnego
  numeru (punkt powrotu), klucz dziala, wpisano USUN-KOPIE. Po usunieciu ponowny przeglad
  katalogow i wpis w dzienniku RODO (liczba plikow, rozmiar, kto - bez nazw plikow).
  Zaszyfrowana kopia numerow z kroku 3 zostaje. Panel RODO pokazuje liczbe kopii sprzed
  szyfrowania, dopoki sa. Krok 4 zapisuje chwile zakonczenia (ponowne uruchomienie bez zmian
  jej nie przesuwa).
- Punkt powrotu kroku 6 musi zawierac wiersze kont: tabela uzytkownikow (z tolerancja 2%, co najmniej
  5 kont), dane kont i szyfrogramy PESEL; uciety zrzut nie jest punktem powrotu.
- Przebieg w tle (WP-Cron): po zmianie klucza przeszyfrowuje dane, po usunieciu kopii jawnych
  szyfruje numer zapisany z pominieciem wtyczki. Sprawdza tez wszystkie szyfrogramy v2: numer PESEL bez kopii
  jawnej albo wartosc dziennika, ktorych nie odczytuje zaden klucz strony, daje alarm klucza (zdejmuje go
  przywrocenie klucza - przebieg w tle sprawdza dane ponownie - przywrocenie z kopii z kroku 3 albo "Uznaj biezacy
  klucz"); nieczytelny szyfrogram konta z kopia jawna - wpis w liczniku bledow RODO i wiersz w SOKS - RODO
  (wyjscie: krok 2 "Szyfruj" z opcja "kopia jawna wygrywa").
- Maska numeru: najwyzej 2 ostatnie cyfry.
- Pusty PESEL w paczce /sync z Desktopu nie kasuje numeru; maska (z gwiazdkami) nigdy nie jest zapisywana
  jako numer.
- Odpowiedzi /sync, /members i /members/{id}: znacznik ks_PESEL_nieczytelny="1", gdy strona nie umie
  odczytac szyfrogramu; znacznik przyslany z zewnatrz nie jest zapisywany.
- SOKS Desktop 1.0.61 (i kazdy klient bez obslugi znacznika ks_PESEL_nieczytelny) nie dostaje w /sync kont,
  ktorych numeru PESEL strona nie umie odczytac (np. po utracie pliku klucza) - 1.0.61 skasowalby swoja
  kopie numeru. Liczba pominietych kont w polu members_pominiete_pesel i w SOKS - RODO; po przywroceniu
  klucza konta wracaja do synchronizacji. Klient z naglowkiem X-SOKS-Znacznik-Pesel: 1 (albo polem
  znacznik_pesel = 1) dostaje konto ze znacznikiem.
- Komunikator: znacznik {{pesel}} wstawia maske, nie numer (SMS i e-mail ida przez bramki
  zewnetrzne).
- Eksporty z pelnym numerem (CSV czlonkow, XLSX do SOZ) zapisuja wpis w dzienniku audytu RODO.
- Klasyfikacja wiekowa jak w 1.5.23: plec liczona z numeru PESEL (poprawiony numer poprawia plec, skrot K/M
  z "Dodaj czlonka" zamienia sie na pelna nazwe), data urodzenia z PESEL tylko, gdy pusta; numer odczytywany
  z szyfrogramu.
- Kopia "tylko czlonkowie" (Backup i przywracanie) nie zawiera jawnego numeru PESEL - tylko szyfrogram (konto
  z sama kopia jawna dostaje w kopii szyfrogram tego numeru). Bez osobnego klucza klubu numery z takiej
  kopii nie wroca po utracie bazy - ostrzezenie w SOKS - RODO i na ekranie Backup i przywracanie. Eksport
  danych czlonkow z kolumna PESEL zawiera pelny numer (wpis w dzienniku audytu RODO).

POPRAWKI:
- Wniosek PZSS (generator i Dokumenty), komunikator i eksport XML rejestracji wolaly
  nieistniejaca funkcje ks_rodo_decrypt_pesel(); /soz-sync, import CSV, rejestracja i import
  skladek dopasowywaly konto tylko po jawnej kopii numeru.
- Panel czlonka: walidacja 11 cyfr dzialala tylko dla pola o kluczu ks_PESEL; teraz takze dla
  pola 'PESEL'. Zla suma kontrolna - ostrzezenie, numer zapisany. Data urodzenia z PESEL tylko
  przy zmianie numeru. Wzorzec 11 cyfr w polu PESEL tylko, gdy pole jest puste albo zapisany numer ma
  11 cyfr: zapisany numer w innym formacie (np. 10 cyfr po imporcie z arkusza, ktory zgubil zero na
  poczatku) nie blokuje wyslania formularza - pod polem jest ostrzezenie, pozostale dane sie zapisuja,
  a niezmieniony numer zostaje bez komunikatu bledu.
- Dokumenty: kazdy wydruk zapisywal w logu serwera wszystkie pola konta (takze PESEL) - teraz
  tylko nazwy pol i tylko przy stalej KS_DEBUG_DOKUMENTY. Import CSV nie loguje wartosci.
- Statystyki i czyszczenie bazy liczyly PESEL po kluczu, ktorego nikt nie zapisuje.
- Usunieta martwa funkcja listy czlonkow v3 (pokazywala pelny PESEL).
- Dziennik (Strefa trenera i wp-admin SOKS - Dziennik): gdy zapisanego PESEL, adresu albo telefonu
  zawodnika (albo telefonu i e-maila opiekuna) nie da sie odszyfrowac - brak albo zmiana klucza
  szyfrowania, np. plik klucza nieprzeniesiony razem z baza - formularz edycji pokazuje pole
  "nieczytelne - brak klucza" zamiast pustego, a zapis zostawia te kolumny bez zmian (wczesniej pusty
  formularz zapisywal NULL i dane znikaly takze po przywroceniu klucza). Obok jest puste pole "nowa
  wartosc": wpisana wartosc zastepuje nieczytelna (np. po "Uznaj biezacy klucz" w SOKS - RODO), puste
  niczego nie zmienia. Wyczyszczenie pola z czytelna wartoscia nadal ja usuwa. Gdy szyfrowanie wpisanej
  wartosci sie nie uda (brak klucza, alarm klucza), zapis jest przerwany z komunikatem, a dotychczasowe
  dane zostaja (wczesniej NULL albo szyfrogram zlym kluczem). Audyt edycji kartoteki porownuje telefon
  zawodnika na tekscie jawnym i wymienia pola pozostawione bez zmian.
- Dziennik: formularze zawodnika i opiekuna (Strefa trenera, wp-admin, nowy opiekun przy kartotece) do
  wykonania kroku 2 "Szyfruj" ostrzegaja, ze zapis w 1.5.24 zapisuje PESEL, adres i telefon zawodnika
  (opiekun: telefon i e-mail) w formacie v2, ktorego 1.5.23 nie odczyta - takze bez zmiany tych pol
  (punkt POWROT DO 1.5.23).
- Kolumny szyfrowane dziennika: nieudane szyfrowanie (alarm klucza, brak klucza, brak AES-256-GCM) nie
  trafia do kolumny - ani NULL, ani pusty napis; dotychczasowa wartosc zostaje, formularz pokazuje powod.
  Migracja telefonu zawodnika pomija wtedy wiersz i ponawia go przy nastepnym przebiegu.
- Numer PESEL odrzucony przy zapisie (nie 11 cyfr, alarm klucza szyfrowania, brak klucza, nieczytelny
  numer przy usuwaniu): PUT /members (SOKS Desktop) odpowiada bledem 422 (format) albo 409 (pozostale
  powody) z komunikatem "Numer PESEL NIE zostal zapisany: ... Pozostale dane czlonka zapisano." zamiast
  {"success":true}; pozostale pola zadania sa zapisane. SOKS Desktop 1.0.61 i 1.0.62 pracuja dalej (blad
  w konsoli; 1.0.62 zostawia usuniecie numeru do ponowienia przy synchronizacji). /sync: liczba odrzuconych
  i konta (bez numeru) w applied.pesel_odrzucone / pesel_odrzucone_konta i w logu PHP. POST /members: konto
  powstaje, odpowiedz ma pesel_odrzucony i ostrzezenia. Panel czlonka: komunikat "Numer PESEL NIE zostal
  zapisany" (wczesniej cisza), data urodzenia z numeru bez zmian.
- Formularz rejestracji bez zadnego klucza (puste haslo bazy i sole z wp-config-sample), bez AES-256-GCM
  albo przy alarmie klucza, w ktorym zapis numeru jest wstrzymany (osobny klucz klubu albo po kroku 4):
  komunikat bledu przy polu PESEL, konto NIE jest tworzone - wczesniej rejestracja konczyla sie sukcesem,
  a numer nie trafial do bazy. Gdy zapis numeru nie uda sie juz po utworzeniu konta, ekran sukcesu mowi
  o tym wprost.
- Lista czlonkow (wp-admin i panel klubu): parametr adresu w postaci tablicy (np. search_pesel_t[]=...) nie
  daje ostrzezenia PHP ani ukrytego pola z wartoscia "Array".
- Pelna kopia bazy (Backup i przywracanie, takze z harmonogramu WP-Cron) zapisywala kazdy znak % jako
  znacznik zastepczy WordPressa (64 znaki szesnastkowe w nawiasach klamrowych; WordPress od 4.8.3): np.
  /%postname%/ wracal z importu uszkodzony, a dane serializowane ze znakiem % mialy bledne dlugosci. Krok 6
  migracji PESEL wymaga takiej kopii jako punktu powrotu. Teraz wartosci sa zapisywane bez znacznika,
  kolumny binarne (np. szyfrogramy dziennika) jako literal szesnastkowy 0x..., a zrzut zaczyna sie od SET
  NAMES, SET FOREIGN_KEY_CHECKS=0 i SQL_MODE='NO_AUTO_VALUE_ON_ZERO' (import niezalezny od kolejnosci tabel
  z kluczami obcymi i od trybu SQL serwera). Przywracanie kopii "tylko czlonkowie" zapisuje wartosci pol bez
  zmian (wczesniej gubilo znaki nowej linii, tabulatory, sekwencje %XX, znaczniki HTML i spacje z brzegow).
  Odzyskiwanie pol z pelnej kopii SQL (ks_recover_from_backup, ks_force_restore_field_from_backup) czyta
  sekwencje MySQL, NULL i literaly 0x... i nie obcina spacji z brzegow wartosci.
- SOKS Desktop na hostingu, ktory obcina naglowek Authorization (logowanie naglowkiem X-SOKS-Auth albo
  polami _soks_user/_soks_token): haslo aplikacji utworzone w WordPressie 6.8 lub nowszym bylo zawsze
  odrzucane (HTTP 401), bo WordPress od 6.8 zapisuje hasla aplikacji innym skrotem. Teraz sprawdza je
  funkcja WordPressa (WP_Application_Passwords::check_password), w starszym WordPressie jak dotad, na tych
  samych warunkach co w WordPressie: hasla aplikacji musza byc dostepne na stronie (HTTPS albo srodowisko
  "local"; strona bez HTTPS wlacza je filtrem wp_is_application_passwords_available, jak dla WordPressa)
  i dla konta (filtr wp_is_application_passwords_available_for_user), a inna wtyczka moze odmowic
  (wp_authenticate_application_password_errors). UWAGA: na stronie bez HTTPS i bez tego filtra SOKS Desktop
  nie zaloguje sie juz ta droga (wczesniej przyjmowala haslo aplikacji tam, gdzie WordPress go nie przyjmuje).
  Nowy limit: po 10 nieudanych probach z jednego adresu IP w ciagu 15 minut kolejne proby ta droga nie sa
  sprawdzane; udane logowanie usuwa licznik tego adresu (takze wygasly).
  Licza sie tylko proby polami _soks_user/_soks_token i naglowkiem X-SOKS-Auth - naglowek
  Authorization: Basic (np. ochrona katalogu haslem w przegladarce) licznika nie zwieksza. Licznik jest
  odczytywany i zapisywany pod blokada bazy (GET_LOCK), wiec rownolegle proby nie omijaja limitu.

== 1.5.23 (2026-10-13) - Senior od 22 lat, skladki mlodziezy, warunek skladki przy wniosku, bezpieczenstwo kopii zapasowych, skladki roczne w kasie ==
<!-- ks-wydanie-v9g-2026-10-13 -->
<!-- ks-v9i-domkniecie-2026-10-13 -->
<!-- ks-v9h-kasa-skladki-2026-10-13 -->
<!-- ks-v9j-parser-sius-2026-10-13 -->
<!-- ks-v9k-kasa-2026-10-13 -->
<!-- ks-v9k-kopie-2026-10-13 -->
<!-- ks-v9k-mlodziez-2026-10-13 -->
<!-- ks-v9l-kasa-2026-10-14 -->
<!-- ks-v9l-mlodziez-2026-10-14 -->
<!-- ks-v9m-domkniecie-2026-10-14 -->
<!-- ks-v9n-drobne-2026-10-14 -->

BEZPIECZENSTWO:
- Kopie zapasowe SOKS (wp-content/ks-backups/ i uploads/ks-backups/) mogly byc do pobrania z sieci bez
  logowania: plik .htaccess katalogu powstawal tylko razem z katalogiem i tylko w starej skladni, a nazwa
  kopii byla sama data i godzina. Od tej wersji:
  * .htaccess (skladnia Apache 2.4 i starsza, takze LiteSpeed) i index.php sa zakladane albo odtwarzane przed
    kazdym zapisem kopii (recznej, z harmonogramu, ks_create_real_backup()) i przy wejsciu do panelu
    administratora (raz na dobe takze kontrola tresci); plik o wlasciwej tresci nie jest nadpisywany. Gdy
    katalogu nie da sie zabezpieczyc, kopia nie powstaje (komunikat albo wpis w logu);
  * nowe kopie maja losowy przyrostek nazwy; lista, przywracanie i czyszczenie przyjmuja stare i nowe nazwy;
  * kopie pobiera sie tylko z panelu: admin-post.php?action=ks_pobierz_kopie (administrator, nonce, walidacja
    nazwy i sciezki), z wpisem w dzienniku RODO; zakladka "Backup i przywracanie" ma liste "Pobierz kopie na
    dysk", a teksty pomocy przywracania wskazuja pobranie z panelu albo przez FTP;
  * istniejace kopie (sprzed tej wersji) dostaja przy aktualizacji losowe nazwy w tym samym formacie co nowe,
    w obu katalogach, bo na serwerach bez obslugi .htaccess (np. nginx) sam plik .htaccess nie chroni:
    jednorazowo, zmiana nazwy w miejscu (bez kopiowania i kasowania, data pliku zostaje); rownolegle zadanie nie
    powtorzy jej ani nie nadpisze raportu (flaga i raport czytane z bazy po zalozeniu blokady). Kopia, ktorej
    nie da sie przemianowac, zostaje bez zmian: wpis w logu, komunikat w panelu administratora i ponowna proba
    raz na dobe;
  * przy pierwszym zadaniu po aktualizacji istniejace katalogi kopii sa od razu zabezpieczane (.htaccess,
    index.php), nie dopiero przy wejsciu do panelu administratora;
  * zakladka "Backup i przywracanie" (lista kopii, wybor kopii do przywrocenia, odnosniki pobierania) tylko dla
    administratora strony; pozostale role z dostepem do "Ustawien bazy" widza informacje bez nazw kopii (liczba
    kopii, data ostatniej);
  * lista plikow katalogu kopii nie trafia juz do logu PHP (dotad przy kazdym otwarciu zakladki "Backup
    i przywracanie" i przy kazdej kopii z harmonogramu).
  Nowe funkcje: ks_kopie_zabezpiecz_katalog(), ks_kopie_pobierz(), ks_kopie_url_pobrania(),
  ks_kopie_migracja_nazw().
  Po aktualizacji sprawdz, czy adres katalogu kopii (/wp-content/ks-backups/) zwraca 403; jesli adres katalogu
  kopii nie zwraca 403 (np. nginx) - zablokuj go w konfiguracji serwera.
- Kasa (panel klubu): zmiana typu albo numeru dokumentu zapisywala w logu PHP nazwe wyswietlana operatora
  (zwykle imie i nazwisko) - teraz tylko jego identyfikator (#ID). Wpisy Kasy o skladkach rocznych w logu PHP
  maja tylko numer dokumentu, ID kont i lata; pelny opis (z kwotami) trafia do dziennika audytu.

ZMIANY:
- ZGODNOSC Z SOKS DESKTOP - przeczytaj przed aktualizacja: SOKS Desktop starszy niz 1.0.61 nie aktualizuje
  na stronie list dyscyplin z SOZ (licencja zawodnicza, patent, licencja sedziego). Strona przyjmuje je
  w POST /soz-sync tylko z polem "parser_soz" >= 2, a to pole wysyla dopiero Desktop 1.0.61. Pozostale pola
  z SOZ (m.in. numery, statusy i daty licencji i patentu, licencja sedziego i instruktora, badania
  lekarskie) zapisuja sie jak dotad. Odpowiedz ma "success": true, a starszy Desktop nie zna pola
  pominiete_dyscypliny i o pominieciu list nie informuje (slad tylko w logu PHP strony). Po aktualizacji
  wtyczki zaktualizuj SOKS Desktop do 1.0.61 i pobierz dane z SOZ ponownie (szczegoly w punkcie o /soz-sync).
- Kategorie wiekowe PZSS zgodnie ze Zbiorem Regulaminow PZSS (20.12.2025, rozdz. 3): kategoria glowna "Senior"
  od 22 lat, "Mlodziezowiec" (22-23 lata, rocznikowo) jako podgrupa seniorow. Konto osoby 22-23-letniej:
  kategoria "Senior", podgrupa w polu ks_podgrupa_wiekowa_pzss, etykieta "Senior (mlodziezowiec)" - takze
  w kolumnie "Kategoria wiekowa" listy czlonkow, w polu "Kategoria wiekowa PZSS" danych konta (tylko do
  odczytu, formularz go nie zapisuje), w wyszukiwarce czlonkow przy recznej rejestracji i na liscie startowej.
  Lista kategorii do wyboru w zawodach i w rundach zawiera kategorie glowne i podgrupe "Mlodziezowiec"
  z opisem "22-23 lata, w ramach kategorii Senior" (zawody tylko dla mlodziezowcow). Nowe funkcje:
  ks_get_kategorie_pzss_glowne(), ks_podgrupy_pzss_dla_wieku(), ks_klasa_pzss_z_podgrupami(),
  ks_klasa_wiekowa_pzss_etykieta(); filtr ks_podgrupy_pzss.
- Jednorazowa migracja przy aktualizacji: domyslny zestaw sprzed 1.5.23 (Mlodziezowiec 22-23, Senior od 24)
  zamieniany na nowy; wlasny zestaw klubu zostaje bez zmian. Klub bez zapisanej konfiguracji klasyfikacji
  dostaje nowy zestaw domyslny zapisany w opcji ks_classification_config (dzialanie 1.5.23 bez zmian). Gdy
  obowiazujace kategorie glowne daja "Senior" dla 22 i 23 lat i nie ma wsrod nich "Mlodziezowiec", z list
  kategorii zawodow i rund usuwany jest "Mlodziezowiec" tam, gdzie lista ma "Senior"; listy z "Mlodziezowiec"
  bez "Senior" zostaja. Przy wlasnym zestawie klubu z kategoria glowna "Mlodziezowiec" albo bez "Senior" dla
  22-23 lat listy zawodow i rund zostaja bez zmian. Zapisane kategorie czlonkow sa przeliczane w tle (WP-Cron,
  paczki po 200 kont).
  Rownolegle zadanie nie powtorzy migracji (flaga czytana z bazy po zalozeniu blokady), a raport przerwanej
  proby przechodzi do kolejnej. Komunikat o skutkach migracji w panelu administratora i w panelu klubu na
  stronie ([klub] / [lista_czlonkow]), z odnosnikiem "Ukryj"; listy, ktorych nie udalo sie zapisac, i nieudany
  zapis konfiguracji klasyfikacji sa w nim osobnymi uwagami. Przy kontach z dawna kategoria "Mlodziezowiec"
  bez daty urodzenia, roku urodzenia i numeru PESEL komunikat podaje ich ID i wskazuje uzupelnienie daty
  urodzenia albo numeru PESEL w danych konta (pole kategorii jest tylko do odczytu).
  Powrot do 1.5.22: 1.5.22 liczy kategorie z zapisanego zestawu (Senior od 22 lat) i nie zna podgrup -
  samodzielny zapis na zawody, ktorych lista kategorii ma tylko "Mlodziezowiec", nie przejdzie dla nikogo.
- Kategorie wiekowe per runda: w edycji rundy (wp-admin > Zawody > Rundy) i w blokach rund formularza edycji
  zawodow na stronie pole "Kategorie wiekowe tej rundy" - "jak w zawodach" (domyslnie) albo wlasna lista.
  Samodzielny zapis (panel czlonka, [rejestracja_zawody]) sprawdza liste wybranej rundy, formularz
  [rejestracja_zawody] ja pokazuje, listy zawodow pokazuja rundy z wlasna lista. Nowa kolumna
  ks_zawody_rundy.kategorie_wiekowe (TEXT NULL, JSON jak ks_zawody.kategorie_wiekowe, NULL = jak w zawodach) -
  migracja automatyczna; SOKS Desktop tylko ja wyswietla. Podgrupa Mlodziezowiec (22-23 lata) dopuszcza przy
  liscie z "Senior" albo "Mlodziezowiec"; komunikat "Senior (mlodziezowiec)".
- Kazda zmiana rundy (dane, zapisy, kategorie, dodanie, usuniecie) aktualizuje updated_at zawodow -
  synchronizacja SOKS Desktop widzi zmiany samych rund.
- Lista startowa wg konkurencji: "Kat. PZSS" liczona na rok rundy z daty urodzenia (konto, gosc, kartoteka
  nieletnich); bez daty urodzenia - zapisana kategoria konta z podgrupa.
- Mlodziez klubowa (rocznikowo ponizej 18 lat albo z kartoteki sekcji mlodziezowej do roku 23. urodzin): nowe
  ustawienie klubu "Mlodziez klubowa placi skladki miesieczne w sekcji zamiast skladki rocznej czlonka"
  (SOKS -> Formularze -> Skladki, opcja ks_mlodziez_skladki_miesieczne). Wartosc poczatkowa jest ustawiana raz,
  przy pierwszym zadaniu po aktualizacji: wlaczone tylko wtedy, gdy modul Dziennik jest wlaczony i klub
  naprawde prowadzi w nim skladki miesieczne - stawka miesieczna ustawiona i wieksza od zera albo co najmniej
  jedna wplata lub zwolnienie w skladkach miesiecznych; inaczej wylaczone. Sam wlaczony Dziennik (np. uzywany
  tylko do treningow) ustawienia nie wlacza: wiersz konfiguracji skladek z miesiacem startu 2026-10 i bez
  stawki zaklada sama migracja dziennika (krok 1.5.14). Blad bazy przy ustalaniu: nic nie jest zapisywane
  (ponowna proba przy nastepnym zadaniu), do tego czasu ustawienie dziala jak wylaczone. Dopoki klub nie
  zapisze ustawienia, na zakladce SOKS -> Formularze -> Skladki komunikat na gorze strony i opis przy
  przelaczniku podaja ten warunek i stan modulu teraz (stawka, liczba wplat i zwolnien); po zapisie
  ustawienia opisu nie ma, takze na stronie z wynikiem zapisu. Potem decyduje klub - po aktualizacji sprawdz
  to ustawienie. Ekran trenera "Ustawienia skladek" pokazuje, jak jest ustawione. Przy wlaczonym ustawieniu
  mlodziez placi w sekcji skladke miesieczna zamiast skladki rocznej:
  * panel czlonka, zakladka "Wplaty": bez skladki rocznej i jej zaleglosci; zamiast nich skladki miesieczne
    z kartoteki (stawka, biezacy miesiac, zalegle, do zaplaty, zaplacone, zwolnienie z programem i okresem;
    miesiace w zapisie MM.RRRR; przy stawce 0 - bez zaleglosci i kwot do zaplaty);
  * przypomnienia o skladce rocznej nie trafiaja do mlodziezy (ks_send_payment_reminder, szablon
    payment_reminder i harmonogramy e-mail, komunikator z filtrem "skladka nieoplacona", preset na liscie
    czlonkow);
  * lista czlonkow (wp-admin i panel klubu): kolumna "Skladka <rok>" - "nie dotyczy", filtry "Oplacona"
    i "Nieoplacona" nie obejmuja mlodziezy, bez przycisku przelaczania skladki rocznej; kolumna "Zaleglosci"
    (panel klubu) - "nie dotyczy" bez kwoty i bez "Rozlicz przez kase" (takie rozliczenie odrzuca tez serwer);
  * komunikator: filtr skladki rocznej nie obejmuje mlodziezy ani przy "oplacona", ani przy "nieoplacona"
    (tak jak lista czlonkow);
  * Kasa: bez oznaczania skladki rocznej (opis w punkcie o Kasie);
  * wniosek o przedluzenie licencji: u mlodziezy warunkiem jest brak zaleglych skladek miesiecznych sekcji
    (miesiace objete zwolnieniem albo programem nie sa nalezne) zamiast skladki rocznej - egzekwowany tylko,
    gdy modul skladek miesiecznych ma ustawiona stawke wieksza od zera; konto bez powiazanej kartoteki albo
    wylaczony dziennik nie blokuja wniosku, a panel i ostrzezenia REST mowia, ze skladek nie dalo sie
    sprawdzic. Dorosli - jak dotad skladka roczna za rok licencji. Nowe funkcje: ks_licencja_warunek_skladki(),
    ks_licencja_skladka_teksty(); filtr ks_licencja_warunek_skladki.
  Przy wylaczonym ustawieniu mlodziez placi skladke roczna jak pozostali czlonkowie we wszystkich tych
  miejscach. Kafelki skladki rocznej w panelu czlonka (u nieletnich ukryte od 1.5.21) ida za tym samym
  ustawieniem: w klubach z wylaczonym ustawieniem pokazuja sie znowu. Filtr ks_ukryj_skladki_nieletnich nadal
  moze wynik zmienic w obie strony - jego wartosc wejsciowa to teraz ustawienie klubu (dotad true), drugi
  argument: ID konta. Nowe funkcje: ks_skladka_roczna_nie_dotyczy(), ks_mlodziez_skladki_miesieczne().
- Powiadomienia e-mail o zmianie statusu: osobne tresci dla osob niepelnoletnich (rocznikowo ponizej 18 lat)
  zgodne z par. 9 regulaminu licencji zawodniczej PZSS - takze dla statusu "Oczekujacy" (zamiast dowodu
  osobistego: legitymacja szkolna albo inny dokument tozsamosci ze zdjeciem oraz pisemna zgoda rodzica lub
  opiekuna prawnego) - oraz przypomnienie o patencie w roku 18. urodzin (gdy w danych klubu nie ma numeru
  patentu). Wersje tresci edytuje sie w zakladce "Powiadomienia o statusie" (SOKS -> Ustawienia E-mail); mozna
  je wylaczyc albo przywrocic tresc domyslna. Wersja grupy (domyslna SOKS albo zapisana przez klub) obowiazuje
  takze wtedy, gdy klub zmienil tresc standardowa statusu; tresc standardowa ta grupa dostaje tylko wtedy,
  gdy klub wylaczy jej wersje, gdy wersja ma pusty temat albo tresc albo gdy status nie ma wersji dla tej
  grupy (np. "Oczekujacy" w roku 18. urodzin). "Klub zmienil tresc standardowa" znaczy: zapisany temat albo
  tresc rozni sie od domyslnej (bez znaczenia sa ukosniki, encje, biale znaki, akapity przepisane przez edytor
  i termin na formalnosci kandydata); zapis bez zmian (np. samo przelaczenie wlacznika) nie jest zmiana. Do
  1.5.22 osoba niepelnoletnia dostawala tresc standardowa, takze zmieniona przez klub - teraz zmiany klubu
  trafiaja do tej grupy dopiero po dopisaniu ich w wersji:
  * kolumna "Wersje wiekowe" oznacza takie statusy "do przejrzenia", formularz wersji podpowiada, co zrobic,
    a po zapisie zmienionej tresci standardowej panel mowi, ktore grupy tej zmiany nie dostana;
  * jednorazowy komunikat po aktualizacji (panel administratora i panel klubu na stronie [klub] /
    [lista_czlonkow], tylko administrator strony): lista takich statusow, odnosnik do zakladki i "Ukryj ten
    komunikat"; znika sam, gdy klub zapisze albo wylaczy wszystkie wersje z listy;
  * przycisk "Przywroc tresc domyslna SOKS" / "Usun te wersje" (usuwa tresc zapisana przez klub) i komunikat
    po kliknieciu mowia, co dostaje grupa: tresc domyslna SOKS (takze gdy wersja byla wylaczona) albo tresc
    standardowa statusu.
- Wniosek o przedluzenie licencji, ponowny wydruk z historii (podglad w Dokumentach, regeneracja z SOKS
  Desktop): rok startow, dyscypliny, tabela startow i data przy podpisie wedlug daty ORYGINALU dokumentu
  (najstarszy wpis z tym numerem w historii), nie dnia wydruku. Filtr ks_dokument_prepare_data ma opcjonalny
  4. argument - date dokumentu (filtry z 3 argumentami dzialaja jak dotad). Nowa metoda
  data_dokumentu_z_historii().
- REST POST /document-generate: pole 'ostrzezenia' (tablica tekstow, dla innych dokumentow pusta) - przy
  wniosku o przedluzenie: spoznione przedluzenie, przeszkody regulaminu, brak dyscyplin, brak 4 startow,
  dyscypliny bez startow, trwajacy rok startow, warunek skladki (u mlodziezy, ktorej nie dotyczy skladka
  roczna - skladki miesieczne sekcji). Generowania nie blokuje.
- REST POST /members: gosc z SOKS Desktop ("typ_konta": "gosc") dostaje ks_typ_zawodnika = gosc jak gosc
  zapisany na stronie (dotad zakladany jako czlonek ze statusem staz); pola goscia z Desktopu (telefon,
  data_urodzenia, plec, klub_macierzysty, numer_licencji_pzss, region, pesel, zgody) trafiaja do kluczy ks_*.
- REST PUT /members/{id} i POST /members: pola wyliczane przez strone (ks_klasa_wiekowa_pzss,
  ks_podgrupa_wiekowa_pzss, 'mlodziez') nie sa zapisywane z danych klienta, takze przez alias klucza (filtr
  ks_rest_pola_tylko_odczyt). Klasyfikacja wiekowa przeliczana po zmianie daty urodzenia, PESEL lub roku
  urodzenia przez REST (PUT/POST /members, /sync) - tylko dla kont, ktorym te pola sie zmienily.
- Synchronizacja z SOZ (/soz-sync): klasa wiekowa z SOZ nie jest zapisywana (liczy ja wtyczka, konto bez
  klasy jest przeliczane); listy dyscyplin przyjmowane tylko od SOKS Desktop z poprawionym parserem SOZ
  (od 1.0.61; "parser_soz" >= 2, stala KS_SOZ_MIN_PARSER, filtr ks_soz_min_parser), pominiete -
  'pominiete_dyscypliny' i wpis w logu (pozostale pola zapisuja sie); listy zapisywane jako JSON z polskimi
  znakami (dotad "u0141").
- Numer patentu wspolny dla kafelka, wniosku PDF, generatora, zaswiadczen i przeszkod przedluzenia:
  ks_numer_patentu() (ks_patent_strzelecki, potem ks_patent_numer z synchronizacji PZSS).
- Blok spoznionego przedluzenia: data ostatniego wniosku wygenerowanego w SOKS i informacja, ze po akceptacji
  blok zniknie; przy startach w zawodach wlasnego klubu nazwa klubu z SOKS -> Dane klubu.
- Teksty licencji: komunikat par. 9 ust. 2 w formie neutralnej; spojny opis sprawdzianu przy braku 4 startow
  w dyscyplinie wiodacej (przycisk, tabela, blok spoznionego przedluzenia).
- Opis szablonu "Wniosek o przedluzenie licencji zawodniczej PZSS" zgodny z dzialaniem wniosku.
- REST GET /settings: pole 'klasyfikacja_pzss' (kategorie glowne i podgrupy z zakresami wieku).
- Dane czlonka dla SOKS Desktop (/sync, GET /members, POST /members): w 'meta' pole wyliczone 'mlodziez'
  (true/false), tylko do odczytu (filtr ks_rest_pola_wyliczone): true, gdy skladka roczna czlonka nie dotyczy
  (ks_skladka_roczna_nie_dotyczy() - mlodziez klubowa przy wlaczonym ustawieniu ks_mlodziez_skladki_miesieczne,
  z uwzglednieniem filtra ks_ukryj_skladki_nieletnich).
- Kasa (panel klubu): sekcja "Skladka roczna" w formularzu rejestracji wplaty (KP, PO) i w oknie edycji
  dokumentu (takze BP) - lata (poprzedni, biezacy, dwa nastepne i lata powiazane z dokumentem), kwota przy
  kazdym roku (domyslnie cena produktu skladki roku, do zmiany; musi byc wieksza od zera - jawne 0 albo rok bez
  ceny, na ktory z kwoty dokumentu nic nie zostaje, to blad, a nie "oplacona 0,00 zl"), suma lat wobec kwoty
  dokumentu: nadwyzka jako dobrowolna nadplata, niedobor wymaga potwierdzenia. Wstepnie zaznaczane lata: produkt
  skladki wybrany w formularzu rejestracji wplaty zaznacza rok, dla ktorego jest ustawiony
  (ks_membership_fee_configs; ten sam produkt dla kilku lat - najwczesniejszy rok nieoplacony przez czlonka),
  a bez niego - podpowiedz lat z tytulu ("skladka 2026, 2027", "skladki za 2026 i 2027", "skladka roczna 2026",
  "skladka poczatkowa"): kazda pozycja tytulu osobno, bez dat (RRRR-MM-DD, DD.MM.RRRR, MM.RRRR) i kwot
  ("2026 zl"), tylko lata od biezacego -1 do +2; bez podpowiedzi dla zwrotow, skladek miesiecznych i "skladka
  na ...". Skladke roczna oznaczaja tylko dokumenty wplywu z dodatnia kwota: KP, PO i BP (filtr
  ks_kasa_skladki_typy_wplywu).
- Kasa: odznaczenie roku, zmiana czlonka, numeru, typu na wyplyw i usuniecie dokumentu przenosza albo cofaja
  powiazane skladki roczne (przywracany stan sprzed powiazania). Lata oplacone innym dokumentem albo
  zamowieniem bez zmian, z ostrzezeniem. Rok oplacony takze zamowieniem WooCommerce, ktore przyszlo po
  powiazaniu z dokumentem, przy cofnieciu zostaje oplacony - schodzi tylko powiazanie z dokumentem; zamowienie
  za rok oplacony juz dokumentem kasowym zapisuje przy roku swoj numer (ks_skladka_dokument_wc_RRRR; status,
  kwota i data bez zmian; nie przy produkcie ustawionym dla kilku lat). Mlodziez klubowa, ktorej nie dotyczy
  skladka roczna: informacja o skladkach miesiecznych, bez nowych lat; lata juz powiazane z dokumentem ida za
  nim (numer, data) albo sa cofane (usuniecie, wyplyw, przeniesienie na inne konto). W oknie edycji wybor
  konta czlonka dokumentu. Naleznosc skladkowa czlonka rozliczana kwota skladek z dokumentu, bez drugiego
  dokumentu KP: tylko lata powiazane sekcja "Skladka roczna" (powiazan starszych wersji nie ksieguje sie
  wstecz), najwyzej kwota dokumentu. Zmiana numeru dokumentu przenosi na nowy numer takze wplaty modulu
  naleznosci z tym numerem - przy zdublowanym numerze w kasie tylko wplaty czlonka przenumerowanego dokumentu
  i tylko wtedy, gdy zaden inny dokument z tym numerem nie jest jego; inaczej zostaja przy starym numerze (bez
  drugiego ksiegowania tej samej wplaty). Do dziennika audytu trafia kazde oznaczenie, cofniecie i przeniesienie
  lat skladki z dokumentu oraz zmiana kwoty zaksiegowanej z dokumentu na naleznosc; przenumerowanie wplat
  modulu naleznosci nie ma wpisu w dzienniku audytu (w logu PHP tylko blad albo pominiecie).
- REST: POST/PUT /financial-docs przyjmuja lata_skladki (lista lat albo {rok: kwota}),
  lata_skladki_potwierdz_niedobor i lata_skladki_na_sucho; odpowiedz: lata_skladki_oznaczone i skladki.
  DELETE /transactions/{id} cofa skladki dokumentu, gdy w kasie nie zostal inny dokument o tym numerze.
  DELETE /financial-docs/{id} nie usuwa wiersza kasy i cofa skladki (czlonek: kontrahent_id dokumentu) tylko
  wtedy, gdy w kasie nie ma juz dokumentu o tym numerze; inaczej skladki zostaja (skladki.info: dokument jest
  nadal w kasie) - np. skladki dokumentu z POST /financial-docs, ktory ma tez wiersz kasy, cofa dopiero
  DELETE /transactions albo usuniecie w panelu. Odpowiedz obu: lata_skladki_cofniete (lista lat, moze byc
  pusta).
  * POST /financial-docs bez lata_skladki: rok z tytulu rozpoznawany jak w 1.5.22 ("skladka roczna [RRRR]",
    "skladka poczatkowa"; jeden rok, cala kwota dokumentu); rok oplacony bez dokumentu nie jest przejmowany
    (tylko jawne lata_skladki). Z jawnym "member_id": null (dokument na kontrahenta - kontrahent_id to wtedy
    numer z listy kontrahentow SOKS Desktop, nie konto czlonka) skladek sie nie oznacza (1.5.22 oznaczalo rok
    na koncie WordPress o numerze kontrahent_id);
  * PUT /financial-docs zmienia skladki tylko z kluczem lata_skladki. Czlonek: member_id albo kontrahent_id
    z zadania, bez nich - czlonek dokumentu w kasie; przy zmianie czlonka dokument w kasie dostaje nowe konto
    i nazwe (kontrahent_nazwa albo nazwa konta, bez obu - pusta), a przy bledzie planu (np. niepotwierdzony
    niedobor) nie zmienia sie nic. Dokumentu nie ma juz w kasie: czlonkiem jest wylacznie kontrahent_id
    dokumentu (przed i po zmianie; te sama wartosc czyta DELETE /financial-docs); member_id inny niz
    kontrahent_id nie zmienia czlonka - wybrane lata nie sa stosowane (powiazania ida tylko za numerem, data
    i kontrahent_id), w odpowiedzi ostrzezenie member_id_bez_kasy z informacja, co stalo sie z latami
    powiazanymi z dokumentem (przeszly na konto z kontrahent_id albo zostaly cofniete bez przeniesienia). PUT
    nie zapisuje member_id do kontrahent_id dokumentu (w SOKS Desktop kontrahent_id to numer z listy
    kontrahentow);
  * PUT /transactions/{id} (wiersz kasy; lata_skladki nie przyjmuje, odpowiedz bez pol skladek): po zmianie
    numeru, typu, daty albo kwoty skladki roczne powiazane z dokumentem ida za nim (numer, data) albo sa
    cofane (typ wyplywu albo kwota 0), a wplaty modulu naleznosci ze starym numerem dostaja nowy numer (zasady
    jak w punkcie o Kasie). Konta czlonka dokumentu PUT /transactions nie zmienia.
- Kasa i REST, skladki roczne tylko na istniejacym koncie WordPress: gdy czlonek dokumentu nie jest kontem
  (np. kontrahent_id z listy kontrahentow SOKS Desktop) - w panelu (nowy dokument, edycja, usuniecie),
  w POST/PUT/DELETE /financial-docs i w PUT/DELETE /transactions - zadnego roku nie oznacza sie ani nie
  przenosi na ten numer; poprzedni czlonek traci tylko lata powiazane z tym dokumentem. Wpisy lat zapisane
  pod numerem bez konta i powiazane z dokumentem (1.5.22: POST /financial-docs z tytulem "skladka roczna"
  albo "skladka poczatkowa" oznaczal rok na numerze member_id/kontrahent_id bez sprawdzenia, czy jest takie
  konto) sa usuwane przy przeliczeniu skladek tego dokumentu (np. edycja, usuniecie), bez zapisu stanu
  "nieoplacona". Gdy bylo co oznaczyc, przeniesc albo cofnac: ostrzezenie brak_konta w komunikacie panelu
  i w odpowiedzi /financial-docs (skladki.ostrzezenia, takze w DELETE); odpowiedzi /transactions go nie maja.
  Sekcja "Skladka roczna" dla konta, ktorego nie ma, pokazuje informacje zamiast lat. Przy zmianie czlonka
  przez PUT /financial-docs wiersz kasy dostaje numer z zadania takze wtedy, gdy nie jest kontem.
- Plik includes/kasa-skladki-roczne.php jest ladowany warunkowo: gdy go brak (np. usuniety przez skaner
  antywirusowy hostingu), strona dziala dalej, a w logu PHP zostaje wpis; wtedy nie ma sekcji "Skladka
  roczna" w Kasie ani lata_skladki w REST, a skladki roczne w Kasie i w REST /financial-docs sa oznaczane
  jak w 1.5.22 (takze u mlodziezy klubowej i bez sprawdzenia, czy konto istnieje).
- Skladki: jedno rozpoznanie statusu "oplacona" (rowniez "oplacone", "Oplacona", "nieoplacone") w panelu
  czlonka, przy zamowieniach WooCommerce, w warunku skladki wniosku o licencje, w komunikatorze i na listach
  czlonkow. Dane w bazie bez zmian. Nowe funkcje: ks_skladka_status_normalizuj(), ks_skladka_status_oplacona(),
  ks_skladka_oplacona().

ZMIANY (dziennik):
- Skladki miesieczne: zwolnienia jako programy - bezterminowe (puste "do" = do odwolania), pole uwagi,
  "Zakoncz zwolnienie" (ostatni miesiac, wpis zostaje w historii) obok "Usun"; aktywne zwolnienie na karcie
  skladek, w przegladzie i w kartotece. Miesiace objete zwolnieniem nie sa nalezne. Zwolnienia sprzed zmiany
  bez zmian znaczenia. Migracja dziennika 1.5.20: kolumny bezterminowo i uwagi w tabeli zwolnien
  (idempotentna, bez zmian danych). Zwolnienie bezterminowe ma w bazie flage bezterminowo i miesiac konca
  2099-12 (stala KS_DZIENNIK_SKLADKI_BEZTERMINOWO_DO); migracja dziennika 1.5.21 przepisuje tak zwolnienia
  zapisane wczesniej z pustym "do". Po powrocie do 1.5.22 zwolnienie bezterminowe dziala do 2099-12, a nie
  przez jeden miesiac.
- Klasy sportowe (domkniecie uwag recenzji 1.5.22): jedna nazwa klasy w calym dzienniku - ta z formularza
  nadania i listy nadan ("klasa trzecia", "klasa druga", "klasa pierwsza", "klasa mistrzowska", "klasa
  mistrzowska miedzynarodowa"): nominacja (powody, uwagi, kolumna "Klasa"), protokol nominacji, formularz
  i opis zawodow (wymagana klasa), informacja o dopuszczeniu, karta zawodnika, dyplom roczny. Wczesniej
  nominacja pisala "III/II/I", a dopuszczenie, karta i dyplom - surowy kod z bazy (np.
  "mistrzowska_miedzynarodowa"). Nowe funkcje: ks_dziennik_klasa_nazwa(), ks_dziennik_klasa_opis_brak_waznej(),
  ks_dziennik_klasa_z_etykieta().
- Informacja o dopuszczeniu: "brak waznej (ostatnia: klasa X, wazna do DD.MM.RRRR)" - ten sam opis co
  w powodzie odmowy nominacji.
- Regula 5 nominacji i napis klasy w jednej funkcji ks_dziennik_zawody_klasa_ocena(); tej samej uzywa protokol
  nominacji dla zawodnika spoza listy kandydatow i zna teraz wymog klasy zawodow (wczesniej przy wymogu
  spelnionym klasa z innej konkurencji pokazywal klase z konkurencji nominacji). Nowy, opcjonalny parametr
  $wymagana_klasa w ks_dziennik_zawody_druk_klasa() i ks_dziennik_zawody_druk_sekcja_protokolu().
- Klasa wpisana bez konkurencji, ktora spelnia wymog zawodow: uwaga (bez blokady) w tym samym brzmieniu co
  klasa z innej konkurencji; w kolumnie "Klasa" dopisek "(bez przypisanej konkurencji)".
- Uwaga o terminie wyliczonym z daty nadania: "jesli norme uzyskano przed rokiem nadania, waznosc tej klasy
  konczy sie najpozniej 31.12.RRRR (koniec roku nadania), czyli przed dniem rozpoczecia zawodow"; dzien normy
  sprawdza sie w komunikacie z zawodow, na ktorych ja uzyskano.
- Podpowiedz normy z wyniku: przy zawodach wewnetrznych, wojewodzkich i bez wpisanej rangi "Ten wynik siega
  progu normy" (zamiast "odpowiada normie"); warunki regulaminu dla klas II i III cytowane doslownie (zawody
  "zgodnie z przepisami ISSF i regulaminami zatwierdzonymi przez wojewodzkie zwiazki strzelectwa sportowego",
  sedziowie wyznaczeni przez kolegium sedziow WZSS). Zawody ogolnopolskie: norme na klase I i M daja MP, PP
  i jego final oraz zawody z kalendarza PZSS pod nadzorem Delegata Technicznego. Podpowiedz wywolana bez rangi
  zawodow: "Ten wynik daje norme - klasa X" (wczesniej niegramatyczne "norme na klasa X").
- Ekran kandydatow i protokol nominacji: wyliczenie regul twardych zalezy od zawodow - przy zawodach
  wewnetrznych licencja PZSS nie jest regula twarda, tylko uwaga. Nowe funkcje
  ks_dziennik_zawody_licencja_jako_uwaga(), ks_dziennik_zawody_reguly_twarde_opis().
- Osiagniecia, sciezka klas: "bez terminu" tylko przy nieczytelnej dacie nadania, osobno liczba wpisow
  z terminem wyliczonym; usuniete zdanie "wpis bez daty waznosci nie jest liczony ani jako wazny, ani jako
  przeterminowany" (nieprawdziwe od 1.5.22).
- Cytowanie Zbioru Regulaminow PZSS ujednolicone na "Warszawa, 20 grudnia 2025" (data ze strony tytulowej) -
  takze opis zestawu kategorii wiekowych (klucz pzss-zbior-2025-12-17 bez zmian; nowe plany zapisza nowy opis
  w plany.zrodlo_kategorii) i komentarze w migracji oraz KPI.
- Daty w tekstach dla czlowieka w calym dzienniku w formacie DD.MM.RRRR (nowa funkcja
  ks_strefa_trenera_data_txt(), formatuje jak ks_strefa_trenera_data(); pusta albo nieczytelna data zostaje bez
  zmian): ekran kandydatow (dzien regul startu, okno mediany), opis i lista zawodow, protokol nominacji
  (termin, dzien regul, data wydruku), dyplom (zakres sezonu, data wydruku), karta zawodnika, wydruk obecnosci,
  protokol oceny talentu, radar techniki, sugestie adaptacji, anonimizacja, skladki (data wplaty, "w klubie
  od"), KPI (K5), komunikaty walidacji treningow wlasnych, opis osi wykresu wielu serii, komunikaty formularza
  kartoteki, komunikat generatora o braku dyscypliny i panel administracyjny dziennika (wp-admin). Bez zmian:
  pola daty w formularzach, zapytania, adresy, dane zapisywane (nazwa planu, komunikat przebiegu generatora,
  audyt).
- Miesiace w tekstach dla czlowieka na ekranach trenera w zapisie MM.RRRR (np. 10.2026), jak w panelu czlonka:
  skladki miesieczne (przeglad, pasek miesiecy, zaleglosci, karta skladek, wplaty, zwolnienia, blok w kartotece,
  komunikaty po zapisie) i wydruk obecnosci (odnosniki do sasiednich miesiecy; naglowek kartki ma nazwe
  miesiaca). Wartosci w adresach, polach miesiaca i zapytaniach bez zmian.
- "Moi zawodnicy": filtr klasy wiekowej PZSS z kategorii glownych (wybor podgrupy "Mlodziezowiec" dawal
  pusta liste).

POPRAWKI:
- Klasyfikacja: rok liczony wedlug strefy czasowej strony (przebieg 1 stycznia 00:05 liczyl jeszcze poprzedni
  rok); nieczytelna data urodzenia nie daje juz roku 1970; data urodzenia z numeru PESEL zapisywana tylko, gdy
  jest poprawna; brak ostrzezen PHP przy niepelnej konfiguracji klasyfikacji i przy nieistniejacym koncie
  w masowej aktualizacji.
- Powiadomienia o statusie: temat i tresc zapisywane bez ukosnikow przed cudzyslowami i apostrofami.
- Panel nieletniego: blok "Starty wymagane" ukrywany takze przy dacie waznosci licencji DD.MM.RRRR.
- /sync: klucz glowny pola PZSS/SOZ wygrywa z aliasem z tej samej paczki (dotad - pierwsza wartosc).
- Wniosek z Dokumentow/REST: data przy podpisie wypelniana (dotad pusta), w strefie czasowej strony.
- Usuniety nieuzywany kod w ks_pzss_forms_generate_renewal().
- Zakladki "Integracja" i "Statystyki" (Ustawienia bazy): na dole zakladki wyswietlal sie tekst doklejony
  omylkowo do pliku - usuniety.
- Kopia zapasowa "tylko czlonkowie" (zakladka "Backup i przywracanie", bez zaznaczonej pelnej kopii) konczyla
  sie bledem krytycznym w PHP 8 (niezdefiniowana stala KS_PLUGIN_VERSION) i nie powstawala - w kopii zapisywana
  jest wersja wtyczki (KS_VERSION). Blad kodowania danych do JSON konczy sie komunikatem bledu (bez pustego
  pliku kopii).
- Przywracanie kopii "tylko czlonkowie" (.json, takze spakowanej do ZIP) nie przywracalo nikogo - kopia ma
  postac "info" + "members", a przywracanie czytalo ja jak sama liste czlonkow ("Przywrocono dane 0
  czlonkow"). Obslugiwane sa oba zapisy; przywracane sa jak dotad tylko pola klubowe (ks_*, imie, nazwisko).
  Zasady przywracania:
  * tryb z formularza dziala (dotad wybor byl pomijany): "Scal z istniejacymi danymi" uzupelnia tylko puste
    pola, "Pelne przywrocenie" nadpisuje pola klubowe wartosciami z kopii (pol, ktorych w kopii nie ma, nie
    usuwa);
  * przed zapisem SOKS tworzy kopie obecnych danych czlonkow (nowa kopia na liscie, takze konta z kopii spoza
    listy czlonkow); gdy nie da sie jej zapisac albo zabezpieczyc katalogu kopii, nic nie jest przywracane;
  * zawsze pomijane sa konta zanonimizowane (RODO) - w bazie albo w kopii - i pozycje bez konta w bazie; bez
    zmian zostaja kategorie wiekowe (liczone od nowa), lata skladki rocznej powiazane z dokumentem kasy lub
    zamowieniem, PESEL konta, ktore go ma, oraz zgody RODO z zapisana wartoscia (kopia nie cofa wycofania
    zgody);
  * przywrocenie jest zapisywane w dzienniku RODO (kto, kiedy, plik, tryb, liczby kont, ID kont zmienionych
    i pominietych zanonimizowanych) i w logu kopii; komunikat podaje liczby i nazwe kopii stanu sprzed
    przywrocenia.
  ks_przywroc_kopie() ma opcjonalny 2. argument (tryb 'full' albo 'merge'), ks_tworz_backup_czlonkow() -
  opcjonalne $dodatkowe_id i $opis (pole info.opis kopii).
- Kasa: okno edycji dokumentu zamyka sie kliknieciem obok, kwota z wybranego zamowienia WooCommerce znow
  wpisuje sie do formularza (skrypt przerywal sie na nieistniejacym elemencie), wzorzec numeru dokumentu
  w oknie edycji jest poprawny w nowych przegladarkach.
- Starty obce z komunikatow w ukladzie SIUS: gdy nazwisko i imie zawodnika byly wydrukowane w wierszach nad
  i pod wierszem wyniku, automat bral numer startowy za nazwisko i start trafial do nierozpoznanych.
  Nazwisko musi zaczynac sie litera, a w takim ukladzie nazwisko i imie sa skladane z wierszy obok.
- REST POST /financial-docs z numerem, pod ktorym w kasie jest juz dokument innego czlonka albo bez czlonka
  (wiersz kasy nie jest wtedy dodawany): skladki roczne nie sa oznaczane na zadnym koncie (dotad rok z tytulu
  trafial do czlonka z zadania, choc dokument w kasie nalezy do kogo innego), w odpowiedzi ostrzezenie
  kolizja_numeru.
- Kopia zapasowa z harmonogramu (WP-Cron) generowala w tle caly HTML zakladki "Backup i przywracanie".
- E-maile o zmianie statusu: tresc zapisana w edytorze zakladki "Powiadomienia o statusie" trafia do bazy bez
  znacznikow <p> i <br> (akapity jako puste linie) i w e-mailu zlewala sie w jeden ciag (takze w 1.5.22). Przy
  wysylce kazda tresc zapisana przez klub (tresc statusu i wersje wiekowe, takze wysylka testowa z formularza)
  przechodzi przez wpautop(), tak jak tresc z edytora na stronie - takze gdy ma akapit z atrybutem (np. linia
  wysrodkowana w edytorze: <p style="text-align: center">); akapity zapisane juz jako <p> nie sa podwajane.
  Tresci domyslne SOKS (klub ich nie zapisal) ida bez zmian.
- Strefa trenera -> Skladki miesieczne (modul Dziennik): poprawione wywolanie funkcji ekranu "Brak dostepu"
  (ks_sdt_ekran_brak_dostepu()) bez wymaganych argumentow w ks_dziennik_skladki_ekran() (takze w 1.5.22).
  Na stronie strefy trenera blad nie byl widoczny: osobe bez wgladu w strefe trenera zatrzymuje wczesniej
  bramka strefy (ekran "Brak dostepu"). Wywolanie ks_dziennik_skladki_ekran() z pominieciem tej bramki
  konczylo sie bledem krytycznym (ArgumentCountError) - teraz zwraca ekran "Brak dostepu".

== 1.5.22 (2026-10-12) - Licencja: wniosek z dyscyplinami spelnionymi, spoznione przedluzenie; klasy sportowe; synchronizacja z Desktopem ==
<!-- ks-wydanie-v8e-2026-10-12 -->

ZMIANY:
- Wniosek o przedluzenie licencji zawodniczej (regulamin licencji PZSS, par. 7 ust. 4 i 8):
  * na wniosek trafiaja tylko dyscypliny ze spelnionym wymogiem startow: dyscyplina wiodaca
    z co najmniej 4 startami i kazda kolejna z co najmniej 2; pozostale dyscypliny licencji
    wymagaja sprawdzianu praktycznego w klubie (par. 7 ust. 8) - przycisk, panel, PDF i Dokumenty
    mowia to wprost. Przycisk jest aktywny, gdy co najmniej jedna dyscyplina ma 4 starty
    (wczesniej tylko przy komplecie wszystkich dyscyplin licencji). Nowa funkcja
    ks_licencja_dyscypliny_do_wniosku() i filtr ks_licencja_dyscypliny_wniosku;
  * start w konkurencji laczonej (np. "Pistolet,Karabin") najpierw domyka dyscypline wiodaca do 4
    startow, potem dyscypline najblizsza 2 startow;
  * przycisk wniosku takze przy komplecie startow (dotad blok "Gratulacje" go nie mial); tekst
    bloku mowi o komplecie startow i skladaniu wniosku w Portalu PZSS zamiast "licencja
    zabezpieczona"; w tabeli "Starty wymagane" dyscyplina z kompletem startow, ale bez 4 startow
    w wiodacej, ma status "starty sa - nie na wniosku".
- Spoznione przedluzenie licencji: gdy licencja w danych klubu byla wazna do konca poprzedniego
  roku, panel pokazuje blok "Licencja na rok Y - spoznione przedluzenie" ze startami z roku
  poprzedniego, a przycisk generuje wniosek na rok biezacy (bez bramki 1 listopada, skladka za rok
  licencji). Na stronie z synchronizacja SOZ tryb wlacza sie tylko po synchronizacji w biezacym
  roku (stare dane nie wlacza go blednie); licencja wygasla dawniej - tylko komunikat. Nowe
  funkcje ks_licencja_cel_wniosku(), ks_licencja_rok_waznosci(); filtr
  ks_licencja_dane_licencji_aktualne.
- Dokumenty generowane przez REST (SOKS Desktop), wp-admin i ponowny wydruk przechodza przez filtr
  ks_dokument_prepare_data tak jak w panelu - wniosek o przedluzenie z Desktopu ma te same
  dyscypliny, rok startow i historie co z panelu.
- Synchronizacja z SOKS Desktop (/sync): dane z PZSS/SOZ (licencja, patent, licencje sedziego
  i instruktora, klasa wiekowa, badania, data synchronizacji SOZ) nie sa juz nadpisywane kopia
  z Desktopu - push uzupelnia je tylko, gdy pole jest puste (takze stare aliasy, np.
  ks_nr_licencji, ks_license_status). Pominiete zmiany sa liczone (pominiete_soz) i logowane.
  Listy kluczy: filtry ks_sync_klucze_soz i ks_sync_aliasy_kluczy_soz.
- Panel czlonka, kafelek "Patent strzelecki": "bezterminowy" tylko przy numerze patentu (takze
  z ks_patent_numer); status z PZSS inny niz "Wazne" wyswietlany jako "status w PZSS: ..." -
  bez blokad.

POPRAWKI:
- Wniosek o przedluzenie w Dokumentach: przy PESEL krotszym niz 11 cyfr albo pustym generowanie
  konczylo sie bledem PHP 8 (str_pad z pustym dopelnieniem).
- Wniosek o przedluzenie: komunikaty diagnostyczne do logu serwera tylko przy WP_DEBUG.
- Dziennik, formularz nadania klasy: data waznosci wczesniejsza niz data nadania jest odrzucana
  (przy normie uzyskanej przed nadaniem podaj "Dzien uzyskania normy").

ZMIANY (dziennik):
- Klasy sportowe w dzienniku wg Zbioru Regulaminow PZSS (Warszawa, 20 grudnia 2025, rozdz. 6,
  "Informacja o przyznanych klasach sportowych i ich ewidencja", pkt 4: waznosc od dnia
  uzyskania normy do konca nastepnego roku kalendarzowego):
  * formularz nadania: nowe pole "Dzien uzyskania normy" (tylko do wyliczenia terminu, bez nowej
    kolumny) - z nim dziennik zapisuje date waznosci 31.12 roku nastepnego; bez dnia normy i bez
    daty waznosci zapisuje NULL, a termin liczy w locie z daty nadania i oznacza "termin wyliczony
    z daty nadania"; reczna data waznosci pozniejsza niz regulamin jest odrzucana z komunikatem;
    sprawdzany format i istnienie dat (nadanie, norma, waznosc);
  * wpisy bez daty waznosci (NULL): termin liczony w locie z daty nadania (bez zapisu do bazy),
    na karcie, liscie nadan, w Osiagnieciach i na dyplomie z dopiskiem o terminie wyliczonym;
  * jedno pojecie "klasy na dzien" - NAJWYZSZA klasa wazna danego dnia (nowa funkcja
    ks_dziennik_klasa_na_dzien(); przy tej samej klasie pierwszenstwo ma wpis z konkurencja
    nominacji i z wpisanym terminem): nominacja (regula 5) na dzien rozpoczecia zawodow, najpierw
    w konkurencji nominacji, klasa z innej konkurencji albo bez wpisanej konkurencji przechodzi
    z uwaga; informacja dopuszczenia, protokol nominacji, karta zawodnika; dyplom roczny -
    najwyzsza klasa wazna 31.12 sezonu, tylko z nadan do konca sezonu (wczesniej najnowsze
    nadanie, takze po sezonie);
  * podpowiedz normy z wyniku (ranga z wiersza startu): klasa MM nigdy za wynik; zawody
    wewnetrzne i wojewodzkie - najwyzej II, z zastrzezeniem warunkow regulaminu;
  * nominacja na zawody wewnetrzne (klubowe): brak licencji PZSS to uwaga, nie powod odmowy (filtr
    ks_dziennik_zawody_wewnetrzne_bez_licencji, domyslnie true); inne rangi bez zmian. Ekran
    kandydatow przy zawodach bez rangi podpowiada ustawienie rangi "zawody wewnetrzne".
  UWAGA PRZED AKTUALIZACJA (klub z historia klas w dzienniku): wpisy bez daty waznosci z nadaniem
  sprzed poprzedniego roku licza sie teraz jako przeterminowane - nominacja z wymagana klasa zostanie
  odrzucona (termin wyliczony z daty nadania jest najdluzszy mozliwy, wiec to rozstrzygniecie jest
  pewne). Zapytanie TYLKO DO ODCZYTU (prefiks tabel wp_ zamien na wlasny):
    SELECT
      COALESCE(SUM(data_waznosci IS NULL
          AND data_nadania < CONCAT(YEAR(CURDATE()) - 1, '-01-01')), 0) AS null_przeterminowane,
      COALESCE(SUM(data_waznosci IS NULL), 0) AS null_wszystkie,
      COALESCE(SUM(data_waznosci > CONCAT(YEAR(data_nadania) + 1, '-12-31')), 0) AS dluzsze_niz_regulamin
    FROM wp_ks_dziennik_klasy_sportowe
    WHERE deleted_at IS NULL;
  null_przeterminowane > 0: te klasy wedlug regulaminu juz wygasly - termin liczony z daty
  nadania jest najdluzszy mozliwy, wiec wpisanie daty waznosci ich nie przywroci (formularz nie
  przyjmie daty pozniejszej niz regulamin). Sprawdz date nadania z dokumentem: jesli wpisano ja
  blednie, usun wpis i dodaj nadanie z poprawna data (dziennik nie ma edycji wpisu klasy). Nowa
  waznosc wymaga nowej normy, czyli nowego nadania. Sprawdz wymagane klasy w zaplanowanych
  zawodach. dluzsze_niz_regulamin > 0: daty wpisane recznie dluzsze niz regulamin - kod ich nie
  poprawia, sprawdz je z dokumentem.
  <!-- ks-klasy-v8c-2026-10-12 -->

== 1.5.21 (uzupelnienie 2026-10-12) - Mlodziez do 23 lat rocznikowo, metryczki bez dubli, przyciski rejestracji ==
<!-- ks-mlodziez-v4-2026-10-12 -->

ZMIANY:
- Zasady sekcji mlodziezowej w panelu czlonka (bez kafelkow skladki rocznej, w "Moje zawody"
  tylko wlasne zgloszenia, zapis na zawody tylko przez administratora) obejmuja: osoby
  rocznikowo niepelnoletnie oraz osoby z kartoteki sekcji mlodziezowej do konca roku, w ktorym
  koncza 23 lata (do wejscia w kategorie Senior). Wiek liczony rocznikowo, jak kategorie
  wiekowe PZSS. Filtr ks_mlodziez_wiek_rocznikowy_max (domyslnie 23). Nowe funkcje:
  ks_wiek_rocznikowy(), ks_czy_w_sekcji_mlodziezowej().
- Kategorie wiekowe zawodow (edycja zawodow > "Kategorie wiekowe PZSS") obowiazuja przy
  SAMODZIELNYM zapisie: panel czlonka (formularz, zapis, przyciski "Zarejestruj"/"Dokup
  start") i formularz [rejestracja_zawody] (czlonek - data urodzenia z konta, gosc - z
  formularza). Kategoria liczona rocznikowo na rok rundy, progi z klasyfikacji SOKS. Pusta
  lista = bez ograniczen. Reczna rejestracja administratora bez zmian - tak zapisuje sie
  m.in. start w wyzszej kategorii wiekowej (Zbior Regulaminow PZSS, rozdz. 3). Formularz
  [rejestracja_zawody] pokazuje dopuszczone kategorie. Nowe funkcje:
  ks_zawody_kategoria_samozapisu(), ks_zawody_kategoria_komunikat().
  <!-- ks-kategorie-samozapis-v6-2026-10-12 -->
- Zgodnosc z przepisami PZSS (regulamin licencji zawodniczej, Zbior Regulaminow PZSS (20.12.2025)):
  * do przedluzenia licencji licza sie tylko starty w zawodach z kalendarza PZSS/WZSS (par. 7
    ust. 4 lit. b) - ranga pusta, "Klubowe" albo nieznana juz sie nie liczy; panel czlonka liczy
    tak samo jak wniosek (wczesniej liczyl kazdy start). "Miedzynarodowe" - jak dotad (filtr
    ks_licencja_ranga_miedzynarodowa_liczy);
  * przeszkody poza startami: licencja w danych klubu wygasla przed rokiem startow (par. 7 ust. 4)
    oraz rok 18. urodzin bez numeru patentu (par. 9 ust. 6) - panel nie pisze wtedy "licencja
    zabezpieczona", przycisk wniosku pokazuje powod. Nowa funkcja ks_licencja_blokady_przedluzenia();
  * dziennik: kategorie wiekowe wg Zbioru PZSS (mlodzik do 16, junior mlodszy 17-18, junior 19-21,
    senior od 22), wiek rocznikowo - zestaw pzss-zbior-2025-12-17 domyslny;
  * coroczne przeliczenie klas wiekowych zawsze 1 stycznia (cykl 365 dni przesuwal sie po roku
    przestepnym na 31 grudnia).
  <!-- ks-licencja-pzss-v7-2026-10-12 -->

POPRAWKI:
- Lista czlonkow, kolumna "Licencja PZSS": kolor waznosci liczony z daty waznosci licencji
  (ks_data_waznosci_licencji, jak kolumna "Data waznosci licencji"); wczesniej czytany byl klucz,
  ktorego nic nie zapisuje, wiec kazda licencja, takze wygasla, byla zielona. Bez daty - szary.
- Przeszkoda "rok 18. urodzin bez patentu" uznaje tez numer patentu z synchronizacji (ks_patent_numer).
  <!-- ks-licencja-kolumna-v8a-2026-10-12 -->
- Zapytania ze zlaczeniami usermeta (tabela i licznik Rejestracji, takze AJAX; lista
  uzytkownikow w edycji rejestracji; starty obce; eksport XML PZSS; karnety) licza kazdy
  wiersz raz (GROUP BY / COUNT DISTINCT), choc konto ma zdublowany klucz. Ranking IPSC:
  zlaczenie z rejestracjami mnozylo wiersze wynikow (np. 3979 zamiast 540) - GROUP BY iw.id
  (punkty byly liczone poprawnie, bo ranking przypisuje wynik do rundy).
- Rejestracje: usuniety przycisk "Filtruj" - Zawody/Runda przeladowuja filtr same, Status
  i Pokaz filtruja tabele od razu, Enter w wyszukiwaniu wysyla filtr na serwer.
  <!-- ks-bez-dubli-v5-2026-10-12 -->
- Metryczki: konkurencja drukowala sie dwa razy, gdy konto mialo zdublowany wiersz w usermeta
  (np. dwie daty urodzenia) - zlaczenia mnozyly rejestracje. Generator bierze kazda rejestracje
  raz.
- Zarzadzanie zawodami > Rejestracje: zmiana "Zawody" albo "Runda" przeladowuje filtr jak
  "Filtruj". Wczesniej odswiezala tylko tabele, a przyciski (CSV, listy startowe, lista wg
  konkurencji, metryczki) i kafelki podsumowania zostawaly dla poprzedniego wyboru.

== 1.5.21 (2026-10-09) - Nieletni: dopuszczenie klubowe PN, bez kafelkow skladki ==
<!-- ks-nieletni-dopuszczenie-pn-2026-10-09 -->

NOWE:
- Dopuszczenie klubowe nieletnich "PN - Pneumatyczna" jako OPCJA klubu, domyslnie WYLACZONA:
  przepisy PZSS dopuszczaja nieletnich do konkurencji P/K/S (regulamin licencji zawodniczej
  par. 9, Zbior Regulaminow PZSS - konkurencje mlodzikow i juniorow mlodszych), ograniczeniem
  sa kategorie wiekowe konkurencji. Wlaczenie: filtr ks_dopuszczenie_klubowe_nieletnich
  zwracajacy array('kod' => 'PN', 'nazwa' => ..., 'opis' => ...). Dane licencji z SOZ
  (dyscypliny P/K/S) zawsze zostaja bez zmian.

POPRAWKI:
- Panel czlonka: osoba niepelnoletnia nie widzi kafelkow skladki rocznej (pokazywaly
  nieprawde). Filtr ks_ukryj_skladki_nieletnich przywraca je w klubie, w ktorym nieletni
  placa skladke roczna.
- Nieletni CZLONEK = ponizej 18 lat i nie gosc z innego klubu (ks_czy_nieletni_czlonek());
  rola WordPressa nie decyduje (czlonkowie bywaja subskrybentami). Gosci zasady klubu dla
  nieletnich nie dotycza.
- Moje zawody, nieletni czlonek: sekcja zawodow pokazuje tylko rundy, na ktore jest zapisany
  (status, platnosc, lista startowa), bez "Zarejestruj" i "Dokup start"; samodzielny zapis
  przez formularz z linku i przez publiczny formularz [rejestracja_zawody] jest zablokowany
  komunikatem (administrator zapisuje go dalej). Blok "Starty wymagane do utrzymania licencji"
  ukryty, dopoki licencja jest wazna dluzej niz do konca biezacego roku albo brak daty
  waznosci (w roku 18. urodzin blok wraca). "Twoje osiagniecia" zostaja.
- Formularz danych czlonka: ks_licencja_dyscypliny (dane licencji PZSS z SOZ) zapisuje sie
  tylko, gdy formularz wyswietlil edytowalna grupe z opcjami (znacznik
  ks_dyscypliny_w_formularzu); pola checkbox_group nie przechodza juz przez pierwsza petle
  zapisu (tablica dawala tam pusty napis). Gdy klub wlaczy opcje PN (filtr
  ks_dopuszczenie_klubowe_nieletnich), nieletni czlonek widzi w tym polu PN, zapis P/K/S jest
  u niego pomijany, a daty urodzenia i PESEL nie zmienia sam; flaga liczona od nowa po zapisie.
  <!-- ks-nieletni-v2-2026-10-09 -->
- Kartoteka nieletnich: podpowiedz pod "Licencja wazna do" mowila "wpisz 31 grudnia tego
  roku". Licencja nieletniego jest wazna do konca roku, w ktorym konczy 18 lat (regulamin
  licencji zawodniczej PZSS, par. 9 ust. 2).

== 1.5.20 (2026-10-08) - Kolumna akcji w tabelach zawsze na ekranie ==

POPRAWKI:
- Lista czlonkow i 20 innych tabel na stronach klubu (panel klubu: kasa, zamowienia WC,
  rozrachunki, historia dokumentow; zawody: rejestracje, zgloszenia startow obcych, wyniki,
  funkcyjni, lista gosci, karnety, lista zawodow, konkurencje i slownik konkurencji; konto
  zawodnika: moje zawody, moje zamowienia, moje zgloszenia): na ekranach 1280-1536 px
  (laptop, Windows ze skalowaniem 125%) tabela wychodzila
  poza panel i ostatnia kolumna z przyciskami (Usun, Zarejestruj, Zaplac teraz, Zatwierdz...)
  lezala poza ekranem albo pod widzetem Facebooka. Teraz tabela przewija sie w swoim pudelku
  ze stale widocznym paskiem, a kolumna akcji stoi przy prawej krawedzi niezaleznie od
  przewiniecia (od 769 px; ponizej tabela jest kartami jak dotad).
- Zamowienia WC w panelu klubu: opakowanie z overflow:hidden ucinalo Usun bez mozliwosci
  przewiniecia.
- Lista czlonkow: jeden dluznik na stronie (formularz "Rozlicz przez kase" w jednej linii)
  chowal Usun nawet przy 1920 px - formularz sie zawija.
- Wpis wynikow: pole z kursorem nie chowa sie pod przyklejona kolumna; podswietlenie wiersza
  (blad IPSC, DSQ, DNS) obejmuje takze kolumne akcji.
- Telefon (tryb kart): przyciski akcji, dlugie nazwiska i status zamowienia zawijaja sie
  w karcie zamiast wypychac ja w bok; wiersze dopisywane AJAX-em (wyszukiwanie, filtry)
  dostaja etykiety kolumn.

== 1.5.19 (2026-10-08) - Automat startow obcych czyta caly kalendarz PZSS ==

POPRAWKI:
- Starty obce > automat z kalendarza PZSS: odnosnik do zawodow czytany w calosci z atrybutu
  href, numer zawodow z konca adresu. Dawny wzorzec gubil 28 ze 186 pozycji kalendarza 2026:
  nazwy z wielka polska litera (np. Puchar Swiata ISSF), z twarda spacja, dwukropkiem,
  cudzyslowem lub ukosnikiem, a przy spacji w nazwie bral numer z samej nazwy (2026 zamiast
  numeru zawodow).
- Adres strony zawodow kodowany czlon po czlonie (polskie litery, spacje, cudzyslowy) - takie
  strony nie koncza sie juz bledem pobrania.

== 1.5.18 (2026-10-07) - Licznik instalacji probnych ==

NOWE:
- Licencja: instalacja bez klucza licencji (okres probny i po nim) raz na dobe anonimowo
  zglasza sie do serwera licencji (action trial/register): skrot SHA-256 losowego
  identyfikatora instalacji i numer wersji - bez adresu strony (wlasny User-Agent zamiast
  domyslnego WordPressa), e-maila i danych klubu. Pierwsze zgloszenie zaraz po wlaczeniu
  wtyczki. Bez czekania na odpowiedz - dzialanie wtyczki sie nie zmienia.

== 1.5.17 (2026-10-07) - Bezpieczenstwo listy gosci, dane klubu na dokumentach, skladka za biezacy rok ==

BEZPIECZENSTWO:
- Panel zawodow > Lista gosci: edycja, usuwanie i awans dzialaja wylacznie na kontach gosci.
  Wczesniej parametr edit_goscia przyjmowal ID dowolnego uzytkownika - formularz pokazywal dane
  czlonka lub administratora i pozwalal zmienic mu e-mail (przejecie konta przez reset hasla).
- Lista czlonkow [lista_czlonkow], panel klubu [klub], panel zawodow [zawody] i lista gosci:
  dolna granica dostepu w kodzie - tylko personel klubu (administrator, zarzad, menadzer, trener,
  instruktor oraz role z uprawnieniem "Wejscie do panelu klubu"). Zalogowany zawodnik, gosc czy
  klient sklepu nie zobaczy kartoteki nawet bez ustawionej "Kontroli dostepu" strony.
  Panel zawodow wymaga teraz zalogowania (wczesniej polegal tylko na ustawieniu strony).
- Formularz goscia na zawody: klauzula informacyjna z art. 13 RODO zamiast wymuszonej zgody;
  administrator danych z ustawien klubu; opiekun prawny nieletniego goscia.
- Biblioteki JavaScript (flatpickr, Sortable, html5-qrcode) z paczki wtyczki zamiast publicznych
  CDN - przegladarka nie wysyla adresu IP odwiedzajacego do jsDelivr ani unpkg.
- Dziennik: anonimizacja zawodnika usuwa tez prognozy i adaptacje; bez pola korekcji wzroku;
  samotesty automatu startow obcych tylko z wiersza polecen.

NOWE:
- SOKS > Dane klubu: status organizacji pozytku publicznego (OPP) oraz statut - paragraf
  o kolekcjonerach i data uchwalenia. Naglowek "ORGANIZACJA POZYTKU PUBLICZNEGO" na deklaracji
  i zaswiadczeniach drukuje sie tylko przy zaznaczonym OPP; zaswiadczenie kolekcjonerskie bierze
  paragraf i date z ustawien (puste = widoczny znacznik "[uzupelnij...]" na dokumencie).
  Do pierwszego zapisu Danych klubu po aktualizacji panel pokazuje przypomnienie.

NAPRAWIONE:
- Wzory dokumentow mialy na sztywno naglowek OPP oraz paragraf i date statutu jednego klubu -
  kazdy klub drukowal je na swoich zaswiadczeniach.
- Usuniete ID produktow sklepu wpisane na sztywno: skladka rejestracyjna bierze produkt
  z Konfiguracji rejestracji, zakladka "Wplaty" - z konfiguracji skladek na dany rok.
- Lista czlonkow (strona i panel WP): kolumna i filtr "Skladka" pokazuja biezacy rok zamiast 2026.
- Zakladka "Wplaty": sekcja skladki wedlug konfiguracji roku - bez falszywego "nieoplacona",
  gdy klub nie otworzyl jeszcze nowego roku, i bez komunikatu o bledzie, gdy produkt nie jest ustawiony.
- Platnosc online skladki: gdy ten sam produkt jest ustawiony dla kilku lat, wplata trafia do
  najwczesniejszego nieoplaconego roku (wczesniej do pierwszego z listy, takze juz oplaconego).
- Dokumenty generowane z panelu WP, z SOKS Desktop i ponownie z historii: uzupelnione dane klubu
  (KRS, NIP, REGON, nazwa w dopelniaczu, numer PZSS, skarbnik, rachunek) - wczesniej puste.

== 1.5.16 (2026-10-04) - Karnet w panelu czlonka, konkurencje laczone, termin dla kandydatow ==

NOWE:
- Moje konto > Moje Zawody: zapis z karnetem. Czlonek z aktywnym karnetem widzi pole
  "Mam karnet - chce wykorzystac starty"; konkurencje pokryte karnetem sa zapisywane od razu
  jako oplacone (0 zl), pozostale ida do kasy. Wczesniej karnet obslugiwal tylko formularz
  publiczny [rejestracja_zawody], z ktorego czlonkowie nie korzystaja.
- Karnet a konkurencje laczone (np. typ broni "Pistolet,Karabin"): start zdejmuje JEDEN start
  z dowolnej posiadanej puli (tej, w ktorej zostalo wiecej). Powiazanie karnetu z rejestracja
  zapisuje pule, z ktorej zszedl start, wiec anulowanie oddaje go tam samo. Dotyczy obu
  formularzy zapisu, przycisku "Rozlicz karnetem" i zakladki Karnety.
- Termin na formalnosci kandydata: ustawienie w "Powiadomieniach o zmianie statusu" (liczba dni,
  0 = bez terminu - domyslnie). Termin podaja komunikat po rejestracji online, e-mail powitalny
  i domyslna tresc dla statusu "Oczekujacy"; lista czlonkow oznacza kandydatow "zostalo N dni" /
  "po terminie od N dni" i pokazuje pasek z liczba kont po terminie. Konta NIE sa usuwane
  automatycznie - decyzje i usuniecie wykonuje zarzad.

NAPRAWIONE:
- Status licencji (Moje Zawody) i wniosek o przedluzenie licencji PZSS: start w konkurencji
  laczonej byl pomijany w obu licznikach. Liczy sie teraz jako jeden start w tej z dyscyplin
  konkurencji, w ktorej zawodnikowi brakuje startow (4 w wiodacej, po 2 w kolejnych).
- Lista zgloszen na zawody: zapis z karnetu zlozony przez zawodnika jest oznaczony "Internet"
  (wczesniej "Recznie", bo nie ma zamowienia w sklepie).
- E-mail powitalny po rejestracji online: zamiast "zarzad skontaktuje sie z Toba" - wezwanie
  do biura z dowodem osobistym i zdjeciem.
- Rejestracja goscia na zawody: tozsamosc zalogowanego bierze sie z jego konta, nie z wpisanego
  e-maila; personel nie zamienia konta czlonka w konto goscia (lista gosci, reczna rejestracja).
- Komunikaty PZSS: "REZULTATY FINALU" i "FINAL RESULTS" to osobne sekcje, a tabela
  "I start / II start / SUMA" zamyka sekcje - final ani suma dwoch startow nie trafiaja
  juz do kartoteki jako start z cudza nazwa konkurencji (komunikat klasyfikacyjny MPM
  2026: suma 80 strzalow byla zapisana jako osobny start i wygrywala z prawdziwym
  wynikiem w "najlepszym starcie" strefy trenera).
- Strony z zapisami na zawody i formularzami nie ida juz z cache strony (LiteSpeed Cache
  trzymal je 7 dni): lista rund pokazywala "Wkrotce rejestracja" jeszcze po starcie
  zapisow, nieaktualny byl koniec zapisow i licznik miejsc, a nonce w zapamietanym
  formularzu wygasal po dobie - logowanie z takiej kopii po cichu nie przechodzilo,
  a zgloszenie goscia konczylo sie "Sesja wygasla". Dotyczy stron z [rejestracja_zawody],
  [moje_konto_klubowe], [ks_formularz_logowania], [ks_formularz_rejestracji]
  i [ks_formularz_odzyskiwania_hasla]; pozostale strony dalej ida z cache.
- Rejestracja na zawody: "Rejestracja rozpocznie sie 08.11.2026 o 00:00" zamiast
  "08.11.2026 2026 00:00" (litera "o" w formacie daty byla brana za rok).

== 1.5.15 (2026-09-30) - Parser komunikatow PZSS, blokada dubli w automacie, lista czlonkow na telefonie ==

NAPRAWIONE:
- Starty obce z komunikatow: automat nie dopisuje drugiego wpisu, gdy zawodnik wpisal
  ten sam start skrotem ("PSP 30" obok "25m Pistolet sportowy 30 Open"). Ten sam dzien,
  wynik i typ broni to jeden start - ta sama kontrola co przy zgloszeniu, zatwierdzeniu
  i wpisie administratora. Start odrzucony wczesniej przez czlowieka nie wraca.
- Ten sam wynik z dwoch roznych broni tego samego dnia to dwa starty, nie duplikat
  (dotyczy takze zgloszenia, zatwierdzenia i wpisu administratora).
- Rozbieznosc z komunikatem nie kasuje juz uwag wpisu: notatka automatu jest dopisywana
  jeden raz, tak samo przy automatycznym zatwierdzeniu zgloszenia.
- Komunikaty PZSS: w 25m pistolecie standardowym 2x30 zapisywany jest wynik meczu, a nie
  suma pierwszej fazy; odczytywani sa tez zawodnicy z nazwiskiem przeniesionym do
  wiersza wyzej.
- Komunikaty PZSS: wynik meczu zamiast sumy czesci takze w 25m pistolecie sportowym
  30+30 (tez "Precision/Rapid", "Czesc 1/2", "Stage 1/2"), w fazach "20s/10s", "20 sek",
  "150s" oraz w 3x20/3x40 - starty trzech postaw nie byly dotad zapisywane wcale.
- Komunikaty PZSS: bez przypadkowych liczb - DNS nie tworzy startu z numerem sasiada,
  kolumna Punkty ani dziesiatki bez litery x nie sa brane za wynik; DNF tworzy start
  z wynikiem z komunikatu (takze 0-0x); klub w osobnym wierszu pod nazwiskiem jest
  rozpoznawany; wiersze finalu drukowanego bez naglowka nie daja drugiego startu.
- Komunikaty PZSS: nazwa klubu bez wzgledu na wielkosc liter i zlamana na dwa wiersze
  (reszta nazwy wiersz nizej albo poczatek wiersz wyzej); stanowisko oznaczone litera
  (pistolet 25 m) i etykiety "Faza 1/2", "Postawa 1/2" oraz etykieta sklejona z nazwa klubu.
- Komunikaty PZSS: daty i naglowki po angielsku (JUN, SEPT, "RESULTS", "Version"),
  klasyfikacja "KLUB" pomijana jak druzynowa, miejscowosc nie jest juz linia daty.
- Starty obce: automat znajduje komunikat z wynikami takze wtedy, gdy nazwa pliku nie
  mowi, ze to wyniki ("IRUNDAPUCHARUPOLSKIOPEN2026.pdf") albo ma literowke ("REZULATAY...");
  plik o obojetnej nazwie wybierany jest po tresci. Dotad takie zawody czekaly na
  rezultaty bez konca.
- Lista czlonkow na telefonie: edycja czlonka otwierala sie dopiero za drugim
  stuknieciem. Od 1.5.13 przyciski akcji maja min. 64 px, a komorka akcji nie
  zawijala wiersza - czlonek z 4-5 przyciskami (oczekujacy, subskrybent) rozpychal
  strone szerzej niz ekran, a Chrome traktowal ja wtedy jak strone niemobilna
  (podwojne stukniecie powieksza, stukniecia opoznione). Przyciski zawijaja sie
  teraz w obrebie karty.

ZMIENIONE:
- "Moje zgloszenia startow w innych klubach": wycofania automatu (starty, ktorych czlonek
  sam nie zglaszal) nie sa pokazywane czlonkowi; zostaja w panelu administratora.

== 1.5.14 (2026-09-28) - Starty obce z komunikatow, slad platnosci, DNF klasyfikowany, rangi PZSS/WZSS ==

BEZPIECZENSTWO:
- Logowanie /soks/v1/auth/login i webhook bez zaszytego klucza, porownania stalo-czasowe.
  SOKS Desktop laczy sie haslem aplikacji i tej zmiany nie odczuje; ewentualny wlasny
  klucz klienta ustawia sie w panelu.

DODANE:
- Starty obce z komunikatow: automat przeglada kalendarz PZSS i komunikaty z wynikami
  (takze zawody miedzynarodowe z SCloud) i dopisuje starty zawodnikow klubu, bez
  duplikatow. Ustawienia, kolejka i lista "do wpisania recznie" w panelu.
- Zatwierdzony start obcy trafia na karte zawodnika w Dzienniku trenera.
- Slad platnosci: zamowienie jest oplacone dopiero po potwierdzeniu operatora platnosci
  (Przelewy24) albo po dokumencie KP; bez tego wraca do "Oczekujace na platnosc".
- Zapis zawodnika z kartoteki nieletnich na zawody i wynik na jego karcie.

ZMIENIONE:
- Wyniki: zawodnik z DNF w konkurencjach statycznych jest klasyfikowany (miejsce wedlug
  wyniku czesciowego); po zmianie statusu DNS/DNF/DSQ miejsca przeliczaja sie bez luk.
- Ranga zawodow: PZSS i WZSS zamiast "Ogolnopolskie" i "Wojewodzkie" - istniejace dane
  przepisuje migracja; ranga spoza listy nie zmienia sie przy edycji.
- Rejestracje: platnosci zbiorcze tylko po wybraniu akcji; formularz edycji wiersza
  ladowany na zadanie; pole "Na protokole" zapisuje sie od razu.
- Podglad dokumentu otwiera sie w nowej karcie; arkusze stylow odswiezaja sie po kazdej
  zmianie pliku.

NAPRAWIONE:
- SOKS Desktop: status DNF lub DNS ustawiany dla rundy albo konkurencji nie zamienia sie
  w DSQ i nie zeruje wyniku.
- Starty obce: rozpoznawanie nieletnich z kontem i kartoteka oraz konkurencji
  rozgrywanych dwa dni; kontrola schematu Dziennika po moscie startow obcych.

== 1.5.13 (2026-09-21) - Lista czlonkow na telefonie ==

ZMIENIONE:
- Przyciski akcji na liscie czlonkow maja wieksze pola dotyku (min. 44 px), wiec
  pojedyncze stukniecie dziala na telefonie.

== 1.5.12 (2026-09-16) - Komunikat klasyfikacyjny: dyskwalifikacje w protokolach, klasyfikacja zbiorcza czesci dynamicznej ==

NAPRAWIONE:
- Protokol konkurencji dynamicznej pomijal zawodnikow ze statusem DNS/DSQ, ktorzy nie maja
  zmierzonego czasu. Zdyskwalifikowany przed strzelaniem znikal z wydruku, mimo ze w panelu
  byl widoczny. Wpis ze statusem trafia teraz do protokolu, z czasem wypisanym jako mysnik.
- Klasyfikacja zbiorcza czesci dynamicznej pokazywala zero dla rundy, ktora odbyla sie przed
  wlaczeniem modulu IPSC i ma wyniki wylacznie w zaimportowanym protokole zbiorczym.
  Punkty z takiego protokolu sa doliczane.
- Zestawienie zbiorcze konkurencji tarczowych zakladalo osobne sekcje dla protokolow czesci
  dynamicznej, nazwane tytulem pliku z importu Practiscore zamiast nazwa biezacych zawodow.
  Protokoly czesci dynamicznej sa w tym zestawieniu pomijane - maja wlasna klasyfikacje.
- Dywizja zawodnika znanego wylacznie z zaimportowanego protokolu bywala wypelniona nazwa
  druzyny. Uzupelniana jest dywizja zawodow, gdy zawody maja dokladnie jedna.

ZMIENIONE:
- Klasyfikacja zbiorcza czesci dynamicznej liczy N-1 najlepszych rund, tak samo jak
  zestawienia konkurencji tarczowych. Runda odrzucona jest wyszarzona w tabeli.
- Tytul tabeli i regula liczenia powtarzaja sie na kazdej stronie zestawienia, nie tylko
  na pierwszej - przy klasyfikacji na kilkanascie stron wiadomo, co sie czyta.
- Nazwa klubu nie lamie sie na dwie linie w tabelach o kilkunastu kolumnach.
- Panel nie zapowiada liczby stron dokumentu, tylko liczbe protokolow - liczba stron
  zalezy od liczby wynikow i potrafi byc kilkadziesiat.

== 1.4.4 (2026-08-16) - Biblioteka Metodyki i narzędzia trenera w Dzienniku; saldo kasy liczone z dokumentów ==

Wydanie rozwija moduł Dziennika elektronicznego o metodykę treningową i narzędzia
analityczne dla trenera oraz poprawia liczenie stanu kasy po stronie strony klubu.

DODANE:
- Biblioteka Metodyki — nowy moduł z bazą metodyki treningowej (plany, ćwiczenia,
  zasady), z osobnym uprawnieniem i przełącznikiem w panelu. Trener widzi bibliotekę
  w swojej strefie, a osoba uprawniona edytuje treści z historią wersji, przywracaniem
  i zatwierdzaniem.
- Generator planu treningowego zawodnika w Dzienniku — układa plan na podstawie reguł
  metodycznych i kalendarza.
- Prognozator adaptacji zawodnika — szacuje przewidywaną adaptację na podstawie wpisów.
- Progres/regres zawodnika — procent frekwencji i miniatura wykresu progresu na listach,
  pełne wykresy w „Zestawieniach", z rozdzieleniem wyników treningowych i zawodniczych.

NAPRAWIONE:
- Stan kasy na stronie klubu liczony jest teraz wprost z dokumentów kasowych
  (suma KP − KW), tak samo jak raport kasowy. Kafelek „Aktualny stan kasy" oraz kontrola
  dostępnych środków przy rejestrowaniu wydatku (KW) pokazują realny stan kasy także wtedy,
  gdy dokumenty powstają w aplikacji SOKS Desktop — wcześniej mogły w takim układzie
  pokazywać 0 i blokować wystawienie wydatku.

UWAGA: przy pierwszym włączeniu modułu Metodyki powstają jego tabele; istniejące dane
nie są zmieniane.

== 1.4.3 (2026-08-13) - Dziennik: zakładanie zawodnika od razu z grupą, ręczne sprawdzanie aktualizacji ==

DODANE:
- Formularz zakładania nowego zawodnika w panelu Dziennika (SOKS -> Dziennik) pozwala
  od razu przypisać go do grupy treningowej, a opcjonalnie także wskazać dyscyplinę
  i trenera prowadzącego. Wcześniej trzeba było najpierw zapisać samą kartotekę,
  wrócić na listę zawodników i dopiero w edycji dodawać przypisanie do grupy.
- Przycisk „Sprawdź aktualizacje" na stronie SOKS -> Licencja — pozwala ręcznie
  sprawdzić, czy dostępna jest nowsza wersja wtyczki, bez czekania na automatyczne
  sprawdzenie WordPressa.

== 1.4.2 (2026-08-13) - Dziennik elektroniczny: panel administracyjny, dostęp trenera do własnych grup, kilka ról naraz ==

Wydanie rozwija moduł Dziennika elektronicznego (dotąd dostępnego tylko do odczytu
w strefie trenera) o pełny panel administracyjny oraz porządkuje zarządzanie rolami
w klubie.

DODANE:
- Panel administracyjny modułu Dziennik (SOKS -> Dziennik) — zakładanie i edycja grup
  treningowych, terminów zajęć, przypisań trenerów do grup, kartotek zawodników oraz
  ich opiekunów. Dotąd dane trzeba było wprowadzać poza wtyczką; teraz cała obsługa
  dziennika odbywa się z panelu administratora.
- Ekran „Uprawnienia ról" (SOKS -> 🔐 Uprawnienia ról) — administrator może włączać
  i wyłączać poszczególne funkcje wtyczki osobno dla każdej roli klubowej, bez
  edytowania kodu.

NAPRAWIONE:
- Panel dziennika ograniczony do własnych grup trenera — trener widzi i edytuje
  wyłącznie grupy, do których jest przypisany; administrator nadal widzi i edytuje
  wszystko. Wcześniej podmiana identyfikatora grupy w żądaniu pozwalała edytować
  cudzą grupę.
- Nadawanie ról klubowych — jedna osoba może mieć teraz kilka ról naraz (np. Trener
  i jednocześnie Zarząd). Wcześniej zapis nowej roli kasował wszystkie pozostałe.

== 1.3.15 (2026-08-11) - Daty rund zgodne po zapisie, pełny progres zawodnika ==

Wydanie naprawcze. Nie zmienia struktury bazy danych i nie wymaga żadnych działań
administratora. Usuwa dwie usterki, przez które dane wyglądały na poprawne, a nie były.

NAPRAWIONE:
- Zapis zawodów z panelu administratora oraz przez aplikację Desktop aktualizował daty rund
  tylko częściowo — nowe daty trafiały do zawodów, ale tabela rund zostawała ze starymi.
  Skutkiem były rozjeżdżające się terminy: inna data w edycji zawodów, inna na liście rund
  i w rejestracji zawodników. Teraz wszystkie trzy drogi zapisu (strona, panel, aplikacja)
  zapisują rundy tak samo. Okna rejestracji i limity uczestników przy tym nie znikają.
- Usunięcie rundy, na którą są już zapisani zawodnicy, jest teraz blokowane — czy to przez
  zmniejszenie liczby rund, czy przyciskiem „usuń rundę". Zamiast po cichu zerwać ich zapisy,
  system zostawia rundę i wypisuje, ilu zawodników ją blokuje. Żeby taką rundę usunąć, trzeba
  najpierw wypisać z niej zawodników.
- „Mój progres" pomijał wyniki PZSS wprowadzane ręcznie, przez co zawodnik widział np. jeden
  start w PSP 30, mając w „Mojej Historii Zawodów" trzy. Wyniki klubowe i starty w innych
  klubach trafiają teraz do jednej wspólnej serii, więc zgadza się liczba startów, średnia,
  rekord życiowy i wykres trendu. Ten sam start obecny w dwóch źródłach (np. wynik klubowy
  i zaimportowany protokół) liczony jest raz, bez podwojenia.

DODANE:
- „Mój progres" pokazuje rekordową serię 10 strzałów w danej konkurencji — najlepszą
  pojedynczą serię wraz z numerem, liczbą dziesiątek wewnętrznych (10X) i datą startu.
  Kafel pojawia się tam, gdzie wyniki mają rozbicie na serie (wpis ręczny PZSS).

== 1.3.14 (2026-08-08) - Saldo kasy udostępniane programowi Desktop ==

Wydanie współpracujące z SOKS Desktop 1.0.50. Nie zmienia bazy danych i nie wymaga
żadnych działań administratora.

DODANE:
- Odpowiedź synchronizacji zawiera teraz autorytatywne saldo kasy klubu (to samo, które strona
  pokazuje jako „Saldo Kasy") wraz z sumami wpłat i wypłat. Dzięki temu program Desktop pokazuje
  dokładnie ten sam stan kasy co strona i sam wykrywa ewentualny rozjazd — bez ręcznego
  porównywania raportów.

== 1.3.13 (2026-08-08) - Bezpieczeństwo: załatane luki wykryte w audycie ==

Wydanie naprawcze po pełnym audycie bezpieczeństwa. Aktualizacja nie zmienia bazy danych
i nie wymaga żadnych działań administratora — usuwa natomiast realne luki, przez które
dane klubu mogły być narażone.

ZAŁATANE LUKI:
- Eskalacja uprawnień: endpoint synchronizacji przyjmował dowolny klucz danych użytkownika,
  co pozwalało koncie z rolą trenera nadać sobie uprawnienia administratora WordPressa.
  Zapis ograniczony wyłącznie do pól klubowych.
- Wgrywanie plików: zdjęcie zawodnika było sprawdzane tylko po nagłówku od przeglądarki,
  co teoretycznie pozwalało wgrać plik wykonywalny podszywający się pod obraz. Teraz typ jest
  weryfikowany po realnej zawartości pliku, a katalog zdjęć ma zablokowane wykonywanie kodu.
- Wyciek danych: wyszukiwarka członków zwracała imiona, nazwiska i adresy e-mail każdemu
  zalogowanemu użytkownikowi. Teraz wymaga uprawnień do kartoteki (zarząd, trener, instruktor).
- Nieuwierzytelniony zapis: publiczny endpoint konkurencji mógł tworzyć rundy zawodów
  bez zalogowania. Zapis wymaga teraz zalogowanego użytkownika z uprawnieniami do zawodów.
- Ochrona formularzy (CSRF): konfiguracja składek i operacje na kopiach zapasowych
  wymagają teraz tokena bezpieczeństwa i uprawnień administratora.
- Tworzenie raportu kasowego wymaga uprawnień zarządu (wcześniej wystarczało zalogowanie).

NAPRAWIONE:
- Przywracanie z kopii zapasowej: przycisk „Przywróć z backupu" wywoływał nieistniejącą funkcję
  i kończył się białym ekranem. Teraz kopia członków (.json) przywraca się bezpośrednio,
  a dla pełnej kopii bazy (.sql) pojawia się jasna instrukcja przywrócenia przez panel hostingu.
- Usunięte martwe wywołania nieistniejącej funkcji powiadomień, które groziły błędem krytycznym,
  gdyby ktoś ponownie podpiął stary kod.

DANE OSOBOWE:
- Z komentarzy w kodzie generatora metryczek usunięto nazwisko osoby zgłaszającej błąd.

== 1.3.12 (2026-08-05) - Higiena danych: usunięte dane klubu pilotażowego i osób z kodu wtyczki ==

BEZPIECZEŃSTWO I DANE OSOBOWE:
- USUNIĘTY katalog examples/ (362 KB) — zawierał dwa pliki eksportu z PractiScore z danymi
  236 realnych zawodników: imiona, nazwiska i roczniki urodzenia, plus model telefonu osoby
  prowadzącej zawody. Katalog nie był używany przez ani jedną linię kodu, a jechał w paczce
  do każdego klubu instalującego wtyczkę. To był najpoważniejszy z wykrytych problemów.
- Adres e-mail konkretnej osoby przestał być DOMYŚLNYM adresem wysyłki testowej powiadomień
  o statusie. Wcześniej obcy klub, który kliknął „wyślij test", wysyłał wiadomość na cudzą
  skrzynkę. Teraz domyślnym adresem jest e-mail administratora danej instalacji WordPress.
- Podgląd testowy powiadomień używa neutralnych danych przykładowych zamiast danych realnej osoby.
- Nazwa klubu pilotażowego i nazwisko administratora usunięte z kodu, komentarzy, podpowiedzi
  w panelu, przykładów w formularzach i z historii zmian. Instalka trafia do wielu klubów,
  a paczka ZIP rozpakowuje się jedną komendą — takie ślady były czytelne dla każdego.
- USUNIĘTE z paczki pliki robocze: dwie kopie zapasowe kodu (.bak) i martwe narzędzie
  diagnostyczne pola instruktora.

NAPRAWIONE:
- Pozycja menu importu nazwana po klubie pilotażowym prowadziła do NIEISTNIEJĄCEJ funkcji — kliknięcie otwierało pustą
  stronę. Pozycja usunięta; import CSV z polskimi nagłówkami działa bez zmian z poziomu
  standardowego importu.
- Narzędzie „Czyszczenie duplikatów w bazie" miało zaszyty numer konkretnego użytkownika i pokazywało
  wyłącznie jego dane. Teraz administrator podaje numer użytkownika do sprawdzenia w formularzu.
- Wykrywanie błędnych wartości w polach numeru licencji nie szuka już jednego konkretnego nazwiska,
  tylko rozpoznaje wzorzec: wartość ze spacją i bez ani jednej cyfry (numer licencji zawsze ma cyfry).
  Dzięki temu działa w każdym klubie, a nie tylko tam, gdzie wystąpił pierwotny błąd.
- Usunięte martwe narzędzia migracji metadanych, które szukały w bazie konkretnego nazwiska.
  Porządkowanie, do którego służyły, zakończyło się w wersji 1.2.6 — nie miały już czego szukać.

BEZ ZMIAN W DZIAŁANIU:
- Aktualizacja nie zmienia bazy danych i nie wymaga żadnych działań administratora.
- Zmiany dotyczą danych przykładowych, komentarzy, podpowiedzi i martwego kodu.
  Żadna funkcja używana w codziennej pracy nie zmieniła zachowania.

== 1.3.11 (2026-08-01) - Powiadomienia o statusie także z programu desktop, awans gościa, wynik startów obcych ==

NOWE FUNKCJE:
- Powiadomienie e-mail o zmianie statusu członka (funkcja z 1.3.8) wychodzi teraz TAKŻE wtedy, gdy
  status zmieniono w programie SOKS Desktop. Wcześniej mail wysyłał wyłącznie panel na stronie, więc
  ta sama operacja wykonana w aplikacji nie powiadamiała członka.
  Wiadomość nadal wysyła WYŁĄCZNIE WordPress i tylko przy realnej zmianie statusu, więc powtórzone
  żądanie, ponowienie po zerwanym połączeniu ani cykliczna synchronizacja nie wyślą jej drugi raz.
- Zmiana ZBIORCZA statusu z programu desktop domyślnie NIE wysyła powiadomień — tak samo jak zmiana
  zbiorcza w panelu na stronie. Program pyta o to osobnym przełącznikiem przed wykonaniem operacji.
- Awans gościa na członka klubu można wykonać również z programu desktop (Lista członków → przycisk
  przy koncie gościa). Działa dokładnie tak samo jak przycisk na stronie: konto, historia startów
  i wyniki zostają zachowane.
- Wynik startu w innym klubie (suma punktów + liczba 10X, funkcja z 1.3.5) jest teraz przyjmowany
  i zwracany przez API, dzięki czemu program desktop może go zapisać i pokazać. Wcześniej pole dało
  się wypełnić tylko na stronie, a zapis z aplikacji je gubił.

SZCZEGÓŁY TECHNICZNE:
- REST: PUT /members/{id} przyjmuje opcjonalne "skip_status_email" (operacje zbiorcze).
  Poprzedni status odczytywany PRZED zapisem meta — inaczej strażnik "realna zmiana" zawsze
  porównywałby wartość samą ze sobą i nigdy nie wysłałby wiadomości.
- REST: nowy endpoint POST /members/{id}/promote-guest. Osobny, bo awans USUWA meta gościa
  (ks_typ_zawodnika, ks_typ_konta), a PUT potrafi tylko zapisywać wartości.
- Logika awansu wydzielona do ks_promote_guest_to_member() w includes/club-data-helpers.php —
  jedno źródło prawdy dla panelu i dla API (panel korzysta teraz z tej samej funkcji).
- REST: POST/PUT /starty-obce obsługują suma_punktow i liczba_10x; puste pole zapisuje NULL, nie 0.
- Bez zmian w bazie danych — nie dodano żadnej kolumny, nie jest potrzebna migracja ani backfill.

== 1.3.10 (2026-07-29) - Medale i miejsca przy wynikach IPSC ==

POPRAWKI I ULEPSZENIA:
- [BUG] W „Mojej Historii Zawodów" wyniki z modułu IPSC nie pokazywały medalu ani wiersza
  „Miejsce: X/Y" — nawet zawodnik z najlepszym wynikiem na torze (100%) widział samą punktację.
  Przyczyna: zapytanie pobierające wyniki IPSC miało zahardkodowane `NULL as miejsce`, a medal
  i miejsce renderują się z tego samego pola. Tabela `ks_zawody_ipsc_wyniki` nie przechowuje
  kolumny `miejsce` — silnik wyliczał je tylko na potrzeby komunikatu i nie zapisywał.
  Naprawa: miejsce wyliczane w locie wspólnym wyrażeniem SQL, identycznym z regułą klasyfikacji
  z komunikatu (nowe `ks_ipsc_miejsce_sql()` w includes/zawody/ks-ipsc-scoring.php — jedno źródło
  prawdy dla wszystkich zapytań). Wyniki z importu PractiScore działały wcześniej i działają nadal.
- [BUG] Wniosek PZSS o przedłużenie licencji nie liczył startów wprowadzonych przez moduł IPSC —
  historia startów scalała tylko 3 źródła i pomijała tabelę IPSC. Efekt: panel „Starty wymagane"
  pokazywał komplet startów, a wniosek generował się na ich część. Dodano czwarte źródło
  (includes/features/pzss-forms/starts-history.php).
- [POPRAWKA] Sekcja „Twoje Miejsca i Puchary" liczy miejsca IPSC tą samą regułą co reszta systemu
  (wcześniej miała własny, uproszczony wariant — bez wykluczania niesklasyfikowanych i bez
  rozstrzygania remisów), więc liczba pucharów nie może się już rozjechać z miejscami przy wynikach.
- [POPRAWKA] Mianownik w „Miejsce: X/Y" dla wyników IPSC pomija zawodników niesklasyfikowanych
  (DNS/DSQ/DSQ match) — tak samo jak licznik. Wcześniej ostatni sklasyfikowany mógł zobaczyć „198/203".
- [POPRAWKA] Zawodnik z DNF ma teraz to samo miejsce w panelu i w protokole PDF toru. Wcześniej
  wydruk spychał DNF na koniec listy, choć jest klasyfikowany (punktowany z tego, co oddał).
- [POPRAWKA] Zapytanie sekcji osiągnięć nie używa już HAVING na aliasie bez GROUP BY — filtr
  przeniesiony do zewnętrznego SELECT (odporność na tryb ONLY_FULL_GROUP_BY na serwerach MySQL).

UWAGI TECHNICZNE:
- Reguła klasyfikacji odwzorowuje sortowanie silnika: niesklasyfikowani (DNS/DSQ/DSQ_MATCH) bez
  miejsca i bez wpływu na miejsca innych, pozostali (w tym DNF) wg stage_points malejąco, remis
  rozstrzygany po nazwisku porównaniem bajtowym (odpowiednik strcasecmp() z PHP — porównanie wg
  collation dałoby inną kolejność dla polskich znaków).
- Bez zmian w bazie danych — nie dodano żadnej kolumny, nie jest potrzebna migracja ani backfill.

== 1.3.9 (2026-07-23) - Naprawa „Zapłać teraz" dla zamówień „W kasie klubu" ==

BUG FIXES (2026-07-25, CZĘŚĆ 2 naprawy „Zapłać teraz"):
- [KRYTYCZNE] Wpłata online za zamówienie „W kasie klubu" nie księgowała się (jedno zamówienie klubu:
  klient zapłacił P24/BLIK — w P24 „Dokonana", a zamówienie wisiało jako „W trakcie realizacji",
  bez date_paid, rejestracje nieopłacone, brak wpisu w ks_platnosci).
  Przyczyna: payment_complete() w WooCommerce ma DRUGĄ listę dozwolonych statusów
  (woocommerce_valid_order_statuses_for_payment_complete = pending/failed/on-hold/cancelled,
  BEZ processing) — IPN Przelewy24 wywoływał payment_complete(), ale WooCommerce go ignorował.
  Część 1 naprawy (23.07) odblokowała samą STRONĘ płatności; księgowanie wymagało części 2.
  Naprawa (includes/shortcodes/payments.php):
  * filtr ks_wc_valid_statuses_for_payment_complete — dopuszcza 'processing' TYLKO gdy
    zamówienie nie ma date_paid (opłacone online processing MA date_paid, więc powtórzony
    IPN nadal nic nie zmienia — ochrona przed podwójnym księgowaniem),
  * ks_auto_complete_paid_orders podpięte też pod woocommerce_payment_complete (prio 40) —
    przy processing→processing nie ma PRZEJŚCIA statusu, więc dotychczasowy hook
    woocommerce_order_status_processing nie wystrzeliłby i zamówienie nie dostałoby
    statusu „Zrealizowane".
  Testy E2E na kopii lokalnej: 11/11 OK (księgowanie, rejestracje paid_at, ks_platnosci,
  duplikat IPN, regresja normalnych przepływów pending/opłacone-processing).
- Skrypt jednorazowy mc_fix_order_<nr>.php (root WP): ręczne zaksięgowanie transakcji
  P24 jednego zamówienia po wgraniu fixu (guardy: processing, brak date_paid, kwota 200).
  Po użyciu USUNĄĆ z serwera.

WYDAJNOŚĆ (2026-07-25):
- Zakładka Rejestracje (Zarządzanie zawodami): nowy filtr „Pokaż" — Ostatnie 50 / 200 / 500 /
  Wszystkie (domyślnie 200). Tabela ładowała WSZYSTKIE rejestracje (każdy wiersz niesie ukryty
  formularz edycji — 600 rejestracji ≈ 9 MB HTML), przez co strona długo się renderowała na
  słabszych komputerach. Limit tnie tylko WYŚWIETLANIE: kafelki statystyk, sumy PLN oraz filtry
  Zawody/Runda/Status nadal liczą i działają na całości danych.
- Wyszukiwarka zawodnika/klubu przeszukuje teraz też CAŁĄ bazę (server-side, Enter lub
  „Filtruj"): wcześniej filtrowała wyłącznie wiersze już wyświetlone w przeglądarce, więc po
  wprowadzeniu limitu nie znalazłaby osób spoza ostatnich N. Szuka po: imię/nazwisko/e-mail
  członka (usermeta) ORAZ gosc_imie/gosc_nazwisko/gosc_email/gosc_klub (goście). Pisanie w polu
  dalej filtruje na żywo wyświetlone wiersze (bez przeładowania).
- Komunikat nad tabelą gdy lista przycięta: „Wyświetlono ostatnie N z X pasujących rejestracji"
  + link „Pokaż wszystkie". Wybrany limit zapamiętywany w localStorage jak pozostałe filtry.
  Akcje zbiorcze/zaznaczanie działają na wyświetlonych wierszach (jak dotychczas).
- Pliki: includes/shortcodes/competitions.php (ks_render_rejestracje_management).

BUG FIXES:
- [NAPRAWA] Przycisk „Zapłać teraz" w panelu konta (Moje zamówienia WC) kończył się błędem
  „Status tego zamówienia to «…» — nie można za nie zapłacić" dla zamówień z metodą
  „W kasie klubu" (cod, status processing) i przelewem tradycyjnym (bacs/cheque, on-hold).
  Przyczyna: WooCommerce (WC_Order::needs_payment) domyślnie pozwala opłacić na stronie
  order-pay TYLKO statusy pending/failed, a panel pokazywał przycisk także dla
  processing/on-hold — użytkownik zawsze lądował na błędzie i nie mógł zmienić formy płatności.
  Naprawa (includes/shortcodes/payments.php): filtr woocommerce_valid_order_statuses_for_payment
  (ks_wc_valid_statuses_for_payment) dopuszcza 'on-hold' oraz 'processing' wyłącznie dla metod
  offline (cod/cheque/bacs); zamówienia z ustawioną date_paid (opłacone online) pozostają
  zablokowane — ochrona przed podwójną zapłatą.
- Panel konta (includes/shortcodes/account.php): warunek pokazania „Zapłać teraz" oparty teraz
  wprost o $order->needs_payment() (to samo kryterium co strona płatności — przycisk i strona
  nie mogą się już rozjechać) + current_user_can('pay_for_order') — przycisk nie pokazuje się
  dla zamówień przypisanych do innego konta (dopasowanych tylko po mailu), które WooCommerce
  i tak odrzuciłby błędem „To zamówienie nie może zostać opłacone".

== 1.3.8 (2026-07-18) - Powiadomienia e-mail o zmianie statusu członka ==

NOWE FUNKCJE:
- Automatyczne powiadomienie e-mail do członka przy każdej zmianie jego "Statusu konta" (przez zarząd
  w panelu klubu). Treść zawiera instruktażowe kolejne kroki na ścieżce kariery zawodnika wg PZSS
  (patent -> licencja -> przedłużenia, pozwolenie na broń sportową).
- Nowa zakładka "SOKS -> Ustawienia email -> Powiadomienia o statusie": dla każdego statusu osobny
  włącznik + temat + treść (edytor WYSIWYG), globalny włącznik oraz "Wyślij test" (wyłącznie na wskazany
  adres testowy - nigdy do członków). 12 gotowych treści domyślnych opartych na oficjalnej ścieżce PZSS.
- Treści w pełni edytowalne w panelu i niezależne od klubu (placeholdery {imie}, {status_nazwa},
  {klub_nazwa_pelna}, {klub_email}, {link_panelu} itd.) - zmienne wymogi PZSS nie są zaszyte w kodzie.

SZCZEGÓŁY TECHNICZNE:
- Wyzwalacz wpięty w handlery AJAX zmiany statusu (pojedyncza i zbiorcza), z guardami: realna zmiana
  statusu, istniejący poprzedni status (pomija tworzenie konta i import), globalny/per-status włącznik,
  poprawny e-mail, pominięcie kont gościnnych. Import/migracja/RODO nie wyzwalają wysyłki.
- Powiadomienia domyślnie WYŁĄCZONE dla statusów "Zablokowany" i "Zawieszony" (treść łagodna, decyzja klubu).

== 1.3.7 (2026-07-18) - Raport kasowy jako PDF, turnkey wdrożenie (strony + menu), poprawki wydruków ==

NOWE FUNKCJE:
- Raport kasowy/bankowy generowany teraz jako PDF (mPDF) zamiast druku HTML przez przeglądarkę:
  numeracja stron "Strona X z Y" na każdej stronie, powtarzany nagłówek tabeli, orientacja pionowa,
  koniec rozjazdu "podgląd ≠ wydruk". Data sporządzenia jest MODYFIKOWALNA (pole daty przed drukiem)
  i pojawia się jednakowo w nagłówku, stopce i na końcu raportu.
- Awans gościa na członka klubu jednym kliknięciem (Lista gości → "Awansuj na członka") — bez usuwania
  konta; zachowana cała historia startów i wyników, zmiana klubu na własny, status "aktywny".
- Instalacja wtyczki tworzy automatycznie niezbędne strony (Logowanie, Rejestracja, Odzyskiwanie hasła,
  Moje konto, Rejestracja na zawody, Panel klubu, Zarządzanie zawodami) oraz pozycje w menu głównym —
  turnkey wdrożenie nowego klubu. Idempotentnie (wykrywa istniejące strony po shortcodzie — bez duplikatów).

POPRAWKI:
- Przycisk "Generuj wniosek PDF" (przedłużenie licencji PZSS) wymaga teraz też opłaconej składki na rok
  docelowy (obok daty ≥ 1.11 i wymaganych startów).
- Raport kasowy: usunięta kolumna "Typ" (typ jest w numerze dokumentu), usunięty zbędny duplikat w tytule
  operacji (np. "…PSP3+10 (PSP3+10)"), stopka "STAN KASY" uwzględnia saldo początkowe.
- Stopki wszystkich wydruków: usunięte "Wygenerowano: {data godzina}" — zostaje tylko dyskretna sygnatura
  "Wydruk z programu SOKS v{wersja}".

== 1.3.6 (2026-07-01) - Sygnatura wydruku (program + wersja) na wszystkich wydrukach ==

POPRAWKI I ULEPSZENIA:
- Wszystkie wydruki z wtyczki (PDF i HTML/druk przez przeglądarkę) mają teraz w stopce dyskretną
  sygnaturę informującą, że wydruk pochodzi z programu SOKS w danej wersji (np. "Wydruk z programu SOKS v1.3.6").
- Uzupełniono pokrycie sygnaturą tam, gdzie jej brakowało: vouchery (PDF), eksport listy do PDF,
  oraz podgląd/druk dokumentu członkowskiego (HTML). Pozostałe wydruki (dokumenty PDF, wyniki/komunikaty,
  wnioski PZSS, raporty kasowe/bankowe, masowy druk KP/KW/PO, listy startowe) miały sygnaturę już wcześniej.
- Metryczki zawodników celowo POMINIĘTE (za mało miejsca na karcie).
- Sygnatura pobiera wersję z KS_VERSION i tekst ze wspólnej funkcji ks_wydruk_sygnatura_tekst()
  (spójność) — funkcje w includes/club-data-helpers.php: ks_wydruk_stopka_mpdf() dla PDF,
  ks_wydruk_stopka_html() dla wydruków przez przeglądarkę.

== 1.3.5 (2026-06-29) - Wynik startów w innych klubach (śledzenie progresu) ==

NOWE FUNKCJE:
- "Moje konto" > "Moje zawody": przy startach w innych klubach (starty obce) można teraz podać WYNIK -
  sumę punktów oraz liczbę dziesiątek wewnętrznych 10X. Dzięki temu zawodnik widzi swój progres także
  z zawodów organizowanych poza klubem.
- Formularz "Dodaj starty w innych klubach" ma dwa nowe (opcjonalne) pola: "Suma punktów" i "Liczba 10X".
- Tabela "Moje zgłoszenia startów w innych klubach" pokazuje kolumny Suma pkt / 10X oraz pozwala dopisać
  lub poprawić wynik w już dodanym starcie (inline "✏️ Wynik") - również dla wpisów dodanych wcześniej.
- "Moja Historia Zawodów" wyświetla wynik startów obcych (suma punktów + 10X) obok wyników klubowych
  i z Practiscore.
- Panel klubu "Zgłoszenia Startów w Innych Klubach": administrator widzi wynik (Suma pkt / 10X) przy
  każdym zgłoszeniu i może go podać, dodając start za zawodnika.

POPRAWKI:
- Raport kasowy (KP/KW) na froncie: stopka "STAN KASY" uwzględnia teraz saldo początkowe. Wcześniej
  pokazywała jedynie obrót netto okresu (KP - KW), przez co przy dodatnim saldzie początkowym stan kasy
  wyglądał na ujemny. Teraz stopka pokazuje osobno "Obrót netto okresu (KP - KW)" oraz właściwy
  "STAN KASY (Saldo pocz. + KP - KW)" - zgodny z kartą "Saldo końcowe" i podsumowaniem na wydruku.

WYDRUKI:
- Wszystkie wydruki i pliki PDF z wtyczki mają teraz w stopce dyskretną sygnaturę
  "Wydruk z programu SOKS v<wersja>" (zaświadczenia, deklaracja, komunikaty/protokoły wyników,
  listy startowe, wnioski PZSS, vouchery, raport kasowy oraz druk pojedynczy i masowy dokumentów
  kasowych). Metryczki celowo pominięte (brak miejsca). Wersja pobierana z jednego źródła (KS_VERSION).

BAZA DANYCH:
- Migracja 4.14.2: kolumny suma_punktow i liczba_10x w tabeli ks_zawody_starty_obce (idempotentnie).

== 1.3.4 (2026-06-26) - Łatwe wdrożenie w nowym klubie (usunięcie danych "na sztywno") ==

POPRAWKI I ULEPSZENIA:
- Wtyczka jest teraz neutralna i gotowa do wdrożenia w dowolnym klubie - dane konkretnego klubu
  (nazwa, logo, NIP, numer ewidencyjny PZSS) pobierane są z panelu "SOKS > Dane Klubu", a nie wpisane
  w kodzie. Nowy klub konfiguruje się z panelu, bez edytowania plików wtyczki.
- Szablony dokumentów (zaświadczenie sportowe/kolekcjonerskie, deklaracja wstępującego) używają teraz
  nazwy klubu z ustawień (placeholdery {{klub_nazwa_pelna}}/{{klub_nazwa_skrocona}}) zamiast nazwy
  konkretnego klubu - istotne dla dokumentów składanych na Policję i deklaracji członkowskich.
- Zgody RODO (statut, polityka prywatności, komunikacja) podstawiają nazwę klubu z ustawień przy
  wyświetlaniu - nowy członek akceptuje politykę WŁASNEGO klubu, nie cudzego.
- Logo: gdy klub nie wgrał własnego logo, dokumenty nie pokazują już cudzego logo (neutralny placeholder).
- Front rejestracji na zawody, domyślny klub nowego członka, filtr listy członków oraz podpisy e-maili -
  wszędzie nazwa klubu pochodzi z ustawień.
- Nowe pole "Numer ewidencyjny PZSS" w panelu Dane Klubu (placeholder {{klub_numer_pzss}}).
- Nowe pole "Nazwa w dopełniaczu" w panelu Dane Klubu (placeholder {{klub_nazwa_dopelniacz}}) - używane w
  zaświadczeniach i deklaracji, by zachować poprawną odmianę nazwy klubu (np. "Jest członkiem Przykładowego
  Klubu..."). Puste pole = użyta zostanie nazwa pełna (mianownik).
- "Status gotowości" - panel admina ostrzega, gdy kluczowe dane klubu (nazwa, logo, NIP) nie są
  jeszcze uzupełnione.
- Domyślne ID produktów WooCommerce ustawione na 0 (zamiast ID konkretnego klubu) - nowy klub ustawia
  własne produkty w panelu.
- Licencjonowanie - doprecyzowanie: po zakończeniu okresu próbnego ORAZ po wygaśnięciu licencji wtyczka
  działa dalej w pełni (brak trybu "tylko do odczytu"). Licencja jest wymagana wyłącznie do otrzymywania
  automatycznych aktualizacji. Poprawione komunikaty w panelu i jawna logika (metody ograniczeń nie blokują
  funkcji wtyczki).

== 1.3.3 (2026-06-26) - Spójność rejestracji i zamówień WC, porządki w kodzie ==

POPRAWKI I ULEPSZENIA:
- Ręczna rejestracja zawodnika (panel "Zarządzanie zawodami") nie tworzy już zamówień WooCommerce.
  Wcześniej opcje "Oczekująca na płatność" i "BLIK" zakładały zamówienie WC, które nie wiązało się
  z rejestracją - powstawały "zamówienia-sieroty" dublujące wpłaty gotówkowe (KP) i zawyżające
  historię wpłat. Ręczna rejestracja korzysta teraz z opcji: gotówka (dokument KP), start gratis
  lub karnet. Rejestracja online z płatnością przebiega jak dotąd - przez koszyk i checkout WooCommerce
  (tam zamówienie powstaje i po opłaceniu zawodnik trafia na listę startową).
- Moje konto > "Moje zamówienia WC" oraz "Historia wpłat": kolumny "Produkty" i "Metoda płatności"
  są teraz wypełnione także dla opłat startowych (pozycje typu "opłata"/fee - wcześniej bywały puste).
- Porządki w kodzie: scalenie i uproszczenie plików obsługujących shortcode'y oraz usunięcie
  nieużywanych, zdublowanych wariantów shortcode'ów - bez zmian w działaniu dla użytkownika.

== 1.3.2 (2026-06-23) - Poprawki: wyniki IPSC w rankingach, koncie zawodnika i medalach ==

POPRAWKI I ULEPSZENIA:
- Rankingi cyklu: wyniki IPSC zaimportowane z PractiScore pojawiają się teraz we wszystkich
  rundach - zarówno w rankingu pojedynczej konkurencji (Karabin/Pistolet/Strzelba dynamiczna),
  jak i w klasyfikacji "Match Results (Combined)". Wcześniej kolumny kolejnych rund
  pozostawały puste mimo poprawnie wgranych wyników.
- "Match Results (Combined)" dla rund wczytanych jako osobne tory IPSC: generalka liczona
  jako suma punktów torów zawodnika, spójnie ze zbiorczym protokołem PractiScore.
- Moje konto > Moja Historia Zawodów: widoczne wyniki IPSC z każdej rundy.
- Starty do utrzymania licencji: starty w konkurencjach IPSC są teraz wliczane.
- Medale i osiągnięcia: miejsca 1-5 w konkurencjach IPSC liczą się do osiągnięć zawodnika.
- Import .psc / PractiScore: poprawione dekodowanie trafień (pudła M i no-shooty NS nie są
  już mylone), trafione poppery i rzutki liczone jako trafienia, poprawne sumy punktów torów.
- Import: rozróżnianie zawodników o tym samym imieniu i nazwisku po roku urodzenia.

== 1.3.1 (2026-06-19) - Poprawki: karnety, konto zawodnika, dyscyplina wiodąca ==

POPRAWKI I ULEPSZENIA:
- Historia karnetów (Zarządzanie zawodami): filtr live wyszukiwania po zawodniku - lista
  zawęża się na bieżąco bez przeładowania strony, ignoruje polskie znaki (wpiszesz "luka",
  znajdzie "ŁUKA"), licznik "widocznych X z Y".
- Rozliczanie karnetem (rejestracje): po rozliczeniu jednej konkurencji filtr zawodnika oraz
  filtr rundy są zapamiętywane - kolejne konkurencje tego samego zawodnika rozliczasz bez
  ponownego wyszukiwania.
- "Składka początkowa": zamówienie zawierające składkę początkową (bez roku w nazwie) poprawnie
  oznacza składkę bieżącego roku jako opłaconą na liście członków.
- Moje konto > Moje zamówienia WC: szkice koszyka WooCommerce (status "Szkic" / checkout-draft)
  nie są już pokazywane na liście zamówień zawodnika - to placeholdery porzuconego koszyka,
  z którymi nie da się nic zrobić.
- Wymagania licencyjne (Moje Zawody) oraz wniosek o przedłużenie licencji PZSS: dyscyplina
  wiodąca jest wyznaczana po największej liczbie startów, a przy równej liczbie startów
  domyślnie pozostaje Pistolet (priorytet Pistolet > Karabin > Strzelba). Tabela w panelu konta
  i PDF wniosku pokazują teraz zawsze tę samą dyscyplinę wiodącą.

== 1.3.0 (2026-06-08) - System IPSC + PractiScore (eksport/import Match, punktacja Comstock) ==

NOWE FUNKCJE:
- Pełny system konkurencji dynamicznych IPSC zgodny z aktualnymi przepisami (Comstock):
  punktacja Minor/Major (A=5/C=3/D=1 oraz A=5/C=4/D=2), kary -10 (pudło, no-shoot,
  proceduralne), automatyczny Hit Factor i punkty toru (stage points) z normalizacją
  do najlepszego wyniku na torze.
- Definicja celów per konkurencja i per runda: liczba oraz typ celów (papier/popper/plate),
  no-shoots, liczba strzałów, scoring Minor/Major - może być różna w różnych rundach tych
  samych zawodów.
- Ręczne wprowadzanie i korekta wyników IPSC: czas przebiegu, trafienia A/C/D/M/NS, kary
  proceduralne, statusy DNS / DNF / DSQ konkurencja / DSQ match. Tryb zbiorczy oraz tryb
  "per cel" (jak ekran PractiScore) z walidacją każdego wiersza (A+C+D+M = liczba strzałów
  celu; wiersz zielony = poprawny, czerwony = błąd, brak zapisu bez wpisanego czasu).
  Automatyczne formatowanie czasu (wpisujesz 2339, pojawia się 23.39).
- Eksport zawodów (Match) do PractiScore: plik .psc (archiwum ZIP: match_def.json +
  match_scores.json). Match = runda, tory = konkurencje IPSC aktywne w rundzie.
- Import wyników z PractiScore: plik .psc (dekodowanie trafień z pola ts) oraz HTML
  "Stage Results - Combined". Ręczne mapowanie toru, gdy nazwa stage nie pasuje do nazwy
  konkurencji w SOKS.
- Komunikat klasyfikacyjny IPSC: kolumny A/C/D/kary/Hit Factor/punkty toru/%, podział
  per Division, sekcja DQ, edytowalna nazwa konkurencji. Strony IPSC w orientacji poziomej.
- Konfigurowalne klasyfikacje (włączane w ustawieniach zawodów): Generalna (Overall) zawsze
  + opcjonalnie per Division, per klasa, per kategoria. Klasyfikacja łączna Overall po rundach.
- Częściowe wykupienie torów: zawodnik może wykupić tylko wybrane tory. W protokole toru
  widoczni są wyłącznie zawodnicy z wykupionym torem, a w klasyfikacji Match tory niewykupione
  liczone są jako 0 punktów (zawodnik nadal sklasyfikowany z sumy wykupionych).

ULEPSZENIA:
- Listy startowe i eksport CSV PractiScore obejmują wyłącznie zawodników z opłaconą
  rejestracją. Wyjątek: "Lista startowa (do druku)" dla biura - pokazuje, kto opłacił,
  a kto jeszcze nie.
- Panel "Rejestracje z amunicją klubową" respektuje filtr rundy (wcześniej pokazywał stan
  całych zawodów).
- Edycja rejestracji: zmiana zawodnika (filtr live zamiast długiej listy ~1200 osób) oraz
  zmiana konkurencji z walidacją (duplikaty, konkurencje wyłączone, przeliczenie opłaty,
  numer startowy).
- Przycisk "Anuluj" przy zamówieniach WooCommerce (zmienia status na anulowane, bez usuwania).

BAZA DANYCH:
- Migracja 4.14.1: nowe tabele IPSC (ks_zawody_ipsc_stage, ks_zawody_ipsc_stage_cele,
  ks_zawody_ipsc_wyniki) oraz rozszerzenia kolumn. Migracja uruchamia się automatycznie
  po aktualizacji wtyczki - nie wymaga działań administratora.

== 1.2.7 (2026-05-20) - Raporty kasowe profesjonalne + masowy druk KP/KW + bug fixes ==

NOWE FUNKCJE:
- Raport kasowy (KP/KW) i bankowy (PO) dla ksiegowej - profesjonalny wydruk A4 landscape
  z naglowkiem klubu (nazwa, NIP, REGON, KRS), numerem raportu RRRR/MM, lewy margines 25mm
  do segregatora, czarno-biale, 2 podpisy (Sporzadzil + Zatwierdzil/Prezes z dane_klubu).
  Druk w nowym oknie z czystym HTML (bez konfliktow z theme WordPressa).
- Raport miesieczny zamowien WooCommerce z filtrem statusu i metody platnosci.
  Poprawne liczenie oplaconych (completed lub processing+online), wydzielenie kategorii
  "W kasie klubu" (cod/cheque/bacs ze statusem processing - czeka na wplate).
- Masowy druk dokumentow KP/KW: zaznaczanie checkboxami w tabeli + endpoint
  ks_kasa_bulk_print drukujacy 4 dokumenty na A4 landscape (identyczny layout
  jak druk pojedynczy z pelnym adresem czlonka z user_meta).
- Dropdown "Wystawil" przy nowej transakcji KP - wybor kasjera z listy uzytkownikow WP
  (admin/editor/manage_ks). Migracja DB 4.11.0 dodaje kolumne wystawil_id.
- Filtry typ + data od/do w widoku Kasa Klubowa (2 widoki: cash-register + shortcodes-v2).
- Pelna tresc opisu w raportach (lista rozliczonych zamowien w przelewach zbiorczych).

BUG FIXES:
- Usuniecie zamowienia WooCommerce kaskadowo czysci wp_ks_zawody_rejestracje
  (pending/cancelled -> DELETE, paid/confirmed -> UPDATE status=cancelled z notatka
  dla zachowania historii ksiegowej). 4 hooki: woocommerce_trash_order,
  woocommerce_delete_order, wp_trash_post, before_delete_post (HPOS + legacy).
- Usuniecie rejestracji rozliczonej karnetem aktualizuje teraz licznik
  zuzyto_* w karnecie (poprzednio licznik pozostawal blokowany).
- Bulk PDF metryczek napraciony - array_values() po SQL group-by (klucze byly user_id zamiast 0,1,2,3).

ULEPSZENIA UI:
- Metryczki zawodnikow A4 landscape 4 na strone - regulacja marginesow, ipsc-row
  ze stalym rozmiarem, KCZopt.3+10L (3+10 regex override), 6+ konkurencji
  w 2 kolumnach z very-dense, signature pod tabela full-width.

== 1.2.6 ITER 7 (2026-05-16) - CRITICAL FIX: BINARY w cleanup (case-insensitive collation) ==

🚨 INCYDENT FAZA F:
Po pomyślnym dry-run cleanup (rescued: 10) wykonanie cleanup spowodowało utratę:
- ks_PESEL: 1122 → 0 (WSZYSCY)
- ks_miejsce_urodzenia: 1010 → 0 (WSZYSCY)

PRZYCZYNA: MySQL collation utf8_general_ci jest case-insensitive.
$wpdb->delete($wpdb->usermeta, ['meta_key' => 'ks_pesel']) generuje
DELETE WHERE meta_key = 'ks_pesel' co przy case-insensitive USUWA TEŻ ks_PESEL (kanoniczny!).

To samo dla ks_Miejsce_urodzenia ↔ ks_miejsce_urodzenia (różnica tylko wielką M).

Safety net SKOPIOWAŁ wartości (1034 rescued), ale potem case-insensitive DELETE
USUNĄŁ zarówno alt jak i nowo skopiowany kanoniczny → utrata danych.

NAPRAWA w includes/meta-migration.php (iter 7):

ks_run_meta_cleanup():
- SELECT COUNT: zmieniono z "meta_key = %s" → "BINARY meta_key = %s"
- DELETE: zamiast $wpdb->delete() użyto $wpdb->query() z explicit BINARY
- Safety net LEFT JOIN: dodano BINARY do obu warunków meta_key

Po wgraniu: cleanup będzie case-sensitive (jak powinno być).

ODZYSKANIE DANYCH:
- właściciel uruchamia ks_recover_from_backup z świeżego backupu PRZED Fazą F
- Funkcja parsuje SQL, znajduje stare wartości alt (PESEL, ks_pesel, ks_Miejsce_urodzenia)
- Kopiuje do kanonicznych (ks_PESEL, ks_miejsce_urodzenia) tylko gdy puste
- NIE odtwarza alt kluczy (zostają usunięte zgodnie z planem cleanup)

== 1.2.6 ITER 6 (2026-05-16) - safety net w ks_run_meta_cleanup ==

Po pomyślnej Fazie E (813 pól skopiowanych) diagnoza wykryła:
- ks_klub: 1124 vs ks_nazwa_klubu: 1132 → 8 użytkowników ma TYLKO ks_nazwa_klubu
- To prawdopodobnie nowi userzy dodani PO Fazie B krok 1
- Bez safety net w cleanup → Faza F usunęłaby ks_nazwa_klubu BEZ skopiowania do ks_klub
- = utrata danych dla 8 osób!

Naprawa includes/meta-migration.php:

ks_run_meta_cleanup($dry_run):
- DODANE: safety net PRZED usunięciem każdego alt key
- Funkcja znajduje użytkowników którzy mają wartość w alt ALE BRAK w kanonicznym
  (LEFT JOIN ze sprawdzeniem can.umeta_id IS NULL)
- Kopiuje wartość z alt do kanonicznego (rescue)
- Dopiero potem usuwa alt key
- Zwraca dodatkowo: rescued (liczba uratowanych), rescue_log (max 100 szczegółów)

UI cleanup (sekcja Faza F):
- Dodany komunikat zielony "🛟 URATOWANYCH wartości"
- Tabela rescue_log: user_id, alt_key → canonical, wartość

Po wgraniu Fazy F może być uruchomiona bezpiecznie — ta sama 813 wartość
skopiowana w Fazie E + ~8 dodatkowych rescue dla "spóźnialskich".

== 1.2.6 ITER 5 (2026-05-16) - rest-api.php naprawa wycieku alt z SOKS Desktop ==

Test B drugi raz (user #11174) pokazał że alt klucze wciąż generowane:
- ks_first_name_2 (umeta_id 202674) — milisekundę PRZED ks_drugie_imie (202675)
- ks_nazwa_klubu (202725) — milisekundę PRZED ks_klub (202726)
- ks_seria_dowodu (202678) — milisekundę PRZED ks_nr_dowodu (202679)

ŹRÓDŁO znalezione: rest-api.php endpointy używane przez SOKS Desktop sync
piszą WSZYSTKIE klucze z body bez mapowania alt → canonical.

Naprawy w includes/rest-api.php:

PUT /members/{userId} (linia 1300-1308):
- DODANO: mapowanie reverse_map przez ks_sync_get_old_to_canonical_map()
- Filtr: jeśli klucz jest alt (np. ks_first_name_2), zamapuj na kanoniczny
  (ks_drugie_imie) PRZED update_user_meta()
- Po wgraniu desktop może pushować dowolne alt — WP zawsze zapisuje kanoniczny

POST /members create (linia 1374-1384):
- ZMIENIONO: zamiast bezpośredniego update_user_meta() teraz używamy
  ks_update_user_meta_fallback() — auto-mapowanie na kanoniczny
- Pole 'ks_seria_dowodu' w body → zapisywane pod 'ks_nr_dowodu'
- Pole 'ks_nazwa_klubu' w body → zapisywane pod 'ks_klub'

Po wgraniu nowy testowy user przez sync z SOKS Desktop POWINIEN mieć 0 alt kluczy.

== 1.2.6 ITER 4 (2026-05-16) - meta cleanup po Test B ==

Test B sanity check (user #11172) wykrył 3 alt klucze + 1 duplikat:
- ks_birth_date generowany przez KS_Meta_Sync hook (był primary w starej mapie)
- ks_first_name_2 — brak ks_drugie_imie w KS_Meta_Sync mapie
- ks_nazwa_klubu — formularz ks-add-member-form ma name="ks_nazwa_klubu"
- ks_seria_dowodu — duplikat ks_nr_dowodu (KS_Meta_Sync alt + desktop sync)

Naprawy (iteracja 4, do wgrania):

includes/shortcodes/ajax-handlers.php (linia 3030):
- 'ks_nazwa_klubu' => 'ks_nazwa_klubu' → 'ks_nazwa_klubu' => 'ks_klub' (kanoniczny)
- Po wgraniu formularz "Dodaj członka" zapisuje pod ks_klub zamiast alt

includes/class-ks-meta-sync.php (krytyczne ujednolicenie mapy):
- USUNIĘTE: ks_birth_date jako primary (linia 115-120)
- DODANE: ks_data_urodzenia jako primary z alt ks_birth_date
- DODANE: ks_drugie_imie jako primary (wcześniej brak w mapie!)
- USUNIĘTE: osobne wpisy ks_nazwa_klubu i ks_klub
- DODANE: ks_klub jako primary z alt [ks_nazwa_klubu, nazwa_klubu, club_name, ks_club_name]
- Hooki sync_on_meta_update i sync_on_meta_add nadal aktywne — teraz synchronizują
  na kanoniczny klucz zgodnie z meta-migration.php (już bez konfliktu).

includes/meta-migration.php:
- ks_nr_dowodu alt rozszerzone o 'ks_seria_dowodu' (z KS_Meta_Sync)
- Migracja Fazy E scali wartości z ks_seria_dowodu do ks_nr_dowodu

Status: lokalnie naprawione (XAMPP). właściciel podmienia 3 pliki na produkcji,
test B ponowny powinien pokazać 0 alt kluczy dla nowego testowego usera.

== 1.2.6 (2026-05-15) - meta cleanup faza B+C (po diagnozie) ==

Kontynuacja porządkowania metadanych użytkowników (Faza B+C planu cleanup).
ITERACJA 2: po wynikach diagnozy + decyzjach właściciela 2026-05-15.

Wyniki diagnozy na produkcji (1132 użytkowników z klubem):
- ks_klub vs ks_nazwa_klubu: 1096 to duplikat, 4 różne, 32 tylko ks_nazwa_klubu
- "imię i nazwisko administratora" w polach licencji: 11 rekordów (nie 25 — część wyczyszczono)
- ks_PESEL/ks_pesel: 1122 KAŻDY (źródło duplikatów: ks_update_user_meta_fallback)
- ks_drugie_imie 107 vs ks_first_name_2 345 → 238 do migracji
- ks_adres_zamieszkania 506 vs adres_zamieszkania 1016 → 510 do migracji

Decyzje właściciela:
- ks_klub vs ks_nazwa_klubu: SCALAMY, preferujemy KRÓTSZĄ wartość ("klub pilotażowy")
- helpers.php WRITE: naprawić TERAZ + rozszerzyć READ fallback
- Refaktor rejestracja.php + members-admin.php: zrobić TERAZ

ZMIANY KODU (vs iteracja 1 z dzisiaj):

includes/meta-migration.php:
- Mapa kanoniczna ks_klub: alt rozszerzone o ks_nazwa_klubu (i nazwa_klubu)
- Nowa funkcja ks_fix_klub_records($dry_run) — naprawia:
  * 32 rekordów (tylko ks_nazwa_klubu) → kopia do ks_klub
  * 4 rekordy (różne wartości) → krótsza wartość w obu kluczach
- Nowa sekcja UI "🔧 Faza B krok 2 — naprawa rekordów klubowych" z przyciskami
  dry_run i wykonanie + raport ze szczegółami zmienionych rekordów

includes/shortcodes/helpers.php — KLUCZOWA NAPRAWA źródła duplikatów:
- ks_update_user_meta_fallback (linia 696-700): WCZEŚNIEJ pisało pod
  WSZYSTKIMI alt kluczami "dla spójności" (telefon → 6 kluczy, PESEL → 3,
  kod_pocztowy → 5). To było źródło 1122× duplikatów PESEL i 1025×
  kod_pocztowy. TERAZ pisze TYLKO pod KANONICZNYM (pierwszy w liście).
- ks_get_user_meta_fallback (default case): rozszerzony o auto-fallback
  z ks_get_canonical_meta_map() — dla pól nieobjętych switch'em
  automatycznie próbuje wszystkich alt z mapy. Dzięki temu odczyt nadal
  znajduje stare wartości po zmianie WRITE.

includes/formularze/rejestracja.php (linia 190+):
- Stary kod auto-prefixował ks_ + strtolower (pisał pod ks_first_name_2,
  ks_seria_i_numer_dowodu_osobistego). TERAZ używa ks_update_user_meta_fallback
  → automatyczne mapowanie na kanoniczne (ks_drugie_imie, ks_nr_dowodu).

includes/members-admin.php (linia 1596+):
- Identyczna zmiana: hardkodowana mapa $field_mapping zastąpiona
  wywołaniem ks_update_user_meta_fallback.

includes/menu-admin.php (linia 6218):
- Mapowanie pole→sekcja: dodany ks_drugie_imie (kanoniczny) jako primary,
  ks_first_name_2 zostawiony jako legacy do usunięcia w Fazie F.

includes/ustawienia-bazy/import-export.php (linie 1316-1318):
- AI mapping kolumn CSV ('drugie imię', 'drugie imie', 'second name')
  wskazuje teraz na ks_drugie_imie (kanoniczny) jako primary,
  ks_first_name_2 jako fallback dla starszych instalacji.

Uwaga o szablonach PDF (dokumenty/szablones/*):
- Placeholder {{ks_first_name2}} w szablonach HTML POZOSTAJE bez zmian.
- Logika fallback w class-ks-pdf-core.php:727 i ks-modul-dokumenty.php:3686
  już zawiera ks_drugie_imie jako alt → PDF nadal zadziała po Fazie F.

ZMIANY ITERACJI 1 (już zawarte w 1.2.6):

Rozszerzona mapa kanoniczna (includes/meta-migration.php):
- Dodane 6 par sędzia/instruktor (decyzja właściciela 2026-05-15)
- Dodane aliasy z SOKS Desktop: ks_klasa_pzss, ks_licencja_pzss, ks_adres,
  ks_country, ks_first_name2, ks_numer_dowodu (z database.js:1450-1466)
- Dodane aliasy z helpers.php: ks_phone, ks_address_1, ks_postcode, ks_city
- Dodany ks_licencja_nadana (947 rekordów na produkcji) jako alt dla
  ks_data_nadania_licencji
- Dodany ks_licencja_wazna_do (970 rekordów na produkcji) jako alt dla
  ks_data_waznosci_licencji
- Lepsze komentarze grupujące pola (TOŻSAMOŚĆ/KONTAKT/KLUB/LICENCJA/...)

Nowe funkcje diagnostyczne (Faza B — diagnoza PRZED migracją):
- ks_run_meta_diagnosis_klub() — rozstrzyga ks_klub vs ks_nazwa_klubu
  (statystyka: ile rekordów ma tę samą wartość, ile różne)
- ks_run_meta_diagnosis_bledne_rekordy() — lista 25 błędnych rekordów
  z wartością "imię i nazwisko administratora" w polach licencji
- ks_run_meta_diagnosis_coverage() — pokrycie każdego klucza kanonicznego
  vs alternatywne (skala migracji per pole)

UI diagnozy w admin: SOKS Ustawienia → Migracja metadanych
- Nowa sekcja "Faza B — diagnoza" z przyciskiem "Uruchom diagnozę"
- 3 sekcje raportów (klub/imię i nazwisko administratora/pokrycie) z tabelami i kolorowaniem

Fixy bezsporne w kodzie zapisującym meta:
- menu-admin.php:1266 + 1361: 'telefon' => 'telefon' → 'ks_telefon'
  (bug — telefon trafiał pod kluczem bez prefiksu w niektórych ścieżkach
  ks_dodaj_uzytkownika i ks_aktualizuj_istniejacego_uzytkownika).
- rest-api.php:1355-1356: USUNIĘTE ks_Imie/ks_Nazwisko (duplikat
  z wp_users.first_name/last_name zapisanymi wyżej przez wp_insert_user).
- ajax-handlers.php:3027: 'Seria_i_numer_dowodu_osobistego' → 'ks_nr_dowodu'
  (poprzednio pisało pod ks_seria_i_numer_dowodu_osobistego = alt key).

Następne kroki:
- Faza D: testy lokalne w XAMPP (rejestracja, edycja, sync desktop)
  + sprawdzenie czy w trakcie testów nowe zapisy NIE generują duplikatów
- Faza B krok 2: uruchomienie "Napraw rekordy klubowe" przez admin
- Faza E: backup + dry_run migracji + migracja na produkcji
- Faza F: cleanup starych kluczy + 11 rekordów "imię i nazwisko administratora"
  + usunięcie ks_PESEL plaintext z rodo-compliance.php:83
- Faza F: cleanup starych kluczy + rodo-compliance.php:83

== 1.2.5 (2026-05-15) - feature ==

Pełna obsługa konkurencji TRAP (strzelba do rzutek) — w wynikach,
komunikacie klasyfikacyjnym i rankingu (feedback właściciela 2026-05-15):

Migracja DB 4.10.0 (idempotentna, bezpieczna):
- Tabela ks_zawody_konkurencje — nowa kolumna `strzaly_per_seria`
  (SMALLINT, NULL = parsowanie z nazwy). Pozwala na ręczne ustawienie
  np. TRAP25=25 strzałów per seria, TRAP125=5 serii × 25.
- Tabela ks_zawody_wyniki_szczegoly — nowa kolumna `dogrywka`
  (TINYINT, NULL/0/1). Tie-breaker shoot-off dla konkurencji rzutkowych.

Nowe helpery w includes/zawody/ks-modul-zawody.php:
- ks_konkurencja_typ_punktacji() — rozpoznaje typ: 'rzutki' (TRAP/SKEET/
  SPORTING/COMPAK), 'ipsc' (IPSC/Practiscore), 'tarcza' (KSP/PSP/PCZ/KCZ).
  UWAGA regex `(?:^|\W)(TRAP|SKEET|...)` — bez \b po nazwie bo TRAP10
  ma cyfrę po P (word char, brak boundary).
- ks_konkurencja_uses_10x() — true tylko dla tarcza, false dla rzutek.
- ks_konkurencja_parse_format() — parsuje liczbę serii/strzałów z nazwy:
  TRAP10→1×10, TRAP25→1×25, TRAP125→5×25, KSP20→2×10, PSP30→3×10.

Edytor konkurencji (Formularze → Konkurencje):
- Nowe pole "Strzały w serii" obok "Ilość serii" — administrator może
  ręcznie ustawić obie wartości (NULL = parse z nazwy konkurencji).

Ręczna rejestracja wyników (Wyniki → Wpisz wyniki ręcznie):
- Dla konkurencji rzutkowych (TRAP/SKEET/SPORTING/COMPAK):
  * Ukryte kolumny "10x" per seria i "10X" sumaryczne (nie ma dziesiątek).
  * Walidacja max wartości w polu Seria zależna od strzaly_per_seria
    (max 10 dla TRAP10, max 25 dla TRAP25).
  * Nagłówek "Seria 1 (/10)" pokazuje maks. liczbę trafionych rzutek.
  * Dla konkurencji pełnych (≥25 strzałów) nowa kolumna "Dogrywka":
    sędzia wpisuje 1 (trafił) lub 0 (spudłował) — TYLKO przy remisach.

Sortowanie wyników z tie-breakerem zależnym od typu:
- Rzutki: suma → dogrywka (1>0>NULL) → nazwisko (PZSS/ISSF shoot-off).
- Tarcza/IPSC: suma → liczba X → nazwisko (bez zmian, jak wcześniej).

Komunikat klasyfikacyjny PDF (Komunikat Klasyfikacyjny):
- Dla rzutek znikają kolumny "10X" w nagłówku tabeli i kolumny X<n> z list
  wyników serii (filtrowane przez regex).
- Kolumna "Dogrywka" wyświetlana TYLKO gdy są wartości !=NULL
  (sędzia wypełnił przy remisach) — ✓ trafił, ✗ spudłował, — brak.

Ranking sezonu (Overall PDF):
- Dla rzutek znikają kolumny "10X" per runda i "X" sumaryczna.
- Sortowanie po samej sumie (tie-break przez dogrywkę nie ma sensu
  w klasyfikacji wieloturniejowej — to per-zawodow specific).

REST API /results/manual (POST z SOKS Desktop):
- Akceptuje pole dogrywka w body. Tie-breaker sortowania wybierany
  na podstawie typu konkurencji. Idempotentne — pole dogrywka
  zapisywane tylko jeśli kolumna istnieje (defensywnie po migracji).

Naprawiony bug regex (build 1):
- W pierwszej wersji regex `\bTRAP\b` NIE pasował do "TRAP10"
  bo \b wymaga przejścia litera→nie-litera, a w "TRAP10" po P jest
  cyfra (też word char). Zmienione na `(?:^|\W)(TRAP|SKEET|...)`.

Konkurencje SKEET/SPORTING/COMPAK — odłożone na później:
- Logika identyczna (też bez 10X), ale różne reguły szczegółowe
  per dyscyplina (np. SKEET ma stałe pozycje stanowisk 1-8).
- Helper już akceptuje te nazwy — wystarczy dodać tabele klas
  i specyfikę punktacji gdy klub będzie ich potrzebował.

== 1.2.4 (2026-05-12) - feature ==

Nowy filtr na liście członków: data ważności licencji PZSS (feedback właściciel):
- Dodana kolumna "Data ważności licencji" w wyborze kolumn listy członków
  (Lista Członków → Wybór kolumn do wyświetlenia → Data ważności licencji).
- Filtr działa w dwóch trybach:
  * Konkretny dzień — wypełnij tylko pole "od" (np. 31.12.2024 znajdzie
    wszystkich członków którym licencja kończy się dokładnie tego dnia).
  * Zakres — wypełnij oba pola "od" i "do" (np. licencje wygasające w grudniu
    2026: od 2026-12-01 do 2026-12-31).
- Renderowanie wartości w komórce:
  * Ważna >30 dni: zielone tło, data dd.mm.yyyy
  * Wygasa w ciągu 30 dni: żółte tło + licznik dni do końca
  * Przeterminowana: czerwone tło + etykieta "PRZETERMINOWANA"
  * Brak daty: szary myślnik
- Obsługa wielu formatów daty w bazie (Y-m-d, d.m.Y, d/m/Y) + 3 meta keys
  (ks_data_waznosci_licencji, ks_licencja_data_waznosci, ks_licencja_zawodnicza_data_waznosci)
  — kompatybilność z różnymi wersjami importu CSV i pluginu.

Nowy typ filtra "date_range" dodany do systemu dynamicznych kolumn — może być
wykorzystany w przyszłości dla innych pól datowych (data dołączenia,
data egzaminu patentowego, daty ważności pozostałych licencji etc.).

Opcja ukrycia przycisku „💵 Oznacz jako opłacone" (feedback właściciela 2026-05-15):
- W SOKS → Ustawienia kasy → sekcja „Dodatkowe opcje" dodany checkbox
  „Pokaż przycisk Oznacz jako opłacone w pasku akcji zbiorczych".
- Gdy włączony (domyślnie — kompatybilność wstecz): przycisk widoczny na
  stronie Zarządzanie zawodami → Rejestracje, działa jak dotychczas
  (tylko zmiana statusu, BEZ generowania KP/PO).
- Gdy wyłączony: przycisk ukryty. Kasjer/admin musi użyć przycisków
  „🧾 Rozlicz wpisowe zawodnika" (jeden zawodnik → KP/PO) lub
  „💼 Rozlicz — inny płatnik" (kontrahent → KP/PO/należność).
  Wymusza pełną dokumentację księgową każdej wpłaty —
  zabezpieczenie przed przypadkowym oznaczeniem rejestracji jako
  opłaconych bez śladu finansowego w kasie klubu.
- Ustawienie zapisywane w `ks_ustawienia_kasy['show_oznacz_oplacone_btn']`.

== 1.2.3 (2026-05-08) - hotfix ==

Hotfix dla rozliczania należności od operatorów płatności (BLIK / Przelewy24).

Problem:
- Po rozliczeniu przelewu od operatora dokument PO tworzył się poprawnie
  (PO/097/2026 itp.), ale wszystkie zamówienia (w tym pierwsze, które miało
  wc_order_id w PO) pozostawały na liście "Należności od operatorów płatności"
  jako nierozliczone. Pierwsza próba naprawy (uproszczenie warunku tekstowego)
  nie zadziałała w produkcji.

Przyczyna główna:
- Filter wyszukiwał zamówienia w PO przez LIKE '%#X:%' w polach tekstowych
  opis i tytul_operacji. Ta strategia była zawodna — kombinacja sanityzacji
  WordPress (wp_kses_post), kodowania UTF-8 znaków specjalnych (myślnik
  długi —, wielokrotne spacje, znaki nowej linii) oraz potencjalnie escape
  LIKE w niektórych wersjach MariaDB powodowała że tekst zapisany do bazy
  nie zawsze pasował do prostego wzorca '%#X:%'.

Rozwiązanie strukturalne:
- Migracja DB 4.9.0 — dodano tabelę łączącą wp_ks_po_zamowienia_link
  (po_transakcja_id × wc_order_id) z UNIQUE KEY i indeksami na obu
  kolumnach. Wypełniona automatycznie dla wszystkich istniejących PO
  przez backfill parsujący OBA pola tekstowe regexem /#(\d+):/:
    * tytul_operacji — stary format zbiorczych PO 1.2.x ("Zamówienia #5654: ...")
    * opis — nowy format zbiorczych PO 1.2.2+ (lista numerów po "[ROZLICZONE]")
  Plus wc_order_id (główne zamówienie) — pojedyncze PO i pierwsze
  ze zbiorczych.
- Nowa funkcja ks_link_po_zamowienia($po_id, $order_ids) — idempotentne
  wstawienie powiązań (INSERT IGNORE).
- Funkcja ks_dodaj_dokument_po() — automatycznie zapisuje powiązanie
  z głównym order_id po udanym INSERT.
- AJAX zbiorczego rozliczenia — dolinkowuje WSZYSTKIE order_ids
  zaznaczone do zbiorczego PO (nie tylko pierwszy).
- AJAX zbiorczego rozliczenia (ścieżka istniejącego PO) — też dolinkowuje
  zamówienie jeśli powiązanie jeszcze nie istnieje.
- Filter "Należności od operatorów" — JOIN z tabelą łączącą zamiast
  wyszukiwania tekstowego. Deterministyczne i wydajne (jedno zapytanie
  zamiast N w pętli).

Naprawa pojedynczego rozliczenia (z poprzedniego patcha):
- ks_ajax_rozlicz_rozrachunek (metoda 'po') zawsze dopisuje flagę
  [ROZLICZONE: data] do opisu, niezależnie od kwoty otrzymanej.

Zbiorcze rozliczenia rejestracji na zawody (feedback właściciela 2026-05-08):
Dodane 2 nowe przyciski w pasku akcji "Lista rejestracji":

1. "Rozlicz wpisowe zawodnika" — zbiorcze rozliczenie startowych
   pojedynczego zawodnika za wszystkie jego konkurencje:
   * Walidacja: wszystkie zaznaczone muszą być TEGO SAMEGO zawodnika
   * Modal: wybór formy (KP gotówka / PO przelew już otrzymany) + data
   * Generuje JEDEN zbiorczy dokument KP lub PO z opisem zawierającym
     listę konkurencji
   * Status wszystkich zaznaczonych rejestracji → paid + nr dokumentu
     w polu uwagi

2. "Rozlicz — inny płatnik" — zbiorcze rozliczenie wielu zawodników
   z możliwością wyboru płatnika:
   * Płatnik = Budżet klubu → status paid + adnotacja "Pokryte przez klub",
     bez dokumentu kasowego
   * Płatnik = inny kontrahent z bazy → trzy formy:
     - KP (gotówka w kasie) — zbiorczy dokument KP od razu
     - PO (przelew już otrzymany) — zbiorczy dokument PO od razu
     - Należność z terminem płatności — rozrachunek w ks_zobowiazania
       z terminem (do późniejszego rozliczenia, można wystawić fakturę
       z poziomu modułu rozrachunków)
   * Możliwość dodania nowego kontrahenta z poziomu modala
   * Wszystkie zaznaczone rejestracje → paid + adnotacja w uwagach

Pierwszy przycisk "Oznacz wybrane jako opłacone" pozostaje bez zmian
(tylko zmiana statusu, bez dokumentu — gdy płatność już gdzieś zaksięgowana).

Trzy przyciski rozliczeń na stronie rejestracji (feedback właściciela 2026-05-09/10):
- Pasek akcji zbiorczych na zakładce „Rejestracje" rozszerzony o 2 nowe przyciski:
  * 🧾 Rozlicz wpisowe zawodnika — JEDEN zawodnik, JEDEN dokument KP/PO
    (gotówka w kasie / przelew na konto). Walidacja: wszystkie zaznaczone
    rejestracje muszą być tego samego user_id (lub gościa). Generuje jeden
    zbiorczy KP/PO na sumę wszystkich startowych zawodnika.
  * 💼 Rozlicz — inny płatnik — wielu zawodników, wybór płatnika:
      - Budżet klubu — pokrywa z budżetu klubu (bez dokumentu kasowego,
        tylko status paid + adnotacja w uwagach)
      - Inny kontrahent (z bazy ks_kontrahenci lub nowy):
        ⦿ KP — gotówka w kasie (zbiorczy KP na nazwę kontrahenta)
        ⦿ PO — przelew już otrzymany (zbiorczy PO na nazwę kontrahenta)
        ⦿ Należność z terminem płatności — utworzy rozrachunek w
          ks_zobowiazania, można później rozliczyć przez panel Rozrachunki
          (po opłaceniu generuje się PO)
- Każdy z dokumentów ma w opisie listę powiązanych rejestracji (ID).
- Każda rejestracja po rozliczeniu otrzymuje w polu uwagi adnotację
  z numerem dokumentu/rozrachunku oraz nazwą płatnika (jeśli inny niż
  sam zawodnik).
- Status rejestracji: paid (lub confirmed jeśli był).
- Istniejący przycisk „💵 Oznacz wybrane jako opłacone" — bez zmian
  (tylko zmiana statusu, zachowany jako shortcut dla przypadków gdzie
  płatność jest udokumentowana w innym miejscu, np. WC online).

Auto-rozliczanie prowizji po KOM z SOKS Desktop (feedback właściciela 2026-05-10):
- Plugin teraz wykrywa dokumenty KOM (kompensata) utworzone przez SOKS Desktop
  w tabeli wp_ks_kasa_transakcje i automatycznie aktualizuje powiązane
  zobowiązania w wp_ks_zobowiazania na status 'oplacone'.
- Parser opis KOM rozpoznaje format "Należności: X,Y,Z | Zobowiązania: A,B"
  (taki generuje SOKS Desktop). Obsługuje IDs z offsetem +1000000 (konwencja
  SOKS Desktop) ORAZ surowe IDs z WP plugin.
- Dla każdego rozpoznanego ID: ustawiane są pola status='oplacone',
  rozliczone_data (z daty KOM), rozliczone_dokument (numer KOM),
  rozliczone_typ='kompensata'.
- Idempotentne — sprawdzenie odbywa się przy każdym renderowaniu zakładki
  Rozliczenia, ale UPDATE dotyczy tylko rekordów wciąż 'nieoplacone'.
- Rozwiązuje problem rozjazdu sync REST API: jeśli SOKS Desktop nie zdoła
  zaktualizować WP po wykonaniu kompensaty (np. przez błąd mapowania
  lokalnego id ↔ wp_id), plugin sam się "naprawia" przy następnym render.

Wyśrodkowanie separatora v4.1 (FINAL) + adres pod nazwiskiem
(feedback właściciela 2026-05-11, 4 iteracje):
- Kreska podziału ORYGINAŁ/KOPIA na wydrukach KP/KW/PO/PW/BP/BW/IW/IWY/KOM
  była przesunięta ~4 mm w górę od środka strony A4 landscape przy
  domyślnych ustawieniach drukarki Chrome.
- Iteracje naprawy:
  * v1 (95mm + 10mm gap + 95mm) — separator 4 mm za wysoko
  * v2 (@page margin: 0 + absolute positioning) — wycentrowane, ale overflow
    przez hardware printer margins → 2 strony. WYCOFANE
  * v3 (@page margin: 5mm + flex 1:1 + bez fixed body height) — dalej 4 mm
    za wysoko bo Chrome przy "Domyślne" marginesach ignoruje deklarację
  * v4 (body flex centered + wrapper 190 mm) — zostało 2 mm za wysoko
  * v4.1 FINAL — padding-top: 4mm na body kompensuje asymetrię marginesów
    fizycznych drukarki. Separator trafia DOKŁADNIE na środek 210 mm A4.
- Działa identycznie dla wszystkich typów dokumentów (CSS wspólny).
- Adres odbiorcy w wierszu „Przyjęto od / Wydano dla" przeniesiony pod
  nazwę (wcześniej po przecinku w tej samej linii). Zgodne z polską
  praktyką formatowania dokumentów księgowych.

Layout dokumentów KP/KW — flex column z fixed height (feedback właściciela 2026-05-10):
- Każda połowa dokumentu (ORYGINAŁ + KOPIA) ma teraz SZTYWNĄ wysokość 95mm
  z layoutem flex column (header / tabela / spacer / podpisy / stopka).
- Separator między kopiami: 10mm gap z linią przerywaną pośrodku.
- Suma: 5mm @page top + 95mm + 10mm + 95mm + 5mm @page bottom = 210mm
  (dokładnie wysokość A4 landscape).
- Treść rozkłada się równomiernie na 95mm — header u góry, tabela bezpośrednio
  pod nim, podpisy zepchnięte do dolnej krawędzi przez flex-grow spacer.
- Print preview teraz wykorzystuje pełną wysokość strony (wcześniej dokumenty
  wyglądały na 60-70mm zamiast pełnych 95mm).
- Identyczny fix wprowadzony do SOKS Desktop (renderer/app.js).

Layout dokumentów KP/KW — naprawa overflow (feedback właściciela 2026-05-09):
- Adres odbiorcy renderowany w jednej linii ("ulica nr_domu, kod miasto")
  zamiast dwóch (było: ulica<br>kod miasto). Skraca wysokość wiersza
  "Przyjęto od:" / "Wydano dla:" o ~5mm.
- Padding komórek tabeli: 6px 8px → 3px 6px (~5mm w pionie zaoszczędzone).
- Font-size tabeli: 11px → 10px, kwoty 14px → 12px.
- Header dokumentu: logo max-height 60px → 45px, club font 11px → 9px,
  margin-bottom 15px → 8px, padding-bottom 10px → 6px.
- Sekcja podpisów: margin-top 20px → 8px, długość linii (margin-top
  border-top) 25px → 15px. Nazwiska 9px → 8px.
- Stopka "Wygenerowano": margin-top 10px → 4px, font 9px → 8px.

Wszystkie zmiany razem zaoszczędziły ~25mm wysokości w każdej połowie,
co pozwala zmieścić pełną treść (header + 6 wierszy tabeli + podpisy
+ stopka) w 100mm — nie ucinając Kwoty/Słownie/Uwag jak wcześniej
przy dłuższych adresach lub rozbudowanym opisie.

Estetyka dokumentów KP/KW (feedback właściciela 2026-05-09):
- Kwota na dokumencie kasowym renderowana w kolorze czarnym
  (wcześniej zielona dla KP/PO, czerwona dla KW/PW — kolorystyka
  utrudniała czytelność na czarno-białym wydruku, gdzie szary kolor
  zielonego/czerwonego rozmywał się). Teraz spójna czerń ułatwia
  archiwizację i czytanie kserokopii.
- Etykiety "ORYGINAŁ" i "KOPIA" — kolor zmieniony na czarny
  + pogrubienie. Bez zmian w wydruku PDF i w przeglądarce.
- Adres odbiorcy/wpłacającego (pod nazwiskiem w wierszu "Przyjęto od:"
  / "Wydano dla:") — z #555 na czarny.
- Nazwiska pod liniami podpisów — z #666 na czarny (Wpłacił/Wydał +
  Przyjął/Odebrał).

Podpisy na dokumentach KP/KW (feedback właściciela 2026-05-08):
- KP: "Wpłacił" (osoba) + "Przyjął" (kasjer)
- KW: "Wydał" (kasjer) + "Odebrał" (osoba) ← brakowało wyraźnego
       miejsca na podpis odbierającego gotówkę
- PO: "Wpłacił" (osoba) + "Przyjął" (kasjer/operator)
- PW: "Wydał" (kasjer) + "Odebrał" (osoba)
Pod każdą linią podpisu wyświetlana jest nazwa osoby (auto-fill
z bazy: imię/nazwisko członka albo wystawiającego dokument).

Konsolidacja prowizji (feedback właściciela 2026-05-08):
- Wcześniej zbiorcze rozliczenie tworzyło N osobnych wpisów prowizji
  (np. 10 zamówień → 10 rekordów po 1,03 zł). Teraz: jeden zbiorczy
  przelew = JEDNA pozycja prowizji w ks_zobowiazania (suma + opis
  z numerami zamówień).
- Pojedyncze rozliczenie nadal tworzy jedną prowizję per zamówienie
  (jak wcześniej).
- Dodana akcja masowa "Oznacz prowizje jako rozliczone" — checkboxy
  + numer faktury + data + AJAX endpoint ks_oznacz_prowizje_rozliczone.
  Pozwala wyczyścić historyczne wpisy które zostały rozliczone fakturą
  od PayPro w SOKS Desktop / zewnętrznym systemie księgowym.

Korzyść:
- Istniejące dokumenty PO działają bez ręcznej edycji — backfill wypełni
  tabelę łączącą na podstawie istniejących danych w polu opis i wc_order_id.
- Filter jest teraz O(1) per zamówienie zamiast O(N) — dla 200+ rejestracji
  na zawodach przyspiesza renderowanie listy.
- Nowy schemat eliminuje całą kategorię bugów związanych z escape, sanityzacją
  i kodowaniem znaków w polach tekstowych.

== 1.2.2 (2026-05-06) - released ==

Wydanie produkcyjne zawierające naprawy krytyczne i znaczące rozszerzenia
po pierwszym tygodniu pełnego wdrożenia SOKS w klubie pilotażowym.

Najważniejsze zmiany:
- Pełna responsywność tabel mobile (karta-na-mobile, 4 iteracje rozwoju)
- 4-poziomowy fix koszyka WooCommerce — koszyk nie zachowuje produktów
  między sesjami (zwrot błędnych zakupów odbytych zawodów więcej się nie
  powtórzy)
- PDF dokumentów kasowych KP/KW: A4 landscape z dwoma egzemplarzami po
  100mm na lewej połowie strony, kreska rozdzielająca dokładnie na 10,5cm,
  kwota słownie po polsku (osiemnaście tysięcy trzydzieści pięć zł 79/100)
- Pełna edycja dokumentu kasowego (typ + numer + data + walidacja
  unikalności + ślad audytowy zmian)
- Krytyczne naprawy: składka WC silent fail, zbiorcze rozliczenie PO
  (varchar 255 limit + return false bez raportu), wniosek PZSS (błędy SQL),
  checkbox wyboru odbiorcy emaila, import CSV (PESEL hash dla post-RODO)
- Modal "Dodaj członka" pełny formularz z RODO checkbox + ślad audytowy
  papierowej deklaracji
- Numer początkowy KP/KW przy wdrożeniu klubu mid-year (kontynuacja
  numeracji z poprzedniego systemu)
- Numeracja dokumentów kasowych: MAX(numer) zamiast ORDER BY id (poprawne
  sortowanie liczbowe dla numerów >= 1000)

Szczegóły poniżej.



* [ROZSZERZENIE] Pełna edycja dokumentów kasowych — Typ + Numer + Data
  - Stan przed: modal "Edycja dokumentu" (Panel klubu → Kasa Klubowa →
    Edytuj) miał Typ i Nr dokumentu jako read-only spany — admin nie mógł
    poprawić błędnie wprowadzonego numeru lub typu (np. KP zamiast KW).
  - Stan po: oba pola edytowalne:
    * Typ: select z opcjami KP/KW/PO/PW
    * Numer: input text z walidacją wzorca [A-Za-z0-9/.-_ ]+
  - Walidacja w handlerze:
    1. Typ tylko z whitelisty allowed_typy = ['KP', 'KW', 'PO', 'PW']
    2. Numer:
       - Nie pusty
       - Tylko dozwolone znaki (litery, cyfry, / . - _ spacja)
       - Unikalność — sprawdzenie czy inny dokument nie ma już tego numeru
         (SELECT id WHERE numer_dokumentu = X AND id != edit_doc_id)
       - Jeśli duplikat: czytelny komunikat "Numer X jest już używany przez
         inny dokument (ID: Y). Wybierz inny numer."
  - Ślad audytowy: gdy admin zmienia typ lub numer, error_log zapisuje
    "KS Kasa edit: dokument #X — zmiana typu/numeru przez [admin].
     Stare: typ=KP, nr=KP/050/2026. Nowe: typ=KW, nr=KW/020/2026"
    Plus komunikat na frontendzie informuje że zmiana została zalogowana.
  - Ostrzeżenie wizualne (żółty banner) w modalu:
    "⚠️ Uwaga: Zmiana typu lub numeru dokumentu jest operacją ryzykowną —
     może powodować nieciągłość numeracji i problemy z księgowością.
     Edytuj tylko jeśli wiesz co robisz."
  - Plus diagnostyka: jeśli wpdb->update zwróci false, komunikat zawiera
    last_error MySQL (jak w innych miejscach SOKS).
  - Pliki: includes/shortcodes-v2-complete.php (modal HTML + JS + handler)

* [NAPRAWA KRYTYCZNA] Koszyk WC nie zapamiętuje produktów między sesjami
  - Symptom z 5.05.2026: zawodnik wszedł do koszyka po kilku dniach od poprzedniej
    wizyty, znalazł w nim "Opłatę startową PSP30" dodaną wcześniej. Kupił przez
    BLIK — okazało się że to zawody które JUŻ SIĘ ODBYŁY. Admin musiał zwracać
    pieniądze.
  - Przyczyna: WooCommerce ma 2 mechanizmy zapamiętywania koszyka:
    1. wp_woocommerce_sessions (DB) — TTL domyślnie 48h, klucz przez cookie
    2. _woocommerce_persistent_cart_X (user_meta) — przywiązany do user_id,
       działa CROSS-DEVICE i CROSS-SESSION (główny winowajca)
    Po zalogowaniu się user'a WC sięga po persistent cart i przywraca produkty
    sprzed kilku dni — niezależnie od tego z jakiego urządzenia się loguje.
  - Naprawa 4-poziomowa:
    1. Wyłączenie persistent cart całkowicie:
         add_filter('woocommerce_persistent_cart_enabled', '__return_false');
       Koszyk już nie jest persisted w user_meta — żyje tylko w bieżącej sesji.
    2. Czyszczenie koszyka przy LOGOWANIU (wp_login):
       - delete_user_meta('_woocommerce_persistent_cart_*') — defensywnie,
         na wypadek gdyby inny plugin włączył persistent
       - WC()->cart->empty_cart(true) — wyczyść też session cart
       Nowa wizyta = pusty koszyk.
    3. Czyszczenie koszyka przy WYLOGOWANIU (wp_logout) — symetrycznie,
       szczególnie ważne dla udostępnionych komputerów klubowych
       (kolejny user nie widzi produktów poprzedniego).
    4. Skrócenie TTL sesji WC z 48h do 1h:
         add_filter('wc_session_expiration', fn() => HOUR_IN_SECONDS);
       Po 1h nieaktywności sesja wygasa, koszyk znika — wymusza świeże
       dodawanie do koszyka przy każdej nowej wizycie zakupowej.
  - DODATKOWA WARSTWA OBRONNA: auto-usuwanie z koszyka pozycji których zawody
    już się odbyły. Defensywa drugiego poziomu — gdyby coś przeszło przez
    powyższe mechanizmy (np. user wrócił szybko z otwartą sesją), pozycja
    jest filtrowana przed renderem koszyka i checkout:
    - ks_check_product_zawody_expired($product_id) sprawdza czy product_id
      jest powiązany z konkurencją (ks_zawody_konkurencje.wc_product_id)
      i czy najpóźniejsza runda tych zawodów jest w przeszłości
    - Hooki: woocommerce_before_cart, woocommerce_before_checkout_form,
      woocommerce_check_cart_items, wp_loaded
    - Notice dla użytkownika: "Usunięto z koszyka pozycje, których zawody
      już się odbyły: [nazwa] (zawody odbyły się 4.05.2026)"
    - Plus walidacja przy add-to-cart (woocommerce_add_to_cart_validation):
      blokuje dodanie produktu z minionych zawodów + komunikat error.
    error_log('KS Cart cleanup: ...') na każdym usunięciu — diagnostyka.
  - Pliki: includes/shortcodes/payments.php (4 nowe sekcje + 3 nowe funkcje)

* [POPRAWA OSTATECZNA] PDF kasowy — kreska rozdzielająca DOKŁADNIE na 10,5cm
  od krawędzi strony (środek A4 landscape)
  - Wymóg klubu: pozioma kreska między ORYGINAŁ a KOPIA musi wypadać dokładnie
    na 10,5cm = 105mm od krawędzi papieru = środek A4 landscape (210mm / 2).
    To pozwala dokładnie zgiąć/odciąć po linii.
  - Po dwóch nieudanych próbach z flex layoutem (różnice 10mm/cm w wysokości
    half'ów) zmieniono na DETERMINISTYCZNY układ blokowy:
      A4 landscape: 297mm × 210mm, @page margin 5mm
      → użyteczna wysokość: 200mm
      → ORYGINAŁ: SZTYWNA wysokość 100mm
      → Separator: height 0, margin 0, border-top 2px dashed
      → KOPIA: SZTYWNA wysokość 100mm
      → Total: 100 + 100 = 200mm (dokładnie 100% użytecznej wysokości)
      → Pozycja kreski: 100mm od top contentu + 5mm margin @page = 105mm
        od krawędzi strony = 10,5cm ✓
  - Bez flex/grid — Chrome w trybie print czasem traktuje flex inaczej niż
    na ekranie. Sztywne `height: 100mm` na każdym half'ie + separator height 0
    daje matematycznie pewny rezultat na każdym engine.
  - To samo w preview na ekranie — width 148mm, height 100mm × 2.
  - Pliki: includes/shortcodes-v2-complete.php

* [POPRAWA] PDF kasowy — strukturalna naprawa równego podziału wysokości
  - Po pierwszej iteracji flex: 1 1 0 dolny dokument wciąż był wyższy.
  - Przyczyna: `border-bottom: 2px dashed` na pierwszym half-ie nadal
    zabierał 2px z jego flex-basis (z `box-sizing: border-box`). Plus
    wrapper miał height: 180mm — content nie zajmował całej dostępnej
    wysokości strony, zostawiając białą przestrzeń na dole.
  - Rozwiązanie strukturalne:
    1. Separator między ORYGINAŁ a KOPIA jako OSOBNY element <div class="ks-separator">
       (renderowany w pętli foreach gdy $copy_index === 1). Dzięki temu obie
       half mają IDENTYCZNY box model — bez borderów, bez różnic w paddingach.
       .ks-separator { flex: 0 0 auto; height: 0; border-top: 2px dashed }
    2. Wrapper rozszerzony na PEŁNĄ dostępną wysokość:
       - html, body { height: 100% } z @page A4 landscape margin: 5mm
         → dostępne 200mm × 287mm
       - .ks-pdf-wrapper { min-height: 195mm; height: 100% } — flex: 1 1 0
         na każdej half daje dokładnie 50/50 podział całej dostępnej wysokości
       - body jako display: flex (wrapper jako flex item)
    3. Preview: identyczna struktura — height: 200mm, separator jako osobny div.
  - Pliki: includes/shortcodes-v2-complete.php

* [POPRAWA] PDF kasowy — równy podział wysokości ORYGINAŁ/KOPIA (flex layout)
  - Symptom: dolny dokument (KOPIA) wyższy niż górny (ORYGINAŁ).
  - Przyczyna: różnice w box modelu między dwiema połówkami:
    1. Pierwsza połówka miała border-bottom: 2px (oddzielacz dashed) który
       z box-sizing: border-box zabierał 2px z height
    2. Pierwsza miała też dodatkowy padding-bottom: 6px (vs 5px standardowo)
    3. W preview używałem `min-height: 95mm` zamiast `height` — KOPIA mogła
       się rozciągać bardziej bo nie miała border-bottom zamykającego ją
       wizualnie.
  - Rozwiązanie: flexbox z flex: 1 1 0 na każdej połówce — wymusza IDENTYCZNĄ
    wysokość niezależnie od różnic w borderach/paddingach:
    Print:
      .ks-pdf-wrapper { display: flex; flex-direction: column; height: 180mm }
      .ks-document-half { flex: 1 1 0; min-height: 0; overflow: hidden }
    Preview:
      #ks-pdf-content { display: flex; flex-direction: column; height: 190mm }
      .ks-document-half { flex: 1 1 0; min-height: 0; overflow: hidden }
    Plus usunięty extra padding-bottom z first-child — oba mają teraz
    identyczny padding 5px 10px.
    flex: 1 1 0 + min-height: 0 to klasyczny pattern equal-height columns
    w flex containerze.
  - Mobile preview: flex wyłączone (display: block), wysokość naturalna —
    żeby na telefonie dokumenty układały się normalnie bez kompresji.
  - Pliki: includes/shortcodes-v2-complete.php

* [POPRAWA] PDF kasowy — fix podwójnej strony + wskazówka o headerze Chrome
  - Symptom 1: po poprzedniej zmianie (@page margin: 0 + body height: 210mm)
    okno druku pokazywało "1/2" — content przelewał się na drugą pustą stronę.
  - Przyczyna: html/body { height: 210mm } w połączeniu z wymuszonym przez
    Chrome headerem/footerem (ok. 8mm + 8mm) → body 210mm nie mieściło się
    na 194mm dostępnej wysokości → overflow → druga strona.
  - Rozwiązanie:
    * Usunięte fixed height z html/body — content płynie naturalnie.
    * @page margin wrócony na 5mm (zamiast 0) — Chrome i tak nie respektuje
      0 z włączonym headerem, więc równie dobrze przywracamy normalny margines.
    * .ks-document-half height zmniejszone z 95mm do 88mm
      (2 × 88mm = 176mm < 200mm dostępne po marginesach 5mm).
    * Dodane page-break-inside: avoid + break-inside: avoid — Chrome
      nie podzieli pojedynczego dokumentu na 2 strony.
    * Dodane page-break-after: avoid + break-after: avoid na ostatnim
      dokumencie — Chrome nie dodaje pustej strony po contencie.
  - Symptom 2: nagłówek "4.05.2026, 12:47 | about:blank" i stopka "1/2"
    dalej widoczne mimo @page margin: 0.
  - Przyczyna: Chrome WYMUSZA header/footer w trybie druku gdy user ma
    włączoną opcję "Nagłówki i stopki" w oknie drukowania. To jest
    USTAWIENIE PRZEGLĄDARKI po stronie użytkownika — żaden CSS nie może
    tego nadpisać. Jedyny sposób uniknięcia headera/footera bez interakcji
    użytkownika to wygenerowanie PDF po stronie serwera (mPDF).
  - Rozwiązanie: dodana czytelna wskazówka (żółty banner z ikonką 💡) na
    stronie podglądu PDF, instruująca użytkownika jak ODZNACZYĆ "Nagłówki
    i stopki" w oknie druku Chrome. To jest jednorazowa operacja —
    przeglądarka zapamiętuje preferencję dla kolejnych wydruków.
  - Pliki: includes/shortcodes-v2-complete.php

* [POPRAWA] PDF kasowy — usunięte nagłówki/stopki przeglądarki, kwota słownie
  zapisywana w pełni po polsku
  - Symptomy:
    1. W oknie druku Chrome dodawał automatycznie header (data/godzina,
       tytuł "KASA WYDA - KW/020/2026") i footer ("about:blank", "1/1") —
       elementy obce na profesjonalnym dokumencie kasowym.
    2. Pole "Słownie:" zawierało zapis liczbowy "18035 zł 79/100" zamiast
       wymaganego księgowo zapisu słownego "osiemnaście tysięcy trzydzieści
       pięć zł 79/100".
  - Rozwiązania:
    1. Header/footer Chrome:
       - @page { margin: 0 } w stylach drukowania — wyłącza domyślny
         header/footer przeglądarki w trybie druku
       - body { padding: 5mm } — zachowujemy fizyczny margines drukarki
         (przejmuje rolę @page margin)
       - <title> </title> (pusty/spacja) — żeby Chrome nie pisał tytułu
         w nagłówku strony
    2. Kwota słownie:
       - Nowa funkcja `ks_kwota_slownie($amount)` w helpers.php
       - Pomocnicza `ks_liczba_na_slowa_pl($n)` — pełny konwerter polski
         liczba → słowa (zakres: 0 do 999 999 999)
       - Obsługa form gramatycznych (tysiąc/tysiące/tysięcy,
         milion/miliony/milionów) z poprawnym uwzględnieniem 12-14 vs 2-4
       - Wyjątek "jeden tysiąc" → "tysiąc" (typowa polska konwencja)
       - Grosze pozostają w formacie XX/100 (księgowa konwencja, pełny
         słowny zapis groszy rzadko stosowany)
       - Funkcja chroniona function_exists() — można jej użyć też
         w innych szablonach SOKS (PDF wniosków, dokumentów członkowskich)
       Przykłady:
         123.45    → "sto dwadzieścia trzy zł 45/100"
         1000      → "tysiąc zł 00/100"
         18035.79  → "osiemnaście tysięcy trzydzieści pięć zł 79/100"
         2024.50   → "dwa tysiące dwadzieścia cztery zł 50/100"
         1234567   → "milion dwieście trzydzieści cztery tysiące pięćset
                      sześćdziesiąt siedem zł 00/100"
  - Pliki:
    * includes/shortcodes/helpers.php (nowa funkcja ks_kwota_slownie + helper PL)
    * includes/shortcodes-v2-complete.php (użycie funkcji + @page margin: 0
      + pusty title w oknie druku)

* [POPRAWA] PDF dokumentów kasowych KP/KW — A4 landscape z dwoma egzemplarzami
  na lewej połowie strony, jeden pod drugim
  - Pierwotny format: A4 portrait (pion) z dokumentami pod sobą.
  - Pożądany format (po wyjaśnieniu z klubem):
      Strona:    A4 landscape = 297mm × 210mm, marginesy 5mm
      Drukowane: TYLKO LEWA POŁOWA — szerokość 143mm
      Layout:    dwa dokumenty (ORYGINAŁ + KOPIA) JEDEN POD DRUGIM
                 każdy ~143mm × 95mm (po marginesach treść ~125mm × 90mm)
                 oddzielone POZIOMĄ kreską przerywaną do odcięcia
      Prawa połowa A4: pusta (do oderwania, archiwizacji lub konwencja klubowa)
  - Klasyczny szablon "papierowego druku KP/KW na pół-arkuszu" stosowany
    w księgowości — pozwala wydrukować dwa egzemplarze (jeden dla wpłacającego,
    drugi do archiwum kasy) na połowie A4, zachowując kompaktowy format.
  - Zmiany techniczne:
    1. Preview (#ks-pdf-content): width: 148mm (połowa A4 landscape), margin: 0 auto
    2. .ks-document-half: display: block (jeden pod drugim), width: 100%,
       min-height: 95mm, border-bottom (zamiast border-right) jako oddzielacz
    3. Print mode (ksPrintDocument): @page { size: A4 landscape; margin: 5mm }
       + wrapper width: 143mm, margin: 0 (lewa strona, nie auto-center)
       + każdy .ks-document-half height: 95mm, font-size: 9px (drobniejsze
       żeby zmieściło się 90mm × 125mm bez przekroczenia)
    4. Mobile preview: pełna szerokość ekranu poniżej 800px
  - Pliki: includes/shortcodes-v2-complete.php (sekcja kasa_action === 'pdf')

* [NAPRAWA OSTATECZNA] Tabela "Należności od operatorów" w Rozrachunkach
  na mobile — wartości komórek niewidoczne mimo poprawnego CSS
  - Po poprzednich 3 iteracjach (flex → grid → block) tabela "Zamówienia WC"
    zaczęła działać poprawnie, ale tabela "Należności od operatorów"
    (Rozrachunki) DALEJ pokazywała tylko etykiety bez wartości.
  - Diagnoza różnicy między tabelami:
    * Zamówienia WC:  <table class="ks-table">  ← bez inline min-width ✓
    * Należności:     <table style="min-width: 900px;" id="ks-po-table"> ✗
  - Przyczyna: inline style="min-width: 900px" wymuszał tabelę 900px szeroką
    nawet po display: block. Komórki <td> ze szerokością 100% rozszerzały się
    do 900px każda. Wartości komórek są wyrenderowane, ALE na pozycji
    odpowiadającej oryginalnej kolumnie tabeli (np. KWOTA na 800px od lewej).
    Telefon ma viewport ~360px → wartości są PRZEWINIĘTE w prawo, parent ma
    overflow-x: auto → niewidoczne. Etykiety ::before (pozycja 0px = początek
    komórki) były widoczne, bo zawsze na lewym brzegu.
  - Rozwiązanie:
    1. Dodano `min-width: 0 !important; max-width: 100% !important`
       do tabel z klasą .ks-table-rwd-ready — wymusza zignorowanie inline
       min-width: 900px.
    2. Selektor uproszczony — `.ks-table-rwd-ready` standalone (poprzednio
       wymagał kombinacji z innymi klasami) — teraz łapie też tabele bez
       klasy ks-table które dostały rwd-ready od JS.
    3. Wrappery z overflow-x: auto (np. div parent tabeli #ks-po-table)
       dostają overflow: visible na mobile — żeby karty mogły być
       renderowane bez przycinania.
  - Pliki: css/responsive.css

* [NAPRAWA] Tabele jako karty na mobile — wartości komórek dalej niewidoczne
  (po poprzednich dwóch iteracjach: flex i grid)
  - Symptom: tabela "Należności od operatorów" w Rozrachunkach na telefonie
    pokazuje listę etykiet (NR ZAM., DATA PŁATNOŚCI, CZŁONEK...) BEZ wartości.
    Identyczny symptom jak po poprzednich dwóch iteracjach naprawy.
  - Przyczyna: i `display: flex`, i `display: grid` na <td> z ::before
    pseudo-elementem mają **wspólny problem** w niektórych mobile engines
    (Samsung Internet, czasem Chrome Android): "anonymous text nodes" (gołe
    napisy w komórce, np. "#5774", "Składka roczna 2026" bez wewnętrznego
    taga jak <strong>) NIE są renderowane jako flex/grid items poprawnie —
    zostają niewidoczne lub mają zerową szerokość.
  - Rozwiązanie (3. iteracja, porzucamy flex/grid):
    `display: block` na <td> + `display: block` na `::before` z labelem.
    Etykieta NAD wartością (zamiast obok), nieco mniej eleganckie ale
    **100% niezawodne** w każdej przeglądarce mobile bo nie używa flex/grid
    ani anonymous text node tricks.
    Przykład:
      ┌────────────────────────────────────────┐
      │  #5774                                  │  (pierwsza komórka, gradient)
      ├────────────────────────────────────────┤
      │  DATA PŁATNOŚCI                         │  (label, mała szara)
      │  30.04.2026                             │  (wartość, normalna)
      ├────────────────────────────────────────┤
      │  CZŁONEK                                │
      │  Imie NAZWISKO                  │
      ├────────────────────────────────────────┤
      │  ...                                    │
      └────────────────────────────────────────┘
  - Pliki: css/responsive.css

* [NAPRAWA] Składka WC — czasem zostawała "NIEOPŁACONA" mimo zrealizowanego
  zamówienia BLIK
  - Symptom: zamówienie WC #5773 (Składka roczna 2026, BLIK, 470 zł) widnieje
    jako "Zrealizowane", w "Historia wpłat" status "ZAKOŃCZONA", ale w panelu
    "Moje konto → Wpłaty" składka pokazuje "NIEOPŁACONA". Dla większości
    członków działało, dla pojedynczych przypadków nie.
  - Przyczyna: funkcja ks_zapisz_platnosc_woocommerce w shortcodes/payments.php
    miała early-return guard:
      $existing = SELECT id FROM ks_platnosci WHERE order_id = %d
      if ($existing) return;
    Guard zapobiegał duplikatom w tabeli ks_platnosci, ale RÓWNIEŻ blokował
    sekcję "AUTOMATYCZNA ZMIANA STATUSU SKŁADKI" gdy ten sam hook odpalał
    się drugi raz. Scenariusz przykladowy:
      1. woocommerce_payment_complete → zapis ks_platnosci ✓
         + próba update_user_meta składki, ale fee_product_id nie pasował
         (np. produkt "Składka roczna 2026" miał inny ID niż w
         ks_membership_fee_configs[2026]) → składka NIE oznaczona
      2. woocommerce_order_status_completed (po przejściu na completed)
         → guard $existing → return PRZED sekcją składki → drugiej szansy
         na oznaczenie nie było
  - Rozwiązanie:
    1. Reorganizacja funkcji: guard $existing teraz blokuje TYLKO ponowny
       insert do ks_platnosci, a sekcja aktualizacji składki wykonuje się
       ZAWSZE (nawet dla "powtórnych" wywołań). Idempotentnie — sprawdza
       czy status już 'opłacona' przed update.
    2. Trzeci poziom dopasowania produktu do roku składki:
       a. ks_membership_fee_configs[YEAR][fee_product_id] (per-rok, nowy)
       b. ks_membership_fee_config[fee_product_id] + [fee_year] (legacy)
       c. NOWE: dopasowanie po NAZWIE produktu — regex
          /sk[łl]adka.*?(\d{4})/iu wyciąga rok z nazwy "Składka roczna 2026",
          "Składka klubowa 2026" itp. Działa nawet gdy product_id został
          przebudowany ale nazwa zachowana.
    3. error_log (zawsze, nie tylko WP_DEBUG) gdy składka zostanie oznaczona
       — diagnostyka pokazuje jaki year, user, order_id, poprzedni status.
  - Dla istniejących przypadków "zgubionych" składek (np. wybrany czlonek):
    admin może ręcznie kliknąć zielony przycisk "✓" w panelu klubu →
    Lista członków → kolumna "Składka". Po wgraniu fixu nowe zamówienia
    automatycznie oznaczają składkę poprawnie.
  - Pliki: includes/shortcodes/payments.php (funkcja ks_zapisz_platnosc_woocommerce)

* [NAPRAWA] Import CSV — wyszukiwanie po PESEL nie znajdowało userów
  (po migracji RODO PESELe są zaszyfrowane w bazie)
  - Symptom: import 787 rekordów licencji PZSS dla członków klubu zwracał
    "Zaimportowano 0 z 787 (787 błędów)" — żaden PESEL nie pasował.
  - Przyczyna: SOKS RODO szyfruje PESEL w bazie:
      ks_PESEL_encrypted  = AES-256-CBC ciphertext
      ks_PESEL_hash       = SHA256(PESEL + salt) — do wyszukiwania
      ks_PESEL            = plain text (zachowane do migracji, ale po pełnej
                            migracji może zostać usunięte)
    Handler importu szukał WYŁĄCZNIE w `ks_PESEL` (plain) → po migracji RODO
    pole jest puste/usunięte → 0 dopasowań mimo że userzy istnieją.
  - Rozwiązanie: dwustopniowa strategia wyszukiwania w handlerze:
      1. Najpierw `ks_PESEL` (plain) — kompatybilność wsteczna z klubami
         przed migracją RODO.
      2. Jeśli nie znaleziono → `ks_PESEL_hash` z hashem SHA256 wygenerowanym
         tym samym sposobem co rodo-compliance.php (`hash('sha256', $pesel
         . 'ks_pesel_search')`).
    Działa dla obu typów klubów: pre-RODO (plain) i post-RODO (encrypted).
  - Komunikat błędu rozszerzony o diagnostykę: gdy nie znaleziono usera,
    pokazuje ile userów w ogóle ma PESEL w bazie (count z meta_key IN
    'ks_PESEL', 'ks_PESEL_hash'). Jeśli liczba 0 → migracja RODO nie odpaliła.
    Jeśli liczba > 0 ale konkretny PESEL nie pasuje → różnica formatu (np.
    leading zeros) lub PESEL nie należy do żadnego członka klubu.
  - Infrastruktura `skipped` w response (zliczana osobno od imported i
    errors) zostawiona w kodzie do przyszłego wykorzystania (gdyby ktoś
    importował szerszą bazę PZSS gdzie część PESELi nie należy do klubu).
  - Pliki:
    * includes/menu-admin.php (handler ks_przetworz_wiersz_importu — szukanie
      hashem; ks_handle_csv_import_batch — zliczanie skipped)
    * includes/ustawienia-bazy/import-export.php (JS — wyświetlanie skipped
      w raporcie końcowym)

* [USPRAWNIENIE] Czytelne komunikaty błędów przy imporcie CSV
  - Symptom: użytkownik widzi enigmatyczne "Błąd uploadu: Błąd bezpieczeństwa"
    bez wyjaśnienia co dokładnie nie tak i jak to naprawić.
  - Przyczyna techniczna: wygasły nonce WordPress (TTL 24h) lub zmiana
    user_token (np. wylogowanie/zalogowanie w innej karcie). Forma została
    wyrenderowana z nonce X, ale przy uploadzie WP oczekuje nonce Y.
  - Rozwiązanie: handler ks_handle_csv_import_updated zwraca teraz konkretny
    komunikat z instrukcją:
      * Brak pola nonce w POST: "Brak tokena bezpieczeństwa w formularzu.
        Odśwież stronę (F5) i spróbuj ponownie."
      * Pole obecne ale wygasło: "Token bezpieczeństwa wygasł lub jest
        nieprawidłowy. Najczęstsza przyczyna: strona była otwarta dłużej
        niż 24 godziny lub wylogowałeś/zalogowałeś się w innej karcie.
        Rozwiązanie: odśwież stronę (F5) i ponów import."
    Diagnostyka error_log z polami: nonce_present, nonce_len, is_logged_in,
    user_id — pomocna gdy bug występuje powtarzalnie.
    Identyczna poprawa w handlerze batch (ks_handle_csv_import_batch).
  - Pliki: includes/menu-admin.php

* [NAPRAWA] Tabele jako karty na mobile — wartości komórek niewidoczne
  (pokazywały się tylko etykiety nagłówków)
  - Symptom: na telefonie tabele Rozrachunków (Należności od operatorów,
    Prowizje operatorów) pokazywały listę nagłówków (NR ZAM., DATA PŁATNOŚCI,
    CZŁONEK, ...) BEZ ich wartości. Każda karta miała tylko etykiety.
  - Przyczyna: w pierwszej iteracji refaktora RWD użyłem `display: flex`
    na `<td>` z `::before { content: attr(data-label) }`. Anonymous text
    nodes (zwykłe wartości tekstowe komórek bez tagu wewnętrznego, np.
    "#5771", "470,00 zł") jako flex items NIE renderowały się poprawnie
    w połączeniu z `flex: 0 0 40%` na `::before` i `text-align: right` —
    były wciśnięte do zera szerokości albo niewidoczne.
  - Rozwiązanie: zamiana `display: flex` na `display: grid` z explicite
    `grid-template-columns: minmax(35%, max-content) 1fr`. Grid layout
    GWARANTUJE że anonymous text nodes zajmują drugą kolumnę i są zawsze
    widoczne. Plus dodatkowy fallback `display: block` dla komórek bez
    atrybutu data-label (gdy JS jeszcze nie zaanotował tabeli).
  - Drugi powiązany fix w responsive-tables.js: rozszerzony selektor
    o `.ks-tab-section table` (kontener tabu Rozrachunków) oraz
    `table[style*="min-width"]` (catch-all dla tabel bez klas SOKS,
    np. id="ks-po-table" w shortcodes-v2-complete.php). MutationObserver
    również obserwuje `.ks-tab-section` aby auto-anotować tabele
    pojawiające się dynamicznie po zmianie tabu.
  - Pliki:
    * css/responsive.css (display:grid + fallback bez data-label)
    * js/responsive-tables.js (selektor + observer)

* [NAPRAWA KRYTYCZNA] Zbiorcze rozliczenie PO — błąd "Data too long for column
  tytul_operacji" przy 10+ zamówieniach, plus wcześniejsze silent fail bez PO
  - Symptom finalny: po wgraniu poprzedniej diagnostyki użytkownik zobaczył
    czytelny komunikat: "Błąd bazy danych WordPressa: Przetwarzanie wartości
    dla następującego pola nie powiodło się: tytul_operacji. Podana wartość
    może być za długa..." — co dokładnie wskazało na właściwą przyczynę.
  - Przyczyna: kolumna tytul_operacji to varchar(255). Handler składał tytuł
    zbiorczy w formacie:
      "Zamówienia #5771: Składka roczna 2026; #5770: Składka roczna 2026; ...
       (×16); #5741: Opłata Startowa PSP30"
    Już przy 8-10 zamówieniach przekraczał limit 255 znaków — MySQL rzucał
    error i ks_dodaj_dokument_po zwracało false.
  - Rozwiązanie: rozdzielenie informacji między dwie kolumny:
    * tytul_operacji (varchar 255) — krótki podpis identyfikujący zbiorczy
      przelew, np. "Zbiorczy przelew Przelewy24 — 16 zamówień (2026-04-30)".
      Defensywne obcięcie do 250 znaków (margines pod varchar 255).
    * opis (TEXT, do 65 KB) — pełna lista zamówień:
        "Przelew bankowy [ROZLICZONE: 2026-04-30]
         Zamówienia rozliczone w tym przelewie:
         #5771: Składka roczna 2026;
         #5770: Składka roczna 2026;
         ..."
      Defensywne obcięcie do 60 000 znaków.
  - Filter wyszukiwania PO w shortcodes-v2-complete.php (przy renderowaniu
    listy "Należności od operatorów") nie wymagał zmiany — szuka numeru
    zamówienia zarówno w `tytul_operacji LIKE '%#X:%'` JAK I w `opis LIKE
    '%#X:%'`. Przeniesienie listy z tytułu do opisu zachowuje wyszukiwanie.
  - Diagnostyka error_log rozszerzona o tytul_len i opis_len żeby od razu
    widzieć wzrost rozmiaru pól przy ewentualnych przyszłych problemach.
  - Pliki: includes/shortcodes/ajax-handlers.php

* [NAPRAWA KRYTYCZNA] Zbiorcze rozliczenie PO — dokument PO NIE był tworzony,
  zamówienia nie znikały z listy "Należności od operatorów" (mimo że frontend
  pokazywał "Rozliczenie zapisane")
  - Przyczyna główna: funkcja ks_dodaj_dokument_po() w cash-register.php
    miała wczesny return false gdy:
      a) ks_get_payment_operator_name($payment_method) zwracał false
         (nie rozpoznał metody płatności jako operatora online), ORAZ
      b) get_user_by('ID', $user_id) zwracał false (user nie istnieje).
    Handler ks_ajax_rozlicz_po_zbiorczo wywołuje funkcję z $user_id=0 dla
    zbiorczego PO obejmującego wiele zamówień. get_user_by(0) zawsze daje
    false → return false → DOKUMENT PO NIE ZAPISANY.
  - Przyczyna współbieżna: handler nie sprawdzał return value funkcji,
    więc zwracał wp_send_json_success mimo że żaden dokument nie powstał.
    Frontend pokazywał alert "Rozliczenie zapisane", ale po reload-zie
    zamówienia dalej widniały jako nieopłacone (bo PO nie istnieje).
  - Rozwiązanie:
    1. ks_dodaj_dokument_po — dodano fallback drugiego poziomu: gdy
       brak operatora I brak usera, zamiast `return false` używa nazwy
       metody płatności jako "osoba" (np. "BLIK", "Przelewy24") albo
       "Operator płatności online" jeśli payment_method też pusty.
       Plus error_log z diagnostyką gdy fallback zostanie użyty.
    2. ks_ajax_rozlicz_po_zbiorczo — sprawdza return value funkcji.
       Jeśli false → wp_send_json_error z komunikatem zawierającym
       $wpdb->last_error (jeśli błąd SQL) lub wskazówkę "sprawdź debug.log".
       Plus diagnostyczne error_log na każdym etapie:
         "tworzenie zbiorczego PO — kwota=..., order_id=..., metoda=..."
         "utworzono dokument PO/X/2026" (sukces)
         "BŁĄD — ks_dodaj_dokument_po zwróciło false. last_db_error=..."
    3. Defensywa: jeśli funkcja ks_dodaj_dokument_po nie istnieje
       (theoretycznie niemożliwe ale defensywnie), handler zwraca
       jasny błąd zamiast cicho ignorować.
  - Pliki:
    * includes/shortcodes/cash-register.php (ks_dodaj_dokument_po — fallback)
    * includes/shortcodes/ajax-handlers.php (ks_ajax_rozlicz_po_zbiorczo —
      sprawdzanie return + diagnostyka)

* [NAPRAWA] Zbiorcze rozliczenie przelewu od operatora (Panel klubu →
  Rozliczenia → Należności) nie reagowało na klik przycisku "Rozlicz zaznaczone"
  - Obserwacja: użytkownik nie mógł rozliczyć zbiorczo — checkbox nie aktualizował
    sumy ZA pierwszym razem albo przycisk nie wywoływał akcji.
  - Najprawdopodobniej: po refaktorze JS na addEventListener (bez inline onclick),
    direct binding do elementów odbywa się raz na DOMContentLoaded. Jeśli inny
    script (cache plugin, security plugin, AJAX reload tabu) zmodyfikuje DOM
    PO init(), bezpośrednie listenery są tracone. Dodatkowo niektóre security
    pluginy (np. Wordfence z restrictive CSP) mogą blokować bezpośrednie
    bindowanie listenerów na elementach z atrybutem CSP-protected.
  - Rozwiązanie: event delegation na document.body — listener zarejestrowany
    raz dla całego BODY, łapie zdarzenia z dowolnego momentu cyklu życia
    elementów (przed/po render, przed/po AJAX reload, etc.). Działa nawet jeśli
    DOM zostanie podmieniony.
    Mechanizm:
      document.body.addEventListener('change', ...) — handler sprawdza target
        czy to .ks-po-checkbox lub #ks-po-select-all, wywołuje odpowiednią akcję
      document.body.addEventListener('click', ...) — handler szuka closest
        elementu #ks-po-rozlicz-btn, jeśli znajdzie — wywołuje ksRozliczPOZbiorczo
      document.body.addEventListener('input', ...) — łapie zmiany w polu
        kwoty przelewu, aktualizuje prowizję
    Plus: flaga window.__ksPoDelegationInstalled zapobiega wielokrotnej
    rejestracji listenerów w przypadku wielokrotnego włączenia skryptu.
  - Diagnostyka: dodano console.log w newralgicznych miejscach, aktywowane przez
    window.ksPoDebug = true; (do uruchomienia w konsoli F12 przed klikaniem).
    Pozwala szybko zdiagnozować czy: skrypt się załadował, ile elementów PO
    znaleziono (TOTAL), czy checkboxy są przechwytywane przez delegation,
    jakie ID są wysyłane do AJAX, jaka odpowiedź serwera.
  - Pliki:
    * includes/shortcodes-v2-complete.php (sekcja JS w tabie rozliczenia →
      Należności od operatorów płatności)

* [DUŻA POPRAWA] Kompleksowa responsywność frontendu (mobile RWD)
  - Problem: tabele i layouty na ekranach mobilnych (telefon < 480px) wyglądały
    nieprofesjonalnie. Tabela "Zamówienia WooCommerce" w panelu klubu pokazywała
    pojedynczą datę w 3 liniach, kolumny rozjeżdżały się, filtry przyklejały się
    z dziwnymi gap-ami. Brak konsekwentnego wzorca dla tabel, modale wyglądały
    jak okienka desktop'owe.
  - Implementacja:
    1. Nowy plik js/responsive-tables.js (auto data-label):
       Każdy <td> w tabelach SOKS automatycznie dostaje atrybut
       data-label="<tekst nagłówka>" na podstawie odpowiadającego <th>.
       Dotyczy tabel z klasami: ks-table, widefat, ks-payments-table,
       ks-wplaty-table, ks-kasa-table, ks-kalendarz-table, ks-zgloszenia-table,
       oraz wszystkich tabel wewnątrz .ks-tab-content / .ks-klub-tab-content.
       Idempotentny — można uruchamiać wielokrotnie (po AJAX reload).
       MutationObserver auto-anotuje nowe tabele dodane do DOM.
       Eksport: window.ksAnnotateResponsiveTables() do ręcznego wywołania.
    2. css/responsive.css — rozbudowane o 4 nowe sekcje:
       a. TABELE JAKO KARTY (< 768px)
          Tabela.ks-table-rwd-ready zmienia się w listę kart:
          - <thead> ukryty
          - każdy wiersz to karta (border, shadow, padding, margin)
          - każda komórka to "Etykieta: Wartość" (flex space-between)
          - pierwsza komórka wyróżniona (gradient bg, większy font)
          - touch targets min 36px (mobile) / 44px (< 480px, iOS HIG)
       b. FILTRY (< 768px)
          Wszystkie .ks-search-filters-form i form[method="get"]
          stack-ują pionowo, inputs/selecty 100% width, label nad polem,
          button submit 100% width prominent.
       c. MODALE (< 768px)
          Modale (#ks-add-member-modal, #ks-manual-registration-modal,
          #ks-dsq-modal, .ks-modal-overlay) full-screen na mobile:
          - 100% width / 100vh height, brak border-radius
          - sticky header + sticky footer z przyciskami
          - scrollable content middle
          - grid-y w formularzach → 1 kolumna
          - przyciski 100% width, kolejność Submit/Cancel zoptymalizowana
       d. UNIWERSALNE GRIDY
          Łapie inline style="grid-template-columns: ..." dla wzorów
          repeat(2/3/4), 1fr 1fr, 2fr 1fr, 1fr 2fr, 3fr 1fr, 1fr 3fr,
          1fr 1fr 1fr auto — wszystkie redukują do 1 kolumny na mobile.
    3. Print mode: tabele wracają do standardowego rendering (display: revert).
  - Kompatybilność wsteczna:
    Tabele bez klasy ks-table-rwd-ready działają jak wcześniej (zwykły
    horizontal-scroll). JS dodaje klasę dynamicznie po dodaniu data-label.
  - Pliki:
    * js/responsive-tables.js (NEW, ~100 linii)
    * css/responsive.css (rozbudowane z 458 do ~770 linii)
    * soks.php (wp_enqueue_script dla nowego JS)

* [ROZSZERZENIE] Modal "Dodaj nowego członka" w panelu klubu — uzupełnione
  wszystkie pola z formularza rejestracji frontendowej + sekcja RODO
  - Problem: modal admin miał tylko 9 podstawowych pól (imię, nazwisko, email,
    telefon, PESEL, data ur., płeć, klub, status), podczas gdy frontendowy
    formularz rejestracji ma znacznie więcej (drugie imię, miejsce urodzenia,
    seria dowodu, adres zamieszkania, kod pocztowy, miasto). Admin tworzył
    konto z brakującymi danymi które potem trzeba było dopełniać ręcznie
    przez edycję profilu.
  - Modal admin teraz ma 4 sekcje:
    1. 📋 Dane podstawowe — imię, nazwisko, drugie imię, e-mail, telefon,
       PESEL, data urodzenia, miejsce urodzenia, płeć, seria i numer dowodu
    2. 🏠 Adres zamieszkania — adres (ulica i nr), kod pocztowy, miasto
    3. 🎯 Klub i status członka — klub, status konta
    4. 🔒 Zgody RODO — banner informacyjny + checkbox potwierdzenia
       pisemnej deklaracji członkowskiej
  - RODO (ważne): konto utworzone ręcznie przez admina NIE oznacza automatycznej
    akceptacji zgód RODO. Zgodę musi wyrazić sam członek. Modal wyjaśnia to
    adminowi i daje opcjonalny checkbox "Potwierdzam, że członek złożył
    pisemną deklarację członkowską". Po zaznaczeniu, w bazie zapisywane są
    metadane:
      ks_zgody_papierowa = '1'
      ks_zgody_papierowa_data = (timestamp utworzenia konta)
      ks_zgody_papierowa_admin_id = (ID admina)
      ks_zgody_papierowa_admin_nazwa = (display_name admina)
    + audyt RODO (ks_rodo_log_access — jeśli moduł aktywny).
    Cyfrowo członek powinien dodatkowo zatwierdzić zgody w panelu RODO
    przy pierwszym logowaniu.
  - Handler AJAX (ks_ajax_frontend_add_member) już wcześniej obsługiwał
    wszystkie te pola — wystarczyło tylko dodać HTML w modalu i obsługę
    checkboxa rodo_papierowa.
  - Pliki:
    * includes/menu-admin.php (HTML modal: nowe pola + sekcja RODO)
    * includes/shortcodes/ajax-handlers.php (zapis śladu audytowego zgody papierowej)

* [NAPRAWA] Duplikat emaila "Płatność potwierdzona" po rejestracji wpłaty w kasie
  - Problem: po oznaczeniu zamówienia WooCommerce jako opłaconego (np. przez
    panel kasy klubowej "Przyjmij zapłatę") klient otrzymywał DWIE jednakowe
    wiadomości email "Płatność potwierdzona — [nazwa zawodów]".
  - Przyczyna: funkcja ks_woocommerce_order_status_completed() była podpięta
    do DWÓCH hooków WooCommerce:
      add_action('woocommerce_order_status_completed', ...);
      add_action('woocommerce_payment_complete', ...);
    Przy oznaczaniu zamówienia jako opłaconego oba hooki strzelały po kolei
    (najpierw payment_complete przy płatności, potem order_status_completed
    gdy WC zmieniał status na "completed"). Funkcja nie miała guard'a, więc
    cały handler (update paid_at + wysyłka emaila) wykonywał się dwukrotnie.
    Dwa hooki muszą zostać — różne bramki płatności (BLIK, P24, kasa, ręczny
    completed) trigger-ują różne zdarzenia, więc usunięcie jednego pominęłoby
    część przypadków.
  - Rozwiązanie: idempotency guard z meta order:
      $already = $order->get_meta('_ks_paid_processed_at');
      if (!empty($already)) return; // już przetworzone, pomijamy
    Po pełnym pierwszym przetworzeniu (oba flow: nowy + fallback) zapisujemy
    timestamp w meta. Drugie wystrzelenie hooka znajduje flagę i wraca early.
  - Pliki: includes/shortcodes/payments.php

* [WYJAŚNIENIE/UTWARDZENIE] Grupowe wysyłanie emaila do członków — odbiorcy
  NIE widzą listy pozostałych adresów (już prawidłowo, dodano komentarz
  zabezpieczający)
  - Mechanizm: handler "Wyślij email do członków" (panel klubu → Lista członków)
    iteruje po wybranych członkach i WYSYŁA OSOBNY email dla każdego z osobna,
    przekazując pojedynczy adres do wp_mail(). Każdy odbiorca w polu "To:"
    widzi WYŁĄCZNIE swój adres, bez listy pozostałych. Spełnia wymóg RODO.
  - Zmiana: dodano duży komentarz blokujący w kodzie aby przyszłe optymalizacje
    nie zmieniły logiki na "batch wp_mail z tablicą" lub "BCC-batch" — co
    byłoby szybsze, ale ujawniłoby adresy. Komentarz wyjaśnia że jest to
    świadoma decyzja prywatnościowa, nie wąskie gardło wydajności.
  - Pliki: includes/menu-admin.php (handler ks_send_selected_email)

== 1.2.1 (2026-04-29) - released ==

Wersja release'owa zawierająca poprawki krytyczne wykryte w fazie testów
przed pełnym wdrożeniem SOKS w klubie pilotażowym (1 maja 2026).

Najważniejsze zmiany:
- Wniosek o przedłużenie licencji PZSS — pełna naprawa obu ścieżek (admin
  + self-service): slug template_type, krytyczne błędy SQL (data_rozpoczecia,
  miejsce_zawodow), per-runda granularność, PESEL kratki, nagłówki kolumn
- Wyszukiwarka "Wybierz członka" w Dokumentach/Kasie — token-based CONCAT_WS
- Lista członków: przycisk "A" zmienia status na "Staż początkującego",
  checkbox wyboru odbiorcy emaila po filtrowaniu (silent fail bug)
- Ręczna rejestracja zawodnika — usunięto silent fail Chrome dla zawodów
  z przeszłości (admin może retroaktywnie dopisać brakujące wyniki)
- Przygotowanie do wdrożenia: pole "Ostatni numer KP/KW z poprzedniego
  systemu" w Ustawieniach kasy (ciągłość numeracji KP/KW od dnia wdrożenia)

Szczegóły poniżej.

* [NOWE] Numer początkowy KP/KW przy wdrożeniu SOKS w nowym klubie
  - Kontekst: kluby przechodzące z poprzedniego programu (Excel, ręczna
    ewidencja, inny system księgowy) potrzebują zachować ciągłość numeracji
    KP/KW w bieżącym roku. Wdrożenie pilotażowe (1 maja 2026) — przykład wdrożeniowy.
    KP/KW to jedyne dokumenty z zerowaniem ROCZNYM, więc przy wdrożeniu
    w trakcie roku trzeba móc ustawić "ostatni wystawiony numer" — SOKS
    zacznie od kolejnego.
  - Stan przed naprawą:
    * Pola `numer_poczatkowy_kp` / `numer_poczatkowy_kw` istniały w UI
      ustawień kasy, ale były IGNOROWANE przez funkcje generujące numery
      (ks_dodaj_dokument_kp/kw w cash-register.php oraz duplikat tej
      logiki w ks_ajax_add_kp_document/kw_document w ajax-handlers.php).
    * Logika "ostatniego numeru" używała `ORDER BY id DESC LIMIT 1` +
      regex `(\d+)` — bug: dla numerów >= 1000 sortowanie alfabetyczne
      zwracało "999" jako wyższe od "1000" (porządek leksykalny). Plus
      filtr `numer_dokumentu LIKE '%2026%'` mógł łapać numery zawierające
      "2026" przez przypadek (np. KP/2026/2025).
    * Brak walidacji w UI — admin mógł ustawić numer NIŻSZY niż najwyższy
      już istniejący w bazie, co generowałoby duplikaty.
  - Implementacja:
    1. ks_dodaj_dokument_kp / ks_dodaj_dokument_kw (cash-register.php):
       - Pobierają numer_poczatkowy_kp/kw z ks_ustawienia_kasy
       - Używają MAX(CAST(SUBSTRING_INDEX(...) AS UNSIGNED)) — poprawne
         numeryczne sortowanie
       - Filtrują po YEAR(data_dokumentu) — precyzyjne
       - next_number = max(last_z_bazy + 1, numer_poczatkowy + 1)
    2. ks_ajax_add_kp_document / ks_ajax_add_kw_document (ajax-handlers.php):
       analogiczna naprawa (duplikat tej samej logiki)
    3. UI (menu-admin.php):
       - Etykiety zmienione z "Numer początkowy KP" na "Ostatni numer KP
         z poprzedniego systemu" — semantyka: wpisz X, SOKS zacznie od X+1
       - Dodany żółty banner z instrukcją kiedy używać tych pól (przy
         wdrożeniu z poprzedniego programu)
       - Walidacja przy zapisie: nie można wpisać numeru NIŻSZEGO niż
         najwyższy w bazie SOKS dla bieżącego roku
       - Komunikat zwrotny pokazuje aktualny najwyższy numer + ustawiony
         numer początkowy + jaki będzie następny dokument
    4. shortcodes-v2-complete.php już używał poprawnej logiki — bez zmian.
  - Pliki:
    * includes/shortcodes/cash-register.php (KP + KW — funkcje główne)
    * includes/shortcodes/ajax-handlers.php (KP + KW — duplikat logiki)
    * includes/menu-admin.php (UI Ustawienia kasy + walidacja)

* [NAPRAWA] Lista członków — checkbox wyboru odbiorcy emaila nie reagował
  na klik (po filtrowaniu nie można było zaznaczyć członka do wysłania mu
  wiadomości)
  - Przyczyna: sekwencja "iteruj po wszystkich + wywołaj add/remove +
    auto-render-resync" tworzyła pętlę destrukcyjną:
    1. User klika checkbox B → DOM ustawia checked=true
    2. Event change fire → ks_update_selected_count() iteruje WSZYSTKIE checkboxy
    3. Pierwsza iteracja przetwarza checkbox A (unchecked) → ksRemoveRecipient(A)
    4. ksRemoveRecipient → ksRenderRecipients() resetuje stan WSZYSTKICH
       checkboxów według ksRecipients (a tam B jeszcze nie ma)
    5. Checkbox B jest WYCZYSZCZONY przez sync — choć user właśnie go zaznaczył
    6. Iteracja dochodzi do B, ale cb.checked=false → ksRemoveRecipient (no-op)
    7. B nigdy nie zostaje dodany. Dla użytkownika: "klikam, nic się nie dzieje"
  - Rozwiązanie:
    1. Per-checkbox event handler operuje TYLKO na zmienionym checkboxie
       (e.target), nie iteruje po wszystkich → nie ma destrukcyjnego
       resetu z poziomu ksRenderRecipients.
    2. ks_toggle_all_members (zaznacz wszystkich) zmienione na 3-etapowe:
       (a) ustaw checked=true/false dla wszystkich checkboxów
       (b) zmodyfikuj ksRecipients hurtowo (bez auto-render)
       (c) wywołaj sessionStorage.setItem + ksRenderRecipients raz na końcu
    3. ks_update_selected_count zostawione jako shim (tylko render UI),
       bo jest wywoływane z innych miejsc (klasyfikacja, bulk deselect).
    4. bulkDeselectBtn handler dodatkowo czyści ksRecipients dla widocznych
       checkboxów (wcześniej tylko czyścił DOM, ale ksRenderRecipients zaraz
       potem przywracał checkboxy z sessionStorage).
  - Pliki:
    * includes/menu-admin.php

* [NAPRAWA] Ręczna rejestracja zawodnika — przycisk "✓ Zarejestruj zawodnika"
  nie reagował na kliknięcie (silent fail), zwłaszcza dla zawodów które już
  się odbyły (np. admin chciał retroaktywnie dopisać zawodnika gdy znaleziony
  został wynik nieuwzględniony w klasyfikacji)
  - Przyczyna 1: checkbox `zgoda_rodo_admin` (sekcja RODO dla gości) miał
    sztywny atrybut `required` w HTML. JS toggluje go dynamicznie tylko
    `onchange` radio buttonów "typ zawodnika". Gdy modal otwierał się
    z domyślnym "czlonek", sekcja RODO była `display:none`, ale checkbox
    DALEJ wymagany. Chrome próbował fokusowć ukryty element przy walidacji
    HTML5 → silent fail bez komunikatu.
  - Przyczyna 2: brak walidacji JS-side z czytelnymi komunikatami — gdy
    coś nie pasowało (brak konkurencji, brak wybranego członka), przycisk
    "klikał" ale nic się nie działo.
  - Rozwiązanie:
    1. Usunięto sztywny `required` z `zgoda_rodo_admin` w HTML.
       Atrybut jest dodawany dynamicznie przez JS tylko gdy wybrany "gosc".
    2. Dodano `ksToggleZawodnikType()` na DOMContentLoaded — synchronizuje
       widoczność sekcji RODO + atrybut required przy otwarciu modalu.
    3. Dodano explicit JS submit handler z czytelnymi `alert()`-ami
       walidującymi: zawody, runda, konkurencje, wybór członka, dane gościa,
       zgoda RODO. Działa identycznie dla zawodów z przyszłości i przeszłości.
  - Pliki:
    * includes/shortcodes/competitions.php

* [ZMIANA] Przycisk "A" (zmień rolę na Zawodnika) na liście członków
  - Wcześniej: kliknięcie "A" zmieniało rolę z Subskrybenta na Zawodnika
    ORAZ ustawiało status członka na "Aktywny".
  - Teraz: status członka jest ustawiany na "Staż początkującego" zamiast
    "Aktywny" — nowy zawodnik przechodzi przez okres wstępny (egzamin
    patent, pierwsze zawody) zanim zarząd ręcznie zmieni status na "Aktywny".
  - Zaktualizowano:
    * Tooltip przycisku: "Zmień rolę na Zawodnika (status: Staż początkującego)"
    * Komunikat potwierdzenia (JS confirm): informuje o nowym statusie
    * Komunikat sukcesu po zmianie: "Rola zmieniona na Zawodnik, status: Staż początkującego"
  - Plik: includes/menu-admin.php (case 'activate' w switch akcji)

* [NAPRAWA] Wyszukiwarka "Wybierz członka" w Dokumentach/Kasie zwracała
  błędne/przypadkowe wyniki przy wpisywaniu nazwiska
  - Problem: AJAX `ks_search_members` używał osobnych zapytań:
    1. `get_users` z 'search'=>'*X*' na user_login/user_email/display_name
    2. `get_users` z meta_query OR na first_name/last_name
    Wadą tego podejścia było:
    - Nie obsługiwało wieloczłonowego wyszukiwania (np. "Kowalski Jan"
      nie pasował do żadnego pojedynczego pola)
    - Pokazywało konta techniczne (admini, importerzy, boty bez imienia)
      które miały coś w user_login/email
    - Łączenie wyników z dwóch zapytań mogło dawać dziwną kolejność
  - Rozwiązanie: token-based search na CONCAT_WS (jak w `ks_search_members_live`):
    1. Każde słowo z zapytania jest osobnym warunkiem AND
    2. CONCAT_WS scala 6 pól (login, email, display_name, first_name,
       last_name, ks_numer_licencji) — token musi pasować w SCALONYM ciągu
    3. Wymagamy by first_name LUB last_name było niepuste — eliminuje
       konta techniczne
    4. Sortowanie po last_name (case-insensitive)
  - Pliki:
    * includes/shortcodes/ajax-handlers.php (funkcja ks_ajax_search_members)
  - Wpływ: poprawia wyszukiwarkę w trzech miejscach jednocześnie:
    Zarządzanie klubem → Dokumenty, Zarządzanie klubem → Kasa Klubowa,
    oraz drugie pole członka w panelu dokumentów (admin).

* [SYNCHRONIZACJA] Wniosek PZSS na "Moje konto → Generuj wniosek PDF" —
  zsynchronizowanie z wersją admina (Zarządzanie klubem → Dokumenty)
  - Po naprawach administracyjnej wersji wniosku PZSS, przycisk self-service
    "Generuj wniosek PDF" w zakładce Moje konto → Moje Zawody używał osobnego
    szablonu PHP (templates/przedluzenie-licencji.php) z TYMI SAMYMI bugami
    co wersja admina (przed naprawą). Tutaj zsynchronizowano wszystkie poprawki.
  - Zmiany w templates/przedluzenie-licencji.php:
    1. ranga_allowed: złagodzony — przepuszcza wszystko oprócz klubowych/towarzyskich
    2. format_ranga: PZSS jako wartość domyślna (zamiast pustej) gdy ranga
       pusta lub nieznana
    3. Grupowanie startów: nazwa+miejsce+runda_id|data — każda runda osobny wiersz
    4. Numer rundy w nazwie zawodów ("Puchar Klubu — Runda 2")
    5. PESEL kratki: <table> zamiast <span> (mPDF nie obsługuje inline-block z border)
    6. Nagłówki kolumn dyscyplin: poziome skróty "pist./kar./strz." zamiast
       writing-mode rotacji (mPDF nie obsługuje)
    7. Nagłówki tabeli: usunięto <em>, dodano font-weight: bold + szare tło
       (czytelność w PDF)
  - Pliki:
    * includes/features/pzss-forms/templates/przedluzenie-licencji.php

* [ZMIANA] Wniosek PZSS — każda runda zawodów to osobny wiersz tabeli
  - Wcześniej: zawody klubowe z 2 rundami (np. Puchar Klubu Runda 1
    i Runda 2) były grupowane w 1 wiersz wniosku, bo grupowanie odbywało
    się po (nazwa + miejsce). Użytkownik widział "1 wpis zamiast 2 startów".
  - Teraz: grupowanie po (nazwa + miejsce + runda_id LUB data_zawodow).
    Każda runda = osobny wiersz wniosku. Zgodne z logiką PZSS — każdy
    odrębny start to odrębny wpis na wniosku.
  - Dodatkowo: w kolumnie "Nazwa zawodów" wyświetlamy numer rundy,
    np. "Puchar Klubu — Runda 1" / "Puchar Klubu — Runda 2".
  - Implementacja:
    1. SQL klubowe/Practiscore: JOIN z `wp_ks_zawody_rundy` po `runda_id`
       — każda runda zwraca własną datę (`r.data`) i numer (`r.numer_rundy`).
       Praktyscore: `runda_id` z `wp_ks_zawody_wyniki_html.runda_id`.
    2. Starty obce: `runda_id`/`numer_rundy` jako NULL — różne daty
       w DB to już różne wiersze, więc nie potrzeba dodatkowego klucza.
    3. Klucz grupowania w build_starts_table_html: `nazwa|miejsce|r{runda_id}`
       albo `nazwa|miejsce|d{data_zawodow}` gdy brak runda_id.
    4. Klucz deduplikacji: dodano runda_id (różne rundy NIE są duplikatami).
  - Pliki:
    * includes/features/pzss-forms/starts-history.php (SQL JOINy + dedup key)
    * includes/features/pzss-forms/dokumenty-hook.php (grouping key + nazwa rundy)

* [NAPRAWA] Wniosek PZSS — KRYTYCZNE: błędy SQL w collect_user_starts
  (tabela startów dalej była pusta mimo wgrania poprzednich poprawek)
  - Bug 1: zapytanie używało nieistniejącej kolumny `z.data_rozpoczecia`
    w tabeli wp_ks_zawody. Tabela ma `daty_rund` (JSON) i `rok`, ale nie
    ma `data_rozpoczecia`. Błąd SQL → zero rezultatów klubowych/Practiscore.
    Fix: data pobierana przez sub-select z `wp_ks_zawody_rundy.data`
    (MIN po zawody_id), z fallbackiem na `CONCAT(z.rok, '-01-01')`.
    ORDER BY zmienione z `z.data_rozpoczecia` na `z.rok, z.nazwa`.
  - Bug 2: zapytanie startów obcych używało nieistniejącej kolumny
    `miejsce_zawodow` w tabeli wp_ks_zawody_starty_obce. Tabela ma kolumnę
    `miejsce` (miejscowość) — `zdobyte_miejsce` to numer miejsca w klasyfikacji.
    Błąd SQL → zero startów obcych nawet jak były zatwierdzone.
    Fix: `miejsce AS miejsce_zawodow` (alias zachowuje nazwę spodziewaną
    przez resztę kodu).
  - Pliki:
    * includes/features/pzss-forms/starts-history.php

* [NAPRAWA] Wniosek PZSS — uzupełnienia po pierwszej iteracji naprawy
  - Problem 1: tabela startów dalej była pusta mimo że zawodnik miał
    starty (4 w pistolecie). Powód: filtr `is_in_pzss_kalendarz` wymagał
    aby `ranga` zawodów zawierała "PZSS"/"WZSS"/"mistrz"/"puchar"/"krajow"
    /"wojewódzk" — a w SOKS pole ranga jest opcjonalne i często niewypełnione.
    Rozwiązanie: filtr przepuszcza WSZYSTKO oprócz jawnie oznaczonych
    klubowych/towarzyskich. Pusta ranga / nieznane oznaczenie = uznajemy
    za zawody PZSS (zgodnie z oczekiwaniem użytkownika "mam starty,
    powinny być na wniosku"). Admin nadal może wykluczyć zawody oznaczając
    je jako "klubowe" lub "towarzyskie".
  - Problem 2: nagłówki kolumn tabeli startów dalej były niewidoczne w PDF.
    Powód: `<em>` italic + `font-style: italic` z domyślnym `font-weight: normal`
    przy `font-size: 8pt` mogły być źle renderowane przez mPDF.
    Rozwiązanie: usunięto `<em>`, dodano `font-weight: bold`, jasnoszare
    tło (`#f5f5f5`) i większy padding — nagłówki teraz wyróżniają się
    wizualnie i są na pewno widoczne.
  - Problem 3: PESEL pokazywany jako ciągły tekst "<numer PESEL — usuniety z opisu 04.09.2026>" zamiast
    11 osobnych kratek. Powód: oryginał używał `<span>` z
    `display: inline-block; border: 1px solid; margin-right: -1px;`
    — mPDF nie obsługuje dobrze `inline-block` z borders ani ujemnych marginesów.
    Rozwiązanie: zamieniono na `<table class="pesel-boxes">` z 11 komórek
    `<td>` — mPDF świetnie renderuje tabele.
  - Dodano diagnostyczny `error_log` do build_starts_table_html — pokazuje
    ile rekordów zwróciło `collect_user_starts`, ile po filtrze, oraz
    próbkę wartości `ranga` jeśli wszystkie zostały odfiltrowane.
  - Pliki:
    * includes/features/pzss-forms/dokumenty-hook.php (PESEL kratki <td>,
      format_ranga z domyślnym PZSS, error_log)
    * includes/features/pzss-forms/starts-history.php (is_in_pzss_kalendarz
      przepuszczalny dla pustej/nieznanej rangi)
    * dokumenty/szablones/Wniosek_Przedluzenie_Licencji_PZSS/Wniosek_Przedluzenie_Licencji_PZSS.html
      (nagłówki kolumn bez <em>, table dla PESEL kratek)

* [NAPRAWA] Wniosek PZSS w "Zarządzanie klubem → Dokumenty" — brak podstawiania
  danych zawodów + brak nazw kolumn tabeli startów
  - Problem: ten sam szablon w "Moje Zawody → Generuj wniosek" (Moje konto)
    działał poprawnie, ale przy generowaniu z panelu admina (Zarządzanie klubem
    → Dokumenty) tabela startów była pusta (tylko liczby porządkowe 1-8),
    nagłówki kolumn z dyscyplinami niewidoczne, dane zawodów niepodstawiane.
  - Przyczyna 1: hook `ks_dokument_prepare_data` (dokumenty-hook.php) sprawdzał
    template_type sztywno (`wniosek-przedluzenie-licencji-pzss` lub
    `wniosek_przedluzenie_licencji_pzss`). Jeśli sanitize_title() zwracał
    inny wariant slug-a (np. z polskimi znakami zamienionymi inaczej), hook
    zwracał wcześnie i NIE wstrzykiwał placeholderów {{tabela_startow_pzss}},
    {{checkbox_*}}, {{pesel_kratki}} itp.
  - Przyczyna 2: nagłówki kolumn dyscyplin (pistolet/karabin/strzelba) używały
    CSS `writing-mode: vertical-rl` + `transform: rotate(180deg)` — mPDF nie
    obsługuje tych właściwości, więc nagłówki były niewidoczne w PDF.
  - Rozwiązanie:
    1. Hook teraz używa elastycznego dopasowania (substring-check):
       template_type musi zawierać "wniosek" + "przedluzenie" + "pzss"
       (po normalizacji underscore/dash i polskich znaków). Funkcja
       `ks_pzss_forms_is_renewal_template()`.
    2. Dodano error_log diagnostyczny — w razie problemów widać w logu
       jaki template_type został przekazany i czy hook firuje.
    3. Nagłówki kolumn dyscyplin: zamieniono pionową rotację na poziome
       skróty ("pist." / "kar." / "strz.") — czytelne w PDF mPDF.
  - Pliki:
    * includes/features/pzss-forms/dokumenty-hook.php
    * dokumenty/szablones/Wniosek_Przedluzenie_Licencji_PZSS/Wniosek_Przedluzenie_Licencji_PZSS.html

* [NOWE] Tryb admin-view: edycja konta innego użytkownika + filtrowanie zgód
  RODO dla gości
  - Część A: filtrowanie zgód RODO dla gości
    Goście (ks_typ_zawodnika='gosc' lub tylko rola subscriber) NIE są członkami
    klubu — nie dotyczą ich zgody klubowe (statut, regulaminy strzelań/PZSS,
    oświadczenie karne, polityka prywatności klubu, kandydatura). W zakładce
    RODO pokazujemy tylko zgody związane z rejestracją na zawody:
      ✅ zgoda_rodo (przetwarzanie danych — wymagane do rejestracji)
      ✅ zgoda_komunikacja (e-mail/SMS — dobrowolne)
    Pełna lista zgód widocznych dla gości: filter `ks_rodo_guest_consents`
    Niebieski banner informacyjny "Konto Gość — widoczne są tylko zgody…"
    pojawia się nad listą zgód.

  - Część B: tryb admin-view (edytuj jako użytkownik)
    Admin (manage_options/manage_ks/edit_users) może otworzyć panel
    "Moje konto" dowolnego użytkownika (np. gościa) używając parametru
    URL ?edit_user_id=X. W panelu RODO:
      * Żółty banner "⚠️ Tryb administratora — przeglądasz konto użytkownika
        [nazwa] (ID X)" — admin wie że nie patrzy na własne konto
      * Akcje (wyrażenie/wycofanie zgody) działają na koncie tego użytkownika
        — AJAX przekazuje target_user_id, log audytu zapisuje "(przez admina ID=Y)"
      * Filtrowanie zgód odbywa się dla DOCELOWEGO usera (jeśli to gość,
        admin też widzi tylko 2 zgody)

  - Część C: nowy przycisk "Otwórz panel gościa" w liście gości
    Plik: includes/shortcodes/guests.php — w tabeli akcji obok "Edytuj"
    i "Usuń" pojawił się fioletowy przycisk 👁 (ikona) z tooltipem
    "Otwórz panel 'Moje konto' tego gościa (jako admin)". Klik otwiera
    /account/?edit_user_id=X w nowej karcie.
    Helper: ks_guests_find_account_page_url() — cache 12h.

  - Pliki:
    * includes/rodo-compliance.php (filter zgód, banner admin-view,
      AJAX z target_user_id, JS przekazuje target_user_id)
    * includes/shortcodes/guests.php (przycisk "Otwórz panel gościa"
      + helper ks_guests_find_account_page_url)

* [NAPRAWA] Ręczna rejestracja gościa w zawodach — komunikat o zgodzie RODO
  bez możliwości jej wyrażenia
  - Problem: po dodaniu wymaganej zgody RODO w handler POST rejestracji gościa
    (poprzednia zmiana), modal "Ręczna rejestracja zawodnika" w panelu admina
    nie miał checkboxów zgód → admin nie mógł zarejestrować gościa, dostawał
    komunikat "Aby się zarejestrować, musisz wyrazić zgodę…".
  - Rozwiązanie:
    1. Dodano sekcję "🔒 Oświadczenie zgód (RODO)" w modalu ręcznej rejestracji
       (competitions.php ~linia 2200), widoczna TYLKO gdy wybrano typ "Gość"
       (toggle przez ksToggleZawodnikType()).
    2. Dwa checkboxy:
         - zgoda_rodo_admin (WYMAGANY) — admin oświadcza że gość wyraził zgodę
           w sposób tradycyjny (telefon/papier/osobiście)
         - zgoda_komunikacja_admin (DOBROWOLNY) — analogicznie dla komunikacji
    3. Handler POST rozpoznaje `_POST['ks_manual_registration']` i sprawdza
       odpowiednie pola: dla admina `zgoda_rodo_admin`, dla frontendu `zgoda_rodo_gosc`.
    4. Komunikat błędu różny zależnie od scenariusza (admin vs gość frontend).
  - Pliki: includes/shortcodes/competitions.php

* [NOWE] Uproszczony panel "Moje konto" dla gości spoza klubu
  - Cel: goście (subscriber + ks_typ_zawodnika='gosc') mają widzieć tylko
    funkcje przydatne dla nich — bez sekcji typowo klubowych.
  - Wykrycie gościa (account.php) — `$is_gosc`:
      $typ_zawodnika === 'gosc' OR (rola = subscriber bez innych ról klubowych)
  - Co WIDZI gość:
      ✅ 📋 Dane osobowe (może edytować)
      ✅ 🏆 Moje Zawody (UPROSZCZONE — tylko Moja historia + Formularz rejestracji)
      ✅ 📊 Rankingi
      ✅ 🔒 RODO (zgody, eksport danych)
  - Czego NIE widzi gość:
      ❌ 💰 Wpłaty (składki klubowe — irrelevant)
      ❌ Zakładki dynamiczne modułów (Komunikator, Moje dokumenty, etc.)
  - W zakładce "Moje Zawody" UKRYTE dla gości:
      ❌ 🎯 Starty wymagane do utrzymania licencji w kolejnym roku
      ❌ 🏆 Twoje osiągnięcia + 🏅 Twoje Miejsca i Puchary (layout 2-kol)
      ❌ ➕ Dodaj starty w innych klubach
  - W zakładce "Moje Zawody" ZACHOWANE dla gości:
      ✅ Formularz rejestracji do nowych zawodów
      ✅ Lista moich rejestracji (do potwierdzenia/anulowania)
      ✅ 🏆 Moja Historia Zawodów (wyniki z poprzednich zawodów)
  - Pliki: includes/shortcodes/account.php
    - $is_gosc detection na początku ks_render_zawody_tab()
    - $is_gosc_for_tabs detection w renderingu nawigacji zakładek
    - 3 sekcje opakowane w if (!$is_gosc): ... endif;

* [NOWE] Modal "consent prompt" zachęcający do wyrażenia zgody na komunikację
  - Cel: dla zalogowanych użytkowników którzy nie mają ustawionej zgody
    `ks_checkbox_zgoda_komunikacja` ('tak') wyświetlany jest modal zachęcający
    do jej wyrażenia. Modal pojawia się max 8 razy (konfigurowalne przez
    filter `ks_consent_prompt_max_shows`), potem ukrywany na stałe.
  - Treść modalu:
    * Nagłówek z gradientem + emoji 📬 "Bądź zawsze na bieżąco z klubem!"
    * Spersonalizowane pozdrowienie (nazwa klubu z wp_options.ks_dane_klubu)
    * Lista 4 konkretnych korzyści (zawody, składki, wyniki, nagłe zmiany)
    * Notka "Zgoda jest dobrowolna — możesz wycofać w zakładce RODO"
    * 3 akcje:
        - Zielony "✓ Tak, wyrażam zgodę" — AJAX zapisuje zgodę + zamyka
        - Szary "Nie teraz" — tylko zamyka (licznik się inkrementował przy renderze)
        - Dyskretny link "Nie pokazuj więcej" — ustawia licznik na 99 (trwałe ukrycie)
    * Licznik pozostałych wyświetleń ("To okno pojawi się jeszcze N razy")
  - Warunki wyświetlania (wszystkie muszą być spełnione):
    * is_user_logged_in()
    * NIE w /wp-admin/, AJAX, REST, XMLRPC
    * NIE na stronie logowania/rejestracji
    * NIE na zakładce RODO (nie zakłócamy kontekstu gdzie user i tak może to ustawić)
    * Zgoda nie jest 'tak' (czyli niewyrażona LUB wycofana)
    * Licznik wyświetleń < 8
  - Mechanika:
    * Hook `wp_footer` (po całej zawartości strony) — modal pojawia się jako
      overlay nad stroną z animacją fade-in + slide-up
    * Licznik `ks_zgoda_komunikacja_prompt_count` inkrementowany przy każdym
      render (żeby nawet zamknięcie przez ESC było wliczone do limitu)
    * "Nie pokazuj więcej" → licznik = 99 + timestamp w `_dismissed_at`
    * AJAX endpoints: `ks_consent_prompt_grant`, `ks_consent_prompt_dismiss`
    * Po wyrażeniu zgody: audit log (`ks_rodo_audit_log` jeśli dostępne)
  - Dostępność (a11y):
    * `role="dialog"` + `aria-modal="true"` + `aria-labelledby`
    * ESC zamyka modal
    * Przyciski mają czytelne opisy
  - Plik: includes/features/consent-prompt.php (NOWY, ~290 linii z CSS+JS inline)
  - Podłączony w soks.php ok. linii 230

* [UX] Dropdown "Status" zamieniony na pole tekstowe (read-only)
  - Uzasadnienie: skoro status DNS/DNF/DSQ ustawia się teraz przez dedykowane
    przyciski (z modalem, polem "Powód" i logiem audytu), dropdown w kolumnie
    Status jest redundantny. Mógłby też powodować konflikty — np. user zmienił
    dropdown na DSQ ale bez modal/powodu, submituje formularz → zapis bez
    audytu + bez zerowania wyników (niezgodny z logiką przycisków).
  - Rozwiązanie:
    * Kolumna "Status" zachowana, ale zamiast <select> pokazuje badge
      w kolorze statusu (czerwony DSQ, żółty DNF, szary DNS) lub "—" gdy brak.
    * Hidden input <input type="hidden" name="status[X]" value="<current>">
      zachowuje bieżącą wartość przy submit całego formularza "Zapisz wyniki"
      — inaczej zapis by wyzerował status z bazy.
    * Jedyna droga ustawienia statusu: przyciski w kolumnie "Akcje DSQ"
      (które przechodzą przez modal + AJAX + log).
    * Jedyna droga cofnięcia: przycisk "↺ Cofnij [status]" w tej samej kolumnie.
    * JS blokujący pola dla DNS/DSQ czyta teraz wartość z hidden inputa
      (.ks-reczne-status-hidden), nie z dropdowna.
  - Plik: includes/shortcodes/results.php

* [NOWE] Cofnięcie statusu DNS/DNF/DSQ — przycisk "↺ Cofnij [status]"
  - Gdy zawodnik ma aktywny status (DNS/DNF/DSQ), w kolumnie "Akcje DSQ"
    pojawia się:
      1. Badge informacyjny "Status: DNS/DNF/DSQ" (w kolorze typu)
      2. Zielony przycisk "↺ Cofnij [status]"
  - Modal dostosowuje się do cofania:
      - Tytuł: "↺ Cofnięcie statusu DNS/DNF/DSQ"
      - Opis różny dla DNF vs DSQ/DNS:
          DNF → "wynik częściowy zostanie zachowany" (zielony info)
          DSQ/DNS → "wyniki były wyzerowane — trzeba je wpisać ręcznie" (czerwony info)
      - Pole "Powód" UKRYTE (cofnięcie nie wymaga uzasadnienia)
      - Przycisk potwierdzenia zielony ("↺ Cofnij status")
  - Po cofnięciu:
      DNF → status=NULL, stage_pts + kolumny zachowane (już są w bazie)
      DSQ/DNS → status=NULL, stage_pts=0 (było wyzerowane) — admin wpisuje
                wyniki w formularzu ręcznym i klika "Zapisz wyniki"
  - AJAX ks_dsq_calosc_zawody z mode='undo' obsługuje wszystkie 3 statusy
    (status IN ('DSQ','DNS','DNF') → NULL)
  - Typowy scenariusz pomyłki:
      1. Admin klika "🚫 DSQ konkurencja" dla zawodnika przez pomyłkę
      2. Strona się odświeża — pojawia się badge "Status: DSQ" + przycisk cofnięcia
      3. Admin klika "↺ Cofnij DSQ" → modal z ostrzeżeniem
      4. Zatwierdza → status=NULL, strona reload
      5. Badge znika, przyciski statusu wracają do normalnej postaci
      6. Admin wpisuje wyniki w polach Seria 1/10x/Seria 2/10x ręcznie
      7. Klika "Zapisz wyniki" → wyniki zapisane
  - Plik: includes/shortcodes/results.php (dodany badge statusu, przycisk
    Cofnij, rozszerzona logika JS modala dla mode='undo')

* [NOWE] Przyciski DNS i DNF (obok DSQ) w formularzu "Wpisz wyniki ręcznie"
  - Rozszerzenie: przyciski akcji zamiast tylko jednego "DSQ konkurencja"
    pokazują teraz 3 przyciski per wiersz:
      ⊘ DNS konkurencja (szary)  — nie wystartował (zeruje wyniki)
      ◐ DNF konkurencja (żółty)  — nie ukończył (wynik częściowy zachowany)
      🚫 DSQ konkurencja (pomarańczowy) — zdyskwalifikowany (zeruje wyniki)
      🚫 DSQ Runda (IPSC) (czerwony, gdy konkurencja IPSC)
  - AJAX ks_dsq_calosc_zawody rozszerzony o parametr status_type (DSQ/DNS/DNF):
    * DSQ/DNS → UPDATE stage_pts=0, stage_pct=NULL, kolumny_wynikow=NULL,
      miejsce=0, status=<typ>  (brak klasyfikacji, wyniki wyzerowane)
    * DNF → UPDATE status='DNF', miejsce=0 (ZACHOWUJE stage_pts + kolumny_wynikow
      — wynik częściowy zgodnie z ISSF 6.14.4)
  - Modal dostosowuje się do typu:
    * Tytuł: "DSQ/DNS/DNF — [nazwa akcji] (konkurencja/runda IPSC)"
    * Kolor przycisku zatwierdzenia: czerwony (DSQ) / szary (DNS) / żółty (DNF)
    * Opis w żółtym bannerze wskazuje czy wyniki będą zerowane czy zachowane
  - Log audytu (ks_dsq_log_<zawody_id>) zawiera teraz pole status_type
  - Cofnięcie (mode='undo') obsługuje wszystkie 3 statusy:
    status IN ('DSQ','DNS','DNF') → NULL
  - Dropdown Status w tej samej tabeli formularza (DNS/DNF/DSQ) działa
    RÓWNOLEGLE — zapisuje status tylko przy submit całego formularza.
    Przyciski dają natychmiastową akcję (AJAX) + pole "Powód" + wpis w log.
    Po reload obie ścieżki są spójne (dropdown pokazuje status z bazy).
  - Pliki:
    * includes/shortcodes/results.php (3 przyciski per wiersz + modal z
      dynamicznymi etykietami/kolorami)
    * includes/shortcodes/ajax-handlers.php (parametr status_type,
      warunkowa logika zerowania, log audytu z typem, komunikat sukcesu)

* [UX] Przyciski DSQ w formularzu "Wpisz wyniki ręcznie" (obok każdego zawodnika)
  - Kontekst (iteracja 3): przyciski DSQ początkowo były w widoku Rejestracji,
    potem przeniesione do osobnej sekcji "Zarządzanie DSQ" w widoku Wyników.
    Użytkownik wskazał że najlepiej umieścić je BEZPOŚREDNIO w formularzu
    "Wpisz wyniki ręcznie" — obok każdego wiersza zawodnika.
  - Rozwiązanie końcowe:
    1. USUNIĘTA sekcja "Zarządzanie DSQ" (osobna tabela przed statystykami).
    2. USUNIĘTE przyciski DSQ z widoku Rejestracji (competitions.php).
    3. DODANA kolumna "Akcje DSQ" w tabeli formularza "Wpisz wyniki ręcznie"
       (results.php ~linia 1398). Każdy wiersz zawodnika ma 2 przyciski:
         - 🚫 DSQ konkurencja (pomarańczowy) — scope tej konkurencji,
           w tej rundzie, dla tego zawodnika
         - 🚫 DSQ Runda (IPSC) (czerwony) — TYLKO gdy aktualna konkurencja
           jest IPSC; scope wszystkich konkurencji IPSC w tej rundzie
    4. Modal DSQ + JS przeniesiony do formularza ręcznego (results.php).
  - Logika warunku IPSC: sprawdzany $reczne_konk->rodzaj_strzelectwa
    (JSON zawierające "IPSC"); dla konkurencji PZSS przycisk DSQ Runda
    się nie wyświetla.
  - Pliki:
    * includes/shortcodes/results.php (kolumna Akcje DSQ + przyciski +
      modal + JS; +~220 linii netto)
    * includes/shortcodes/competitions.php (brak zmian w tej iteracji —
      przyciski już usunięte wcześniej)

* [RODO] Formularz rejestracji na zawody [rejestracja_zawody] — zgody gościa
  - Problem: dotychczas formularz rejestracji gości spoza klubu miał tylko
    jeden checkbox (akceptacja regulaminu). Gość wpisywał: imię, nazwisko,
    e-mail, telefon, datę urodzenia, płeć, klub, numer licencji PZSS
    — wszystkie dane OSOBOWE w rozumieniu RODO — BEZ wyraźnej zgody
    na przetwarzanie. Naruszenie art. 6 ust. 1 lit. a RODO.
  - Naprawa:
    1. Dodano WYMAGANY checkbox "Zgoda RODO" w formularzu (czerwona ramka):
       treść zgody zgodna z art. 6 ust. 1 lit. a + art. 13 RODO
       (podstawa prawna, cele przetwarzania, prawa użytkownika).
       Bez zaznaczenia formularz nie przechodzi walidacji PHP.
    2. Dodano DOBROWOLNY checkbox "Zgoda na komunikację" (niebieska ramka):
       treść zgodna z art. 10 UŚUDE + art. 172 Prawa telekomunikacyjnego
       (e-mail + SMS, potwierdzenie rejestracji, harmonogram, wyniki).
    3. Zapis zgód do user_meta (spójny z resztą systemu SOKS):
       - ks_checkbox_zgoda_rodo = 'tak' + _data (timestamp)
       - ks_checkbox_zgoda_komunikacja = 'tak' + _data (jeśli zaznaczona)
       Zapis wykonywany zarówno przy UTWORZENIU nowego gościa (wp_create_user)
       jak i przy AKTUALIZACJI istniejącego (user wcześniej wyrażał zgodę).
    4. Audit log (jeśli ks_rodo_audit_log dostępne) — każde wyrażenie zgody
       zapisywane z ID zawodów + informacją które zgody wyrażono.
  - Plik: includes/shortcodes/competitions.php
    (HTML formularza ~linia 4890, walidacja PHP + zapis zgód ~linia 242-320)

* [NOWE] Moduł SMS Gateway dla Androida — provider `android` (częściowe)
  - Nowy plik: includes/features/sms-gateway/providers/android.php
  - Klasa KS_SMS_Provider_Android dziedzicząca po KS_SMS_Provider (bazowa)
  - Integracja z aplikacją Android SMS Gateway (rekomendowana:
    capcom6/android-sms-gateway — Apache 2.0, open source).
  - Funkcje:
    * send($phone, $message, $opts) — HTTP POST na lokalny API telefonu
      z basic auth, numer w formacie +48XXXXXXXXX, JSON payload
    * test_connection() — sprawdza dostępność bramki (GET /health → /)
    * Szczegółowe komunikaty błędów (401 = auth, 404 = zły URL, timeout)
  - Konfiguracja: URL bramki, login, hasło, timeout, sslverify
  - Uwaga: plik providera utworzony, podłączenie w gateway.php + UI w
    admin.php będzie w kolejnym kroku.
  - Plik: includes/features/sms-gateway/providers/android.php (NOWY, ~170 linii)

* [RODO] Dodana zgoda na komunikację e-mail/SMS (dobrowolna)
  - Kontekst: do SOKS 1.2.0 lista zgód RODO zawierała tylko 4 zgody WYMAGANE
    do członkostwa (statut, regulaminy, oświadczenie karne, polityka
    prywatności). Brakowało DOBROWOLNEJ zgody na komunikację elektroniczną.
  - Wymagane przez:
      * art. 6 ust. 1 lit. a RODO (zgoda jako podstawa przetwarzania)
      * art. 7 RODO (warunki zgody — dobrowolna, świadoma, konkretna,
        możliwa do wycofania)
      * art. 10 ust. 1 UŚUDE (zakaz niezamówionej informacji handlowej)
      * art. 172 Prawa telekomunikacyjnego (zgoda na marketing SMS)
  - Treść zgody (profesjonalna, bez jednoznacznej aprobaty blokującej):
    "Wyrażam zgodę na otrzymywanie od [nazwa klubu] informacji na podany
    adres e-mail oraz numer telefonu (SMS) w celu informowania o
    działalności klubu, organizowanych zawodach, szkoleniach, terminach
    składek członkowskich oraz innych sprawach organizacyjnych. Zgoda
    jest dobrowolna i może zostać wycofana w dowolnym momencie w zakładce
    RODO, bez wpływu na zgodność z prawem przetwarzania dokonanego przed
    jej wycofaniem (art. 7 ust. 3 RODO)."
  - Wymagane=0 (dobrowolna) — niewyrażenie zgody NIE blokuje rejestracji
    ani członkostwa. User może wyrazić/wycofać w każdym momencie w
    zakładce RODO panelu Moje konto.
  - Nazwa klubu w treści dynamicznie z wp_options.ks_dane_klubu
    (nazwa_skrocona lub nazwa_pelna), więc treść sama się dostosuje
    dla każdego klubu SOKS (nie tylko klub pilotażowy).
  - Pliki:
    * includes/admin-config/registration-config.php (nowy domyślny
      checkbox zgoda_komunikacja dla nowych instalacji)
    * includes/migrations/add-zgoda-komunikacja.php (NOWY — migracja
      dla istniejących instalacji z już zapisaną opcją
      ks_rejestracja_checkboxy; idempotentna, flag
      ks_zgoda_komunikacja_migrated_v1)
    * soks.php (podłączenie migracji)

* [UX] Legenda DNS/DNF/DSQ — zawsze pod każdą tabelą klasyfikacyjną w PDF
  - Poprzednio: legenda wyświetlała się tylko gdy w konkurencji byli zawodnicy
    z oznaczeniem DNS/DNF/DSQ (warunek $has_status_entries).
  - Po zmianie: legenda jest BEZWARUNKOWO pod każdą tabelą — czytający
    komunikat klasyfikacyjny zawsze zna znaczenie tych kodów (wymóg formalny
    dokumentu zgodny z przepisami ISSF/PZSS/IPSC).
  - Treść legendy uproszczona zgodnie z zamówieniem:
      DNS — Did Not Start — zawodnik nie wystartował
      DNF — Did Not Finish — nie ukończył
      DSQ — Disqualified — zdyskwalifikowany
  - Usunięto szczegóły implementacyjne (np. "ISSF 6.14.4: wynik częściowy")
    które były zbyt techniczne dla czytelnika komunikatu.
  - Plik: includes/results-pdf-generator.php (build_wyniki_html ~linia 640).

* [DIAGNOSTYKA] Komunikat Klasyfikacyjny PDF — diagnostyka gdy brakuje tabel
  - Kontekst: użytkownik zgłosił że wygenerowany PDF "Komunikat Klasyfikacyjny"
    nie zawiera tabel klasyfikacyjnych w konkurencjach. Obecny kod w
    results-pdf-generator.php miał `if (empty($wyniki)) continue;` w pętli
    per-plik — co powodowało że w razie braku wyników szczegółowych strona
    konkurencji była BEZ informacji (tylko okładka + funkcyjni + podsumowanie),
    bez żadnego komunikatu dlaczego.
  - Naprawa diagnostyczna (bez blokujących zmian w logice):
    1. Widok frontend (results.php) — nad przyciskiem "Generuj PDF" pokazywany
       status:
         * Zielony banner — jeśli wszystkie konkurencje mają zapisane wyniki
           (liczba konkurencji + łączna liczba wyników)
         * Czerwony banner — jeśli niektóre konkurencje nie mają wyników
           szczegółowych (wymienia konkretne konkurencje)
         * Żółty banner — jeśli nic nie zaimportowano (z instrukcją co zrobić)
    2. Generator PDF (results-pdf-generator.php) — zamiast `continue`:
         * Gdy brak wpisów w ks_zawody_wyniki_html dla rundy → strona PDF
           z dużym komunikatem "Brak zaimportowanych wyników" + instrukcja
         * Gdy plik_html istnieje ale brak szczegółów → strona z tytułem
           konkurencji + komunikat "Brak wyników szczegółowych" + html_id
           dla diagnostyki
    Dzięki temu PDF zawsze zawiera informację dlaczego tabele nie są pełne —
    zamiast pustej strony administrator dostaje wyraźną wskazówkę.
  - Pliki:
    * includes/results-pdf-generator.php (fallback per-plik i per-runda)
    * includes/shortcodes/results.php (banner diagnostyczny w widoku PDF)

* [UX] Tabele wyników — lepszy kontrast + sticky nagłówki przy przewijaniu
  - Zwiększony kontrast linii siatki tabel w widoku wyników
    (Zarządzanie zawodami -> Wyniki -> Wpisz wyniki ręcznie):
    * border-bottom między wierszami: #eee -> #9ca3af (wyraźna linia)
    * border-right między kolumnami (nowy): #d1d5db
    * zewnętrzna ramka całej tabeli: 1px solid #6b7280
    * nagłówek kolumn: ciemniejszy dolny pas 2px #4b5563, tekst #1f2937
    * naprzemienne tło wierszy (zebra): #fafbfc
  - Sticky nagłówek — przy przewijaniu długiej listy zawodników nagłówki
    kolumn (Lp / Nazwisko / Klub / Status / Seria 1 / 10x / ... / SUMA / 10X)
    zostają przyklejone do góry okna. CSS: thead + th mają
    position:sticky, top:0, z-index:10. Offset 32px/46px gdy jest WP adminbar.
    Box-shadow pod nagłówkiem dla wizualnej separacji od scrollowanej treści.
  - Wrapper tabeli zmieniony na overflow-y:visible (z domyślnego), bo
    overflow-x:auto implicite ustawiało też overflow-y:auto co blokowało
    działanie position:sticky.
  - Plik: includes/shortcodes/results.php (CSS w okolicy linii 624-680
    + wrapper w linii ~1353).

* [POPRAWKA] Zakresy przycisków DSQ — użyteczniejszy podział + filtr IPSC
  - Poprzednio: [DSQ runda] (pomarańczowy) + [DSQ impreza] (czerwony)
  - Po zmianie (zgodnie z zamówieniem):
    * [🚫 DSQ konkurencja] pomarańczowy — tylko ten JEDEN wpis wyniku
      (runda_id + konkurencja_id + user_id). Dla sytuacji gdy zawodnik ma
      być zdyskwalifikowany z jednej konkurencji ale zaliczony w innych.
    * [🚫 DSQ Runda (IPSC)] czerwony — cała runda ale TYLKO konkurencje IPSC.
      W rundach mieszanych (IPSC + PZSS) konkurencje PZSS pozostają bez zmian.
      Zgodnie z IPSC rules: DQ z matcha (=rundy) nie wpływa na inne dyscypliny
      rozgrywane tego samego dnia.
  - Filtr IPSC w AJAX:
    * JOIN z wp_ks_zawody_konkurencje, warunek rodzaj_strzelectwa LIKE '%IPSC%'
    * Gdy brak konkurencji IPSC w rundzie → wyraźny komunikat błędu:
      "Brak konkurencji IPSC w tej rundzie... DSQ Runda działa tylko na
      konkurencje IPSC — konkurencje PZSS w tej rundzie pozostaną bez zmian."
  - Zakres "cała impreza" pozostał w AJAX jako fallback (bez dedykowanego
    przycisku w UI).
  - Rozszerzenie AJAX ks_ajax_dsq_calosc_zawody:
    * Nowy parametr konkurencja_id (int, opcjonalny)
    * Logika zakresu: (runda + konk) > (runda IPSC-only) > (impreza)
    * Log audytu zawiera teraz konkurencja_id
    * Komunikat zwraca scope: 'konkurencja' | 'runda' | 'impreza'
  - Modal UI:
    * Tytuł zmieniany per scope ("DSQ — z konkurencji" / "DSQ — z rundy (IPSC)")
    * Wyraźne ostrzeżenie dla scope runda że dotyczy tylko IPSC
  - Pliki:
    * includes/shortcodes/ajax-handlers.php (rozszerzenie + filtr IPSC w JOIN)
    * includes/shortcodes/competitions.php (nowe przyciski + modal JS)

* [NOWE] DSQ — dokończona integracja UI + parser Practiscore + modal z powodem
  - Kontynuacja pracy nad DNS/DNF/DSQ (poprzedni wpis). Zamknięto trzy TODO:
  1. PARSER HTML PRACTISCORE rozpoznaje DNS/DNF/DSQ:
     results-parser.php — nowa metoda detect_status() sprawdza w polach Name,
     Class, oraz wszystkich komórkach wiersza wystąpienia:
       DSQ | DISQ | Disqualified | Disqualif -> DSQ
       DQ (samodzielne) -> DSQ
       DNF | "Did Not Finish" | "Not Finished" -> DNF
       DNS | "Did Not Start" | "Not Started" -> DNS
     Metoda clean_status_from_name() usuwa oznaczenia z pola nazwisko
     (np. "KOWALSKI Jan DQ" -> "KOWALSKI Jan").
     Import w results.php zapisuje status do bazy + dla DNS/DSQ zeruje
     stage_pts/kolumny_wynikow (zgodnie z przepisami).
     Komunikat "Zaimportowano wyniki... X z oznaczeniem DNS/DNF/DSQ".

  2. PRZYCISKI UI "DSQ runda" + "DSQ impreza" w panelu Zarządzania zawodami:
     competitions.php, zakładka Rejestracje — przy każdej rejestracji dwa
     nowe przyciski:
       [🚫 DSQ runda]   - pomarańczowy, dyskwalifikacja z TEJ rundy
                           (wszystkie konkurencje IPSC w tej rundzie)
       [🚫 DSQ impreza] - czerwony, dyskwalifikacja z CAŁEJ imprezy
     Przyciski wywołują modal z polem "Powód dyskwalifikacji" (textarea).
     Po Zatwierdzeniu AJAX ks_dsq_calosc_zawody (parametr runda_id opcjonalny).

  3. MODAL DSQ z powodem:
     Jedno pole textarea (opcjonalne ale zalecane), placeholder z przykładem.
     Wyraźne wskazanie zakresu (runda vs impreza) + info że zawodnik pozostaje
     w klasyfikacji (zgodnie z IPSC). Wynik akcji pokazywany w modalu
     (zielony sukces / czerwony błąd z kodem). Auto-reload strony po 2s
     żeby admin zobaczył zaktualizowane statusy.

  - Rozszerzenie AJAX ks_ajax_dsq_calosc_zawody (ajax-handlers.php):
    * Nowy parametr runda_id (int, opcjonalny) — gdy >0, DSQ obejmuje
      TYLKO wyniki w tej rundzie (przez JOIN z wp_ks_zawody_wyniki_html).
    * Powód zapisywany w log audytu (wp_options.ks_dsq_log_<zawody_id>)
      razem z polem runda_id (0 = impreza).
    * sanitize_textarea_field() dla powodu (zachowuje newlines).

  - Pliki zmienione:
    * includes/results-parser.php (NOWE: detect_status, clean_status_from_name)
    * includes/shortcodes/results.php (zapis statusu z parsera)
    * includes/shortcodes/ajax-handlers.php (rozszerzenie AJAX o runda_id)
    * includes/shortcodes/competitions.php (przyciski DSQ + modal HTML + JS)

* [NOWE] Obsługa statusów DNS/DNF/DSQ w wynikach zawodów (ISSF/PZSS/IPSC)
  - Kontekst: przepisy ISSF 6.14, PZSS i IPSC wymagają oznaczania zawodników
    specjalnymi kodami:
      * DNS (Did Not Start) — nie wystartował
      * DNF (Did Not Finish) — nie ukończył (ISSF 6.14.4: zawodnik otrzymuje
        wynik częściowy = oddane strzały + 0 za nieoddane)
      * DSQ (Disqualified) — zdyskwalifikowany (wyniki anulowane/zerowane)
    Do SOKS 1.2.0 system obsługiwał tylko wyniki liczbowe — zawodnicy DNS/DSQ
    zapisywani byli z wynikiem 0, co było niezgodne z przepisami.
  - Funkcje dodane:
    1. MIGRACJA TABELI: wp_ks_zawody_wyniki_szczegoly + kolumna `status` VARCHAR(10).
       Idempotentna (flaga ks_wyniki_status_column_v1), admin notice po wykonaniu.
       Plik: includes/migrations/add-wyniki-status.php (NOWY)
    2. FORMULARZ RĘCZNY WYNIKÓW (results.php): dodany dropdown statusu przed
       kolumnami wyników serii [—/DNS/DNF/DSQ]. JavaScript blokuje pola wyników
       dla DNS/DSQ (brak wyniku), zostawia aktywne dla DNF (wynik częściowy).
       Legenda pod tabelą z opisami kodów.
    3. ZAPIS: logika zgodna z przepisami:
         - DNS/DSQ → stage_pts=0, kolumny_wynikow=NULL (zero pkt w rankingu
           wielorundowym), miejsce=0 (niesklasyfikowany)
         - DNF → stage_pts=suma częściowa (ISSF 6.14.4), miejsce=0
         - NULL → normalny wynik, miejsce=1,2,3...
       Sortowanie: sklasyfikowani → DNF → DNS → DSQ (status ASC, potem wynik DESC).
    4. WYŚWIETLANIE W PROTOKOLE PDF (results-pdf-generator.php):
         - Kolumna "Miejsce": liczba dla sklasyfikowanych, "—" dla DNS/DNF/DSQ
         - Kolumna "SUMA/Punkty": liczba dla sklasyfikowanych i DNF (dopisek
           "(DNF)" szary dla DNF), TEXT status dla DNS (szary) i DSQ (czerwony)
         - Wiersz ma subtelny background (szary DNF/DNS, czerwonawy DSQ)
         - Legenda pod tabelą (automatycznie gdy są wpisy ze statusem)
         - Sortowanie SQL: ORDER BY (status IS NOT NULL) ASC, miejsce ASC,
           FIELD(status,'DNF','DNS','DSQ'), stage_pts DESC, nazwisko_imie ASC
    5. RANKING WIELORUNDOWY (rankings.php): działa automatycznie — stage_pts=0
       dla DNS/DSQ oznacza że w sumie N-1 najlepszych z N rund te będą pominięte.
       DNF z wynikiem częściowym wliczany normalnie (zgodnie z ISSF).
    6. BULK DSQ CAŁEJ IMPREZY (IPSC total disqualification):
       Nowy AJAX endpoint ks_ajax_dsq_calosc_zawody — oznacza WSZYSTKIE wyniki
       zawodnika we WSZYSTKICH rundach × konkurencjach danej imprezy jako DSQ.
       Zawodnik NIE jest usuwany z klasyfikacji (zgodnie z IPSC) — zostaje
       w protokołach z oznaczeniem DSQ. Support dla mode='undo' (cofnięcie DSQ
       — status→NULL, uwaga: wyniki stage_pts były wyzerowane, trzeba wpisać
       ponownie). Log audytu w wp_options.ks_dsq_log_<zawody_id> (ostatnie 100).
       Plik: includes/shortcodes/ajax-handlers.php (dodane na końcu).
       UWAGA: przycisk UI w widoku wyników jest do dodania — do osobnego tiketu.
  - Pliki:
    * includes/migrations/add-wyniki-status.php (NOWY)
    * includes/shortcodes/results.php (dropdown, JS, logika zapisu)
    * includes/results-pdf-generator.php (renderowanie protokołu)
    * includes/shortcodes/ajax-handlers.php (AJAX bulk DSQ całej imprezy)
    * soks.php (podłączenie migracji)
  - TODO w kolejnej iteracji:
    * Parser HTML Practiscore — rozpoznawanie "DNF"/"DNS"/"DSQ"/"DISQ" w importach
    * Przycisk "DSQ cała impreza" w panelu zarządzania zawodami (wywołuje AJAX)
    * Formularz powodu dyskwalifikacji (pole tekstowe przy DSQ)

* [NAPRAWA] Dropdown statusu członka w liście — defense-in-depth
  - Kontekst: admin/zarząd zgłosił że zmiana statusu członka z dropdownu
    w tabeli [lista_czlonkow] (Panel klubu -> Lista Członków) bywa
    niesubiektywna. Dropdown zwracał wartość ale albo UI nie aktualizował,
    albo występował bag w logice originalValue.
  - Naprawa 3-warstwowa:
    1. Rozszerzone uprawnienia AJAX ks_update_member_status:
       wcześniej wymagało manage_options LUB ks_zarzad.
       Teraz: manage_options, edit_users, ks_zarzad LUB manage_ks.
       Adminowie ze standardową rolą WP (edit_users) też mogą zmienić.
    2. Szczegółowe komunikaty błędów — zamiast ogólnikowego "Błąd"
       AJAX zwraca konkretny code + message:
         * invalid_nonce — nonce wygasł (informacja: odśwież F5)
         * no_permission — brak uprawnień (pokazuje role użytkownika)
         * invalid_status — status nie jest w dozwolonej liście
         * user_not_found — błędne user_id
       JS przekazuje to do alert() + console.log dla diagnostyki.
    3. Poprawiona logika originalValue w JS:
       select dostał atrybut data-original-status (z serwera).
       Przy błędzie AJAX dropdown wraca do tej wartości (nie do nowej,
       jak było w buggy kodzie). Logi w konsoli [SOKS] dla debugowania.
  - Pliki:
    * includes/shortcodes/ajax-handlers.php (handler AJAX)
    * includes/menu-admin.php (select HTML + JS handler)

* [NAPRAWA KRYTYCZNA] Admin/zarząd nie mógł aktualizować pól patentu i licencji
  zawodniczej innego członka (niespójność nazw meta)
  - Problem: Admin edytujący dane członka w panelu "Moje konto" (page_id=5178)
    aktualizował pole "Numer patentu strzeleckiego" — dostawał komunikat
    "Dane zaktualizowane!", ALE toolbar (karta statusu członka) dalej
    pokazywał "brak" (podobnie eksport, docs-monitor, karta PZSS forms).
  - Przyczyna: konfiguracja pól formularza (account-panel-config.php) używała
    nazw typu "Numer_patentu_strzeleckiego" (bez prefiksu ks_), co powodowało
    zapis do meta `ks_Numer_patentu_strzeleckiego`. Reszta systemu (toolbar,
    eksport, docs-monitor, PZSS forms, menu-admin) czyta z KANONICZNYCH kluczy:
    `ks_patent_strzelecki`, `ks_numer_licencji`, `ks_data_waznosci_licencji`.
    Dwa różne klucze dla tego samego bytu = zapis "w próżnię" z perspektywy UX.
  - Naprawa:
    1. Rozszerzony switch/case w ks_update_user_meta_fallback() — dodane
       mapowanie aliasów dla 5 grup pól:
         * patent_strzelecki (kanoniczny: ks_patent_strzelecki)
         * numer_licencji (kanoniczny: ks_numer_licencji)
         * data_waznosci_licencji (kanoniczny: ks_data_waznosci_licencji)
         * data_nadania_licencji (kanoniczny: ks_data_nadania_licencji)
         * data_nadania_patentu (ks_data_wydania_patentu + ks_data_nadania_patentu)
       Zapis pod KAŻDYM aliasem aktualizuje wszystkie wersje jednocześnie
       — niezależnie od konfiguracji pól w wp_options.
    2. Migracja jednorazowa includes/migrations/sync-meta-aliases.php —
       skanuje istniejących userów, jeśli mają wartość w którymś aliasie
       a drugi pusty, kopiuje wartość (priorytet: klucz kanoniczny).
       Flaga: ks_sync_meta_aliases_v1. Admin notice po uruchomieniu.
  - Sprawdzone inne pola na niespójność: PESEL, telefon, adres, miasto,
    kod_pocztowy, miejsce_urodzenia, nr_dowodu, data_urodzenia — te mają
    już poprawne mapowanie aliasów w helpers.php.
  - Pliki:
    * includes/shortcodes/helpers.php (rozszerzony switch/case)
    * includes/migrations/sync-meta-aliases.php (NOWY)
    * soks.php (podłączenie migracji)

* [UX] Panel klubu -> Dokumenty -> pole "Wybierz członka" domyślnie puste
  - Przed 1.2.0 pole automatycznie zawierało zalogowanego użytkownika
    (lub ostatnio wybranego członka z user_meta `ks_docs_last_member`).
    Było to mylące — admin generujący dokument dla INNEGO członka musiał
    najpierw "wyczyścić" pole, co nie zawsze jest oczywiste.
  - Po zmianie: pole "Wybierz członka" jest PUSTE na pierwszym wejściu
    do zakładki Dokumenty. Admin aktywnie wybiera członka z live-search.
    Po submicie formularza (np. błąd walidacji brakujących pól) pole
    zachowuje wybór z POST.
  - Usunięto auto-save do user_meta `ks_docs_last_member` — już niepotrzebne.
  - Pliki: dokumenty/ks-modul-dokumenty.php linie ~343-376 (logika),
    ~412-428 (template hidden input + JS).

* [NAPRAWA] Raport kasowy — poprawki wygladu i druku
  - Problem 1: duza pusta przestrzen na pierwszej stronie wydruku
    raportu (Kasa -> Raport kasowy -> Drukuj). Stary @media print
    uzywal `visibility: hidden` + selector `#tab-kasa` ktory nie
    targetuje aktualnej struktury DOM, przez co elementy strony WP
    (header, menu, sidebar Facebook, admin bar) zajmowaly pierwsza
    strone wydruku.
  - Problem 2: przycisk "Generuj raport" bywa niepewny przy submicie
    (form method=get bez jawnego atrybutu action moze zle zachowywac
    sie przy niestandardowych permalinkach).
  - Naprawa:
    1. Przepisany @media print — ukrywa WSZYSTKIE elementy theme'u WP
       i panel SOKS, pokazuje tylko kontener .ks-raport-print z raportem.
       Ustawienia strony: @page margin: 1.2cm, size A4.
    2. Jawny tytul raportu dodany wewnatrz kontenera druku (.ks-raport-
       print-only) — wyswietla sie TYLKO przy druku (normalnie ukryty
       przez `display: none`, aktywowany @media print). Tytul ponad
       tabela zamiast wsrod przyciskow nawigacji.
    3. Formularz filtrow dostal jawny atrybut action=<base-url>.
       Bezpieczniejsze dla konfiguracji permalinkow, submit zawsze
       idzie w wlasciwe miejsce.
    4. Przyciski "Raport kasowy/bankowy", "Powrot do kasy", "Generuj",
       "Drukuj" dostaly klase .ks-raport-no-print / .ks-raport-filters
       i sa ukrywane w wydruku.
  - Plik: includes/shortcodes-v2-complete.php linie ~1067-1150,
    ~1316 (zamkniecie kontenera).

* [NAPRAWA KRYTYCZNA] Duplikaty dokumentow KP dla tej samej rejestracji
  - Root cause: akcja "Przyjmij zaplate" w panelu "Zarzadzanie zawodami
    -> Rejestracje" byla wywolywana przez link GET z parametrami URL
    (?action_rej=przyjmij_zaplate&rej_id=X). Po wykonaniu akcji URL w
    pasku przegladarki zachowywal parametry, wiec KAZDE z nastepujacych
    dzialan generowalo kolejny duplikat KP:
      - F5 / Ctrl+R (odswiezenie strony)
      - Przycisk "wstecz" + ponowne zaladowanie
      - Kopiowanie URL do drugiej karty
  - Przyklad z wdrożeniu pilotażowym (2026-04-19):
      Rejestracja ID 507 (zawodnik A) -> 8 duplikatow KP (30 min)
      Rejestracja ID 550 (zawodniczka B) -> 7 duplikatow KP (9 min)
    Wszystkie wygenerowane przez tego samego administratora
    co sugeruje ze kazde kolejne dzialanie na tabeli rejestracji
    wyzwalalo akcje URL z poprzedniego kliknieca.
  - Naprawa 4-warstwowa (defense-in-depth):
    1. HISTORY.REPLACESTATE w panelu rejestracji:
       competitions.php:~1048 — po wykonaniu akcji JavaScript czysci
       parametry (action_rej, rej_id, _wpnonce, bulk_rej_ids) z URL w
       pasku przegladarki bez przeladowania strony. F5 juz nie powtorzy
       akcji. Komunikat sukcesu pozostaje widoczny.
    2. GUARD paid_at w akcjach przyjmij_zaplate/oznacz_oplacone/
       rozlicz_karnet — blokuje akcje dla rejestracji juz oplaconych
       (dodany wczesniej w 1.2.0 dla przyjmij_zaplate i oznacz_oplacone,
       teraz rozszerzony o rozlicz_karnet).
    3. IDEMPOTENCY w ks_dodaj_dokument_kp:
       cash-register.php:4-23 — jesli opis pasuje do "Rejestracja ID: X"
       i istnieje juz KP z tym samym opisem -> zwraca istniejacy numer
       zamiast tworzyc duplikat. Obrona ostatniej szansy.
    4. MIGRACJA CZYSZCZACA (jednorazowa po update do 1.2.0):
       includes/migrations/cleanup-duplicate-kp.php — grupuje KP po
       opisie "Rejestracja ID: N", zachowuje najstarszy (MIN id) w
       kazdej grupie, usuwa reszte. Admin notice z liczba usunietych
       dokumentow, szczegoly w wp_options (ks_cleanup_duplicate_kp_v1).
  - Skutek: eliminacja duplikatow KP (zarowno historycznych jak i
    nowych). Nawet przy kombinacji bledow UI (F5 + race condition +
    otwarcie URL w 2 kartach) zadnego duplikatu nie powstanie.
  - Pliki:
    * includes/shortcodes/competitions.php (history.replaceState,
      guard rozlicz_karnet)
    * includes/shortcodes/cash-register.php (idempotency guard)
    * includes/migrations/cleanup-duplicate-kp.php (NOWY)
    * soks.php (podlaczenie migracji)

* [NAPRAWA KRYTYCZNA] Zamowienia WC pokazywane jako nieoplacone mimo
  wygenerowanego KP i oplaconych rejestracji
  - Problem: W panelu Zarzadzanie klubem -> Rozrachunki -> Naleznosci
    zamowienia WooCommerce w statusie "processing" (W trakcie realizacji),
    "pending" (Szkic), "on-hold" (Wstrzymane) pokazywaly sie jako
    nieoplacone NAWET jesli wszystkie ich rejestracje byly juz oplacone
    (paid_at ustawione) i KP zostaly wygenerowane.
  - Reprodukcja: zawodnik wybiera "W kasie klubu" przy zakupie -> zamowienie
    WC dostaje status "processing" -> admin przyjmuje zaplate w panelu
    Rejestracje -> generuje sie KP, rejestracja.paid_at = now -> ALE status
    zamowienia WC zostaje "processing" -> widoczne jako "nieoplacone" w
    Rozrachunkach (duplikacja naleznosci!).
  - Przyklad (wdrożenie pilotażowe 2026-04-21): 35+ zamowien w Rozrachunkach mimo ze
    wszystkie rejestracje byly zaplacone i istnialy KP/066-KP/113/2026.
  - Naprawa trojfazowa:
    1. FILTR W ROZRACHUNKACH (natychmiastowy fix UX):
       shortcodes-v2-complete.php:3012 — dla kazdego zamowienia WC
       sprawdzamy czy rejestracje w ks_zawody_rejestracje z tym order_id
       maja total === paid_count. Jesli tak — wykluczamy z listy naleznosci.
    2. AUTO-COMPLETION STATUSU WC (proaktywnie dla nowych akcji):
       competitions.php:996 — po akcji przyjmij_zaplate/oznacz_oplacone/
       rozlicz_karnet jesli wszystkie rejestracje order_id sa oplacone
       (unpaid_left = 0) -> $wc_order->update_status('completed').
    3. MIGRACJA HISTORYCZNA (jednorazowa po update do 1.2.0):
       includes/migrations/sync-wc-orders-paid.php — skanuje istniejace
       zamowienia "processing/pending/on-hold/checkout-draft", zamyka te
       ktorych rejestracje sa w pelni oplacone. Uruchamia sie raz
       (flaga ks_sync_wc_orders_v1), admin notice z iloscia zmian.
  - Skutek: widok Rozrachunkow pokazuje tylko zamowienia rzeczywiscie
    nieoplacone. Istniejace 35+ zamowien z wdrożenia pilotażowego zostana automatycznie
    zamkniete po aktywacji 1.2.0.
  - Pliki:
    * includes/shortcodes-v2-complete.php (filtr)
    * includes/shortcodes/competitions.php (auto-completion)
    * includes/migrations/sync-wc-orders-paid.php (NOWY)
    * soks.php (podlaczenie migracji)

* [KNOWN ISSUE — do naprawy w 1.2.1] Zakladka "Moje dokumenty" pokazuje
  stary widok zamiast monitoring dokumentow (docs-monitor)
  - Miejsce: /moje_konto_klubowe?tab=dokumenty
  - Oczekiwane: panel docs-monitor z progress barami waznosci (licencja,
    patent, LS/LI/LD, lekarskie) + licznik dni do wygasniecia
  - Faktyczne: widok z ks-modul-dokumenty (lista wygenerowanych PDF)
  - Przyczyna: kolizja rejestracji add_shortcode('ks_moje_dokumenty'):
    * includes/features/docs-monitor/frontend.php:14 (nowy, 1.2.0)
    * dokumenty/ks-modul-dokumenty.php:262 (klasyczny, ladowany pozniej
      przez soks.php:754 — nadpisuje pierwsza rejestracje)
  - Wplyw funkcjonalny: ZERO — feature docs-monitor jest aktywny, zbiera
    dane (wp_ks_docs istnieje, db_version=1.0.0), cron powiadomien
    60/30/7 dni dziala. Tylko widok czlonkowski jest "schowany".
  - Planowana naprawa 1.2.1: zmiana nazwy shortcode docs-monitor
    na [ks_docs_monitor] + dodanie drugiej zakladki (rozdzielenie
    "Waznosc dokumentow" vs "Moje dokumenty wygenerowane")

* [NAPRAWA BUGA] Zla data rundy w emailu "Rejestracja potwierdzona"
  - Problem: gdy zawodnik byl zarejestrowany w zawodach na kilka rund
    (np. runda 1 w lutym, runda 2 w marcu), email potwierdzajacy nowa
    rejestracje na runde 2 pokazywal date rundy 1 (15.02 zamiast 15.03).
  - Reprodukcja: zawodnik zarejestrowal sie na runde 2 Pucharu Klubu,
    dostal email z Data: 15.02.2026 (data rundy 1), zamiast daty rundy 2.
  - Przyczyna: w includes/shortcodes/email.php zapytanie
    SELECT runda_id ... LIMIT 1 (bez ORDER BY) zwracalo dowolna runde
    zawodnika — w praktyce zwykle pierwsza historyczna (najmniejsze id).
    Dodatkowo funkcja nie przyjmowala runda_id jako parametr, wiec
    wywolujacy (payments.php, competitions.php) nie mogli jednoznacznie
    okreslic ktorej rundy dotyczy email.
  - Naprawa warstwowa:
    1. ks_wyslij_email_potwierdzenie_rejestracji() przyjmuje nowy
       opcjonalny parametr $runda_id (null = backward compat).
    2. Fallback SQL: ORDER BY id DESC LIMIT 1 (najnowsza rejestracja).
    3. Wszystkie 5 miejsc wywolania przekazuja runda_id gdy jest znane:
       * payments.php:640 — zbiera runda_ids w email_groups; jesli jedna
         wspolna runda → przekazuje ja, jesli wiele → null.
       * payments.php:729 (po WooCommerce paid) — SELECT DISTINCT runda_id
         z rejestracje_ids; jesli unique = 1 → przekazuje.
       * payments.php:776 (backward compat fallback) — analogicznie.
       * competitions.php:1009 (akcja "potwierdz") — przekazuje
         $rejestracja->runda_id.
       * competitions.php:1018 (akcja oznacz_oplacone/przyjmij_zaplate/
         rozlicz_karnet) — przekazuje $rejestracja->runda_id.
  - Pliki:
    * includes/shortcodes/email.php (sygnatura + logika wyboru rundy)
    * includes/shortcodes/payments.php (3 wywolania)
    * includes/shortcodes/competitions.php (2 wywolania)
  - Kompatybilnosc: wywolania bez parametru $runda_id nadal dzialaja,
    ale z poprawionym fallback (najnowsza rejestracja zamiast losowej).

* [RODO hard-guard] Pozostale shortcode listy czlonkow - zabezpieczenie
  defense-in-depth przez is_user_logged_in()
  - Problem: 3 shortcode listy czlonkow (pozostale po usunieciu
    [lista_czlonkow_klubu]) mialy tylko komentarz "Kontrola dostepu na
    poziomie strony (meta box)" bez wbudowanego is_user_logged_in().
    Shortcodes te moga wyswietlac dane wrazliwe (PESEL, adres, telefon,
    email, data urodzenia) zaleznie od konfiguracji kolumn.
  - Ryzyko: jesli admin klubu wklei ktorys z tych shortcode na strone bez
    ustawienia _ks_access_type = 'logged_in', dane osobowe czlonkow beda
    publicznie widoczne (naruszenie RODO).
  - Naprawa: dodano na poczatku kazdej funkcji twardy guard:
    if (!is_user_logged_in()) return <komunikat + link do logowania>
  - Zabezpieczone funkcje:
    * ks_shortcode_lista_czlonkow_kompletna() [lista_czlonkow_kompletna]
      -> includes/shortcodes/members.php linia 367
    * ks_shortcode_lista_czlonkow_v3() [lista_czlonkow_v3]
      -> includes/shortcodes/members.php linia 731
    * ks_shortcode_lista_czlonkow() [lista_czlonkow]
      -> includes/menu-admin.php linia 2964
  - Weryfikacja na wdrożeniu pilotażowym: zadne z tych 3 shortcode nie jest
    osadzone na stronach klubu (audyt wp_posts potwierdzil). Zmiana jest
    defense-in-depth na przyszlosc oraz dla innych klientow SOKS.
  - Zachowanie dla zalogowanych: bez zmian, lista dziala jak dotad.

* [USUNIECIE RODO] Shortcode [lista_czlonkow_klubu] - usuniety z pluginu
  - Powod: shortcode wyswietlal na publicznej stronie WP konfigurowalnie
    dane wrazliwe czlonkow (imie, nazwisko, e-mail, telefon, PESEL, adres,
    data urodzenia) bez wbudowanego sprawdzenia is_user_logged_in().
    Zabezpieczenie opieralo sie wylacznie na meta _ks_access_type strony
    WordPress — jesli admin nie ustawil tego, dane byly publiczne.
  - Ryzyko RODO: mozliwy wyciek PESEL i adresow przez zle skonfigurowana
    strone — szczegolnie dla nowych klientow przed konfiguracja.
  - Weryfikacja na wdrożeniu pilotażowym: shortcode NIE byl uzyty (audyt
    wp_posts potwierdzil brak wystapien w stronach publikowanych/draft).
  - Alternatywy:
    * [klub] — pelny panel klubu z lista czlonkow, z wbudowanym
      is_user_logged_in() (shortcodes-v2-complete.php:13)
    * [lista_czlonkow_v3] — publiczna lista ale bez pol wrazliwych
    * [lista_czlonkow_kompletna] — kompletna lista (chroniona wg config)
  - Zachowane (wspoldzielone z innymi shortcode listy czlonkow):
    * opcja ks_lista_czlonkow_ustawienia w wp_options (uzywana tez przez
      [lista_czlonkow_kompletna], [lista_czlonkow_v3] i panel admina)
    * AJAX handler ks_ajax_frontend_add_member + nonce ks_add_member_frontend
    * CSS .ks-members-table, .ks-members-container
  - Pliki zmienione:
    * includes/shortcodes/members.php: usunieto funkcje
      ks_shortcode_lista_publiczna (1625 linii, linie 4-1629) oraz
      rejestracje add_shortcode('lista_czlonkow_klubu', ...) z linii 1990
    * includes/shortcodes.php: zaktualizowano komentarz w loaderze
    * includes/admin-menu.php: usunieto wpis ze sciagawki dostepnych
      shortcode w panelu admina (linia 263)
  - Oszczednosc: -1629 linii martwego kodu
  - Migracja: inni klienci uzywajacy tego shortcode na swoich stronach
    zobacza surowy tekst [lista_czlonkow_klubu] po aktualizacji — nalezy
    recznie zmienic na [klub] lub [lista_czlonkow_v3].

* [NAPRAWA BUGA] Generowanie KP dla rejestracji rozliczonej karnetem
  - Problem: Po kliknieciu "🎫 Karnet" rejestracja zostawala rozliczona,
    ale ponowne kliknienie (lub otwarcie starego URL z action_rej=przyjmij_zaplate)
    generowalo dokument KP mimo ze karnet jest BEZPLATNY.
  - Reprodukcja: Zarzadzanie zawodami -> rejestracja -> 🎫 Karnet ->
    status "oplacona karnetem" -> otwarcie linku przyjmij_zaplate w innej
    karcie -> generowanie KP/129/2026 dla kwoty z karnetu (0 zl bledu,
    ale zaszumia kase).
  - Naprawa: Defensywne sprawdzenie w handlerach 'przyjmij_zaplate' oraz
    'oznacz_oplacone' — jesli paid_at jest juz ustawione, zakoncz akcje
    z komunikatem ostrzegawczym (rozroznia karnet vs zwykla oplata po
    obecnosci 'Karnet #' w polu uwagi).
  - Bulk handler (masowa akcja 'Oznacz wybrane jako oplacone') juz wczesniej
    sprawdzal paid_at — pomija rejestracje rozliczone karnetem.
  - Plik: includes/shortcodes/competitions.php linie 728-738 (oznacz_oplacone),
    779-787 (przyjmij_zaplate).

* [NAPRAWA] Brakujacy link "Wlacz automatyczne aktualizacje" dla SOKS
  w panelu wtyczek WordPressa
  - Przyczyna: check_for_update() wypelnial tylko $transient->response
    (dostepne aktualizacje), pomijajac $transient->no_update (wtyczki
    monitorowane). WordPress wymaga wpisu w no_update zeby pokazac
    link auto-update obok wtyczki.
  - Poprawka: gdy brak nowej wersji -> dodaj wtyczke do no_update
    z pelnym obiektem plugin_data (id, slug, url, icons, banners,
    requires, tested, compatibility etc.)
  - Efekt: po wgraniu SOKS 1.2.0 obok wtyczki pojawi sie link
    "Wlacz automatyczne aktualizacje" (jak przy TablePress, WooCommerce)
  - Plik: includes/updater.php linie 33-72

* [NAPRAWA KRYTYCZNA] Rozjazd case-sensitivity w updater.php (SOKS vs soks)
  - includes/updater.php: zmiana '$proper_dir' z WP_PLUGIN_DIR . '/SOKS' na WP_PLUGIN_DIR . '/soks'
  - Zmiana fallback basename z 'SOKS/soks.php' na 'soks/soks.php' (linie 40, 85, 101)
  - Przyczyna: ZIP zawiera folder 'soks/' (lowercase), updater probowal przeniesc do '/SOKS'
  - Skutek przed poprawka: na hostingach Linux po aktualizacji powstawaly duplikaty katalogow
  - Dotyczy wszystkich klientow na hostingach case-sensitive (produkcyjne)

* [NOWE] System Feature Flags (includes/features/)
  - Klasa KS_Features z API: is_enabled(), enable(), disable(), get_config(), set_config()
  - Strona admina: SOKS -> Moduly (karty z toggle switch, grupowanie po kategoriach)
  - 10 modulow przygotowanych do aktywacji:
    * docs_monitor - Monitoring waznosci dokumentow
    * bookings - Rezerwacja stanowisk/torow
    * pzss_forms - Generator wnioskow PZSS
    * sms_gateway - Bramka SMS (SerwerSMS/SMSAPI)
    * live_scoring - Live scoring (SIUS/Megalink/Meyton)
    * bracket_gen - Generator drabinek (ISSF)
    * messenger - Komunikator wewnetrzny
    * vouchers - Vouchery/karnety prezentowe
    * referee_zone - Strefa sedziego
    * pzss_registration - Rejestracja na zawody rangi PZSS
  - Hook ks_feature_toggled uruchamia migracje tabel modulu przy pierwszym wlaczeniu
  - Dane nie sa kasowane przy wylaczeniu modulu (safe toggle)

* [POPRAWKA] Modul 1: Zmiana nazwy submenu (konflikt z istniejacym "Dokumenty")
  - SOKS → 📅 Dokumenty → zmieniono na SOKS → 📅 Monitoring dokumentow
  - Unika to konfuzji z istniejacym modulem SOKS Dokumenty (generator)
  - Plik: includes/features/docs-monitor/admin.php linia 27

* [POPRAWKA] Modul 1: Auto-synchronizacja licencji z profilu uzytkownika
  - Usunieto z listy typow: Patent strzelecki (bezterminowy),
    Pozwolenie na bron (poza zakresem klubu)
  - Zmiana nazw:
    * Legitymacja sedziowska → Licencja sedziego PZSS
    * Uprawnienia instruktorskie → Licencja instruktora PZSS
  - Dodano nowy typ: Licencja sedziego IPSC
  - Finalna lista 7 typow:
    1. Licencja zawodnicza PZSS (auto, obowiazkowy)
    2. Orzeczenie lekarskie (reczne, obowiazkowy)
    3. Orzeczenie psychologiczne (reczne)
    4. OC klubu (reczne)
    5. Licencja sedziego PZSS (auto)
    6. Licencja sedziego IPSC (auto)
    7. Licencja instruktora PZSS (auto)
  - NOWE: ks_docs_monitor_sync_from_user_meta() - auto-synchronizacja
    wpisow w wp_ks_docs z danymi z user_meta (pola w profilu klubowicza)
  - Mapowanie kluczy meta zgodne z istniejacym schematem SOKS:
    * ks_data_waznosci_licencji / ks_numer_licencji
    * ks_licencja_sedziego_pzss_data_waznosci / _numer
    * ks_licencja_sedziego_ipsc_data_waznosci / _numer
    * ks_licencja_instruktora_data_waznosci / _numer
  - Normalizacja dat: obsluga formatow YYYY-MM-DD, DD.MM.YYYY, DD/MM/YYYY
  - Upsert logic: jeden wpis per (user_id, doc_type)
    * Aktualizuje gdy zmieni sie data lub numer
    * Reaktywuje status='expired' → 'active' gdy data w przyszlosci
    * Kasuje log powiadomien gdy data sie zmienila (nowy rejs powiadomien)
  - Sync uruchamiany automatycznie:
    * Przy kazdym wejsciu w panel admina modulu (tanie)
    * W cron daily przed skanowaniem progow
  - Admin: ikona 🔄 obok auto-typow, info-box z wyjasnieniem, ukrycie
    auto-typow w formularzu "Dodaj dokument" (nie ma sensu recznie dodawac)

* [NOWE] Modul 1: Monitoring waznosci dokumentow (features/docs-monitor/)
  - Tabele: wp_ks_docs (dokumenty), wp_ks_docs_notifications_log (audit anty-duplikat)
  - 8 typow dokumentow: licencja PZSS, patent, lekarskie, psychologiczne,
    pozwolenie na bron, OC klubu, legitymacja sedziowska, uprawnienia instruktorskie
  - Cron daily ks_docs_monitor_daily (konfigurowalna godzina, domyslnie 07:00)
  - Powiadomienia e-mail z kolorowaniem (60 dni=niebieski, 30=zolty, 7=czerwony)
  - Deduplikacja przez UNIQUE KEY uq_doc_threshold_channel
  - Opcjonalna wysylka SMS (gdy aktywny modul sms_gateway)
  - Panel admina SOKS -> Dokumenty: lista z filtrem i kolorowaniem wierszy,
    zakladki Lista/Dodaj/Ustawienia/Log, przycisk "Uruchom cron teraz" (test)
  - Shortcode [ks_moje_dokumenty] - panel czlonka z progress barem waznosci,
    self-service aktualizacja daty waznosci z ownership check
  - Integracja z panelem [moje_konto_klubowe] przez filter ks_account_tabs
  - Auto-oznaczanie dokumentow jako 'expired' gdy data minela

* [NOWE] Modul 2: Rezerwacja stanowisk/torow (features/bookings/)
  - Tabele: wp_ks_bookings_ranges (tory), wp_ks_bookings_lanes (stanowiska),
    wp_ks_bookings_schedule (godziny otwarcia), wp_ks_bookings_reservations (rezerwacje)
  - 4 typy rezerwacji: godzinowy / blok / caly dzien / custom
  - Silnik slotow ks_bookings_get_available_slots() - generuje dostepne terminy
  - Walidacja anty-duble 3-poziomowa:
    * UNIQUE KEY (lane_id, start_datetime, status) w bazie
    * Sprawdzanie konfliktow uzytkownika (jedna rezerwacja w tym samym czasie)
    * Sprawdzanie konfliktow stanowiska
  - Walidacja terminow: min/max wyprzedzenie konfigurowalne per tor
  - Integracja z karnetami: filter ks_karnet_try_punch / ks_karnet_refund_punch
    (tor moze uzywac karnetu zamiast oplaty)
  - Panel admina 6 zakladek: Kalendarz tygodnia, Tory, Stanowiska (hurtowo),
    Rozklad tygodniowy, Lista rezerwacji, Ustawienia
  - Kalendarz tygodnia z kolorowaniem: zielony (≥50% wolne), zolty (<50%), czerwony (zajete)
  - Shortcody:
    * [ks_bookings_calendar] - grid slotow z wyborem stanowiska i rezerwacja
    * [ks_bookings_my] - moje nadchodzace rezerwacje z anulowaniem
  - Self-cancel przez czlonka (z limitem H przed startem, konfigurowalne)
  - E-mail potwierdzajacy rezerwacje i anulowanie (opcjonalne)
  - Integracja z [moje_konto_klubowe] - zakladka "Moje rezerwacje"

* [NOWE] Przycisk "Rozlicz karnetem" w liscie rejestracji (Zarzadzanie zawodami)
  - Dla rejestracji oczekujacych na zaplate, obok "Przyjmij zaplate" i "Przelew"
    pojawia sie zielony przycisk "🎫 Karnet" gdy zawodnik ma aktywny karnet
    z wolnymi startami dla typu broni konkurencji.
  - Klikniecie wykonuje (atomowo):
    1. Inkrementuje zuzyto_<typ_broni> w tabeli wp_ks_zawody_karnety
    2. Tworzy wpis bridge w wp_ks_zawody_karnet_rejestracje
    3. Ustawia oplata_kwota=0, paid_at=now, uwagi='Karnet #X', status=paid
    4. Przydziela numer startowy (jesli brak)
    5. Oznacza karnet jako 'completed' gdy wszystkie starty zuzyte
    6. Wysyla email potwierdzajacy oplacenie rejestracji
  - Przycisk widoczny TYLKO gdy warunki spelnione — server-side sprawdzanie
    przez ks_get_active_karnety_for_user() + ks_karnet_typ_broni_col().
  - Brak przycisku dla:
    * Rejestracji goscia (brak user_id)
    * Zawodnika bez karnetu
    * Karnetu bez wolnych startow w danym typie broni
  - Handler akcji 'rozlicz_karnet' w switch z pelna walidacja bezpieczenstwa:
    * Nonce _wpnonce
    * Uprawnienia admin/zarzad
    * Sprawdzenie rejestracji, karnetow, typu broni
    * Komunikaty bledow dla kazdego przypadku
  - Plik: includes/shortcodes/competitions.php linie ~856-998 (case),
    ~1119 (SELECT dodany k.typ_broni), ~1562-1600 (przycisk HTML).

* [NAPRAWA BUGA] Wyszukiwarka czlonkow - nie znajdowala po "Nazwisko Imie"
  - Problem: AJAX endpoint ks_ajax_search_members_live (ajax-handlers.php:887)
    szukal calego ciagu w jednym polu (osobno first_name ORAZ last_name).
    Wpisujac "NAZWISKO Imie" system probuje znalezc "%NAZWISKO Imie%" w
    first_name (gdzie jest tylko imie) i last_name (gdzie jest tylko "NAZWISKO")
    — nigdy nie pasowalo, bo imie i nazwisko sa w osobnych kolumnach.
  - Reprodukcja: Zarzadzanie zawodami -> Reczna rejestracja -> wyszukaj
    "NAZWISKO Imie" -> "Nie znaleziono". "NAZWISKO" -> znajduje.
  - Naprawa: tokenizacja wyszukiwania (preg_split /\s+/) + CONCAT_WS z 6 pol
    (login, email, display_name, first_name, last_name, ks_numer_licencji).
    Dla kazdego tokena WHERE x LIKE %s (AND — wszystkie tokeny musza pasowac).
  - Bonus: mozna teraz szukac po numerze licencji (np. "L-82724")
  - Plik: includes/shortcodes/ajax-handlers.php linie ~900-945

* [NAPRAWA BUGA] Wyniki/Wpisz wyniki recznie - pokazywalo zawodnikow ze wszystkich rund
  - Problem: formularz recznego wpisywania wynikow dla konkretnej konkurencji
    (np. KSP20) i rundy (np. Runda 2) pobieral WSZYSTKICH zawodnikow zapisanych
    na ta konkurencje w CALYCH zawodach, bez filtracji po rundzie.
    Przyklad: FLINTA Runda 2 miala 117 zawodnikow na liscie startowej, ale
    formularz wynikow KSP20 Runda 2 pokazywal 134 wierszy (wszystkie rundy).
  - Przyczyna: w zapytaniu SQL (linia ~1195 results.php) brakowalo warunku
    r.runda_id = %d. Query filtrowal tylko zawody_id + konkurencja_id + status.
  - Naprawa: dodano WHERE r.runda_id = %d oraz przekazano $filtr_runda
    jako trzeci parametr prepare().
  - Plik: includes/shortcodes/results.php linia ~1195-1213
  - Efekt: formularz pokazuje teraz tylko zawodnikow zapisanych na wybrana
    konkurencje W WYBRANEJ RUNDZIE, co jest zgodne z lista startowa.

* [ULEPSZENIE] Zarzadzanie rejestracjami do zawodow - auto-filtr + zapamietywanie
  - Problem 1: filtr "Runda" wymagal klikniecia przycisku Filtruj
    (w przeciwienstwie do filtra "Zawody" ktory mial onchange=this.form.submit)
  - Problem 2: po rejestracji recznej zawodnika i powrocie na strone filtry
    resetowaly sie do domyslnych wartosci (brak zapamietywania)
  - Rozwiazania:
    1. Dodano onchange="this.form.submit()" do dropdownow:
       * filtr_runda (zmiana rundy → auto submit)
       * filtr_status (zmiana statusu platnosci → auto submit)
    2. Zapamietywanie filtrow w localStorage (klucz: ks_rejestracje_zawodow_filters_v1):
       * Przy submit formularza → zapis aktualnych 3 filtrow
       * Przy ponownym wejsciu na strone (bez GET parametrow) → przekierowanie
         z zapisanymi filtrami (window.location.replace)
       * Przycisk "Wyczysc" ma teraz &cleared=1 w URL i czysci localStorage
         (zeby zapisane filtry nie wrocily po wyczyszczeniu)
       * Flaga cleared=1 jest usuwana z URL przez replaceState po dotarciu
         (czysty URL dla uzytkownika)
  - Plik: includes/shortcodes/competitions.php linie ~1122-1182

* [NAPRAWA BUGA] Rozrachunki/Naleznosci - pokazywaly juz rozliczone zamowienia (v2)
  - Problem: zamowienia WC obejmowane przez dokument PO z wieloma zamowieniami
    byly nadal pokazywane jako "brak Dok. PO" w tabeli Naleznosci.
    Przyklad: PO/051/2026 obejmowal #5708 + #5709. Tabela zamowien w ks_kasa_transakcje
    zapisywala wc_order_id tylko dla pierwszego zamowienia. Pozostale byly w
    kolumnie `tytul_operacji` w formacie "Zamowienia #5708: ...; #5709: ..."
  - Klucz: numery zamowien sa w TYTULE OPERACJI a nie w OPISIE
    (widac w ks_ajax_rozlicz_po_zbiorczo() linia 2631 ajax-handlers.php:
     $tytul_zbiorczy = 'Zamowienia ' . implode('; ', $czesci_tytulu))
  - Naprawa: rozszerzono WHERE o sprawdzenie 3 warunkow:
    WHERE typ_dokumentu='PO' AND opis LIKE '%%[ROZLICZONE%%'
      AND (wc_order_id = %d
           OR tytul_operacji LIKE '%%#<id>:%%'
           OR opis LIKE '%%#<id>:%%')
  - Pattern "#<id>:" jest jednoznaczny — dwukropek po numerze nie koliduje
    z prefixami (#5708 vs #57080).
  - Plik: includes/shortcodes-v2-complete.php linia ~3098
  - Poprzednia wersja poprawki (sprawdzala tylko `opis`) nie dzialala dla
    zbiorczych PO z wieloma zamowieniami.

* [POPRAWKA] Miesieczny reset licznika numeracji dokumentow (ZS, ZK, DEKL)
  - Problem: numer porzadkowy {nr} byl ciagly przez caly rok, a format pokazywal
    zmienny {mm} - niespojnosc (np. ZS/026/04/2026 a potem ZS/027/05/2026)
  - Zmiana segregacja "rok" -> "miesiac" w 3 configach:
    * dokumenty/szablones/Zaswiadczenie_Sport/config.json
    * dokumenty/szablones/Zaswiadczenie_Kolekcja/config.json
    * dokumenty/szablones/Deklaracja Wstepujacego/config.json
  - Nowy plik dokumenty/migrations-numeracja-monthly.php:
    * Skanuje wp_ks_dokumenty i grupuje wpisy po (typ, rok, miesiac)
    * Inicjalizuje liczniki miesieczne ks_num_<skrot>_<rok>_<mm> = MAX nr z danego miesiaca
    * Oznacza migracje flaga ks_num_monthly_migrated_v1 (uruchamia sie raz)
    * NIE MODYFIKUJE istniejacych dokumentow (historia nietknieta)
    * Admin notice po sukcesie z liczba zainicjowanych licznikow
    * Reset migracji przez URL ?ks_num_reset_migration=1 (tylko manage_options)
  - Efekt:
    * Istniejace dokumenty (ZS/001..026) zachowuja swoje numery
    * Nowy dokument w biezacym miesiacu kontynuuje numeracje (ZS/027/04/2026)
    * Od nowego miesiaca licznik zeruje sie (ZS/001/05/2026)
  - Hook init priority 20 - migracja uruchamia sie raz przy pierwszym
    wejsciu admina w panel po wgraniu 1.2.0

* [NAPRAWA BUGA] Komunikator utykal na "Ladowanie..." w zakladce Moje Konto
  - Przyczyna: zakladki w [moje_konto_klubowe] sa ladowane przez AJAX
    lazy-loading. Przy uzyciu `container.innerHTML = response.html`
    przegladarka NIE WYKONUJE taga <script> - wiec kod JS komunikatora
    (polling, inicjalizacja nonce) nigdy sie nie odpala.
  - Naprawa: w loadTabContent() po ustawieniu innerHTML iterujemy po
    wszystkich <script> w containerze, klonujemy je (nowy Node) i wstawiamy
    ponownie - to zmusza przegladarke do wykonania JS.
  - Plik: includes/shortcodes/account.php, funkcja loadTabContent()
  - Dotyczy takze innych modulow 1.2.0: vouchery, rezerwacje, skaner QR

* [POPRAWKA] Dodanie dynamicznych zakladek [moje_konto_klubowe]
  - Problem: filtry ks_account_tabs z modulow 1.2.0 (messenger, vouchers,
    bookings, docs-monitor, pzss-registration, referee-zone) byly rejestrowane,
    ale nigdy nie wywolywane - zakladki byly hardcoded w account.php
  - Naprawa: dodano apply_filters('ks_account_tabs', array()) w 3 miejscach:
    * linia 51 - $allowed_tabs rozszerzone o klucze z filtra
    * linia 478+ - dodatkowa petla renderujaca zakladki z modulow
      (obok Dane/Wplaty/Zawody/Rankingi/RODO)
    * linia 483+ - default case w switch renderujacy content z tab['content']
      lub tab['callback']
  - Dodano te same zmiany w AJAX handler ks_ajax_load_account_tab
    (obsluga lazy loading zakladek z modulow)
  - Efekt: po aktywacji modulow pojawiaja sie zakladki:
    * 💬 Komunikator (messenger)
    * 🎁 Moje vouchery (vouchers)
    * 📆 Moje rezerwacje (bookings)
    * 📅 Moje dokumenty (docs-monitor)
    * 🏅 Moje zapisy PZSS (pzss-registration)
    * 👨‍⚖️ Moje sedziowanie (referee-zone, tylko dla sedziow)
    * 📄 Wnioski PZSS (pzss-forms)

* [POPRAWKA] Usuniecie karty "Generator wnioskow PZSS" z panelu Moduly
  - Po przeniesieniu funkcjonalnosci do systemu Dokumentow, karta w Modulach
    byla pusta i mylaca (nie mialo sensu ja wlaczac/wylaczac)
  - Usunieto wpis 'pzss_forms' z KS_Features::registry()
  - Modul pzss-forms jest teraz ladowany ZAWSZE w soks.php (poza loader
    feature flags), bo funkcjonalnosc wbudowana jest w system Dokumentow
  - Migracje tabeli wp_ks_pzss_forms_history uruchamiaja sie przy pierwszym
    zaladowaniu wtyczki (warunek get_option('ks_pzss_forms_db_version'))
  - Usunieto warunek is_enabled('pzss_forms') z:
    * renewal-button.php (przycisk self-service zawsze aktywny)
    * starts-history.php (funkcja eligibility zawsze dostepna)
  - Dokumenty generujemy w jednym miejscu: panel klubu -> Dokumenty

* [UPROSZCZENIE] Modul 3: Usuniecie duplikatu generatora wnioskow
  - Panel "SOKS -> 📄 Wnioski PZSS" zostal usuniety (admin.php modulu)
    Powód: duplikowal funkcjonalnosc istniejacego "Dokumenty" (panel klubu)
    + zawieral 3 niepoprawne typy wnioskow (licencja nowa/sedziowska/klasa
    sportowa) ktore nie byly 1:1 z oficjalnymi wzorami PZSS
  - Usuniete pliki:
    * includes/features/pzss-forms/admin.php
    * includes/features/pzss-forms/templates/licencja-zawodnicza.php
  - Generator wnioskow o przedluzenie przeniesiony do istniejacego
    systemu Dokumentow (SOKS 1.0):
    * dokumenty/szablones/Wniosek_Przedluzenie_Licencji_PZSS/
    * config.json + Wniosek_Przedluzenie_Licencji_PZSS.html
    * pzss-logo.png (kopia z features/pzss-forms/assets/)
  - Dodano filter 'ks_dokument_prepare_data' w ks-modul-dokumenty.php
    (linia ~1407, przed wywolaniem process_template())
  - Nowy plik includes/features/pzss-forms/dokumenty-hook.php
    rejestruje handler dla filtra, ktory wstrzykuje custom placeholdery:
    * {{pzss_logo_path}} - absolutna sciezka do loga
    * {{checkbox_pistolet}} / karabin / strzelba - "X" lub " "
    * {{pesel_kratki}} - HTML 11 spanow z cyframi PESEL
    * {{rok_startow_dwucyfrowy}} - np. "26"
    * {{tabela_startow_pzss}} - HTML 8 wierszy <tr> z historia startow
  - ks_pzss_forms_types() zredukowany do 1 typu (tylko przedluzenie)
  - Dzieki temu:
    * Admin klubu generuje wniosek z panelu klubu -> Dokumenty
      (wspolna numeracja z innymi dokumentami WPL/{nr}/{rok})
    * Czlonek generuje dla siebie z Moje Konto -> Moje Zawody (przycisk)
    * Jeden szablon HTML, jeden silnik PDF, jedno miejsce

* [ROZSZERZENIE] Modul 3: Szablon PDF przedluzenia licencji 1:1 z wzoru PZSS
  - Pobrano oficjalne logo PZSS (200x200 PNG, pzss.org.pl)
    -> assets/pzss-logo.png (35KB)
  - Przepisano szablon przedluzenie-licencji.php wiernie wedlug oryginalu:
    * Naglowek "UWAGA! Wniosek nalezy skladac wylacznie do kierownika klubu" (kursywa, prawy gorny rog)
    * Logo PZSS po lewej + tytul "W N I O S E K" (rozstrzelone litery) po prawej
    * Podstawa prawna art. 13 ust.1 pkt. 2 ustawy o sporcie z 25 czerwca 2010
    * 3 checkboxy: pistolet / karabin / strzelba gladkolufowa z auto-zaznaczeniem
      z licencji zawodnika
    * Nr patentu strzeleckiego + Nr posiadanej licencji w obramowanych polach
    * PESEL rozbity na 11 kratek (auto-decrypt z modulu RODO)
    * Numer telefonu w obramowanym polu
    * Nazwisko i imie w obramowanym polu
    * Paragraf "Oswiadczam, ze zgodnie z regulaminem..." + "w 20__ roku"
      (ostatnie 2 cyfry roku startow auto-wpisane)
    * Tabela 8 wierszy z kolumnami: Lp | Nazwa zawodow | Data | Miejsce zawodow |
      pistolet | karabin | strzelba | Zawody w kalendarzu WZSS lub PZSS | Uwagi
      (nagłowki dyscyplin WRITTEN VERTICALLY, kursywa)
    * Auto-detekcja typu broni per start -> wypelnia odpowiednia kolumne X
    * Auto-detekcja zawodow w kalendarzu (ranga zawiera PZSS/WZSS/krajowe/
      mistrz/puchar) -> wypelnia kolumne X
    * Sortowanie startow po dacie, ograniczenie do 8 (zgodnie z oryginalem)
    * Info-notka gdy zawodnik ma > 8 startow (pelny wykaz w ewidencji klubu)
    * Oswiadczenie o niekaralnosci dyscyplinarnej
    * Oswiadczenie RODO (stara wersja art. 7 pkt 5 z 1997 - jak w oryginale PZSS)
    * Podpis wnioskodawcy z auto-wypelnieniem pola "miejscowosc, data"
      (z adres_miasto + data biezaca)
    * Linia pozioma separator
    * Blok "POTWIERDZENIE kierownika klubu:" (bold)
    * Podpis kierownika klubu z miejscowosc klubu
  - Generator.php: dodano _id, nr_patentu, nr_licencji do collect_user_data()
  - mPDF skonfigurowany do ladowania lokalnego logo z KS_PLUGIN_DIR

* [POPRAWKA] Modul 3: Grupowanie startow po zawodach (szablon przedluzenia)
  - Wczesniej: kazdy start = osobny wiersz (konflikt z wzorem PZSS)
  - Teraz: jeden wiersz = jedno wydarzenie (zgodnie z rzeczywista praktyka)
  - Grupowanie po kluczu: data + nazwa_zawodow + miejsce_zawodow
  - Kolumny pistolet/karabin/strzelba: LICZBA konkurencji w tej dyscyplinie
    (np. jesli zawodnik w Pucharze Polski wystrzelil w PSP30, PCZ3+10, PD, PSt
    to w kolumnie 'pistolet' bedzie '4')
  - Kolumna Uwagi: skroty nazw konkurencji oddzielone przecinkami
    (PSP30, PCZ3+10, PD, PSt)
  - Funkcja pomocnicza short_konkurencja() wyciaga skrot:
    * Usuwa sufiks " cz. dokladna" / " cz. szybka"
    * Usuwa nawiasy
    * Wyciaga pierwszy ciag duzych liter/cyfr/plusow
    * Fallback: pierwszy wyraz
  - Ograniczenie do 8 wierszy dotyczy teraz ZAWODOW a nie startow
  - Notka gdy >8 zawodow: "X zawodow (Y startow lacznie)" w ewidencji klubu
  - Deduplikacja skrotow konkurencji w obrebie jednych zawodow

* [POPRAWKA] Modul 3: Starty klubowe/towarzyskie NIE licza sie do wniosku PZSS
  - Nowa funkcja ks_pzss_forms_is_in_pzss_kalendarz($ranga)
  - Zawody klubowe, towarzyskie i bez rangi sa odfiltrowane z:
    * ks_pzss_forms_count_starts_by_discipline() - liczenie do eligibility
    * ks_pzss_forms_generate_renewal() - dane przekazywane do szablonu PDF
    * format_ranga() w szablonie - usunieto fallback dla skrotow 2-5 liter
  - Zgodnosc z oryginalem PZSS: "bralem(am) udzial w nastepujacych zawodach
    zgloszonych do kalendarza WZSS lub PZSS"
  - W efekcie: tabela startow w PDF i tabela wymagan w panelu czlonka licza
    TYLKO zawody rangi PZSS/WZSS/krajowe/wojewodzkie/mistrzostwa/puchar
  - Funkcja format_ranga() uproszczona: zwraca tylko "PZSS" lub "WZSS" lub ""

* [POPRAWKA] Modul 3: Kolumna "Zawody w kalendarzu WZSS lub PZSS"
  - Wczesniej: X (ogolne zaznaczenie)
  - Teraz: faktyczna ranga (PZSS / WZSS)
  - Funkcja format_ranga() normalizuje wartosc z bazy:
    * "PZSS", "Puchar Polski", "Mistrzostwa Polski", "krajowe" -> "PZSS"
    * "WZSS", "Wojewodzkie" -> "WZSS"
    * Gotowe skroty 2-5 duzych liter (np. "MWP") -> zostawione
    * Klubowe / towarzyskie / brak rangi -> puste
  - Styl: 10pt bold uppercase w waskiej kolumnie (jak w oryginale PZSS)

* [ROZSZERZENIE] Modul 3: Wniosek o PRZEDLUZENIE licencji z historii zawodow
  - Nowy plik: starts-history.php - agreguje starty z 3 zrodel:
    * wp_ks_zawody_wyniki (zawody klubowe)
    * wp_ks_zawody_wyniki_szczegoly + wyniki_html (import Practiscore)
    * wp_ks_zawody_starty_obce (starty obce - tylko status=approved)
    * Deduplikacja po md5(data + nazwa + konkurencja)
  - Funkcja ks_pzss_forms_check_renewal_eligibility() - walidacja 4+2+2:
    * 1 typ licencji: 4 w wiodacej
    * 2 typy: 4 w wiodacej + 2 w drugiej
    * 3 typy: 4 + 2 + 2 (min 8 lacznie)
  - Funkcja ks_pzss_forms_renewal_button_status() - 3 stany przycisku:
    * before_nov1: nieaktywny do 1 listopada biezacego roku
    * requirements_not_met: nieaktywny gdy brak startow
    * ok: aktywny (gradient zielony)
  - Nowy plik renewal-button.php z handlerem POST + security re-check
  - Nowy szablon PDF przedluzenie-licencji.php z tabela historii startow
    (data, zawody, miejsce, ranga, konkurencja, dyscyplina, miejsce)
    + tabela podsumowania spelnienia wymogu per dyscyplina
  - Integracja w account.php (zakladka "Moje Zawody") - przycisk POD tabelka
    "Starty wymagane do utrzymania licencji w kolejnym roku"
  - Bezpieczny fallback: gdy modul pzss_forms wylaczony, przycisk sie nie
    pojawia (nie lamie layoutu)

* [NOWE] Modul 3: Generator wnioskow PZSS (features/pzss-forms/)
  - Tabela: wp_ks_pzss_forms_history (historia z data_snapshot)
  - 4 typy wnioskow:
    * Licencja zawodnicza PZSS
    * Przedluzenie licencji
    * Licencja sedziowska
    * Klasa sportowa
  - 12 dyscyplin PZSS (pistolet sportowy, karabin, strzelba, dynamiczne, czarnoproch, ...)
  - Silnik PDF bazujacy na mPDF (juz obecnym w dokumenty/vendor/)
  - Auto-pobieranie danych z metadanych uzytkownika (first_name, pesel, adres, etc.)
  - Auto-deszyfrowanie PESEL z modulu RODO (jesli aktywny)
  - Zapis PDF w uploads/soks-pzss-forms/ z .htaccess blokujacym listing
  - Integracja z audytem RODO (ks_rodo_log_access)
  - Panel admina z 3 zakladkami:
    * Generuj pojedynczy - formularz z wyborem dyscyplin
    * Generowanie zbiorcze - dla zaznaczonych czlonkow jednoczesnie
    * Historia - lista wszystkich wygenerowanych wnioskow z linkami PDF
  - Shortcode [ks_pzss_form_button] - self-service dla czlonka
  - Integracja z [moje_konto_klubowe] - zakladka "Wnioski PZSS"
  - Szablon HTML edytowalny: includes/features/pzss-forms/templates/

* [NOWE] Modul 4: Bramka SMS (features/sms-gateway/)
  - Tabele: wp_ks_sms_log (audyt + koszty), wp_ks_sms_templates (szablony)
  - 3 providery: SerwerSMS.pl, SMSAPI.pl, SMSPlanet.pl (stub)
  - Klasa bazowa KS_SMS_Provider z normalize_phone() i estimate_parts()
  - Filter ks_sms_send - inne moduly SOKS moga wysylac przez apply_filters
  - Dziala z Modulem 1 (docs_monitor) - przypomnienia o dokumentach tez jako SMS
  - Dzienny limit SMS jako zabezpieczenie przed wyczerpaniem kredytu
  - 5 domyslnych szablonow seedowanych przy instalacji:
    * skladka_przypomnienie, zawody_odwolane, dokument_wygasa,
      rezerwacja_potwierdzenie, kod_2fa
  - Panel admina 4 zakladki: Ustawienia, Szablony, Log wysylek, Test SMS
  - Statystyki w logu: liczba wyslanych, dzisiaj, laczny koszt
  - Przycisk Test SMS do testowania konfiguracji

* [NOWE] Modul 5: Live scoring (features/live-scoring/)
  - Tabele: wp_ks_live_sessions, wp_ks_live_stations, wp_ks_live_shots
  - 4 protokoly: SIUS Ascor (XML/JSON), Megalink, Meyton, Recznie
  - Klasa bazowa KS_LiveScoring_Protocol - latwo dodac kolejne (Polytronic, Disag, etc.)
  - REST webhook: POST /wp-json/soks/v1/live/shot?session=ID z naglowkiem X-KS-Live-Token
  - REST results: GET /wp-json/soks/v1/live/session/{id}/results (JSON, publiczne)
  - Panel admina: Sesje, Nowa sesja (host/port bridge), Live view z auto-refresh 3s
  - Reczny tryb wprowadzania strzalow (dla klubow bez tarcz elektronicznych)
  - Automatyczne liczenie serii (co 10 strzalow = nowa seria) zgodnie z ISSF
  - Publiczny shortcode [ks_live_results session="X"] do embedu na stronie zawodow
  - Sortowanie wg sumy dziesietnej + liczba inner 10x (tiebreaker ISSF)

* [NOWE] Modul 10: Rejestracja na zawody rangi PZSS (features/pzss-registration/)
  - Tabele: wp_ks_pzss_events, wp_ks_pzss_entries
  - CRUD wydarzen: nazwa, ranga (PZSS/WZSS), okno rejestracji datetime,
    max_entries, konkurencje (slug|nazwa), oplaty (slug|kwota)
  - Walidacja 4-poziomowa przy zapisie:
    1. Event published & w oknie czasowym (registration_opens <= now <= closes)
    2. Limit uczestnikow nieprzekroczony
    3. Aktywna licencja PZSS (jesli wymagana)
    4. Anty-duplikat (jeden zawodnik = jeden zapis per event)
  - Unikalny kod zgloszenia: ENT-XXXXXXXX
  - Auto-kalkulacja oplaty z wybranych konkurencji
  - Eksport XML listy startowej (format uproszczony SOZ PZSS):
    Event + Participants + Competitions z PESEL (auto-decrypt RODO)
  - Panel admina: Wydarzenia (edycja) + Zgloszenia (zmiana statusu + platnosc)
  - Shortcody: [ks_pzss_events] (lista otwartych), [ks_pzss_register event=X]
    (formularz), [ks_moje_starty_pzss] (moje zgloszenia)
  - E-mail potwierdzajacy zapis (do zawodnika + admina)
  - Integracja z [moje_konto_klubowe] - zakladka "Moje zapisy PZSS"
  - Statusy: pending/confirmed/rejected/cancelled + payment: unpaid/paid/refunded/waived

* [NOWE] Modul 9: Strefa sedziego (features/referee-zone/)
  - Tabele: wp_ks_referees (profile), wp_ks_referee_assignments (przydzialy),
    wp_ks_referee_payments (wyplaty), wp_ks_referee_evaluations (oceny)
  - 4 klasy licencji sedziowskiej: klubowy, okregowy, zwiazkowy, miedzynarodowy
  - 6 rol: sedzia glowny, zastepca, sedzia, sekretariat, obserwator, prowadzacy
  - Stawki per sedzia: godzinowa + dzienna (wybor typu przy przydziale)
  - Auto-kalkulacja amount_due = rate_type === 'daily' ? rate : rate * hours
  - Statusy przydzialu: planned/confirmed/completed/cancelled/no_show
  - Panel admina 5 zakladek:
    * Sedziowie (CRUD profili z licencja i stawkami)
    * Przydzialy (lista z filtrem, edycja z powiazaniem zawody_id)
    * Wyplaty (historia + nowa wyplata z powiazaniem przydzialu)
    * Ewaluacje (4-wymiarowa ocena 1-5: ogolna, punktualnosc, wiedza, prof)
    * Statystyki (aktywnych sedziow, naliczone/wyplacone/do_wyplaty, srednia ocen)
  - Shortcode [ks_moje_przydzialy] - panel sedziego:
    * Bilans: naliczone/wyplacone/do_wyplaty (gradient boxes)
    * Lista przydzialow z ostatnich 12 miesiecy
  - Auto-widoczna zakladka "Moje sedziowanie" w [moje_konto_klubowe]
    gdy user jest sedzia (filter ks_account_tabs)

* [NOWE] Modul 7: Role admina kanalu (delegacja zarzadzania)
  - 3 role per kanal (kolumna role w wp_ks_msg_members):
    * creator - tworca kanalu (pelne uprawnienia, nie mozna usunac)
    * admin - moze dodawac/usuwac/promowac czlonkow kanalu
    * member - zwykly uczestnik
  - Funkcja ks_messenger_user_can_manage_channel($user, $channel):
    * TRUE dla cap 'manage_ks' (admin WP)
    * TRUE dla role='creator' lub role='admin' w tym kanale
  - AJAX endpoint ks_msg_channel_manage z operacjami:
    * list - lista czlonkow z rolami (kazdy moze widziec)
    * search_users - wyszukiwanie userow do dodania (tylko admin)
    * add - dodaj czlonka (tylko admin kanalu)
    * remove - usun czlonka (tylko admin, nie mozna usunac tworcy, nie siebie)
    * promote - nadaj role admina (tylko admin)
    * demote - odbierz role admina (tylko admin)
  - UI we frontendzie (shortcode [ks_messenger]):
    * Ikona 👥 obok kanalu widoczna TYLKO dla admina kanalu
    * Modal "Zarzadzanie czlonkami" z lista i akcjami
    * Wyszukiwarka z debounce 250ms dla kanalow prywatnych
    * Odznaki roli: 👑 tworca (zolto), ⚙️ admin (niebiesko), czlonek (szaro)
    * Dodatkowy badge "auto-sekcja" dla userow z kanalow sekcyjnych
  - Automatyczne nadanie roli 'creator' tworcy kanalu
    (wstawka do ks_msg_members przy INSERT do ks_msg_channels)
  - Panel admina WP rozszerzony:
    * Kolumna "Rola" w liscie czlonkow z dropdownem zmiany (member/admin)
    * Legenda kolorow rol
    * Tworca (creator) chroniony - "(chroniony)" zamiast przycisku Usun
  - Rozszerzenie get_channels: flaga can_manage w JSON
  - Zabezpieczenia:
    * Admin kanalu nie moze usunac siebie (musi najpierw zdegradowac)
    * Tylko cap 'manage_ks' moze usunac/zdegradowac tworce
    * Kanaly publiczne - brak mozliwosci zarzadzania czlonkami

* [NOWE] Modul 7: Powiadomienia email/SMS dla komunikatora
  - Nowy plik includes/features/messenger/notifications.php
  - Rozszerzenie tabeli wp_ks_msg_members o kolumny:
    * notification_mode (instant/digest/off)
    * last_notified_at (throttle)
    * digest_queue (JSON lista message_id)
  - 3 tryby powiadomien per uzytkownik per kanal:
    * instant - natychmiast (email + SMS jesli wlaczony)
    * digest - codzienny email-digest o 07:00 (cron)
    * off - brak powiadomien
    * mute - wyciszenie ale dalej widzi w interfejsie
  - Specjalna obsluga @wzmianek (@user_login lub @display_name):
    * ZAWSZE natychmiastowe powiadomienie dla wzmiankowanego
    * Czerwony nagłowek emaila (vs niebieski dla zwyklych)
    * Niezaleznie od trybu uzytkownika (nawet off)
  - Throttling: domyslnie 15 min miedzy notifikacjami z tego samego kanalu
    (chroni przed spamem gdy ktos pisze szybko kilka wiadomosci pod rzad)
  - Autor wiadomosci nie dostaje powiadomienia o swojej wiadomosci
  - Pominiecie uzytkownikow wyciszonych (muted=1)
  - Hook ks_msg_posted uruchamiany po zapisie wiadomosci
  - AJAX endpoint ks_msg_set_notify - zmiana trybu per kanal
  - Frontend: ikona 🔔/📬/🔕/🔇 obok kanalu + menu wyboru trybu
    (dropdown pokazujacy sie po kliknieciu)
  - Szablon email z brandingiem SOKS, preview tresci, przycisk "Otwórz komunikator"
  - Szablon SMS (skrocony): "SOKS: Nowa wiadomosc w kanale X od Y: [pierwsze 80 znakow]"
  - Cron ks_messenger_digest_daily - raz dziennie o 07:00 wysyla digest
  - Zbiorczy email z pogrupowaniem per kanal i liczba wiadomosci w naglowku
  - Integracja z modulem sms_gateway przez filter ks_sms_send
  - Konfiguracja w panelu admina SOKS -> Komunikator -> Ustawienia

* [POPRAWKA] Modul 7: Dodanie panelu admina komunikatora
  - Przycisk "Ustawienia" w karcie modulu zwracal "Brak uprawnien dostepu"
    (brak zarejestrowanej strony ks-module-messenger)
  - Dodano includes/features/messenger/admin.php z 4 zakladkami:
    * Kanaly - CRUD (publiczne/sekcyjne/prywatne) z licznikami czlonkow+wiadomosci
    * Czlonkowie - przypisywanie userow do kanalow prywatnych (multi-select)
    * Ustawienia - max dlugosc wiadomosci, rate limit, poll interval, zalaczniki
    * Statystyki - liczba kanalow/wiadomosci/7d/aktywnych userow + top kanaly
  - Podpięcie do init.php modulu
  - Submenu SOKS -> 💬 Komunikator

* [NOWE] Modul 8: Vouchery / karnety prezentowe z QR (features/vouchers/)
  - Tabele: wp_ks_vouchers_products (typy), wp_ks_vouchers (kody),
    wp_ks_vouchers_redemptions (historia realizacji)
  - 4 domyslne typy voucherow seedowane: wejscie 1x, pakiet 5, pakiet 10, karnet roczny
  - Generowanie unikalnych kodow format SOKS-VCR-XXXX-YYYY-ZZZZ
    (alfabet bez mylacych 0/O, 1/I/L, 20 prob na unikalnosc)
  - QR code generator: api.qrserver.com z cachem w uploads/soks-vouchers/qr/
  - PDF voucher A5 poziomy: logo klubu, QR 180x180, regulamin, dedykacja,
    wiadomosc prezentowa, stopka klubu
  - Panel admina 5 zakladek:
    * Lista voucherow (filtr po statusie, widok detali)
    * Wystaw voucher (pojedynczo, z opcja "dla kogo")
    * Skaner QR (html5-qrcode z CDN, kamera urzadzenia mobilnego)
    * Typy voucherow (CRUD z kolorem i powiazaniem WooCommerce)
    * Statystyki (liczby + przychod)
  - Walidacja realizacji 5-poziomowa:
    1. Voucher istnieje
    2. Status != cancelled
    3. Status != expired (auto-update jesli data minela)
    4. Data valid_from <= dzis <= valid_until
    5. entries_used < entries_total (pozostale wejscia)
  - Integracja WooCommerce (woocommerce.php):
    * Hook na woocommerce_order_status_completed/processing
    * Mapowanie woo_product_id → ks_vouchers_products.id
    * Auto-generowanie voucherow per quantity
    * E-mail z PDF do nabywcy
    * Anty-duplikat po woo_order_id
  - Shortcode [ks_moje_vouchery] - panel klubowicza z voucherami
    (zakupione + otrzymane jako prezent po recipient_email)
  - Shortcode [ks_voucher_check] - publiczny sprawdzacz statusu
  - Cron ks_vouchers_daily_expire - codziennie 03:00 oznacza wygasle
  - Integracja z [moje_konto_klubowe] - zakladka "Moje vouchery"

* [NOWE] Modul 6: Generator drabinek (features/bracket-gen/)
  - Tabele: wp_ks_brackets, wp_ks_bracket_seeds, wp_ks_bracket_matches
  - 4 formaty: pojedyncza eliminacja, podwojna, fina ISSF (8), kazdy z kazdym
  - Wielkosci: 4, 8, 16, 32, 64 zawodnikow (potegi 2)
  - Algorytm rozstawienia fold-style (1v8, 4v5, 3v6, 2v7 przy 8 seed)
  - Auto-promocja zwyciezcy do nastepnego meczu (next_match_id)
  - Panel admina: lista / nowa / zawodnicy (seeding) / widok drabinki
  - Widok drabinki - responsywny grid z kolumnami per runda
  - Zapis wynikow inline przy meczu (A vs B score)
  - Auto-naming rund (Cwiercfinal / Polfinal / Final zaleznie od wielkosci)
  - Wyswietlanie zwyciezcy z zlotym tlem gradient
  - Publiczny shortcode [ks_bracket id="X"] do embedu

== 1.1.0 (2026-04-13) ==

* [NOWE] Modul RODO compliance (includes/rodo-compliance.php)
  - Szyfrowanie PESEL (AES-256-CBC) z kluczem z wp-config.php
  - Maskowanie PESEL w wyswietlaniu (12345******) z przyciskiem "Pokaz" i logowaniem dostepu
  - Automatyczne usuwanie zdjec zawodnikow z dysku przy kasowaniu konta (hook delete_user)
  - Anonimizacja danych osobowych (Art. 17 RODO - prawo do zapomnienia)
  - Polityka retencji z automatycznym czyszczeniem (cron WP daily)
  - Audit log dostepu do danych wrazliwych (tabela wp_ks_rodo_audit_log)
  - Self-service eksport danych dla czlonka (Art. 15/20 RODO, format JSON)
  - Wycofanie zgody RODO z panelu czlonka z powiadomieniem admina
  - Shortcode [ks_rodo_panel] do osadzenia panelu RODO
  - Migracja istniejacych PESELi do postaci zaszyfrowanej (AJAX)

* [NOWE] Zakladka "RODO" w panelu konta czlonka ([moje_konto_klubowe])
  - Przycisk "Pobierz moje dane" (eksport JSON)
  - Lista zgod z mozliwoscia wycofania
  - Informacja o prawie do usuniecia danych

* [ULEPSZONE] Formularz email do czlonkow (lista czlonkow - frontend)
  - Picker odbiorcow z wyszukiwaniem live (AJAX) - nie gubi zaznaczen po filtrowaniu
  - Mozliwosc recznego wpisywania adresu email
  - Stan odbiorcow zapisywany w sessionStorage (przetrwa przeladowanie strony)
  - Synchronizacja checkboxow w tabeli z lista odbiorcow
  - Usuniety przycisk "Dodaj medium" z edytora (media_buttons=false)
  - Przerobione zalaczniki: jeden przycisk "Dodaj zalacznik", kumulatywne dodawanie plikow
  - Walidacja plikow po stronie JS (rozmiar, format, duplikaty)
  - Obsluga wielu plikow z lista i mozliwoscia usuwania

* [ULEPSZONE] Maskowanie PESEL we wszystkich widokach
  - Lista czlonkow (frontend shortcode)
  - Panel admina (members-admin.php)
  - Shortcode [ks_czlonkowie]

* [NOWE] AJAX endpoint ks_search_members_for_email
  - Wyszukiwanie czlonkow po imieniu, nazwisku, emailu
  - Wykluczanie gosci, max 10 wynikow
  - Weryfikacja nonce i uprawnien

== 1.0.3 ==
* Wersja bazowa
