From f44e3eaffcbf1ef2fdce447d426bcf7c887dc1a7 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Mon, 13 Apr 2026 18:51:02 +0200 Subject: [PATCH 01/24] wip --- api/filters.py | 27 ++++++++++++- frontend/src/api/hooks/useGroupQuery.ts | 16 +++++++- frontend/src/api/services/GroupService.ts | 12 +++++- frontend/src/api/urls.ts | 1 + frontend/src/pages/Card.css.ts | 16 ++++++++ frontend/src/pages/Card.tsx | 48 +++++++++++++++++++++-- 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/api/filters.py b/api/filters.py index dd7eda0ce..5bea77bbd 100644 --- a/api/filters.py +++ b/api/filters.py @@ -37,10 +37,33 @@ class GroupFilter(filters.FilterSet): Filtr skupin podle klienta (client) a aktivity skupiny (active). Filtr aktivity je základní. Filtr klienta umožňuje filtrovat jednodušším URL parametrem, než konkrétní cestou k related_field (xx_yy). + Parametr onlyPast=true vrátí jen skupiny, které klient opustil (má tam účast na lekci, ale již není členem). """ - client = filters.NumberFilter(field_name="memberships__client__pk") + client = filters.NumberFilter() + onlyPast = filters.BooleanFilter() + + def filter_queryset(self, queryset): + client_id = self.form.cleaned_data.get("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) + + return queryset class Meta: model = Group - fields = "client", "active" + fields = "client", "onlyPast", "active" diff --git a/frontend/src/api/hooks/useGroupQuery.ts b/frontend/src/api/hooks/useGroupQuery.ts index ff0e32753..aa579fc95 100644 --- a/frontend/src/api/hooks/useGroupQuery.ts +++ b/frontend/src/api/hooks/useGroupQuery.ts @@ -43,7 +43,7 @@ export function useGroup(id: GroupType["id"] | undefined) { }) } -/** Hook pro získání skupin daného klienta. */ +/** Hook pro získání skupin daného klienta (jen aktuální členství). */ export function useGroupsFromClient(clientId: ClientType["id"] | undefined) { return useQuery({ queryKey: ["groups", { client: clientId }], @@ -56,3 +56,17 @@ export function useGroupsFromClient(clientId: ClientType["id"] | undefined) { enabled: !!clientId, }) } + +/** Hook pro získání skupin, které klient opustil (má tam účast na lekci, ale již není členem). */ +export function useAllGroupsEverFromClient(clientId: ClientType["id"] | undefined) { + return useQuery({ + queryKey: ["groups", { client: clientId, onlyPast: true }], + queryFn: () => { + if (!clientId) { + throw new Error("Client ID is required") + } + return GroupService.getAllEverFromClient(clientId) + }, + enabled: !!clientId, + }) +} diff --git a/frontend/src/api/services/GroupService.ts b/frontend/src/api/services/GroupService.ts index 7aef441b1..5a81442dd 100644 --- a/frontend/src/api/services/GroupService.ts +++ b/frontend/src/api/services/GroupService.ts @@ -39,7 +39,7 @@ function getInactive(): Promise { }) } -/** Získá skupiny zadaného klienta. */ +/** Získá skupiny zadaného klienta (jen aktuální členství). */ function getAllFromClient(clientId: ClientType["id"]): Promise { const url = `${baseUrl}?${API_URLS.groups.filters.client}=${clientId}` return axiosRequestData({ @@ -48,6 +48,15 @@ function getAllFromClient(clientId: ClientType["id"]): Promise { }) } +/** Získá skupiny, které klient opustil (má tam účast na lekci, ale již není členem). */ +function getAllEverFromClient(clientId: ClientType["id"]): Promise { + const url = `${baseUrl}?${API_URLS.groups.filters.client}=${clientId}&${API_URLS.groups.filters.onlyPast}=true` + return axiosRequestData({ + url: url, + method: API_METHODS.get, + }) +} + /** Aktualizuje (PUT) skupinu. */ function update(context: GroupPutApi): Promise { return axiosRequestData({ @@ -82,6 +91,7 @@ const GroupService = { update, remove, getAllFromClient, + getAllEverFromClient, } export default GroupService diff --git a/frontend/src/api/urls.ts b/frontend/src/api/urls.ts index dc5f1de56..e6c10781c 100644 --- a/frontend/src/api/urls.ts +++ b/frontend/src/api/urls.ts @@ -40,6 +40,7 @@ export const API_URLS = Object.freeze({ url: `groups${API_DELIM}`, filters: { client: "client", + onlyPast: "onlyPast", active: "active", }, }, diff --git a/frontend/src/pages/Card.css.ts b/frontend/src/pages/Card.css.ts index 5a2338e1f..154caa7cd 100644 --- a/frontend/src/pages/Card.css.ts +++ b/frontend/src/pages/Card.css.ts @@ -32,3 +32,19 @@ globalStyle(`${cardInfo} > *`, { margin: "0 auto 1rem", maxWidth: "600px", }) + +export const pastGroup = style({}) + +globalStyle(`${pastGroup} [data-qa="group_name"]`, { + position: "relative", + display: "inline-block", +}) + +globalStyle(`${pastGroup} [data-qa="group_name"]::after`, { + position: "absolute", + top: "50%", + left: 0, + borderBottom: "2px solid rgb(255 0 0 / 0.6)", + width: "100%", + content: '""', +}) diff --git a/frontend/src/pages/Card.tsx b/frontend/src/pages/Card.tsx index ca50329d5..f691d705d 100644 --- a/frontend/src/pages/Card.tsx +++ b/frontend/src/pages/Card.tsx @@ -5,6 +5,7 @@ import * as React from "react" import { Alert, Col, Container, ListGroup, ListGroupItem, Row } from "reactstrap" import { + useAllGroupsEverFromClient, useClient, useGroup, useGroupsFromClient, @@ -18,8 +19,8 @@ import ClientEmail from "../components/ClientEmail" import ClientName from "../components/ClientName" import ClientNote from "../components/ClientNote" import ClientPhone from "../components/ClientPhone" +import ComponentsList from "../components/ComponentsList" import GroupName from "../components/GroupName" -import GroupsList from "../components/GroupsList" import Heading from "../components/Heading" import * as lectureStyles from "../components/Lecture.css" import LectureNumber from "../components/LectureNumber" @@ -69,6 +70,7 @@ const Card: React.FC = ({ id, isClientPage }) => { 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 lecturesFromGroupQuery = useLecturesFromGroup(isClientPageValue ? undefined : id, false) @@ -83,6 +85,9 @@ const Card: React.FC = ({ id, isClientPage }) => { /** Skupiny, jejichž členem je zobrazený klient. */ const groupsOfClient: GroupType[] = groupsOfClientQuery.data ?? [] + /** Skupiny, které klient opustil (měl v nich lekci, ale už není členem). */ + const pastGroups: GroupType[] = allGroupsEverQuery.data ?? [] + /** Lekce zobrazeného klienta nebo skupiny, seskupené podle kurzů. */ const lectures: GroupedObjectsByCourses = React.useMemo(() => { const lecturesData = isClientPageValue @@ -116,6 +121,7 @@ const Card: React.FC = ({ id, isClientPage }) => { const isLoading = (isClientPageValue ? clientQuery.isLoading : groupQuery.isLoading) || (isClientPageValue ? groupsOfClientQuery.isLoading : false) || + (isClientPageValue ? allGroupsEverQuery.isLoading : false) || (isClientPageValue ? lecturesFromClientQuery.isLoading : lecturesFromGroupQuery.isLoading) || @@ -124,6 +130,7 @@ const Card: React.FC = ({ id, isClientPage }) => { const isFetching = (isClientPageValue ? clientQuery.isFetching : groupQuery.isFetching) || (isClientPageValue ? groupsOfClientQuery.isFetching : false) || + (isClientPageValue ? allGroupsEverQuery.isFetching : false) || (isClientPageValue ? lecturesFromClientQuery.isFetching : lecturesFromGroupQuery.isFetching) || @@ -132,7 +139,9 @@ const Card: React.FC = ({ id, isClientPage }) => { const refreshObjectFromModal = React.useCallback( (data: ModalClientsGroupsData): void => { if (data?.isDeleted) { - void navigate({ to: isClientPageValue ? APP_URLS.klienti.url : APP_URLS.skupiny.url }) + void navigate({ + to: isClientPageValue ? APP_URLS.klienti.url : APP_URLS.skupiny.url, + }) } }, [isClientPageValue, navigate], @@ -173,7 +182,11 @@ const Card: React.FC = ({ id, isClientPage }) => {
- +
) @@ -246,7 +259,34 @@ const Card: React.FC = ({ id, isClientPage }) => { E-mail: - Skupiny: + Skupiny:{" "} + {groupsOfClient.length === 0 && pastGroups.length === 0 ? ( + žádné skupiny + ) : ( + ( + + )), + ...pastGroups.map((g) => ( + + + + )), + ]} + /> + )} Poznámka: From babfdb97810513989ce50f996cffb46e14be6baf Mon Sep 17 00:00:00 2001 From: rodlukas Date: Mon, 13 Apr 2026 19:00:28 +0200 Subject: [PATCH 02/24] fix tests --- api/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/filters.py b/api/filters.py index 5bea77bbd..76a2ce1bd 100644 --- a/api/filters.py +++ b/api/filters.py @@ -43,7 +43,7 @@ class GroupFilter(filters.FilterSet): client = filters.NumberFilter() onlyPast = filters.BooleanFilter() - def filter_queryset(self, queryset): + def filter_queryset(self, queryset: QuerySet) -> QuerySet: client_id = self.form.cleaned_data.get("client") only_past = self.form.cleaned_data.get("onlyPast") active = self.form.cleaned_data.get("active") From d133d0b09708eec4e1160ed23cacb57f613ca764 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 16:21:45 +0200 Subject: [PATCH 03/24] wip --- .github/copilot-instructions.md | 11 + AGENTS.md | 145 +++++++++++ CLAUDE.md | 11 + api/serializers.py | 3 + api/views.py | 24 +- frontend/src/api/hooks/useClientMutations.ts | 17 +- frontend/src/api/hooks/useGroupMutations.ts | 17 +- frontend/src/api/services/ClientService.ts | 10 + frontend/src/api/services/GroupService.ts | 10 + frontend/src/components/ClientAnalysis.css.ts | 20 ++ frontend/src/components/ClientAnalysis.tsx | 229 ++++++++++++++++++ frontend/src/global/constants.ts | 5 + frontend/src/global/utils.ts | 13 +- frontend/src/pages/Card.tsx | 97 +++++--- frontend/src/pages/Clients.tsx | 59 ++++- frontend/src/pages/Groups.tsx | 59 ++++- frontend/src/pages/Statistics.css.ts | 2 + frontend/src/types/models.ts | 6 +- 18 files changed, 687 insertions(+), 51 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 frontend/src/components/ClientAnalysis.css.ts create mode 100644 frontend/src/components/ClientAnalysis.tsx 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/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..33e9cd028 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,145 @@ +# 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. 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 +``` + +### 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 + +# 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 5 + 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. + +### 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..9b1fc3531 --- /dev/null +++ b/CLAUDE.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/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/views.py b/api/views.py index 54f46daa8..20e0f0266 100644 --- a/api/views.py +++ b/api/views.py @@ -4,7 +4,7 @@ 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 @@ -58,6 +58,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.", @@ -209,6 +217,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=( diff --git a/frontend/src/api/hooks/useClientMutations.ts b/frontend/src/api/hooks/useClientMutations.ts index a7d7e9393..01e901de0 100644 --- a/frontend/src/api/hooks/useClientMutations.ts +++ b/frontend/src/api/hooks/useClientMutations.ts @@ -1,4 +1,4 @@ -import { useMutation } from "@tanstack/react-query" +import { useMutation, useQueryClient } from "@tanstack/react-query" import { ClientPostApi, ClientPutApi, ClientType } from "../../types/models" import ClientService from "../services/ClientService" @@ -32,3 +32,18 @@ export function useDeleteClient() { }, }) } + +/** Hook pro hromadné přesunutí stale aktivních klientů do neaktivních. */ +export function useDeactivateClients() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (ids) => + Promise.all(ids.map((id) => ClientService.deactivate(id))).then(() => undefined), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["clients"] }) + }, + 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..32bdf34f0 100644 --- a/frontend/src/api/hooks/useGroupMutations.ts +++ b/frontend/src/api/hooks/useGroupMutations.ts @@ -1,4 +1,4 @@ -import { useMutation } from "@tanstack/react-query" +import { useMutation, useQueryClient } from "@tanstack/react-query" import { GroupPostApi, GroupPutApi, GroupType } from "../../types/models" import GroupService from "../services/GroupService" @@ -32,3 +32,18 @@ export function useDeleteGroup() { }, }) } + +/** Hook pro hromadné přesunutí stale aktivních skupin do neaktivních. */ +export function useDeactivateGroups() { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (ids) => + Promise.all(ids.map((id) => GroupService.deactivate(id))).then(() => undefined), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["groups"] }) + }, + meta: { + successMessage: "Skupiny přesunuty do neaktivních", + }, + }) +} diff --git a/frontend/src/api/services/ClientService.ts b/frontend/src/api/services/ClientService.ts index 5a8e55ae6..80bf6feab 100644 --- a/frontend/src/api/services/ClientService.ts +++ b/frontend/src/api/services/ClientService.ts @@ -60,6 +60,15 @@ function create(context: ClientPostApi): Promise { }) } +/** Deaktivuje klienta. */ +function deactivate(id: Item["id"]): Promise { + return axiosRequestData({ + url: `${baseUrl}${id}${API_DELIM}`, + method: API_METHODS.patch, + data: { active: false }, + }) +} + const ClientService = { getAll, get, @@ -68,6 +77,7 @@ const ClientService = { create, update, remove, + deactivate, } export default ClientService diff --git a/frontend/src/api/services/GroupService.ts b/frontend/src/api/services/GroupService.ts index 5a81442dd..4e00728e2 100644 --- a/frontend/src/api/services/GroupService.ts +++ b/frontend/src/api/services/GroupService.ts @@ -82,6 +82,15 @@ function create(context: GroupPostApi): Promise { }) } +/** Deaktivuje skupinu. */ +function deactivate(id: Item["id"]): Promise { + return axiosRequestData({ + url: `${baseUrl}${id}${API_DELIM}`, + method: API_METHODS.patch, + data: { active: false }, + }) +} + const GroupService = { getAll, get, @@ -90,6 +99,7 @@ const GroupService = { create, update, remove, + deactivate, getAllFromClient, getAllEverFromClient, } diff --git a/frontend/src/components/ClientAnalysis.css.ts b/frontend/src/components/ClientAnalysis.css.ts new file mode 100644 index 000000000..44e77aa89 --- /dev/null +++ b/frontend/src/components/ClientAnalysis.css.ts @@ -0,0 +1,20 @@ +import { style } from "@vanilla-extract/css" + +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: "0.75rem", +}) + +export const tooltip = 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", +}) diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx new file mode 100644 index 000000000..f8703f862 --- /dev/null +++ b/frontend/src/components/ClientAnalysis.tsx @@ -0,0 +1,229 @@ +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 * as styles from "./ClientAnalysis.css" + +type Props = { + clientId: ClientType["id"] + lectures: LectureType[] +} + +type CourseInfo = { + id: CourseType["id"] + name: CourseType["name"] + color: CourseType["color"] +} + +const MONTH_LABELS = [ + "Led", + "Úno", + "Bře", + "Dub", + "Kvě", + "Čvn", + "Čvc", + "Srp", + "Zář", + "Říj", + "Lis", + "Pro", +] as const + +const CHART_MARGIN = { top: 8, right: 8, left: 4, bottom: 8 } as const +const AXIS_TICK = { fontSize: 12, fill: "#6c757d" } as const +const AXIS_LABEL = { fontSize: 11, fill: "#6c757d" } as const + +const GRID_STROKE = "#e9ecef" +const LEGEND_STYLE = { fontSize: 12 } + +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 + }) + + // Sesbírej unikátní kurzy v pořadí výskytu + const courseMap = new Map() + for (const lecture of happened) { + if (!courseMap.has(lecture.course.id)) { + courseMap.set(lecture.course.id, { + id: lecture.course.id, + name: lecture.course.name, + color: lecture.course.color, + }) + } + } + const courses = Array.from(courseMap.values()) + + // Počty lekcí per kurz per měsíc + const monthMap = new Map>() + for (const lecture of happened) { + const date = new Date(lecture.start) + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` + const monthData = monthMap.get(key) ?? {} + const courseKey = String(lecture.course.id) + 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 + } + + const multiCourse = analysis.courses.length > 1 + + 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 >= 2 && ( +
+ + + + + + } /> + {multiCourse && } + {analysis.courses.map((course, index) => ( + + ))} + + +
+ )} +
+ ) +} + +export default ClientAnalysis 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..5695bbf9f 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,17 @@ export function getDisplayName

(Component: React.ComponentType

): string { return Component.displayName ?? Component.name ?? "UnknownComponent" } +/** + * Vrátí true pokud je aktivní klient/skupina „stale" – tj. má datum poslední lekce a ta + * proběhla před více než DAYS_WITHOUT_LECTURE_WARNING dny. + * Nová entita bez jediné lekce (lastLectureDate === 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.tsx b/frontend/src/pages/Card.tsx index f691d705d..3d7f8ed89 100644 --- a/frontend/src/pages/Card.tsx +++ b/frontend/src/pages/Card.tsx @@ -15,6 +15,7 @@ import { 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 +41,7 @@ import { getDefaultValuesForLecture, groupObjectsByCourses, GroupedObjectsByCourses, + isStaleActive, pageTitle, } from "../global/utils" import { ModalClientsGroupsData } from "../types/components" @@ -250,50 +252,69 @@ const Card: React.FC = ({ id, isClientPage }) => { : TEXTS.WARNING_INACTIVE_GROUP} )} - {isClient(object) && ( - - - Telefon: - - - E-mail: - - - Skupiny:{" "} - {groupsOfClient.length === 0 && pastGroups.length === 0 ? ( - žádné skupiny - ) : ( - ( - - )), - ...pastGroups.map((g) => ( - + {object && object.active && isStaleActive(object.last_lecture_date) && ( + + {isClient(object) + ? TEXTS.WARNING_STALE_CLIENT + : TEXTS.WARNING_STALE_GROUP} + + )} + + {isClient(object) && ( + + + + + Telefon: + + + E-mail: + + + Skupiny:{" "} + {groupsOfClient.length === 0 && pastGroups.length === 0 ? ( + žádné skupiny + ) : ( + ( - - )), - ]} - /> - )} - - - Poznámka: - - - )} - + )), + ...pastGroups.map((g) => ( + + + + )), + ]} + /> + )} + + + 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 +47,14 @@ 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)) + } + } + 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 +132,16 @@ 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 +45,14 @@ 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)) + } + } + 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 && ( @@ -97,6 +139,15 @@ const Groups: React.FC = () => { } /> )} + {group.active && + isStaleActive(group.last_lecture_date) && ( + + )}
diff --git a/frontend/src/pages/Statistics.css.ts b/frontend/src/pages/Statistics.css.ts index 35a89dbcd..26a769b04 100644 --- a/frontend/src/pages/Statistics.css.ts +++ b/frontend/src/pages/Statistics.css.ts @@ -3,6 +3,7 @@ import { globalStyle, style } from "@vanilla-extract/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 +122,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": { diff --git a/frontend/src/types/models.ts b/frontend/src/types/models.ts index a286ca0cb..3ac49ea63 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). */ @@ -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[] } From a43ed436c1bf8babe35e766df0fc6c21eef2e69a Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 16:32:53 +0200 Subject: [PATCH 04/24] wip --- frontend/src/components/Tooltip.tsx | 12 ++++++++++-- frontend/src/pages/Clients.tsx | 7 +++++-- frontend/src/pages/Groups.tsx | 7 +++++-- 3 files changed, 20 insertions(+), 6 deletions(-) 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/pages/Clients.tsx b/frontend/src/pages/Clients.tsx index e5af3e49b..1b0aace91 100644 --- a/frontend/src/pages/Clients.tsx +++ b/frontend/src/pages/Clients.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { faCalendarExclamation, faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" import { Alert, Badge, Button, Container, Table } from "reactstrap" @@ -83,7 +83,9 @@ const Clients: React.FC = () => { {active && !clientsActiveContext.isLoading && staleClients.length > 0 && ( + style={{ width: "fit-content" }} + className="d-flex align-items-center gap-3 flex-wrap mx-auto"> + {staleClients.length}{" "} {staleClients.length === 1 @@ -139,6 +141,7 @@ const Clients: React.FC = () => { postfix={`Client_StaleActive_${client.id}`} placement="right" size="1x" + icon={faCalendarExclamation} text={TEXTS.WARNING_STALE_CLIENT} /> )} diff --git a/frontend/src/pages/Groups.tsx b/frontend/src/pages/Groups.tsx index 1be91bff4..8a8626125 100644 --- a/frontend/src/pages/Groups.tsx +++ b/frontend/src/pages/Groups.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { faCalendarExclamation, faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" import { Alert, Badge, Button, Container, Table } from "reactstrap" @@ -82,7 +82,9 @@ const Groups: React.FC = () => { {active && !groupsActiveContext.isLoading && staleGroups.length > 0 && ( + style={{ width: "fit-content" }} + className="d-flex align-items-center gap-3 flex-wrap mx-auto"> + {staleGroups.length}{" "} {staleGroups.length === 1 @@ -145,6 +147,7 @@ const Groups: React.FC = () => { postfix={`Group_StaleActive_${group.id}`} placement="right" size="1x" + icon={faCalendarExclamation} text={TEXTS.WARNING_STALE_GROUP} /> )} From 2245f8b7ab81f21bc9f037b1ffe496fc924a234c Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 16:35:12 +0200 Subject: [PATCH 05/24] fix tests --- frontend/__mocks__/data.json | 21 ++++++++++++++------- frontend/src/types/models.ts | 4 ++-- 2 files changed, 16 insertions(+), 9 deletions(-) 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/types/models.ts b/frontend/src/types/models.ts index 3ac49ea63..2dea2c8d0 100644 --- a/frontend/src/types/models.ts +++ b/frontend/src/types/models.ts @@ -242,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 } From 3ae5d1a177c9888990c7c53e94aa3e863bada467 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 17:42:19 +0200 Subject: [PATCH 06/24] cleanup --- frontend/src/api/hooks/useClientMutations.ts | 6 +----- frontend/src/api/hooks/useGroupMutations.ts | 6 +----- frontend/src/components/ClientAnalysis.css.ts | 13 ++----------- frontend/src/components/charts.css.ts | 12 ++++++++++++ frontend/src/pages/Statistics.css.ts | 13 ++----------- 5 files changed, 18 insertions(+), 32 deletions(-) create mode 100644 frontend/src/components/charts.css.ts diff --git a/frontend/src/api/hooks/useClientMutations.ts b/frontend/src/api/hooks/useClientMutations.ts index 01e901de0..991dac2b0 100644 --- a/frontend/src/api/hooks/useClientMutations.ts +++ b/frontend/src/api/hooks/useClientMutations.ts @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useMutation } from "@tanstack/react-query" import { ClientPostApi, ClientPutApi, ClientType } from "../../types/models" import ClientService from "../services/ClientService" @@ -35,13 +35,9 @@ export function useDeleteClient() { /** Hook pro hromadné přesunutí stale aktivních klientů do neaktivních. */ export function useDeactivateClients() { - const queryClient = useQueryClient() return useMutation({ mutationFn: (ids) => Promise.all(ids.map((id) => ClientService.deactivate(id))).then(() => undefined), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["clients"] }) - }, 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 32bdf34f0..fa21a3d1e 100644 --- a/frontend/src/api/hooks/useGroupMutations.ts +++ b/frontend/src/api/hooks/useGroupMutations.ts @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from "@tanstack/react-query" +import { useMutation } from "@tanstack/react-query" import { GroupPostApi, GroupPutApi, GroupType } from "../../types/models" import GroupService from "../services/GroupService" @@ -35,13 +35,9 @@ export function useDeleteGroup() { /** Hook pro hromadné přesunutí stale aktivních skupin do neaktivních. */ export function useDeactivateGroups() { - const queryClient = useQueryClient() return useMutation({ mutationFn: (ids) => Promise.all(ids.map((id) => GroupService.deactivate(id))).then(() => undefined), - onSuccess: () => { - void queryClient.invalidateQueries({ queryKey: ["groups"] }) - }, meta: { successMessage: "Skupiny přesunuty do neaktivních", }, diff --git a/frontend/src/components/ClientAnalysis.css.ts b/frontend/src/components/ClientAnalysis.css.ts index 44e77aa89..de2bc892a 100644 --- a/frontend/src/components/ClientAnalysis.css.ts +++ b/frontend/src/components/ClientAnalysis.css.ts @@ -1,5 +1,7 @@ import { style } from "@vanilla-extract/css" +export { chartTooltip as tooltip } from "./charts.css" + export const chartPanel = style({ border: "1px solid #dee2e6", borderRadius: "0.375rem", @@ -7,14 +9,3 @@ export const chartPanel = style({ backgroundColor: "#fff", padding: "0.75rem", }) - -export const tooltip = 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", -}) diff --git a/frontend/src/components/charts.css.ts b/frontend/src/components/charts.css.ts new file mode 100644 index 000000000..c72a22f98 --- /dev/null +++ b/frontend/src/components/charts.css.ts @@ -0,0 +1,12 @@ +import { style } from "@vanilla-extract/css" + +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", +}) diff --git a/frontend/src/pages/Statistics.css.ts b/frontend/src/pages/Statistics.css.ts index 26a769b04..43c5ca979 100644 --- a/frontend/src/pages/Statistics.css.ts +++ b/frontend/src/pages/Statistics.css.ts @@ -1,5 +1,7 @@ import { globalStyle, style } from "@vanilla-extract/css" +export { chartTooltip } from "../components/charts.css" + export const statCard = style({ border: "1px solid #dee2e6", borderRadius: "0.375rem", @@ -132,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", From f16df711719ae0c41de75e806d4421fc3ebc1283 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 18:02:59 +0200 Subject: [PATCH 07/24] cleanup --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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/ From ce3677c68eb2453fb11a59bbadba874883f299f3 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 18:40:34 +0200 Subject: [PATCH 08/24] cleanup --- frontend/src/components/ClientAnalysis.tsx | 38 +++++++--------------- frontend/src/components/charts.ts | 20 ++++++++++++ frontend/src/pages/Card.tsx | 20 +++++++----- frontend/src/pages/Statistics.tsx | 23 ++----------- 4 files changed, 45 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/charts.ts diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx index f8703f862..ac6f10078 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -13,6 +13,7 @@ import { import { useAttendanceStatesContext } from "../contexts/AttendanceStatesContext" import { ClientType, CourseType, LectureType, LectureTypeWithDate } from "../types/models" +import { AXIS_LABEL, AXIS_TICK, GRID_STROKE, LEGEND_FONT, MONTH_LABELS } from "./charts" import * as styles from "./ClientAnalysis.css" type Props = { @@ -26,27 +27,7 @@ type CourseInfo = { color: CourseType["color"] } -const MONTH_LABELS = [ - "Led", - "Úno", - "Bře", - "Dub", - "Kvě", - "Čvn", - "Čvc", - "Srp", - "Zář", - "Říj", - "Lis", - "Pro", -] as const - const CHART_MARGIN = { top: 8, right: 8, left: 4, bottom: 8 } as const -const AXIS_TICK = { fontSize: 12, fill: "#6c757d" } as const -const AXIS_LABEL = { fontSize: 11, fill: "#6c757d" } as const - -const GRID_STROKE = "#e9ecef" -const LEGEND_STYLE = { fontSize: 12 } type TooltipEntry = { color: string @@ -87,9 +68,7 @@ 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 scheduled = lectures.filter((l): l is LectureTypeWithDate => l.start !== null) const happened = scheduled.filter((l) => !l.canceled) const notHappened = scheduled.filter((l) => l.canceled) @@ -173,7 +152,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => {
Zaplaceno
- {analysis.monthlyData.length >= 2 && ( + {analysis.monthlyData.length >= 1 && (
@@ -205,7 +184,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { }} /> } /> - {multiCourse && } + {multiCourse && } {analysis.courses.map((course, index) => ( = ({ clientId, lectures }) => { name={course.name} stackId="a" {...(index === 0 && !multiCourse - ? { radius: [3, 3, 0, 0] as [number, number, number, number] } + ? { + radius: [3, 3, 0, 0] as [ + number, + number, + number, + number, + ], + } : {})} /> ))} diff --git a/frontend/src/components/charts.ts b/frontend/src/components/charts.ts new file mode 100644 index 000000000..89f610dcc --- /dev/null +++ b/frontend/src/components/charts.ts @@ -0,0 +1,20 @@ +/** 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 } diff --git a/frontend/src/pages/Card.tsx b/frontend/src/pages/Card.tsx index ce4ff1fc6..8a24a1f05 100644 --- a/frontend/src/pages/Card.tsx +++ b/frontend/src/pages/Card.tsx @@ -259,8 +259,10 @@ const Card: React.FC = ({ id, isClientPage }) => { : TEXTS.WARNING_STALE_GROUP} )} - {isClient(object) && ( - +
+ {isClient(object) && ( +
+ Telefon: @@ -301,13 +303,13 @@ const Card: React.FC = ({ id, isClientPage }) => { Poznámka: - )} -
- {isClient(object) && ( - +
+ +
+ )} {isGroup(object) && ( = { @@ -556,7 +537,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í" From b61db78decb0c0334dab759ba37cf9dd2d6fbbd4 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 18:52:32 +0200 Subject: [PATCH 09/24] wip --- frontend/src/components/charts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/charts.ts b/frontend/src/components/charts.ts index 89f610dcc..4b019013b 100644 --- a/frontend/src/components/charts.ts +++ b/frontend/src/components/charts.ts @@ -17,4 +17,4 @@ export const MONTH_LABELS = [ 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 } +export const LEGEND_FONT = { fontSize: 12 } as const From 2e31d1f747c10b5d6fb0ad666ef2c9a8cd58ecf8 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 14 Apr 2026 20:03:13 +0200 Subject: [PATCH 10/24] wip --- .mcp.json | 8 ++++++++ AGENTS.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .mcp.json 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 index 33e9cd028..355bd7e42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ pipenv run python manage.py migrate # aplikuje migrace na DB # 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 @@ -75,7 +76,7 @@ Systém pro správu lekcí a kurzů — Django REST API backend + React SPA fron ### Backend -Django 5 + Django REST Framework — REST API pro všechny operace. Kód je rozdělen do Django aplikací: +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) From 3472b0cfb4d5665e8987a602004742edd0e0eac9 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Wed, 15 Apr 2026 07:18:49 +0200 Subject: [PATCH 11/24] cleanup --- frontend/src/api/hooks/useClientMutations.ts | 2 +- frontend/src/api/hooks/useGroupMutations.ts | 2 +- frontend/src/components/ClientAnalysis.css.ts | 6 +++--- frontend/src/components/charts.css.ts | 8 ++++++-- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/hooks/useClientMutations.ts b/frontend/src/api/hooks/useClientMutations.ts index 991dac2b0..01c64be84 100644 --- a/frontend/src/api/hooks/useClientMutations.ts +++ b/frontend/src/api/hooks/useClientMutations.ts @@ -33,7 +33,7 @@ export function useDeleteClient() { }) } -/** Hook pro hromadné přesunutí stale aktivních klientů do neaktivních. */ +/** Hook pro hromadné přesunutí aktivních klientů bez lekce v poslední době do neaktivních. */ export function useDeactivateClients() { return useMutation({ mutationFn: (ids) => diff --git a/frontend/src/api/hooks/useGroupMutations.ts b/frontend/src/api/hooks/useGroupMutations.ts index fa21a3d1e..704eb7687 100644 --- a/frontend/src/api/hooks/useGroupMutations.ts +++ b/frontend/src/api/hooks/useGroupMutations.ts @@ -33,7 +33,7 @@ export function useDeleteGroup() { }) } -/** Hook pro hromadné přesunutí stale aktivních skupin do neaktivních. */ +/** Hook pro hromadné přesunutí aktivních skupin bez lekce v poslední době do neaktivních. */ export function useDeactivateGroups() { return useMutation({ mutationFn: (ids) => diff --git a/frontend/src/components/ClientAnalysis.css.ts b/frontend/src/components/ClientAnalysis.css.ts index de2bc892a..8d1b0d92e 100644 --- a/frontend/src/components/ClientAnalysis.css.ts +++ b/frontend/src/components/ClientAnalysis.css.ts @@ -1,11 +1,11 @@ import { style } from "@vanilla-extract/css" +import { chartBaseStyles } from "./charts.css" + export { chartTooltip as tooltip } from "./charts.css" export const chartPanel = style({ - border: "1px solid #dee2e6", - borderRadius: "0.375rem", + ...chartBaseStyles, boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", - backgroundColor: "#fff", padding: "0.75rem", }) diff --git a/frontend/src/components/charts.css.ts b/frontend/src/components/charts.css.ts index c72a22f98..d138ce96b 100644 --- a/frontend/src/components/charts.css.ts +++ b/frontend/src/components/charts.css.ts @@ -1,10 +1,14 @@ import { style } from "@vanilla-extract/css" -export const chartTooltip = style({ +export const chartBaseStyles = { border: "1px solid #dee2e6", borderRadius: "0.375rem", - boxShadow: "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)", 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", From 428e3b057f7a8ebe43b51c2703272e36511951a2 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 19:02:30 +0200 Subject: [PATCH 12/24] fix claude --- CLAUDE.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b1fc3531..2bf357e80 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,3 +1,5 @@ +@AGENTS.md + # Agent Instructions Pointer Tento soubor je záměrně stručný. @@ -6,6 +8,5 @@ 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`. +- Řiď se výhradně pravidly z `AGENTS.md`. +- Pokud je zde cokoliv v konfliktu s `AGENTS.md`, přednost má `AGENTS.md`. From 06cb42181c572e637591faf7b235ef016cb95821 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 19:29:16 +0200 Subject: [PATCH 13/24] cr fixes --- api/filters.py | 34 +++--- api/tests/test_groups_filter.py | 114 +++++++++++++++++++++ frontend/src/components/ClientAnalysis.tsx | 11 +- frontend/src/components/GroupName.css.ts | 2 + frontend/src/components/GroupName.tsx | 2 +- frontend/src/components/GroupsList.tsx | 24 ----- frontend/src/components/charts.ts | 7 ++ frontend/src/pages/Card.css.ts | 6 +- frontend/src/pages/Statistics.tsx | 15 ++- 9 files changed, 162 insertions(+), 53 deletions(-) create mode 100644 api/tests/test_groups_filter.py delete mode 100644 frontend/src/components/GroupsList.tsx diff --git a/api/filters.py b/api/filters.py index 76a2ce1bd..b9a9f3116 100644 --- a/api/filters.py +++ b/api/filters.py @@ -40,28 +40,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 zpracovává společně 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: + # samotný onlyPast (bez client) nemá efekt; filtrování probíhá ve filter_client return queryset class Meta: diff --git a/api/tests/test_groups_filter.py b/api/tests/test_groups_filter.py new file mode 100644 index 000000000..45c70c535 --- /dev/null +++ b/api/tests/test_groups_filter.py @@ -0,0 +1,114 @@ +""" +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 + + +def _ids(response_data: list[dict]) -> set[int]: + return {item["id"] for item in response_data} + + +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 aktuální člen, má v ní účast 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 ní měl lekci, ale už není členem (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 + ) + # aktuálně tam má členství jen jiný klient + Membership.objects.create(client=self.client_other, group=self.group_past) + + # Skupina C — klient nikdy neúčastnil, nemá tam členství (kontrolní) + 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/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx index ac6f10078..eea569633 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -13,7 +13,14 @@ import { import { useAttendanceStatesContext } from "../contexts/AttendanceStatesContext" import { ClientType, CourseType, LectureType, LectureTypeWithDate } from "../types/models" -import { AXIS_LABEL, AXIS_TICK, GRID_STROKE, LEGEND_FONT, MONTH_LABELS } from "./charts" +import { + AXIS_LABEL, + AXIS_TICK, + ChartMargin, + GRID_STROKE, + LEGEND_FONT, + MONTH_LABELS, +} from "./charts" import * as styles from "./ClientAnalysis.css" type Props = { @@ -27,7 +34,7 @@ type CourseInfo = { color: CourseType["color"] } -const CHART_MARGIN = { top: 8, right: 8, left: 4, bottom: 8 } as const +const CHART_MARGIN: ChartMargin = { top: 8, right: 8, left: 4, bottom: 8 } type TooltipEntry = { color: string diff --git a/frontend/src/components/GroupName.css.ts b/frontend/src/components/GroupName.css.ts index a05ab75a6..a8ee32de6 100644 --- a/frontend/src/components/GroupName.css.ts +++ b/frontend/src/components/GroupName.css.ts @@ -3,3 +3,5 @@ import { style } from "@vanilla-extract/css" export const courseCircle = style({ marginRight: "0.25rem", }) + +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/charts.ts b/frontend/src/components/charts.ts index 4b019013b..7011810aa 100644 --- a/frontend/src/components/charts.ts +++ b/frontend/src/components/charts.ts @@ -18,3 +18,10 @@ 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/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/Statistics.tsx b/frontend/src/pages/Statistics.tsx index 48b01de46..f6a7a24ea 100644 --- a/frontend/src/pages/Statistics.tsx +++ b/frontend/src/pages/Statistics.tsx @@ -19,7 +19,14 @@ import { import { useStatistics } from "../api/hooks" import APP_URLS from "../APP_URLS" -import { AXIS_LABEL, AXIS_TICK, GRID_STROKE, LEGEND_FONT, MONTH_LABELS } from "../components/charts" +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" @@ -28,10 +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 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") { From 89cc8e16e353f57261c0487335fcc05f8c3dddb8 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 19:34:26 +0200 Subject: [PATCH 14/24] cr fixes --- api/filters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/filters.py b/api/filters.py index b9a9f3116..7b768b7c5 100644 --- a/api/filters.py +++ b/api/filters.py @@ -55,7 +55,7 @@ def filter_client(self, queryset: QuerySet, name: str, value: int) -> QuerySet: return queryset.filter(memberships__client__pk=value) def filter_only_past(self, queryset: QuerySet, name: str, value: bool) -> QuerySet: - # samotný onlyPast (bez client) nemá efekt; filtrování probíhá ve filter_client + # samostatny onlyPast (bez client) nema efekt; filtrovani probiha ve filter_client return queryset class Meta: From 00f4f31ded356f748c559f4c0fd7ee8237bc2bf5 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 19:34:36 +0200 Subject: [PATCH 15/24] cr fixes --- frontend/src/components/ClientAnalysis.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx index eea569633..40f7c7256 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -159,7 +159,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => {
Zaplaceno
- {analysis.monthlyData.length >= 1 && ( + {analysis.monthlyData.length > 0 && (
From b4dffd22c2bfa0c2c554b379b2dc69b0881f929c Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 20:09:59 +0200 Subject: [PATCH 16/24] tuneups --- api/filters.py | 20 ++-- api/tests/test_lectures_filter.py | 99 ++++++++++++++++++++ api/views.py | 29 +++++- frontend/src/api/hooks/useClientMutations.ts | 3 +- frontend/src/api/hooks/useGroupMutations.ts | 3 +- frontend/src/api/hooks/useLectureQuery.ts | 14 +++ frontend/src/api/services/ClientService.ts | 12 +-- frontend/src/api/services/GroupService.ts | 12 +-- frontend/src/api/services/LectureService.ts | 10 ++ frontend/src/api/urls.ts | 1 + frontend/src/components/ClientAnalysis.tsx | 32 ++++--- frontend/src/pages/Card.tsx | 40 +++++--- 12 files changed, 221 insertions(+), 54 deletions(-) create mode 100644 api/tests/test_lectures_filter.py diff --git a/api/filters.py b/api/filters.py index 7b768b7c5..85b9b7029 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 diff --git a/api/tests/test_lectures_filter.py b/api/tests/test_lectures_filter.py new file mode 100644 index 000000000..2a4a8ad03 --- /dev/null +++ b/api/tests/test_lectures_filter.py @@ -0,0 +1,99 @@ +""" +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 + + +def _ids(response_data: list[dict]) -> set[int]: + return {item["id"] for item in response_data} + + +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 — individuální lekce cílového 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 — skupinová lekce cílového 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 — individuální lekce jiného klienta (kontrolní) + 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 20e0f0266..6ca99121f 100644 --- a/api/views.py +++ b/api/views.py @@ -9,7 +9,8 @@ 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): """ @@ -100,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"]), @@ -204,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): """ @@ -263,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/src/api/hooks/useClientMutations.ts b/frontend/src/api/hooks/useClientMutations.ts index 01c64be84..72a0bc179 100644 --- a/frontend/src/api/hooks/useClientMutations.ts +++ b/frontend/src/api/hooks/useClientMutations.ts @@ -36,8 +36,7 @@ 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) => - Promise.all(ids.map((id) => ClientService.deactivate(id))).then(() => undefined), + 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 704eb7687..65f6d14ef 100644 --- a/frontend/src/api/hooks/useGroupMutations.ts +++ b/frontend/src/api/hooks/useGroupMutations.ts @@ -36,8 +36,7 @@ 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) => - Promise.all(ids.map((id) => GroupService.deactivate(id))).then(() => undefined), + 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 80bf6feab..4eab08d0a 100644 --- a/frontend/src/api/services/ClientService.ts +++ b/frontend/src/api/services/ClientService.ts @@ -60,12 +60,12 @@ function create(context: ClientPostApi): Promise { }) } -/** Deaktivuje klienta. */ -function deactivate(id: Item["id"]): Promise { - return axiosRequestData({ - url: `${baseUrl}${id}${API_DELIM}`, +/** Hromadně deaktivuje klienty. */ +function deactivateAll(ids: Item["id"][]): Promise { + return axiosRequestData({ + url: `${baseUrl}deactivate-bulk${API_DELIM}`, method: API_METHODS.patch, - data: { active: false }, + data: { ids }, }) } @@ -77,7 +77,7 @@ const ClientService = { create, update, remove, - deactivate, + deactivateAll, } export default ClientService diff --git a/frontend/src/api/services/GroupService.ts b/frontend/src/api/services/GroupService.ts index 4e00728e2..aa5853fad 100644 --- a/frontend/src/api/services/GroupService.ts +++ b/frontend/src/api/services/GroupService.ts @@ -82,12 +82,12 @@ function create(context: GroupPostApi): Promise { }) } -/** Deaktivuje skupinu. */ -function deactivate(id: Item["id"]): Promise { - return axiosRequestData({ - url: `${baseUrl}${id}${API_DELIM}`, +/** Hromadně deaktivuje skupiny. */ +function deactivateAll(ids: Item["id"][]): Promise { + return axiosRequestData({ + url: `${baseUrl}deactivate-bulk${API_DELIM}`, method: API_METHODS.patch, - data: { active: false }, + data: { ids }, }) } @@ -99,7 +99,7 @@ const GroupService = { create, update, remove, - deactivate, + 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.tsx b/frontend/src/components/ClientAnalysis.tsx index 40f7c7256..4176ae783 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -29,8 +29,8 @@ type Props = { } type CourseInfo = { - id: CourseType["id"] - name: CourseType["name"] + key: string + name: string color: CourseType["color"] } @@ -91,13 +91,15 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { return att?.paid === true }) - // Sesbírej unikátní kurzy v pořadí výskytu - const courseMap = new Map() + // Sesbírej unikátní kurzy (individuální a skupinové zvlášť) v pořadí výskytu + const courseMap = new Map() for (const lecture of happened) { - if (!courseMap.has(lecture.course.id)) { - courseMap.set(lecture.course.id, { - id: lecture.course.id, - name: lecture.course.name, + 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, }) } @@ -107,10 +109,11 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { // Počty lekcí per kurz per měsíc 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 = String(lecture.course.id) + const courseKey = `${lecture.course.id}_${isGroup ? "g" : "i"}` monthData[courseKey] = (monthData[courseKey] ?? 0) + 1 monthMap.set(key, monthData) } @@ -133,7 +136,6 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { return null } - const multiCourse = analysis.courses.length > 1 return (
@@ -161,7 +163,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => {
{analysis.monthlyData.length > 0 && (
- + = ({ clientId, lectures }) => { }} /> } /> - {multiCourse && } + {true && } {analysis.courses.map((course, index) => ( = ({ id, isClientPage }) => { 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ě. */ @@ -120,23 +122,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 => { @@ -306,7 +316,7 @@ const Card: React.FC = ({ id, isClientPage }) => {
From a57a190eaee13a14e809ca6eaa7c6ab495613543 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 20:19:06 +0200 Subject: [PATCH 17/24] fixes --- frontend/src/components/ClientAnalysis.tsx | 16 +++------------- frontend/src/global/utils.ts | 4 +++- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx index 4176ae783..48ab53538 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -163,7 +163,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => {
{analysis.monthlyData.length > 0 && (
- + = ({ clientId, lectures }) => { }} /> } /> - {true && } - {analysis.courses.map((course, index) => ( + + {analysis.courses.map((course) => ( ))} diff --git a/frontend/src/global/utils.ts b/frontend/src/global/utils.ts index 5695bbf9f..a64a0a102 100644 --- a/frontend/src/global/utils.ts +++ b/frontend/src/global/utils.ts @@ -184,7 +184,9 @@ export function getDisplayName

(Component: React.ComponentType

): string { * Nová entita bez jediné lekce (lastLectureDate === null) varování nedostane. */ export function isStaleActive(lastLectureDate: string | null): boolean { - if (!lastLectureDate) { return false } + if (!lastLectureDate) { + return false + } const daysSince = (Date.now() - new Date(lastLectureDate).getTime()) / (1000 * 60 * 60 * 24) return daysSince > DAYS_WITHOUT_LECTURE_WARNING } From 3b71c3a7ea91f8fa56647057c08f55ed9d8cd203 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 21:08:23 +0200 Subject: [PATCH 18/24] wip --- api/tests/test_bulk_deactivate.py | 100 ++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 api/tests/test_bulk_deactivate.py 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."}) From 1fd3728bb3d8e0858d72f70efe3c9c98abec3c8a Mon Sep 17 00:00:00 2001 From: rodlukas Date: Fri, 17 Apr 2026 21:32:55 +0200 Subject: [PATCH 19/24] update agents.md --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 355bd7e42..1b0e7deed 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ pipenv run python manage.py makemigrations # vytvoří nové migrace po změn 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 přes `make db` nebo `docker compose up`. Bez běžící PostgreSQL na `localhost:5432` backend testy selžou už při inicializaci testovací databáze. + ### Frontend (TypeScript / React) ```bash @@ -125,6 +127,12 @@ React 19 SPA v [frontend/src/](frontend/src/). Webpack dev server na portu 3000 **Pre-commit hooky (Husky + lint-staged):** automaticky spouštějí ESLint a Prettier na staged JS/TS souborech. +## Ověření změn + +- Č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`: From 2957f82137c97d1ae563b610569bd9fd468a70cc Mon Sep 17 00:00:00 2001 From: rodlukas Date: Sat, 18 Apr 2026 09:09:08 +0200 Subject: [PATCH 20/24] cleanup --- AGENTS.md | 7 +++++-- api/filters.py | 2 +- api/tests/helpers.py | 2 ++ api/tests/test_groups_filter.py | 4 +--- api/tests/test_lectures_filter.py | 4 +--- frontend/src/components/ClientAnalysis.tsx | 5 ++--- frontend/src/components/GroupName.css.ts | 1 + frontend/src/global/utils.ts | 6 +----- 8 files changed, 14 insertions(+), 17 deletions(-) create mode 100644 api/tests/helpers.py diff --git a/AGENTS.md b/AGENTS.md index 1b0e7deed..d56389001 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,10 @@ cp .env.template .env pipenv install --dev # Python závislosti npm ci # Node závislosti (postinstall hook automaticky nainstaluje i frontend a buildne ho) -# 3. Spusť databázi a servery +# 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 @@ -48,7 +51,7 @@ pipenv run python manage.py makemigrations # vytvoří nové migrace po změn 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 přes `make db` nebo `docker compose up`. Bez běžící PostgreSQL na `localhost:5432` backend testy selžou už při inicializaci testovací databáze. +> **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) diff --git a/api/filters.py b/api/filters.py index 85b9b7029..f38a44e44 100644 --- a/api/filters.py +++ b/api/filters.py @@ -50,7 +50,7 @@ class GroupFilter(filters.FilterSet): onlyPast = filters.BooleanFilter(method="filter_only_past") def filter_client(self, queryset: QuerySet, name: str, value: int) -> QuerySet: - # parametr onlyPast se zpracovává společně s filtrem client + # parametr onlyPast se zpracovava spolecne s filtrem client only_past = self.form.cleaned_data.get("onlyPast") if only_past: return ( 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_groups_filter.py b/api/tests/test_groups_filter.py index 45c70c535..62a385bf8 100644 --- a/api/tests/test_groups_filter.py +++ b/api/tests/test_groups_filter.py @@ -11,9 +11,7 @@ from admin.models import Attendance, AttendanceState, Client, Course, Group, Lecture, Membership - -def _ids(response_data: list[dict]) -> set[int]: - return {item["id"] for item in response_data} +from .helpers import ids as _ids class GroupClientFilterTest(TestCase): diff --git a/api/tests/test_lectures_filter.py b/api/tests/test_lectures_filter.py index 2a4a8ad03..1b53ed357 100644 --- a/api/tests/test_lectures_filter.py +++ b/api/tests/test_lectures_filter.py @@ -11,9 +11,7 @@ from admin.models import Attendance, AttendanceState, Client, Course, Group, Lecture, Membership - -def _ids(response_data: list[dict]) -> set[int]: - return {item["id"] for item in response_data} +from .helpers import ids as _ids class LectureClientFilterTest(TestCase): diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx index 48ab53538..1699af156 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -91,7 +91,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { return att?.paid === true }) - // Sesbírej unikátní kurzy (individuální a skupinové zvlášť) v pořadí výskytu + // unikatni kurzy (individualni a skupinove zvlast) v poradi vyskytu const courseMap = new Map() for (const lecture of happened) { const isGroup = lecture.group !== null @@ -106,7 +106,7 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { } const courses = Array.from(courseMap.values()) - // Počty lekcí per kurz per měsíc + // pocty lekci per kurz per mesic const monthMap = new Map>() for (const lecture of happened) { const isGroup = lecture.group !== null @@ -136,7 +136,6 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { return null } - return (

diff --git a/frontend/src/components/GroupName.css.ts b/frontend/src/components/GroupName.css.ts index a8ee32de6..0dc626a54 100644 --- a/frontend/src/components/GroupName.css.ts +++ b/frontend/src/components/GroupName.css.ts @@ -4,4 +4,5 @@ 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/global/utils.ts b/frontend/src/global/utils.ts index a64a0a102..7fd6836b8 100644 --- a/frontend/src/global/utils.ts +++ b/frontend/src/global/utils.ts @@ -178,11 +178,7 @@ export function getDisplayName

(Component: React.ComponentType

): string { return Component.displayName ?? Component.name ?? "UnknownComponent" } -/** - * Vrátí true pokud je aktivní klient/skupina „stale" – tj. má datum poslední lekce a ta - * proběhla před více než DAYS_WITHOUT_LECTURE_WARNING dny. - * Nová entita bez jediné lekce (lastLectureDate === null) varování nedostane. - */ +/** 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 From a4d3bfa6ddd61763b21869c7d17097bc8eccfc24 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Sat, 18 Apr 2026 09:45:37 +0200 Subject: [PATCH 21/24] tuneups --- AGENTS.md | 5 +++ frontend/src/analytics.ts | 2 + frontend/src/pages/Card.tsx | 72 +++++++++++++++++++++++++++++++--- frontend/src/pages/Clients.tsx | 9 ++++- frontend/src/pages/Groups.tsx | 9 ++++- 5 files changed, 89 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d56389001..9b5100205 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,11 @@ React 19 SPA v [frontend/src/](frontend/src/). Webpack dev server na portu 3000 ## 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. 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/pages/Card.tsx b/frontend/src/pages/Card.tsx index 7dcf52bdf..67e1640a8 100644 --- a/frontend/src/pages/Card.tsx +++ b/frontend/src/pages/Card.tsx @@ -1,12 +1,17 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" 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, @@ -70,12 +75,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 lecturesFromClientAllQuery = useLecturesFromClientAll( + isClientPageValue ? id : undefined, + false, + ) const lecturesFromGroupQuery = useLecturesFromGroup(isClientPageValue ? undefined : id, false) /** Klient nebo skupina zobrazená na kartě. */ @@ -163,6 +174,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 navigate({ to: APP_URLS.klienti.url }) + }, + }) + } else { + deactivateGroup.mutate([id], { + onSuccess: () => { + trackEvent("group_deactivated", { source: "group_card" }) + void navigate({ to: APP_URLS.skupiny.url }) + }, + }) + } + } + const cardSource = isClientPageValue ? ("client_card" as const) : ("group_card" as const) const renderLecture = (lecture: LectureType): React.ReactElement => { @@ -263,10 +299,34 @@ const Card: React.FC = ({ id, isClientPage }) => { )} {object && object.active && isStaleActive(object.last_lecture_date) && ( - - {isClient(object) - ? TEXTS.WARNING_STALE_CLIENT - : TEXTS.WARNING_STALE_GROUP} + + + {isClient(object) + ? TEXTS.WARNING_STALE_CLIENT + : TEXTS.WARNING_STALE_GROUP} + + )}

diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx index 1b0aace91..cdcc65e4c 100644 --- a/frontend/src/pages/Clients.tsx +++ b/frontend/src/pages/Clients.tsx @@ -3,6 +3,7 @@ import { faCalendarExclamation, faSpinnerThird } from "@rodlukas/fontawesome-pro import * as React from "react" import { Alert, Badge, Button, Container, Table } from "reactstrap" +import { trackEvent } from "../analytics" import { useDeactivateClients, useInactiveClients } from "../api/hooks" import APP_URLS from "../APP_URLS" import ActiveSwitcher from "../components/buttons/ActiveSwitcher" @@ -51,7 +52,13 @@ const Clients: React.FC = () => { 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)) + deactivateClients.mutate( + staleClients.map((c) => c.id), + { + onSuccess: () => + trackEvent("client_deactivated", { source: "clients_page", count }), + }, + ) } } diff --git a/frontend/src/pages/Groups.tsx b/frontend/src/pages/Groups.tsx index 8a8626125..e67b3233e 100644 --- a/frontend/src/pages/Groups.tsx +++ b/frontend/src/pages/Groups.tsx @@ -3,6 +3,7 @@ import { faCalendarExclamation, faSpinnerThird } from "@rodlukas/fontawesome-pro import * as React from "react" import { Alert, Badge, Button, Container, Table } from "reactstrap" +import { trackEvent } from "../analytics" import { useDeactivateGroups, useInactiveGroups } from "../api/hooks" import APP_URLS from "../APP_URLS" import ActiveSwitcher from "../components/buttons/ActiveSwitcher" @@ -49,7 +50,13 @@ const Groups: React.FC = () => { 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)) + deactivateGroups.mutate( + staleGroups.map((g) => g.id), + { + onSuccess: () => + trackEvent("group_deactivated", { source: "groups_page", count }), + }, + ) } } From 099b02e848629d076c42d56f4d873b5b52687dac Mon Sep 17 00:00:00 2001 From: rodlukas Date: Sat, 18 Apr 2026 09:47:06 +0200 Subject: [PATCH 22/24] wip --- frontend/src/pages/Card.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/Card.tsx b/frontend/src/pages/Card.tsx index 67e1640a8..35120c485 100644 --- a/frontend/src/pages/Card.tsx +++ b/frontend/src/pages/Card.tsx @@ -1,5 +1,6 @@ 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" @@ -67,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 => @@ -186,14 +188,14 @@ const Card: React.FC = ({ id, isClientPage }) => { deactivateClient.mutate([id], { onSuccess: () => { trackEvent("client_deactivated", { source: "client_card" }) - void navigate({ to: APP_URLS.klienti.url }) + void queryClient.invalidateQueries({ queryKey: ["clients", id] }) }, }) } else { deactivateGroup.mutate([id], { onSuccess: () => { trackEvent("group_deactivated", { source: "group_card" }) - void navigate({ to: APP_URLS.skupiny.url }) + void queryClient.invalidateQueries({ queryKey: ["groups", id] }) }, }) } From a2921d6a65774bedf610aa0ec594ac3a5c314d01 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Sat, 18 Apr 2026 10:02:01 +0200 Subject: [PATCH 23/24] wip --- frontend/src/pages/Clients.tsx | 6 ++-- frontend/src/pages/Groups.tsx | 52 ++++++++++++++++++++-------------- 2 files changed, 33 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/Clients.tsx b/frontend/src/pages/Clients.tsx index cdcc65e4c..f79d0523b 100644 --- a/frontend/src/pages/Clients.tsx +++ b/frontend/src/pages/Clients.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faCalendarExclamation, faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { faHourglassEnd, faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" import { Alert, Badge, Button, Container, Table } from "reactstrap" @@ -92,7 +92,7 @@ const Clients: React.FC = () => { color="warning" style={{ width: "fit-content" }} className="d-flex align-items-center gap-3 flex-wrap mx-auto"> - + {staleClients.length}{" "} {staleClients.length === 1 @@ -148,7 +148,7 @@ const Clients: React.FC = () => { postfix={`Client_StaleActive_${client.id}`} placement="right" size="1x" - icon={faCalendarExclamation} + icon={faHourglassEnd} text={TEXTS.WARNING_STALE_CLIENT} /> )} diff --git a/frontend/src/pages/Groups.tsx b/frontend/src/pages/Groups.tsx index e67b3233e..c911970cf 100644 --- a/frontend/src/pages/Groups.tsx +++ b/frontend/src/pages/Groups.tsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faCalendarExclamation, faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { faHourglassEnd, faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" import { Alert, Badge, Button, Container, Table } from "reactstrap" @@ -91,7 +91,7 @@ const Groups: React.FC = () => { color="warning" style={{ width: "fit-content" }} className="d-flex align-items-center gap-3 flex-wrap mx-auto"> - + {staleGroups.length}{" "} {staleGroups.length === 1 @@ -136,27 +136,35 @@ const Groups: React.FC = () => { {getGroupsData().map((group) => (
- {" "} + {group.active && - !areAllMembersActive(group.memberships) && ( - - )} - {group.active && - isStaleActive(group.last_lecture_date) && ( - + (!areAllMembersActive(group.memberships) || + isStaleActive(group.last_lecture_date)) && ( + + {!areAllMembersActive( + group.memberships, + ) && ( + + )} + {isStaleActive( + group.last_lecture_date, + ) && ( + + )} + )} From 9670d126ce62c515f08089563f4941b9b74248e8 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Sat, 18 Apr 2026 10:23:40 +0200 Subject: [PATCH 24/24] wip --- api/tests/test_groups_filter.py | 8 ++++---- api/tests/test_lectures_filter.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/tests/test_groups_filter.py b/api/tests/test_groups_filter.py index 62a385bf8..7e68c0766 100644 --- a/api/tests/test_groups_filter.py +++ b/api/tests/test_groups_filter.py @@ -37,7 +37,7 @@ def setUp(self) -> None: self.client_target = Client.objects.create(firstname="Alice", surname="T") self.client_other = Client.objects.create(firstname="Bob", surname="T") - # Skupina A — klient je aktuální člen, má v ní účast na lekci + # 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( @@ -47,7 +47,7 @@ def setUp(self) -> None: client=self.client_target, lecture=lecture_current, paid=True, attendancestate=state_ok ) - # Skupina B — klient v ní měl lekci, ale už není členem (opustil) + # 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 @@ -55,10 +55,10 @@ def setUp(self) -> None: Attendance.objects.create( client=self.client_target, lecture=lecture_past, paid=True, attendancestate=state_ok ) - # aktuálně tam má členství jen jiný klient + # aktualne tam ma clenstvi jen jiny klient Membership.objects.create(client=self.client_other, group=self.group_past) - # Skupina C — klient nikdy neúčastnil, nemá tam členství (kontrolní) + # 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) diff --git a/api/tests/test_lectures_filter.py b/api/tests/test_lectures_filter.py index 1b53ed357..d77840f59 100644 --- a/api/tests/test_lectures_filter.py +++ b/api/tests/test_lectures_filter.py @@ -37,7 +37,7 @@ def setUp(self) -> None: self.client_target = Client.objects.create(firstname="Alice", surname="T") self.client_other = Client.objects.create(firstname="Bob", surname="T") - # Lekce A — individuální lekce cílového klienta (group=null) + # Lekce A — individualni lekce ciloveho klienta (group=null) self.lecture_individual = Lecture.objects.create( start=start, canceled=False, duration=60, course=course, group=None ) @@ -48,7 +48,7 @@ def setUp(self) -> None: attendancestate=state_ok, ) - # Lekce B — skupinová lekce cílového klienta (group!=null) + # 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( @@ -61,7 +61,7 @@ def setUp(self) -> None: attendancestate=state_ok, ) - # Lekce C — individuální lekce jiného klienta (kontrolní) + # Lekce C — individualni lekce jineho klienta (kontrolni) self.lecture_other = Lecture.objects.create( start=start, canceled=False, duration=60, course=course, group=None )