diff --git a/AGENTS.md b/AGENTS.md index 1f414519f..a553f6e6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,8 +95,8 @@ Django 6 + Django REST Framework — REST API pro všechny operace. Kód je rozd **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 +- Typování: mypy — veškerý nový kód musí mít typové anotace, mypy nesmí hlásit chyby; pozor: `mypy.ini` má `exclude = tests`, E2E kroky tedy CI typově nehlídá (konvence pro ně platí dál) +- Dead code: vulture — nepoužívané symboly jsou chybou; vulture není zapojený v CI, spouští se ručně (bez whitelistu hlásí šum z migrací) - Závislosti: Pipenv (`Pipfile` + `Pipfile.lock`) — nikdy `pip install` přímo ### Frontend @@ -109,7 +109,8 @@ React 19 SPA v [frontend/src/](frontend/src/). Webpack dev server na portu 3000 - 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 +- UI: Mantine 9 (`@mantine/core`, `form`, `hooks`, `notifications`, `spotlight`) + FontAwesome PRO ikony +- Dark mode: barevné schéma (světlý/tmavý/systém) přes Mantine, přepínač v navbaru; FOUC řeší init skript `admin/static/admin/color-scheme-init.js` - Fuzzy search: Fuse.js - Grafy: Recharts diff --git a/admin/static/admin/color-scheme-init.js b/admin/static/admin/color-scheme-init.js new file mode 100644 index 000000000..c44192493 --- /dev/null +++ b/admin/static/admin/color-scheme-init.js @@ -0,0 +1,32 @@ +// Inicializace barevneho schematu jeste PRED prvnim vykreslenim stranky (zabrana FOUC +// u dark uzivatelu). Musi zustat maly synchronni ES5 skript bez modulu — bezi driv, +// nez se nacte React aplikace i Mantine. +// +// Semantika musi presne odpovidat Mantine (@mantine/core): +// - stejny localStorage klic "mantine-color-scheme" (pouziva ho localStorageColorSchemeManager), +// - "auto" se ridi systemovym nastavenim (prefers-color-scheme), +// - nastavuje se atribut data-mantine-color-scheme (na nej cili Mantine CSS) +// + inline style color-scheme (kvuli nativnim scrollbarum/form controls bez FOUC). +(function () { + try { + // povolene ulozene hodnoty — cokoliv jineho (poskozena/rucne prepsana hodnota + // v localStorage) musi spadnout na chovani "auto" + var KNOWN_SCHEMES = ["light", "dark", "auto"]; + var stored = window.localStorage.getItem("mantine-color-scheme"); + var scheme = KNOWN_SCHEMES.indexOf(stored) !== -1 ? stored : "auto"; + + // "auto" = podle aktualniho systemoveho schematu + var resolved = + scheme === "auto" + ? window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light" + : scheme; + + document.documentElement.setAttribute("data-mantine-color-scheme", resolved); + document.documentElement.style.colorScheme = resolved; + } catch (e) { + // localStorage/matchMedia nemusi byt dostupne (private mode apod.) — ticha + // degradace, schema pak nastavi az Mantine po startu aplikace + } +})(); diff --git a/admin/static/admin/index.css b/admin/static/admin/index.css index 87b448ac6..842511bd3 100644 --- a/admin/static/admin/index.css +++ b/admin/static/admin/index.css @@ -19,8 +19,19 @@ } } +/* Stejné hexy jako pozadí SPA — token `vars.bg.page` v frontend/src/theme/tokens.ts + (light #e9eef5, dark dark-9 #141414), aby pozadí při mountu Reactu neskočilo. + Tento soubor nemůže TS tokeny importovat, hodnoty drž v synchronu ručně. */ body { - background-color: #ecf0f5; + background-color: #e9eef5; font-family: "Segoe UI", Arial, sans-serif; - text-align: center; +} + +html[data-mantine-color-scheme="dark"] body { + background-color: #141414; +} + +html[data-mantine-color-scheme="dark"] .loader:empty { + border-color: rgba(255, 255, 255, 0.1); + border-left-color: #f8fafc; } diff --git a/admin/static/admin/site.webmanifest b/admin/static/admin/site.webmanifest index f64d1da90..74ebf600b 100644 --- a/admin/static/admin/site.webmanifest +++ b/admin/static/admin/site.webmanifest @@ -12,8 +12,8 @@ "type": "image/png" } ], - "theme_color": "#ffffff", - "background_color": "#ffffff", + "theme_color": "#1f2b3c", + "background_color": "#1f2b3c", "display": "standalone", "start_url": "/?utm_source=a2hs" } diff --git a/admin/templates/head.html b/admin/templates/head.html index 2434c4a9c..483d07a3b 100644 --- a/admin/templates/head.html +++ b/admin/templates/head.html @@ -3,6 +3,18 @@ + + + + @@ -11,4 +23,3 @@ - diff --git a/docs/superpowers/specs/2026-05-05-darkmode-toggle-design.md b/docs/superpowers/specs/2026-05-05-darkmode-toggle-design.md new file mode 100644 index 000000000..3177b4b8f --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-darkmode-toggle-design.md @@ -0,0 +1,38 @@ +# Design: Dark mode toggle v nastavení + +> **Odchylka implementace:** přepínač nakonec není `SegmentedControl` na stránce Nastavení, +> ale dropdown menu v navbaru (`ColorSchemeToggle.tsx`, `data-qa="color_scheme_toggle"`). +> Důvod: navbar je dostupný odkudkoliv v aplikaci, uživatel nemusí kvůli změně schématu +> navštěvovat Nastavení — lepší UX. Zbytek specifikace (hodnoty, ikony, localStorage přes +> Mantine) platí beze změny. + +## Shrnutí + +Přidání přepínače barevného schématu (světlý / tmavý / systém) na stránku Nastavení. Volba se automaticky ukládá do localStorage přes Mantine. + +## Umístění + +Nová karta "Vzhled aplikace" v `Settings.tsx`, vložená jako samostatný řádek pod stávajícím `SimpleGrid` (kurzy + stavy účasti), nad blokem s verzí aplikace (`footerBlock`). + +## Komponenta + +- `useMantineColorScheme()` z Mantine → `colorScheme`, `setColorScheme` +- `SegmentedControl` se třemi hodnotami: + - `"auto"` → "Systém" + - `"light"` → "Světlý" + - `"dark"` → "Tmavý" +- Ikony: FontAwesome (monitor / slunce / měsíc) z `@rodlukas/fontawesome-pro-solid-svg-icons` +- Layout: label "Barevné schéma" vlevo + `SegmentedControl` vpravo — stejný pattern jako `configRow` / `configRowLabel` / `configRowControl` + +## localStorage + +Mantine ukládá volbu automaticky do `localStorage['mantine-color-scheme']` — žádný vlastní kód pro ukládání není potřeba. Výchozí hodnota při prvním spuštění je `"auto"` (odpovídá systémovému nastavení), což odpovídá stávajícímu `defaultColorScheme="auto"` v `MantineProvider`. + +## CSS + +Nová třída `appearanceSection` v `Settings.css.ts` — stejný vizuální styl jako `footerBlock` (border, box-shadow, border-radius, background s `light-dark()`). + +## Soubory ke změně + +- `frontend/src/pages/Settings.tsx` — přidání sekce s `SegmentedControl` +- `frontend/src/pages/Settings.css.ts` — přidání stylu `appearanceSection` diff --git a/docs/superpowers/specs/2026-05-05-design-polish-plan.md b/docs/superpowers/specs/2026-05-05-design-polish-plan.md new file mode 100644 index 000000000..fd0b2d261 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-design-polish-plan.md @@ -0,0 +1,200 @@ +# Design Polish Plan – ÚPadmin + +Kompletní přehled problémů nalezených průchodem celé aplikace (light + dark mode, desktop + mobile). Skupiny jsou seřazeny podle priority. + +--- + +## Skupina A — Dark mode bugs (hardcoded light-only barvy) + +Tři konkrétní místa v kódu, kde se používají Mantine pastelové shade-1/2 barvy, které jsou navrženy pouze pro light mode. V dark mode jsou téměř neviditelné nebo vizuálně špatné. + +### A1 – Bank.tsx: titulek zelený/červený (řádek 165) + +**Soubor:** `frontend/src/components/Bank.tsx` + +```tsx +// PROBLÉM: +bg={isLackOfMoney ? "red.1" : "green.1"} +// green.1 a red.1 jsou velmi světlé pastelové barvy = v dark mode text splývá s pozadím +``` + +**Fix:** Nahradit Mantine `bg` prop za `style={{ backgroundColor: "light-dark(...)" }}` nebo použít CSS třídu v `Bank.css.ts`. + +```tsx +// v Bank.css.ts přidat: +export const bankTitleOk = style({ + backgroundColor: "light-dark(var(--mantine-color-green-1), var(--mantine-color-green-9))", +}) +export const bankTitleWarning = style({ + backgroundColor: "light-dark(var(--mantine-color-red-1), var(--mantine-color-red-9))", +}) +// + odpovídající text color pro obě varianty +``` + +### A2 – Bank.tsx: dnešní řádek transakce (řádek 103) + +**Soubor:** `frontend/src/components/Bank.tsx` + +```tsx +// PROBLÉM: + +// yellow.1 = světle žlutá, v dark mode téměř neviditelná / nevhodná +``` + +**Fix:** Inline style nebo CSS třída s `light-dark(var(--mantine-color-yellow-1), var(--mantine-color-yellow-9))`. + +### A3 – DashboardDay.tsx: záhlaví dnešního dne (řádek 133) + +**Soubor:** `frontend/src/components/DashboardDay.tsx` + +```tsx +// PROBLÉM: +bg={isToday(getDate()) ? "blue.2" : undefined} +// blue.2 je velmi světlá modrá = v dark mode příliš jasná/nevhodná +``` + +**Fix:** CSS třída s `light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-9))`, nebo přidat podmíněnou třídu v `DashboardDay.css.ts`: + +```ts +export const dashboardDayDateToday = style({ + backgroundColor: "light-dark(var(--mantine-color-indigo-1), var(--mantine-color-indigo-9))", +}) +``` + +--- + +## Skupina B — Design: Zájemci (Applications) — záhlaví kurzů + +**Soubory:** `frontend/src/pages/Applications.tsx`, `frontend/src/pages/Applications.css.ts` + +**Problém:** Záhlaví sekce kurzu (`courseHeadingItem`) používá plnou barvu kurzu jako background celého pásu s tmavým overlay. Výsledek je vizuálně velmi těžký, "Bootstrap 3 accordion panel" styl, působí zastarale. + +**Aktuální stav:** +```ts +courseHeadingItem: { + backgroundColor: `${applicationsVars.courseBackground} !important`, + backgroundImage: "linear-gradient(rgb(15 23 42 / 0.2), rgb(15 23 42 / 0.2))", + padding: "0.5rem 1rem", +} +// bílý text, barva kurzu jako plné pozadí celého pásu +``` + +**Navrhovaný fix:** Odlehčit záhlaví — místo plnobarevného pásu použít: +- Bílé/dark-7 pozadí (`light-dark(#fff, dark-7)`) se subtilní levou barevnou čarou (4px border-left v barvě kurzu) +- Kurz badge zůstane (component `CourseName`/`CourseCircle`), ale nebude dominovat celá šířka +- Text tmavý (ne bílý) + +```ts +// nový styl courseHeadingItem: +export const courseHeadingItem = style({ + display: "flex", + alignItems: "center", + gap: "0.6rem", + padding: "0.65rem 1rem", + backgroundColor: "light-dark(#f8fafc, var(--mantine-color-dark-6))", + borderBottom: "1px solid light-dark(#dbe3ed, var(--mantine-color-dark-4))", + borderLeft: `4px solid ${applicationsVars.courseBackground}`, +}) +// courseHeading text: dark color, žádný textShadow +// courseHeadingBadge: upravit pro nový kontext +``` + +--- + +## Skupina C — Rozestupy a padding (drobné, ale viditelné nedostatky) + +### C1 – Hlavní nadpisy stránek (Heading komponenta) + +**Soubor:** `frontend/src/components/Heading.tsx` + +`my="md"` je 16px (Mantine `md` = 16px). Nadpisy stránek (H1) mají příliš malý spodní rozestup od obsahu stránky — vizuálně se obsah tísní hned pod nadpis. + +**Fix:** Změnit `my="md"` na `mt="md" mb="lg"` (bottom 24px) nebo nastavit na `mt={12} mb={20}`. + +### C2 – Dashboard: H1 nadpisy bez vizuální separace od karet + +**Soubor:** `frontend/src/pages/Dashboard.tsx` + +"Dnešní lekce" a "Bankovní účet" jsou H1 nadpisy, ale obojí jsou na jedné stránce jako dva samostatné sloupce. Nadpisy jsou příliš velké (h1 = 1.375rem + 1.5vw = ~40px na 1400px) pro "sekční" nadpisy. + +**Fix:** Snížit na `order={2}` nebo přidat vlastní CSS `fontSize` override pro tyto sekční nadpisy (ne globální h1). + +### C3 – Diary stránka: záhlaví bez vizuální oddělení od obsahu + +**Soubor:** `frontend/src/pages/Diary.tsx`, `frontend/src/pages/Diary.css.ts` + +Záhlaví "Týden 4. 5. – 8. 5." je velké a centered, pak hned pod ním jsou karty dní bez padding-top. Přidat `mb` pod záhlaví nebo `mt` nad karty. + +**Konkrétní:** přidat `marginBottom: "1rem"` na záhlaví diáře (nebo `gap` na kontejner). + +### C4 – Statistics: sekce "Rozsah lekcí" — malý rozestup nad filter tlačítky + +**Soubor:** `frontend/src/pages/Statistics.css.ts` + +`sectionTightTop` má `marginTop: "0.2rem"` — příliš malý rozestup pod titulkem sekce. + +**Fix:** Zvýšit na `0.5rem`. + +--- + +## Skupina D — Komponenta Card a lekce: dark mode (medium priority) + +### D1 – Card.css.ts: lectureFuture a lecturePrepaid barvy + +**Soubor:** `frontend/src/pages/Card.css.ts` + +```ts +lectureFuture: "light-dark(#fff8dd, var(--mantine-color-yellow-9))" +lecturePrepaid: "light-dark(#ddf6e4, var(--mantine-color-green-9))" +``` + +`yellow-9` a `green-9` jsou v dark mode velmi tmavé (tmavě hnědá/tmavě zelená). Lépe by seděly `yellow-8` a `green-8`, nebo vlastní tmavší pastelové barvy. + +**Fix:** +```ts +lectureFuture: "light-dark(#fff8dd, color-mix(in srgb, var(--mantine-color-yellow-9) 60%, var(--mantine-color-dark-7) 40%))" +``` +Nebo jednoduše: `var(--mantine-color-yellow-8)` / `var(--mantine-color-green-8)`. + +### D2 – DashboardDay / Card courseHeadingItem + +**Soubor:** `frontend/src/pages/Card.css.ts` + +Stejný pattern jako Zájemci (`courseHeadingItem` s plnou barvou kurzu). Na klientské kartě to slouží jako záhlaví každé lekce — zde je to vhodné (identifikuje kurz barvou), ale dark mode overlay může být příliš tmavý. + +**Fix:** Snížit dark overlay intenzitu: +```ts +backgroundImage: "linear-gradient(rgb(15 23 42 / 0.15), rgb(15 23 42 / 0.15))" +// místo 0.28 / 0.2 +``` + +--- + +## Skupina E — Drobná polish + +### E1 – Bank title: text color v dark mode + +Při opravě A1 zajistit, že text titulku banky (`bankTitleText`) má správný kontrast pro obě varianty (zelená/červená, light/dark). + +### E2 – Odhlásit button v light mode + +Tlačítko "Odhlásit" v navbaru má v light mode `variant` který ho renderuje jako šedý/outlined button. Vizuálně vypadá lehce inconsistentně vedle tmavého navbaru. Zkontrolovat variantu a případně použít `variant="light"` s explicitním `color="gray"` pro lepší kontrast na tmavém pozadí navbaru (navbar je vždy tmavý). + +**Soubor:** `frontend/src/components/Menu.tsx` — zkontrolovat `Odhlásit` button props. + +### E3 – PrepaidCounters dark mode + +Rychle ověřit, zda `PrepaidCounters` komponenta (zobrazuje předplacené lekce) má správné barvy v dark mode. + +--- + +## Souhrnná priorita implementace + +| # | Problém | Soubory | Effort | +|---|---------|---------|--------| +| 1 | A1–A3: dark mode barvy (Bank + DashboardDay) | Bank.tsx, Bank.css.ts, DashboardDay.tsx, DashboardDay.css.ts | nízký | +| 2 | B: Zájemci headers redesign | Applications.tsx, Applications.css.ts | střední | +| 3 | C1–C4: Rozestupy | Heading.tsx, Dashboard.tsx, Diary.css.ts, Statistics.css.ts | nízký | +| 4 | D1: lectureFuture/Prepaid dark mode | Card.css.ts | nízký | +| 5 | D2: courseHeadingItem overlay | Card.css.ts, DashboardDay.css.ts | nízký | +| 6 | E1–E3: Drobná polish | Bank.css.ts, Menu.tsx | nízký | diff --git a/frontend/__mocks__/MockContexts.tsx b/frontend/__mocks__/MockContexts.tsx index e4d8e60a1..6588d4960 100644 --- a/frontend/__mocks__/MockContexts.tsx +++ b/frontend/__mocks__/MockContexts.tsx @@ -1,3 +1,4 @@ +import { MantineProvider } from "@mantine/core" import * as React from "react" import { AttendanceStatesContext } from "../src/contexts/AttendanceStatesContext" @@ -7,28 +8,30 @@ import { GroupsActiveContext } from "../src/contexts/GroupsActiveContext" import * as data from "./data.json" const MockContexts: React.FC<{ children: React.ReactNode }> = (props) => ( - - + - - {props.children} - - - + + {props.children} + + + + ) export default MockContexts diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 6afae2f16..dfaa85c7e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -80,7 +80,13 @@ export default [ version: "detect", }, "import/resolver": { - typescript: { alwaysTryTypes: true }, + typescript: { + alwaysTryTypes: true, + project: "./tsconfig.json", + }, + node: { + extensions: [".js", ".jsx", ".ts", ".tsx"], + }, }, "import/ignore": ["chroma"], }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index eec1d9413..b83687ff8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,29 +10,27 @@ "license": "MIT", "dependencies": { "@babel/runtime": "^7.29.2", - "@emotion/cache": "^11.14.0", - "@emotion/react": "^11.14.0", "@fortawesome/fontawesome-svg-core": "~1.2.36", "@fortawesome/free-brands-svg-icons": "~5.15.4", "@fortawesome/react-fontawesome": "~0.2.6", + "@mantine/core": "^9.1.1", + "@mantine/form": "^9.1.1", + "@mantine/hooks": "^9.1.1", + "@mantine/notifications": "^9.1.1", + "@mantine/spotlight": "^9.1.1", "@rodlukas/fontawesome-pro-solid-svg-icons": "^5.15.2", "@sentry/browser": "^10.49.0", "@tanstack/react-query": "^5.99.0", "@tanstack/react-router": "^1.168.22", "@tanstack/react-router-devtools": "^1.166.13", "axios": "^1.15.0", - "bootstrap": "^5.3.8", "chroma-js": "^3.2.0", "classnames": "^2.5.1", "fuse.js": "^7.3.0", "jwt-decode": "^4.0.0", "react": "^19.2.5", - "react-color-palette": "^7.3.1", "react-dom": "^19.2.5", "react-ga4": "^3.0.1", - "react-select": "^5.10.2", - "react-toastify": "^11.0.5", - "reactstrap": "^9.2.3", "recharts": "^3.8.1" }, "devDependencies": { @@ -170,6 +168,7 @@ }, "node_modules/@babel/code-frame": { "version": "7.29.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", @@ -219,6 +218,7 @@ }, "node_modules/@babel/generator": { "version": "7.29.1", + "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -310,6 +310,7 @@ }, "node_modules/@babel/helper-globals": { "version": "7.28.0", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -329,6 +330,7 @@ }, "node_modules/@babel/helper-module-imports": { "version": "7.28.6", + "dev": true, "license": "MIT", "dependencies": { "@babel/traverse": "^7.28.6", @@ -419,6 +421,7 @@ }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -426,6 +429,7 @@ }, "node_modules/@babel/helper-validator-identifier": { "version": "7.28.5", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -466,6 +470,7 @@ }, "node_modules/@babel/parser": { "version": "7.29.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -1643,6 +1648,7 @@ }, "node_modules/@babel/template": { "version": "7.28.6", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.28.6", @@ -1655,6 +1661,7 @@ }, "node_modules/@babel/traverse": { "version": "7.29.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.0", @@ -1671,6 +1678,7 @@ }, "node_modules/@babel/types": { "version": "7.29.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -3091,107 +3099,9 @@ "tslib": "^2.4.0" } }, - "node_modules/@emotion/babel-plugin": { - "version": "11.13.5", - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.16.7", - "@babel/runtime": "^7.18.3", - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/serialize": "^1.3.3", - "babel-plugin-macros": "^3.1.0", - "convert-source-map": "^1.5.0", - "escape-string-regexp": "^4.0.0", - "find-root": "^1.1.0", - "source-map": "^0.5.7", - "stylis": "4.2.0" - } - }, - "node_modules/@emotion/babel-plugin/node_modules/convert-source-map": { - "version": "1.9.0", - "license": "MIT" - }, - "node_modules/@emotion/babel-plugin/node_modules/source-map": { - "version": "0.5.7", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/@emotion/cache": { - "version": "11.14.0", - "license": "MIT", - "dependencies": { - "@emotion/memoize": "^0.9.0", - "@emotion/sheet": "^1.4.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "stylis": "4.2.0" - } - }, "node_modules/@emotion/hash": { "version": "0.9.2", - "license": "MIT" - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "license": "MIT" - }, - "node_modules/@emotion/react": { - "version": "11.14.0", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.18.3", - "@emotion/babel-plugin": "^11.13.5", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/use-insertion-effect-with-fallbacks": "^1.2.0", - "@emotion/utils": "^1.4.2", - "@emotion/weak-memoize": "^0.4.0", - "hoist-non-react-statics": "^3.3.1" - }, - "peerDependencies": { - "react": ">=16.8.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@emotion/serialize": { - "version": "1.3.3", - "license": "MIT", - "dependencies": { - "@emotion/hash": "^0.9.2", - "@emotion/memoize": "^0.9.0", - "@emotion/unitless": "^0.10.0", - "@emotion/utils": "^1.4.2", - "csstype": "^3.0.2" - } - }, - "node_modules/@emotion/sheet": { - "version": "1.4.0", - "license": "MIT" - }, - "node_modules/@emotion/unitless": { - "version": "0.10.0", - "license": "MIT" - }, - "node_modules/@emotion/use-insertion-effect-with-fallbacks": { - "version": "1.2.0", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0" - } - }, - "node_modules/@emotion/utils": { - "version": "1.4.2", - "license": "MIT" - }, - "node_modules/@emotion/weak-memoize": { - "version": "0.4.0", + "dev": true, "license": "MIT" }, "node_modules/@epic-web/invariant": { @@ -3852,22 +3762,56 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.3", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@fortawesome/fontawesome-common-types": { @@ -4067,6 +4011,7 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -4084,6 +4029,7 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -4100,10 +4046,12 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4223,6 +4171,87 @@ "dev": true, "license": "MIT" }, + "node_modules/@mantine/core": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mantine/core/-/core-9.1.1.tgz", + "integrity": "sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==", + "license": "MIT", + "dependencies": { + "@floating-ui/react": "^0.27.19", + "clsx": "^2.1.1", + "react-number-format": "^5.4.5", + "react-remove-scroll": "^2.7.2", + "type-fest": "^5.6.0" + }, + "peerDependencies": { + "@mantine/hooks": "9.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "node_modules/@mantine/form": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mantine/form/-/form-9.1.1.tgz", + "integrity": "sha512-xmebZ3s8GGMrCOPOaOwA+gQkdgNVfT2F9kBtkjAbRoZrMoY+vYFbiPWbIvWFl8pU1jBslYZrj+M0PIawJmFOdQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/@mantine/hooks": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-9.1.1.tgz", + "integrity": "sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==", + "license": "MIT", + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/@mantine/notifications": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mantine/notifications/-/notifications-9.1.1.tgz", + "integrity": "sha512-ZfcEMMDp0BQ+yKmVp8ifPXLKej8pv9TcaRnmy2CZ07USD61E9LH5ClRAP/hxQuCyf/qLb5BPHsI7+f3K8uhj4Q==", + "license": "MIT", + "dependencies": { + "@mantine/store": "9.1.1", + "react-transition-group": "4.4.5" + }, + "peerDependencies": { + "@mantine/core": "9.1.1", + "@mantine/hooks": "9.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "node_modules/@mantine/spotlight": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mantine/spotlight/-/spotlight-9.1.1.tgz", + "integrity": "sha512-UWeL3ADxfMKltxSygdzoUlVH5PO9CbbEu41kXs5sBItU87SQxppyG35wCsYfZ0plIJ7/LBAjnl5lQcKWl8AOxA==", + "license": "MIT", + "dependencies": { + "@mantine/store": "9.1.1" + }, + "peerDependencies": { + "@mantine/core": "9.1.1", + "@mantine/hooks": "9.1.1", + "react": "^19.2.0", + "react-dom": "^19.2.0" + } + }, + "node_modules/@mantine/store": { + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/@mantine/store/-/store-9.1.1.tgz", + "integrity": "sha512-kbxEU8wVGbobHlmQmk0lu9M+xCILKjuAPcMAshgzPznGLfXeE9zrB0gNT2cbk11Ik8dlV9J6Vsn9cuACyOSpfQ==", + "license": "MIT", + "peerDependencies": { + "react": "^19.2.0" + } + }, "node_modules/@mswjs/interceptors": { "version": "0.41.3", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", @@ -4514,14 +4543,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -5478,7 +5499,10 @@ }, "node_modules/@types/parse-json": { "version": "4.0.2", - "license": "MIT" + "dev": true, + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/@types/qs": { "version": "6.14.0", @@ -5492,6 +5516,7 @@ }, "node_modules/@types/react": { "version": "19.2.14", + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -5505,13 +5530,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/react-transition-group": { - "version": "4.4.12", - "license": "MIT", - "peerDependencies": { - "@types/react": "*" - } - }, "node_modules/@types/retry": { "version": "0.12.2", "dev": true, @@ -7461,7 +7479,10 @@ }, "node_modules/babel-plugin-macros": { "version": "3.1.0", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", @@ -7474,7 +7495,10 @@ }, "node_modules/babel-plugin-macros/node_modules/cosmiconfig": { "version": "7.1.0", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", @@ -7490,7 +7514,10 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, "license": "ISC", + "optional": true, + "peer": true, "engines": { "node": ">= 6" } @@ -7633,23 +7660,6 @@ "dev": true, "license": "ISC" }, - "node_modules/bootstrap": { - "version": "5.3.8", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -7808,6 +7818,7 @@ }, "node_modules/callsites": { "version": "3.1.0", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8847,6 +8858,7 @@ }, "node_modules/debug": { "version": "4.4.3", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -9018,6 +9030,12 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/dns-packet": { "version": "5.6.1", "dev": true, @@ -9055,6 +9073,8 @@ }, "node_modules/dom-helpers": { "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.8.7", @@ -9223,6 +9243,7 @@ }, "node_modules/error-ex": { "version": "1.3.4", + "dev": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -9463,6 +9484,7 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -10224,7 +10246,6 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, "license": "MIT" }, "node_modules/fast-diff": { @@ -10355,10 +10376,6 @@ "dev": true, "license": "MIT" }, - "node_modules/find-root": { - "version": "1.1.0", - "license": "MIT" - }, "node_modules/find-up": { "version": "5.0.0", "dev": true, @@ -10587,6 +10604,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "license": "MIT", @@ -10832,17 +10858,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "license": "BSD-3-Clause", - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "license": "MIT" - }, "node_modules/hpack.js": { "version": "2.1.6", "dev": true, @@ -11151,6 +11166,7 @@ }, "node_modules/import-fresh": { "version": "3.3.1", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -11165,6 +11181,7 @@ }, "node_modules/import-fresh/node_modules/resolve-from": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -11265,6 +11282,7 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", + "dev": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -11357,6 +11375,7 @@ }, "node_modules/is-core-module": { "version": "2.16.1", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -11973,6 +11992,7 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -11988,6 +12008,7 @@ }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -12073,6 +12094,15 @@ "node": ">=0.10.0" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/language-subtag-registry": { "version": "0.3.23", "dev": true, @@ -12384,6 +12414,7 @@ }, "node_modules/lines-and-columns": { "version": "1.2.4", + "dev": true, "license": "MIT" }, "node_modules/loader-runner": { @@ -12552,10 +12583,6 @@ "url": "https://github.com/sponsors/streamich" } }, - "node_modules/memoize-one": { - "version": "6.0.0", - "license": "MIT" - }, "node_modules/merge-descriptors": { "version": "1.0.3", "dev": true, @@ -12703,6 +12730,7 @@ }, "node_modules/ms": { "version": "2.1.3", + "dev": true, "license": "MIT" }, "node_modules/msw": { @@ -12750,22 +12778,6 @@ } } }, - "node_modules/msw/node_modules/type-fest": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", - "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "dependencies": { - "tagged-tag": "^1.0.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/multicast-dns": { "version": "7.2.5", "dev": true, @@ -13140,6 +13152,7 @@ }, "node_modules/parent-module": { "version": "1.0.1", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -13150,6 +13163,7 @@ }, "node_modules/parse-json": { "version": "5.2.0", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -13199,6 +13213,7 @@ }, "node_modules/path-parse": { "version": "1.0.7", + "dev": true, "license": "MIT" }, "node_modules/path-to-regexp": { @@ -13208,7 +13223,10 @@ }, "node_modules/path-type": { "version": "4.0.0", + "dev": true, "license": "MIT", + "optional": true, + "peer": true, "engines": { "node": ">=8" } @@ -13233,6 +13251,7 @@ }, "node_modules/picocolors": { "version": "1.1.1", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { @@ -14858,16 +14877,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-color-palette": { - "version": "7.3.1", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16.8" - } - }, "node_modules/react-dom": { "version": "19.2.5", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", @@ -14880,10 +14889,6 @@ "react": "^19.2.5" } }, - "node_modules/react-fast-compare": { - "version": "3.2.2", - "license": "MIT" - }, "node_modules/react-ga4": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/react-ga4/-/react-ga4-3.0.1.tgz", @@ -14894,6 +14899,16 @@ "version": "17.0.2", "license": "MIT" }, + "node_modules/react-number-format": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-5.4.5.tgz", + "integrity": "sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==", + "license": "MIT", + "peerDependencies": { + "react": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -14925,38 +14940,79 @@ "node": ">=0.10.0" } }, - "node_modules/react-select": { - "version": "5.10.2", + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.12.0", - "@emotion/cache": "^11.4.0", - "@emotion/react": "^11.8.1", - "@floating-ui/dom": "^1.0.1", - "@types/react-transition-group": "^4.4.0", - "memoize-one": "^6.0.0", - "prop-types": "^15.6.0", - "react-transition-group": "^4.3.0", - "use-isomorphic-layout-effect": "^1.2.0" + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, - "node_modules/react-toastify": { - "version": "11.0.5", + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", "license": "MIT", "dependencies": { - "clsx": "^2.1.1" + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" }, "peerDependencies": { - "react": "^18 || ^19", - "react-dom": "^18 || ^19" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/react-transition-group": { "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", "license": "BSD-3-Clause", "dependencies": { "@babel/runtime": "^7.5.5", @@ -14969,35 +15025,6 @@ "react-dom": ">=16.6.0" } }, - "node_modules/reactstrap": { - "version": "9.2.3", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5", - "@popperjs/core": "^2.6.0", - "classnames": "^2.2.3", - "prop-types": "^15.5.8", - "react-popper": "^2.2.4", - "react-transition-group": "^4.4.2" - }, - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, - "node_modules/reactstrap/node_modules/react-popper": { - "version": "2.3.0", - "license": "MIT", - "dependencies": { - "react-fast-compare": "^3.0.1", - "warning": "^4.0.2" - }, - "peerDependencies": { - "@popperjs/core": "^2.0.0", - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, "node_modules/readable-stream": { "version": "3.6.2", "dev": true, @@ -15274,6 +15301,7 @@ }, "node_modules/resolve": { "version": "1.22.11", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -16186,10 +16214,6 @@ "postcss": "^8.4.32" } }, - "node_modules/stylis": { - "version": "4.2.0", - "license": "MIT" - }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -16203,6 +16227,7 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -16342,11 +16367,16 @@ "url": "https://opencollective.com/synckit" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "license": "MIT" + }, "node_modules/tagged-tag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -16702,7 +16732,6 @@ }, "node_modules/tslib": { "version": "2.8.1", - "dev": true, "license": "0BSD" }, "node_modules/tsyringe": { @@ -16733,13 +16762,15 @@ } }, "node_modules/type-fest": { - "version": "0.21.3", - "dev": true, + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.6.0.tgz", + "integrity": "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==", "license": "(MIT OR CC0-1.0)", - "optional": true, - "peer": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, "engines": { - "node": ">=10" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -17004,11 +17035,42 @@ "punycode": "^2.1.0" } }, - "node_modules/use-isomorphic-layout-effect": { - "version": "1.2.1", + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { @@ -17309,13 +17371,6 @@ "node": ">=18" } }, - "node_modules/warning": { - "version": "4.0.3", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.0.0" - } - }, "node_modules/watchpack": { "version": "2.5.1", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 3e011d8a8..79bb0ef76 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -85,29 +85,27 @@ }, "dependencies": { "@babel/runtime": "^7.29.2", - "@emotion/cache": "^11.14.0", - "@emotion/react": "^11.14.0", "@fortawesome/fontawesome-svg-core": "~1.2.36", "@fortawesome/free-brands-svg-icons": "~5.15.4", "@fortawesome/react-fontawesome": "~0.2.6", + "@mantine/core": "^9.1.1", + "@mantine/form": "^9.1.1", + "@mantine/hooks": "^9.1.1", + "@mantine/notifications": "^9.1.1", + "@mantine/spotlight": "^9.1.1", "@rodlukas/fontawesome-pro-solid-svg-icons": "^5.15.2", "@sentry/browser": "^10.49.0", "@tanstack/react-query": "^5.99.0", "@tanstack/react-router": "^1.168.22", "@tanstack/react-router-devtools": "^1.166.13", "axios": "^1.15.0", - "bootstrap": "^5.3.8", "chroma-js": "^3.2.0", "classnames": "^2.5.1", "fuse.js": "^7.3.0", "jwt-decode": "^4.0.0", "react": "^19.2.5", - "react-color-palette": "^7.3.1", "react-dom": "^19.2.5", "react-ga4": "^3.0.1", - "react-select": "^5.10.2", - "react-toastify": "^11.0.5", - "reactstrap": "^9.2.3", "recharts": "^3.8.1" }, "overrides": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4cb2581a4..51e11a002 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,6 +13,10 @@ type AppLayoutProps = { queryClient: QueryClient } +const isDevelopment = process.env.NODE_ENV === "development" +const isDevtoolsEnabled = + isDevelopment && new URLSearchParams(globalThis.location.search).has("devtools") + const AppLayout: React.FC = ({ queryClient }) => ( @@ -24,8 +28,12 @@ const AppLayout: React.FC = ({ queryClient }) => ( - - + {isDevtoolsEnabled && ( + <> + + + + )} ) diff --git a/frontend/src/Main.css.ts b/frontend/src/Main.css.ts index 6d4fb574b..5a82c5102 100644 --- a/frontend/src/Main.css.ts +++ b/frontend/src/Main.css.ts @@ -1,26 +1,106 @@ import { globalStyle, style } from "@vanilla-extract/css" -globalStyle(".navbar .badge", { +/** + * Výška fixního navbaru v px — jediný zdroj pravdy pro odvozené layout hodnoty: + * `navbarInner.minHeight`, `isAuthenticated.paddingTop` (navbar + odstup obsahu) + * a `loginContainer.minHeight` v Login.css.ts (100vh − navbar). + * Odvozené hodnoty se zapisují v rem (56px = 3.5rem při výchozích 16px = 1rem), + * aby škálovaly s uživatelskou velikostí písma. + */ +export const NAVBAR_HEIGHT = 56 + +export const navbar = style({ + position: "fixed", + zIndex: 1030, + top: 0, + right: 0, + left: 0, + borderBottom: "1px solid rgb(255 255 255 / 0.12)", + boxShadow: "0 8px 18px rgb(15 23 42 / 0.22)", + background: "linear-gradient(180deg, #1f2b3c 0%, #223247 100%)", + padding: "0 0.8rem", +}) + +export const navbarInner = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "0.5rem", + margin: "0 auto", + width: "100%", + maxWidth: "1500px", + minHeight: NAVBAR_HEIGHT, +}) + +export const navbarBrand = style({ + display: "flex", + alignItems: "center", + marginRight: "0.5rem", + textDecoration: "none", + letterSpacing: "0.01em", + color: "#f8fafc", + fontSize: "1.25rem", + fontWeight: 600, + ":hover": { + textDecoration: "none", + color: "#ffffff", + }, +}) + +export const navbarBadge = style({ "@media": { "(min-width: 992px)": { - // pri klasickem menu pridej pravou mezeru k badge - marginRight: "1rem", + marginRight: "0.5rem", }, }, }) +export const navbarBurger = style({ + marginLeft: "auto", +}) + +export const navbarCollapse = style({ + display: "none", + width: "100%", + "@media": { + "(min-width: 992px)": { + display: "flex", + flexBasis: "auto", + flexGrow: 1, + alignItems: "center", + width: "auto", + }, + }, +}) + +export const navbarCollapseOpen = style({ + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + gap: "0.2rem", + paddingBottom: "0.5rem", + width: "100%", +}) + export const isAuthenticated = style({ - paddingTop: "3.5rem", // 56px + // výška navbaru (3.5rem) + 0.25rem odstup obsahu pod fixním navbarem + paddingTop: `${NAVBAR_HEIGHT / 16 + 0.25}rem`, +}) + +globalStyle(".main", { + marginBottom: "1.5rem", }) globalStyle(".nav-content", { maxWidth: "900px", }) -globalStyle(".main a, .modal a", { +// Mantine žádný `[data-mantine-modal]` atribut nevykresluje — obsah modalu je +// v `.mantine-Modal-content` (stejný selektor používá i FormBase.css.ts). +globalStyle(".main a, .mantine-Modal-content a", { textDecoration: "none", }) -globalStyle(".main a:hover, .modal a:hover", { +globalStyle(".main a:hover, .mantine-Modal-content a:hover", { textDecoration: "underline", }) diff --git a/frontend/src/Main.tsx b/frontend/src/Main.tsx index d3cf100eb..073f541a2 100644 --- a/frontend/src/Main.tsx +++ b/frontend/src/Main.tsx @@ -1,89 +1,29 @@ +import { Badge, Burger } from "@mantine/core" import { Link, Outlet, useRouterState } from "@tanstack/react-router" import classNames from "classnames" -import Fuse, { IFuseOptions, FuseResult } from "fuse.js" import * as React from "react" -import { Slide, ToastContainer } from "react-toastify" -import "react-toastify/dist/ReactToastify.css" -import { Badge, Collapse, Navbar, NavbarBrand, NavbarToggler } from "reactstrap" -import { trackEvent } from "./analytics" import { useAuthContext } from "./auth/AuthContext" import AppCommit from "./components/AppCommit" +import AppSpotlight from "./components/AppSpotlight" +import ColorSchemeSync from "./components/ColorSchemeSync" import Loading from "./components/Loading" import Menu from "./components/Menu" -import Search from "./components/Search" -import { useClientsActiveContext } from "./contexts/ClientsActiveContext" import { getEnvName, isEnvDemo, isEnvLocal, isEnvTesting } from "./global/funcEnvironments" -import { isModalShown } from "./global/utils" -import useKeyPress from "./hooks/useKeyPress" import * as styles from "./Main.css" -import { ClientActiveType } from "./types/models" - -// konfigurace Fuse.js vyhledavani -const searchOptions: IFuseOptions = { - shouldSort: true, - ignoreDiacritics: true, - threshold: 0.5, - keys: ["firstname", "surname", "phone", "email", "normalized"], -} /** Hlavní kostra aplikace. */ const Main: React.FC = () => { const [isMenuOpened, setIsMenuOpened] = React.useState(false) - const [foundResults, setFoundResults] = React.useState[]>([]) - const [searchVal, setSearchVal] = React.useState("") - const searchSessionTrackedRef = React.useRef(false) const authContext = useAuthContext() - const clientsActiveContext = useClientsActiveContext() const locationPathname = useRouterState({ select: (state) => state.location.pathname, }) - const escPress = useKeyPress("Escape") - - const fuse = React.useMemo( - () => new Fuse(clientsActiveContext.clients, searchOptions), - [clientsActiveContext.clients], - ) - - const search = React.useCallback(() => { - if (searchVal !== "" && !clientsActiveContext.isLoading) { - const results = fuse.search(searchVal) - setFoundResults(results) - if (!searchSessionTrackedRef.current) { - trackEvent("search_used", { has_results: results.length > 0 }) - searchSessionTrackedRef.current = true - } - } - }, [searchVal, fuse, clientsActiveContext.isLoading]) - - function resetSearch(): void { - setFoundResults([]) - setSearchVal("") - searchSessionTrackedRef.current = false - } React.useEffect(() => { - resetSearch() - // pri odchodu z vyhledavani zavreme menu setIsMenuOpened(false) }, [locationPathname]) - React.useEffect(() => { - if (!isModalShown()) { - resetSearch() - } - }, [escPress]) - - React.useEffect(() => { - search() - }, [search]) - - React.useEffect(() => { - if (!isMenuOpened) { - resetSearch() - } - }, [isMenuOpened]) - function toggleNavbar(): void { setIsMenuOpened((prevIsMenuOpened) => !prevIsMenuOpened) } @@ -92,45 +32,56 @@ const Main: React.FC = () => { setIsMenuOpened(false) } - function onSearchChange(newSearchVal: string): void { - setSearchVal(newSearchVal) - } - return (
+ {authContext.isAuth && ( - - - ÚPadmin - - {isEnvLocal() && Vývojová verze} - {isEnvTesting() && ( - - Testing - - )} - {isEnvDemo() && DEMO} - - - +
+ + ÚPadmin + + {isEnvLocal() && ( + + Vývojová verze + + )} + {isEnvTesting() && ( + + Testing + + )} + {isEnvDemo() && ( + + DEMO + + )} + - - +
+ +
+
+ )}
- - + {authContext.isAuth && } }> diff --git a/frontend/src/api/queryClient.tsx b/frontend/src/api/queryClient.tsx index 2be6c74f2..af1e6f571 100644 --- a/frontend/src/api/queryClient.tsx +++ b/frontend/src/api/queryClient.tsx @@ -1,12 +1,12 @@ +import { notifications } from "@mantine/notifications" import { MutationCache, QueryCache, QueryClient } from "@tanstack/react-query" import { AxiosError } from "axios" import * as React from "react" -import { toast } from "react-toastify" import APP_URLS from "../APP_URLS" import Token from "../auth/Token" -import Notification from "../components/Notification" import { NOTIFY_TEXT } from "../global/constants" +import { bold, italic } from "../global/utility.css" import { parseDjangoError } from "./parseDjangoError" @@ -37,8 +37,8 @@ function getErrorMessage(
    {Object.keys(djangoError).map((field) => (
  • - {field}: - {String(djangoError[field])} + {field}: + {String(djangoError[field])}
  • ))}
@@ -93,7 +93,9 @@ function handleError(axiosError: AxiosError, getNavigate?: () => NavigateFn | un logErrorToConsole(axiosError, djangoError) const errorMessage = getErrorMessage(errorResponse, djangoError) - toast.error(, { + notifications.show({ + message: typeof errorMessage === "string" ? errorMessage : <>{errorMessage}, + color: "red", autoClose: 15000, }) @@ -121,6 +123,9 @@ export function createQueryClient(getNavigate?: () => NavigateFn | undefined): Q queries: { retry: 1, refetchOnWindowFocus: false, + // 30s staleTime tlumí refetch při navigaci a remountu kontextových providerů; + // po mutaci se aktivní queries stejně refetchují přes mutationCache.onSuccess. + staleTime: 30_000, }, }, queryCache: new QueryCache({ @@ -141,7 +146,12 @@ export function createQueryClient(getNavigate?: () => NavigateFn | undefined): Q // Invalidace pouze refetchuje aktivní queries a označí ostatní jako stale, // takže se refetchují až když budou potřeba. // Viz: https://tkdodo.eu/blog/automatic-query-invalidation-after-mutations - void queryClient.invalidateQueries() + // Výjimka: bankovní data (["bank"]) nezávisí na mutacích v aplikaci a dotazují + // se na externí rate-limited API (banka má vlastní manuální refresh s minutovým + // limitem), proto je z plošné invalidace vyjmeme. + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] !== "bank", + }) // Notifikace potlačíme, pokud je v meta nastaveno skipSuccessNotification if (mutation.options.meta?.skipSuccessNotification) { @@ -151,7 +161,9 @@ export function createQueryClient(getNavigate?: () => NavigateFn | undefined): Q // Získáme success zprávu z mutation meta, pokud je k dispozici const successMessage = mutation.options.meta?.successMessage as string | undefined - toast.success(, { + notifications.show({ + message: successMessage ?? "Uloženo", + color: "green", autoClose: 4000, }) }, diff --git a/frontend/src/auth/AuthContext.tsx b/frontend/src/auth/AuthContext.tsx index 06d80b8b3..56f71c08c 100644 --- a/frontend/src/auth/AuthContext.tsx +++ b/frontend/src/auth/AuthContext.tsx @@ -1,12 +1,11 @@ +import { notifications } from "@mantine/notifications" import { useNavigate } from "@tanstack/react-router" import * as React from "react" -import { toast } from "react-toastify" import { trackEvent } from "../analytics" import { useLogin } from "../api/hooks" import LoginService from "../api/services/LoginService" import APP_URLS from "../APP_URLS" -import Notification from "../components/Notification" import { useContextWithProvider } from "../hooks/useContextWithProvider" import { AuthorizationType } from "../types/models" import { fEmptyVoid } from "../types/types" @@ -64,9 +63,12 @@ const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => setIsAuth(true) } catch { setIsAuth(false) - toast.warning( - , - ) + notifications.show({ + message: + "Neúspěšný pokus o obnovení vašeho přihlášení (pravděpodobně z důvodu delší neaktivity). Přihlašte se, prosím, znovu!", + color: "yellow", + autoClose: false, + }) } return } diff --git a/frontend/src/auth/PrivateRoute.tsx b/frontend/src/auth/PrivateRoute.tsx index 12c331690..358468c7d 100644 --- a/frontend/src/auth/PrivateRoute.tsx +++ b/frontend/src/auth/PrivateRoute.tsx @@ -22,6 +22,8 @@ const PrivateRoute: React.FC = ({ title, children }) => { const locationPathname = useRouterState({ select: (state) => state.location.pathname, }) + // Fallback na window.location.pathname kvůli stale routeru během neauth → login redirectu + // (symetricky s Login.tsx, kde stejně čteme `globalThis.location.search`). const rawPathname = globalThis.location?.pathname ?? locationPathname const redirectPath = rawPathname === APP_URLS.prihlasit.url ? undefined : rawPathname diff --git a/frontend/src/components/AppCommit.tsx b/frontend/src/components/AppCommit.tsx index a272b23a5..46f66d415 100644 --- a/frontend/src/components/AppCommit.tsx +++ b/frontend/src/components/AppCommit.tsx @@ -1,30 +1,21 @@ +import { Tooltip } from "@mantine/core" import * as React from "react" import { GITHUB_REPO_URL } from "../global/constants" import * as styles from "./AppCommit.css" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" - -type Props = { - /** Název stránky, na které se komponenta používá (musí být unikátní). */ - pageId: string -} /** Komponenta zobrazující hash commitu příslušné verze aplikace. */ -const AppCommit: React.FC = ({ pageId }) => ( - <> +const AppCommit: React.FC = () => ( + + rel="noopener noreferrer"> %GIT_COMMIT - - Zobrazení commitu (GitHub) - - + ) export default AppCommit diff --git a/frontend/src/components/AppDate.tsx b/frontend/src/components/AppDate.tsx index 53200001d..b3b9f7f08 100644 --- a/frontend/src/components/AppDate.tsx +++ b/frontend/src/components/AppDate.tsx @@ -1,6 +1,8 @@ import * as React from "react" +import { nowrap } from "../global/utility.css" + /** Komponenta zobrazující datum a čas sestavení příslušné verze aplikace. */ -const AppDate: React.FC = () => %GIT_DATETIME +const AppDate: React.FC = () => %GIT_DATETIME export default AppDate diff --git a/frontend/src/components/AppRelease.tsx b/frontend/src/components/AppRelease.tsx index 9ab96cd7b..19ccb3ec7 100644 --- a/frontend/src/components/AppRelease.tsx +++ b/frontend/src/components/AppRelease.tsx @@ -1,9 +1,8 @@ +import { Tooltip } from "@mantine/core" import * as React from "react" import { GITHUB_REPO_URL } from "../global/constants" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" - /** Komponenta zobrazující číslo verze aplikace. */ const AppRelease: React.FC = () => { const version = "%GIT_RELEASE" @@ -18,20 +17,19 @@ const AppRelease: React.FC = () => { return ( <> {!isTaggedCommit() && "větev "} - - {branchOrVersion} - - - {isTaggedCommit() ? "Poznámky k verzi" : "Přejít na větev"} (GitHub) - + + + {branchOrVersion} + + ) } diff --git a/frontend/src/components/AppSpotlight.test.tsx b/frontend/src/components/AppSpotlight.test.tsx new file mode 100644 index 000000000..09b3d7cae --- /dev/null +++ b/frontend/src/components/AppSpotlight.test.tsx @@ -0,0 +1,194 @@ +import { MantineProvider } from "@mantine/core" +import { spotlight } from "@mantine/spotlight" +import { RouterProvider } from "@tanstack/react-router" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" + +import { trackEvent } from "../analytics" +import { ClientsActiveContext } from "../contexts/ClientsActiveContext" +import { GroupsActiveContext } from "../contexts/GroupsActiveContext" +import { createTestRouter } from "../testUtils/createTestRouter" +import { ClientActiveType, GroupType } from "../types/models" + +import AppSpotlight from "./AppSpotlight" + +vi.mock("../analytics", () => ({ + trackEvent: vi.fn(), +})) + +function createClient( + id: number, + firstname: string, + surname: string, + normalized: string[], +): ClientActiveType { + return { + id, + active: true, + email: "", + note: "", + phone: "", + firstname, + surname, + last_lecture_date: null, + normalized, + } +} + +// poradi ve zdrojovem poli je zamerne OPACNE nez relevance pro dotaz "rod": +// slaba fuzzy shoda ("Robeš") je prvni, presna shoda ("Rod") az druha +const clients: ClientActiveType[] = [ + createClient(1, "Radim", "Robeš", ["Radim", "Robes"]), + createClient(2, "Pavels", "Rod", ["Pavels", "Rod"]), + createClient(3, "Eva", "Žáková", ["Eva", "Zakova"]), +] + +const groups: GroupType[] = [ + { + id: 21, + name: "Žabky", + memberships: [], + active: true, + course: { id: 5, name: "Plavání", color: "#f39c12", duration: 30, visible: true }, + last_lecture_date: null, + }, +] + +async function renderAppSpotlight(): Promise { + const router = await createTestRouter( + + + + + + + , + ) + render() +} + +const getSearchInput = async (): Promise => + await screen.findByRole("textbox", { name: "Globální vyhledávání" }) + +async function openSpotlight(): Promise { + act(() => { + spotlight.open() + }) + return await getSearchInput() +} + +/** Vrátí texty labelů aktuálně zobrazených akcí (v pořadí vykreslení). */ +function getActionLabels(): string[] { + return Array.from(document.querySelectorAll(".mantine-Spotlight-actionLabel")).map( + (label) => label.textContent ?? "", + ) +} + +/** Vrátí popisky skupin akcí - Spotlight je renderuje přes CSS proměnnou, ne jako text. */ +function getGroupLabels(): string[] { + return Array.from( + document.querySelectorAll(".mantine-Spotlight-actionsGroup"), + ).map((group) => group.style.getPropertyValue("--spotlight-label")) +} + +beforeEach(() => { + vi.mocked(trackEvent).mockClear() +}) + +afterEach(async () => { + // store spotlightu je globalni modulovy singleton - dotaz i stav otevreni by jinak + // protekly do dalsiho testu (s env="test" se nevola onExited, ktery dotaz cisti); + // dotaz se musi vycistit i po testech, ktere spotlight samy zavrely - proto + // se nejdriv (znovu) otevre + act(() => { + spotlight.open() + }) + const input = await screen.findByRole("textbox", { name: "Globální vyhledávání" }) + fireEvent.change(input, { target: { value: "" } }) + act(() => { + spotlight.close() + }) + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()) +}) + +test("shows all actions with total counts when query is empty", async () => { + await renderAppSpotlight() + await openSpotlight() + + expect(getActionLabels()).toEqual(["Robeš Radim", "Rod Pavels", "Žáková Eva", "Žabky"]) + // uvozovky: čte se interní Mantine CSS proměnná --spotlight-label (jsdom neumí přečíst + // ::before content) — při upgradu Mantine může assert vyžadovat úpravu + expect(getGroupLabels()).toEqual(["'Klienti (3)'", "'Skupiny (1)'"]) +}) + +test("orders search results by relevance, not by source array order", async () => { + await renderAppSpotlight() + const input = await openSpotlight() + + fireEvent.change(input, { target: { value: "rod" } }) + + // presna shoda "Rod Pavels" musi predbehnout slabou fuzzy shodu "Robeš Radim", + // ktera je ve zdrojovem poli prvni; "Žáková Eva" a skupina "Žabky" neodpovidaji vubec + await waitFor(() => expect(getActionLabels()).toEqual(["Rod Pavels", "Robeš Radim"])) +}) + +test("shows filtered match counts in group labels when query is active", async () => { + await renderAppSpotlight() + const input = await openSpotlight() + + fireEvent.change(input, { target: { value: "rod" } }) + + // 2 nalezeni klienti ze 3 celkem; skupina bez shody uplne zmizi + await waitFor(() => expect(getGroupLabels()).toEqual(["'Klienti (2)'"])) +}) + +test("shows nothing found message for query without matches", async () => { + await renderAppSpotlight() + const input = await openSpotlight() + + fireEvent.change(input, { target: { value: "qqq" } }) + + expect(await screen.findByText("Žádné výsledky odpovídající dotazu.")).toBeInTheDocument() +}) + +test("tracks search_used once with has_results=true after closing", async () => { + await renderAppSpotlight() + const input = await openSpotlight() + + // vice zmen dotazu v jedne session => stale jen jeden event (az pri zavreni) + fireEvent.change(input, { target: { value: "ro" } }) + fireEvent.change(input, { target: { value: "rod" } }) + expect(trackEvent).not.toHaveBeenCalled() + + act(() => { + spotlight.close() + }) + + expect(trackEvent).toHaveBeenCalledTimes(1) + expect(trackEvent).toHaveBeenCalledWith("search_used", { has_results: true }) +}) + +test("tracks search_used with has_results=false for query without matches", async () => { + await renderAppSpotlight() + const input = await openSpotlight() + + fireEvent.change(input, { target: { value: "qqq" } }) + act(() => { + spotlight.close() + }) + + expect(trackEvent).toHaveBeenCalledTimes(1) + expect(trackEvent).toHaveBeenCalledWith("search_used", { has_results: false }) +}) + +test("doesn't track search_used without a query of at least 2 characters", async () => { + await renderAppSpotlight() + const input = await openSpotlight() + + fireEvent.change(input, { target: { value: "r" } }) + act(() => { + spotlight.close() + }) + + expect(trackEvent).not.toHaveBeenCalled() +}) diff --git a/frontend/src/components/AppSpotlight.tsx b/frontend/src/components/AppSpotlight.tsx new file mode 100644 index 000000000..a20f5a939 --- /dev/null +++ b/frontend/src/components/AppSpotlight.tsx @@ -0,0 +1,238 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { useHotkeys } from "@mantine/hooks" +import { + Spotlight, + SpotlightActionData, + SpotlightActionGroupData, + SpotlightFilterFunction, + spotlight, +} from "@mantine/spotlight" +import { faUser, faUsers } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { useNavigate } from "@tanstack/react-router" +import Fuse, { IFuseOptions } from "fuse.js" +import * as React from "react" + +import { trackEvent } from "../analytics" +import { useClientsActiveContext } from "../contexts/ClientsActiveContext" +import { useGroupsActiveContext } from "../contexts/GroupsActiveContext" +import { clientName, isModalShown, prettyPhone } from "../global/utils" +import { ClientActiveType, GroupType } from "../types/models" + +const clientFuseOptions: IFuseOptions = { + shouldSort: true, + ignoreDiacritics: true, + threshold: 0.5, + keys: ["firstname", "surname", "phone", "email", "normalized"], +} + +const groupFuseOptions: IFuseOptions = { + shouldSort: true, + ignoreDiacritics: true, + threshold: 0.4, + keys: ["name"], +} + +const buildClientDescription = (client: ClientActiveType): string | undefined => { + const parts = [client.phone ? prettyPhone(client.phone) : null, client.email || null].filter( + Boolean, + ) + return parts.length > 0 ? parts.join(" · ") : undefined +} + +/** Popisek skupiny výsledků s počtem položek. */ +const groupLabel = (label: string, count: number): string => `${label} (${count})` + +const buildGroupDescription = (group: GroupType): string | undefined => { + const memberCount = group.memberships.length + const courseName = group.course?.name + const parts = [ + courseName ? `kurz: ${courseName}` : null, + memberCount > 0 + ? `${memberCount} ${memberCount === 1 ? "člen" : memberCount < 5 ? "členové" : "členů"}` + : null, + ].filter(Boolean) + return parts.length > 0 ? parts.join(" · ") : undefined +} + +/** Spotlight pro globální vyhledávání klientů a skupin. */ +const AppSpotlight: React.FC = () => { + const navigate = useNavigate() + const clientsActiveContext = useClientsActiveContext() + const groupsActiveContext = useGroupsActiveContext() + const searchSessionRef = React.useRef<{ queried: boolean; hasResults: boolean }>({ + queried: false, + hasResults: false, + }) + + const clientFuse = React.useMemo( + () => new Fuse(clientsActiveContext.clients, clientFuseOptions), + [clientsActiveContext.clients], + ) + + const groupFuse = React.useMemo( + () => new Fuse(groupsActiveContext.groups, groupFuseOptions), + [groupsActiveContext.groups], + ) + + const clientActions: SpotlightActionData[] = React.useMemo( + () => + clientsActiveContext.clients.map((client) => ({ + id: `client-${client.id}`, + label: clientName(client), + description: buildClientDescription(client), + leftSection: , + onClick: () => { + void navigate({ to: "/klienti/$id", params: { id: String(client.id) } }) + }, + })), + [clientsActiveContext.clients, navigate], + ) + + const groupActions: SpotlightActionData[] = React.useMemo( + () => + groupsActiveContext.groups.map((group) => ({ + id: `group-${group.id}`, + label: group.name, + description: buildGroupDescription(group), + leftSection: , + onClick: () => { + void navigate({ to: "/skupiny/$id", params: { id: String(group.id) } }) + }, + })), + [groupsActiveContext.groups, navigate], + ) + + const actions = React.useMemo<(SpotlightActionData | SpotlightActionGroupData)[]>(() => { + const groups: SpotlightActionGroupData[] = [] + if (clientActions.length > 0) { + groups.push({ + group: groupLabel("Klienti", clientActions.length), + actions: clientActions, + }) + } + if (groupActions.length > 0) { + groups.push({ + group: groupLabel("Skupiny", groupActions.length), + actions: groupActions, + }) + } + return groups + }, [clientActions, groupActions]) + + // mapy id → akce pro převod výsledků Fuse zpět na Spotlight akce + const clientActionsById = React.useMemo( + () => new Map(clientActions.map((action) => [action.id, action])), + [clientActions], + ) + + const groupActionsById = React.useMemo( + () => new Map(groupActions.map((action) => [action.id, action])), + [groupActions], + ) + + const filter = React.useCallback( + (query, actionsToFilter) => { + // prázdný dotaz = výchozí stav spotlightu se všemi akcemi a celkovými počty + if (!query.trim()) { + return actionsToFilter + } + + // Fuse se `shouldSort: true` vrací výsledky seřazené podle relevance (skóre), + // akce se proto musí skládat znovu v pořadí výsledků Fuse — pouhé filtrování + // původního pole by řazení podle relevance zahodilo a nejlepší shoda by mohla + // skončit pod slabými fuzzy shodami (případně kvůli `limit` úplně zmizet). + const filteredClientActions = clientFuse + .search(query) + .map((result) => clientActionsById.get(`client-${result.item.id}`)) + .filter((action): action is SpotlightActionData => action !== undefined) + + const filteredGroupActions = groupFuse + .search(query) + .map((result) => groupActionsById.get(`group-${result.item.id}`)) + .filter((action): action is SpotlightActionData => action !== undefined) + + // počty v popiscích skupin musí odpovídat počtu nalezených výsledků, + // ne celkovému počtu klientů/skupin + const filtered: (SpotlightActionData | SpotlightActionGroupData)[] = [] + if (filteredClientActions.length > 0) { + filtered.push({ + group: groupLabel("Klienti", filteredClientActions.length), + actions: filteredClientActions, + }) + } + if (filteredGroupActions.length > 0) { + filtered.push({ + group: groupLabel("Skupiny", filteredGroupActions.length), + actions: filteredGroupActions, + }) + } + return filtered + }, + [clientFuse, groupFuse, clientActionsById, groupActionsById], + ) + + // Zaznamenat nejnovější stav dotazu (eventy se odešlou až při zavření spotlightu, + // aby šel report o úspěšnosti hledání nad finálním dotazem, ne nad jedním znakem). + // Detekce musí být zde, nikoli ve `filter` — ten Mantine volá během renderu + // a mutace ref by tam byla vedlejším efektem v render fázi. + // Fuse se tím hledá 2× na stisk klávesy (zde + ve `filter`) — vědomý trade-off, + // při stovkách záznamů je to <1 ms a čistota `filter` má přednost. + const onQueryChange = React.useCallback( + (query: string) => { + if (query.trim().length >= 2) { + searchSessionRef.current = { + queried: true, + hasResults: + clientFuse.search(query).length + groupFuse.search(query).length > 0, + } + } + }, + [clientFuse, groupFuse], + ) + + const onSpotlightClose = React.useCallback(() => { + if (searchSessionRef.current.queried) { + trackEvent("search_used", { has_results: searchSessionRef.current.hasResults }) + } + searchSessionRef.current = { queried: false, hasResults: false } + }, []) + + // Vlastni hotkey s `isModalShown()` guardem – pokud je otevreny Mantine Modal, + // nechci, aby Spotlight prebral focus a vytvoril druhy focus trap. + // (Internal Mantine shortcut je vypnuty pres `shortcut={null}` nize.) + // Prazdne `tagsToIgnore` – paleta prikazu se musi otevrit globalne, tedy i pri + // fokusu v input/textarea/select (vychozi chovani useHotkeys by je ignorovalo). + useHotkeys( + [ + [ + "mod+K", + () => { + if (!isModalShown()) { + spotlight.open() + } + }, + ], + ], + [], + ) + + return ( + + ) +} + +export default AppSpotlight diff --git a/frontend/src/components/AttendancePaidButton.css.ts b/frontend/src/components/AttendancePaidButton.css.ts index b0b5958e2..d168e7464 100644 --- a/frontend/src/components/AttendancePaidButton.css.ts +++ b/frontend/src/components/AttendancePaidButton.css.ts @@ -1,24 +1,43 @@ import { style } from "@vanilla-extract/css" +import { vars } from "../theme/tokens" + +export const buttonWrap = style({ + display: "inline-flex", + borderRadius: vars.radius.sm, + selectors: { + "&:focus-visible": { + outline: `2px solid ${vars.colors.primary}`, + outlineOffset: "2px", + }, + // Pri pending stavu visualne signalizuj „cekej", ale ponech pointer-events, + // jinak by Mantine Tooltip prestal reagovat na hover. Kliknuti blokuje handler. + '&[aria-busy="true"]': { + opacity: 0.6, + cursor: "wait", + }, + }, +}) + export const attendancePaidButton = style({ - position: "relative", - top: "0.06rem", transition: "color 0.15s ease-in-out", cursor: "pointer", }) export const attendancePaidButtonSuccess = style({ + color: vars.colors.success, selectors: { "&:hover": { - color: "#13663f !important", + color: vars.colors.successHover, }, }, }) export const attendancePaidButtonDanger = style({ + color: vars.colors.danger, selectors: { "&:hover": { - color: "#c72332 !important", + color: vars.colors.dangerHover, }, }, }) diff --git a/frontend/src/components/AttendancePaidButton.tsx b/frontend/src/components/AttendancePaidButton.tsx index 627dd8687..f63d131a5 100644 --- a/frontend/src/components/AttendancePaidButton.tsx +++ b/frontend/src/components/AttendancePaidButton.tsx @@ -1,4 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Tooltip } from "@mantine/core" import { faUsdCircle } from "@rodlukas/fontawesome-pro-solid-svg-icons" import classNames from "classnames" import * as React from "react" @@ -7,7 +8,6 @@ import { AnalyticsSource, trackEvent } from "../analytics" import { usePatchAttendance } from "../api/hooks" import * as styles from "./AttendancePaidButton.css" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" type Props = { /** Lekce je zaplacená (true). */ @@ -24,7 +24,12 @@ const AttendancePaidButton: React.FC = (props) => { successMessage: "Stav platby za lekci uložen", }) + const isPending = patchAttendance.isPending + const onClick = React.useCallback((): void => { + if (isPending) { + return + } const newPaid = !props.paid const id = props.attendanceId const data = { id, paid: newPaid } @@ -32,31 +37,39 @@ const AttendancePaidButton: React.FC = (props) => { onSuccess: () => trackEvent("attendance_paid_toggled", { source: props.source, paid: newPaid }), }) - }, [props.paid, props.attendanceId, props.source, patchAttendance]) + }, [isPending, props.paid, props.attendanceId, props.source, patchAttendance]) const className = classNames(styles.attendancePaidButton, { [styles.attendancePaidButtonSuccess]: props.paid, [styles.attendancePaidButtonDanger]: !props.paid, - "text-success": props.paid, - "text-danger": !props.paid, }) const title = `Označit lekci jako ${props.paid ? "NE" : ""}ZAPLACENOU` + // focus: obsah tooltipu musí být dosažitelný i z klávesnice (WCAG 1.4.13) return ( - <> - + - - {title} - - + onKeyDown={(e): void => { + if ((e.key === "Enter" || e.key === " ") && !isPending) { + e.preventDefault() + onClick() + } + }}> + + + ) } diff --git a/frontend/src/components/AttendanceRemindPay.tsx b/frontend/src/components/AttendanceRemindPay.tsx index b970bbc1b..45fe893de 100644 --- a/frontend/src/components/AttendanceRemindPay.tsx +++ b/frontend/src/components/AttendanceRemindPay.tsx @@ -1,11 +1,11 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Tooltip } from "@mantine/core" import { faCommentAltDollar } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" +import { vars } from "../theme/tokens" import { AttendanceType } from "../types/models" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" - type Props = { /** Účast klienta na lekci. */ attendance: AttendanceType @@ -17,18 +17,16 @@ const AttendanceRemindPay: React.FC = ({ attendance }) => { return null } return ( - <> - - - Příště platit - - + + + + + ) } diff --git a/frontend/src/components/AttendanceSelectAttendanceState.tsx b/frontend/src/components/AttendanceSelectAttendanceState.tsx index 9fb0b3980..e339cc696 100644 --- a/frontend/src/components/AttendanceSelectAttendanceState.tsx +++ b/frontend/src/components/AttendanceSelectAttendanceState.tsx @@ -1,9 +1,9 @@ +import { Select } from "@mantine/core" import * as React from "react" import { AnalyticsSource, trackEvent } from "../analytics" import { usePatchAttendance } from "../api/hooks" import { useAttendanceStatesContext } from "../contexts/AttendanceStatesContext" -import CustomInputWrapper from "../forms/helpers/CustomInputWrapper" import { AttendanceStateType, AttendanceType } from "../types/models" type Props = { @@ -23,35 +23,38 @@ const AttendanceSelectAttendanceState: React.FC = (props) => { }) const onChange = React.useCallback( - (e: React.ChangeEvent): void => { - const newValue = Number(e.currentTarget.value) - const id = props.attendanceId - const data = { id, attendancestate: newValue } - patchAttendance.mutate(data, { - onSuccess: () => trackEvent("attendance_state_changed", { source: props.source }), - }) + (val: string | null): void => { + if (!val) { + return + } + patchAttendance.mutate( + { id: props.attendanceId, attendancestate: Number(val) }, + { + onSuccess: () => + trackEvent("attendance_state_changed", { source: props.source }), + }, + ) }, [props.attendanceId, props.source, patchAttendance], ) + const data = attendancestates + .filter((s) => s.visible || s.id === props.value) + .map((s) => ({ value: s.id.toString(), label: s.name })) + return ( - - {attendancestates.map( - (attendancestate) => - // ukaz pouze viditelne, pokud ma klient neviditelny, ukaz ho take - (attendancestate.visible || attendancestate.id === props.value) && ( - - ), - )} - + data={data} + value={props.value.toString()} + onChange={onChange} + size="sm" + comboboxProps={{ withinPortal: true }} + allowDeselect={false} + // select nemá viditelný label — přístupný název pro čtečky obrazovky + aria-label="Výběr stavu účasti klienta na lekci" + data-qa="lecture_select_attendance_attendancestate" + /> ) } diff --git a/frontend/src/components/Attendances.css.ts b/frontend/src/components/Attendances.css.ts index 5a51953a4..c14f30d52 100644 --- a/frontend/src/components/Attendances.css.ts +++ b/frontend/src/components/Attendances.css.ts @@ -5,18 +5,18 @@ export const attendances = style({ verticalAlign: "top", }) -export const attendanceNumber = style({ - selectors: { - [`${attendances} &`]: { - marginBottom: "0.3rem", - }, - }, -}) - globalStyle(`${attendances} li`, { + display: "flex", + flexWrap: "wrap", + alignItems: "center", + gap: "0.2rem 0.3rem", listStyleType: "none", }) +export const attendanceStateWrapper = style({ + flex: "1 0 100%", +}) + globalStyle(`${attendances} p`, { margin: 0, }) diff --git a/frontend/src/components/Attendances.tsx b/frontend/src/components/Attendances.tsx index cbb46bf56..8f481451a 100644 --- a/frontend/src/components/Attendances.tsx +++ b/frontend/src/components/Attendances.tsx @@ -1,6 +1,6 @@ +import { Badge } from "@mantine/core" import classNames from "classnames" import * as React from "react" -import { Badge } from "reactstrap" import { AnalyticsSource } from "../analytics" import { AttendanceType, LectureType } from "../types/models" @@ -28,20 +28,20 @@ const Attendance: React.FC = ({ attendance, showClient = false, {" "} {attendance.number && ( <> - + {attendance.number} {" "} )} - - + + +
+ +
) @@ -62,7 +62,12 @@ const Attendances: React.FC = ({ lecture, showClient = false, return (
    {lecture.attendances.map((attendance) => ( - + ))}
) diff --git a/frontend/src/components/Bank.css.ts b/frontend/src/components/Bank.css.ts index 8a814ac4e..b1670cb9f 100644 --- a/frontend/src/components/Bank.css.ts +++ b/frontend/src/components/Bank.css.ts @@ -1,13 +1,90 @@ import { style } from "@vanilla-extract/css" +import { surfaceCard } from "../global/surfaces.css" +import { vars } from "../theme/tokens" + +export const bankWrapper = style([ + surfaceCard, + { + overflow: "hidden", + }, +]) + export const bankTitle = style({ - padding: "0.75rem", + borderBottom: vars.borderShort.default, + padding: "0.8rem", +}) + +export const bankTitleInner = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "space-between", + gap: "0.5rem", +}) + +export const bankContent = style({ + padding: "0.8rem", }) export const bankTitleText = style({ + marginBottom: 0, "@media": { "(min-width: 576px)": { paddingLeft: "2.3125rem", }, }, }) + +export const bankActions = style({ + color: vars.text.muted, +}) + +export const bankDateColumn = style({ + minWidth: "6em", +}) + +export const bankAmountColumn = style({ + minWidth: "7em", +}) + +// V dark módu nelze použít plné green-9/red-9 pozadí (světlý text by na něm neměl WCAG AA +// kontrast), proto se sytá barva tlumí přes color-mix s povrchem dark-7 — stejně jako u today-row. +// Kontrasty textu: light #1e6b30 na green-1 = 5.72:1; dark green-2 na mixu (#26482d) = 7.97:1. +export const bankTitleOk = style({ + backgroundColor: + "light-dark(var(--mantine-color-green-1), color-mix(in srgb, var(--mantine-color-green-9) 35%, var(--mantine-color-dark-7)))", + color: "light-dark(#1e6b30, var(--mantine-color-green-2))", +}) + +// Kontrasty textu: light #9b1c1c na red-1 = 6.73:1; dark red-2 na mixu (#5e2626) = 8.11:1. +// Ikona `danger` (red-4) má na dark mixu 5.09:1 (ikonám stačí 3:1). +export const bankTitleWarning = style({ + backgroundColor: + "light-dark(var(--mantine-color-red-1), color-mix(in srgb, var(--mantine-color-red-9) 35%, var(--mantine-color-dark-7)))", + color: "light-dark(#9b1c1c, var(--mantine-color-red-2))", +}) + +// Mantine `` aplikuje zebra `tr:nth-of-type(odd/even)` pravidla s vyšší +// specificitou než jedna třída — proto `!important`, aby zvýraznění today-row přebilo zebru. +// Hover styl se pak resi explicitne nize, jinak by `!important` background zrusil i highlightOnHover. +// V dark módu nelze použít plné yellow-9 pozadí (světlý text by měl jen 1.81:1), proto se žlutá +// tlumí přes color-mix s povrchem dark-7. Podíl 25 % (hover 20 %) je zvolen tak, aby i záporné +// částky (`danger` = red-4) měly ≥4.5:1: red-4 na mixu 4.56:1 (hover 4.97:1), běžný text (dark-0) +// 6.38:1 (hover 6.94:1). Light: text #1f2937 na yellow-1 = 13.16:1, red-9 na yellow-1 = 4.89:1; +// hover yellow-2 (yellow-3 by s red-9 měla jen 4.19:1): text 12.38:1, red-9 4.60:1. +export const bankRowToday = style({ + backgroundColor: + "light-dark(var(--mantine-color-yellow-1), color-mix(in srgb, var(--mantine-color-yellow-9) 25%, var(--mantine-color-dark-7))) !important", + selectors: { + "&:hover": { + backgroundColor: + "light-dark(var(--mantine-color-yellow-2), color-mix(in srgb, var(--mantine-color-yellow-9) 20%, var(--mantine-color-dark-7))) !important", + }, + }, +}) + +/** Text pro chybové/záporné hodnoty — sémantický `danger` token místo raw `red.7`. */ +export const bankDangerText = style({ + color: vars.colors.danger, +}) diff --git a/frontend/src/components/Bank.tsx b/frontend/src/components/Bank.tsx index 54823750d..9e62e120a 100644 --- a/frontend/src/components/Bank.tsx +++ b/frontend/src/components/Bank.tsx @@ -1,4 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Box, Table, Text, Title, Tooltip } from "@mantine/core" import { faExclamationCircle, faExternalLink, @@ -7,18 +8,18 @@ import { } from "@rodlukas/fontawesome-pro-solid-svg-icons" import classNames from "classnames" import * as React from "react" -import { ListGroup, ListGroupItem, Table } from "reactstrap" import { useBank } from "../api/hooks" import { BANKING_URL } from "../global/constants" import { isToday, prettyDateWithDayYearIfDiff } from "../global/funcDateTime" +import { bold, inlineBlockNowrap, nowrap } from "../global/utility.css" import { prettyAmount } from "../global/utils" +import { vars } from "../theme/tokens" import { BankType, BankSuccessType, BankErrorType } from "../types/models" import * as styles from "./Bank.css" import CustomButton from "./buttons/CustomButton" import NoInfo from "./NoInfo" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" /** Type guard pro úspěšná bankovní data. */ function isBankSuccess(data: BankType | undefined): data is BankSuccessType { @@ -36,15 +37,15 @@ const REFRESH_TIMEOUT = 60 // sekundy type TableInfoProps = { /** Text k zobrazení. */ text?: string - /** Barva textu (bootstrap). */ - color?: string } /** Pomocná komponenta pro výpis hlášky místo transakcí v tabulce. */ -const TableInfo: React.FC = ({ text, color = "text-muted" }) => ( - - - +const TableInfo: React.FC = ({ text }) => ( + + + {text} + + ) /** Komponenta zobrazující přehled transakcí z banky. */ @@ -82,7 +83,7 @@ const Bank: React.FC = () => { return isLoadingState ? "načítání" : "neznámý" } return ( - + {prettyAmount(bankData.accountStatement.info.closingBalance)} ) @@ -102,31 +103,34 @@ const Bank: React.FC = () => { const duplicates = messageObj && messageObj.value === commentObj?.value const targetAccountOwnerObj = transaction.column10 return ( - - + {!duplicates && ( - + )} - - - + + ) }) } @@ -134,82 +138,105 @@ const Bank: React.FC = () => { const renderMainContent = (): React.ReactNode => { if (isBankSuccess(bankData)) { return ( -
{text}
+ + {commentObj?.value ?? (targetAccountOwnerObj?.value ? ( `Vlastník protiúčtu: ${targetAccountOwnerObj.value}` ) : ( ))} - + {messageObj ? messageObj.value : } - + {prettyDateWithDayYearIfDiff(date, true)} - + + {prettyAmount(amount)} -
- - - - - - - - - {renderTableBody(bankData)} -
PoznámkaZpráva pro příjemceDatumSuma
+ + + + + Poznámka + Zpráva pro příjemce + Datum + Suma + + + {renderTableBody(bankData)} +
+
) } if (isBankError(bankData)) { - return

{bankData.error_info}

+ return ( + + {bankData.error_info} + + ) } return null } return ( - - -

- Aktuální stav: {getBalanceText()}{" "} - {isLackOfMoney && ( - <> - - Na účtu není dostatek peněz (alespoň{" "} - - {prettyAmount(bankData.rent_price)} +
+ +
+ + Aktuální stav: {getBalanceText()}{" "} + {isLackOfMoney && ( + <Tooltip + label={`Na účtu není dostatek peněz (alespoň ${prettyAmount(bankData.rent_price)}) pro zaplacení nájmu!`} + // focus + tabIndex: obsah tooltipu musí být dosažitelný + // i z klávesnice (WCAG 1.4.13) + events={{ hover: true, focus: true, touch: true }}> + {/* eslint-disable jsx-a11y/no-noninteractive-tabindex -- + trigger tooltipu musí být fokusovatelný, jinak je obsah + jen pro myš (WAI-ARIA tooltip pattern); bloková forma, + protože -next-line nedosáhne na atribut o 2 řádky níž */} + <span + tabIndex={0} + role="img" + aria-label="Varování: nedostatek peněz na zaplacení nájmu"> + {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */} + <FontAwesomeIcon + icon={faExclamationCircle} + color={vars.colors.danger} + size="lg" + aria-hidden + /> </span> - ) pro zaplacení nájmu! - </UncontrolledTooltipWrapper> - <FontAwesomeIcon - id="Bank_RentWarning" - icon={faExclamationCircle} - className="text-danger" - size="lg" - /> - </> - )} - </h4> - <div className="text-muted d-inline float-end" id="Bank"> - <CustomButton - onClick={onClick} - disabled={isRefreshDisabled} - size="sm" - content={ - <FontAwesomeIcon icon={faSyncAlt} size="lg" spin={isLoadingState} /> - } - /> - <UncontrolledTooltipWrapper target="Bank"> - {isRefreshDisabled ? `Výpis lze obnovit jednou za minutu` : "Obnovit výpis"} - </UncontrolledTooltipWrapper>{" "} + </Tooltip> + )} + +
+ + + + } + /> + + +
- - +
+
{renderMainContent()} -
- Transakce starší než{" "} - 30 dnů lze zobrazit pouze{" "} + + {" "} + + Transakce starší než 30 dnů lze zobrazit pouze{" "} + v bankovnictví - . -
- - + . + +
+
) } diff --git a/frontend/src/components/BaseModal.tsx b/frontend/src/components/BaseModal.tsx new file mode 100644 index 000000000..4671e98e6 --- /dev/null +++ b/frontend/src/components/BaseModal.tsx @@ -0,0 +1,27 @@ +import { Modal, ModalProps } from "@mantine/core" +import * as React from "react" + +import { MODAL_TRANSITION_PROPS } from "../theme/theme" + +type Props = ModalProps + +/** + * Standardní modal wrapper. Centrální defaulty jsou v theme (centered, overlay, transition). + * Použij size prop pro přizpůsobení: "sm" | "md" | "lg" | "xl" | "90rem". + * + * Kontrakt kompozice obsahu (Mantine Modal balí children vždy do vlastního Modal.Body): + * - formuláře (FormXxx s `data-qa^='form_'`) si renderují vlastní Modal.Header/Modal.Body + * a modal otevírají s `withCloseButton={false}` — padding vnějšího těla nuluje globální + * reset ve FormBase.css.ts, + * - ne-formulářový obsah hlavičku nevnořuje a používá prop `title` (+ výchozí zavírací + * křížek), Mantine pak vykreslí hlavičku přes celou šířku okna sám. + * + * `transitionProps` se zde mergují s theme defaultem: Mantine props theme default nahrazují + * celé, takže předání pouhého `{ onExited }` by jinak modal tiše přepnulo na výchozí + * animaci Mantine ("fade-down"). + */ +const BaseModal: React.FC = ({ transitionProps, ...props }) => ( + +) + +export default BaseModal diff --git a/frontend/src/components/Celebration.tsx b/frontend/src/components/Celebration.tsx index 0592387f8..7a699a1e0 100644 --- a/frontend/src/components/Celebration.tsx +++ b/frontend/src/components/Celebration.tsx @@ -1,9 +1,9 @@ +import { Tooltip } from "@mantine/core" import * as React from "react" import { USER_CELEBRATION } from "../global/constants" import * as styles from "./Celebration.css" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" type Props = { /** ID označující, co slaví lektorka (svátek/narozeniny/nic). */ @@ -15,17 +15,19 @@ const Celebration: React.FC = ({ isUserCelebratingResult }) => { if (isUserCelebratingResult === USER_CELEBRATION.NOTHING) { return null } + const label = `Oslava ${isUserCelebratingResult === USER_CELEBRATION.BIRTHDAY ? "narozenin" : "svátku"}` return ( - <> - - Všechno nejlepší k{" "} - {isUserCelebratingResult === USER_CELEBRATION.BIRTHDAY ? "narozeninám" : "svátku"}! - 😍 - - + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- trigger tooltipu + musí být fokusovatelný, jinak je obsah jen pro myš (WAI-ARIA tooltip pattern) */} + 🎉 - + ) } diff --git a/frontend/src/components/ClientAnalysis.css.ts b/frontend/src/components/ClientAnalysis.css.ts index 8d1b0d92e..9c1488f63 100644 --- a/frontend/src/components/ClientAnalysis.css.ts +++ b/frontend/src/components/ClientAnalysis.css.ts @@ -1,11 +1,54 @@ import { style } from "@vanilla-extract/css" +import { vars } from "../theme/tokens" + import { chartBaseStyles } from "./charts.css" -export { chartTooltip as tooltip } from "./charts.css" +export { chartTooltip as tooltip, tooltipSeriesColor, tooltipSeriesEntry } from "./charts.css" export const chartPanel = style({ ...chartBaseStyles, - boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", - padding: "0.75rem", + boxShadow: vars.shadow.card, + padding: "0.8rem", +}) + +export const tooltipLabel = style({ + marginBottom: "0.25rem", + fontWeight: 600, +}) + +export const tooltipTotal = style({ + marginTop: "0.25rem", + borderTop: vars.borderShort.default, + paddingTop: "0.25rem", +}) + +export const summary = style({ + display: "flex", + flexWrap: "wrap", + justifyContent: "space-around", + gap: "0.5rem", + marginBottom: "1rem", + paddingTop: "0.25rem", +}) + +export const summaryItem = style({ + textAlign: "center", +}) + +export const summaryNumber = style({ + marginBottom: 0, + fontSize: "1.25rem", + fontWeight: 700, +}) + +export const summaryLabel = style({ + color: vars.text.muted, + fontSize: "0.875em", +}) + +export const chartDivider = style({ + marginTop: "0.5rem", + borderTop: vars.borderShort.default, + paddingTop: "1rem", }) diff --git a/frontend/src/components/ClientAnalysis.tsx b/frontend/src/components/ClientAnalysis.tsx index 1699af156..4329877ab 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -1,3 +1,4 @@ +import { assignInlineVars } from "@vanilla-extract/dynamic" import * as React from "react" import { Bar, @@ -55,14 +56,17 @@ const ChartTooltip: React.FC = ({ active, label, payload }) const total = payload.reduce((sum, entry) => sum + entry.value, 0) return (
-
{label}
+
{label}
{payload.map((entry) => ( -
+
{entry.name}: {entry.value}
))} {payload.length > 1 && ( -
+
Celkem: {total}
)} @@ -138,30 +142,30 @@ const ClientAnalysis: React.FC = ({ clientId, lectures }) => { return (
-
-
-
{analysis.happened.length}
-
Proběhlé
+
+
+
{analysis.happened.length}
+
Proběhlé
-
-
{analysis.excused.length}
-
Omluvené
+
+
{analysis.excused.length}
+
Omluvené
-
-
+
+
{analysis.notHappened.length - analysis.excused.length}
-
Zrušené
+
Zrušené
-
-
+
+
{analysis.paid.length}/{analysis.happened.length}
-
Zaplaceno
+
Zaplaceno
{analysis.monthlyData.length > 0 && ( -
+
+ render({ui}) + test("shows email", () => { - render() + renderWithMantine() const link = screen.getByRole("link", { name: "blabla@domena.cz" }) expect(link).toBeInTheDocument() expect(link).toHaveTextContent("blabla@domena.cz") @@ -11,7 +15,7 @@ test("shows email", () => { }) test("doesn't show empty email", () => { - render() + renderWithMantine() const link = screen.queryByRole("link") expect(link).not.toBeInTheDocument() }) diff --git a/frontend/src/components/ClientName.tsx b/frontend/src/components/ClientName.tsx index 5fddd39f8..3cd79029e 100644 --- a/frontend/src/components/ClientName.tsx +++ b/frontend/src/components/ClientName.tsx @@ -17,12 +17,10 @@ type PlainClientNameProps = { const PlainClientName: React.FC = ({ client, bold }) => ( - {client.surname}{" "} + {client.surname}{" "} ( - {children} - )}> + wrapper={(children): React.ReactNode => {children}}> {client.firstname} diff --git a/frontend/src/components/ClientNote.tsx b/frontend/src/components/ClientNote.tsx index 1a5112269..96291d7ad 100644 --- a/frontend/src/components/ClientNote.tsx +++ b/frontend/src/components/ClientNote.tsx @@ -12,7 +12,11 @@ type Props = { /** Komponenta pro jednotné zobrazení poznámky ke klientovi napříč aplikací. */ const ClientNote: React.FC = ({ note }) => { if (note !== "") { - return {note} + return ( + + {note} + + ) } return } diff --git a/frontend/src/components/ClientPhone.tsx b/frontend/src/components/ClientPhone.tsx index eba652816..b322884e2 100644 --- a/frontend/src/components/ClientPhone.tsx +++ b/frontend/src/components/ClientPhone.tsx @@ -2,6 +2,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" import { faPhone } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" +import { iconBeforeText } from "../global/utility.css" import { prettyPhone } from "../global/utils" import { ClientType } from "../types/models" @@ -25,11 +26,7 @@ const ClientPhone: React.FC = ({ phone, icon = false }) => { className={styles.clientPhone} data-gdpr> {icon && ( - + )} {prettyPhone(phone)} diff --git a/frontend/src/components/ClientsList.tsx b/frontend/src/components/ClientsList.tsx index 8023bd817..4be22a8c7 100644 --- a/frontend/src/components/ClientsList.tsx +++ b/frontend/src/components/ClientsList.tsx @@ -1,5 +1,6 @@ import * as React from "react" +import { dimmedText } from "../global/utility.css" import { MembershipType } from "../types/models" import ClientName from "./ClientName" @@ -13,7 +14,7 @@ type Props = { /** Komponenta zobrazující čárkami oddělený seznam všech členů skupiny. */ const ClientsList: React.FC = ({ memberships = [] }) => { if (!memberships.length) { - return žádní členové + return žádní členové } const clientComponents = memberships.map((membership) => ( diff --git a/frontend/src/components/ColorSchemeSync.tsx b/frontend/src/components/ColorSchemeSync.tsx new file mode 100644 index 000000000..553202ee1 --- /dev/null +++ b/frontend/src/components/ColorSchemeSync.tsx @@ -0,0 +1,28 @@ +import { useComputedColorScheme } from "@mantine/core" +import * as React from "react" + +/** + * Synchronizace inline `style.colorScheme` na `` s aktuálně aplikovaným schématem. + * Musí být namountovaná po celou dobu běhu aplikace (tedy i na přihlašovací stránce), + * jinak by u uživatele s „auto" režimem zůstal inline styl z init scriptu zastaralý + * po přepnutí OS schématu — proto žije mimo ColorSchemeToggle, který se renderuje + * jen pro přihlášené uživatele. + */ +const ColorSchemeSync: React.FC = () => { + // `getInitialValueInEffect: false` – aplikace bezi pouze CSR (zadny SSR/hydration), + // potrebujeme spravne schema uz pri prvnim renderu, jinak by useEffect nize + // krátce nastavil `style.colorScheme = "light"` a zpusobil 1-frame flash pro dark uzivatele. + const computedColorScheme = useComputedColorScheme("light", { getInitialValueInEffect: false }) + + // Init script v admin/static/admin/color-scheme-init.js nastavi inline + // `style.colorScheme` na (kvuli nativnim scrollbarum/form controls bez FOUC). + // Inline style ma vyssi specificity nez Mantine CSS pravidlo `:root { color-scheme: var(--mantine-color-scheme) }`, + // takze ho musime presynchronizovat pri runtime prepnuti i pri zmene OS schematu v „auto" rezimu. + React.useEffect(() => { + document.documentElement.style.colorScheme = computedColorScheme + }, [computedColorScheme]) + + return null +} + +export default ColorSchemeSync diff --git a/frontend/src/components/ColorSchemeToggle.css.ts b/frontend/src/components/ColorSchemeToggle.css.ts new file mode 100644 index 000000000..f4682d938 --- /dev/null +++ b/frontend/src/components/ColorSchemeToggle.css.ts @@ -0,0 +1,39 @@ +import { style } from "@vanilla-extract/css" + +export const toggleButton = style({ + border: "none", + color: "rgb(241 245 249 / 0.85)", + // Mantine ActionIcon v "subtle" variantě ovládá barvu ikony přes --ai-color; + // navbar má fixní tmavé pozadí ve všech motivech, takže ikonu držíme světlou. + vars: { + "--ai-color": "rgb(241 245 249 / 0.85)", + "--ai-hover-color": "#ffffff", + "--ai-hover": "rgb(255 255 255 / 0.12)", + }, + "@media": { + "(min-width: 992px)": { + marginLeft: "0.25rem", + }, + "(max-width: 991.98px)": { + alignSelf: "flex-start", + marginTop: "0.25rem", + }, + }, + selectors: { + "&:hover": { + backgroundColor: "rgb(255 255 255 / 0.12)", + color: "#ffffff", + }, + // sjednoceny focus ring s navLink: vychozi indigo ring Mantine (.mantine-focus-auto) + // ma vuci tmavemu navbaru jen ~3:1 (hranicni 1.4.11), blue-3 je vyrazne viditelnejsi; + // vyssi specificita prebiji Mantine ring i pri shode poradi + "&.mantine-focus-auto:focus-visible": { + outline: "2px solid var(--mantine-color-blue-3)", + outlineOffset: "2px", + }, + }, +}) + +export const dropdown = style({ + border: "none", +}) diff --git a/frontend/src/components/ColorSchemeToggle.test.tsx b/frontend/src/components/ColorSchemeToggle.test.tsx new file mode 100644 index 000000000..ec3224121 --- /dev/null +++ b/frontend/src/components/ColorSchemeToggle.test.tsx @@ -0,0 +1,55 @@ +import { MantineProvider } from "@mantine/core" +import { fireEvent, render, screen, waitFor } from "@testing-library/react" + +import ColorSchemeToggle from "./ColorSchemeToggle" + +// env="test" vypina transitions a hideDetached (v jsdom maji elementy nulove rozmery, +// floating-ui by jinak dropdown menu skryl pres display: none); +// defaultColorScheme="auto" odpovida realne aplikaci (index.tsx) +const renderColorSchemeToggle = () => + render( + + + , + ) + +const openMenu = async (): Promise => { + fireEvent.click(screen.getByRole("button", { name: "Přepnout barevné schéma" })) + return await screen.findAllByRole("menuitemradio") +} + +afterEach(() => { + // color scheme prezije unmount (localStorage + atribut na ), testy se musi izolovat + window.localStorage.clear() + document.documentElement.removeAttribute("data-mantine-color-scheme") +}) + +test("shows a menu item per scheme with the active one checked", async () => { + renderColorSchemeToggle() + + const items = await openMenu() + + expect(items.map((item) => item.textContent)).toEqual(["Systém", "Světlý", "Tmavý"]) + // vychozi schema aplikace je auto (Systém) → prave jedna polozka je aria-checked + expect(screen.getByRole("menuitemradio", { name: "Systém" })).toBeChecked() + expect(screen.getByRole("menuitemradio", { name: "Světlý" })).not.toBeChecked() + expect(screen.getByRole("menuitemradio", { name: "Tmavý" })).not.toBeChecked() +}) + +test("switches color scheme to dark on click", async () => { + renderColorSchemeToggle() + + await openMenu() + fireEvent.click(screen.getByRole("menuitemradio", { name: "Tmavý" })) + + await waitFor(() => + expect(document.documentElement).toHaveAttribute("data-mantine-color-scheme", "dark"), + ) + + // po znovuotevreni menu je zaskrtnuta tmava polozka + const items = await openMenu() + const checkedLabels = items + .filter((item) => item.getAttribute("aria-checked") === "true") + .map((item) => item.textContent) + expect(checkedLabels).toEqual(["Tmavý"]) +}) diff --git a/frontend/src/components/ColorSchemeToggle.tsx b/frontend/src/components/ColorSchemeToggle.tsx new file mode 100644 index 000000000..a7dadb7eb --- /dev/null +++ b/frontend/src/components/ColorSchemeToggle.tsx @@ -0,0 +1,100 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { + ActionIcon, + Menu, + Tooltip, + useComputedColorScheme, + useMantineColorScheme, +} from "@mantine/core" +import type { MantineColorScheme } from "@mantine/core" +import { faDesktop, faMoon, faSun } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import * as React from "react" + +import * as styles from "./ColorSchemeToggle.css" + +const SCHEME_ICON: Record = { + auto: faDesktop, + light: faSun, + dark: faMoon, +} + +const SCHEME_LABEL: Record = { + auto: "Systém", + light: "Světlý", + dark: "Tmavý", +} + +/** Přepínač barevného schématu (Systém / Světlý / Tmavý) v hlavičce. */ +const ColorSchemeToggle: React.FC = () => { + const { colorScheme, setColorScheme } = useMantineColorScheme() + // `getInitialValueInEffect: false` – aplikace bezi pouze CSR (zadny SSR/hydration), + // potrebujeme spravne schema uz pri prvnim renderu, jinak by ikona/tooltip + // v „auto" rezimu na prvni frame ukazovaly nespravne schema. + // (Resynchronizace inline `style.colorScheme` na zije v ColorSchemeSync, + // ktery je namountovany vzdy — tato komponenta resi jen UI prepinace.) + const computedColorScheme = useComputedColorScheme("light", { getInitialValueInEffect: false }) + + // theme-color meta nemenime per scheme: navbar je fixne tmavy gradient v obou motivech + // (viz Main.css.ts), takze i mobile Chrome / PWA status bar drzime na barve horniho + // okraje navbaru, aby nevznikal viditelny sev mezi status barem a navbarem. + + // V „auto" režimu ukazuj v navbaru ikonu aktuálně aplikovaného schématu + // (sun/moon), aby bylo na první pohled vidět, co je právě zobrazeno. + const targetIcon = + colorScheme === "auto" ? SCHEME_ICON[computedColorScheme] : SCHEME_ICON[colorScheme] + const tooltipLabel = + colorScheme === "auto" + ? `Barevné schéma: Systém (${SCHEME_LABEL[computedColorScheme]})` + : `Barevné schéma: ${SCHEME_LABEL[colorScheme]}` + + return ( + + + + + + + + + + Barevné schéma + {/* menuitemradio polozky musi byt dle ARIA 1.2 seskupene v role="group" + (v ramci menu), aby ctecky hlasily kontext skupiny („1 z 3"); + Mantine zadne group API s touto roli nema, proto obycejny div wrapper. */} +
+ {(["auto", "light", "dark"] as MantineColorScheme[]).map((value) => ( + } + onClick={() => setColorScheme(value)} + data-qa={`color_scheme_${value}`} + fw={colorScheme === value ? 600 : 400} + // Aktivni schema musi byt rozpoznatelne i pro ctecky obrazovky + // (samotne tucne pismo nestaci) — trojice voleb se chova jako + // radio skupina: role="menuitemradio" + aria-checked. + // Mantine Menu.Item nastavuje role="menuitem" natvrdo az PO + // rozprostreni props, takze `role` nelze predat jako prop — + // override jde pres polymorfni `renderRoot`. + renderRoot={(rootProps) => ( +
+
+
+ ) +} + +export default ColorSchemeToggle diff --git a/frontend/src/components/CourseCircle.css.ts b/frontend/src/components/CourseCircle.css.ts index b8fb2dad9..a96f10bc6 100644 --- a/frontend/src/components/CourseCircle.css.ts +++ b/frontend/src/components/CourseCircle.css.ts @@ -1,8 +1,15 @@ -import { style } from "@vanilla-extract/css" +import { createVar, style } from "@vanilla-extract/css" + +// Dynamická barva a velikost kolečka — hodnoty dosazuje CourseCircle.tsx přes assignInlineVars. +export const circleColor = createVar() +export const circleSize = createVar() export const courseCircle = style({ display: "inline-block", borderRadius: "50%", boxShadow: "0 0 1px 0 rgb(0 0 0 / 0.85)", + background: circleColor, + width: circleSize, + height: circleSize, verticalAlign: "middle", }) diff --git a/frontend/src/components/CourseCircle.tsx b/frontend/src/components/CourseCircle.tsx index 89a9b8a04..6495c23a3 100644 --- a/frontend/src/components/CourseCircle.tsx +++ b/frontend/src/components/CourseCircle.tsx @@ -1,8 +1,9 @@ +import { Tooltip } from "@mantine/core" +import { assignInlineVars } from "@vanilla-extract/dynamic" import classNames from "classnames" import * as React from "react" import * as styles from "./CourseCircle.css" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" type Props = { /** Barva kolečka u kurzu. */ @@ -17,28 +18,18 @@ type Props = { /** Komponenta zobrazující barevné kolečko s různou barvou a velikostí pro zobrazení barvy kurzu. */ const CourseCircle: React.FC = ({ color, size, showTitle = false, className }) => { - const sizeWithUnit = `${size}rem` - const colorWithoutHash = color.substring(1) - - return ( - <> - - {showTitle && ( - - Kód barvy: {color} - - )} - + const circle = ( + ) + + return showTitle ? {circle} : circle } export default CourseCircle diff --git a/frontend/src/components/CourseName.css.ts b/frontend/src/components/CourseName.css.ts index 692389923..cb87ec708 100644 --- a/frontend/src/components/CourseName.css.ts +++ b/frontend/src/components/CourseName.css.ts @@ -2,8 +2,12 @@ import { createThemeContract, style } from "@vanilla-extract/css" export const courseNameVars = createThemeContract({ color: "", + // Barva textu zvolená podle luminance pozadí (getReadableTextColor) — bílý text + // Mantine `variant="filled"` měl na světlých barvách kurzů kontrast hluboko pod 4.5:1. + textColor: "", }) export const courseName = style({ backgroundColor: `${courseNameVars.color} !important`, + color: `${courseNameVars.textColor} !important`, }) diff --git a/frontend/src/components/CourseName.tsx b/frontend/src/components/CourseName.tsx index d5e1a22f1..08aac471d 100644 --- a/frontend/src/components/CourseName.tsx +++ b/frontend/src/components/CourseName.tsx @@ -1,8 +1,9 @@ +import { Badge } from "@mantine/core" import { assignInlineVars } from "@vanilla-extract/dynamic" import classNames from "classnames" import * as React from "react" -import { Badge } from "reactstrap" +import { getReadableTextColor } from "../global/utils" import { CourseType } from "../types/models" import * as styles from "./CourseName.css" @@ -17,11 +18,14 @@ type Props = { /** Komponenta pro jednotné zobrazení názvu kurzu napříč aplikací. */ const CourseName: React.FC = ({ course, className }) => ( {course.name} diff --git a/frontend/src/components/DashboardDay.css.ts b/frontend/src/components/DashboardDay.css.ts index a6fef108c..47347e895 100644 --- a/frontend/src/components/DashboardDay.css.ts +++ b/frontend/src/components/DashboardDay.css.ts @@ -1,23 +1,50 @@ import { createThemeContract, globalStyle, style } from "@vanilla-extract/css" +import { surfaceCard } from "../global/surfaces.css" +import { vars } from "../theme/tokens" + +// Opacita ztmavujícího overlaye hlavičky — sdílená konstanta pro CSS gradient níže +// i pro výpočet barvy textu v DashboardDay.tsx (getReadableTextColorWithOverlay); +// musí být jedna hodnota, jinak by se text počítal proti jinému pozadí, než se vykreslí. +export const LECTURE_HEADING_OVERLAY_OPACITY = 0.18 + export const dashboardDayVars = createThemeContract({ courseBackground: "", + // Barva textu podle luminance složeného pozadí (kurz + overlay níže) — + // viz getReadableTextColorWithOverlay; natvrdo bílý text neměl na světlých + // barvách kurzů dostatečný kontrast. + courseText: "", }) export const lectureGroup = style({ - backgroundColor: "#e7e7e7", + backgroundColor: vars.bg.muted, }) export const dashboardDayDate = style({ - padding: "0.75rem", + display: "flex", + alignItems: "center", + gap: "0.5rem", + borderBottom: vars.borderShort.default, + backgroundColor: vars.bg.surface, + padding: "0.6rem 0.85rem", + minHeight: "3.25rem", + color: vars.text.headingSoft, +}) + +// přebíjí `dashboardDayDate` výše — stejná specificita, vyhrává pozdější pořadí v tomto souboru +export const dashboardDayDateToday = style({ + backgroundColor: "light-dark(var(--mantine-color-indigo-1), var(--mantine-color-indigo-9))", }) export const celebrationNone = style({ - "@media": { - "(min-width: 576px)": { - paddingLeft: "2.86875rem", - }, - }, + flex: 1, + paddingLeft: "2.4rem", + minWidth: 0, + textAlign: "center", +}) + +export const dashboardDayDateAction = style({ + flexShrink: 0, }) export const lectureCanceledDashboardday = style({}) @@ -28,7 +55,8 @@ globalStyle(`${lectureCanceledDashboardday} h4 span::after`, { export const lectureHeading = style({ backgroundColor: `${dashboardDayVars.courseBackground} !important`, - color: "white", + backgroundImage: `linear-gradient(rgb(15 23 42 / ${LECTURE_HEADING_OVERLAY_OPACITY}), rgb(15 23 42 / ${LECTURE_HEADING_OVERLAY_OPACITY}))`, + color: dashboardDayVars.courseText, }) export const courseName = style({ @@ -37,13 +65,29 @@ export const courseName = style({ }) export const lectureNumber = style({ - backgroundColor: "white", + backgroundColor: "light-dark(white, var(--mantine-color-dark-6))", }) export const lectureFree = style({ + // !important: přebíjí padding shorthand třídy `lecture` (Lecture.css.ts) — stejná + // specificita a pořadí tříd napříč soubory není v bundlu garantované paddingTop: "1rem !important", }) -export const dashboardDayWrapper = style({ - position: "relative", +export const dashboardDayWrapper = style([ + surfaceCard, + { + position: "relative", + overflow: "hidden", + }, +]) + +export const dashboardDayItem = style({ + transition: "background-color 0.15s ease-in-out", + borderTop: vars.borderShort.default, + selectors: { + "&:hover": { + backgroundColor: vars.bg.hover, + }, + }, }) diff --git a/frontend/src/components/DashboardDay.tsx b/frontend/src/components/DashboardDay.tsx index df7b69bad..f79df474e 100644 --- a/frontend/src/components/DashboardDay.tsx +++ b/frontend/src/components/DashboardDay.tsx @@ -1,7 +1,7 @@ +import { Box, Text, Title, Tooltip } from "@mantine/core" import { assignInlineVars } from "@vanilla-extract/dynamic" import classNames from "classnames" import * as React from "react" -import { ListGroup, ListGroupItem, ListGroupItemHeading } from "reactstrap" import { AnalyticsSource } from "../analytics" import { useLecturesFromDay } from "../api/hooks" @@ -16,7 +16,8 @@ import { prettyTime, toISODate, } from "../global/funcDateTime" -import { courseDuration } from "../global/utils" +import { inlineBlockNowrap, mb0 } from "../global/utility.css" +import { courseDuration, getReadableTextColorWithOverlay } from "../global/utils" import { DEFAULT_DELAY, useDelayedValue } from "../hooks/useDelayedValue" import Attendances from "./Attendances" @@ -27,7 +28,6 @@ import GroupName from "./GroupName" import * as lectureStyles from "./Lecture.css" import LectureNumber from "./LectureNumber" import Loading from "./Loading" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" type Props = { /** Při požadavcích na API nedělej prodlevu (true) - prodleva se hodí při rychlém překlikávání mezi dny v diáři. */ @@ -57,103 +57,111 @@ const DashboardDay: React.FC = (props) => { const isUserCelebratingResult = isUserCelebrating(getDate()) const showLoading = isLoading || attendanceStatesContext.isLoading + const hasLectures = lectures.length > 0 + let content: React.ReactNode + if (showLoading) { + content = ( +
+ +
+ ) + } else if (hasLectures) { + content = lectures.map((lecture) => { + const className = classNames(lectureStyles.lecture, styles.dashboardDayItem, { + [styles.lectureGroup]: lecture.group && !lecture.canceled, + [lectureStyles.lectureCanceled]: lecture.canceled, + [styles.lectureCanceledDashboardday]: lecture.canceled, + }) + return ( +
+
+ + <Tooltip label={courseDuration(lecture.duration)}> + <strong>{prettyTime(new Date(lecture.start))}</strong> + </Tooltip> + + + + +
+
+ {lecture.group && ( + + <GroupName group={lecture.group} title link /> + + )} + +
+
+ ) + }) + } else { + content = ( +
+ + Volno + +
+ ) + } return ( - - -

+ + <Celebration isUserCelebratingResult={isUserCelebratingResult} /> {title} - </h4> + - - {showLoading ? ( - - - - ) : lectures.length > 0 ? ( - lectures.map((lecture) => { - const className = classNames(lectureStyles.lecture, { - [styles.lectureGroup]: lecture.group && !lecture.canceled, - [lectureStyles.lectureCanceled]: lecture.canceled, - [styles.lectureCanceledDashboardday]: lecture.canceled, - }) - return ( - -
-

- - {prettyTime(new Date(lecture.start))} - - - {courseDuration(lecture.duration)} - -

- - - -
-
- {lecture.group && ( -
- -
- )} - -
-
- ) - }) - ) : ( - - - Volno - - - )} - +
+ {content} +

) } diff --git a/frontend/src/components/GroupName.tsx b/frontend/src/components/GroupName.tsx index 217a03ab3..e3e1ae97c 100644 --- a/frontend/src/components/GroupName.tsx +++ b/frontend/src/components/GroupName.tsx @@ -1,8 +1,8 @@ import { Link } from "@tanstack/react-router" -import classNames from "classnames" import * as React from "react" import APP_URLS from "../APP_URLS" +import { bold as boldStyle, nowrap } from "../global/utility.css" import { GroupType } from "../types/models" import ConditionalWrapper from "./ConditionalWrapper" @@ -24,9 +24,7 @@ const PlainName: React.FC = ({ group, title, bold }) => ( ( - {children} - )}> + wrapper={(children): React.ReactNode => {children}}> {title && "Skupina "} {group.name} @@ -64,7 +62,7 @@ const GroupName: React.FC = ({ {"id" in group && link ? ( - + {showCircle && ( * + *`, { - marginLeft: "0.3rem", +export const headingButtons = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", + justifyContent: "flex-end", + gap: "0.4rem", + "@media": { + "(max-width: 767.98px)": { + justifyContent: "flex-start", + width: "100%", + }, + }, +}) + +export const headingTitle = style({ + margin: 0, + color: vars.text.primary, +}) + +export const headingWithoutButtons = style({ + width: "100%", }) globalStyle(`${headingButtons} > *`, { "@media": { - "(max-width: 991.98px)": { + "(max-width: 767.98px)": { marginTop: "0.3rem", }, }, }) + +globalStyle(`${headingTitle} .mantine-Badge-root`, { + verticalAlign: "middle", +}) diff --git a/frontend/src/components/Heading.tsx b/frontend/src/components/Heading.tsx index ee1ec7d19..520d835e7 100644 --- a/frontend/src/components/Heading.tsx +++ b/frontend/src/components/Heading.tsx @@ -1,8 +1,7 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" -import classNames from "classnames" +import { Group, Loader, Title } from "@mantine/core" import * as React from "react" -import { Col, Row } from "reactstrap" + +import { iconAfterText } from "../global/utility.css" import * as styles from "./Heading.css" @@ -15,31 +14,39 @@ type Props = { fluid?: boolean /** Probíhá načítání dat na pozadí (true) - zobrazí spinner v nadpisu. */ isFetching?: boolean + /** HTML úroveň nadpisu (h1–h6). Defaultně 1; používej 2 u sekundárních sekcí. */ + order?: 1 | 2 | 3 | 4 | 5 | 6 } /** Komponenta pro jednotné zobrazení nadpisu stránky napříč aplikací. */ -const Heading: React.FC = ({ title, buttons, fluid = false, isFetching = false }) => ( - - -

- {title} - {isFetching && ( - - )} -

- - - {buttons} - -
+const Heading: React.FC = ({ + title, + buttons, + fluid = false, + isFetching = false, + order = 1, +}) => ( + + + {title} + {isFetching && ( + <Loader + size="xs" + type="dots" + color="gray" + className={iconAfterText} + data-qa="loading" + /> + )} + + {buttons ?
{buttons}
: null} +
) export default Heading diff --git a/frontend/src/components/InfoTooltip.css.ts b/frontend/src/components/InfoTooltip.css.ts new file mode 100644 index 000000000..cad0e3f3f --- /dev/null +++ b/frontend/src/components/InfoTooltip.css.ts @@ -0,0 +1,8 @@ +import { style } from "@vanilla-extract/css" + +import { vars } from "../theme/tokens" + +// Sémantický `warning` token (light #b45309 = 5.02:1 na bílé; yellow-7 by měla jen 2.13:1). +export const warningIcon = style({ + color: vars.colors.warning, +}) diff --git a/frontend/src/components/InfoTooltip.tsx b/frontend/src/components/InfoTooltip.tsx new file mode 100644 index 000000000..88afb3ce0 --- /dev/null +++ b/frontend/src/components/InfoTooltip.tsx @@ -0,0 +1,43 @@ +import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome" +import { Tooltip as MantineTooltip, TooltipProps } from "@mantine/core" +import { faInfoCircle } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import * as React from "react" + +import * as styles from "./InfoTooltip.css" + +type Props = { + /** Text zobrazený v Tooltipu. */ + text: React.ReactNode + /** Velikost ikony, která zobrazí Tooltip. */ + size?: FontAwesomeIconProps["size"] + /** Pozice Tooltipu. */ + placement?: TooltipProps["position"] + /** Ikona zobrazená jako trigger Tooltipu (výchozí: faInfoCircle). */ + icon?: FontAwesomeIconProps["icon"] + /** Přístupný popisek ikony pro čtečky obrazovky. */ + label?: string +} + +/** Komponenta pro zobrazení info ikony s titulkem po najetí myší nebo focusu z klávesnice. */ +const InfoTooltip: React.FC = ({ + text, + size = "lg", + placement = "bottom", + icon = faInfoCircle, + label = "Doplňující informace", +}) => ( + + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- trigger tooltipu + musí být fokusovatelný, jinak je obsah jen pro myš (WAI-ARIA tooltip pattern) */} + + + + +) + +export default InfoTooltip diff --git a/frontend/src/components/Lecture.css.ts b/frontend/src/components/Lecture.css.ts index 650294ee8..f4eab4076 100644 --- a/frontend/src/components/Lecture.css.ts +++ b/frontend/src/components/Lecture.css.ts @@ -1,5 +1,7 @@ import { globalStyle, style } from "@vanilla-extract/css" +import { vars } from "../theme/tokens" + // Společné styly pro lekce používané v Card a DashboardDay export const lecture = style({ @@ -7,7 +9,7 @@ export const lecture = style({ }) export const lectureCanceled = style({ - backgroundColor: "#f8d7da", + backgroundColor: vars.statusTint.danger, }) globalStyle(`${lectureCanceled} h4 span`, { @@ -21,7 +23,6 @@ globalStyle(`${lectureCanceled} h4 span::after`, { right: 0, borderBottom: "2px solid rgb(255 0 0 / 0.6)", width: "100%", - color: "#fff", content: '""', }) diff --git a/frontend/src/components/LectureNote.css.ts b/frontend/src/components/LectureNote.css.ts index e26913dd4..edf8bcf8a 100644 --- a/frontend/src/components/LectureNote.css.ts +++ b/frontend/src/components/LectureNote.css.ts @@ -3,4 +3,6 @@ import { style } from "@vanilla-extract/css" export const lectureNote = style({ marginBottom: "0.3rem", textAlign: "left", + // poznamka je uzivatelsky obsah - default uppercase Mantine Badge by ji deformoval + textTransform: "none", }) diff --git a/frontend/src/components/LectureNote.tsx b/frontend/src/components/LectureNote.tsx index bd97f9677..9701710bd 100644 --- a/frontend/src/components/LectureNote.tsx +++ b/frontend/src/components/LectureNote.tsx @@ -1,5 +1,5 @@ +import { Badge } from "@mantine/core" import * as React from "react" -import { Badge } from "reactstrap" import { AttendanceType } from "../types/models" @@ -11,10 +11,19 @@ type Props = { } /** Komponenta zobrazující poznámku k lekci. */ -const LectureNote: React.FC = ({ attendance }) => ( - - {attendance.note} - -) +const LectureNote: React.FC = ({ attendance }) => { + if (!attendance.note) { + return null + } + return ( + + {attendance.note} + + ) +} export default LectureNote diff --git a/frontend/src/components/LectureNumber.css.ts b/frontend/src/components/LectureNumber.css.ts index 096cfc7e6..017301db4 100644 --- a/frontend/src/components/LectureNumber.css.ts +++ b/frontend/src/components/LectureNumber.css.ts @@ -1,9 +1,13 @@ import { createThemeContract, style } from "@vanilla-extract/css" export const lectureNumberVars = createThemeContract({ - color: "", + // Barva kurzu upravená pro kontrast ≥4.5:1 zvlášť pro světlé a tmavé pozadí + // pilulky (light-dark(white, dark-6), viz DashboardDay.css.ts) — + // výpočet dělá adjustColorForContrast v LectureNumber.tsx. + colorLight: "", + colorDark: "", }) export const lectureNumber = style({ - color: `${lectureNumberVars.color} !important`, + color: `light-dark(${lectureNumberVars.colorLight}, ${lectureNumberVars.colorDark}) !important`, }) diff --git a/frontend/src/components/LectureNumber.tsx b/frontend/src/components/LectureNumber.tsx index 88c31ba50..4e4c3914b 100644 --- a/frontend/src/components/LectureNumber.tsx +++ b/frontend/src/components/LectureNumber.tsx @@ -1,12 +1,19 @@ +import { Badge } from "@mantine/core" import { assignInlineVars } from "@vanilla-extract/dynamic" import classNames from "classnames" import * as React from "react" -import { Badge } from "reactstrap" +import { adjustColorForContrast } from "../global/utils" import { LectureType } from "../types/models" import * as styles from "./LectureNumber.css" +// Pozadí pilulky čísla lekce: bílá ve světlém režimu, Mantine dark-6 v tmavém +// (override v DashboardDay.css.ts `lectureNumber`) — vůči těmto barvám se počítá +// kontrast obarveného čísla. +const BADGE_BG_LIGHT = "#ffffff" +const BADGE_BG_DARK = "#2e2e2e" // var(--mantine-color-dark-6) + type Props = { /** Lekce. */ lecture: LectureType @@ -30,17 +37,17 @@ const LectureNumber: React.FC = ({ } return ( diff --git a/frontend/src/components/Loading.css.ts b/frontend/src/components/Loading.css.ts new file mode 100644 index 000000000..e1c66cd10 --- /dev/null +++ b/frontend/src/components/Loading.css.ts @@ -0,0 +1,33 @@ +import { style } from "@vanilla-extract/css" + +import { vars } from "../theme/tokens" + +export const wrapper = style({ + marginTop: "0.65rem", + textAlign: "center", +}) + +export const spinner = style({ + color: vars.colors.primary, +}) + +export const text = style({ + marginTop: "0.45rem", + // jako `text.muted`, ale dark o stupeň světlejší (gray-4) — načítací text leží přímo + // na tmavším pozadí stránky (`bg.page` dark-9), ne na povrchu karty + color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-4))", + fontWeight: 500, +}) + +export const longHint = style({ + marginLeft: "0.3rem", + color: vars.text.muted, + fontWeight: 400, +}) + +export const overlongAlert = style({ + marginTop: "0.5rem", + marginRight: "auto", + marginLeft: "auto", + maxWidth: "30rem", +}) diff --git a/frontend/src/components/Loading.tsx b/frontend/src/components/Loading.tsx index a523a2c10..a9513e8d3 100644 --- a/frontend/src/components/Loading.tsx +++ b/frontend/src/components/Loading.tsx @@ -1,9 +1,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Alert } from "@mantine/core" import { faSpinnerThird, faSyncAlt } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" -import { Alert } from "reactstrap" import CustomButton from "./buttons/CustomButton" +import * as styles from "./Loading.css" const LONG_LOADING_THRESHOLD = 5 // sekundy const OVERLONG_LOADING_THRESHOLD = 25 // sekundy @@ -46,13 +47,27 @@ const Loading: React.FC = ({ text = "Načítání" }) => { }, []) return ( -
- -
- {text}... - {loadingState === LOADING_STATE.LONG_LOADING && " Stále pracuji 😎"} +
+ +

+ {text}… + {loadingState === LOADING_STATE.LONG_LOADING && ( + Stále pracuji + )} +

{loadingState === LOADING_STATE.OVERLONG_LOADING && ( - +

⚠ Načítání trvá příliš dlouho, mohlo dojít k chybě. Zkuste stránku načíst znovu. diff --git a/frontend/src/components/Menu.css.ts b/frontend/src/components/Menu.css.ts index f198b9544..418342b3f 100644 --- a/frontend/src/components/Menu.css.ts +++ b/frontend/src/components/Menu.css.ts @@ -1,7 +1,52 @@ import { globalStyle, style } from "@vanilla-extract/css" -globalStyle(".active", { - fontWeight: "bold", +import { vars } from "../theme/tokens" + +export const navLink = style({ + display: "block", + transition: "all 0.15s ease-in-out", + borderRadius: vars.radius.md, + padding: "0.5rem 0.85rem", + textDecoration: "none", + color: "rgb(241 245 249 / 0.78)", + fontWeight: 500, + ":hover": { + backgroundColor: "rgb(255 255 255 / 0.12)", + textDecoration: "none", + color: "#ffffff", + }, + // plny outline misto poloprusvitneho stinu - ring s alpha 0.45 mel vuci tmavemu + // navbaru jen ~2.3:1 (pod WCAG 1.4.11); blue-3 na #1f2b3c dava >3:1 a outline + // prezije i forced-colors rezim + ":focus-visible": { + outline: "2px solid var(--mantine-color-blue-3)", + outlineOffset: "2px", + }, + "@media": { + "(min-width: 992px)": { + borderBottom: "2px solid transparent", + borderRadius: 0, + backgroundColor: "transparent", + padding: "0.75rem 0.5rem 0.625rem", + ":hover": { + borderBottomColor: "rgb(255 255 255 / 0.6)", + backgroundColor: "transparent", + color: "#ffffff", + }, + }, + }, +}) + +globalStyle(`.active${navLink}`, { + backgroundColor: "rgb(255 255 255 / 0.18)", + color: "#ffffff", + fontWeight: 600, + "@media": { + "(min-width: 992px)": { + borderBottomColor: "#ffffff", + backgroundColor: "transparent", + }, + }, }) export const navExternalLink = style({ @@ -12,15 +57,83 @@ globalStyle(`${navExternalLink} svg`, { marginLeft: "0.2em", }) -globalStyle(".navbar.navbar-expand-lg .btn", { +export const navList = style({ + display: "flex", + flexDirection: "column", + margin: 0, + padding: 0, + width: "100%", + listStyle: "none", + "@media": { + "(min-width: 992px)": { + flexDirection: "row", + gap: "0.25rem", + marginLeft: "auto", + width: "auto", + }, + }, +}) + +export const logoutButton = style({ + // sjednoceny focus ring s navLink/spotlightButton: vychozi indigo ring Mantine + // (.mantine-focus-auto) ma vuci tmavemu navbaru jen ~3:1 (hranicni 1.4.11), blue-3 + // je vyrazne viditelnejsi; vyssi specificita prebiji Mantine ring i pri shode poradi + selectors: { + "&.mantine-focus-auto:focus-visible": { + outline: "2px solid var(--mantine-color-blue-3)", + outlineOffset: "2px", + }, + }, "@media": { "(min-width: 992px)": { - // pri klasickem menu pridej levou mezeru k odhlasovacimu tlacitku marginLeft: "0.5rem", }, "(max-width: 991.98px)": { - // pri hamburger menu pridej horni mezeru k odhlasovacimu tlacitku marginTop: "0.5rem", + width: "100%", }, }, }) + +export const spotlightButton = style({ + display: "flex", + alignItems: "center", + gap: "0.5rem", + transition: "all 0.15s ease-in-out", + borderRadius: vars.radius.md, + padding: "0.4rem 0.75rem", + color: "rgb(241 245 249 / 0.78)", + fontSize: "0.875rem", + ":hover": { + backgroundColor: "rgb(255 255 255 / 0.12)", + color: "#ffffff", + }, + // plny outline misto poloprusvitneho stinu - ring s alpha 0.45 mel vuci tmavemu + // navbaru jen ~2.3:1 (pod WCAG 1.4.11); blue-3 na #1f2b3c dava >3:1 a outline + // prezije i forced-colors rezim + ":focus-visible": { + outline: "2px solid var(--mantine-color-blue-3)", + outlineOffset: "2px", + }, + "@media": { + "(min-width: 992px)": { + marginRight: "0.75rem", + border: "1px solid rgb(255 255 255 / 0.2)", + width: "18rem", + maxWidth: "30vw", + }, + "(max-width: 991.98px)": { + marginBottom: "0.25rem", + padding: "0.5rem 0.85rem", + width: "100%", + }, + }, +}) + +export const spotlightButtonLabel = style({ + flex: 1, + overflow: "hidden", + textAlign: "left", + textOverflow: "ellipsis", + whiteSpace: "nowrap", +}) diff --git a/frontend/src/components/Menu.tsx b/frontend/src/components/Menu.tsx index 0adea4451..12db15172 100644 --- a/frontend/src/components/Menu.tsx +++ b/frontend/src/components/Menu.tsx @@ -1,25 +1,23 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faExternalLink } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { Button, Kbd, UnstyledButton } from "@mantine/core" +import { spotlight } from "@mantine/spotlight" +import { faExternalLink, faSearch } from "@rodlukas/fontawesome-pro-solid-svg-icons" import { Link, LinkProps } from "@tanstack/react-router" import classNames from "classnames" import * as React from "react" -import { Button, Nav, NavItem, NavLink } from "reactstrap" import APP_URLS from "../APP_URLS" import AuthChecking from "../auth/AuthChecking" import { useAuthContext } from "../auth/AuthContext" +import { isApplePlatform } from "../global/utils" import { fEmptyVoid, QA } from "../types/types" +import ColorSchemeToggle from "./ColorSchemeToggle" import * as styles from "./Menu.css" -import SearchInput from "./SearchInput" type Props = { /** Funkce pro zavření otevřeného hamburger menu. */ closeNavbar: fEmptyVoid - /** Funkce, která se zavolá při úpravě vyhledávaného výrazu. */ - onSearchChange: (newSearchVal: string) => void - /** Hledaný výraz. */ - searchVal: string } type MyNavLinkProps = { @@ -42,19 +40,22 @@ const MyNavLink: React.FC = ({ onClick={onCloseNavbar} activeOptions={{ exact }} activeProps={{ - className: classNames("nav-link", className, activeClassName), + className: classNames(styles.navLink, className, activeClassName), }} inactiveProps={{ - className: classNames("nav-link", className), + className: classNames(styles.navLink, className), }} /> ) +// zkratka zobrazená v UI i v aria-labelu musí odpovídat skutečné klávese +// na dané platformě (mod = ⌘ na Apple platformách, jinde Ctrl) +const spotlightShortcutLabel = isApplePlatform() ? "⌘K" : "Ctrl K" + /** Komponenta zobrazující menu aplikace pro přihlášené uživatele. */ const Menu: React.FC = (props) => { const authContext = useAuthContext() const onClickLogout = () => { - // pri odhlaseni chceme zavrit menu props.closeNavbar() authContext.logout() } @@ -63,76 +64,89 @@ const Menu: React.FC = (props) => { <> {authContext.isAuth && ( <> - -

- diff --git a/frontend/src/components/NoInfo.tsx b/frontend/src/components/NoInfo.tsx index 225938c56..f39902788 100644 --- a/frontend/src/components/NoInfo.tsx +++ b/frontend/src/components/NoInfo.tsx @@ -1,3 +1,4 @@ +import { Text } from "@mantine/core" import * as React from "react" import { QA } from "../types/types" @@ -6,9 +7,9 @@ type Props = QA /** Komponenta pro jednotné zobrazení nevyplněného údaje napříč aplikací. */ const NoInfo: React.FC = (props) => ( - + --- - + ) export default NoInfo diff --git a/frontend/src/components/Notification.tsx b/frontend/src/components/Notification.tsx deleted file mode 100644 index 6badfbdd3..000000000 --- a/frontend/src/components/Notification.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from "react" - -import { ErrMsg } from "../types/types" - -type Props = { - /** Element s chybovou zprávou. */ - text?: ErrMsg -} - -/** Komponenta zobrazující obsah notifikace. */ -const Notification: React.FC = ({ text = "" }) => { - return

{text}

-} - -export default Notification diff --git a/frontend/src/components/PrepaidCounters.css.ts b/frontend/src/components/PrepaidCounters.css.ts index d5fa32954..8e9ab991a 100644 --- a/frontend/src/components/PrepaidCounters.css.ts +++ b/frontend/src/components/PrepaidCounters.css.ts @@ -1,11 +1,28 @@ import { style } from "@vanilla-extract/css" +import { surfaceCard } from "../global/surfaces.css" +import { vars } from "../theme/tokens" + +export const memberCard = style([ + surfaceCard, + { + padding: "0.8rem", + height: "100%", + }, +]) + +export const memberHeading = style({ + marginBottom: "0.55rem", + color: vars.text.primary, + fontSize: "1.02rem", +}) + export const prepaidCountersInput = style({ - minWidth: "3.75rem !important", + minWidth: "3.75rem", fontWeight: 600, }) export const prepaidCountersInputGroupLabel = style({ - backgroundColor: "#28a745", + backgroundColor: vars.colors.successSolid, color: "white", }) diff --git a/frontend/src/components/PrepaidCounters.test.tsx b/frontend/src/components/PrepaidCounters.test.tsx new file mode 100644 index 000000000..c14301a4d --- /dev/null +++ b/frontend/src/components/PrepaidCounters.test.tsx @@ -0,0 +1,209 @@ +import { MantineProvider } from "@mantine/core" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { RouterProvider } from "@tanstack/react-router" +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react" +import * as React from "react" + +import { createQueryClient } from "../api/queryClient" +import MembershipService from "../api/services/MembershipService" +import { createTestRouter } from "../testUtils/createTestRouter" +import { MembershipType } from "../types/models" + +import PrepaidCounters from "./PrepaidCounters" + +// mock service vrstvy - testy tak ridi okamzik a poradi resolvnuti PATCH requestu +// (useMutation z TanStack Query vcetne onSuccess/onError callbacku zustava realny) +vi.mock("../api/services/MembershipService", () => ({ + default: { + patch: vi.fn(), + }, +})) + +const patchMock = vi.mocked(MembershipService).patch + +/** Promise s rucne ovladatelnym resolvnutim - simulace PATCH requestu "v letu". */ +function createDeferred(): { + promise: Promise + resolve: (value: MembershipType) => void +} { + let resolve!: (value: MembershipType) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +function createMembership(id: number, prepaidCnt: number): MembershipType { + return { + id, + prepaid_cnt: prepaidCnt, + client: { + id: id + 100, + active: true, + email: "", + note: "", + phone: "", + firstname: `Jmeno${id}`, + surname: `Prijmeni${id}`, + last_lecture_date: null, + }, + } +} + +/** + * Vyrenderuje PrepaidCounters a vrati setter membershipu - ten simuluje refetch, + * ktery komponente zvenku doruci novou (treba i zastaralou) verzi dat. + */ +async function renderPrepaidCounters(memberships: MembershipType[]): Promise<{ + queryClient: QueryClient + refetchMemberships: (next: MembershipType[]) => void +}> { + const queryClient = createQueryClient() + let setMemberships: (next: MembershipType[]) => void = () => undefined + const Wrapper: React.FC = () => { + const [current, setCurrent] = React.useState(memberships) + setMemberships = setCurrent + return + } + const router = await createTestRouter( + + + , + ) + render( + + + , + ) + return { + queryClient, + refetchMemberships: (next): void => { + act(() => { + setMemberships(next) + }) + }, + } +} + +/** Počká, až se všechny mutace usadí (PATCH doběhl včetně onSuccess/onError). */ +async function waitForMutationsSettled(queryClient: QueryClient): Promise { + await waitFor(() => expect(queryClient.isMutating()).toBe(0)) +} + +/** + * Propustí frontu (micro)tasků - TanStack Query volá mutationFn asynchronně, takže assert + * "PATCH se NEodeslal" by bez toho prošel falešně pozitivně. + */ +async function flushAsync(): Promise { + await act(async () => { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 0) + }) + }) +} + +beforeEach(() => { + patchMock.mockReset() +}) + +test("renders a counter input with the initial value for each membership", async () => { + await renderPrepaidCounters([createMembership(1, 3), createMembership(2, 0)]) + + const inputs = screen.getAllByRole("spinbutton") + expect(inputs).toHaveLength(2) + expect(inputs[0]).toHaveValue(3) + expect(inputs[1]).toHaveValue(0) + expect(screen.getByText("Prijmeni1")).toBeInTheDocument() + expect(screen.getByText("Prijmeni2")).toBeInTheDocument() +}) + +test("shows a message when there are no memberships", async () => { + await renderPrepaidCounters([]) + + expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument() + expect(screen.getByText("Žádní účastníci")).toBeInTheDocument() +}) + +test("sends PATCH on blur and doesn't repeat it for an unchanged value", async () => { + patchMock.mockResolvedValue(createMembership(1, 7)) + const { queryClient } = await renderPrepaidCounters([createMembership(1, 3)]) + const input = screen.getByRole("spinbutton") + + // blur bez zmeny hodnoty -> zadny PATCH + fireEvent.blur(input) + await flushAsync() + expect(patchMock).not.toHaveBeenCalled() + + fireEvent.change(input, { target: { value: "7" } }) + fireEvent.blur(input) + + await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(1)) + expect(patchMock).toHaveBeenCalledWith({ id: 1, prepaid_cnt: 7 }) + await waitForMutationsSettled(queryClient) + expect(input).toHaveValue(7) + + // server hodnotu potvrdil -> dalsi blur se stejnou hodnotou neposila duplicitni PATCH + fireEvent.blur(input) + await flushAsync() + expect(patchMock).toHaveBeenCalledTimes(1) +}) + +test("refetch with stale data doesn't clobber a newer local edit", async () => { + const deferred = createDeferred() + patchMock.mockReturnValueOnce(deferred.promise) + const { queryClient, refetchMemberships } = await renderPrepaidCounters([ + createMembership(1, 3), + ]) + const input = screen.getByRole("spinbutton") + + // uzivatel ulozi 7 (PATCH zustava v letu) a hned rozepise novou hodnotu 9 + fireEvent.change(input, { target: { value: "7" } }) + fireEvent.blur(input) + await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(1)) + fireEvent.change(input, { target: { value: "9" } }) + + // mezitim dorazi refetch se zastaralym server stavem (3) -> nesmi prepsat rozepsanou 9 + refetchMemberships([createMembership(1, 3)]) + expect(input).toHaveValue(9) + + // doraz odpovedi na PATCH (7) take nesmi prepsat novejsi lokalni hodnotu + deferred.resolve(createMembership(1, 7)) + await waitForMutationsSettled(queryClient) + expect(input).toHaveValue(9) +}) + +// Pozn.: cast ochrany proti out-of-order odpovedim zajistuje uz TanStack Query v5 - +// per-mutate onSuccess/onError callbacky vystreli jen pro POSLEDNI mutate() na dane +// useMutation instanci, starsi (prekonana) mutace zadny callback nedostane. Test pres +// realny useMutation proto overuje vysledny pozorovatelny kontrakt komponenty, nikoli +// jen jeji vnitrni guard v onSuccess (ten je timto seamem nedosazitelny). +test("out-of-order PATCH responses don't overwrite the newer confirmed value", async () => { + const firstPatch = createDeferred() + const secondPatch = createDeferred() + patchMock.mockReturnValueOnce(firstPatch.promise).mockReturnValueOnce(secondPatch.promise) + const { queryClient } = await renderPrepaidCounters([createMembership(1, 3)]) + const input = screen.getByRole("spinbutton") + + // uzivatel ulozi 7 a jeste pred dobehnutim PATCHe ulozi 9 (dva PATCHe v letu) + fireEvent.change(input, { target: { value: "7" } }) + fireEvent.blur(input) + await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(1)) + fireEvent.change(input, { target: { value: "9" } }) + fireEvent.blur(input) + await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(2)) + expect(patchMock).toHaveBeenNthCalledWith(1, { id: 1, prepaid_cnt: 7 }) + expect(patchMock).toHaveBeenNthCalledWith(2, { id: 1, prepaid_cnt: 9 }) + + // odpovedi dorazi v opacnem poradi: nejdriv novejsi (9), pak starsi (7) + secondPatch.resolve(createMembership(1, 9)) + await waitFor(() => expect(queryClient.isMutating()).toBe(1)) + firstPatch.resolve(createMembership(1, 7)) + await waitForMutationsSettled(queryClient) + expect(input).toHaveValue(9) + + // server-potvrzena hodnota je 9 (ne 7 ze starsi odpovedi) + // -> blur se stejnou hodnotou nesmi vyvolat treti PATCH + fireEvent.blur(input) + await flushAsync() + expect(patchMock).toHaveBeenCalledTimes(2) +}) diff --git a/frontend/src/components/PrepaidCounters.tsx b/frontend/src/components/PrepaidCounters.tsx index bc8017688..a55189345 100644 --- a/frontend/src/components/PrepaidCounters.tsx +++ b/frontend/src/components/PrepaidCounters.tsx @@ -1,27 +1,16 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Container, Grid, Text, TextInput, Title, Tooltip } from "@mantine/core" import { faSackDollar } from "@rodlukas/fontawesome-pro-solid-svg-icons" import classNames from "classnames" import * as React from "react" -import { - Col, - Container, - Input, - InputGroup, - InputGroupText, - Label, - ListGroup, - ListGroupItem, - Row, -} from "reactstrap" import { usePatchMembership } from "../api/hooks" import { TEXTS } from "../global/constants" import { MembershipType } from "../types/models" import ClientName from "./ClientName" +import InfoTooltip from "./InfoTooltip" import * as styles from "./PrepaidCounters.css" -import Tooltip from "./Tooltip" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" type Props = { /** Pole se členstvími všech klientů. */ @@ -47,24 +36,118 @@ const PrepaidCounters: React.FC = (props) => { }, [props.memberships]) const [prepaidCnts, setPrepaidCnts] = React.useState(() => createPrepaidCntObjects()) + // Posledni server-potvrzena hodnota (aktualizuje se pouze v onSuccess). + const serverPrepaidCntsRef = React.useRef(createPrepaidCntObjects()) + // ID polozek, ktere uzivatel rozepsal (mezi onChange a uspesnym PATCH) — externi refetch + // jejich hodnotu neprepise (jinak by uzivatel prisel o rozepsany text). + const dirtyIdsRef = React.useRef>(new Set()) + // Hodnoty, ktere jsou prave v letu na server (mezi mutate a settled). Deduplikujou se, + // takze rapid blur/refocus se stejnou hodnotou nevypustí druhy PATCH; a refetch ji + // pri merge nesmaze. + const inFlightRef = React.useRef({}) React.useEffect(() => { - setPrepaidCnts(createPrepaidCntObjects()) + const fresh = createPrepaidCntObjects() + // Garbage-collect dirty/in-flight IDs, ktere uz nejsou ve fresh memberships + // (smazane / vymenene), at se neukotvi cizi data v ref/state. + for (const id of Array.from(dirtyIdsRef.current)) { + if (!(id in fresh)) { + dirtyIdsRef.current.delete(id) + } + } + for (const id of Object.keys(inFlightRef.current).map(Number)) { + if (!(id in fresh)) { + delete inFlightRef.current[id] + } + } + // serverRef si drzi server-potvrzenou hodnotu kazdeho ID; pro in-flight nebo dirty + // nechame puvodne potvrzenou hodnotu (jinak by se ztratil "previous" pro revert). + const nextServer: PrepaidCntObjectsType = {} + for (const id of Object.keys(fresh).map(Number)) { + if (id in inFlightRef.current || dirtyIdsRef.current.has(id)) { + // refetch zachytil stary server state, ale my mame novejsi (in-flight nebo dirty); + // ponech predchozi server snapshot, fresh hodnota nas zajimat nesmi. + nextServer[id] = serverPrepaidCntsRef.current[id] ?? fresh[id] + } else { + nextServer[id] = fresh[id] + } + } + serverPrepaidCntsRef.current = nextServer + setPrepaidCnts((prev) => { + const merged: PrepaidCntObjectsType = { ...fresh } + // pro dirty / in-flight polozky zachovej rozpracovanou uzivatelskou hodnotu + for (const id of dirtyIdsRef.current) { + if (id in prev) { + merged[id] = prev[id] + } + } + for (const id of Object.keys(inFlightRef.current).map(Number)) { + if (id in prev) { + merged[id] = prev[id] + } + } + return merged + }) }, [createPrepaidCntObjects]) - const onChange = React.useCallback( - (e: React.ChangeEvent): void => { + // Pozn.: Number("") vrací 0, vymazané pole se tedy při bluru uloží jako 0 — díky + // select-on-focus (viz onFocus) uživatel typicky přepisuje celou hodnotu, vědomě bez guardu. + const onChange = React.useCallback((e: React.ChangeEvent): void => { + const target = e.currentTarget + const value = Number(target.value) + const id = Number(target.dataset.id) + dirtyIdsRef.current.add(id) + setPrepaidCnts((prevPrepaidCnts) => { + const newPrepaidCnts = { ...prevPrepaidCnts } + newPrepaidCnts[id] = value + return newPrepaidCnts + }) + }, []) + + // Commit hodnotu na server az pri blur, ne pri kazdem keystroke. + // serverPrepaidCntsRef se aktualizuje az v onSuccess (drzi server-potvrzenou hodnotu). + // inFlightRef se aktualizuje pred mutate a maze v onSettled (drzi prave odesilanou hodnotu) + // → rapid blur/refocus se stejnou hodnotou nepustí duplicitni PATCH a refetch ji nesmaze. + const onBlur = React.useCallback( + (e: React.FocusEvent): void => { const target = e.currentTarget const value = Number(target.value) - const id = Number(target.dataset.id!) - setPrepaidCnts((prevPrepaidCnts) => { - // vytvorime kopii prepaidCnts (ma jen jednu uroven -> staci melka kopie) - const newPrepaidCnts = { ...prevPrepaidCnts } - newPrepaidCnts[id] = value - return newPrepaidCnts - }) - const data = { id, prepaid_cnt: value } - patchMembership.mutate(data) + const id = Number(target.dataset.id) + const serverValue = serverPrepaidCntsRef.current[id] + const inFlightValue = inFlightRef.current[id] + const effectiveValue = inFlightValue ?? serverValue + if (effectiveValue === value) { + // bud se hodnota nezmenila, nebo uz je presne tato hodnota odeslana → ne-op + dirtyIdsRef.current.delete(id) + return + } + inFlightRef.current[id] = value + patchMembership.mutate( + { id, prepaid_cnt: value }, + { + onSuccess: () => { + // Pokud uz je v letu novejsi PATCH (uzivatel mezitim zmenil hodnotu + // a znovu blurnul), necham vsechno na ten novejsi mutate – jinak by + // out-of-order odpoved tohoto PATCHe stale prepsala serverRef i dirty. + if (inFlightRef.current[id] !== value) { + return + } + serverPrepaidCntsRef.current = { + ...serverPrepaidCntsRef.current, + [id]: value, + } + dirtyIdsRef.current.delete(id) + delete inFlightRef.current[id] + }, + onError: () => { + // Stejna ochrana: pokud uz je v letu novejsi PATCH, nech mu drzet inFlight. + if (inFlightRef.current[id] === value) { + delete inFlightRef.current[id] + } + // dirty zustava → efekt na refetchi UI neprepise a retry projde + }, + }, + ) }, [patchMembership], ) @@ -75,55 +158,60 @@ const PrepaidCounters: React.FC = (props) => { return ( - + {props.memberships.map((membership) => ( - - - -
- {" "} - {props.isGroupActive && !membership.client.active && ( - - )} -
- - +
+ + <ClientName client={membership.client} link />{" "} + {props.isGroupActive && !membership.client.active && ( + <InfoTooltip + text={TEXTS.WARNING_INACTIVE_CLIENT_GROUP} + size="1x" + /> + )} + + {/* focus: obsah tooltipu musí být dosažitelný i z klávesnice (WCAG 1.4.13) */} + + 0, - })}> - + } + /> + +
+ ))} {props.memberships.length === 0 && ( -

Žádní účastníci

+ + Žádní účastníci + )} -
+
) } diff --git a/frontend/src/components/Search.css.ts b/frontend/src/components/Search.css.ts deleted file mode 100644 index fc633af64..000000000 --- a/frontend/src/components/Search.css.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { keyframes, style } from "@vanilla-extract/css" - -const fadeIn = keyframes({ - from: { - opacity: 0, - }, - to: { - opacity: 1, - }, -}) - -export const searchOverlay = style({ - position: "fixed", - zIndex: 1020, // Bootstrap navbar ma z-index 1030 - top: "56px", // vyska navbaru (56px) - desktop - right: 0, - bottom: 0, - left: 0, - display: "flex", - alignItems: "flex-start", - justifyContent: "center", - backdropFilter: "blur(4px)", - backgroundColor: "rgba(0, 0, 0, 0.5)", - cursor: "default", - overflowY: "auto", - animation: `${fadeIn} 0.15s ease-out`, - "@media": { - "(max-width: 991.98px)": { - zIndex: 1040, // vyssi nez navbar (1030), aby byl nad rozbalenym menu - top: "106px", // vyska navbaru (56px) + search input (~50px) - }, - }, -}) - -export const searchContainer = style({ - position: "relative", - zIndex: 1021, - margin: "0 auto", - marginBottom: "2rem", - borderRadius: "0 0 0.375rem 0.375rem", - boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", - backgroundColor: "white", - padding: "0 1rem 2rem", - width: "100%", - maxWidth: "900px", - height: "auto", - animation: `${fadeIn} 0.2s ease-out`, -}) diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx deleted file mode 100644 index b1554e2b9..000000000 --- a/frontend/src/components/Search.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import { FuseResult } from "fuse.js" -import * as React from "react" -import { Badge, CloseButton, Col, Container, ListGroup, ListGroupItem, Row } from "reactstrap" - -import { useClientsActiveContext } from "../contexts/ClientsActiveContext" -import ModalClients from "../forms/ModalClients" -import { ClientActiveType } from "../types/models" -import { fEmptyVoid } from "../types/types" - -import ClientEmail from "./ClientEmail" -import ClientName from "./ClientName" -import ClientPhone from "./ClientPhone" -import Heading from "./Heading" -import Loading from "./Loading" -import * as styles from "./Search.css" - -type Props = { - /** Výsledky vyhledávání klientů. */ - foundResults: FuseResult[] - /** Hledaný výraz. */ - searchVal: string - /** Funkce pro zahájení vyhledávání klientů. */ - search: fEmptyVoid - /** Funkce zrušení vyhledávání klientů. */ - resetSearch: fEmptyVoid -} - -/** Komponenta zobrazující výsledky vyhledávání - seznam klientů. */ -const Search: React.FC = ({ foundResults, searchVal, search, resetSearch }) => { - const clientsActiveContext = useClientsActiveContext() - const dialogRef = React.useRef(null) - - // Focus trap - zajistí, že focus zůstane uvnitř dialogu - React.useEffect(() => { - if (searchVal === "" || !dialogRef.current) { - return undefined - } - - const dialog = dialogRef.current - const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - - const getFocusableElements = (): HTMLElement[] => { - return Array.from(dialog.querySelectorAll(selector)).filter( - (el) => !el.hasAttribute("disabled") && !el.hasAttribute("aria-hidden"), - ) - } - - const handleKeyDown = (e: KeyboardEvent): void => { - if (e.key !== "Tab") { - return - } - - // Pokud je otevřený Bootstrap modal, necháme ho zpracovat Tab sám - const openModal = document.querySelector(".modal.show") - if (openModal) { - return - } - - const focusableElements = getFocusableElements() - if (focusableElements.length === 0) { - return - } - - const firstElement = focusableElements[0] - const lastElement = focusableElements.at(-1)! - const activeElement = document.activeElement as HTMLElement - - // Pokud není žádný prvek ve focusu nebo je mimo dialog, nastav focus na první - if (!activeElement || !dialog.contains(activeElement)) { - e.preventDefault() - firstElement.focus() - return - } - - // Cyklická navigace: poslední -> první (Tab) nebo první -> poslední (Shift+Tab) - const isAtFirst = activeElement === firstElement - const isAtLast = activeElement === lastElement - - if ((!e.shiftKey && isAtLast) || (e.shiftKey && isAtFirst)) { - e.preventDefault() - ;(e.shiftKey ? lastElement : firstElement).focus() - } - } - - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [searchVal]) - - React.useEffect(() => { - if (searchVal === "") { - return undefined - } - document.body.style.overflow = "hidden" - return () => { - document.body.style.overflow = "" - } - }, [searchVal]) - - return ( - <> - {searchVal !== "" && ( -
{ - // Zavři dialog pouze pokud byl klik na overlay, ne na kontejner - if (e.target === e.currentTarget) { - resetSearch() - } - }} - role="presentation" - aria-label="Výsledky vyhledávání"> - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} -
e.stopPropagation()} - onKeyDown={(e) => { - if (e.key === "Escape" && !document.querySelector(".modal.show")) { - resetSearch() - } - }} - role="dialog" - aria-modal="true" - aria-labelledby="search-dialog-title"> - - - Nalezení klienti{" "} - - {foundResults.length} - - - } - buttons={ - - } - /> - {clientsActiveContext.isLoading && } - {foundResults.length > 0 && !clientsActiveContext.isLoading && ( - - {foundResults.map(({ item }) => ( - - - {" "} - -
- -
- {item.note !== "" && ( - - {" "} - – {item.note} - - )} - - - {item.phone && ( - - )} - - - {item.email && ( - - )} - - - - -
-
- ))} -
- )} - {foundResults.length === 0 && !clientsActiveContext.isLoading && ( -

Žádní klienti nenalezeni

- )} -
-
-
- )} - - ) -} - -export default Search diff --git a/frontend/src/components/SearchInput.css.ts b/frontend/src/components/SearchInput.css.ts deleted file mode 100644 index 8a97d5a0d..000000000 --- a/frontend/src/components/SearchInput.css.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { globalStyle, style } from "@vanilla-extract/css" - -export const search = style({ - marginLeft: "auto", - maxWidth: "25rem", - "@media": { - "(max-width: 991.98px)": { - // pri hamburger menu pridej horni mezeru k vyhledavacimu poli a vycentruj ho - margin: "0.5rem auto", - }, - "(min-width: 992px)": { - // pri klasickem menu pridej pravou mezeru k vyhledavacimu poli - marginRight: "1rem", - }, - }, -}) - -export const label = style({ - marginBottom: 0, - color: "#6c757d", -}) - -export const iconWrapper = style({ - marginRight: 0, -}) - -globalStyle(`${search} input, ${iconWrapper}`, { - border: 0, - backgroundColor: "rgb(255 255 255 / 0.07)", -}) - -globalStyle(`${search} input`, { - color: "white", -}) - -globalStyle(`${search} input::placeholder`, { - color: "#6c757d", -}) - -globalStyle(`${search} input:focus`, { - boxShadow: "none", - backgroundColor: "rgb(255 255 255 / 0.03)", - color: "white", -}) diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx deleted file mode 100644 index 7b371ebaa..000000000 --- a/frontend/src/components/SearchInput.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faSearch } from "@rodlukas/fontawesome-pro-solid-svg-icons" -import * as React from "react" -import { Input, InputGroup, InputGroupText, Label } from "reactstrap" - -import * as styles from "./SearchInput.css" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" - -type Props = { - /** Funkce, která se zavolá při úpravě vyhledávaného výrazu. */ - onSearchChange: (newSearchVal: string) => void - /** Hledaný výraz. */ - searchVal: string -} - -/** Komponenta zobrazující pole pro vyhledávání. */ -const SearchInput: React.FC = (props) => { - function onSearchChange(e: React.ChangeEvent): void { - props.onSearchChange(e.currentTarget.value) - } - - return ( - - - - - - Hledání klientů - - - - ) -} - -export default SearchInput diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx deleted file mode 100644 index 3b65ac648..000000000 --- a/frontend/src/components/Tooltip.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome" -import { faInfoCircle } from "@rodlukas/fontawesome-pro-solid-svg-icons" -import * as React from "react" -import { UncontrolledTooltipProps } from "reactstrap" - -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" - -type Props = { - /** Unikátní textové ID pro Tooltip. */ - postfix: string - /** Text zobrazený v Tooltipu. */ - text: React.ReactNode - /** Velikost ikony, která zobrazí Tooltip. */ - 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", - icon = faInfoCircle, -}) => ( - <> - - {text} - - - -) - -export default Tooltip diff --git a/frontend/src/components/UncontrolledTooltipWrapper.tsx b/frontend/src/components/UncontrolledTooltipWrapper.tsx deleted file mode 100644 index 5a439b09f..000000000 --- a/frontend/src/components/UncontrolledTooltipWrapper.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import * as React from "react" -import { UncontrolledTooltip, UncontrolledTooltipProps } from "reactstrap" - -/** Wrapper pro UncontrolledTooltip zajišťující vhodné výchozí hodnoty. */ -const UncontrolledTooltipWrapper: React.FC = ({ - placement = "auto", - ...props -}) => - -export default UncontrolledTooltipWrapper diff --git a/frontend/src/components/buttons/ActiveSwitcher.css.ts b/frontend/src/components/buttons/ActiveSwitcher.css.ts index fac991fa4..c511a7165 100644 --- a/frontend/src/components/buttons/ActiveSwitcher.css.ts +++ b/frontend/src/components/buttons/ActiveSwitcher.css.ts @@ -1,7 +1,35 @@ import { globalStyle, style } from "@vanilla-extract/css" -export const activeSwitcher = style({}) +export const activeSwitcher = style({ + display: "inline-flex", + border: "none", + borderRadius: "var(--mantine-radius-sm)", + boxShadow: "inset 0 1px 2px rgb(0 0 0 / 0.04)", + overflow: "hidden", + "@media": { + "(max-width: 767.98px)": { + width: "100%", + }, + }, +}) + +globalStyle(`${activeSwitcher} .mantine-Button-root`, { + border: 0, + minWidth: "6.7rem", + fontWeight: 600, + "@media": { + "(max-width: 767.98px)": { + flex: 1, + minWidth: 0, + }, + }, +}) + +globalStyle(`${activeSwitcher} .mantine-Button-root[data-variant='default']`, { + backgroundColor: "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))", + color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0))", +}) -globalStyle(`${activeSwitcher} .active`, { - cursor: "default !important", +globalStyle(`${activeSwitcher} .mantine-Button-root[data-variant='default']:hover`, { + backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))", }) diff --git a/frontend/src/components/buttons/ActiveSwitcher.tsx b/frontend/src/components/buttons/ActiveSwitcher.tsx index a70510049..93ed14b61 100644 --- a/frontend/src/components/buttons/ActiveSwitcher.tsx +++ b/frontend/src/components/buttons/ActiveSwitcher.tsx @@ -1,5 +1,5 @@ +import { Button } from "@mantine/core" import * as React from "react" -import { Button, ButtonGroup } from "reactstrap" import { AnalyticsSource, trackEvent } from "../../analytics" @@ -9,42 +9,42 @@ type Props = { /** Je vybráno zobrazení aktivních klientů/skupin (true). */ active: boolean /** Funkce, která se zavolá při přepínání. */ - onChange: (active: boolean, ignoreActiveRefresh: boolean) => void + onChange: (active: boolean) => void /** Identifikace místa, odkud byla akce provedena (pro analytiku). */ source: AnalyticsSource } /** Přepínač ne/aktivních skupin/klientů. */ const ActiveSwitcher: React.FC = (props) => { + const inactive = props.active === false + function onSwitcherChange(e: React.MouseEvent): void { const target = e.currentTarget const value = target.dataset.value === "true" // pokud doslo ke zmene, propaguj vyse if (props.active !== value) { trackEvent("active_filter_toggled", { source: props.source, active: value }) - props.onChange(value, true) + props.onChange(value) } } return ( - + - + ) } diff --git a/frontend/src/components/buttons/AddButton.tsx b/frontend/src/components/buttons/AddButton.tsx index 36eeedce2..a02ecc959 100644 --- a/frontend/src/components/buttons/AddButton.tsx +++ b/frontend/src/components/buttons/AddButton.tsx @@ -1,16 +1,18 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Button, ButtonProps } from "@mantine/core" import { faPlus } from "@rodlukas/fontawesome-pro-solid-svg-icons" import classNames from "classnames" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" import * as styles from "./buttons.css" -type Props = ButtonProps & { +type Props = Omit & { /** Text v tlačítku. */ content: string /** Tlačítko je malé (true). */ small?: boolean + onClick?: React.MouseEventHandler + className?: string } /** Tlačítko pro přidání objektu v aplikaci. */ @@ -22,8 +24,11 @@ const AddButton: React.FC = ({ content, onClick, small = false, className className, ) return ( - ) diff --git a/frontend/src/components/buttons/BackButton.tsx b/frontend/src/components/buttons/BackButton.tsx index 463eab7d7..7b8502654 100644 --- a/frontend/src/components/buttons/BackButton.tsx +++ b/frontend/src/components/buttons/BackButton.tsx @@ -1,19 +1,23 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Button, ButtonProps } from "@mantine/core" import { faArrowLeft } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" import * as styles from "./buttons.css" -type Props = ButtonProps & { +type Props = Omit & { /** Text v tlačítku. */ content?: string + onClick?: React.MouseEventHandler } /** Tlačítko pro krok zpět v aplikaci. */ const BackButton: React.FC = ({ onClick, content = "Jít zpět" }) => ( - ) diff --git a/frontend/src/components/buttons/CancelButton.tsx b/frontend/src/components/buttons/CancelButton.tsx index cdda4f7de..ae2e5ac4f 100644 --- a/frontend/src/components/buttons/CancelButton.tsx +++ b/frontend/src/components/buttons/CancelButton.tsx @@ -1,9 +1,13 @@ +import { Button, ButtonProps } from "@mantine/core" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" + +type Props = Omit & { + onClick?: React.MouseEventHandler +} /** Tlačítko pro storno v rámci aplikace. */ -const CancelButton: React.FC = ({ onClick }) => ( - ) diff --git a/frontend/src/components/buttons/CustomButton.tsx b/frontend/src/components/buttons/CustomButton.tsx index 9d3b53f47..c5a5ef886 100644 --- a/frontend/src/components/buttons/CustomButton.tsx +++ b/frontend/src/components/buttons/CustomButton.tsx @@ -1,22 +1,17 @@ +import { Button, ButtonProps } from "@mantine/core" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" - -import { noop } from "../../global/utils" type Props = Omit & { /** Jakýkoliv uzel JSX tvořící text tlačítka. */ content: React.ReactNode + onClick?: React.MouseEventHandler + disabled?: boolean + id?: string } -/** Obecné tlačítko v rámci aplikace. */ -const CustomButton: React.FC = ({ - onClick = noop, - content = "", - disabled = false, - id, - ...props -}) => ( - ) diff --git a/frontend/src/components/buttons/DeleteButton.tsx b/frontend/src/components/buttons/DeleteButton.tsx index 381728bd3..1918d074d 100644 --- a/frontend/src/components/buttons/DeleteButton.tsx +++ b/frontend/src/components/buttons/DeleteButton.tsx @@ -1,14 +1,15 @@ +import { Button, ButtonProps } from "@mantine/core" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" -type Props = ButtonProps & { +type Props = Omit & { /** Text v tlačítku. */ content?: string + onClick?: React.MouseEventHandler } /** Tlačítko pro smazání objektu v aplikaci. */ const DeleteButton: React.FC = ({ onClick, content = "", ...props }) => ( - ) diff --git a/frontend/src/components/buttons/EditButton.tsx b/frontend/src/components/buttons/EditButton.tsx index f769efa8f..c0e08b21f 100644 --- a/frontend/src/components/buttons/EditButton.tsx +++ b/frontend/src/components/buttons/EditButton.tsx @@ -1,36 +1,34 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Button, ButtonProps, Tooltip } from "@mantine/core" import { faPencil } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" import { makeIdFromString } from "../../global/utils" -import UncontrolledTooltipWrapper from "../UncontrolledTooltipWrapper" -type Props = ButtonProps & { +type Props = Omit & { /** Text v tlačítku. */ content?: string /** ID objektu, pro který se zobrazuje tlačítko. */ contentId: number | string + onClick?: React.MouseEventHandler } /** Tlačítko pro úpravu objektu v aplikaci. */ const EditButton: React.FC = ({ content = "Upravit", onClick, contentId, ...props }) => ( - <> + - - {content} - - + ) export default EditButton diff --git a/frontend/src/components/buttons/SubmitButton.tsx b/frontend/src/components/buttons/SubmitButton.tsx index a7eded2da..f1c3803c8 100644 --- a/frontend/src/components/buttons/SubmitButton.tsx +++ b/frontend/src/components/buttons/SubmitButton.tsx @@ -1,15 +1,15 @@ -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons" +import { Button, ButtonProps } from "@mantine/core" import * as React from "react" -import { Button, ButtonProps } from "reactstrap" -type Props = ButtonProps & { +type Props = Omit & { /** Text v tlačítku. */ content?: string /** Zobraz načítací animaci v tlačítku (true). */ loading?: boolean /** Tlačítko není aktivní (true). */ disabled?: boolean + onClick?: React.MouseEventHandler + id?: string } /** Tlačítko pro odeslání formuláře v aplikaci. */ @@ -20,13 +20,13 @@ const SubmitButton: React.FC = ({ ...props }) => ( ) diff --git a/frontend/src/components/charts.css.ts b/frontend/src/components/charts.css.ts index d138ce96b..4c9eba6bc 100644 --- a/frontend/src/components/charts.css.ts +++ b/frontend/src/components/charts.css.ts @@ -1,16 +1,47 @@ -import { style } from "@vanilla-extract/css" +import { createVar, globalStyle, style } from "@vanilla-extract/css" + +import { vars } from "../theme/tokens" + +// Recharts předává `stroke`/`fill` jako SVG prezentační atributy — `var()` v nich funguje +// (osvědčeno stávajícím kódem), ale `light-dark()` přímo v hodnotě atributu spolehlivé není. +// Přepínání schémat proto řeší tyto CSS proměnné (stejný vzor jako remap v index.css.ts) +// a v atributech grafů zůstává jen čistý `var()` — viz konstanty v charts.ts. +// Kontrasty: light gray-7 ticks = 8.18:1 na bílé (gray-6 měla jen 3.32:1); +// dark dark-1 ticks = 7.83:1 na dark-7. Mřížka: light gray-3, dark dark-4 (dekorativní linky +// kontrast nevyžadují — gray-3 by ale v dark módu nepatřičně zářila). +globalStyle(":root", { + vars: { + "--up-chart-grid-stroke": "var(--mantine-color-gray-3)", + "--up-chart-tick-fill": "var(--mantine-color-gray-7)", + }, +}) + +globalStyle(":root[data-mantine-color-scheme='dark']", { + vars: { + "--up-chart-grid-stroke": "var(--mantine-color-dark-4)", + "--up-chart-tick-fill": "var(--mantine-color-dark-1)", + }, +}) export const chartBaseStyles = { - border: "1px solid #dee2e6", - borderRadius: "0.375rem", - backgroundColor: "#fff", + border: vars.borderShort.default, + borderRadius: vars.radius.md, + backgroundColor: vars.bg.surface, } export const chartTooltip = style({ ...chartBaseStyles, - boxShadow: "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)", + boxShadow: vars.shadow.elevated, padding: "0.5rem 0.75rem", lineHeight: 1.5, - color: "#212529", + color: vars.text.primary, fontSize: "0.8rem", }) + +/** Barva série grafu pro položku tooltipu — dynamická hodnota přes assignInlineVars. */ +export const tooltipSeriesColor = createVar() + +/** Položka tooltipu grafu obarvená barvou své série (místo inline `style={{ color }}`). */ +export const tooltipSeriesEntry = style({ + color: tooltipSeriesColor, +}) diff --git a/frontend/src/components/charts.ts b/frontend/src/components/charts.ts index 7011810aa..3fa834c81 100644 --- a/frontend/src/components/charts.ts +++ b/frontend/src/components/charts.ts @@ -1,3 +1,5 @@ +import "./charts.css" + /** Krátké názvy měsíců pro osu X grafů. */ export const MONTH_LABELS = [ "Led", @@ -14,9 +16,10 @@ export const MONTH_LABELS = [ "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" +// Adaptivní barvy (light/dark) jsou definované v charts.css.ts — viz komentář tamtéž. +export const AXIS_TICK = { fontSize: 12, fill: "var(--up-chart-tick-fill)" } as const +export const AXIS_LABEL = { fontSize: 11, fill: "var(--up-chart-tick-fill)" } as const +export const GRID_STROKE = "var(--up-chart-grid-stroke)" export const LEGEND_FONT = { fontSize: 12 } as const export type ChartMargin = { diff --git a/frontend/src/declarations.d.ts b/frontend/src/declarations.d.ts index b4bf09644..ef6d741f6 100644 --- a/frontend/src/declarations.d.ts +++ b/frontend/src/declarations.d.ts @@ -1,2 +1 @@ declare module "*.css" {} -declare module "react-color-palette/css" {} diff --git a/frontend/src/forms/FormApplications.tsx b/frontend/src/forms/FormApplications.tsx index 88e32ac0e..f87e80766 100644 --- a/frontend/src/forms/FormApplications.tsx +++ b/frontend/src/forms/FormApplications.tsx @@ -1,5 +1,6 @@ +import { Group, Modal, Textarea, Title } from "@mantine/core" +import { useForm } from "@mantine/form" import * as React from "react" -import { Col, Form, FormGroup, Input, Label, ModalBody, ModalFooter, ModalHeader } from "reactstrap" import { trackEvent } from "../analytics" import { useClients, useCreateApplication, useUpdateApplication } from "../api/hooks" @@ -17,6 +18,7 @@ import { } from "../types/models" import { fEmptyVoid } from "../types/types" +import * as baseStyles from "./FormBase.css" import Or from "./helpers/Or" import SelectClient from "./helpers/SelectClient" import SelectCourse from "./helpers/SelectCourse" @@ -43,49 +45,55 @@ const FormApplications: React.FC = (props) => { const createApplication = useCreateApplication() const updateApplication = useUpdateApplication() - /** Kurz zájemce. */ - const [course, setCourse] = React.useState( - props.application.course, - ) - /** Klient. */ - const [client, setClient] = React.useState( - props.application.client, - ) - /** Poznámka k zájemci o kurz. */ - const [note, setNote] = React.useState(props.application.note) - - const onChange = (e: React.ChangeEvent): void => { - props.setFormDirty() - const target = e.currentTarget - const value = target.type === "checkbox" ? target.checked : target.value - if (target.id === "note") { - setNote(value as string) - } - } + const form = useForm<{ + course: ApplicationPostApiDummy["course"] + client: ApplicationPostApiDummy["client"] + note: ApplicationPostApiDummy["note"] + }>({ + initialValues: { + course: props.application.course, + client: props.application.client, + note: props.application.note, + }, + onValuesChange: () => props.setFormDirty(), + }) const onSelectChange = ( name: "course" | "client", obj?: CourseType | ClientType | null, ): void => { - props.setFormDirty() if (obj === undefined) { obj = null } if (name === "course") { - setCourse(obj as CourseType | null) + form.setFieldValue("course", obj as CourseType | null) } else if (name === "client") { - setClient(obj as ClientType | null) + form.setFieldValue("client", obj as ClientType | null) } } const isApplicationValue = isApplication(props.application) + // Po pokusu o odeslání s prázdným povinným Selectem (skrytý input neumí constraint + // validaci, takže reportValidity je no-op) zobrazíme chybu přes `error` prop Selectů. + const [triedSubmit, setTriedSubmit] = React.useState(false) + const onSubmit = React.useCallback( - (e: React.FormEvent): void => { + (e: React.SyntheticEvent): void => { e.preventDefault() - const courseId = course!.id - const clientId = client!.id - const dataPost: ApplicationPostApi = { course_id: courseId, client_id: clientId, note } + const { course, client } = form.values + // pojistka: bez vybraneho kurzu/klienta neodesilame a zobrazime chybu u Selectu + if (!course || !client) { + setTriedSubmit(true) + return + } + const courseId = course.id + const clientId = client.id + const dataPost: ApplicationPostApi = { + course_id: courseId, + client_id: clientId, + note: form.values.note, + } if (isApplication(props.application)) { const dataPut: ApplicationPutApi = { ...dataPost, id: props.application.id } @@ -104,7 +112,7 @@ const FormApplications: React.FC = (props) => { }) } }, - [course, client, note, props, createApplication, updateApplication], + [form.values, props, createApplication, updateApplication], ) const close = (): void => { @@ -112,85 +120,98 @@ const FormApplications: React.FC = (props) => { } const processAdditionOfClient = (newClient: ClientType): void => { - props.setFormDirty() - setClient(newClient) + form.setFieldValue("client", newClient) } const isLoading = clientsLoading || coursesVisibleContext.isLoading const isSubmit = createApplication.isPending || updateApplication.isPending return ( -
- - {isApplicationValue ? "Úprava" : "Přidání"} zájemce o kurz - - + + + + {isApplicationValue ? "Úprava" : "Přidání"} zájemce o kurz + + + + {isLoading ? ( ) : ( - <> - - - - - - } - /> - - - - - - - - - - - - - - - +
+
+ + Základní údaje + +
+
+ + + + } + /> +
+
+ + +
+
+