diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..9b1fc3531 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,11 @@ +# Agent Instructions Pointer + +Tento soubor je záměrně stručný. + +Veškerá pravidla a instrukce jsou v `AGENTS.md` v rootu repozitáře. + +## Povinný postup + +1. Nejdřív načti `AGENTS.md`. +2. Řiď se výhradně pravidly z `AGENTS.md`. +3. Pokud je zde cokoliv v konfliktu s `AGENTS.md`, přednost má `AGENTS.md`. diff --git a/.gitignore b/.gitignore index e4ce00936..c55197d91 100644 --- a/.gitignore +++ b/.gitignore @@ -185,5 +185,5 @@ venv.bak/ # mypy .mypy_cache/ -# Snyk Security Extension - AI Rules (auto-generated) -.cursor/rules/snyk_rules.mdc +# cursor settings +.cursor/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 000000000..9131bf3a8 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "puppeteer": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-puppeteer"] + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..9b5100205 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,162 @@ +# AGENTS.md + +Tento soubor poskytuje instrukce AI agentům při práci s kódem v tomto repozitáři. + +## Příkazy + +### Lokální spuštění (od nuly) + +```bash +# 1. Zkopíruj a vyplň env proměnné (DATABASE_URL, SECRET_KEY, GPR_TOKEN, ...) +cp .env.template .env + +# 2. Nainstaluj závislosti +pipenv install --dev # Python závislosti +npm ci # Node závislosti (postinstall hook automaticky nainstaluje i frontend a buildne ho) + +# 3. (Pouze při prvním spuštění native režimu) vytvoř DB kontejner postgresql_cz +source scripts/shell/postgresql_docker.sh + +# 4. Spusť databázi a servery +make db # PostgreSQL v Docker kontejneru +make be # Django dev server na 0.0.0.0:8000 +make fe # Webpack dev server na http://localhost:3000 +``` + +**Alternativa přes Docker Compose** (spustí vše včetně DB): +```bash +docker compose up +docker compose run web python manage.py createsuperuser +``` + +> **FontAwesome PRO:** ikony jsou z private GitHub Package Registry — `GPR_TOKEN` v `.env` musí být nastaven i pro lokální vývoj (jinak `npm ci` selže). + +### Backend (Python / Django) + +```bash +# Testy a kvalita kódu +pipenv run mypy . # typová kontrola +pipenv run python manage.py test # unit testy (Django TestCase) +pipenv run python manage.py behave --stage=api --format=progress3 # E2E API testy (behave) +pipenv run python manage.py behave --stage=ui --format=progress3 # E2E UI testy (behave + Selenium) + +# Coverage (kombinuje všechny testy dohromady) +pipenv run coverage run -a manage.py test +pipenv run coverage run -a manage.py behave --stage=api --format=progress3 +pipenv run coverage run -a manage.py behave --stage=ui --format=progress3 +pipenv run coverage report + +# Migrace +pipenv run python manage.py makemigrations # vytvoří nové migrace po změně modelů +pipenv run python manage.py migrate # aplikuje migrace na DB +``` + +> **PostgreSQL je povinné i pro testy:** před `pipenv run python manage.py test` nebo `behave` nejdřív spusť DB (`make db` v native režimu, nebo `docker compose up` v compose režimu). Bez běžící PostgreSQL backend testy selžou už při inicializaci testovací databáze. + +### Frontend (TypeScript / React) + +```bash +# Testy a kvalita kódu (z rootu repozitáře — doporučeno pro CI paritu) +npm run frontend:test # typy + lint + jest (kompletní frontend CI suite) +npm run frontend:lint:js # pouze ESLint +npm run frontend:audit # security audit závislostí (audit-ci) + +# Detailní příkazy (ze složky frontend/) +cd frontend +npm run types # TypeScript typová kontrola (tsc) +npm run types:watch # tsc ve watch módu +npm run lint # ESLint + Prettier check +npm run lint! # ESLint + Prettier autofix +npm run jest # pouze Jest testy +npm run jest:watch # Jest ve watch módu +npm run build # produkční build (Webpack) +npm run build:analyze # bundle analyzer +``` + +## Architektura + +Systém pro správu lekcí a kurzů — Django REST API backend + React SPA frontend, nasazení na Fly.io. + +**Větev pro vývoj a CI je `master`** (ne `main`). + +### Backend + +Django 6 + Django REST Framework — REST API pro všechny operace. Kód je rozdělen do Django aplikací: + +- [api/](api/) — DRF viewsets, serializéry, filtry, business logika; **zde žije veškerá API logika** +- [admin/](admin/) — modely, Django admin interface, šablony (shell pro SPA) +- [up/](up/) — projekt config: nastavení (`settings/base.py`, `settings/local.py`, `settings/production.py`), hlavní URL routing +- [tests/](tests/) — BDD E2E testy (behave), feature soubory v Gherkin, Selenium UI testy +- [scripts/shell/](scripts/shell/) — shell skripty pro CI (`ci/`) a lokální setup + +**Modely** jsou v [admin/models.py](admin/models.py): klienti, skupiny, kurzy, lekce, přihlášky, stavy docházky. + +**Nastavení:** `up/settings/` — `base.py` je základ, `local.py` a `production.py` ho přetěžují. V CI se používá `production.py` (`DJANGO_SETTINGS_MODULE=up.settings.production`). Lokálně se bere z `.env`. + +**Python konvence:** +- Formátování: Black (`line-length = 100`) +- Typování: mypy — veškerý nový kód musí mít typové anotace, mypy nesmí hlásit chyby +- Dead code: vulture — nepoužívané symboly jsou chybou +- Závislosti: Pipenv (`Pipfile` + `Pipfile.lock`) — nikdy `pip install` přímo + +### Frontend + +React 19 SPA v [frontend/src/](frontend/src/). Webpack dev server na portu 3000 se napojuje na Django přes proxy (HMR). + +**`npm ci` z rootu** nainstaluje root závislosti (Husky) a přes `postinstall` hook automaticky provede `cd frontend && npm ci && npm run build:ci`. Pro vývoj je proto potřeba `npm ci` spustit z rootu, ne ze složky `frontend/`. + +**Klíčové knihovny:** +- Routing: TanStack Router (`frontend/src/router.tsx`, URL konstanty v `frontend/src/APP_URLS.ts`) +- Server state: TanStack Query (React Query) — veškerá komunikace s API +- CSS: vanilla-extract (type-safe CSS-in-JS, soubory `*.css.ts`) +- UI: Reactstrap (Bootstrap 5 wrappery) + FontAwesome PRO ikony +- Fuzzy search: Fuse.js +- Grafy: Recharts + +**Struktura `frontend/src/`:** +- `api/` — API client (axios) a query hooky (TanStack Query) +- `components/` — sdílené React komponenty +- `pages/` — stránkové komponenty (Diary, Dashboard, atd.) +- `forms/` — formulářové komponenty +- `types/` — TypeScript typy sdílené napříč aplikací +- `hooks/` — custom React hooky +- `contexts/` — React Context provídery + +**Frontend konvence:** +- Formátování: Prettier (`tabWidth: 4`, `printWidth: 100`) +- Linting: ESLint 9 s pluginy (react, typescript, jest, testing-library, vanilla-extract, tanstack-query) +- CSS: soubory pojmenovány `*.css.ts`, **vždy** vanilla-extract — nikdy inline styly ani plain CSS +- Testy: Jest + React Testing Library, soubory colocated se zdrojovým kódem (`*.test.ts` / `*.test.tsx`), API mockované přes MSW + +**Pre-commit hooky (Husky + lint-staged):** automaticky spouštějí ESLint a Prettier na staged JS/TS souborech. + +## Ověření změn + +- **Po každé úpravě frontend souborů** (přidání importů, nové komponenty, přeformátování) spusť autofix před kontrolou: + ```bash + cd frontend && npm run lint! + ``` + ESLint + Prettier opraví pořadí importů, zbytečné mezery a další formátovací chyby, které jinak hlásí CI jako errory. +- Čistě frontend změny ověř primárně přes `npm run frontend:test`. +- Backend změny ověř přes `pipenv run mypy .` a relevantní `manage.py test` / `behave`, ale až po spuštění PostgreSQL. +- Změny přesahující backend i frontend ideálně ověř na obou stranách; lokální "green" frontend testy neříkají nic o dostupnosti DB nebo API. + +### Build a nasazení + +**CI** ([`.github/workflows/test.yml`](.github/workflows/test.yml)) se spouští na každý push/PR do `master`: +1. Nainstaluje Node 20 + Python 3.12 + závislosti +2. Vytvoří `.npmrc` pro FontAwesome PRO z private GitHub Package Registry (token `GPR_TOKEN`) +3. Spustí frontend testy (typy + lint + jest) +4. Spustí mypy +5. Nastartuje PostgreSQL 14 s českou locale v Dockeru +6. Buildne Django (migrace + staticfiles) přes `scripts/shell/release_tasks.sh` +7. Django deployment checklist +8. Django unit testy + E2E API testy + E2E UI testy (behave + Selenium/Firefox) +9. Nahraje code coverage do Codecov +10. Nasadí testing verzi na Fly.io (přeskočí pro Dependabot) + +**Deploy** ([`.github/workflows/deploy.yml`](.github/workflows/deploy.yml)) se spouští na git tagy — nasadí produkci na Fly.io a pushne Docker image do ghcr.io. + +**Prostředí:** +- Testing: automaticky nasazeno z `master` +- Produkce: manuálně přes git tag diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2bf357e80 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +@AGENTS.md + +# Agent Instructions Pointer + +Tento soubor je záměrně stručný. + +Veškerá pravidla a instrukce jsou v `AGENTS.md` v rootu repozitáře. + +## Povinný postup + +- Řiď se výhradně pravidly z `AGENTS.md`. +- Pokud je zde cokoliv v konfliktu s `AGENTS.md`, přednost má `AGENTS.md`. diff --git a/api/filters.py b/api/filters.py index 76a2ce1bd..f38a44e44 100644 --- a/api/filters.py +++ b/api/filters.py @@ -13,19 +13,25 @@ class LectureFilter(filters.FilterSet): Filtr lekcí podle startu (date), skupiny (group) a klienta (client). Filtr skupiny je základní. Filtr startu a klienta umožňuje filtrovat jednodušším URL parametrem, než konkrétní cestou k related_field (xx_yy). - Filtr klienta navíc odstraní z výsledku skupinové lekce. + Filtr klienta ve výchozím stavu vrátí jen individuální lekce (group__isnull=True). + Parametr includeGroup=true přidá i skupinové lekce klienta (group__isnull=False). """ date = filters.DateFilter(field_name="start__date") client = filters.NumberFilter(field_name="attendances__client", method="filter_client") + includeGroup = filters.BooleanFilter(method="filter_include_group") def filter_client(self, queryset: QuerySet, name: str, value: int) -> QuerySet: - """ - Filtr podle klienta, kde name je aktuální filtrované pole (klient), - value je jeho hodnota (ID klienta). - """ - # aby bylo mozne rozsirit filtr na group__isnull, sami si praci s filtrem nad querysetem obstarame - return queryset.filter(**{name: value}, group__isnull=True) + # parametr includeGroup se zpracovava spolecne s filtrem client + include_group = self.form.cleaned_data.get("includeGroup") + q = queryset.filter(**{name: value}) + if not include_group: + q = q.filter(group__isnull=True) + return q + + def filter_include_group(self, queryset: QuerySet, name: str, value: bool) -> QuerySet: + # samostatny includeGroup (bez client) nema efekt; zpracovani probiha ve filter_client + return queryset class Meta: model = Lecture @@ -40,28 +46,22 @@ class GroupFilter(filters.FilterSet): Parametr onlyPast=true vrátí jen skupiny, které klient opustil (má tam účast na lekci, ale již není členem). """ - client = filters.NumberFilter() - onlyPast = filters.BooleanFilter() + client = filters.NumberFilter(method="filter_client") + onlyPast = filters.BooleanFilter(method="filter_only_past") - def filter_queryset(self, queryset: QuerySet) -> QuerySet: - client_id = self.form.cleaned_data.get("client") + def filter_client(self, queryset: QuerySet, name: str, value: int) -> QuerySet: + # parametr onlyPast se zpracovava spolecne s filtrem client only_past = self.form.cleaned_data.get("onlyPast") - active = self.form.cleaned_data.get("active") - - if client_id is not None: - if only_past: - queryset = ( - queryset - .filter(lectures__attendances__client=client_id) - .exclude(memberships__client__pk=client_id) - .distinct() - ) - else: - queryset = queryset.filter(memberships__client__pk=client_id) - - if active is not None: - queryset = queryset.filter(active=active) + if only_past: + return ( + queryset.filter(lectures__attendances__client=value) + .exclude(memberships__client__pk=value) + .distinct() + ) + return queryset.filter(memberships__client__pk=value) + def filter_only_past(self, queryset: QuerySet, name: str, value: bool) -> QuerySet: + # samostatny onlyPast (bez client) nema efekt; filtrovani probiha ve filter_client return queryset class Meta: diff --git a/api/serializers.py b/api/serializers.py index b12335f4e..02e6650a1 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -51,6 +51,8 @@ class ClientSerializer(serializers.ModelSerializer[Client]): Serializer pro klienta lektorky. """ + last_lecture_date = serializers.DateTimeField(read_only=True, allow_null=True, default=None) + class Meta: model = Client fields = "__all__" @@ -147,6 +149,7 @@ class GroupSerializer(ValidateCourseIdMixin, serializers.ModelSerializer[Group]) Serializer skupiny klientů nějakého kurzu. """ + last_lecture_date = serializers.DateTimeField(read_only=True, allow_null=True, default=None) # nazev skupiny (znovuuvedeni kvuli validaci unikatnosti) name = serializers.CharField( validators=[UniqueValidator(queryset=Group.objects.all())], diff --git a/api/tests/helpers.py b/api/tests/helpers.py new file mode 100644 index 000000000..7da694022 --- /dev/null +++ b/api/tests/helpers.py @@ -0,0 +1,2 @@ +def ids(response_data: list[dict]) -> set[int]: + return {item["id"] for item in response_data} diff --git a/api/tests/test_bulk_deactivate.py b/api/tests/test_bulk_deactivate.py new file mode 100644 index 000000000..673961f45 --- /dev/null +++ b/api/tests/test_bulk_deactivate.py @@ -0,0 +1,100 @@ +""" +Testy hromadné deaktivace klientů a skupin. +""" + +from django.contrib.auth import get_user_model +from django.test import TestCase +from rest_framework.test import APIClient + +from admin.models import Client, Course, Group + + +class BulkDeactivateClientsTest(TestCase): + """PATCH /api/v1/clients/deactivate-bulk/ deaktivuje vybrané klienty.""" + + def setUp(self) -> None: + user = get_user_model().objects.create_user( + username="clients-deactivate-bulk-test", + email="clients-deactivate-bulk-test@test.cz", + password="test-password", + ) + self.api = APIClient() + self.api.force_authenticate(user=user) + + self.client_target_1 = Client.objects.create(firstname="Alice", surname="T", active=True) + self.client_target_2 = Client.objects.create(firstname="Bob", surname="T", active=True) + self.client_untouched = Client.objects.create(firstname="Carol", surname="T", active=True) + + def test_deactivate_bulk_marks_selected_clients_inactive(self) -> None: + response = self.api.patch( + "/api/v1/clients/deactivate-bulk/", + {"ids": [self.client_target_1.pk, self.client_target_2.pk]}, + format="json", + secure=True, + ) + + self.assertEqual(response.status_code, 204) + + self.client_target_1.refresh_from_db() + self.client_target_2.refresh_from_db() + self.client_untouched.refresh_from_db() + self.assertFalse(self.client_target_1.active) + self.assertFalse(self.client_target_2.active) + self.assertTrue(self.client_untouched.active) + + def test_deactivate_bulk_with_non_list_ids_returns_400(self) -> None: + response = self.api.patch( + "/api/v1/clients/deactivate-bulk/", + {"ids": "not-a-list"}, + format="json", + secure=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"ids": "Musí být seznam."}) + + +class BulkDeactivateGroupsTest(TestCase): + """PATCH /api/v1/groups/deactivate-bulk/ deaktivuje vybrané skupiny.""" + + def setUp(self) -> None: + user = get_user_model().objects.create_user( + username="groups-deactivate-bulk-test", + email="groups-deactivate-bulk-test@test.cz", + password="test-password", + ) + self.api = APIClient() + self.api.force_authenticate(user=user) + + course = Course.objects.create(name="Test", duration=60) + self.group_target_1 = Group.objects.create(name="Alpha", course=course, active=True) + self.group_target_2 = Group.objects.create(name="Beta", course=course, active=True) + self.group_untouched = Group.objects.create(name="Gamma", course=course, active=True) + + def test_deactivate_bulk_marks_selected_groups_inactive(self) -> None: + response = self.api.patch( + "/api/v1/groups/deactivate-bulk/", + {"ids": [self.group_target_1.pk, self.group_target_2.pk]}, + format="json", + secure=True, + ) + + self.assertEqual(response.status_code, 204) + + self.group_target_1.refresh_from_db() + self.group_target_2.refresh_from_db() + self.group_untouched.refresh_from_db() + self.assertFalse(self.group_target_1.active) + self.assertFalse(self.group_target_2.active) + self.assertTrue(self.group_untouched.active) + + def test_deactivate_bulk_with_non_list_ids_returns_400(self) -> None: + response = self.api.patch( + "/api/v1/groups/deactivate-bulk/", + {"ids": "not-a-list"}, + format="json", + secure=True, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json(), {"ids": "Musí být seznam."}) diff --git a/api/tests/test_groups_filter.py b/api/tests/test_groups_filter.py new file mode 100644 index 000000000..7e68c0766 --- /dev/null +++ b/api/tests/test_groups_filter.py @@ -0,0 +1,112 @@ +""" +Testy filtru skupin (client, onlyPast, active) endpointu /api/v1/groups/. +""" + +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils.timezone import make_aware +from rest_framework.test import APIClient + +from admin.models import Attendance, AttendanceState, Client, Course, Group, Lecture, Membership + +from .helpers import ids as _ids + + +class GroupClientFilterTest(TestCase): + """ + Filtr `client` vrací skupiny, kde je klient aktuálně členem. + Filtr `client` + `onlyPast=true` vrací skupiny, kde klient má účast na lekci, + ale už není členem (opustil je). + """ + + def setUp(self) -> None: + user = get_user_model().objects.create_user( + username="groups-client-filter-test", + email="groups-client-filter-test@test.cz", + password="test-password", + ) + self.api = APIClient() + self.api.force_authenticate(user=user) + + state_ok = AttendanceState.objects.create(name="OK", visible=True, default=True) + course = Course.objects.create(name="Test", duration=60) + start = make_aware(datetime(2026, 1, 10, 10, 0)) + + self.client_target = Client.objects.create(firstname="Alice", surname="T") + self.client_other = Client.objects.create(firstname="Bob", surname="T") + + # Skupina A — klient je aktualni clen, ma v ni ucast na lekci + self.group_current = Group.objects.create(name="Current", course=course) + Membership.objects.create(client=self.client_target, group=self.group_current) + lecture_current = Lecture.objects.create( + start=start, canceled=False, duration=60, course=course, group=self.group_current + ) + Attendance.objects.create( + client=self.client_target, lecture=lecture_current, paid=True, attendancestate=state_ok + ) + + # Skupina B — klient v ni mel lekci, ale uz neni clenem (opustil) + self.group_past = Group.objects.create(name="Past", course=course) + lecture_past = Lecture.objects.create( + start=start, canceled=False, duration=60, course=course, group=self.group_past + ) + Attendance.objects.create( + client=self.client_target, lecture=lecture_past, paid=True, attendancestate=state_ok + ) + # aktualne tam ma clenstvi jen jiny klient + Membership.objects.create(client=self.client_other, group=self.group_past) + + # Skupina C — klient nikdy neucastnil, nema tam clenstvi (kontrolni) + self.group_unrelated = Group.objects.create(name="Unrelated", course=course) + Membership.objects.create(client=self.client_other, group=self.group_unrelated) + + def test_client_filter_returns_current_memberships(self) -> None: + response = self.api.get( + f"/api/v1/groups/?client={self.client_target.pk}", secure=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(_ids(response.json()), {self.group_current.pk}) + + def test_client_with_only_past_returns_left_groups(self) -> None: + response = self.api.get( + f"/api/v1/groups/?client={self.client_target.pk}&onlyPast=true", secure=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(_ids(response.json()), {self.group_past.pk}) + + def test_only_past_without_client_is_noop(self) -> None: + response = self.api.get("/api/v1/groups/?onlyPast=true", secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual( + _ids(response.json()), + {self.group_current.pk, self.group_past.pk, self.group_unrelated.pk}, + ) + + +class GroupActiveFilterTest(TestCase): + """Filtr `active` vrací jen aktivní nebo jen neaktivní skupiny.""" + + def setUp(self) -> None: + user = get_user_model().objects.create_user( + username="groups-active-filter-test", + email="groups-active-filter-test@test.cz", + password="test-password", + ) + self.api = APIClient() + self.api.force_authenticate(user=user) + + course = Course.objects.create(name="Test", duration=60) + self.group_active = Group.objects.create(name="Active", course=course, active=True) + self.group_inactive = Group.objects.create(name="Inactive", course=course, active=False) + + def test_active_true_returns_only_active_groups(self) -> None: + response = self.api.get("/api/v1/groups/?active=true", secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(_ids(response.json()), {self.group_active.pk}) + + def test_active_false_returns_only_inactive_groups(self) -> None: + response = self.api.get("/api/v1/groups/?active=false", secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(_ids(response.json()), {self.group_inactive.pk}) diff --git a/api/tests/test_lectures_filter.py b/api/tests/test_lectures_filter.py new file mode 100644 index 000000000..d77840f59 --- /dev/null +++ b/api/tests/test_lectures_filter.py @@ -0,0 +1,97 @@ +""" +Testy filtru lekcí (client, includeGroup) endpointu /api/v1/lectures/. +""" + +from datetime import datetime + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.utils.timezone import make_aware +from rest_framework.test import APIClient + +from admin.models import Attendance, AttendanceState, Client, Course, Group, Lecture, Membership + +from .helpers import ids as _ids + + +class LectureClientFilterTest(TestCase): + """ + Filtr `client` vrací jen individuální lekce klienta (group=null). + Filtr `client` + `includeGroup=true` vrací i skupinové lekce klienta. + Samotný `includeGroup=true` (bez client) nemá efekt. + """ + + def setUp(self) -> None: + user = get_user_model().objects.create_user( + username="lectures-client-filter-test", + email="lectures-client-filter-test@test.cz", + password="test-password", + ) + self.api = APIClient() + self.api.force_authenticate(user=user) + + state_ok = AttendanceState.objects.create(name="OK", visible=True, default=True) + course = Course.objects.create(name="Test", duration=60) + start = make_aware(datetime(2026, 1, 10, 10, 0)) + + self.client_target = Client.objects.create(firstname="Alice", surname="T") + self.client_other = Client.objects.create(firstname="Bob", surname="T") + + # Lekce A — individualni lekce ciloveho klienta (group=null) + self.lecture_individual = Lecture.objects.create( + start=start, canceled=False, duration=60, course=course, group=None + ) + Attendance.objects.create( + client=self.client_target, + lecture=self.lecture_individual, + paid=True, + attendancestate=state_ok, + ) + + # Lekce B — skupinova lekce ciloveho klienta (group!=null) + group = Group.objects.create(name="Skupina", course=course) + Membership.objects.create(client=self.client_target, group=group) + self.lecture_group = Lecture.objects.create( + start=start, canceled=False, duration=60, course=course, group=group + ) + Attendance.objects.create( + client=self.client_target, + lecture=self.lecture_group, + paid=True, + attendancestate=state_ok, + ) + + # Lekce C — individualni lekce jineho klienta (kontrolni) + self.lecture_other = Lecture.objects.create( + start=start, canceled=False, duration=60, course=course, group=None + ) + Attendance.objects.create( + client=self.client_other, + lecture=self.lecture_other, + paid=True, + attendancestate=state_ok, + ) + + def test_client_filter_returns_only_individual_lectures(self) -> None: + response = self.api.get( + f"/api/v1/lectures/?client={self.client_target.pk}", secure=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(_ids(response.json()), {self.lecture_individual.pk}) + + def test_client_with_include_group_returns_all_client_lectures(self) -> None: + response = self.api.get( + f"/api/v1/lectures/?client={self.client_target.pk}&includeGroup=true", secure=True + ) + self.assertEqual(response.status_code, 200) + self.assertEqual( + _ids(response.json()), {self.lecture_individual.pk, self.lecture_group.pk} + ) + + def test_include_group_without_client_is_noop(self) -> None: + response = self.api.get("/api/v1/lectures/?includeGroup=true", secure=True) + self.assertEqual(response.status_code, 200) + self.assertEqual( + _ids(response.json()), + {self.lecture_individual.pk, self.lecture_group.pk, self.lecture_other.pk}, + ) diff --git a/api/views.py b/api/views.py index 54f46daa8..6ca99121f 100644 --- a/api/views.py +++ b/api/views.py @@ -4,12 +4,13 @@ from typing import Any -from django.db.models import Prefetch +from django.db.models import Max, Prefetch, Q, QuerySet from django.db.models.deletion import ProtectedError from django_filters import rest_framework as filters from django_filters.rest_framework import DjangoFilterBackend from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view -from rest_framework import viewsets, mixins +from rest_framework import status, viewsets, mixins +from rest_framework.decorators import action from rest_framework.filters import OrderingFilter from rest_framework.request import Request from rest_framework.response import Response @@ -48,6 +49,7 @@ update=extend_schema(tags=["Klienti"]), partial_update=extend_schema(tags=["Klienti"]), destroy=extend_schema(tags=["Klienti"]), + deactivate_bulk=extend_schema(tags=["Klienti"]), ) class ClientViewSet(viewsets.ModelViewSet, ProtectedErrorMixin): """ @@ -58,6 +60,14 @@ class ClientViewSet(viewsets.ModelViewSet, ProtectedErrorMixin): serializer_class = ClientSerializer filterset_fields = ("active",) + def get_queryset(self) -> QuerySet[Client]: + return Client.objects.annotate( + last_lecture_date=Max( + "attendances__lecture__start", + filter=Q(attendances__lecture__canceled=False), + ) + ) + @extend_schema( summary="Seznam klientů", description="Vrátí seznam všech klientů seřazených vzestupně dle příjmení a křestního jména.", @@ -92,6 +102,18 @@ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: result = super().get_result("Klienta lze smazat jen pokud nemá žádné lekce.") return result + @extend_schema( + summary="Hromadná deaktivace klientů", + description="Přesune seznam klientů (dle ids) do neaktivních.", + ) + @action(detail=False, methods=["patch"], url_path="deactivate-bulk") + def deactivate_bulk(self, request: Request) -> Response: + ids = request.data.get("ids") + if not isinstance(ids, list): + return Response({"ids": "Musí být seznam."}, status=status.HTTP_400_BAD_REQUEST) + Client.objects.filter(pk__in=ids).update(active=False) + return Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema_view( update=extend_schema(tags=["Účasti"]), @@ -196,6 +218,7 @@ def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: update=extend_schema(tags=["Skupiny"]), partial_update=extend_schema(tags=["Skupiny"]), destroy=extend_schema(tags=["Skupiny"]), + deactivate_bulk=extend_schema(tags=["Skupiny"]), ) class GroupViewSet(viewsets.ModelViewSet): """ @@ -209,6 +232,20 @@ class GroupViewSet(viewsets.ModelViewSet): filter_backends = (filters.DjangoFilterBackend,) filterset_class = custom_filters.GroupFilter + def get_queryset(self) -> QuerySet[Group]: + return ( + Group.objects.select_related("course") + .prefetch_related( + Prefetch("memberships", queryset=Membership.objects.select_related("client")) + ) + .annotate( + last_lecture_date=Max( + "lectures__start", + filter=Q(lectures__canceled=False), + ) + ) + ) + @extend_schema( summary="Seznam skupin", description=( @@ -241,6 +278,18 @@ def partial_update(self, request: Request, *args: Any, **kwargs: Any) -> Respons def destroy(self, request: Request, *args: Any, **kwargs: Any) -> Response: return super().destroy(request, *args, **kwargs) + @extend_schema( + summary="Hromadná deaktivace skupin", + description="Přesune seznam skupin (dle ids) do neaktivních.", + ) + @action(detail=False, methods=["patch"], url_path="deactivate-bulk") + def deactivate_bulk(self, request: Request) -> Response: + ids = request.data.get("ids") + if not isinstance(ids, list): + return Response({"ids": "Musí být seznam."}, status=status.HTTP_400_BAD_REQUEST) + Group.objects.filter(pk__in=ids).update(active=False) + return Response(status=status.HTTP_204_NO_CONTENT) + @extend_schema_view( list=extend_schema(tags=["Kurzy"]), diff --git a/frontend/__mocks__/data.json b/frontend/__mocks__/data.json index 5b7876612..0164d0107 100644 --- a/frontend/__mocks__/data.json +++ b/frontend/__mocks__/data.json @@ -12,7 +12,8 @@ "firstname": "Lukáš", "note": "vcvxc vxc v cv xcvxc vcxvxcv cv cx v xcv cx vxc vcxvcxv xcv cx xv xv", "phone": "000000000", - "surname": "Rod" + "surname": "Rod", + "last_lecture_date": null }, "remind_pay": false, "note": "uuu", @@ -45,7 +46,8 @@ "firstname": "Jan", "note": "", "phone": "", - "surname": "Novák" + "surname": "Novák", + "last_lecture_date": null }, "remind_pay": true, "note": "", @@ -106,7 +108,8 @@ "note": "vcvxc vxc v cv xcvxc vcxvxcv cv cx v xcv cx vxc vcxvcxv xcv cx xv xv", "phone": "000000000", "surname": "Rod", - "normalized": ["Lukas", "Rod"] + "normalized": ["Lukas", "Rod"], + "last_lecture_date": null }, { "id": 14, @@ -116,7 +119,8 @@ "note": "", "phone": "", "surname": "Novák", - "normalized": ["Jan", "Novak"] + "normalized": ["Jan", "Novak"], + "last_lecture_date": null } ], "groups": [ @@ -133,7 +137,8 @@ "firstname": "Jan", "note": "", "phone": "", - "surname": "Novák" + "surname": "Novák", + "last_lecture_date": null }, "prepaid_cnt": 0 }, @@ -146,7 +151,8 @@ "firstname": "Lukáš", "note": "vcvxc vxc v cv xcvxc vcxvxcv cv cx v xcv cx vxc vcxvcxv xcv cx xv xv", "phone": "000000000", - "surname": "Rod" + "surname": "Rod", + "last_lecture_date": null }, "prepaid_cnt": 0 } @@ -158,7 +164,8 @@ "duration": 30, "visible": true }, - "active": true + "active": true, + "last_lecture_date": null } ] } diff --git a/frontend/src/analytics.ts b/frontend/src/analytics.ts index 65a277d5e..b6299de22 100644 --- a/frontend/src/analytics.ts +++ b/frontend/src/analytics.ts @@ -26,6 +26,8 @@ type EventName = | "diary_navigated" | "search_used" | "active_filter_toggled" + | "client_deactivated" + | "group_deactivated" export type AnalyticsSource = | "applications_form" diff --git a/frontend/src/api/hooks/useClientMutations.ts b/frontend/src/api/hooks/useClientMutations.ts index a7d7e9393..72a0bc179 100644 --- a/frontend/src/api/hooks/useClientMutations.ts +++ b/frontend/src/api/hooks/useClientMutations.ts @@ -32,3 +32,13 @@ export function useDeleteClient() { }, }) } + +/** Hook pro hromadné přesunutí aktivních klientů bez lekce v poslední době do neaktivních. */ +export function useDeactivateClients() { + return useMutation({ + mutationFn: (ids) => ClientService.deactivateAll(ids), + meta: { + successMessage: "Klienti přesunuti do neaktivních", + }, + }) +} diff --git a/frontend/src/api/hooks/useGroupMutations.ts b/frontend/src/api/hooks/useGroupMutations.ts index cabd1b480..65f6d14ef 100644 --- a/frontend/src/api/hooks/useGroupMutations.ts +++ b/frontend/src/api/hooks/useGroupMutations.ts @@ -32,3 +32,13 @@ export function useDeleteGroup() { }, }) } + +/** Hook pro hromadné přesunutí aktivních skupin bez lekce v poslední době do neaktivních. */ +export function useDeactivateGroups() { + return useMutation({ + mutationFn: (ids) => GroupService.deactivateAll(ids), + meta: { + successMessage: "Skupiny přesunuty do neaktivních", + }, + }) +} diff --git a/frontend/src/api/hooks/useLectureQuery.ts b/frontend/src/api/hooks/useLectureQuery.ts index 843624e3c..26fe60d8a 100644 --- a/frontend/src/api/hooks/useLectureQuery.ts +++ b/frontend/src/api/hooks/useLectureQuery.ts @@ -53,6 +53,20 @@ export function useLecturesFromClient(clientId: ClientType["id"] | undefined, as }) } +/** Hook pro získání všech lekcí daného klienta včetně skupinových. */ +export function useLecturesFromClientAll(clientId: ClientType["id"] | undefined, asc = true) { + return useQuery({ + queryKey: ["lectures", { client: clientId, includeGroup: true, asc }], + queryFn: () => { + if (!clientId) { + throw new Error("Client ID is required") + } + return LectureService.getAllFromClientIncludingGroups(clientId, asc) + }, + enabled: !!clientId, + }) +} + /** Hook pro získání lekcí v daném dni. */ export function useLecturesFromDay(date: string | undefined, asc = true) { return useQuery({ diff --git a/frontend/src/api/services/ClientService.ts b/frontend/src/api/services/ClientService.ts index 5a8e55ae6..4eab08d0a 100644 --- a/frontend/src/api/services/ClientService.ts +++ b/frontend/src/api/services/ClientService.ts @@ -60,6 +60,15 @@ function create(context: ClientPostApi): Promise { }) } +/** Hromadně deaktivuje klienty. */ +function deactivateAll(ids: Item["id"][]): Promise { + return axiosRequestData({ + url: `${baseUrl}deactivate-bulk${API_DELIM}`, + method: API_METHODS.patch, + data: { ids }, + }) +} + const ClientService = { getAll, get, @@ -68,6 +77,7 @@ const ClientService = { create, update, remove, + deactivateAll, } export default ClientService diff --git a/frontend/src/api/services/GroupService.ts b/frontend/src/api/services/GroupService.ts index 5a81442dd..aa5853fad 100644 --- a/frontend/src/api/services/GroupService.ts +++ b/frontend/src/api/services/GroupService.ts @@ -82,6 +82,15 @@ function create(context: GroupPostApi): Promise { }) } +/** Hromadně deaktivuje skupiny. */ +function deactivateAll(ids: Item["id"][]): Promise { + return axiosRequestData({ + url: `${baseUrl}deactivate-bulk${API_DELIM}`, + method: API_METHODS.patch, + data: { ids }, + }) +} + const GroupService = { getAll, get, @@ -90,6 +99,7 @@ const GroupService = { create, update, remove, + deactivateAll, getAllFromClient, getAllEverFromClient, } diff --git a/frontend/src/api/services/LectureService.ts b/frontend/src/api/services/LectureService.ts index ecc79394f..99a7ec013 100644 --- a/frontend/src/api/services/LectureService.ts +++ b/frontend/src/api/services/LectureService.ts @@ -51,6 +51,15 @@ function getAllFromClientOrdered(client: ClientType["id"], asc: boolean): Promis }) } +/** Získá všechny lekce daného klienta včetně skupinových (umožňuje definovat řazení). */ +function getAllFromClientIncludingGroups(client: ClientType["id"], asc: boolean): Promise { + const url = `${baseUrl}?${API_URLS.lectures.filters.client}=${client}&${API_URLS.lectures.filters.includeGroup}=true${ordering(asc)}` + return axiosRequestData({ + url: url, + method: API_METHODS.get, + }) +} + /** Získá všechny lekce v daném dni (umožňuje definovat řazení). */ function getAllFromDayOrdered(date: string, asc: boolean): Promise { const url = `${baseUrl}?${API_URLS.lectures.filters.date}=${date}${ordering(asc)}` @@ -94,6 +103,7 @@ const LectureService = { getAllFromDayOrdered, getAllFromGroupOrdered, getAllFromClientOrdered, + getAllFromClientIncludingGroups, } export default LectureService diff --git a/frontend/src/api/urls.ts b/frontend/src/api/urls.ts index e6c10781c..82c9556bd 100644 --- a/frontend/src/api/urls.ts +++ b/frontend/src/api/urls.ts @@ -25,6 +25,7 @@ export const API_URLS = Object.freeze({ date: "date", client: "client", group: "group", + includeGroup: "includeGroup", }, ordering: { start: "start", diff --git a/frontend/src/components/ClientAnalysis.css.ts b/frontend/src/components/ClientAnalysis.css.ts new file mode 100644 index 000000000..8d1b0d92e --- /dev/null +++ b/frontend/src/components/ClientAnalysis.css.ts @@ -0,0 +1,11 @@ +import { style } from "@vanilla-extract/css" + +import { chartBaseStyles } from "./charts.css" + +export { chartTooltip as tooltip } from "./charts.css" + +export const chartPanel = style({ + ...chartBaseStyles, + boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", + padding: "0.75rem", +}) diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx new file mode 100644 index 000000000..1699af156 --- /dev/null +++ b/frontend/src/components/ClientAnalysis.tsx @@ -0,0 +1,213 @@ +import * as React from "react" +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" + +import { useAttendanceStatesContext } from "../contexts/AttendanceStatesContext" +import { ClientType, CourseType, LectureType, LectureTypeWithDate } from "../types/models" + +import { + AXIS_LABEL, + AXIS_TICK, + ChartMargin, + GRID_STROKE, + LEGEND_FONT, + MONTH_LABELS, +} from "./charts" +import * as styles from "./ClientAnalysis.css" + +type Props = { + clientId: ClientType["id"] + lectures: LectureType[] +} + +type CourseInfo = { + key: string + name: string + color: CourseType["color"] +} + +const CHART_MARGIN: ChartMargin = { top: 8, right: 8, left: 4, bottom: 8 } + +type TooltipEntry = { + color: string + name: string + value: number +} + +type TooltipContentProps = { + active?: boolean + label?: string + payload?: TooltipEntry[] +} + +const ChartTooltip: React.FC = ({ active, label, payload }) => { + if (!active || !payload?.length) { + return null + } + const total = payload.reduce((sum, entry) => sum + entry.value, 0) + return ( +
+
{label}
+ {payload.map((entry) => ( +
+ {entry.name}: {entry.value} +
+ ))} + {payload.length > 1 && ( +
+ Celkem: {total} +
+ )} +
+ ) +} + +/** Analýza docházky klienta — souhrn a graf proběhlých lekcí po měsících s rozlišením kurzů. */ +const ClientAnalysis: React.FC = ({ clientId, lectures }) => { + const { attendancestates } = useAttendanceStatesContext() + + const analysis = React.useMemo(() => { + const scheduled = lectures.filter((l): l is LectureTypeWithDate => l.start !== null) + const happened = scheduled.filter((l) => !l.canceled) + const notHappened = scheduled.filter((l) => l.canceled) + + const excused = notHappened.filter((l) => { + const att = l.attendances.find((a) => a.client.id === clientId) + return att + ? (attendancestates.find((s) => s.id === att.attendancestate)?.excused ?? false) + : false + }) + + const paid = happened.filter((l) => { + const att = l.attendances.find((a) => a.client.id === clientId) + return att?.paid === true + }) + + // unikatni kurzy (individualni a skupinove zvlast) v poradi vyskytu + const courseMap = new Map() + for (const lecture of happened) { + const isGroup = lecture.group !== null + const courseKey = `${lecture.course.id}_${isGroup ? "g" : "i"}` + if (!courseMap.has(courseKey)) { + courseMap.set(courseKey, { + key: courseKey, + name: isGroup ? `${lecture.course.name} (skup.)` : lecture.course.name, + color: lecture.course.color, + }) + } + } + const courses = Array.from(courseMap.values()) + + // pocty lekci per kurz per mesic + const monthMap = new Map>() + for (const lecture of happened) { + const isGroup = lecture.group !== null + const date = new Date(lecture.start) + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` + const monthData = monthMap.get(key) ?? {} + const courseKey = `${lecture.course.id}_${isGroup ? "g" : "i"}` + monthData[courseKey] = (monthData[courseKey] ?? 0) + 1 + monthMap.set(key, monthData) + } + const monthlyData = Array.from(monthMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, courseCounts]) => { + const parts = key.split("-") + const year = parts[0] ?? "" + const monthStr = parts[1] ?? "1" + return { + label: `${MONTH_LABELS[Number(monthStr) - 1]} ${year.slice(2)}`, + ...courseCounts, + } + }) + + return { scheduled, happened, notHappened, excused, paid, courses, monthlyData } + }, [lectures, clientId, attendancestates]) + + if (analysis.scheduled.length === 0) { + return null + } + + return ( +
+
+
+
{analysis.happened.length}
+
Proběhlé
+
+
+
{analysis.excused.length}
+
Omluvené
+
+
+
+ {analysis.notHappened.length - analysis.excused.length} +
+
Zrušené
+
+
+
+ {analysis.paid.length}/{analysis.happened.length} +
+
Zaplaceno
+
+
+ {analysis.monthlyData.length > 0 && ( +
+ + + + + + } /> + + {analysis.courses.map((course) => ( + + ))} + + +
+ )} +
+ ) +} + +export default ClientAnalysis diff --git a/frontend/src/components/GroupName.css.ts b/frontend/src/components/GroupName.css.ts index a05ab75a6..0dc626a54 100644 --- a/frontend/src/components/GroupName.css.ts +++ b/frontend/src/components/GroupName.css.ts @@ -3,3 +3,6 @@ import { style } from "@vanilla-extract/css" export const courseCircle = style({ marginRight: "0.25rem", }) + +// kotva pro selector v Card.css.ts (globalStyle pro preskrtnuti minulych skupin) +export const plainName = style({}) diff --git a/frontend/src/components/GroupName.tsx b/frontend/src/components/GroupName.tsx index 7b3f3b1f1..217a03ab3 100644 --- a/frontend/src/components/GroupName.tsx +++ b/frontend/src/components/GroupName.tsx @@ -21,7 +21,7 @@ type PlainGroupNameProps = { } const PlainName: React.FC = ({ group, title, bold }) => ( - + ( diff --git a/frontend/src/components/GroupsList.tsx b/frontend/src/components/GroupsList.tsx deleted file mode 100644 index fd9b28fa9..000000000 --- a/frontend/src/components/GroupsList.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from "react" - -import { GroupType } from "../types/models" - -import ComponentsList from "./ComponentsList" -import GroupName from "./GroupName" - -type Props = { - /** Pole se skupinami. */ - groups: GroupType[] -} - -/** Komponenta zobrazující čárkami oddělený seznam všech skupin, ve kterých je daný klient. */ -const GroupsList: React.FC = ({ groups = [] }) => { - if (!groups.length) { - return žádné skupiny - } - const groupComponents = groups.map((membership) => ( - - )) - return -} - -export default GroupsList diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index 05e92d43e..3b65ac648 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -14,17 +14,25 @@ type Props = { size?: FontAwesomeIconProps["size"] /** Pozice Tooltipu. */ placement?: UncontrolledTooltipProps["placement"] + /** Ikona zobrazená jako trigger Tooltipu (výchozí: faInfoCircle). */ + icon?: FontAwesomeIconProps["icon"] } /** Komponenta pro zobrazení titulku po najetí myší nad daný element. */ -const Tooltip: React.FC = ({ postfix, text, size = "lg", placement = "bottom" }) => ( +const Tooltip: React.FC = ({ + postfix, + text, + size = "lg", + placement = "bottom", + icon = faInfoCircle, +}) => ( <> {text} diff --git a/frontend/src/components/charts.css.ts b/frontend/src/components/charts.css.ts new file mode 100644 index 000000000..d138ce96b --- /dev/null +++ b/frontend/src/components/charts.css.ts @@ -0,0 +1,16 @@ +import { style } from "@vanilla-extract/css" + +export const chartBaseStyles = { + border: "1px solid #dee2e6", + borderRadius: "0.375rem", + backgroundColor: "#fff", +} + +export const chartTooltip = style({ + ...chartBaseStyles, + boxShadow: "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)", + padding: "0.5rem 0.75rem", + lineHeight: 1.5, + color: "#212529", + fontSize: "0.8rem", +}) diff --git a/frontend/src/components/charts.ts b/frontend/src/components/charts.ts new file mode 100644 index 000000000..7011810aa --- /dev/null +++ b/frontend/src/components/charts.ts @@ -0,0 +1,27 @@ +/** Krátké názvy měsíců pro osu X grafů. */ +export const MONTH_LABELS = [ + "Led", + "Úno", + "Bře", + "Dub", + "Kvě", + "Čvn", + "Čvc", + "Srp", + "Zář", + "Říj", + "Lis", + "Pro", +] as const + +export const AXIS_TICK = { fontSize: 12, fill: "#6c757d" } as const +export const AXIS_LABEL = { fontSize: 11, fill: "#6c757d" } as const +export const GRID_STROKE = "#e9ecef" +export const LEGEND_FONT = { fontSize: 12 } as const + +export type ChartMargin = { + top: number + right: number + left: number + bottom: number +} diff --git a/frontend/src/global/constants.ts b/frontend/src/global/constants.ts index 94258c508..3efb326d2 100644 --- a/frontend/src/global/constants.ts +++ b/frontend/src/global/constants.ts @@ -24,8 +24,13 @@ export enum TEXTS { WARNING_INACTIVE_CLIENT = "Klient není aktivní – přidáním nové lekce se klient stane opět aktivním.", WARNING_INACTIVE_GROUP = "Skupina není aktivní – nelze jí tedy přidávat nové lekce.", WARNING_ACTIVE_GROUP_WITH_INACTIVE_CLIENTS = "Ve skupině jsou neaktivní klienti – přidáním nové lekce se změní na aktivní.", + WARNING_STALE_CLIENT = "Klient je aktivní, ale naposledy měl lekci před více než 60 dny. Zvažte přesunutí do neaktivních.", + WARNING_STALE_GROUP = "Skupina je aktivní, ale naposledy měla lekci před více než 60 dny. Zvažte přesunutí do neaktivních.", } +/** Počet dní bez lekce, po kterých se aktivní klient/skupina považuje za „stale" a zobrazí se varování. */ +export const DAYS_WITHOUT_LECTURE_WARNING = 60 + /** Výchozí délka trvání lekce jednotlivce. */ export const DEFAULT_LECTURE_DURATION_SINGLE = 30 diff --git a/frontend/src/global/utils.ts b/frontend/src/global/utils.ts index 74679fcd4..7fd6836b8 100644 --- a/frontend/src/global/utils.ts +++ b/frontend/src/global/utils.ts @@ -9,7 +9,7 @@ import { MembershipType, } from "../types/models" -import { LOCALE_CZ } from "./constants" +import { DAYS_WITHOUT_LECTURE_WARNING, LOCALE_CZ } from "./constants" import { addDays } from "./funcDateTime" import { getEnvNameShort, isEnvProduction } from "./funcEnvironments" @@ -178,6 +178,15 @@ export function getDisplayName

(Component: React.ComponentType

): string { return Component.displayName ?? Component.name ?? "UnknownComponent" } +/** Vrátí true pokud je aktivní klient/skupina „stale" – naposledy měl lekci před více než DAYS_WITHOUT_LECTURE_WARNING dny. Nová entita bez lekce (null) varování nedostane. */ +export function isStaleActive(lastLectureDate: string | null): boolean { + if (!lastLectureDate) { + return false + } + const daysSince = (Date.now() - new Date(lastLectureDate).getTime()) / (1000 * 60 * 60 * 24) + return daysSince > DAYS_WITHOUT_LECTURE_WARNING +} + /** Vrátí boolean, jestli je zaslaný string URL. */ export function isValidUrl(urlString: string) { try { diff --git a/frontend/src/pages/Card.css.ts b/frontend/src/pages/Card.css.ts index 154caa7cd..26efbb550 100644 --- a/frontend/src/pages/Card.css.ts +++ b/frontend/src/pages/Card.css.ts @@ -1,5 +1,7 @@ import { createThemeContract, globalStyle, style } from "@vanilla-extract/css" +import { plainName as groupPlainName } from "../components/GroupName.css" + export const cardVars = createThemeContract({ courseBackground: "", }) @@ -35,12 +37,12 @@ globalStyle(`${cardInfo} > *`, { export const pastGroup = style({}) -globalStyle(`${pastGroup} [data-qa="group_name"]`, { +globalStyle(`${pastGroup} ${groupPlainName}`, { position: "relative", display: "inline-block", }) -globalStyle(`${pastGroup} [data-qa="group_name"]::after`, { +globalStyle(`${pastGroup} ${groupPlainName}::after`, { position: "absolute", top: "50%", left: 0, diff --git a/frontend/src/pages/Card.tsx b/frontend/src/pages/Card.tsx index f691d705d..35120c485 100644 --- a/frontend/src/pages/Card.tsx +++ b/frontend/src/pages/Card.tsx @@ -1,20 +1,28 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { useQueryClient } from "@tanstack/react-query" import { useNavigate } from "@tanstack/react-router" import { assignInlineVars } from "@vanilla-extract/dynamic" import classNames from "classnames" import * as React from "react" -import { Alert, Col, Container, ListGroup, ListGroupItem, Row } from "reactstrap" +import { Alert, Button, Col, Container, ListGroup, ListGroupItem, Row } from "reactstrap" +import { trackEvent } from "../analytics" import { useAllGroupsEverFromClient, useClient, + useDeactivateClients, + useDeactivateGroups, useGroup, useGroupsFromClient, useLecturesFromClient, + useLecturesFromClientAll, useLecturesFromGroup, } from "../api/hooks" import APP_URLS from "../APP_URLS" import Attendances from "../components/Attendances" import BackButton from "../components/buttons/BackButton" +import ClientAnalysis from "../components/ClientAnalysis" import ClientEmail from "../components/ClientEmail" import ClientName from "../components/ClientName" import ClientNote from "../components/ClientNote" @@ -40,6 +48,7 @@ import { getDefaultValuesForLecture, groupObjectsByCourses, GroupedObjectsByCourses, + isStaleActive, pageTitle, } from "../global/utils" import { ModalClientsGroupsData } from "../types/components" @@ -59,6 +68,7 @@ type CardProps = { const Card: React.FC = ({ id, isClientPage }) => { const attendanceStatesContext = useAttendanceStatesContext() const navigate = useNavigate() + const queryClient = useQueryClient() const isClientPageValue = isClientPage const isClient = (object: ClientOrGroup): object is ClientType => @@ -67,11 +77,18 @@ const Card: React.FC = ({ id, isClientPage }) => { const isGroup = (object: ClientOrGroup): object is GroupType => Boolean(object && "name" in object) + const deactivateClient = useDeactivateClients() + const deactivateGroup = useDeactivateGroups() + const clientQuery = useClient(isClientPageValue ? id : undefined) const groupQuery = useGroup(isClientPageValue ? undefined : id) const groupsOfClientQuery = useGroupsFromClient(isClientPageValue ? id : undefined) const allGroupsEverQuery = useAllGroupsEverFromClient(isClientPageValue ? id : undefined) const lecturesFromClientQuery = useLecturesFromClient(isClientPageValue ? id : undefined, false) + const lecturesFromClientAllQuery = useLecturesFromClientAll( + isClientPageValue ? id : undefined, + false, + ) const lecturesFromGroupQuery = useLecturesFromGroup(isClientPageValue ? undefined : id, false) /** Klient nebo skupina zobrazená na kartě. */ @@ -118,23 +135,31 @@ const Card: React.FC = ({ id, isClientPage }) => { } }, [object]) + const clientQueriesLoading = + clientQuery.isLoading || + groupsOfClientQuery.isLoading || + allGroupsEverQuery.isLoading || + !!lecturesFromClientAllQuery.isLoading || + lecturesFromClientQuery.isLoading + + const groupQueriesLoading = groupQuery.isLoading || lecturesFromGroupQuery.isLoading + const isLoading = - (isClientPageValue ? clientQuery.isLoading : groupQuery.isLoading) || - (isClientPageValue ? groupsOfClientQuery.isLoading : false) || - (isClientPageValue ? allGroupsEverQuery.isLoading : false) || - (isClientPageValue - ? lecturesFromClientQuery.isLoading - : lecturesFromGroupQuery.isLoading) || - attendanceStatesContext.isLoading + (isClientPageValue ? clientQueriesLoading : groupQueriesLoading) || + !!attendanceStatesContext.isLoading + + const clientQueriesFetching = + clientQuery.isFetching || + groupsOfClientQuery.isFetching || + allGroupsEverQuery.isFetching || + !!lecturesFromClientAllQuery.isFetching || + lecturesFromClientQuery.isFetching + + const groupQueriesFetching = groupQuery.isFetching || lecturesFromGroupQuery.isFetching const isFetching = - (isClientPageValue ? clientQuery.isFetching : groupQuery.isFetching) || - (isClientPageValue ? groupsOfClientQuery.isFetching : false) || - (isClientPageValue ? allGroupsEverQuery.isFetching : false) || - (isClientPageValue - ? lecturesFromClientQuery.isFetching - : lecturesFromGroupQuery.isFetching) || - attendanceStatesContext.isLoading + (isClientPageValue ? clientQueriesFetching : groupQueriesFetching) || + !!attendanceStatesContext.isLoading const refreshObjectFromModal = React.useCallback( (data: ModalClientsGroupsData): void => { @@ -151,6 +176,31 @@ const Card: React.FC = ({ id, isClientPage }) => { globalThis.history.back() } + const handleDeactivate = (): void => { + if (!object) { + return + } + const label = isClient(object) ? "klienta" : "skupinu" + if (!globalThis.confirm(`Opravdu chcete přesunout ${label} do neaktivních?`)) { + return + } + if (isClient(object)) { + deactivateClient.mutate([id], { + onSuccess: () => { + trackEvent("client_deactivated", { source: "client_card" }) + void queryClient.invalidateQueries({ queryKey: ["clients", id] }) + }, + }) + } else { + deactivateGroup.mutate([id], { + onSuccess: () => { + trackEvent("group_deactivated", { source: "group_card" }) + void queryClient.invalidateQueries({ queryKey: ["groups", id] }) + }, + }) + } + } + const cardSource = isClientPageValue ? ("client_card" as const) : ("group_card" as const) const renderLecture = (lecture: LectureType): React.ReactElement => { @@ -250,8 +300,41 @@ const Card: React.FC = ({ id, isClientPage }) => { : TEXTS.WARNING_INACTIVE_GROUP} )} - {isClient(object) && ( - + {object && object.active && isStaleActive(object.last_lecture_date) && ( + + + {isClient(object) + ? TEXTS.WARNING_STALE_CLIENT + : TEXTS.WARNING_STALE_GROUP} + + + + )} + + {isClient(object) && ( +

+ Telefon: @@ -292,8 +375,14 @@ const Card: React.FC = ({ id, isClientPage }) => { Poznámka: - )} -
+
+ +
+ + )} {isGroup(object) && ( { /** Je vybráno zobrazení aktivních klientů (true). */ const [active, setActive] = React.useState(true) const { data: inactiveClients = [], isLoading: inactiveLoading } = useInactiveClients(!active) + const deactivateClients = useDeactivateClients() const isLoading = (): boolean => (active ? clientsActiveContext.isLoading : inactiveLoading) const getClientsData = (): ClientType[] => active ? clientsActiveContext.clients : inactiveClients + const staleClients = React.useMemo( + () => clientsActiveContext.clients.filter((c) => isStaleActive(c.last_lecture_date)), + [clientsActiveContext.clients], + ) + const refresh = (newActive: boolean = active): void => { setActive(newActive) } @@ -36,6 +48,20 @@ const Clients: React.FC = () => { } } + const handleDeactivateAll = (): void => { + const count = staleClients.length + const label = count === 1 ? "klienta" : count < 5 ? "klienty" : "klientů" + if (globalThis.confirm(`Opravdu chcete přesunout ${count} ${label} do neaktivních?`)) { + deactivateClients.mutate( + staleClients.map((c) => c.id), + { + onSuccess: () => + trackEvent("client_deactivated", { source: "clients_page", count }), + }, + ) + } + } + return ( { : false } /> + {active && !clientsActiveContext.isLoading && staleClients.length > 0 && ( + + + + {staleClients.length}{" "} + {staleClients.length === 1 + ? "aktivní klient nemá" + : staleClients.length < 5 + ? "aktivní klienti nemají" + : "aktivních klientů nemá"}{" "} + lekci déle než {DAYS_WITHOUT_LECTURE_WARNING} dní. + + + + )} {getClientsData().length > 0 && ( @@ -88,7 +141,17 @@ const Clients: React.FC = () => { {getClientsData().map((client) => (
- + {" "} + {client.active && + isStaleActive(client.last_lecture_date) && ( + + )} { /** Je vybráno zobrazení aktivních skupin (true). */ const [active, setActive] = React.useState(true) const { data: inactiveGroups = [], isLoading: inactiveLoading } = useInactiveGroups(!active) + const deactivateGroups = useDeactivateGroups() const isLoading = (): boolean => (active ? groupsActiveContext.isLoading : inactiveLoading) const getGroupsData = (): GroupType[] => (active ? groupsActiveContext.groups : inactiveGroups) + const staleGroups = React.useMemo( + () => groupsActiveContext.groups.filter((g) => isStaleActive(g.last_lecture_date)), + [groupsActiveContext.groups], + ) + const refresh = (newActive: boolean = active): void => { setActive(newActive) } @@ -37,6 +46,20 @@ const Groups: React.FC = () => { } } + const handleDeactivateAll = (): void => { + const count = staleGroups.length + const label = count === 1 ? "skupinu" : count < 5 ? "skupiny" : "skupin" + if (globalThis.confirm(`Opravdu chcete přesunout ${count} ${label} do neaktivních?`)) { + deactivateGroups.mutate( + staleGroups.map((g) => g.id), + { + onSuccess: () => + trackEvent("group_deactivated", { source: "groups_page", count }), + }, + ) + } + } + return ( { } /> + {active && !groupsActiveContext.isLoading && staleGroups.length > 0 && ( + + + + {staleGroups.length}{" "} + {staleGroups.length === 1 + ? "aktivní skupina nemá" + : staleGroups.length < 5 + ? "aktivní skupiny nemají" + : "aktivních skupin nemá"}{" "} + lekci déle než {DAYS_WITHOUT_LECTURE_WARNING} dní. + + + + )} + {getGroupsData().length > 0 && ( @@ -85,17 +136,35 @@ const Groups: React.FC = () => { {getGroupsData().map((group) => (
- {" "} + {group.active && - !areAllMembersActive(group.memberships) && ( - + (!areAllMembersActive(group.memberships) || + isStaleActive(group.last_lecture_date)) && ( + + {!areAllMembersActive( + group.memberships, + ) && ( + + )} + {isStaleActive( + group.last_lecture_date, + ) && ( + + )} + )} diff --git a/frontend/src/pages/Statistics.css.ts b/frontend/src/pages/Statistics.css.ts index 35a89dbcd..43c5ca979 100644 --- a/frontend/src/pages/Statistics.css.ts +++ b/frontend/src/pages/Statistics.css.ts @@ -1,8 +1,11 @@ import { globalStyle, style } from "@vanilla-extract/css" +export { chartTooltip } from "../components/charts.css" + export const statCard = style({ border: "1px solid #dee2e6", borderRadius: "0.375rem", + boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", backgroundColor: "#fff", padding: "1rem", minHeight: "9rem", @@ -121,6 +124,7 @@ export const chartCaption = style({ export const chartPanel = style({ border: "1px solid #dee2e6", borderRadius: "0.375rem", + boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", backgroundColor: "#fff", padding: "1rem", "@media": { @@ -130,17 +134,6 @@ export const chartPanel = style({ }, }) -export const chartTooltip = style({ - border: "1px solid #dee2e6", - borderRadius: "0.375rem", - boxShadow: "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)", - backgroundColor: "#fff", - padding: "0.5rem 0.75rem", - lineHeight: 1.5, - color: "#212529", - fontSize: "0.8rem", -}) - export const chartEmpty = style({ marginBottom: 0, color: "#6c757d", diff --git a/frontend/src/pages/Statistics.tsx b/frontend/src/pages/Statistics.tsx index 63ffd5efe..f6a7a24ea 100644 --- a/frontend/src/pages/Statistics.tsx +++ b/frontend/src/pages/Statistics.tsx @@ -19,6 +19,14 @@ import { import { useStatistics } from "../api/hooks" import APP_URLS from "../APP_URLS" +import { + AXIS_LABEL, + AXIS_TICK, + ChartMargin, + GRID_STROKE, + LEGEND_FONT, + MONTH_LABELS, +} from "../components/charts" import ClientName from "../components/ClientName" import Heading from "../components/Heading" import Loading from "../components/Loading" @@ -27,14 +35,10 @@ import { StatisticsType } from "../types/models" import * as styles from "./Statistics.css" /** Sjednocené okraje a mřížka napříč Recharts. */ -const CHART_MARGIN = { top: 12, right: 16, left: 4, bottom: 12 } as const +const CHART_MARGIN: ChartMargin = { top: 12, right: 16, left: 4, bottom: 12 } /** Okraje pro grafy s legendou dole – extra bottom pro legendu + XAxis popisek. */ -const CHART_MARGIN_BOTTOM_LEGEND = { top: 12, right: 16, left: 4, bottom: 48 } as const -const CHART_MARGIN_BAR_VERTICAL = { top: 12, right: 16, left: 8, bottom: 48 } as const -const AXIS_TICK = { fontSize: 12, fill: "#6c757d" } -const AXIS_LABEL = { fontSize: 11, fill: "#6c757d" } as const -const GRID_STROKE = "#e9ecef" -const LEGEND_FONT = { fontSize: 12 } +const CHART_MARGIN_BOTTOM_LEGEND: ChartMargin = { top: 12, right: 16, left: 4, bottom: 48 } +const CHART_MARGIN_BAR_VERTICAL: ChartMargin = { top: 12, right: 16, left: 8, bottom: 48 } function formatStackedBarLegend(value: string): string { if (value === "individual") { @@ -49,22 +53,6 @@ function formatStackedBarLegend(value: string): string { return value } -/** Krátké názvy měsíců pro osu X grafu podle měsíců. */ -const MONTH_LABELS_SHORT = [ - "Led", - "Úno", - "Bře", - "Dub", - "Kvě", - "Čvn", - "Čvc", - "Srp", - "Zář", - "Říj", - "Lis", - "Pro", -] as const - type ChartMetric = "lectures" | "hours" const CHART_METRIC_LABEL: Record = { @@ -556,7 +544,7 @@ const LecturesMonthSection: React.FC = ({ ? `${CHART_METRIC_LABEL[chartMetric]} podle kalendářního měsíce začátku napříč celou historií (každý sloupec = součet všech let v daném měsíci). Vhodné pro sezónnost (např. náběh po prázdninách).` : `${CHART_METRIC_LABEL[chartMetric]} v roce ${year} podle měsíce začátku lekce.` const data = byMonth.map((row) => ({ - label: MONTH_LABELS_SHORT[row.month - 1], + label: MONTH_LABELS[row.month - 1], value: chartMetric === "lectures" ? row.total : Number((row.total_minutes / 60).toFixed(1)), })) const yAxisLabel = chartMetric === "hours" ? "Hodiny" : "Počet lekcí" diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index a286ca0cb..2dea2c8d0 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -15,6 +15,7 @@ export type ClientType = Model & { phone: string firstname: string surname: string + last_lecture_date: string | null } /** Aktivní klient (GET). */ @@ -77,6 +78,7 @@ export type GroupType = Model & { memberships: MembershipType[] active: boolean course: CourseType + last_lecture_date: string | null } /** Stav účasti (GET). */ @@ -240,13 +242,13 @@ export type ApplicationPostApiDummy = Omit /** Dummy model pro klienta. */ -export type ClientPostApiDummy = Omit +export type ClientPostApiDummy = Omit /** Dummy model pro kurz. */ export type CoursePostApiDummy = Omit /** Dummy model pro skupinu. */ -export type GroupPostApiDummy = Omit & { +export type GroupPostApiDummy = Omit & { course: GroupType["course"] | null } @@ -276,13 +278,13 @@ export type AttendancePutApi = Omit /** Kurz (PUT). */ export type CoursePutApi = CourseType /** Skupina (PUT). */ -export type GroupPutApi = Omit & { +export type GroupPutApi = Omit & { course_id: CourseType["id"] memberships: MembershipPostApi[] }