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

== 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ę (case #6393:
  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_6393.php (root WP): ręczne zaksięgowanie transakcji
  P24 4592637579 dla #6393 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
