⚠ Načítání trvá příliš dlouho, mohlo dojít k chybě. Zkuste stránku načíst
znovu.
diff --git a/frontend/src/components/Menu.css.ts b/frontend/src/components/Menu.css.ts
index f198b9544..418342b3f 100644
--- a/frontend/src/components/Menu.css.ts
+++ b/frontend/src/components/Menu.css.ts
@@ -1,7 +1,52 @@
import { globalStyle, style } from "@vanilla-extract/css"
-globalStyle(".active", {
- fontWeight: "bold",
+import { vars } from "../theme/tokens"
+
+export const navLink = style({
+ display: "block",
+ transition: "all 0.15s ease-in-out",
+ borderRadius: vars.radius.md,
+ padding: "0.5rem 0.85rem",
+ textDecoration: "none",
+ color: "rgb(241 245 249 / 0.78)",
+ fontWeight: 500,
+ ":hover": {
+ backgroundColor: "rgb(255 255 255 / 0.12)",
+ textDecoration: "none",
+ color: "#ffffff",
+ },
+ // plny outline misto poloprusvitneho stinu - ring s alpha 0.45 mel vuci tmavemu
+ // navbaru jen ~2.3:1 (pod WCAG 1.4.11); blue-3 na #1f2b3c dava >3:1 a outline
+ // prezije i forced-colors rezim
+ ":focus-visible": {
+ outline: "2px solid var(--mantine-color-blue-3)",
+ outlineOffset: "2px",
+ },
+ "@media": {
+ "(min-width: 992px)": {
+ borderBottom: "2px solid transparent",
+ borderRadius: 0,
+ backgroundColor: "transparent",
+ padding: "0.75rem 0.5rem 0.625rem",
+ ":hover": {
+ borderBottomColor: "rgb(255 255 255 / 0.6)",
+ backgroundColor: "transparent",
+ color: "#ffffff",
+ },
+ },
+ },
+})
+
+globalStyle(`.active${navLink}`, {
+ backgroundColor: "rgb(255 255 255 / 0.18)",
+ color: "#ffffff",
+ fontWeight: 600,
+ "@media": {
+ "(min-width: 992px)": {
+ borderBottomColor: "#ffffff",
+ backgroundColor: "transparent",
+ },
+ },
})
export const navExternalLink = style({
@@ -12,15 +57,83 @@ globalStyle(`${navExternalLink} svg`, {
marginLeft: "0.2em",
})
-globalStyle(".navbar.navbar-expand-lg .btn", {
+export const navList = style({
+ display: "flex",
+ flexDirection: "column",
+ margin: 0,
+ padding: 0,
+ width: "100%",
+ listStyle: "none",
+ "@media": {
+ "(min-width: 992px)": {
+ flexDirection: "row",
+ gap: "0.25rem",
+ marginLeft: "auto",
+ width: "auto",
+ },
+ },
+})
+
+export const logoutButton = style({
+ // sjednoceny focus ring s navLink/spotlightButton: vychozi indigo ring Mantine
+ // (.mantine-focus-auto) ma vuci tmavemu navbaru jen ~3:1 (hranicni 1.4.11), blue-3
+ // je vyrazne viditelnejsi; vyssi specificita prebiji Mantine ring i pri shode poradi
+ selectors: {
+ "&.mantine-focus-auto:focus-visible": {
+ outline: "2px solid var(--mantine-color-blue-3)",
+ outlineOffset: "2px",
+ },
+ },
"@media": {
"(min-width: 992px)": {
- // pri klasickem menu pridej levou mezeru k odhlasovacimu tlacitku
marginLeft: "0.5rem",
},
"(max-width: 991.98px)": {
- // pri hamburger menu pridej horni mezeru k odhlasovacimu tlacitku
marginTop: "0.5rem",
+ width: "100%",
},
},
})
+
+export const spotlightButton = style({
+ display: "flex",
+ alignItems: "center",
+ gap: "0.5rem",
+ transition: "all 0.15s ease-in-out",
+ borderRadius: vars.radius.md,
+ padding: "0.4rem 0.75rem",
+ color: "rgb(241 245 249 / 0.78)",
+ fontSize: "0.875rem",
+ ":hover": {
+ backgroundColor: "rgb(255 255 255 / 0.12)",
+ color: "#ffffff",
+ },
+ // plny outline misto poloprusvitneho stinu - ring s alpha 0.45 mel vuci tmavemu
+ // navbaru jen ~2.3:1 (pod WCAG 1.4.11); blue-3 na #1f2b3c dava >3:1 a outline
+ // prezije i forced-colors rezim
+ ":focus-visible": {
+ outline: "2px solid var(--mantine-color-blue-3)",
+ outlineOffset: "2px",
+ },
+ "@media": {
+ "(min-width: 992px)": {
+ marginRight: "0.75rem",
+ border: "1px solid rgb(255 255 255 / 0.2)",
+ width: "18rem",
+ maxWidth: "30vw",
+ },
+ "(max-width: 991.98px)": {
+ marginBottom: "0.25rem",
+ padding: "0.5rem 0.85rem",
+ width: "100%",
+ },
+ },
+})
+
+export const spotlightButtonLabel = style({
+ flex: 1,
+ overflow: "hidden",
+ textAlign: "left",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+})
diff --git a/frontend/src/components/Menu.tsx b/frontend/src/components/Menu.tsx
index 0adea4451..12db15172 100644
--- a/frontend/src/components/Menu.tsx
+++ b/frontend/src/components/Menu.tsx
@@ -1,25 +1,23 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
-import { faExternalLink } from "@rodlukas/fontawesome-pro-solid-svg-icons"
+import { Button, Kbd, UnstyledButton } from "@mantine/core"
+import { spotlight } from "@mantine/spotlight"
+import { faExternalLink, faSearch } from "@rodlukas/fontawesome-pro-solid-svg-icons"
import { Link, LinkProps } from "@tanstack/react-router"
import classNames from "classnames"
import * as React from "react"
-import { Button, Nav, NavItem, NavLink } from "reactstrap"
import APP_URLS from "../APP_URLS"
import AuthChecking from "../auth/AuthChecking"
import { useAuthContext } from "../auth/AuthContext"
+import { isApplePlatform } from "../global/utils"
import { fEmptyVoid, QA } from "../types/types"
+import ColorSchemeToggle from "./ColorSchemeToggle"
import * as styles from "./Menu.css"
-import SearchInput from "./SearchInput"
type Props = {
/** Funkce pro zavření otevřeného hamburger menu. */
closeNavbar: fEmptyVoid
- /** Funkce, která se zavolá při úpravě vyhledávaného výrazu. */
- onSearchChange: (newSearchVal: string) => void
- /** Hledaný výraz. */
- searchVal: string
}
type MyNavLinkProps = {
@@ -42,19 +40,22 @@ const MyNavLink: React.FC = ({
onClick={onCloseNavbar}
activeOptions={{ exact }}
activeProps={{
- className: classNames("nav-link", className, activeClassName),
+ className: classNames(styles.navLink, className, activeClassName),
}}
inactiveProps={{
- className: classNames("nav-link", className),
+ className: classNames(styles.navLink, className),
}}
/>
)
+// zkratka zobrazená v UI i v aria-labelu musí odpovídat skutečné klávese
+// na dané platformě (mod = ⌘ na Apple platformách, jinde Ctrl)
+const spotlightShortcutLabel = isApplePlatform() ? "⌘K" : "Ctrl K"
+
/** Komponenta zobrazující menu aplikace pro přihlášené uživatele. */
const Menu: React.FC = (props) => {
const authContext = useAuthContext()
const onClickLogout = () => {
- // pri odhlaseni chceme zavrit menu
props.closeNavbar()
authContext.logout()
}
@@ -63,76 +64,89 @@ const Menu: React.FC = (props) => {
<>
{authContext.isAuth && (
<>
-
-
-
+
+
+
+ Hledat klienta, skupinu...
+
+ {spotlightShortcutLabel}
+
+
-
+
+
Diář
-
-
+
+
Klienti
-
-
+
+
Skupiny
-
-
+
+
Zájemci
-
-
+
+
Statistiky
-
-
+
+
Nastavení
-
-
-
+
+
+ className={classNames(styles.navLink, styles.navExternalLink)}>
Web
-
-
-
-
+
+
+
+
+
Odhlásit
diff --git a/frontend/src/components/NoInfo.tsx b/frontend/src/components/NoInfo.tsx
index 225938c56..f39902788 100644
--- a/frontend/src/components/NoInfo.tsx
+++ b/frontend/src/components/NoInfo.tsx
@@ -1,3 +1,4 @@
+import { Text } from "@mantine/core"
import * as React from "react"
import { QA } from "../types/types"
@@ -6,9 +7,9 @@ type Props = QA
/** Komponenta pro jednotné zobrazení nevyplněného údaje napříč aplikací. */
const NoInfo: React.FC = (props) => (
-
+
---
-
+
)
export default NoInfo
diff --git a/frontend/src/components/Notification.tsx b/frontend/src/components/Notification.tsx
deleted file mode 100644
index 6badfbdd3..000000000
--- a/frontend/src/components/Notification.tsx
+++ /dev/null
@@ -1,15 +0,0 @@
-import * as React from "react"
-
-import { ErrMsg } from "../types/types"
-
-type Props = {
- /** Element s chybovou zprávou. */
- text?: ErrMsg
-}
-
-/** Komponenta zobrazující obsah notifikace. */
-const Notification: React.FC = ({ text = "" }) => {
- return {text}
-}
-
-export default Notification
diff --git a/frontend/src/components/PrepaidCounters.css.ts b/frontend/src/components/PrepaidCounters.css.ts
index d5fa32954..8e9ab991a 100644
--- a/frontend/src/components/PrepaidCounters.css.ts
+++ b/frontend/src/components/PrepaidCounters.css.ts
@@ -1,11 +1,28 @@
import { style } from "@vanilla-extract/css"
+import { surfaceCard } from "../global/surfaces.css"
+import { vars } from "../theme/tokens"
+
+export const memberCard = style([
+ surfaceCard,
+ {
+ padding: "0.8rem",
+ height: "100%",
+ },
+])
+
+export const memberHeading = style({
+ marginBottom: "0.55rem",
+ color: vars.text.primary,
+ fontSize: "1.02rem",
+})
+
export const prepaidCountersInput = style({
- minWidth: "3.75rem !important",
+ minWidth: "3.75rem",
fontWeight: 600,
})
export const prepaidCountersInputGroupLabel = style({
- backgroundColor: "#28a745",
+ backgroundColor: vars.colors.successSolid,
color: "white",
})
diff --git a/frontend/src/components/PrepaidCounters.test.tsx b/frontend/src/components/PrepaidCounters.test.tsx
new file mode 100644
index 000000000..c14301a4d
--- /dev/null
+++ b/frontend/src/components/PrepaidCounters.test.tsx
@@ -0,0 +1,209 @@
+import { MantineProvider } from "@mantine/core"
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { RouterProvider } from "@tanstack/react-router"
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
+import * as React from "react"
+
+import { createQueryClient } from "../api/queryClient"
+import MembershipService from "../api/services/MembershipService"
+import { createTestRouter } from "../testUtils/createTestRouter"
+import { MembershipType } from "../types/models"
+
+import PrepaidCounters from "./PrepaidCounters"
+
+// mock service vrstvy - testy tak ridi okamzik a poradi resolvnuti PATCH requestu
+// (useMutation z TanStack Query vcetne onSuccess/onError callbacku zustava realny)
+vi.mock("../api/services/MembershipService", () => ({
+ default: {
+ patch: vi.fn(),
+ },
+}))
+
+const patchMock = vi.mocked(MembershipService).patch
+
+/** Promise s rucne ovladatelnym resolvnutim - simulace PATCH requestu "v letu". */
+function createDeferred(): {
+ promise: Promise
+ resolve: (value: MembershipType) => void
+} {
+ let resolve!: (value: MembershipType) => void
+ const promise = new Promise((res) => {
+ resolve = res
+ })
+ return { promise, resolve }
+}
+
+function createMembership(id: number, prepaidCnt: number): MembershipType {
+ return {
+ id,
+ prepaid_cnt: prepaidCnt,
+ client: {
+ id: id + 100,
+ active: true,
+ email: "",
+ note: "",
+ phone: "",
+ firstname: `Jmeno${id}`,
+ surname: `Prijmeni${id}`,
+ last_lecture_date: null,
+ },
+ }
+}
+
+/**
+ * Vyrenderuje PrepaidCounters a vrati setter membershipu - ten simuluje refetch,
+ * ktery komponente zvenku doruci novou (treba i zastaralou) verzi dat.
+ */
+async function renderPrepaidCounters(memberships: MembershipType[]): Promise<{
+ queryClient: QueryClient
+ refetchMemberships: (next: MembershipType[]) => void
+}> {
+ const queryClient = createQueryClient()
+ let setMemberships: (next: MembershipType[]) => void = () => undefined
+ const Wrapper: React.FC = () => {
+ const [current, setCurrent] = React.useState(memberships)
+ setMemberships = setCurrent
+ return
+ }
+ const router = await createTestRouter(
+
+
+ ,
+ )
+ render(
+
+
+ ,
+ )
+ return {
+ queryClient,
+ refetchMemberships: (next): void => {
+ act(() => {
+ setMemberships(next)
+ })
+ },
+ }
+}
+
+/** Počká, až se všechny mutace usadí (PATCH doběhl včetně onSuccess/onError). */
+async function waitForMutationsSettled(queryClient: QueryClient): Promise {
+ await waitFor(() => expect(queryClient.isMutating()).toBe(0))
+}
+
+/**
+ * Propustí frontu (micro)tasků - TanStack Query volá mutationFn asynchronně, takže assert
+ * "PATCH se NEodeslal" by bez toho prošel falešně pozitivně.
+ */
+async function flushAsync(): Promise {
+ await act(async () => {
+ await new Promise((resolve) => {
+ globalThis.setTimeout(resolve, 0)
+ })
+ })
+}
+
+beforeEach(() => {
+ patchMock.mockReset()
+})
+
+test("renders a counter input with the initial value for each membership", async () => {
+ await renderPrepaidCounters([createMembership(1, 3), createMembership(2, 0)])
+
+ const inputs = screen.getAllByRole("spinbutton")
+ expect(inputs).toHaveLength(2)
+ expect(inputs[0]).toHaveValue(3)
+ expect(inputs[1]).toHaveValue(0)
+ expect(screen.getByText("Prijmeni1")).toBeInTheDocument()
+ expect(screen.getByText("Prijmeni2")).toBeInTheDocument()
+})
+
+test("shows a message when there are no memberships", async () => {
+ await renderPrepaidCounters([])
+
+ expect(screen.queryByRole("spinbutton")).not.toBeInTheDocument()
+ expect(screen.getByText("Žádní účastníci")).toBeInTheDocument()
+})
+
+test("sends PATCH on blur and doesn't repeat it for an unchanged value", async () => {
+ patchMock.mockResolvedValue(createMembership(1, 7))
+ const { queryClient } = await renderPrepaidCounters([createMembership(1, 3)])
+ const input = screen.getByRole("spinbutton")
+
+ // blur bez zmeny hodnoty -> zadny PATCH
+ fireEvent.blur(input)
+ await flushAsync()
+ expect(patchMock).not.toHaveBeenCalled()
+
+ fireEvent.change(input, { target: { value: "7" } })
+ fireEvent.blur(input)
+
+ await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(1))
+ expect(patchMock).toHaveBeenCalledWith({ id: 1, prepaid_cnt: 7 })
+ await waitForMutationsSettled(queryClient)
+ expect(input).toHaveValue(7)
+
+ // server hodnotu potvrdil -> dalsi blur se stejnou hodnotou neposila duplicitni PATCH
+ fireEvent.blur(input)
+ await flushAsync()
+ expect(patchMock).toHaveBeenCalledTimes(1)
+})
+
+test("refetch with stale data doesn't clobber a newer local edit", async () => {
+ const deferred = createDeferred()
+ patchMock.mockReturnValueOnce(deferred.promise)
+ const { queryClient, refetchMemberships } = await renderPrepaidCounters([
+ createMembership(1, 3),
+ ])
+ const input = screen.getByRole("spinbutton")
+
+ // uzivatel ulozi 7 (PATCH zustava v letu) a hned rozepise novou hodnotu 9
+ fireEvent.change(input, { target: { value: "7" } })
+ fireEvent.blur(input)
+ await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(1))
+ fireEvent.change(input, { target: { value: "9" } })
+
+ // mezitim dorazi refetch se zastaralym server stavem (3) -> nesmi prepsat rozepsanou 9
+ refetchMemberships([createMembership(1, 3)])
+ expect(input).toHaveValue(9)
+
+ // doraz odpovedi na PATCH (7) take nesmi prepsat novejsi lokalni hodnotu
+ deferred.resolve(createMembership(1, 7))
+ await waitForMutationsSettled(queryClient)
+ expect(input).toHaveValue(9)
+})
+
+// Pozn.: cast ochrany proti out-of-order odpovedim zajistuje uz TanStack Query v5 -
+// per-mutate onSuccess/onError callbacky vystreli jen pro POSLEDNI mutate() na dane
+// useMutation instanci, starsi (prekonana) mutace zadny callback nedostane. Test pres
+// realny useMutation proto overuje vysledny pozorovatelny kontrakt komponenty, nikoli
+// jen jeji vnitrni guard v onSuccess (ten je timto seamem nedosazitelny).
+test("out-of-order PATCH responses don't overwrite the newer confirmed value", async () => {
+ const firstPatch = createDeferred()
+ const secondPatch = createDeferred()
+ patchMock.mockReturnValueOnce(firstPatch.promise).mockReturnValueOnce(secondPatch.promise)
+ const { queryClient } = await renderPrepaidCounters([createMembership(1, 3)])
+ const input = screen.getByRole("spinbutton")
+
+ // uzivatel ulozi 7 a jeste pred dobehnutim PATCHe ulozi 9 (dva PATCHe v letu)
+ fireEvent.change(input, { target: { value: "7" } })
+ fireEvent.blur(input)
+ await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(1))
+ fireEvent.change(input, { target: { value: "9" } })
+ fireEvent.blur(input)
+ await waitFor(() => expect(patchMock).toHaveBeenCalledTimes(2))
+ expect(patchMock).toHaveBeenNthCalledWith(1, { id: 1, prepaid_cnt: 7 })
+ expect(patchMock).toHaveBeenNthCalledWith(2, { id: 1, prepaid_cnt: 9 })
+
+ // odpovedi dorazi v opacnem poradi: nejdriv novejsi (9), pak starsi (7)
+ secondPatch.resolve(createMembership(1, 9))
+ await waitFor(() => expect(queryClient.isMutating()).toBe(1))
+ firstPatch.resolve(createMembership(1, 7))
+ await waitForMutationsSettled(queryClient)
+ expect(input).toHaveValue(9)
+
+ // server-potvrzena hodnota je 9 (ne 7 ze starsi odpovedi)
+ // -> blur se stejnou hodnotou nesmi vyvolat treti PATCH
+ fireEvent.blur(input)
+ await flushAsync()
+ expect(patchMock).toHaveBeenCalledTimes(2)
+})
diff --git a/frontend/src/components/PrepaidCounters.tsx b/frontend/src/components/PrepaidCounters.tsx
index bc8017688..a55189345 100644
--- a/frontend/src/components/PrepaidCounters.tsx
+++ b/frontend/src/components/PrepaidCounters.tsx
@@ -1,27 +1,16 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { Container, Grid, Text, TextInput, Title, Tooltip } from "@mantine/core"
import { faSackDollar } from "@rodlukas/fontawesome-pro-solid-svg-icons"
import classNames from "classnames"
import * as React from "react"
-import {
- Col,
- Container,
- Input,
- InputGroup,
- InputGroupText,
- Label,
- ListGroup,
- ListGroupItem,
- Row,
-} from "reactstrap"
import { usePatchMembership } from "../api/hooks"
import { TEXTS } from "../global/constants"
import { MembershipType } from "../types/models"
import ClientName from "./ClientName"
+import InfoTooltip from "./InfoTooltip"
import * as styles from "./PrepaidCounters.css"
-import Tooltip from "./Tooltip"
-import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper"
type Props = {
/** Pole se členstvími všech klientů. */
@@ -47,24 +36,118 @@ const PrepaidCounters: React.FC = (props) => {
}, [props.memberships])
const [prepaidCnts, setPrepaidCnts] = React.useState(() => createPrepaidCntObjects())
+ // Posledni server-potvrzena hodnota (aktualizuje se pouze v onSuccess).
+ const serverPrepaidCntsRef = React.useRef(createPrepaidCntObjects())
+ // ID polozek, ktere uzivatel rozepsal (mezi onChange a uspesnym PATCH) — externi refetch
+ // jejich hodnotu neprepise (jinak by uzivatel prisel o rozepsany text).
+ const dirtyIdsRef = React.useRef>(new Set())
+ // Hodnoty, ktere jsou prave v letu na server (mezi mutate a settled). Deduplikujou se,
+ // takze rapid blur/refocus se stejnou hodnotou nevypustí druhy PATCH; a refetch ji
+ // pri merge nesmaze.
+ const inFlightRef = React.useRef({})
React.useEffect(() => {
- setPrepaidCnts(createPrepaidCntObjects())
+ const fresh = createPrepaidCntObjects()
+ // Garbage-collect dirty/in-flight IDs, ktere uz nejsou ve fresh memberships
+ // (smazane / vymenene), at se neukotvi cizi data v ref/state.
+ for (const id of Array.from(dirtyIdsRef.current)) {
+ if (!(id in fresh)) {
+ dirtyIdsRef.current.delete(id)
+ }
+ }
+ for (const id of Object.keys(inFlightRef.current).map(Number)) {
+ if (!(id in fresh)) {
+ delete inFlightRef.current[id]
+ }
+ }
+ // serverRef si drzi server-potvrzenou hodnotu kazdeho ID; pro in-flight nebo dirty
+ // nechame puvodne potvrzenou hodnotu (jinak by se ztratil "previous" pro revert).
+ const nextServer: PrepaidCntObjectsType = {}
+ for (const id of Object.keys(fresh).map(Number)) {
+ if (id in inFlightRef.current || dirtyIdsRef.current.has(id)) {
+ // refetch zachytil stary server state, ale my mame novejsi (in-flight nebo dirty);
+ // ponech predchozi server snapshot, fresh hodnota nas zajimat nesmi.
+ nextServer[id] = serverPrepaidCntsRef.current[id] ?? fresh[id]
+ } else {
+ nextServer[id] = fresh[id]
+ }
+ }
+ serverPrepaidCntsRef.current = nextServer
+ setPrepaidCnts((prev) => {
+ const merged: PrepaidCntObjectsType = { ...fresh }
+ // pro dirty / in-flight polozky zachovej rozpracovanou uzivatelskou hodnotu
+ for (const id of dirtyIdsRef.current) {
+ if (id in prev) {
+ merged[id] = prev[id]
+ }
+ }
+ for (const id of Object.keys(inFlightRef.current).map(Number)) {
+ if (id in prev) {
+ merged[id] = prev[id]
+ }
+ }
+ return merged
+ })
}, [createPrepaidCntObjects])
- const onChange = React.useCallback(
- (e: React.ChangeEvent): void => {
+ // Pozn.: Number("") vrací 0, vymazané pole se tedy při bluru uloží jako 0 — díky
+ // select-on-focus (viz onFocus) uživatel typicky přepisuje celou hodnotu, vědomě bez guardu.
+ const onChange = React.useCallback((e: React.ChangeEvent): void => {
+ const target = e.currentTarget
+ const value = Number(target.value)
+ const id = Number(target.dataset.id)
+ dirtyIdsRef.current.add(id)
+ setPrepaidCnts((prevPrepaidCnts) => {
+ const newPrepaidCnts = { ...prevPrepaidCnts }
+ newPrepaidCnts[id] = value
+ return newPrepaidCnts
+ })
+ }, [])
+
+ // Commit hodnotu na server az pri blur, ne pri kazdem keystroke.
+ // serverPrepaidCntsRef se aktualizuje az v onSuccess (drzi server-potvrzenou hodnotu).
+ // inFlightRef se aktualizuje pred mutate a maze v onSettled (drzi prave odesilanou hodnotu)
+ // → rapid blur/refocus se stejnou hodnotou nepustí duplicitni PATCH a refetch ji nesmaze.
+ const onBlur = React.useCallback(
+ (e: React.FocusEvent): void => {
const target = e.currentTarget
const value = Number(target.value)
- const id = Number(target.dataset.id!)
- setPrepaidCnts((prevPrepaidCnts) => {
- // vytvorime kopii prepaidCnts (ma jen jednu uroven -> staci melka kopie)
- const newPrepaidCnts = { ...prevPrepaidCnts }
- newPrepaidCnts[id] = value
- return newPrepaidCnts
- })
- const data = { id, prepaid_cnt: value }
- patchMembership.mutate(data)
+ const id = Number(target.dataset.id)
+ const serverValue = serverPrepaidCntsRef.current[id]
+ const inFlightValue = inFlightRef.current[id]
+ const effectiveValue = inFlightValue ?? serverValue
+ if (effectiveValue === value) {
+ // bud se hodnota nezmenila, nebo uz je presne tato hodnota odeslana → ne-op
+ dirtyIdsRef.current.delete(id)
+ return
+ }
+ inFlightRef.current[id] = value
+ patchMembership.mutate(
+ { id, prepaid_cnt: value },
+ {
+ onSuccess: () => {
+ // Pokud uz je v letu novejsi PATCH (uzivatel mezitim zmenil hodnotu
+ // a znovu blurnul), necham vsechno na ten novejsi mutate – jinak by
+ // out-of-order odpoved tohoto PATCHe stale prepsala serverRef i dirty.
+ if (inFlightRef.current[id] !== value) {
+ return
+ }
+ serverPrepaidCntsRef.current = {
+ ...serverPrepaidCntsRef.current,
+ [id]: value,
+ }
+ dirtyIdsRef.current.delete(id)
+ delete inFlightRef.current[id]
+ },
+ onError: () => {
+ // Stejna ochrana: pokud uz je v letu novejsi PATCH, nech mu drzet inFlight.
+ if (inFlightRef.current[id] === value) {
+ delete inFlightRef.current[id]
+ }
+ // dirty zustava → efekt na refetchi UI neprepise a retry projde
+ },
+ },
+ )
},
[patchMembership],
)
@@ -75,55 +158,60 @@ const PrepaidCounters: React.FC = (props) => {
return (
-
+
{props.memberships.map((membership) => (
-
-
-
-
- {" "}
- {props.isGroupActive && !membership.client.active && (
-
- )}
-
-
-
+
+
+ {" "}
+ {props.isGroupActive && !membership.client.active && (
+
+ )}
+
+ {/* focus: obsah tooltipu musí být dosažitelný i z klávesnice (WCAG 1.4.13) */}
+
+ 0,
- })}>
-
+ (prepaidCnts[membership.id] ??
+ membership.prepaid_cnt) > 0,
+ }),
+ }}
+ leftSection={
+
-
-
-
-
-
- Počet předplacených lekcí
-
-
-
-
+
+ }
+ />
+
+
+
))}
{props.memberships.length === 0 && (
- Žádní účastníci
+
+ Žádní účastníci
+
)}
-
+
)
}
diff --git a/frontend/src/components/Search.css.ts b/frontend/src/components/Search.css.ts
deleted file mode 100644
index fc633af64..000000000
--- a/frontend/src/components/Search.css.ts
+++ /dev/null
@@ -1,48 +0,0 @@
-import { keyframes, style } from "@vanilla-extract/css"
-
-const fadeIn = keyframes({
- from: {
- opacity: 0,
- },
- to: {
- opacity: 1,
- },
-})
-
-export const searchOverlay = style({
- position: "fixed",
- zIndex: 1020, // Bootstrap navbar ma z-index 1030
- top: "56px", // vyska navbaru (56px) - desktop
- right: 0,
- bottom: 0,
- left: 0,
- display: "flex",
- alignItems: "flex-start",
- justifyContent: "center",
- backdropFilter: "blur(4px)",
- backgroundColor: "rgba(0, 0, 0, 0.5)",
- cursor: "default",
- overflowY: "auto",
- animation: `${fadeIn} 0.15s ease-out`,
- "@media": {
- "(max-width: 991.98px)": {
- zIndex: 1040, // vyssi nez navbar (1030), aby byl nad rozbalenym menu
- top: "106px", // vyska navbaru (56px) + search input (~50px)
- },
- },
-})
-
-export const searchContainer = style({
- position: "relative",
- zIndex: 1021,
- margin: "0 auto",
- marginBottom: "2rem",
- borderRadius: "0 0 0.375rem 0.375rem",
- boxShadow: "0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 10px rgba(0, 0, 0, 0.05)",
- backgroundColor: "white",
- padding: "0 1rem 2rem",
- width: "100%",
- maxWidth: "900px",
- height: "auto",
- animation: `${fadeIn} 0.2s ease-out`,
-})
diff --git a/frontend/src/components/Search.tsx b/frontend/src/components/Search.tsx
deleted file mode 100644
index b1554e2b9..000000000
--- a/frontend/src/components/Search.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-import { FuseResult } from "fuse.js"
-import * as React from "react"
-import { Badge, CloseButton, Col, Container, ListGroup, ListGroupItem, Row } from "reactstrap"
-
-import { useClientsActiveContext } from "../contexts/ClientsActiveContext"
-import ModalClients from "../forms/ModalClients"
-import { ClientActiveType } from "../types/models"
-import { fEmptyVoid } from "../types/types"
-
-import ClientEmail from "./ClientEmail"
-import ClientName from "./ClientName"
-import ClientPhone from "./ClientPhone"
-import Heading from "./Heading"
-import Loading from "./Loading"
-import * as styles from "./Search.css"
-
-type Props = {
- /** Výsledky vyhledávání klientů. */
- foundResults: FuseResult[]
- /** Hledaný výraz. */
- searchVal: string
- /** Funkce pro zahájení vyhledávání klientů. */
- search: fEmptyVoid
- /** Funkce zrušení vyhledávání klientů. */
- resetSearch: fEmptyVoid
-}
-
-/** Komponenta zobrazující výsledky vyhledávání - seznam klientů. */
-const Search: React.FC = ({ foundResults, searchVal, search, resetSearch }) => {
- const clientsActiveContext = useClientsActiveContext()
- const dialogRef = React.useRef(null)
-
- // Focus trap - zajistí, že focus zůstane uvnitř dialogu
- React.useEffect(() => {
- if (searchVal === "" || !dialogRef.current) {
- return undefined
- }
-
- const dialog = dialogRef.current
- const selector = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
-
- const getFocusableElements = (): HTMLElement[] => {
- return Array.from(dialog.querySelectorAll(selector)).filter(
- (el) => !el.hasAttribute("disabled") && !el.hasAttribute("aria-hidden"),
- )
- }
-
- const handleKeyDown = (e: KeyboardEvent): void => {
- if (e.key !== "Tab") {
- return
- }
-
- // Pokud je otevřený Bootstrap modal, necháme ho zpracovat Tab sám
- const openModal = document.querySelector(".modal.show")
- if (openModal) {
- return
- }
-
- const focusableElements = getFocusableElements()
- if (focusableElements.length === 0) {
- return
- }
-
- const firstElement = focusableElements[0]
- const lastElement = focusableElements.at(-1)!
- const activeElement = document.activeElement as HTMLElement
-
- // Pokud není žádný prvek ve focusu nebo je mimo dialog, nastav focus na první
- if (!activeElement || !dialog.contains(activeElement)) {
- e.preventDefault()
- firstElement.focus()
- return
- }
-
- // Cyklická navigace: poslední -> první (Tab) nebo první -> poslední (Shift+Tab)
- const isAtFirst = activeElement === firstElement
- const isAtLast = activeElement === lastElement
-
- if ((!e.shiftKey && isAtLast) || (e.shiftKey && isAtFirst)) {
- e.preventDefault()
- ;(e.shiftKey ? lastElement : firstElement).focus()
- }
- }
-
- document.addEventListener("keydown", handleKeyDown)
- return () => document.removeEventListener("keydown", handleKeyDown)
- }, [searchVal])
-
- React.useEffect(() => {
- if (searchVal === "") {
- return undefined
- }
- document.body.style.overflow = "hidden"
- return () => {
- document.body.style.overflow = ""
- }
- }, [searchVal])
-
- return (
- <>
- {searchVal !== "" && (
- {
- // Zavři dialog pouze pokud byl klik na overlay, ne na kontejner
- if (e.target === e.currentTarget) {
- resetSearch()
- }
- }}
- role="presentation"
- aria-label="Výsledky vyhledávání">
- {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
-
e.stopPropagation()}
- onKeyDown={(e) => {
- if (e.key === "Escape" && !document.querySelector(".modal.show")) {
- resetSearch()
- }
- }}
- role="dialog"
- aria-modal="true"
- aria-labelledby="search-dialog-title">
-
-
- Nalezení klienti{" "}
-
- {foundResults.length}
-
-
- }
- buttons={
-
- }
- />
- {clientsActiveContext.isLoading && }
- {foundResults.length > 0 && !clientsActiveContext.isLoading && (
-
- {foundResults.map(({ item }) => (
-
-
- {" "}
-
-
-
-
- {item.note !== "" && (
-
- {" "}
- – {item.note}
-
- )}
-
-
- {item.phone && (
-
- )}
-
-
- {item.email && (
-
- )}
-
-
-
-
-
-
- ))}
-
- )}
- {foundResults.length === 0 && !clientsActiveContext.isLoading && (
- Žádní klienti nenalezeni
- )}
-
-
-
- )}
- >
- )
-}
-
-export default Search
diff --git a/frontend/src/components/SearchInput.css.ts b/frontend/src/components/SearchInput.css.ts
deleted file mode 100644
index 8a97d5a0d..000000000
--- a/frontend/src/components/SearchInput.css.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { globalStyle, style } from "@vanilla-extract/css"
-
-export const search = style({
- marginLeft: "auto",
- maxWidth: "25rem",
- "@media": {
- "(max-width: 991.98px)": {
- // pri hamburger menu pridej horni mezeru k vyhledavacimu poli a vycentruj ho
- margin: "0.5rem auto",
- },
- "(min-width: 992px)": {
- // pri klasickem menu pridej pravou mezeru k vyhledavacimu poli
- marginRight: "1rem",
- },
- },
-})
-
-export const label = style({
- marginBottom: 0,
- color: "#6c757d",
-})
-
-export const iconWrapper = style({
- marginRight: 0,
-})
-
-globalStyle(`${search} input, ${iconWrapper}`, {
- border: 0,
- backgroundColor: "rgb(255 255 255 / 0.07)",
-})
-
-globalStyle(`${search} input`, {
- color: "white",
-})
-
-globalStyle(`${search} input::placeholder`, {
- color: "#6c757d",
-})
-
-globalStyle(`${search} input:focus`, {
- boxShadow: "none",
- backgroundColor: "rgb(255 255 255 / 0.03)",
- color: "white",
-})
diff --git a/frontend/src/components/SearchInput.tsx b/frontend/src/components/SearchInput.tsx
deleted file mode 100644
index 7b371ebaa..000000000
--- a/frontend/src/components/SearchInput.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
-import { faSearch } from "@rodlukas/fontawesome-pro-solid-svg-icons"
-import * as React from "react"
-import { Input, InputGroup, InputGroupText, Label } from "reactstrap"
-
-import * as styles from "./SearchInput.css"
-import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper"
-
-type Props = {
- /** Funkce, která se zavolá při úpravě vyhledávaného výrazu. */
- onSearchChange: (newSearchVal: string) => void
- /** Hledaný výraz. */
- searchVal: string
-}
-
-/** Komponenta zobrazující pole pro vyhledávání. */
-const SearchInput: React.FC = (props) => {
- function onSearchChange(e: React.ChangeEvent): void {
- props.onSearchChange(e.currentTarget.value)
- }
-
- return (
-
-
-
-
-
-
-
- Hledání klientů
-
-
-
- )
-}
-
-export default SearchInput
diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx
deleted file mode 100644
index 3b65ac648..000000000
--- a/frontend/src/components/Tooltip.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { FontAwesomeIcon, FontAwesomeIconProps } from "@fortawesome/react-fontawesome"
-import { faInfoCircle } from "@rodlukas/fontawesome-pro-solid-svg-icons"
-import * as React from "react"
-import { UncontrolledTooltipProps } from "reactstrap"
-
-import UncontrolledTooltipWrapper from "./UncontrolledTooltipWrapper"
-
-type Props = {
- /** Unikátní textové ID pro Tooltip. */
- postfix: string
- /** Text zobrazený v Tooltipu. */
- text: React.ReactNode
- /** Velikost ikony, která zobrazí Tooltip. */
- size?: FontAwesomeIconProps["size"]
- /** Pozice Tooltipu. */
- placement?: UncontrolledTooltipProps["placement"]
- /** Ikona zobrazená jako trigger Tooltipu (výchozí: faInfoCircle). */
- icon?: FontAwesomeIconProps["icon"]
-}
-
-/** Komponenta pro zobrazení titulku po najetí myší nad daný element. */
-const Tooltip: React.FC = ({
- postfix,
- text,
- size = "lg",
- placement = "bottom",
- icon = faInfoCircle,
-}) => (
- <>
-
- {text}
-
-
- >
-)
-
-export default Tooltip
diff --git a/frontend/src/components/UncontrolledTooltipWrapper.tsx b/frontend/src/components/UncontrolledTooltipWrapper.tsx
deleted file mode 100644
index 5a439b09f..000000000
--- a/frontend/src/components/UncontrolledTooltipWrapper.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import * as React from "react"
-import { UncontrolledTooltip, UncontrolledTooltipProps } from "reactstrap"
-
-/** Wrapper pro UncontrolledTooltip zajišťující vhodné výchozí hodnoty. */
-const UncontrolledTooltipWrapper: React.FC = ({
- placement = "auto",
- ...props
-}) =>
-
-export default UncontrolledTooltipWrapper
diff --git a/frontend/src/components/buttons/ActiveSwitcher.css.ts b/frontend/src/components/buttons/ActiveSwitcher.css.ts
index fac991fa4..c511a7165 100644
--- a/frontend/src/components/buttons/ActiveSwitcher.css.ts
+++ b/frontend/src/components/buttons/ActiveSwitcher.css.ts
@@ -1,7 +1,35 @@
import { globalStyle, style } from "@vanilla-extract/css"
-export const activeSwitcher = style({})
+export const activeSwitcher = style({
+ display: "inline-flex",
+ border: "none",
+ borderRadius: "var(--mantine-radius-sm)",
+ boxShadow: "inset 0 1px 2px rgb(0 0 0 / 0.04)",
+ overflow: "hidden",
+ "@media": {
+ "(max-width: 767.98px)": {
+ width: "100%",
+ },
+ },
+})
+
+globalStyle(`${activeSwitcher} .mantine-Button-root`, {
+ border: 0,
+ minWidth: "6.7rem",
+ fontWeight: 600,
+ "@media": {
+ "(max-width: 767.98px)": {
+ flex: 1,
+ minWidth: 0,
+ },
+ },
+})
+
+globalStyle(`${activeSwitcher} .mantine-Button-root[data-variant='default']`, {
+ backgroundColor: "light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6))",
+ color: "light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0))",
+})
-globalStyle(`${activeSwitcher} .active`, {
- cursor: "default !important",
+globalStyle(`${activeSwitcher} .mantine-Button-root[data-variant='default']:hover`, {
+ backgroundColor: "light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))",
})
diff --git a/frontend/src/components/buttons/ActiveSwitcher.tsx b/frontend/src/components/buttons/ActiveSwitcher.tsx
index a70510049..93ed14b61 100644
--- a/frontend/src/components/buttons/ActiveSwitcher.tsx
+++ b/frontend/src/components/buttons/ActiveSwitcher.tsx
@@ -1,5 +1,5 @@
+import { Button } from "@mantine/core"
import * as React from "react"
-import { Button, ButtonGroup } from "reactstrap"
import { AnalyticsSource, trackEvent } from "../../analytics"
@@ -9,42 +9,42 @@ type Props = {
/** Je vybráno zobrazení aktivních klientů/skupin (true). */
active: boolean
/** Funkce, která se zavolá při přepínání. */
- onChange: (active: boolean, ignoreActiveRefresh: boolean) => void
+ onChange: (active: boolean) => void
/** Identifikace místa, odkud byla akce provedena (pro analytiku). */
source: AnalyticsSource
}
/** Přepínač ne/aktivních skupin/klientů. */
const ActiveSwitcher: React.FC = (props) => {
+ const inactive = props.active === false
+
function onSwitcherChange(e: React.MouseEvent): void {
const target = e.currentTarget
const value = target.dataset.value === "true"
// pokud doslo ke zmene, propaguj vyse
if (props.active !== value) {
trackEvent("active_filter_toggled", { source: props.source, active: value })
- props.onChange(value, true)
+ props.onChange(value)
}
}
return (
-
+
Aktivní
Neaktivní
-
+
)
}
diff --git a/frontend/src/components/buttons/AddButton.tsx b/frontend/src/components/buttons/AddButton.tsx
index 36eeedce2..a02ecc959 100644
--- a/frontend/src/components/buttons/AddButton.tsx
+++ b/frontend/src/components/buttons/AddButton.tsx
@@ -1,16 +1,18 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { Button, ButtonProps } from "@mantine/core"
import { faPlus } from "@rodlukas/fontawesome-pro-solid-svg-icons"
import classNames from "classnames"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
import * as styles from "./buttons.css"
-type Props = ButtonProps & {
+type Props = Omit & {
/** Text v tlačítku. */
content: string
/** Tlačítko je malé (true). */
small?: boolean
+ onClick?: React.MouseEventHandler
+ className?: string
}
/** Tlačítko pro přidání objektu v aplikaci. */
@@ -22,8 +24,11 @@ const AddButton: React.FC = ({ content, onClick, small = false, className
className,
)
return (
-
-
+ }
+ {...props}>
{content}
)
diff --git a/frontend/src/components/buttons/BackButton.tsx b/frontend/src/components/buttons/BackButton.tsx
index 463eab7d7..7b8502654 100644
--- a/frontend/src/components/buttons/BackButton.tsx
+++ b/frontend/src/components/buttons/BackButton.tsx
@@ -1,19 +1,23 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { Button, ButtonProps } from "@mantine/core"
import { faArrowLeft } from "@rodlukas/fontawesome-pro-solid-svg-icons"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
import * as styles from "./buttons.css"
-type Props = ButtonProps & {
+type Props = Omit & {
/** Text v tlačítku. */
content?: string
+ onClick?: React.MouseEventHandler
}
/** Tlačítko pro krok zpět v aplikaci. */
const BackButton: React.FC = ({ onClick, content = "Jít zpět" }) => (
-
-
+ }>
{content}
)
diff --git a/frontend/src/components/buttons/CancelButton.tsx b/frontend/src/components/buttons/CancelButton.tsx
index cdda4f7de..ae2e5ac4f 100644
--- a/frontend/src/components/buttons/CancelButton.tsx
+++ b/frontend/src/components/buttons/CancelButton.tsx
@@ -1,9 +1,13 @@
+import { Button, ButtonProps } from "@mantine/core"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
+
+type Props = Omit & {
+ onClick?: React.MouseEventHandler
+}
/** Tlačítko pro storno v rámci aplikace. */
-const CancelButton: React.FC = ({ onClick }) => (
-
+const CancelButton: React.FC = ({ onClick, ...props }) => (
+
Storno
)
diff --git a/frontend/src/components/buttons/CustomButton.tsx b/frontend/src/components/buttons/CustomButton.tsx
index 9d3b53f47..c5a5ef886 100644
--- a/frontend/src/components/buttons/CustomButton.tsx
+++ b/frontend/src/components/buttons/CustomButton.tsx
@@ -1,22 +1,17 @@
+import { Button, ButtonProps } from "@mantine/core"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
-
-import { noop } from "../../global/utils"
type Props = Omit & {
/** Jakýkoliv uzel JSX tvořící text tlačítka. */
content: React.ReactNode
+ onClick?: React.MouseEventHandler
+ disabled?: boolean
+ id?: string
}
-/** Obecné tlačítko v rámci aplikace. */
-const CustomButton: React.FC = ({
- onClick = noop,
- content = "",
- disabled = false,
- id,
- ...props
-}) => (
-
+/** Obecné tlačítko v rámci aplikace (šedá varianta). */
+const CustomButton: React.FC = ({ onClick, content, disabled = false, id, ...props }) => (
+
{content}
)
diff --git a/frontend/src/components/buttons/DeleteButton.tsx b/frontend/src/components/buttons/DeleteButton.tsx
index 381728bd3..1918d074d 100644
--- a/frontend/src/components/buttons/DeleteButton.tsx
+++ b/frontend/src/components/buttons/DeleteButton.tsx
@@ -1,14 +1,15 @@
+import { Button, ButtonProps } from "@mantine/core"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
-type Props = ButtonProps & {
+type Props = Omit & {
/** Text v tlačítku. */
content?: string
+ onClick?: React.MouseEventHandler
}
/** Tlačítko pro smazání objektu v aplikaci. */
const DeleteButton: React.FC = ({ onClick, content = "", ...props }) => (
-
+
Smazat {content}
)
diff --git a/frontend/src/components/buttons/EditButton.tsx b/frontend/src/components/buttons/EditButton.tsx
index f769efa8f..c0e08b21f 100644
--- a/frontend/src/components/buttons/EditButton.tsx
+++ b/frontend/src/components/buttons/EditButton.tsx
@@ -1,36 +1,34 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
+import { Button, ButtonProps, Tooltip } from "@mantine/core"
import { faPencil } from "@rodlukas/fontawesome-pro-solid-svg-icons"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
import { makeIdFromString } from "../../global/utils"
-import UncontrolledTooltipWrapper from "../UncontrolledTooltipWrapper"
-type Props = ButtonProps & {
+type Props = Omit & {
/** Text v tlačítku. */
content?: string
/** ID objektu, pro který se zobrazuje tlačítko. */
contentId: number | string
+ onClick?: React.MouseEventHandler
}
/** Tlačítko pro úpravu objektu v aplikaci. */
const EditButton: React.FC = ({ content = "Upravit", onClick, contentId, ...props }) => (
- <>
+
-
- {content}
-
- >
+
)
export default EditButton
diff --git a/frontend/src/components/buttons/SubmitButton.tsx b/frontend/src/components/buttons/SubmitButton.tsx
index a7eded2da..f1c3803c8 100644
--- a/frontend/src/components/buttons/SubmitButton.tsx
+++ b/frontend/src/components/buttons/SubmitButton.tsx
@@ -1,15 +1,15 @@
-import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
-import { faSpinnerThird } from "@rodlukas/fontawesome-pro-solid-svg-icons"
+import { Button, ButtonProps } from "@mantine/core"
import * as React from "react"
-import { Button, ButtonProps } from "reactstrap"
-type Props = ButtonProps & {
+type Props = Omit & {
/** Text v tlačítku. */
content?: string
/** Zobraz načítací animaci v tlačítku (true). */
loading?: boolean
/** Tlačítko není aktivní (true). */
disabled?: boolean
+ onClick?: React.MouseEventHandler
+ id?: string
}
/** Tlačítko pro odeslání formuláře v aplikaci. */
@@ -20,13 +20,13 @@ const SubmitButton: React.FC = ({
...props
}) => (
{content}
- {loading && }
)
diff --git a/frontend/src/components/charts.css.ts b/frontend/src/components/charts.css.ts
index d138ce96b..4c9eba6bc 100644
--- a/frontend/src/components/charts.css.ts
+++ b/frontend/src/components/charts.css.ts
@@ -1,16 +1,47 @@
-import { style } from "@vanilla-extract/css"
+import { createVar, globalStyle, style } from "@vanilla-extract/css"
+
+import { vars } from "../theme/tokens"
+
+// Recharts předává `stroke`/`fill` jako SVG prezentační atributy — `var()` v nich funguje
+// (osvědčeno stávajícím kódem), ale `light-dark()` přímo v hodnotě atributu spolehlivé není.
+// Přepínání schémat proto řeší tyto CSS proměnné (stejný vzor jako remap v index.css.ts)
+// a v atributech grafů zůstává jen čistý `var()` — viz konstanty v charts.ts.
+// Kontrasty: light gray-7 ticks = 8.18:1 na bílé (gray-6 měla jen 3.32:1);
+// dark dark-1 ticks = 7.83:1 na dark-7. Mřížka: light gray-3, dark dark-4 (dekorativní linky
+// kontrast nevyžadují — gray-3 by ale v dark módu nepatřičně zářila).
+globalStyle(":root", {
+ vars: {
+ "--up-chart-grid-stroke": "var(--mantine-color-gray-3)",
+ "--up-chart-tick-fill": "var(--mantine-color-gray-7)",
+ },
+})
+
+globalStyle(":root[data-mantine-color-scheme='dark']", {
+ vars: {
+ "--up-chart-grid-stroke": "var(--mantine-color-dark-4)",
+ "--up-chart-tick-fill": "var(--mantine-color-dark-1)",
+ },
+})
export const chartBaseStyles = {
- border: "1px solid #dee2e6",
- borderRadius: "0.375rem",
- backgroundColor: "#fff",
+ border: vars.borderShort.default,
+ borderRadius: vars.radius.md,
+ backgroundColor: vars.bg.surface,
}
export const chartTooltip = style({
...chartBaseStyles,
- boxShadow: "0 0.125rem 0.25rem rgba(0, 0, 0, 0.075)",
+ boxShadow: vars.shadow.elevated,
padding: "0.5rem 0.75rem",
lineHeight: 1.5,
- color: "#212529",
+ color: vars.text.primary,
fontSize: "0.8rem",
})
+
+/** Barva série grafu pro položku tooltipu — dynamická hodnota přes assignInlineVars. */
+export const tooltipSeriesColor = createVar()
+
+/** Položka tooltipu grafu obarvená barvou své série (místo inline `style={{ color }}`). */
+export const tooltipSeriesEntry = style({
+ color: tooltipSeriesColor,
+})
diff --git a/frontend/src/components/charts.ts b/frontend/src/components/charts.ts
index 7011810aa..3fa834c81 100644
--- a/frontend/src/components/charts.ts
+++ b/frontend/src/components/charts.ts
@@ -1,3 +1,5 @@
+import "./charts.css"
+
/** Krátké názvy měsíců pro osu X grafů. */
export const MONTH_LABELS = [
"Led",
@@ -14,9 +16,10 @@ export const MONTH_LABELS = [
"Pro",
] as const
-export const AXIS_TICK = { fontSize: 12, fill: "#6c757d" } as const
-export const AXIS_LABEL = { fontSize: 11, fill: "#6c757d" } as const
-export const GRID_STROKE = "#e9ecef"
+// Adaptivní barvy (light/dark) jsou definované v charts.css.ts — viz komentář tamtéž.
+export const AXIS_TICK = { fontSize: 12, fill: "var(--up-chart-tick-fill)" } as const
+export const AXIS_LABEL = { fontSize: 11, fill: "var(--up-chart-tick-fill)" } as const
+export const GRID_STROKE = "var(--up-chart-grid-stroke)"
export const LEGEND_FONT = { fontSize: 12 } as const
export type ChartMargin = {
diff --git a/frontend/src/declarations.d.ts b/frontend/src/declarations.d.ts
index b4bf09644..ef6d741f6 100644
--- a/frontend/src/declarations.d.ts
+++ b/frontend/src/declarations.d.ts
@@ -1,2 +1 @@
declare module "*.css" {}
-declare module "react-color-palette/css" {}
diff --git a/frontend/src/forms/FormApplications.tsx b/frontend/src/forms/FormApplications.tsx
index 88e32ac0e..f87e80766 100644
--- a/frontend/src/forms/FormApplications.tsx
+++ b/frontend/src/forms/FormApplications.tsx
@@ -1,5 +1,6 @@
+import { Group, Modal, Textarea, Title } from "@mantine/core"
+import { useForm } from "@mantine/form"
import * as React from "react"
-import { Col, Form, FormGroup, Input, Label, ModalBody, ModalFooter, ModalHeader } from "reactstrap"
import { trackEvent } from "../analytics"
import { useClients, useCreateApplication, useUpdateApplication } from "../api/hooks"
@@ -17,6 +18,7 @@ import {
} from "../types/models"
import { fEmptyVoid } from "../types/types"
+import * as baseStyles from "./FormBase.css"
import Or from "./helpers/Or"
import SelectClient from "./helpers/SelectClient"
import SelectCourse from "./helpers/SelectCourse"
@@ -43,49 +45,55 @@ const FormApplications: React.FC = (props) => {
const createApplication = useCreateApplication()
const updateApplication = useUpdateApplication()
- /** Kurz zájemce. */
- const [course, setCourse] = React.useState(
- props.application.course,
- )
- /** Klient. */
- const [client, setClient] = React.useState(
- props.application.client,
- )
- /** Poznámka k zájemci o kurz. */
- const [note, setNote] = React.useState(props.application.note)
-
- const onChange = (e: React.ChangeEvent): void => {
- props.setFormDirty()
- const target = e.currentTarget
- const value = target.type === "checkbox" ? target.checked : target.value
- if (target.id === "note") {
- setNote(value as string)
- }
- }
+ const form = useForm<{
+ course: ApplicationPostApiDummy["course"]
+ client: ApplicationPostApiDummy["client"]
+ note: ApplicationPostApiDummy["note"]
+ }>({
+ initialValues: {
+ course: props.application.course,
+ client: props.application.client,
+ note: props.application.note,
+ },
+ onValuesChange: () => props.setFormDirty(),
+ })
const onSelectChange = (
name: "course" | "client",
obj?: CourseType | ClientType | null,
): void => {
- props.setFormDirty()
if (obj === undefined) {
obj = null
}
if (name === "course") {
- setCourse(obj as CourseType | null)
+ form.setFieldValue("course", obj as CourseType | null)
} else if (name === "client") {
- setClient(obj as ClientType | null)
+ form.setFieldValue("client", obj as ClientType | null)
}
}
const isApplicationValue = isApplication(props.application)
+ // Po pokusu o odeslání s prázdným povinným Selectem (skrytý input neumí constraint
+ // validaci, takže reportValidity je no-op) zobrazíme chybu přes `error` prop Selectů.
+ const [triedSubmit, setTriedSubmit] = React.useState(false)
+
const onSubmit = React.useCallback(
- (e: React.FormEvent): void => {
+ (e: React.SyntheticEvent): void => {
e.preventDefault()
- const courseId = course!.id
- const clientId = client!.id
- const dataPost: ApplicationPostApi = { course_id: courseId, client_id: clientId, note }
+ const { course, client } = form.values
+ // pojistka: bez vybraneho kurzu/klienta neodesilame a zobrazime chybu u Selectu
+ if (!course || !client) {
+ setTriedSubmit(true)
+ return
+ }
+ const courseId = course.id
+ const clientId = client.id
+ const dataPost: ApplicationPostApi = {
+ course_id: courseId,
+ client_id: clientId,
+ note: form.values.note,
+ }
if (isApplication(props.application)) {
const dataPut: ApplicationPutApi = { ...dataPost, id: props.application.id }
@@ -104,7 +112,7 @@ const FormApplications: React.FC = (props) => {
})
}
},
- [course, client, note, props, createApplication, updateApplication],
+ [form.values, props, createApplication, updateApplication],
)
const close = (): void => {
@@ -112,85 +120,98 @@ const FormApplications: React.FC = (props) => {
}
const processAdditionOfClient = (newClient: ClientType): void => {
- props.setFormDirty()
- setClient(newClient)
+ form.setFieldValue("client", newClient)
}
const isLoading = clientsLoading || coursesVisibleContext.isLoading
const isSubmit = createApplication.isPending || updateApplication.isPending
return (
-
+
+
)
}
diff --git a/frontend/src/forms/FormBase.css.ts b/frontend/src/forms/FormBase.css.ts
new file mode 100644
index 000000000..ce6020606
--- /dev/null
+++ b/frontend/src/forms/FormBase.css.ts
@@ -0,0 +1,277 @@
+import { globalStyle, style } from "@vanilla-extract/css"
+
+import { statusNoticeDanger } from "../global/surfaces.css"
+import { vars } from "../theme/tokens"
+
+globalStyle("form[data-qa^='form_'] .mantine-Modal-header", {
+ borderBottom: vars.borderShort.formDivider,
+ backgroundColor: vars.bg.subtle,
+ 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: vars.radius.lg,
+ 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.subtle,
+ padding: "1rem 1.25rem 0.95rem",
+ minWidth: 0,
+ minHeight: 0,
+ overflowY: "auto",
+})
+
+globalStyle("form[data-qa^='form_'] .mantine-Modal-close", {
+ borderRadius: vars.radius.md,
+ color: vars.text.subtleMuted,
+})
+
+globalStyle("form[data-qa^='form_'] .mantine-Modal-close:hover", {
+ backgroundColor: vars.bg.hoverElevated,
+ color: vars.text.primary,
+})
+
+globalStyle("form[data-qa^='form_'] .mantine-Modal-body hr", {
+ opacity: 1,
+ margin: "1.1rem 0",
+ borderColor: vars.border.formDivider,
+})
+
+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",
+ borderRadius: vars.radius.md,
+ borderColor: vars.border.default,
+ backgroundColor: vars.bg.elevated,
+ },
+)
+
+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: vars.text.subtleMuted,
+ 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: vars.border.strong,
+ 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: vars.colors.primary,
+ boxShadow: vars.shadow.focusRing,
+ backgroundColor: vars.bg.elevated,
+ },
+)
+
+globalStyle("form[data-qa^='form_'] .mantine-Checkbox-label", {
+ color: vars.text.primary,
+ fontWeight: 500,
+})
+
+export const modalWizardContent = style({})
+
+globalStyle(`${modalWizardContent} .mantine-Modal-header`, {
+ borderBottom: vars.borderShort.formDivider,
+ backgroundColor: vars.bg.surface,
+ padding: "1rem 1.25rem 0.95rem",
+})
+
+globalStyle(`${modalWizardContent} .mantine-Modal-body`, {
+ paddingTop: "0.85rem",
+})
+
+/** 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: vars.borderShort.formDivider,
+ 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: vars.borderShort.formDivider,
+ borderRadius: vars.radius.md,
+ boxShadow: "0 1px 2px rgb(15 23 42 / 0.04)",
+ backgroundColor: vars.bg.elevated,
+ padding: "1rem 1rem 0.95rem",
+})
+
+export const formSectionDanger = style([
+ statusNoticeDanger,
+ {
+ 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 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 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 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: vars.radius.lg,
+ minHeight: "2.8rem",
+ fontWeight: 600,
+})
+
+globalStyle(`${modalActions} .mantine-Button-root[data-variant='default']`, {
+ borderColor: vars.border.default,
+ backgroundColor: vars.bg.elevated,
+ color: vars.text.slate,
+})
+
+globalStyle(`${modalActions} .mantine-Button-root[data-variant='default']:hover`, {
+ backgroundColor: vars.bg.hoverElevated,
+})
+
+globalStyle(`${modalActions} [type='submit']`, {
+ boxShadow: "0 10px 18px rgb(34 139 230 / 0.18)",
+})
+
+// hover lift jen pokud uzivatel nema omezeny pohyb (prefers-reduced-motion)
+globalStyle(`${modalActions} [type='submit']:hover`, {
+ "@media": {
+ "(prefers-reduced-motion: no-preference)": {
+ 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 70df21ec4..95cdfe94e 100644
--- a/frontend/src/forms/FormClients.tsx
+++ b/frontend/src/forms/FormClients.tsx
@@ -1,17 +1,6 @@
+import { Checkbox, Group, Modal, SimpleGrid, Textarea, TextInput, Title } from "@mantine/core"
+import { useForm } from "@mantine/form"
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"
@@ -19,13 +8,15 @@ import CancelButton from "../components/buttons/CancelButton"
import DeleteButton from "../components/buttons/DeleteButton"
import SubmitButton from "../components/buttons/SubmitButton"
import ClientName from "../components/ClientName"
-import Tooltip from "../components/Tooltip"
+import InfoTooltip from "../components/InfoTooltip"
import { TEXTS } from "../global/constants"
import { capitalizeString, prettyPhone } from "../global/utils"
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,51 +40,23 @@ 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 => {
- props.setFormDirty()
- const target = e.currentTarget
- let value = target.type === "checkbox" ? target.checked : target.value
- // pri psani rozdeluj cislo na trojice
- 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)
- } else if (target.id === "surname") {
- value = capitalizeString(value as string)
- setSurname(value)
- } else if (target.id === "email") {
- setEmail(value as string)
- } else if (target.id === "note") {
- setNote(value as string)
- } else if (target.id === "active") {
- setActive(value as boolean)
- }
- }
+ const form = useForm({
+ initialValues: {
+ firstname: props.client.firstname,
+ surname: props.client.surname,
+ email: props.client.email,
+ phone: prettyPhone(props.client.phone),
+ note: props.client.note,
+ active: props.client.active,
+ },
+ })
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()
+ const { firstname, surname, email, phone, note, active } = form.getValues()
const dataPost = { firstname, surname, email, phone, note, active }
if (isClient(props.client)) {
@@ -119,7 +82,7 @@ const FormClients: React.FC = (props) => {
})
}
},
- [firstname, surname, email, phone, note, active, props, createClient, updateClient],
+ [form, props, createClient, updateClient],
)
const close = (): void => {
@@ -131,166 +94,198 @@ const FormClients: React.FC = (props) => {
deleteClient.mutate(id, {
onSuccess: () => {
trackEvent("client_deleted", { source: props.source })
- props.funcForceClose(true, { active, isDeleted: true })
+ props.funcForceClose(true, {
+ active: form.getValues().active,
+ isDeleted: true,
+ })
},
})
},
- [deleteClient, props, active],
+ [deleteClient, props, form],
)
const isSubmit = createClient.isPending || updateClient.isPending
return (
-
-
- {isClient(props.client) ? "Úprava" : "Přidání"} klienta:{" "}
-
-
-
-
-
- Jméno
-
-
-
-
-
-
-
- Příjmení
-
-
-
-
-
-
-
- Email
-
-
-
-
-
-
-
- Telefon
-
-
-
-
- +420
-
-
-
-
-
-
-
- Poznámka
-
-
-
-
-
-
-
- Aktivní
-
-
-
-
- Je aktivní
- {" "}
- {!active && (
-
- )}
-
-
- {isClient(props.client) && (
- <>
-
-
-
- Smazání
-
-
-
-
- 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)
- }
+
+
+
+ {isClient(props.client) ? "Úprava" : "Přidání"} klienta:{" "}
+
+
+
+
+
+
+
+
+ Základní údaje
+
+
+
+
+ ,
+ ): void => {
+ props.setFormDirty()
+ form.setFieldValue(
+ "firstname",
+ capitalizeString(e.currentTarget.value),
+ )
}}
- data-qa="button_delete_client"
+ label="Jméno"
+ required
+ withAsterisk
+ data-autofocus
+ data-qa="client_field_firstname"
+ spellCheck
/>
-
-
-
- >
- )}
-
-
- {" "}
+
+
+ ,
+ ): void => {
+ props.setFormDirty()
+ form.setFieldValue(
+ "surname",
+ capitalizeString(e.currentTarget.value),
+ )
+ }}
+ label="Příjmení"
+ required
+ withAsterisk
+ data-qa="client_field_surname"
+ spellCheck
+ />
+
+
+
+ ): void => {
+ props.setFormDirty()
+ form.setFieldValue("email", e.currentTarget.value)
+ }}
+ label="Email"
+ data-qa="client_field_email"
+ />
+
+
+ ): void => {
+ props.setFormDirty()
+ // pri psani rozdeluj cislo na trojice
+ const formatted = e.currentTarget.value
+ .replace(/(\d{3})([^\s])/, "$1 $2")
+ .replace(/(\d{3}) (\d{3})([^\s])/, "$1 $2 $3")
+ form.setFieldValue("phone", formatted)
+ }}
+ label="Telefon"
+ description="Formát: 123 456 789"
+ pattern="[0-9]{3} [0-9]{3} [0-9]{3}"
+ data-qa="client_field_phone"
+ leftSection={+420 }
+ />
+
+
+ ): void => {
+ props.setFormDirty()
+ form.setFieldValue("note", e.currentTarget.value)
+ }}
+ label="Poznámka"
+ data-qa="client_field_note"
+ spellCheck
+ autosize
+ minRows={3}
+ maxRows={8}
+ />
+
+
+
+ Stav klienta
+
+
+ ,
+ ): void => {
+ props.setFormDirty()
+ form.setFieldValue("active", e.currentTarget.checked)
+ }}
+ data-qa="client_checkbox_active"
+ label="Je aktivní"
+ />
+ {!form.values.active && (
+
+ )}
+
+
+
+
+ {isClient(props.client) && (
+
+
+ Smazání
+
+
+
+ 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 ${form.values.firstname} ${form.values.surname}?`,
+ )
+ ) {
+ handleDelete(props.client.id)
+ }
+ }}
+ data-qa="button_delete_client"
+ />
+
+
+ )}
+
+
+
+
-
-
+
+
)
}
diff --git a/frontend/src/forms/FormGroups.tsx b/frontend/src/forms/FormGroups.tsx
index 1d6b47023..3bda5abdc 100644
--- a/frontend/src/forms/FormGroups.tsx
+++ b/frontend/src/forms/FormGroups.tsx
@@ -1,15 +1,15 @@
-import * as React from "react"
import {
- Alert,
- Col,
- Form,
- FormGroup,
- Input,
- Label,
- ModalBody,
- ModalFooter,
- ModalHeader,
-} from "reactstrap"
+ Checkbox,
+ Group,
+ Modal,
+ MultiSelect,
+ Pill,
+ SimpleGrid,
+ TextInput,
+ Title,
+} from "@mantine/core"
+import { useForm } from "@mantine/form"
+import * as React from "react"
import { AnalyticsSource, trackEvent } from "../analytics"
import { useClients, useCreateGroup, useDeleteGroup, useUpdateGroup } from "../api/hooks"
@@ -17,14 +17,13 @@ import CancelButton from "../components/buttons/CancelButton"
import DeleteButton from "../components/buttons/DeleteButton"
import SubmitButton from "../components/buttons/SubmitButton"
import GroupName from "../components/GroupName"
+import InfoTooltip from "../components/InfoTooltip"
import Loading from "../components/Loading"
-import Tooltip from "../components/Tooltip"
import { useCoursesVisibleContext } from "../contexts/CoursesVisibleContext"
import { clientName } from "../global/utils"
import { ModalGroupsData } from "../types/components"
import {
ClientType,
- CourseType,
GroupPostApi,
GroupPostApiDummy,
GroupPutApi,
@@ -33,9 +32,9 @@ import {
} from "../types/models"
import { fEmptyVoid } from "../types/types"
-import { reactSelectIds } from "./helpers/func"
+import * as styles from "./FormBase.css"
import Or from "./helpers/Or"
-import ReactSelectWrapper from "./helpers/ReactSelectWrapper"
+import { gdprInput } from "./helpers/SelectClient.css"
import SelectCourse from "./helpers/SelectCourse"
import ModalClients from "./ModalClients"
@@ -64,7 +63,7 @@ const FormGroups: React.FC = (props) => {
const updateGroup = useUpdateGroup()
const deleteGroup = useDeleteGroup()
- // pripravi pole se cleny ve spravnem formatu, aby fungoval react-select
+ // přepraví pole se členy ve správném formátu
const getMembersOfGroup = React.useCallback((members: MembershipType[]): ClientType[] => {
return members.map((member) => member.client)
}, [])
@@ -77,53 +76,30 @@ const FormGroups: React.FC = (props) => {
[],
)
- /** Název skupiny. */
- const [name, setName] = React.useState(props.group.name)
- /** Skupina je aktivní (true). */
- const [active, setActive] = React.useState(props.group.active)
- /** Kurz skupiny. */
- const [course, setCourse] = React.useState(props.group.course)
- /** Členové skupiny. */
- const [members, setMembers] = React.useState(
- getMembersOfGroup(isGroup(props.group) ? props.group.memberships : []),
- )
-
- const onSelectChange = (
- fieldName: "members" | "course",
- obj?: CourseType | readonly ClientType[] | ClientType | null,
- ): void => {
- props.setFormDirty()
- // react-select muze vratit null (napr. pri smazani vsech) nebo undefined, udrzujme tedy stav konzistentni
- if (fieldName === "members") {
- if (Array.isArray(obj)) {
- setMembers([...obj])
- } else {
- setMembers([])
- }
- } else if (fieldName === "course") {
- if (obj) {
- setCourse(obj as CourseType)
- } else {
- setCourse(null)
- }
- }
- }
+ const form = useForm({
+ initialValues: {
+ name: props.group.name,
+ active: props.group.active,
+ course: props.group.course,
+ members: getMembersOfGroup(isGroup(props.group) ? props.group.memberships : []),
+ },
+ onValuesChange: () => props.setFormDirty(),
+ })
- const onChange = (e: React.ChangeEvent): void => {
- props.setFormDirty()
- const target = e.currentTarget
- const value = target.type === "checkbox" ? target.checked : target.value
- if (target.id === "name") {
- setName(value as string)
- } else if (target.id === "active") {
- setActive(value as boolean)
- }
- }
+ // Po pokusu o odeslání s prázdným povinným kurzem (skrytý input Selectu neumí constraint
+ // validaci, reportValidity je no-op) zobrazíme chybu přes `error` prop SelectCourse.
+ const [triedSubmit, setTriedSubmit] = React.useState(false)
const onSubmit = React.useCallback(
- (e: React.FormEvent): void => {
+ (e: React.SyntheticEvent): void => {
e.preventDefault()
- const courseId = course!.id
+ const { name, active, course, members } = form.getValues()
+ // pojistka: bez vybraneho kurzu neodesilame a zobrazime chybu u SelectCourse
+ if (!course) {
+ setTriedSubmit(true)
+ return
+ }
+ const courseId = course.id
const dataPost: GroupPostApi = {
name,
memberships: prepareMembersForSubmit(members),
@@ -154,7 +130,7 @@ const FormGroups: React.FC = (props) => {
})
}
},
- [name, members, course, active, props, createGroup, updateGroup, prepareMembersForSubmit],
+ [form, props, createGroup, updateGroup, prepareMembersForSubmit],
)
const close = React.useCallback((): void => {
@@ -166,160 +142,206 @@ const FormGroups: React.FC = (props) => {
deleteGroup.mutate(id, {
onSuccess: () => {
trackEvent("group_deleted", { source: props.source })
- props.funcForceClose(true, { active, isDeleted: true })
+ props.funcForceClose(true, {
+ active: form.getValues().active,
+ isDeleted: true,
+ })
},
})
},
- [deleteGroup, props, active],
+ [deleteGroup, props, form],
)
const processAdditionOfClient = React.useCallback(
(newClient: ClientType): void => {
- props.setFormDirty()
- setMembers((prev) => [...prev, newClient])
+ form.setFieldValue("members", [...form.values.members, newClient])
},
- [props],
+ [form],
)
+ // Sjednocení existujících klientů s aktuálními členy: čerstvě přidaný klient
+ // (přes "přidat nového", viz processAdditionOfClient) ještě není v `clientsData`
+ // kvůli asynchronnímu refetchi. Bez něj by MultiSelect vykreslil pill nad neznámým
+ // id (Mantine pošle do renderPill option: undefined → pád) a onChange by člena tiše
+ // zahodil. Sjednocením má každé vybrané id vždy odpovídající položku.
+ const clientsById = React.useMemo(() => {
+ const byId = new Map()
+ clientsData.forEach((c) => byId.set(c.id.toString(), c))
+ form.values.members.forEach((m) => {
+ const id = m.id.toString()
+ if (!byId.has(id)) {
+ byId.set(id, m)
+ }
+ })
+ return byId
+ }, [clientsData, form.values.members])
+
const isLoading = clientsLoading || coursesVisibleContext.isLoading
const isSubmit = createGroup.isPending || updateGroup.isPending
return (
-
-
- {isGroup(props.group) ? "Úprava" : "Přidání"} skupiny:{" "}
-
-
-
+
+
+
+ {isGroup(props.group) ? "Úprava" : "Přidání"} skupiny:{" "}
+
+
+
+
+
{isLoading ? (
) : (
- <>
-
-
- Název
-
-
-
-
-
-
-
- Kurz
-
-
-
-
-
-
-
- Členové
-
-
-
- {...reactSelectIds("members")}
- value={members}
- getOptionLabel={(option): string => clientName(option)}
- getOptionValue={(option): string => option.id.toString()}
- isMulti
- closeMenuOnSelect={false}
- onChange={(newValue): void =>
- onSelectChange("members", newValue)
- }
- options={clientsData}
- placeholder={"Vyberte členy z existujících klientů..."}
- isClearable={false}
- />
-
- }
- />
-
-
-
-
- Aktivní
-
-
-
-
- Je aktivní
- {" "}
- {!active && (
-
+
+
+ Základní údaje
+
+
+
+
+ form.setFieldValue("name", e.currentTarget.value)
+ }
+ label="Název skupiny"
+ data-autofocus
+ data-qa="group_field_name"
+ required
+ withAsterisk
+ spellCheck
/>
- )}
-
-
- {isGroup(props.group) && (
- <>
-
-
-
- Smazání
-
-
-
- Nenávratně smaže skupinu i s jejími lekcemi
- {
- if (
- isGroup(props.group) &&
- globalThis.confirm(
- `Opravdu chcete smazat skupinu ${name}?`,
- )
- ) {
- handleDelete(props.group.id)
- }
- }}
- data-qa="button_delete_group"
+
+
+
+ Kurz
+
+
+ form.setFieldValue("course", val ?? null)
+ }
+ options={coursesVisibleContext.courses}
+ error={
+ triedSubmit && !form.values.course
+ ? "Vyberte kurz"
+ : undefined
+ }
+ />
+
+
+
+
+
+ Stav skupiny
+
+
+
+ form.setFieldValue(
+ "active",
+ e.currentTarget.checked,
+ )
+ }
+ data-qa="group_checkbox_active"
+ label="Je aktivní"
+ />
+ {!form.values.active && (
+
+ )}
+
+
+
+
+
+ {isGroup(props.group) && (
+
+
+ Smazání
+
+
+
Nenávratně smaže skupinu i s jejími lekcemi.
+
{
+ if (
+ isGroup(props.group) &&
+ globalThis.confirm(
+ `Opravdu chcete smazat skupinu ${form.values.name}?`,
+ )
+ ) {
+ handleDelete(props.group.id)
+ }
+ }}
+ data-qa="button_delete_group"
+ />
+
+
)}
- >
+