From bf7540ea8f97f99a3df3ba6301972b4b1bbdf3bc Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 5 May 2026 07:53:08 +0200 Subject: [PATCH 01/23] docs: add dark mode toggle design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-05-darkmode-toggle-design.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-05-darkmode-toggle-design.md 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 00000000..e43b241a --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-darkmode-toggle-design.md @@ -0,0 +1,32 @@ +# Design: Dark mode toggle v nastavení + +## 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` From 7d26c6f71b18c7f7f8d1287c7a2f60da5ff6fe23 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 5 May 2026 07:54:54 +0200 Subject: [PATCH 02/23] feat: add dark mode toggle to settings page SegmentedControl with auto/light/dark options, persisted via Mantine's built-in localStorage manager (mantine-color-scheme key). Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/pages/Settings.css.ts | 93 ++++++ frontend/src/pages/Settings.tsx | 470 ++++++++++++++++------------- 2 files changed, 347 insertions(+), 216 deletions(-) diff --git a/frontend/src/pages/Settings.css.ts b/frontend/src/pages/Settings.css.ts index f6f7eeb3..b9152453 100644 --- a/frontend/src/pages/Settings.css.ts +++ b/frontend/src/pages/Settings.css.ts @@ -6,3 +6,96 @@ globalStyle(`${footer} a`, { textDecoration: "underline", color: "inherit", }) + +globalStyle(`${footer} a:hover`, { + color: "var(--mantine-color-indigo-6)", +}) + +export const settingsColumn = style({ + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.6rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + padding: "1rem 1rem 1.1rem", + height: "100%", +}) + +globalStyle(`${settingsColumn} h2`, { + marginBottom: "0.75rem", +}) + +globalStyle(`${settingsColumn} h3`, { + marginTop: "0.9rem", + marginBottom: "0.65rem", + fontSize: "1.15rem", +}) + +globalStyle(`${settingsColumn} hr`, { + opacity: 1, + marginTop: "0.9rem", + marginBottom: "0.9rem", + borderColor: "light-dark(#d6dee9, var(--mantine-color-dark-4))", +}) + +export const settingsColumnsRow = style({ + marginBottom: "1rem", +}) + +export const tableSection = style({ + marginTop: "0.25rem", +}) + +export const configList = style({ + marginTop: "0.8rem", + marginBottom: "0.75rem", +}) + +export const configListItem = style({ + padding: "0.55rem 0", + selectors: { + "& + &": { + borderTop: "1px solid var(--mantine-color-gray-2)", + }, + }, +}) + +export const configRow = style({ + display: "flex", + alignItems: "center", + gap: "0.5rem", +}) + +export const configRowLabel = style({ + flex: "0 0 58%", +}) + +export const configRowControl = style({ + flex: 1, +}) + +export const emptyMessage = style({ + textAlign: "center", +}) + +export const appearanceSection = style({ + marginTop: "1.1rem", + marginBottom: "1.1rem", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.6rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + padding: "1rem 1rem 1.1rem", +}) + +globalStyle(`${appearanceSection} h2`, { + marginBottom: "0.75rem", +}) + +export const footerBlock = style({ + marginTop: "1.1rem", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.6rem", + boxShadow: "0 12px 26px rgb(15 23 42 / 0.07), 0 3px 10px rgb(15 23 42 / 0.05)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + padding: "0.9rem 1rem", +}) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 1d44711a..76943f81 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -1,9 +1,9 @@ import { faGithub } from "@fortawesome/free-brands-svg-icons" import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" -import { faCheck, faTimes } from "@rodlukas/fontawesome-pro-solid-svg-icons" -import classNames from "classnames" +import { Alert, Container, Group, SegmentedControl, Select, SimpleGrid, Skeleton, Table, Text, Title, Tooltip, useMantineColorScheme } from "@mantine/core" +import type { MantineColorScheme } from "@mantine/core" +import { faCheck, faDesktop, faMoon, faSun, faTimes } from "@rodlukas/fontawesome-pro-solid-svg-icons" import * as React from "react" -import { Alert, Col, Container, Label, ListGroup, ListGroupItem, Row, Table } from "reactstrap" import { useCourses, usePatchAttendanceState } from "../api/hooks" import APP_URLS from "../APP_URLS" @@ -12,10 +12,7 @@ import AppDate from "../components/AppDate" import AppRelease from "../components/AppRelease" import CourseCircle from "../components/CourseCircle" import Heading from "../components/Heading" -import Loading from "../components/Loading" -import UncontrolledTooltipWrapper from "../components/UncontrolledTooltipWrapper" import { useAttendanceStatesContext } from "../contexts/AttendanceStatesContext" -import CustomInputWrapper from "../forms/helpers/CustomInputWrapper" import ModalSettings from "../forms/ModalSettings" import { EDIT_TYPE } from "../global/constants" import { AttendanceStateType } from "../types/models" @@ -34,15 +31,13 @@ const Visible: React.FC = ({ visible, ...props }) => ( icon={visible ? faCheck : faTimes} size="lg" {...props} - className={classNames({ - "text-success": visible, - "text-secondary": !visible, - })} + color={visible ? "var(--mantine-color-green-7)" : "var(--mantine-color-gray-7)"} /> ) /** Stránka s nastavením – správa kurzů, stavů účasti, info o aplikaci. */ const Settings: React.FC = () => { + const { colorScheme, setColorScheme } = useMantineColorScheme() const attendanceStatesContext = useAttendanceStatesContext() const { data: courses = [], @@ -71,248 +66,291 @@ const Settings: React.FC = () => { } }, [attendanceStatesContext.isLoading, attendanceStatesContext.attendancestates]) - const onChange = (e: React.ChangeEvent): void => { - const target = e.currentTarget - const value = Number(target.value) - if (target.id === "state_default_id") { - setAttendanceStateDefaultId(value) - } else if (target.id === "state_excused_id") { - setAttendanceStateExcusedId(value) - } - const attrApi = target.dataset.attribute - if (attrApi) { - patchAttendanceState.mutate({ id: value, [attrApi]: true }) - } + const onChangeDefaultState = (val: string | null): void => { + if (!val) {return} + const numVal = Number(val) + setAttendanceStateDefaultId(numVal) + patchAttendanceState.mutate({ id: numVal, default: true }) + } + + const onChangeExcusedState = (val: string | null): void => { + if (!val) {return} + const numVal = Number(val) + setAttendanceStateExcusedId(numVal) + patchAttendanceState.mutate({ id: numVal, excused: true }) } const isLoading = coursesLoading || attendanceStatesContext.isLoading const isFetching = coursesFetching || attendanceStatesContext.isFetching return ( - <> - - - - - - } - /> - {isLoading ? ( - - ) : ( + + - - -

Stavy účasti

+ + + + } + /> + {isLoading ? ( + <> + {[...Array(4)].map((_, i) => ( + + ))} + + ) : ( + <> + +
+
+ Stavy účasti {attendanceStatesContext.attendancestates.length > 0 && ( - - - - - - - - - - {attendanceStatesContext.attendancestates.map( - (attendancestate) => ( - - - - - - ), - )} - -
NázevViditelnýAkce
- {attendancestate.name} - - - - -
+ + + + + Název + Viditelný + Akce + + + + {attendanceStatesContext.attendancestates.map( + (attendancestate) => ( + + + {attendancestate.name} + + + + + + + + + ), + )} + +
+
)} {attendanceStatesContext.attendancestates.length === 0 && ( -

Žádné stavy účasti

+ Žádné stavy účasti )}
-

Konfigurace stavů účasti

+ Konfigurace stavů účasti {attendanceStateDefaultId === undefined && ( - + Není vybraný výchozí stav, aplikace nemůže správně fungovat! )} {attendanceStateExcusedId === undefined && ( - - Není vybraný stav „omluven“, aplikace nemůže správně + + Není vybraný stav „omluven“, aplikace nemůže správně fungovat! )} -

+

Pro správné fungování aplikace je třeba některým (viditelným) stavům účasti přiřadit zvláštní vlastnosti podle jejich významu:

- - - -
+
+
+
+ Kurzy {courses.length > 0 && ( - - - - - - - - - - - - {courses.map((course) => ( - - - - - - - - ))} - -
NázevViditelnýBarvaTrvání (min.)Akce
{course.name} - - - - - {course.duration} - - -
+ + + + + Název + Viditelný + Barva + Trvání (min.) + Akce + + + + {courses.map((course) => ( + + {course.name} + + + + + + + + {course.duration} + + + + + + ))} + +
+
)} {courses.length === 0 && ( -

Žádné kurzy

+ Žádné kurzy )} - - -
-

- Verze aplikace:{" "} - +

+
+
+
+ Vzhled aplikace +
+ +
+ setColorScheme(val as MantineColorScheme)} + data={[ + { + value: "auto", + label: ( + + + Systém + + ), + }, + { + value: "light", + label: ( + + + Světlý + + ), + }, + { + value: "dark", + label: ( + + + Tmavý + + ), + }, + ]} + /> +
+
+
+
+

+ Verze aplikace:{" "} + {" ("} {")"} – {" "} - - - - GitHub repozitář ÚPadmin - - + + + + + {" • "} API dokumentace

-

+

S láskou vytvořil{" "} { rel="noopener noreferrer"> Lukáš Rod - , 2018–%GIT_YEAR + , 2018–%GIT_YEAR

- - )} - - +
+ + )} +
) } From a9dd8ac241d04c5594f005837852b68ee30b74cf Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 5 May 2026 08:05:32 +0200 Subject: [PATCH 03/23] docs: add design polish plan with dark mode bugs and spacing issues Co-Authored-By: Claude Sonnet 4.6 --- .../specs/2026-05-05-design-polish-plan.md | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-05-design-polish-plan.md 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 00000000..fd0b2d26 --- /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ý | From 70fc2859123b4c0b3d59d160c3cd525ac5979dc1 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Tue, 5 May 2026 08:13:16 +0200 Subject: [PATCH 04/23] fix: dark mode color adaptation and design polish across all pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bank: replace hardcoded green.1/red.1/yellow.1 Mantine shades with light-dark() CSS classes for title and today-row backgrounds - DashboardDay: fix blue.2 today header → indigo light-dark(), reduce lecture heading dark overlay from 0.28 to 0.18 - Applications: redesign course section headers from heavy full-color banners to subtle light bg + 4px colored left border accent - Heading: increase bottom margin from md(16px) to lg(24px) - Diary: add marginTop to weekGrid for better header separation - Statistics: increase sectionTightTop from 0.2rem to 0.5rem - Card: lighten lectureFuture/Prepaid dark mode from yellow/green-9 to -8, reduce courseHeadingItem overlay from 0.2 to 0.15 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/components/Bank.css.ts | 48 +++++- frontend/src/components/Bank.tsx | 180 ++++++++++---------- frontend/src/components/DashboardDay.css.ts | 35 +++- frontend/src/components/DashboardDay.tsx | 172 +++++++++---------- frontend/src/components/Heading.tsx | 47 +++-- frontend/src/pages/Applications.css.ts | 122 ++++++++++++- frontend/src/pages/Applications.tsx | 175 +++++++++---------- frontend/src/pages/Card.css.ts | 71 +++++++- frontend/src/pages/Diary.css.ts | 74 +++++++- frontend/src/pages/Statistics.css.ts | 57 +++++-- 10 files changed, 656 insertions(+), 325 deletions(-) diff --git a/frontend/src/components/Bank.css.ts b/frontend/src/components/Bank.css.ts index 8a814ac4..8babecc8 100644 --- a/frontend/src/components/Bank.css.ts +++ b/frontend/src/components/Bank.css.ts @@ -1,13 +1,59 @@ import { style } from "@vanilla-extract/css" +export const bankWrapper = style({ + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.55rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + overflow: "hidden", +}) + export const bankTitle = style({ - padding: "0.75rem", + borderBottom: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + 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: "var(--mantine-color-gray-6)", +}) + +export const bankDateColumn = style({ + minWidth: "6em", +}) + +export const bankAmountColumn = style({ + minWidth: "7em", +}) + +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))", +}) + +export const bankRowToday = style({ + backgroundColor: "light-dark(var(--mantine-color-yellow-1), var(--mantine-color-yellow-9))", +}) diff --git a/frontend/src/components/Bank.tsx b/frontend/src/components/Bank.tsx index 54823750..ec82e6b3 100644 --- a/frontend/src/components/Bank.tsx +++ b/frontend/src/components/Bank.tsx @@ -1,13 +1,12 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Box, Table, Text, Title, Tooltip } from "@mantine/core" import { faExclamationCircle, faExternalLink, faInfoCircle, faSyncAlt, } 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" @@ -18,7 +17,6 @@ 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 +34,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" }) => ( - - {text} - +const TableInfo: React.FC = ({ text }) => ( + + + {text} + + ) /** Komponenta zobrazující přehled transakcí z banky. */ @@ -82,7 +80,7 @@ const Bank: React.FC = () => { return isLoadingState ? "načítání" : "neznámý" } return ( - + {prettyAmount(bankData.accountStatement.info.closingBalance)} ) @@ -102,31 +100,34 @@ const Bank: React.FC = () => { const duplicates = messageObj && messageObj.value === commentObj?.value const targetAccountOwnerObj = transaction.column10 return ( - - + + {commentObj?.value ?? (targetAccountOwnerObj?.value ? ( `Vlastník protiúčtu: ${targetAccountOwnerObj.value}` ) : ( ))} - + {!duplicates && ( - + {messageObj ? messageObj.value : } - + )} - + {prettyDateWithDayYearIfDiff(date, true)} - - + + {prettyAmount(amount)} - - + + ) }) } @@ -134,82 +135,89 @@ const Bank: React.FC = () => { const renderMainContent = (): React.ReactNode => { if (isBankSuccess(bankData)) { return ( - - - - - - - - - - {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!`}> + <span> + <FontAwesomeIcon + icon={faExclamationCircle} + color="var(--mantine-color-red-7)" + size="lg" + /> </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/DashboardDay.css.ts b/frontend/src/components/DashboardDay.css.ts index a6fef108..57324160 100644 --- a/frontend/src/components/DashboardDay.css.ts +++ b/frontend/src/components/DashboardDay.css.ts @@ -5,11 +5,19 @@ export const dashboardDayVars = createThemeContract({ }) export const lectureGroup = style({ - backgroundColor: "#e7e7e7", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", }) export const dashboardDayDate = style({ - padding: "0.75rem", + borderBottom: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + padding: "0.8rem 0.85rem", + color: "light-dark(#0f172a, var(--mantine-color-gray-1))", +}) + +export const dashboardDayDateToday = style({ + backgroundColor: + "light-dark(var(--mantine-color-indigo-1), var(--mantine-color-indigo-9)) !important", }) export const celebrationNone = style({ @@ -28,6 +36,8 @@ globalStyle(`${lectureCanceledDashboardday} h4 span::after`, { export const lectureHeading = style({ backgroundColor: `${dashboardDayVars.courseBackground} !important`, + backgroundImage: "linear-gradient(rgb(15 23 42 / 0.18), rgb(15 23 42 / 0.18))", + textShadow: "0 1px 2px rgb(0 0 0 / 0.46)", color: "white", }) @@ -37,7 +47,7 @@ export const courseName = style({ }) export const lectureNumber = style({ - backgroundColor: "white", + backgroundColor: "light-dark(white, var(--mantine-color-dark-6))", }) export const lectureFree = style({ @@ -46,4 +56,23 @@ export const lectureFree = style({ export const dashboardDayWrapper = style({ position: "relative", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.55rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + overflow: "hidden", +}) + +export const floatEnd = style({ + float: "right", +}) + +export const dashboardDayItem = style({ + transition: "background-color 0.15s ease-in-out", + borderTop: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + selectors: { + "&:hover": { + backgroundColor: "light-dark(rgb(241 245 249 / 0.85), var(--mantine-color-dark-6))", + }, + }, }) diff --git a/frontend/src/components/DashboardDay.tsx b/frontend/src/components/DashboardDay.tsx index df7b69ba..f936e817 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" @@ -27,7 +27,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 +56,102 @@ 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" + } + style={{ marginBottom: 0, display: "inline-block", whiteSpace: "nowrap" }}> <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/Heading.tsx b/frontend/src/components/Heading.tsx index ee1ec7d1..2f845bbb 100644 --- a/frontend/src/components/Heading.tsx +++ b/frontend/src/components/Heading.tsx @@ -1,8 +1,5 @@ -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 * as styles from "./Heading.css" @@ -19,27 +16,27 @@ type Props = { /** Komponenta pro jednotné zobrazení nadpisu stránky napříč aplikací. */ const Heading: React.FC = ({ title, buttons, fluid = false, isFetching = false }) => ( - - -

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

- - - {buttons} - -
+ + + {title} + {isFetching && ( + <Loader + size="xs" + type="dots" + color="gray" + style={{ marginLeft: "0.5rem", verticalAlign: "middle" }} + data-qa="loading" + /> + )} + + {buttons ?
{buttons}
: null} +
) export default Heading diff --git a/frontend/src/pages/Applications.css.ts b/frontend/src/pages/Applications.css.ts index 08fb6d8f..6162bd6b 100644 --- a/frontend/src/pages/Applications.css.ts +++ b/frontend/src/pages/Applications.css.ts @@ -6,23 +6,131 @@ export const applicationsVars = createThemeContract({ }) export const course = style({ + display: "flex", + flexDirection: "column", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.6rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + overflow: "hidden", selectors: { "& + &": { - marginTop: "1rem", + marginTop: "1.1rem", }, }, }) -export const courseHeading = style({ - color: "white", +export const applicationItem = style({ + transition: "background-color 0.12s ease-in-out", + borderTop: "1px solid light-dark(#dbe3ed, var(--mantine-color-dark-4))", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + padding: "0.5rem 1rem", + selectors: { + "&:hover": { + backgroundColor: "light-dark(#f4f7fb, var(--mantine-color-dark-6))", + }, + }, +}) + +export const applicationRow = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "center", }) export const courseHeadingItem = style({ - backgroundColor: `${applicationsVars.courseBackground} !important`, + display: "flex", + alignItems: "center", + gap: "0.65rem", + borderBottom: "1px solid light-dark(#dbe3ed, var(--mantine-color-dark-4))", + borderLeft: `4px solid ${applicationsVars.courseBackground}`, + backgroundColor: "light-dark(#f1f5f9, var(--mantine-color-dark-6))", + padding: "0.6rem 1rem", }) export const courseHeadingBadge = style({ - marginLeft: "0.3rem", - backgroundColor: "white !important", - color: `${applicationsVars.badgeColor} !important`, + marginLeft: "0.1rem", + backgroundColor: `${applicationsVars.courseBackground} !important`, + color: "white !important", + fontSize: "0.7rem", + fontWeight: 700, +}) + +export const courseHeading = style({ + marginBottom: 0, + color: "light-dark(#1f2937, var(--mantine-color-gray-1))", + fontSize: "1rem", + fontWeight: 600, +}) + +export const applicationMeta = style({ + marginTop: "0.25rem", + width: "100%", + color: "light-dark(#475569, var(--mantine-color-dark-2))", + "@media": { + "(min-width: 768px)": { + flex: "0 0 41.666667%", + marginTop: 0, + width: "41.666667%", + }, + }, +}) + +export const createdBadge = style({ + marginRight: "0.35rem", + fontWeight: 600, +}) + +export const applicationActions = style({ + display: "flex", + justifyContent: "flex-end", + gap: "0.35rem", + "@media": { + "(max-width: 767.98px)": { + justifyContent: "flex-start", + marginTop: "0.35rem", + }, + }, +}) + +export const listSection = style({ + marginTop: "0.25rem", +}) + +export const applicationNameCol = style({ + width: "100%", + "@media": { + "(min-width: 768px)": { + flex: "0 0 25%", + width: "25%", + }, + "(max-width: 767.98px)": { + marginBottom: "0.2rem", + }, + }, +}) + +export const applicationPhoneCol = style({ + width: "100%", + "@media": { + "(min-width: 768px)": { + flex: "0 0 16.666667%", + width: "16.666667%", + }, + "(max-width: 767.98px)": { + marginTop: "0.3rem", + }, + }, +}) + +export const applicationActionsCol = style({ + marginTop: "0.25rem", + width: "100%", + "@media": { + "(min-width: 768px)": { + flex: "0 0 16.666667%", + marginTop: 0, + width: "16.666667%", + }, + }, }) diff --git a/frontend/src/pages/Applications.tsx b/frontend/src/pages/Applications.tsx index 7c93554d..4d7e515d 100644 --- a/frontend/src/pages/Applications.tsx +++ b/frontend/src/pages/Applications.tsx @@ -1,7 +1,7 @@ +import { Badge, Container, Title, Tooltip } from "@mantine/core" import { assignInlineVars } from "@vanilla-extract/dynamic" import classNames from "classnames" import * as React from "react" -import { Badge, Col, Container, ListGroup, ListGroupItem, Row } from "reactstrap" import { trackEvent } from "../analytics" import { useApplications, useDeleteApplication } from "../api/hooks" @@ -11,7 +11,6 @@ import ClientName from "../components/ClientName" import ClientPhone from "../components/ClientPhone" import Heading from "../components/Heading" import Loading from "../components/Loading" -import UncontrolledTooltipWrapper from "../components/UncontrolledTooltipWrapper" import ModalApplications from "../forms/ModalApplications" import { prettyDateWithYear } from "../global/funcDateTime" import { GroupedObjectsByCourses, groupObjectsByCourses } from "../global/utils" @@ -55,90 +54,78 @@ const Applications: React.FC = () => { } return ( - <> - - } - isFetching={isFetching && applications.length > 0} - /> - {isLoading ? ( - - ) : ( - <> - {applications.length > 0 && ( - - Datum přidání - - )} - {applications.map((courseApplications) => { - const cnt = courseApplications.objects.length - return ( - - -

- - {courseApplications.course.name} - {" "} - - - {cnt} - {" "} - zájemc{getZajemciSuffix(cnt)} - -

-
- {courseApplications.objects.map((application) => ( - - - -
- -
- - + + } + isFetching={isFetching && applications.length > 0} + /> + {isLoading ? ( + + ) : ( + <> + {applications.map((courseApplications) => { + const cnt = courseApplications.objects.length + return ( +
+
+ + <span data-qa="application_course"> + {courseApplications.course.name} + </span> + + + {cnt}{" "} + zájemc{getZajemciSuffix(cnt)} + +
+ {courseApplications.objects.map((application) => ( +
+
+
+ + <ClientName client={application.client} link /> + +
+
+ {prettyDateWithYear( new Date(application.created_at), )} - {" "} - - {application.note} - - - - {application.client.phone && ( - - )} - - - {" "} + + + + {application.note} + +
+
+ {application.client.phone && ( + + )} +
+
+
+ { if ( globalThis.confirm( @@ -151,20 +138,20 @@ const Applications: React.FC = () => { }} data-qa="button_delete_application" /> - - - - ))} - - ) - })} - {applications.length === 0 && ( -

Žádní zájemci

- )} - - )} - - +
+
+
+
+ ))} +
+ ) + })} + {applications.length === 0 && ( +

Žádní zájemci

+ )} + + )} +
) } diff --git a/frontend/src/pages/Card.css.ts b/frontend/src/pages/Card.css.ts index 26efbb55..2054d03a 100644 --- a/frontend/src/pages/Card.css.ts +++ b/frontend/src/pages/Card.css.ts @@ -7,11 +7,13 @@ export const cardVars = createThemeContract({ }) export const courseHeading = style({ + textShadow: "0 1px 2px rgb(0 0 0 / 0.35)", color: "white", }) export const courseHeadingItem = style({ backgroundColor: `${cardVars.courseBackground} !important`, + backgroundImage: "linear-gradient(rgb(15 23 42 / 0.15), rgb(15 23 42 / 0.15))", }) export const lectureCard = style({}) @@ -21,11 +23,11 @@ globalStyle(`${lectureCard} h4`, { }) export const lectureFuture = style({ - backgroundColor: "#fff3cd", + backgroundColor: "light-dark(#fff8dd, var(--mantine-color-yellow-8))", }) export const lecturePrepaid = style({ - backgroundColor: "#d4edda", + backgroundColor: "light-dark(#ddf6e4, var(--mantine-color-green-8))", }) export const cardInfo = style({}) @@ -50,3 +52,68 @@ globalStyle(`${pastGroup} ${groupPlainName}::after`, { width: "100%", content: '""', }) + +export const clientTopRow = style({ + display: "flex", + flexWrap: "wrap", + alignItems: "flex-start", + gap: "1rem", + marginBottom: "1rem", +}) + +export const clientSummaryPanel = style({ + flex: "0 0 auto", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.55rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + minWidth: "220px", + overflow: "hidden", +}) + +export const analysisPanel = style({ + flexGrow: 1, + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.55rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + padding: "0.55rem 0.65rem", + minWidth: 0, +}) + +export const infoList = style({ + display: "flex", + flexDirection: "column", +}) + +export const infoListItem = style({ + padding: "0.5rem 1rem", + selectors: { + "& + &": { + borderTop: "1px solid light-dark(#e9ecef, var(--mantine-color-dark-4))", + }, + }, +}) + +export const lectureColumns = style({ + display: "flex", + flexWrap: "wrap", + justifyContent: "center", + gap: "1rem", +}) + +export const lectureColumn = style({ + width: "100%", + "@media": { + "(min-width: 576px)": { maxWidth: "83.33%" }, + "(min-width: 768px)": { maxWidth: "66.67%" }, + "(min-width: 992px)": { maxWidth: "50%" }, + "(min-width: 1200px)": { maxWidth: "41.67%" }, + }, +}) + +export const lectureColumnNarrow = style({ + "@media": { + "(min-width: 1200px)": { maxWidth: "33.33%" }, + }, +}) diff --git a/frontend/src/pages/Diary.css.ts b/frontend/src/pages/Diary.css.ts index 9a71c66d..4d610be8 100644 --- a/frontend/src/pages/Diary.css.ts +++ b/frontend/src/pages/Diary.css.ts @@ -26,23 +26,91 @@ export const disabledLink = style({ cursor: "default", }) +export const arrowLink = style({ + display: "inline-flex", + outline: "none", + borderRadius: "999px", + selectors: { + "&:focus-visible": { + boxShadow: "0 0 0 0.23rem rgb(13 110 253 / 0.35)", + }, + }, +}) + export const arrowBtn = style({ - transition: "color 0.15s ease-in-out", + transition: "all 0.15s ease-in-out", marginTop: "0.15rem", + borderRadius: "999px", + boxShadow: "0 4px 10px rgb(15 23 42 / 0.1)", + backgroundColor: "light-dark(#e2e8f0, var(--mantine-color-dark-5))", cursor: "pointer", + padding: "0.2rem", + color: "light-dark(#1f2937, var(--mantine-color-gray-1))", fontSize: "2rem", selectors: { "&:hover": { - color: "#414448 !important", + transform: "translateY(-1px)", + backgroundColor: "light-dark(#cfd8e3, var(--mantine-color-dark-4))", + color: "light-dark(#0f172a, var(--mantine-color-gray-0))", }, }, }) export const titleDate = style({ display: "inline-block", - width: "6ch", // aby mely dny v tydennim prehledu vzdy stejnou sirku + borderRadius: "0.35rem", // aby mely dny v tydennim prehledu vzdy stejnou sirku + backgroundColor: "rgb(148 163 184 / 0.12)", + padding: "0.05rem 0.2rem", + width: "6ch", + textAlign: "center", + color: "light-dark(#1f2937, var(--mantine-color-gray-1))", + fontWeight: 700, }) export const titleDateLong = style({ width: "10ch", }) + +export const weekGrid = style({ + marginTop: "0.5rem", + paddingRight: "0.75rem", + paddingLeft: "0.75rem", + "@media": { + "(max-width: 767.98px)": { + paddingRight: "0.5rem", + paddingLeft: "0.5rem", + }, + }, +}) + +export const weekRow = style({ + display: "flex", + flexWrap: "wrap", + marginRight: "-0.75rem", + marginLeft: "-0.75rem", + "@media": { + "(max-width: 767.98px)": { + marginRight: "-0.5rem", + marginLeft: "-0.5rem", + }, + }, +}) + +export const weekDayCol = style({ + paddingRight: "0.75rem", + paddingLeft: "0.75rem", + width: "100%", + "@media": { + "(min-width: 768px) and (max-width: 991.98px)": { + flex: "0 0 50%", + maxWidth: "50%", + }, + "(min-width: 992px)": { + flex: "1", + }, + "(max-width: 767.98px)": { + paddingRight: "0.5rem", + paddingLeft: "0.5rem", + }, + }, +}) diff --git a/frontend/src/pages/Statistics.css.ts b/frontend/src/pages/Statistics.css.ts index 43c5ca97..32f8d1da 100644 --- a/frontend/src/pages/Statistics.css.ts +++ b/frontend/src/pages/Statistics.css.ts @@ -3,10 +3,10 @@ import { globalStyle, style } from "@vanilla-extract/css" export { chartTooltip } from "../components/charts.css" export const statCard = style({ - border: "1px solid #dee2e6", - borderRadius: "0.375rem", - boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", - backgroundColor: "#fff", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.6rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", padding: "1rem", minHeight: "9rem", }) @@ -15,7 +15,7 @@ export const statCardTitle = style({ marginBottom: "0.5rem", textTransform: "uppercase", letterSpacing: "0.04em", - color: "#6c757d", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", fontSize: "0.75rem", fontWeight: 600, }) @@ -29,7 +29,7 @@ export const metricValue = style({ export const statNote = style({ marginBottom: "0.5rem", lineHeight: 1.45, - color: "#6c757d", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", fontSize: "0.75rem", }) @@ -43,18 +43,37 @@ export const pageLead = style({ marginBottom: "1rem", maxWidth: "42rem", lineHeight: 1.5, - color: "#6c757d", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", fontSize: "0.875rem", }) +export const sectionTightTop = style({ + marginTop: "0.5rem", +}) + export const filterSection = style({ marginBottom: "1rem", paddingBottom: "1rem", }) +export const yearFilterButtons = style({ + display: "flex", + flexWrap: "wrap", + gap: "0.25rem", +}) + +globalStyle(`${yearFilterButtons} > *`, { + "@media": { + "screen and (max-width: 575.98px)": { + flex: 1, + minWidth: "4.5rem", + }, + }, +}) + export const filterHeading = style({ marginBottom: "0.25rem", - color: "#212529", + color: "light-dark(#1f2937, var(--mantine-color-gray-1))", fontSize: "0.875rem", fontWeight: 600, }) @@ -63,7 +82,7 @@ export const filterHint = style({ marginBottom: "0.5rem", maxWidth: "42rem", lineHeight: 1.45, - color: "#6c757d", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", fontSize: "0.8rem", }) @@ -77,7 +96,7 @@ export const metricToggle = style({ }, }) -globalStyle(`${metricToggle} > .btn`, { +globalStyle(`${metricToggle} button`, { whiteSpace: "nowrap", "@media": { "screen and (max-width: 767px)": { @@ -108,7 +127,7 @@ export const chartTitleRow = style({ export const chartTitle = style({ marginBottom: 0, - color: "#212529", + color: "light-dark(#1f2937, var(--mantine-color-gray-1))", fontSize: "1.05rem", fontWeight: 600, }) @@ -117,15 +136,15 @@ export const chartCaption = style({ marginBottom: "0.75rem", maxWidth: "48rem", lineHeight: 1.45, - color: "#6c757d", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", fontSize: "0.8rem", }) export const chartPanel = style({ - border: "1px solid #dee2e6", - borderRadius: "0.375rem", - boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)", - backgroundColor: "#fff", + border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderRadius: "0.6rem", + boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", + backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", padding: "1rem", "@media": { "screen and (max-width: 767px)": { @@ -136,6 +155,10 @@ export const chartPanel = style({ export const chartEmpty = style({ marginBottom: 0, - color: "#6c757d", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", fontSize: "0.875rem", }) + +export const rankingTable = style({ + minWidth: "24rem", +}) From 4f43a73231077a6b5fd352046a79199ff3137423 Mon Sep 17 00:00:00 2001 From: rodlukas Date: Wed, 6 May 2026 20:39:03 +0200 Subject: [PATCH 05/23] =?UTF-8?q?design:=20o=C5=BEivit=20token=20syst?= =?UTF-8?q?=C3=A9m,=20doplnit=20a11y,=20vy=C4=8Distit=20inline=20styly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hlavní změny po komplexním review Mantine v9 migrace: **Design system / tokeny** - Aktivovat dosud nepoužívaný theme/tokens (rename z .css.ts na .ts kvůli ambient *.css declaration) - Rozšířit vars o radius, focusRing, text.heading, bg.muted, border.subtle/strong, borderShort - Codemod: nahradit ~60 duplicitních light-dark(#hex,…) literálů za vars tokeny napříč 18 *.css.ts soubory **A11y** - Login: name, autoComplete, aria-label na TextInputech (browser autocomplete + screen readers) - EditButton, Bank refresh: aria-label pro icon-only buttony (Tooltip ho nepřidává automaticky) - Loading: role=status, aria-live=polite, aria-busy - SubmitButton: nahradit FontAwesome spinner Mantine Button loading propem + aria-busy - nav: aria-label "Hlavní navigace" - Table.Th: scope=col jako theme default **Color scheme manager** - Explicitní localStorageColorSchemeManager s key "mantine-color-scheme" (Mantine v9 default je jiný klíč, takže persistence po reload nefungovala) **Inline styly (z 66 na 4 legitimní dynamic colors)** - Nový global/utility.css.ts s reusable utility classes (nowrap, bold, mb0, dimmedText, iconAfterText, …) - Refactor: Main, Login, Bank, DashboardDay, Heading, ClientAnalysis, Statistics, Diary, Card, Settings, Applications, Clients, Groups, ErrorBoundary, queryClient, GroupName, ClientPhone, ClientsList, AppDate, Notification, CourseCircle, SelectCourse, ColorPicker **Cleanup** - Smazat mantine-spotlight.css workaround (webpack sideEffects:true je přímý fix) - Odstranit Tooltip.postfix prop (8 callsites, propu komponenta ignorovala) - Odstranit nepoužívaný getDisplayName + zbytečný React import - CustomButton: vyhodit noop default (Mantine Button funguje s undefined onClick) - Stale komentář "Bootstrap" ve webpack.config.js - Opravit rozbité odsazení v package.json (řádky @fortawesome a chroma-js) **ESLint** - Doplnit explicit project + node resolver pro import-resolver-typescript (webpack ESLint plugin context jinak neumí najít theme/tokens) Co je zbylé pro samostatný PR (vyšší riziko refactoru): - Vystěhovat globalStyle(".mantine-…") overrides v FormBase.css.ts a index.css.ts do theme.components.X.styles - Sjednotit 8 různých radius hodnot na var(--mantine-radius-{sm,md,lg}) Verifikováno: tsc, ESLint+Prettier, vitest 32/32, runtime na localhost:8000. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/eslint.config.mjs | 8 +- frontend/package.json | 8 +- frontend/src/Main.css.ts | 80 ++- frontend/src/Main.tsx | 131 ++-- frontend/src/api/queryClient.tsx | 5 +- frontend/src/components/AppDate.tsx | 4 +- frontend/src/components/AppSpotlight.tsx | 126 ++++ frontend/src/components/Bank.css.ts | 10 +- frontend/src/components/Bank.tsx | 14 +- frontend/src/components/ClientAnalysis.css.ts | 47 +- frontend/src/components/ClientAnalysis.tsx | 32 +- frontend/src/components/ClientPhone.tsx | 3 +- frontend/src/components/ClientsList.tsx | 3 +- frontend/src/components/CourseCircle.tsx | 34 +- frontend/src/components/DashboardDay.css.ts | 16 +- frontend/src/components/DashboardDay.tsx | 10 +- frontend/src/components/GroupName.tsx | 6 +- frontend/src/components/Heading.css.ts | 31 +- frontend/src/components/Heading.tsx | 4 +- frontend/src/components/Loading.css.ts | 31 + frontend/src/components/Loading.tsx | 29 +- frontend/src/components/Notification.tsx | 3 +- .../src/components/PrepaidCounters.css.ts | 19 +- frontend/src/components/PrepaidCounters.tsx | 96 ++- frontend/src/components/Tooltip.tsx | 31 +- .../src/components/buttons/CustomButton.tsx | 19 +- .../src/components/buttons/EditButton.tsx | 25 +- .../src/components/buttons/SubmitButton.tsx | 15 +- frontend/src/components/charts.css.ts | 12 +- frontend/src/forms/FormBase.css.ts | 289 ++++++++ frontend/src/forms/FormClients.tsx | 329 ++++----- frontend/src/forms/FormGroups.tsx | 278 ++++---- frontend/src/forms/FormLectures.css.ts | 73 +- frontend/src/forms/FormLectures.tsx | 661 ++++++++---------- frontend/src/forms/helpers/ColorPicker.css.ts | 30 +- frontend/src/forms/helpers/ColorPicker.tsx | 23 +- .../src/forms/helpers/SelectCourse.css.ts | 8 + frontend/src/forms/helpers/SelectCourse.tsx | 72 +- frontend/src/global/utility.css.ts | 83 +++ frontend/src/global/utils.ts | 11 +- frontend/src/index.css.ts | 169 +---- frontend/src/index.tsx | 16 +- frontend/src/pages/Applications.css.ts | 16 +- frontend/src/pages/Applications.tsx | 5 +- frontend/src/pages/Card.css.ts | 13 +- frontend/src/pages/Card.tsx | 371 +++++----- frontend/src/pages/Clients.tsx | 198 +++--- frontend/src/pages/Diary.css.ts | 6 +- frontend/src/pages/Diary.tsx | 155 ++-- frontend/src/pages/ErrorBoundary.tsx | 77 +- frontend/src/pages/Groups.tsx | 219 +++--- frontend/src/pages/Login.css.ts | 20 +- frontend/src/pages/Login.tsx | 106 ++- frontend/src/pages/Settings.css.ts | 20 +- frontend/src/pages/Settings.tsx | 9 +- frontend/src/pages/Statistics.css.ts | 68 +- frontend/src/pages/Statistics.tsx | 184 ++--- frontend/src/theme/theme.ts | 58 ++ frontend/src/theme/tokens.ts | 64 ++ frontend/webpack.config.js | 6 +- 60 files changed, 2627 insertions(+), 1862 deletions(-) create mode 100644 frontend/src/components/AppSpotlight.tsx create mode 100644 frontend/src/components/Loading.css.ts create mode 100644 frontend/src/forms/FormBase.css.ts create mode 100644 frontend/src/forms/helpers/SelectCourse.css.ts create mode 100644 frontend/src/global/utility.css.ts create mode 100644 frontend/src/theme/theme.ts create mode 100644 frontend/src/theme/tokens.ts diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 6afae2f1..dfaa85c7 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.json b/frontend/package.json index 3e011d8a..909f405d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -85,18 +85,18 @@ }, "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.0.2", + "@mantine/hooks": "^9.0.2", + "@mantine/spotlight": "^9.0.2", "@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", @@ -105,9 +105,7 @@ "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/Main.css.ts b/frontend/src/Main.css.ts index 6d4fb574..6733dce3 100644 --- a/frontend/src/Main.css.ts +++ b/frontend/src/Main.css.ts @@ -1,26 +1,94 @@ import { globalStyle, style } from "@vanilla-extract/css" -globalStyle(".navbar .badge", { +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: "56px", +}) + +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)": { + marginRight: "0.5rem", + }, + }, +}) + +export const navbarBurger = style({ + marginLeft: "auto", +}) + +export const navbarCollapse = style({ + display: "none", + width: "100%", "@media": { "(min-width: 992px)": { - // pri klasickem menu pridej pravou mezeru k badge - marginRight: "1rem", + 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 + paddingTop: "3.75rem", +}) + +globalStyle(".main", { + marginBottom: "1.5rem", }) globalStyle(".nav-content", { maxWidth: "900px", }) -globalStyle(".main a, .modal a", { +globalStyle(".main a, [data-mantine-modal] a", { textDecoration: "none", }) -globalStyle(".main a:hover, .modal a:hover", { +globalStyle(".main a:hover, [data-mantine-modal] a:hover", { textDecoration: "underline", }) diff --git a/frontend/src/Main.tsx b/frontend/src/Main.tsx index d3cf100e..56aad4d1 100644 --- a/frontend/src/Main.tsx +++ b/frontend/src/Main.tsx @@ -1,89 +1,30 @@ +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 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 +33,53 @@ 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 + + )} + - - +
+ +
+
+ )}
- + }> diff --git a/frontend/src/api/queryClient.tsx b/frontend/src/api/queryClient.tsx index 2be6c74f..d3975d18 100644 --- a/frontend/src/api/queryClient.tsx +++ b/frontend/src/api/queryClient.tsx @@ -7,6 +7,7 @@ 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 +38,8 @@ function getErrorMessage(
    {Object.keys(djangoError).map((field) => (
  • - {field}: - {String(djangoError[field])} + {field}: + {String(djangoError[field])}
  • ))}
diff --git a/frontend/src/components/AppDate.tsx b/frontend/src/components/AppDate.tsx index 53200001..b3b9f7f0 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/AppSpotlight.tsx b/frontend/src/components/AppSpotlight.tsx new file mode 100644 index 00000000..5ee62c0a --- /dev/null +++ b/frontend/src/components/AppSpotlight.tsx @@ -0,0 +1,126 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" +import { Spotlight, SpotlightActionData, SpotlightFilterFunction } 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 } 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"], +} + +/** Spotlight pro globální vyhledávání klientů a skupin. */ +const AppSpotlight: React.FC = () => { + const navigate = useNavigate() + const clientsActiveContext = useClientsActiveContext() + const groupsActiveContext = useGroupsActiveContext() + const searchTrackedRef = React.useRef(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: [client.phone, client.email].filter(Boolean).join(" · ") || undefined, + leftSection: , + group: "Klienti", + 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, + leftSection: , + group: "Skupiny", + onClick: () => { + void navigate({ to: "/skupiny/$id", params: { id: String(group.id) } }) + }, + })), + [groupsActiveContext.groups, navigate], + ) + + const actions = React.useMemo( + () => [...clientActions, ...groupActions], + [clientActions, groupActions], + ) + + const filter = React.useCallback( + (query, actionsToFilter) => { + if (!query.trim()) { + return actionsToFilter + } + + if (!searchTrackedRef.current) { + trackEvent("search_used", { has_results: actionsToFilter.length > 0 }) + searchTrackedRef.current = true + } + + const clientResults = clientFuse.search(query) + const clientIds = new Set(clientResults.map((r) => `client-${r.item.id}`)) + + const groupResults = groupFuse.search(query) + const groupIds = new Set(groupResults.map((r) => `group-${r.item.id}`)) + + return actionsToFilter.filter((action): action is SpotlightActionData => { + if (!("id" in action)) { + return false + } + return clientIds.has(action.id) || groupIds.has(action.id) + }) + }, + [clientFuse, groupFuse], + ) + + const onSpotlightClose = React.useCallback(() => { + searchTrackedRef.current = false + }, []) + + return ( + + ) +} + +export default AppSpotlight diff --git a/frontend/src/components/Bank.css.ts b/frontend/src/components/Bank.css.ts index 8babecc8..7375322d 100644 --- a/frontend/src/components/Bank.css.ts +++ b/frontend/src/components/Bank.css.ts @@ -1,15 +1,17 @@ import { style } from "@vanilla-extract/css" +import { vars } from "../theme/tokens" + export const bankWrapper = style({ - border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + border: vars.borderShort.default, borderRadius: "0.55rem", - boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", - backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + boxShadow: vars.shadow.card, + backgroundColor: vars.bg.surface, overflow: "hidden", }) export const bankTitle = style({ - borderBottom: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderBottom: vars.borderShort.default, padding: "0.8rem", }) diff --git a/frontend/src/components/Bank.tsx b/frontend/src/components/Bank.tsx index ec82e6b3..ef42247a 100644 --- a/frontend/src/components/Bank.tsx +++ b/frontend/src/components/Bank.tsx @@ -11,6 +11,7 @@ import * as React from "react" 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 { BankType, BankSuccessType, BankErrorType } from "../types/models" @@ -80,7 +81,7 @@ const Bank: React.FC = () => { return isLoadingState ? "načítání" : "neznámý" } return ( - + {prettyAmount(bankData.accountStatement.info.closingBalance)} ) @@ -117,13 +118,12 @@ const Bank: React.FC = () => { {messageObj ? messageObj.value : } )} - + {prettyDateWithDayYearIfDiff(date, true)} {prettyAmount(amount)} @@ -164,7 +164,9 @@ const Bank: React.FC = () => {
- + <Title + order={4} + className={`${styles.bankTitleText} ${inlineBlockNowrap}`}> Aktuální stav: {getBalanceText()}{" "} {isLackOfMoney && ( <Tooltip @@ -191,11 +193,13 @@ const Bank: React.FC = () => { onClick={onClick} disabled={isRefreshDisabled} size="sm" + aria-label="Obnovit výpis" content={ <FontAwesomeIcon icon={faSyncAlt} size="lg" spin={isLoadingState} + aria-hidden /> } /> diff --git a/frontend/src/components/ClientAnalysis.css.ts b/frontend/src/components/ClientAnalysis.css.ts index 8d1b0d92..2a58201f 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 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 1699af15..04af8cf8 100644 --- a/frontend/src/components/ClientAnalysis.tsx +++ b/frontend/src/components/ClientAnalysis.tsx @@ -55,14 +55,14 @@ const ChartTooltip: React.FC<TooltipContentProps> = ({ active, label, payload }) const total = payload.reduce((sum, entry) => sum + entry.value, 0) return ( <div className={styles.tooltip}> - <div className="fw-semibold mb-1">{label}</div> + <div className={styles.tooltipLabel}>{label}</div> {payload.map((entry) => ( <div key={entry.name} style={{ color: entry.color }}> {entry.name}: <strong>{entry.value}</strong> </div> ))} {payload.length > 1 && ( - <div className="mt-1 border-top pt-1"> + <div className={styles.tooltipTotal}> Celkem: <strong>{total}</strong> </div> )} @@ -138,30 +138,30 @@ const ClientAnalysis: React.FC<Props> = ({ clientId, lectures }) => { return ( <div className={styles.chartPanel}> - <div className="d-flex flex-wrap justify-content-around mb-3 gap-2 pt-1"> - <div className="text-center"> - <div className="fw-bold h5 mb-0">{analysis.happened.length}</div> - <div className="text-muted small">Proběhlé</div> + <div className={styles.summary}> + <div className={styles.summaryItem}> + <div className={styles.summaryNumber}>{analysis.happened.length}</div> + <div className={styles.summaryLabel}>Proběhlé</div> </div> - <div className="text-center"> - <div className="fw-bold h5 mb-0">{analysis.excused.length}</div> - <div className="text-muted small">Omluvené</div> + <div className={styles.summaryItem}> + <div className={styles.summaryNumber}>{analysis.excused.length}</div> + <div className={styles.summaryLabel}>Omluvené</div> </div> - <div className="text-center"> - <div className="fw-bold h5 mb-0"> + <div className={styles.summaryItem}> + <div className={styles.summaryNumber}> {analysis.notHappened.length - analysis.excused.length} </div> - <div className="text-muted small">Zrušené</div> + <div className={styles.summaryLabel}>Zrušené</div> </div> - <div className="text-center"> - <div className="fw-bold h5 mb-0"> + <div className={styles.summaryItem}> + <div className={styles.summaryNumber}> {analysis.paid.length}/{analysis.happened.length} </div> - <div className="text-muted small">Zaplaceno</div> + <div className={styles.summaryLabel}>Zaplaceno</div> </div> </div> {analysis.monthlyData.length > 0 && ( - <div className="border-top mt-2 pt-3"> + <div className={styles.chartDivider}> <ResponsiveContainer width="100%" height={190}> <BarChart data={analysis.monthlyData} margin={CHART_MARGIN}> <CartesianGrid diff --git a/frontend/src/components/ClientPhone.tsx b/frontend/src/components/ClientPhone.tsx index eba65281..79974416 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" @@ -28,7 +29,7 @@ const ClientPhone: React.FC<Props> = ({ phone, icon = false }) => { <FontAwesomeIcon flip="horizontal" icon={faPhone} - className="align-middle me-1" + className={iconBeforeText} /> )} {prettyPhone(phone)} diff --git a/frontend/src/components/ClientsList.tsx b/frontend/src/components/ClientsList.tsx index 8023bd81..4be22a8c 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<Props> = ({ memberships = [] }) => { if (!memberships.length) { - return <span className="text-muted">žádní členové</span> + return <span className={dimmedText}>žádní členové</span> } const clientComponents = memberships.map((membership) => ( <ClientName client={membership.client} key={membership.client.id} link /> diff --git a/frontend/src/components/CourseCircle.tsx b/frontend/src/components/CourseCircle.tsx index 89a9b8a0..4d3e4d5b 100644 --- a/frontend/src/components/CourseCircle.tsx +++ b/frontend/src/components/CourseCircle.tsx @@ -1,8 +1,8 @@ +import { Tooltip } from "@mantine/core" 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. */ @@ -18,27 +18,19 @@ type Props = { /** Komponenta zobrazující barevné kolečko s různou barvou a velikostí pro zobrazení barvy kurzu. */ const CourseCircle: React.FC<Props> = ({ color, size, showTitle = false, className }) => { const sizeWithUnit = `${size}rem` - const colorWithoutHash = color.substring(1) - - return ( - <> - <span - data-qa="course_color" - className={classNames(styles.courseCircle, className)} - id={`CourseCircle_${colorWithoutHash}`} - style={{ - background: color, - width: sizeWithUnit, - height: sizeWithUnit, - }} - /> - {showTitle && ( - <UncontrolledTooltipWrapper target={`CourseCircle_${colorWithoutHash}`}> - Kód barvy: {color} - </UncontrolledTooltipWrapper> - )} - </> + const circle = ( + <span + data-qa="course_color" + className={classNames(styles.courseCircle, className)} + style={{ + background: color, + width: sizeWithUnit, + height: sizeWithUnit, + }} + /> ) + + return showTitle ? <Tooltip label={`Kód barvy: ${color}`}>{circle}</Tooltip> : circle } export default CourseCircle diff --git a/frontend/src/components/DashboardDay.css.ts b/frontend/src/components/DashboardDay.css.ts index 57324160..f7be58e4 100644 --- a/frontend/src/components/DashboardDay.css.ts +++ b/frontend/src/components/DashboardDay.css.ts @@ -1,16 +1,18 @@ import { createThemeContract, globalStyle, style } from "@vanilla-extract/css" +import { vars } from "../theme/tokens" + export const dashboardDayVars = createThemeContract({ courseBackground: "", }) export const lectureGroup = style({ - backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + backgroundColor: vars.bg.surface, }) export const dashboardDayDate = style({ - borderBottom: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", - backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + borderBottom: vars.borderShort.default, + backgroundColor: vars.bg.surface, padding: "0.8rem 0.85rem", color: "light-dark(#0f172a, var(--mantine-color-gray-1))", }) @@ -56,10 +58,10 @@ export const lectureFree = style({ export const dashboardDayWrapper = style({ position: "relative", - border: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + border: vars.borderShort.default, borderRadius: "0.55rem", - boxShadow: "0 14px 30px rgb(15 23 42 / 0.08), 0 4px 12px rgb(15 23 42 / 0.06)", - backgroundColor: "light-dark(#ffffff, var(--mantine-color-dark-7))", + boxShadow: vars.shadow.card, + backgroundColor: vars.bg.surface, overflow: "hidden", }) @@ -69,7 +71,7 @@ export const floatEnd = style({ export const dashboardDayItem = style({ transition: "background-color 0.15s ease-in-out", - borderTop: "1px solid light-dark(#d6dee9, var(--mantine-color-dark-4))", + borderTop: vars.borderShort.default, selectors: { "&:hover": { backgroundColor: "light-dark(rgb(241 245 249 / 0.85), var(--mantine-color-dark-6))", diff --git a/frontend/src/components/DashboardDay.tsx b/frontend/src/components/DashboardDay.tsx index f936e817..7f177a33 100644 --- a/frontend/src/components/DashboardDay.tsx +++ b/frontend/src/components/DashboardDay.tsx @@ -16,6 +16,7 @@ import { prettyTime, toISODate, } from "../global/funcDateTime" +import { inlineBlockNowrap, mb0 } from "../global/utility.css" import { courseDuration } from "../global/utils" import { DEFAULT_DELAY, useDelayedValue } from "../hooks/useDelayedValue" @@ -133,12 +134,13 @@ const DashboardDay: React.FC<Props> = (props) => { className={`${styles.dashboardDayDate}${isToday(getDate()) ? ` ${styles.dashboardDayDateToday}` : ""}`}> <Title order={4} - className={ + className={classNames( isUserCelebratingResult === USER_CELEBRATION.NOTHING ? styles.celebrationNone - : "celebration" - } - style={{ marginBottom: 0, display: "inline-block", whiteSpace: "nowrap" }}> + : "celebration", + mb0, + inlineBlockNowrap, + )}> <Celebration isUserCelebratingResult={isUserCelebratingResult} /> {title} = ({ group, title, bold }) => ( ( - {children} + {children} )}> {title && "Skupina "} {group.name} @@ -64,7 +64,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 2f845bbb..eccff82b 100644 --- a/frontend/src/components/Heading.tsx +++ b/frontend/src/components/Heading.tsx @@ -1,6 +1,8 @@ import { Group, Loader, Title } from "@mantine/core" import * as React from "react" +import { iconAfterText } from "../global/utility.css" + import * as styles from "./Heading.css" type Props = { @@ -30,7 +32,7 @@ const Heading: React.FC = ({ title, buttons, fluid = false, isFetching = size="xs" type="dots" color="gray" - style={{ marginLeft: "0.5rem", verticalAlign: "middle" }} + className={iconAfterText} data-qa="loading" /> )} diff --git a/frontend/src/components/Loading.css.ts b/frontend/src/components/Loading.css.ts new file mode 100644 index 00000000..ee8e11cd --- /dev/null +++ b/frontend/src/components/Loading.css.ts @@ -0,0 +1,31 @@ +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: "var(--mantine-color-blue-6)", +}) + +export const text = style({ + marginTop: "0.45rem", + 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 a523a2c1..bb7179ab 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/Notification.tsx b/frontend/src/components/Notification.tsx index 6badfbdd..80702f0b 100644 --- a/frontend/src/components/Notification.tsx +++ b/frontend/src/components/Notification.tsx @@ -1,5 +1,6 @@ import * as React from "react" +import { mb0 } from "../global/utility.css" import { ErrMsg } from "../types/types" type Props = { @@ -9,7 +10,7 @@ type Props = { /** Komponenta zobrazující obsah notifikace. */ const Notification: React.FC = ({ text = "" }) => { - return

{text}

+ return

{text}

} export default Notification diff --git a/frontend/src/components/PrepaidCounters.css.ts b/frontend/src/components/PrepaidCounters.css.ts index d5fa3295..b5af2b75 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 { vars } from "../theme/tokens" + +export const memberCard = style({ + border: vars.borderShort.default, + borderRadius: "0.55rem", + boxShadow: "0 12px 24px rgb(15 23 42 / 0.08)", + backgroundColor: vars.bg.surface, + 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", fontWeight: 600, }) export const prepaidCountersInputGroupLabel = style({ - backgroundColor: "#28a745", + backgroundColor: "var(--mantine-color-green-7)", color: "white", }) diff --git a/frontend/src/components/PrepaidCounters.tsx b/frontend/src/components/PrepaidCounters.tsx index bc801768..f758d90d 100644 --- a/frontend/src/components/PrepaidCounters.tsx +++ b/frontend/src/components/PrepaidCounters.tsx @@ -1,18 +1,8 @@ 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" @@ -20,8 +10,7 @@ import { MembershipType } from "../types/models" import ClientName from "./ClientName" import * as styles from "./PrepaidCounters.css" -import Tooltip from "./Tooltip" -import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper" +import Tooltip2 from "./Tooltip" type Props = { /** Pole se členstvími všech klientů. */ @@ -58,7 +47,6 @@ const PrepaidCounters: React.FC = (props) => { 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 @@ -75,55 +63,51 @@ 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 && ( + <Tooltip2 + text={TEXTS.WARNING_INACTIVE_CLIENT_GROUP} + size="1x" + /> + )} + + + 0, - })}> - + } + /> + +
+ ))} {props.memberships.length === 0 && ( -

Žádní účastníci

+ Žádní účastníci )} -
+
) } diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index 3b65ac64..ec55b1df 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,42 +1,35 @@ 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 { 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"] + placement?: TooltipProps["position"] /** Ikona zobrazená jako trigger Tooltipu (výchozí: faInfoCircle). */ icon?: FontAwesomeIconProps["icon"] } -/** Komponenta pro zobrazení titulku po najetí myší nad daný element. */ +/** Komponenta pro zobrazení info ikony s titulkem po najetí myší. */ const Tooltip: React.FC = ({ - postfix, text, size = "lg", placement = "bottom", icon = faInfoCircle, }) => ( - <> - - {text} - - - + + + + + ) export default Tooltip diff --git a/frontend/src/components/buttons/CustomButton.tsx b/frontend/src/components/buttons/CustomButton.tsx index 9d3b53f4..c5a5ef88 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/EditButton.tsx b/frontend/src/components/buttons/EditButton.tsx index f769efa8..83c954f6 100644 --- a/frontend/src/components/buttons/EditButton.tsx +++ b/frontend/src/components/buttons/EditButton.tsx @@ -1,36 +1,35 @@ 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 a7eded2d..6864760f 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,14 @@ const SubmitButton: React.FC = ({ ...props }) => ( ) diff --git a/frontend/src/components/charts.css.ts b/frontend/src/components/charts.css.ts index d138ce96..8749f805 100644 --- a/frontend/src/components/charts.css.ts +++ b/frontend/src/components/charts.css.ts @@ -1,16 +1,18 @@ import { style } from "@vanilla-extract/css" +import { vars } from "../theme/tokens" + export const chartBaseStyles = { - border: "1px solid #dee2e6", - borderRadius: "0.375rem", - backgroundColor: "#fff", + border: vars.borderShort.default, + borderRadius: "0.55rem", + backgroundColor: vars.bg.surface, } export const chartTooltip = style({ ...chartBaseStyles, - boxShadow: "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)", + boxShadow: "0 8px 20px rgb(15 23 42 / 0.1)", padding: "0.5rem 0.75rem", lineHeight: 1.5, - color: "#212529", + color: vars.text.primary, fontSize: "0.8rem", }) diff --git a/frontend/src/forms/FormBase.css.ts b/frontend/src/forms/FormBase.css.ts new file mode 100644 index 00000000..8127677c --- /dev/null +++ b/frontend/src/forms/FormBase.css.ts @@ -0,0 +1,289 @@ +import { globalStyle, style } from "@vanilla-extract/css" + +import { vars } from "../theme/tokens" + +globalStyle("form[data-qa^='form_'] .mantine-Modal-header", { + borderBottom: "1px solid light-dark(#edf2f7, var(--mantine-color-dark-4))", + backgroundColor: vars.bg.surface, + padding: "1rem 1.25rem 0.95rem", +}) + +globalStyle(".mantine-Modal-content:has(form[data-qa^='form_'])", { + boxSizing: "border-box", + display: "flex", + flexDirection: "column", + /** + * Nesaž `minWidth: 0` na celý bílý box — v kombinaci s flex uvnitř + * `.mantine-Modal-inner` to při méně obsahu (Přidat vs. Úprava) zúží okno + * jen na šířku textu, zatímco u delšího formuláře vypadá širší. Scrollování + * řeší `Modal.Body` s `minHeight: 0`. + */ + border: 0, + borderRadius: "1rem", + boxShadow: + "0 18px 48px rgb(15 23 42 / 0.16), 0 6px 18px rgb(15 23 42 / 0.08), 0 0 0 1px light-dark(rgb(226 232 240 / 0.85), rgb(60 70 90 / 0.6))", + backgroundColor: vars.bg.surface, + width: "100%", + maxHeight: "calc(100dvh - 2rem)", + overflow: "hidden", +}) + +globalStyle(".mantine-Modal-content:has(form[data-qa^='form_']) > .mantine-Modal-body", { + display: "flex", + flex: 1, + flexDirection: "column", + padding: 0, + minHeight: 0, + overflow: "hidden", +}) + +globalStyle(".mantine-Modal-content:has(form[data-qa^='form_']) form[data-qa^='form_']", { + display: "flex", + flex: 1, + flexDirection: "column", + width: "100%", + minHeight: 0, +}) + +globalStyle("form[data-qa^='form_'] .mantine-Modal-title", { + lineHeight: 1.35, + letterSpacing: "-0.015em", + color: vars.text.primary, + fontSize: "1rem", + fontWeight: 600, +}) + +globalStyle(".mantine-Modal-content form[data-qa^='form_'] .mantine-Modal-body", { + flex: "1 1 auto", + backgroundColor: vars.bg.surface, + padding: "1rem 1.25rem 0.95rem", + minWidth: 0, + minHeight: 0, + overflowY: "auto", +}) + +globalStyle("form[data-qa^='form_'] .mantine-Modal-close", { + borderRadius: "0.8rem", + color: "light-dark(#64748b, var(--mantine-color-dark-2))", +}) + +globalStyle("form[data-qa^='form_'] .mantine-Modal-close:hover", { + backgroundColor: "light-dark(#f1f5f9, var(--mantine-color-dark-5))", + color: vars.text.primary, +}) + +globalStyle("form[data-qa^='form_'] .mantine-Modal-body hr", { + opacity: 1, + margin: "1.1rem 0", + borderColor: "light-dark(#edf2f7, var(--mantine-color-dark-4))", +}) + +globalStyle( + "form[data-qa^='form_'] .mantine-Input-input, form[data-qa^='form_'] .mantine-Select-input, form[data-qa^='form_'] .mantine-Textarea-input", + { + transition: + "border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, background-color 0.15s ease-in-out, transform 0.15s ease-in-out", + borderRadius: "0.9rem", + borderColor: "light-dark(#d7dee8, var(--mantine-color-dark-4))", + backgroundColor: vars.bg.elevated, + minHeight: "2.85rem", + }, +) + +globalStyle( + "form[data-qa^='form_'] .mantine-InputWrapper-label, form[data-qa^='form_'] .mantine-Textarea-label", + { + marginBottom: "0.35rem", + lineHeight: 1.35, + color: vars.text.primary, + fontSize: "0.84rem", + fontWeight: 600, + }, +) + +globalStyle( + "form[data-qa^='form_'] .mantine-InputWrapper-description, form[data-qa^='form_'] .mantine-Textarea-description", + { + marginTop: "0.35rem", + lineHeight: 1.35, + color: "light-dark(#6b7280, var(--mantine-color-dark-2))", + fontSize: "0.75rem", + }, +) + +globalStyle( + "form[data-qa^='form_'] .mantine-Input-input:hover, form[data-qa^='form_'] .mantine-Select-input:hover, form[data-qa^='form_'] .mantine-Textarea-input:hover", + { + borderColor: "light-dark(#c8d2df, var(--mantine-color-dark-3))", + backgroundColor: vars.bg.elevated, + }, +) + +globalStyle( + "form[data-qa^='form_'] .mantine-Input-input:focus, form[data-qa^='form_'] .mantine-Select-input:focus, form[data-qa^='form_'] .mantine-Textarea-input:focus", + { + borderColor: "#228be6", + boxShadow: "0 0 0 0.18rem rgb(34 139 230 / 0.16)", + backgroundColor: vars.bg.elevated, + }, +) + +globalStyle("form[data-qa^='form_'] .mantine-Checkbox-label", { + color: vars.text.primary, + fontWeight: 500, +}) + +/** Sjednotí šířku s `Modal` size u klienta/skupiny: Přidat i Upravit stejně široké. */ +export const modalContentClientGroup = style({ + boxSizing: "border-box", + alignSelf: "stretch", + minWidth: "min(100%, 40rem)", + maxWidth: "100%", +}) + +export const modalActions = style({ + display: "flex", + flexShrink: 0, + flexWrap: "wrap", + gap: "0.55rem", + marginTop: "1.05rem", + borderTop: "1px solid light-dark(#edf2f7, var(--mantine-color-dark-4))", + borderRadius: 0, + backgroundColor: "transparent", + paddingTop: "0.95rem", + paddingRight: 0, + paddingBottom: 0, + paddingLeft: 0, +}) + +export const formContent = style({ + display: "flex", + flexDirection: "column", + gap: "1.05rem", +}) + +export const formSection = style({ + border: "1px solid light-dark(#edf2f7, var(--mantine-color-dark-4))", + borderRadius: "1rem", + boxShadow: "0 1px 2px rgb(15 23 42 / 0.04)", + backgroundColor: vars.bg.elevated, + padding: "1rem 1rem 0.95rem", +}) + +export const formPanel = style({ + border: 0, + borderRadius: 0, + backgroundColor: "transparent", + padding: 0, +}) + +export const formSectionDanger = style({ + border: "1px solid light-dark(#ffe0e5, var(--mantine-color-red-9))", + borderLeft: "3px solid light-dark(#fa8ea0, var(--mantine-color-red-6))", + borderRadius: "1rem", + backgroundColor: "light-dark(#fff8f9, var(--mantine-color-dark-6))", + padding: "0.95rem 1rem", +}) + +export const formSectionTitle = style({ + marginBottom: "0.8rem", + textTransform: "none", + letterSpacing: "-0.01em", + color: vars.text.primary, + fontSize: "0.94rem", + fontWeight: 700, +}) + +export const fieldRow = style({ + marginBottom: "0.85rem", +}) + +export const fieldBlock = style({ + display: "flex", + flexDirection: "column", + gap: "0.45rem", +}) + +export const fieldLabel = style({ + lineHeight: 1.35, + color: vars.text.primary, + fontSize: "0.84rem", + fontWeight: 600, +}) + +export const fieldHint = style({ + lineHeight: 1.35, + color: "light-dark(#6b7280, var(--mantine-color-dark-2))", + fontSize: "0.75rem", +}) + +export const fieldStack = style({ + display: "flex", + flexDirection: "column", + gap: "0.95rem", +}) + +export const inlineCheckboxRow = style({ + display: "flex", + alignItems: "center", + gap: "0.5rem", + minHeight: "2.7rem", +}) + +export const labelCol = style({ + color: "light-dark(#4b5563, var(--mantine-color-gray-4))", + fontWeight: 600, +}) + +globalStyle(`${labelCol} label`, { + display: "inline-block", + marginBottom: "0.2rem", + lineHeight: 1.35, +}) + +export const deleteAlertText = style({ + border: 0, + borderRadius: 0, + backgroundColor: "transparent", + padding: 0, +}) + +globalStyle(`${deleteAlertText} p`, { + marginBottom: "0.65rem", +}) + +globalStyle(`${modalActions} button`, { + minWidth: "unset", +}) + +globalStyle(`${modalActions} .mantine-Button-root`, { + borderRadius: "0.9rem", + minHeight: "2.8rem", + fontWeight: 600, +}) + +globalStyle(`${modalActions} .mantine-Button-root[data-variant='default']`, { + borderColor: vars.border.default, + backgroundColor: vars.bg.elevated, + color: "light-dark(#334155, var(--mantine-color-gray-2))", +}) + +globalStyle(`${modalActions} .mantine-Button-root[data-variant='default']:hover`, { + backgroundColor: "light-dark(#f8fafc, var(--mantine-color-dark-5))", +}) + +globalStyle(`${modalActions} [type='submit']`, { + boxShadow: "0 10px 18px rgb(34 139 230 / 0.18)", +}) + +globalStyle(`${modalActions} [type='submit']:hover`, { + transform: "translateY(-1px)", +}) + +globalStyle(`${modalActions} > *`, { + "@media": { + "(max-width: 575.98px)": { + flex: "0 0 auto", + }, + }, +}) diff --git a/frontend/src/forms/FormClients.tsx b/frontend/src/forms/FormClients.tsx index 70df21ec..be2cabd4 100644 --- a/frontend/src/forms/FormClients.tsx +++ b/frontend/src/forms/FormClients.tsx @@ -1,17 +1,5 @@ +import { Checkbox, Group, Modal, SimpleGrid, Textarea, TextInput, Title } from "@mantine/core" import * as React from "react" -import { - Alert, - Col, - Form, - FormGroup, - Input, - InputGroup, - InputGroupText, - Label, - ModalBody, - ModalFooter, - ModalHeader, -} from "reactstrap" import { AnalyticsSource, trackEvent } from "../analytics" import { useCreateClient, useDeleteClient, useUpdateClient } from "../api/hooks" @@ -26,6 +14,8 @@ import { ModalClientsData } from "../types/components" import { ClientPostApiDummy, ClientType } from "../types/models" import { fEmptyVoid } from "../types/types" +import * as styles from "./FormBase.css" + type Props = { /** Klient. */ client: ClientType | ClientPostApiDummy @@ -49,48 +39,40 @@ const FormClients: React.FC = (props) => { const updateClient = useUpdateClient() const deleteClient = useDeleteClient() - /** Křestní jméno klienta. */ const [firstname, setFirstname] = React.useState(props.client.firstname) - /** Příjmení klienta. */ const [surname, setSurname] = React.useState(props.client.surname) - /** E-mail klienta. */ const [email, setEmail] = React.useState(props.client.email) - /** Telefonní číslo klienta. */ const [phone, setPhone] = React.useState(prettyPhone(props.client.phone)) - /** Poznámka ke klientovi. */ const [note, setNote] = React.useState(props.client.note) - /** Klient je aktivní (true). */ const [active, setActive] = React.useState(props.client.active) - const onChange = (e: React.ChangeEvent): void => { + const onChange = (e: React.ChangeEvent): void => { props.setFormDirty() const target = e.currentTarget - let value = target.type === "checkbox" ? target.checked : target.value - // pri psani rozdeluj cislo na trojice + const value = target.value if (target.id === "phone") { - value = (value as string) - .replace(/([0-9]{3})([^\s])/, "$1 $2") - .replace(/([0-9]{3}) ([0-9]{3})([^\s])/, "$1 $2 $3") - setPhone(value) - } - // nastav velke pocatecni pismeno ve jmenu i prijmeni klienta - else if (target.id === "firstname") { - value = capitalizeString(value as string) - setFirstname(value) + const formatted = value + .replace(/(\d{3})([^\s])/, "$1 $2") + .replace(/(\d{3}) (\d{3})([^\s])/, "$1 $2 $3") + setPhone(formatted) + } else if (target.id === "firstname") { + setFirstname(capitalizeString(value)) } else if (target.id === "surname") { - value = capitalizeString(value as string) - setSurname(value) + setSurname(capitalizeString(value)) } else if (target.id === "email") { - setEmail(value as string) + setEmail(value) } else if (target.id === "note") { - setNote(value as string) - } else if (target.id === "active") { - setActive(value as boolean) + setNote(value) } } + const onActiveChange = (e: React.ChangeEvent): void => { + props.setFormDirty() + setActive(e.currentTarget.checked) + } + const onSubmit = React.useCallback( - (e: React.FormEvent): void => { + (e: React.SyntheticEvent): void => { // stopPropagation, aby nedoslo k propagaci submit na nadrazene formulare pri vnoreni modalnich oken e.stopPropagation() e.preventDefault() @@ -140,157 +122,140 @@ const FormClients: React.FC = (props) => { const isSubmit = createClient.isPending || updateClient.isPending return ( -
- - {isClient(props.client) ? "Úprava" : "Přidání"} klienta:{" "} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {" "} - {!active && ( - - )} - - - {isClient(props.client) && ( - <> -
- - - - -

- Klienta lze smazat pouze pokud nemá žádné lekce, smažou se - také všechny jeho zájmy o kurzy a členství ve skupinách -

- { - if ( - isClient(props.client) && - globalThis.confirm( - `Opravdu chcete smazat klienta ${firstname} ${surname}?`, - ) - ) { - handleDelete(props.client.id) - } - }} - data-qa="button_delete_client" + + + + {isClient(props.client) ? "Úprava" : "Přidání"} klienta:{" "} + + + + + +
+
+ Základní údaje +
+ +
+ +
+
+ +
+
+
+ +
+
+ +420} + /> +
+
+