Compare commits

..

9 Commits

Author SHA1 Message Date
BOHA
9c862034ca chore(release): v2.0.2 — dashboard "today's assignment" card
- New "Vaše dnešní zařazení" dashboard card: shows the logged-in user's
  work-plan entry for today (category + project/site + note, multi-day
  range badge), with a muted empty state.

No DB migrations. tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:26:02 +02:00
BOHA
67d62a6df0 fix(dashboard): rename today-plan card to "Vaše dnešní zařazení"
Clearer, formal (matches the app's Vy/Vaše tone) and category-agnostic
(reads fine for work and absence days) so the employee plainly sees what
they're assigned to. Was "Můj plán na dnešek".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:25:35 +02:00
BOHA
512f1fd92b feat(dashboard): show the user's work plan for today
Adds a "Můj plán na dnešek" card near the clock-in: for the logged-in
user it shows today's work-plan entry — category (coloured dot + label),
the project/site (the "where"), the note, and a "součást rozsahu …" badge
only for genuinely multi-day ranges. A muted "Pro dnešek nemáte
naplánováno." empty state when nothing is scheduled.

Server: GET /api/admin/dashboard now returns today_plan, reusing
plan.service resolveCell (override-beats-range precedence) for the current
user + today's local (Prague) date, enriched with the category label +
colour so the widget needs no extra query. Gated by attendance.record /
attendance.manage (the plan permission); omitted otherwise. No migration.

Verified in Chrome: renders today's entry (Práce · OBJ-2026-006 — koko)
with the category colour. tsc -b --noEmit, npm run build, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 22:22:07 +02:00
BOHA
941caf9d85 chore(release): v2.0.1 — button/icon consistency + mobile header actions
UI consistency pass on top of v2.0.0:
- Colored button text unified to white on all filled controls (both
  themes); darker dark-mode fills so white stays legible.
- Icon badges unified via a shared iconBadgeSx (solid tile + white glyph).
- Detail-page header action buttons stack one-per-row on mobile.
- Dropped the duplicate "Zrušit" in the plan view-cell modal.

No DB migrations. tsc -b --noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:55:20 +02:00
BOHA
b00d18d0b3 fix(plan): drop duplicate 'Zrušit' in the view-mode cell modal
The read-only plan-cell detail modal (view mode) already has a "Zavřít"
submit button, but the kit Modal also rendered its built-in "Zrušit"
cancel — two buttons that do the same thing. Pass hideCancel so only
"Zavřít" remains (same fix as the category-management modal).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:54:42 +02:00
BOHA
decadd895e fix(ui): stack detail-page header action buttons one-per-row on mobile
Detail-page headers put their action buttons in a flex row with
flexWrap:"wrap", so on a phone the 2-4 buttons (Potvrzení / Zahájit
realizaci / Smazat, etc.) wrapped into a ragged grid.

New shared `headerActionsSx` (ui/PageHeader): on xs it's a full-width
column (each button its own full-width row); from sm up it's the usual
right-aligned wrapping row — desktop unchanged. A StatusChip kept in the
same group stays its natural size (alignSelf) instead of stretching.

Applied to PageHeader's own actions slot and every detail-page header
action group: Order/Offer/Invoice (both views)/Project + the Warehouse
Receipt/Issue/Item/Inventory detail headers.

Verified in Chrome at 430px on OrderDetail: the group is flexDirection
column with all three buttons at the same x, full-width, stacked. tsc -b
--noEmit, npm run build, vitest 152/152 clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:44:22 +02:00
BOHA
54f3c414f5 fix(ui): unify icon badges — solid tile + white glyph via shared helper
The labelled icon-badge tiles were inconsistent: some used a same-hue
light tile + colored glyph (e.g. the Settings 2FA row: success.light tile
+ success.main glyph — low contrast, the reported "badly visible" icon),
and the solid-tile ones used the bright .main fill in dark mode without
darkening, so a white glyph on bright green/amber was low-contrast too
(DashProfile's success badge was ~1.9:1).

Add a single `iconBadgeSx(color)` helper (theme.ts): solid `<color>.main`
fill + white glyph, darkened in dark mode (FILLED_DARK_BG) so white stays
legible; `default` = neutral grey. Applied to every badge so they can't
drift: StatCard, DashActivityFeed, DashProfile (2FA badge), Dashboard
(2FA banner icon), AttendanceHistory (month tile), and the Settings 2FA
row tile (now solid green when required / grey when optional + white lock,
instead of the low-contrast light-green tile). Also switched DashProfile's
backup-codes warning callout from warning.light to a subtle warning wash
so its amber text stays readable in dark mode.

tsc -b --noEmit, npm run build, vitest 152/152 all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:34:06 +02:00
BOHA
ca092c6166 fix(ui): unify colored-control text — white on all filled, both themes
The text color on filled colored controls was inconsistent: contained
primary resolved to white in both schemes, but error/success/warning/info
used a per-scheme contrastText (#fff light, #1a1a1a dark). So in DARK mode
a primary (red) button showed white text while an error (red) button —
e.g. every ConfirmDialog delete confirm, the contained "Smazat", and the
dashboard quick-action tiles — showed near-black text. "Black text on one
red button, white on another."

Unify on a single rule: every FILLED colored surface gets WHITE text/glyph
in BOTH themes. The palette .main stays bright in dark mode (it drives text,
outlined buttons and row tints), but a bright fill makes white illegible, so
filled surfaces drop to a darker fill in dark mode (new FILLED_DARK_BG, ≈ the
light-scheme shades; all ≥4.5:1 with white):
- theme MuiButton: contained error/success/warning/info -> white text +
  darker dark-mode fill (disabled state preserved).
- theme MuiChip: filled error/success/warning/info -> white label + darker
  dark-mode fill (covers StatusChip + status pills).
- StatCard icon badges: white glyph + darker dark-mode tile.

Plus two consistency cleanups:
- Detail-page "Smazat" unified to variant=outlined color=error (Offer +
  Invoice now match Order/Project/Warehouse; the contained->confirm pattern
  stays: subtle trigger, strong contained confirm dialog).
- "Zrušit filtry" reset buttons -> color=inherit (neutral), matching the
  rest of the secondary-button family (were default outlined-primary).

Neutral (color=inherit) text/outlined buttons were already correct (label =
ambient text color) and are untouched. tsc -b --noEmit, npm run build and
vitest 152/152 all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:41:50 +02:00
BOHA
c7f9d9aa36 docs(claude): update for completed MUI migration + v2.0.0
Bring CLAUDE.md in line with the current codebase:

- Tech Stack: add Material UI v7 (Emotion) to the frontend row.
- Project Structure: ~105 .tsx (was 57), 49 models (was 32); add the
  ui/ kit, lib/ (React Query), theme.ts and GlobalStyles.tsx; fix
  contexts/ -> context/; correct the hooks list (drop the nonexistent
  useApiCall/useListData); refresh the components/ + __tests__ notes.
- Testing: drop "coverage is minimal: only auth and numbering" — the
  suite is ~14 files / 152 tests across many areas.
- Frontend Conventions: styling is MUI/Emotion (no .css files, use
  theme.vars), data layer is React Query, token responses carry
  expires_in.
- "MUI Migration (in progress)" -> "Styling & MUI (complete, v2.0.0)":
  zero custom CSS; where styling lives (theme/GlobalStyles/kit); and the
  conventions learned (channel-alpha row tints not solid .light, solid
  tile + white-glyph icon badges, single mui-mode theme key, dialog
  scroll-lock). Removed the stale "data-theme drives MUI and legacy CSS".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 20:12:08 +02:00
27 changed files with 457 additions and 161 deletions

View File

@@ -14,7 +14,7 @@ Handles attendance, invoicing, leave/trips, projects, vehicles, and HR operation
| ORM | Prisma 6.19.2 → MySQL |
| Auth | JWT (HS256, 15 min) + TOTP 2FA (RFC 6238, otpauth) + bcryptjs |
| Validation | Zod 4.3.6 |
| Frontend | React 18.3.1 + Vite 8.0.0 |
| Frontend | React 18.3.1 + Vite 8.0.0 + Material UI v7 (Emotion) |
| Testing | Vitest 4.1.0 + Supertest |
| PDF | Puppeteer 24.x |
| Email | nodemailer 8.x |
@@ -35,17 +35,21 @@ src/
├── utils/ # totp.ts, pdf.ts, email.ts, audit.ts, formatters, etc.
├── config/ # env.ts (config singleton, Date.toJSON override)
├── types/ # index.ts (AuthData, JwtPayload, ApiResponse, re-exports from Prisma)
├── admin/ # React 18 frontend (57 .tsx files)
├── admin/ # React 18 + Material UI frontend (~105 .tsx files)
│ ├── AdminApp.tsx # Router + lazy-loaded pages
│ ├── contexts/ # AuthContext, AlertContext
│ ├── components/ # Layout, modals, tables, editors
│ ├── theme.ts # MUI theme — light/dark color schemes, tokens, component defaults
│ ├── GlobalStyles.tsx # App-wide global styles via MUI <GlobalStyles> (reset, typography, utilities)
│ ├── ui/ # MUI component kit (AppShell, Button, DataTable, Modal, …) — pages import from here
│ ├── context/ # AuthContext, AlertContext (ThemeContext lives in src/context/)
│ ├── components/ # Non-page components: RichEditor (Quill), PlanGrid, file manager, dashboard/ + warehouse/ widgets
│ ├── pages/ # One file per page/feature
│ ├── hooks/ # useApiCall, useListData, useTableSort, etc.
│ ├── lib/ # React Query options & mutations (queries/) + shared label maps
│ ├── hooks/ # usePaginatedQuery, useTableSort, useDebounce, useReducedMotion, …
│ └── utils/ # api.ts (fetch wrapper with token refresh), formatters, helpers
└── __tests__/ # Vitest tests (auth, numbering)
└── __tests__/ # Vitest tests (~14 files: auth, numbering, warehouse, plan, invoices, …)
prisma/
├── schema.prisma # 32 models, MySQL, snake_case columns
├── schema.prisma # 49 models, MySQL, snake_case columns
└── migrations/ # Applied migrations
dist/ # Compiled server (CommonJS, ES2022)
@@ -236,7 +240,7 @@ if ("error" in body) return error(reply, body.error, 400);
Tests live in `src/__tests__/`. They use Vitest + Supertest against a real test database (`.env.test`).
- Test coverage is minimal: only `auth` and `numbering` are tested.
- The suite spans ~14 files / 152 tests — auth, numbering, warehouse, plan, invoices, exchange-rates, schema coercion, NAS file manager, env, manual-create. Server-side only (no component tests yet).
- Use `buildApp()` helper to spin up the Fastify instance for tests.
- Tests use `vitest.config.ts` with `environment: 'node'` and 15s timeout.
- **Do not mock Prisma** — tests hit a real database to catch schema/query bugs.
@@ -250,9 +254,9 @@ When adding new features, add tests in `src/__tests__/`. Name test files `<featu
- Pages are lazy-loaded via `React.lazy()` in `AdminApp.tsx`.
- Auth state lives in `AuthContext`; use `useAuth()` hook to access it.
- Alerts/toasts use `AlertContext`; use `useAlert()` to show them.
- API calls go through `src/admin/utils/api.ts` which handles token refresh automatically (deduplicates concurrent refresh calls).
- Custom hooks: `useApiCall`, `useListData`, `useTableSort`, `useDebounce`, `useModalLock`.
- Styling: CSS files in `src/admin/` — no CSS-in-JS, no Tailwind. Use CSS variables.
- Data fetching uses **React Query** (query options + mutations in `src/admin/lib/queries/`); `src/admin/utils/api.ts` (`apiFetch`) handles token refresh automatically dedupes concurrent refreshes, and token responses carry `expires_in` so the client refreshes BEFORE expiry (no reactive 401 churn).
- Custom hooks: `usePaginatedQuery`, `useTableSort`, `useDebounce`, `useModalLock`, `useReducedMotion`.
- Styling: **Material UI v7** (Emotion) — theme in `src/admin/theme.ts`, `sx`/`styled()` in components, app-wide rules in `GlobalStyles.tsx`. **No hand-written `.css` files, no Tailwind.** Use `theme.vars` for colours so light/dark resolve automatically.
### Query Invalidation Convention
@@ -274,16 +278,30 @@ When entity A embeds/references entity B, A's mutation handlers invalidate B's d
---
## Frontend — MUI Migration (in progress)
## Frontend — Styling & MUI (migration complete, shipped in v2.0.0)
The admin frontend is being migrated off ~7,100 lines of hand-written CSS onto **MUI (Material UI v7)** — a "Soft-SaaS shell + dense tables" look, dark/light preserved, **incremental and always shippable**.
The admin frontend is **fully on Material UI v7** (Emotion). The old ~7,100 lines of hand-written CSS are gone — **there are no custom `.css` files in `src/admin`**; the only stylesheets imported anywhere are the two third-party libs (`react-quill-new/dist/quill.snow.css`, `leaflet/dist/leaflet.css`). Look-and-feel is "Soft-SaaS shell + dense tables", dark/light preserved. Full history/decisions/gotchas live in agent memory (`project_mui_migration.md`); the original spec/plans are under `docs/superpowers/`.
- **Spec:** `docs/superpowers/specs/2026-06-06-css-mui-migration-design.md`. **Per-phase plans:** `docs/superpowers/plans/2026-06-06-mui-migration-phase-*.md`.
- **Progress is tracked in agent memory** (`project_mui_migration.md` — what's merged, what's next). **Keep it updated as each phase merges.** Memory practice generally: durable, non-obvious facts about ongoing work / decisions / gotchas belong in the agent memory store (one fact per file, indexed in `MEMORY.md`), not left only in chat.
- **Migrating a page:** mirror `src/admin/pages/Vehicles.tsx` (the pilot). Import only from the **`src/admin/ui/` kit** — never `@mui/*` directly in pages. **Preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation; drop the page's `admin-*` CSS usage. Per page: plan → subagent-driven build → spec + code-quality review → user visual check → merge.
- **Kit (`src/admin/ui/`):** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField (date-fns v4, `dd.MM.yyyy`, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable via `sortKey`/`sortBy`/`onSort`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField, SwitchField, Field, Alert. Theme + tokens in `src/admin/theme.ts`; one `data-theme` attribute drives MUI **and** legacy CSS. Dev-only `/ui-kit` showcase route.
- **Gates every step:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — that's a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
- **Dialog gotchas (solved in the kit — don't reintroduce):** dialogs lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroll container); Modal/ConfirmDialog freeze title/label/loading through the close fade (derive-state-from-props) so nothing flashes during the transition. Login renders OUTSIDE the AppShell (sibling route).
**Where styling lives**
- **Theme — `src/admin/theme.ts`:** `cssVariables` with `colorSchemeSelector: "[data-theme='%s']"`, light + dark `colorSchemes`, tokens, component defaults. Use **`theme.vars!.palette.*`** (the `vars` field is typed optional → `!`) so colours resolve per scheme; for alpha use the channel tokens — `rgba(${theme.vars!.palette.primary.mainChannel} / 0.12)`; for per-scheme one-offs use `theme.applyStyles("dark", { … })`.
- **Global rules — `src/admin/GlobalStyles.tsx`:** reset, typography, scrollbar, `::selection`, view-transition timing, and the utility classes (`.text-*`, `.flex-*`, `.mb-*`, ), all theme-aware. (The pre-React bootstrap spinner is inlined in `src/App.tsx` because it mounts before MUI.)
- **Component kit — `src/admin/ui/`:** AppShell, Button, Card, TextField, Select (string-based — convert ids at the boundary), DateField/MonthField/TimeField (date-fns v4, cs), Modal, ConfirmDialog (optional `children` + content freeze), DataTable (sortable + mobile card layout + `rowSx`/`rowDanger`/`rowInactive`), Pagination, Tabs/TabPanel, StatusChip, CheckboxField/SwitchField, Field, Alert, PageHeader, FilterBar, StatCard, ProgressBar, FileUpload, EmptyState, LoadingState, ThemeToggle, PageEnter (staggered page entrance), RichEditorRoot/RichTextView (Quill). Dev-only `/ui-kit` showcase route.
**Building / editing pages**
- Pages import from the **kit** (`src/admin/ui/`); reach for `@mui/material` (`styled`/`sx`) directly only for one-off layout or infra (theme, GlobalStyles, the Quill/Leaflet wrappers).
- Wrap a page's top-level sections in `<PageEnter>`. Filters go in `<FilterBar>` with **bare** controls (no `<Field>` label) at the standard widths.
- When refactoring, **preserve ALL data logic verbatim** (hooks, mutations, `invalidate` arrays, validation, permissions); change only presentation.
**Conventions learned — don't regress**
- **Status row tints** = subtle channel-alpha washes (`rgba(var(--mui-palette-X-mainChannel) / 0.12)`), NEVER a solid `.light` fill: `.light` is a light colour in BOTH schemes, so white dark-mode text on it is invisible. Voided/disabled rows = `opacity` + muted text.
- **Icon badges** = solid `X.main` tile + **white** glyph (not an `X.light` tile + `X.main` glyph — that's low-contrast).
- **Theme is single-source:** `src/context/ThemeContext.tsx` owns the `<html data-theme>` attribute + the View-Transitions cross-fade, and persists under MUI's **`mui-mode`** localStorage key (the same key MUI reads on mount). Do NOT add a second theme key — that desyncs page vs toggle on refresh.
- **Dialogs** lock `<html>` via `useDialogScrollLock` (MUI only locks `<body>`, and `html{overflow-x:hidden}` makes `<html>` the scroller); Modal/ConfirmDialog freeze title/label/loading through the close fade so nothing flashes. Login renders OUTSIDE AppShell.
**Gates every change:** `npx tsc -b --noEmit` (NOT `-p tsconfig.json` — a vacuous solution file that checks nothing), `npm run build`, `npx vitest run`.
---

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "app-ts",
"version": "2.0.0",
"version": "2.0.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "app-ts",
"version": "2.0.0",
"version": "2.0.2",
"license": "ISC",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "2.0.0",
"version": "2.0.2",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -479,6 +479,7 @@ function ViewModal(props: Props) {
onClose={onClose}
onSubmit={onClose}
submitText="Zavřít"
hideCancel
>
<Typography sx={{ mb: 1 }}>
<strong>Datum:</strong> {formatDate(c.shift_date)}

View File

@@ -2,6 +2,7 @@ import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card, Button, EmptyState } from "../../ui";
import { iconBadgeSx, type IconBadgeColor } from "../../theme";
import {
ENTITY_TYPE_LABELS,
getActivityIconClass,
@@ -159,17 +160,20 @@ export default function DashActivityFeed({
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
bgcolor: isDefault ? "grey.600" : `${badgeColor}.main`,
color: "common.white",
}}
sx={[
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: "50%",
flexShrink: 0,
},
iconBadgeSx(
isDefault ? "default" : (badgeColor as IconBadgeColor),
),
]}
>
{getActivityIcon(act.action)}
</Box>

View File

@@ -10,6 +10,7 @@ import DialogActions from "@mui/material/DialogActions";
import { useAuth } from "../../context/AuthContext";
import { useAlert } from "../../context/AlertContext";
import apiFetch from "../../utils/api";
import { iconBadgeSx } from "../../theme";
import { Card, Button, Modal, Field, TextField } from "../../ui";
import useDialogScrollLock from "../../ui/useDialogScrollLock";
@@ -251,17 +252,18 @@ export default function DashProfile({
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
bgcolor: totpEnabled ? "success.main" : "grey.600",
color: "common.white",
}}
sx={[
{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
iconBadgeSx(totpEnabled ? "success" : "default"),
]}
>
<svg
width="18"
@@ -425,7 +427,8 @@ export default function DashProfile({
p: 1.5,
mb: 2,
borderRadius: 2,
bgcolor: "warning.light",
bgcolor:
"rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
color: "warning.main",
fontSize: "0.875rem",
}}

View File

@@ -0,0 +1,110 @@
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { Card } from "../../ui";
import { formatDate } from "../../utils/formatters";
/** The logged-in user's resolved work plan for today (see GET /api/admin/dashboard). */
export interface TodayPlan {
shift_date: string;
category: string;
category_label: string;
category_color: string | null;
project_id: number | null;
project_number: string | null;
project_name: string | null;
note: string;
source: "entry" | "override";
rangeFrom: string | null;
rangeTo: string | null;
}
function projectLabel(p: TodayPlan): string {
const parts = [p.project_number, p.project_name].filter(Boolean);
return parts.length > 0 ? parts.join(" — ") : "Bez projektu";
}
/**
* "Vaše dnešní zařazení" — shows the logged-in user's work-plan entry for today
* (category + project/site + note, with a range badge when the day is part of a
* multi-day range). `plan === null` = the user has plan access but nothing is
* scheduled today (muted empty state). Rendered near the clock-in on the
* dashboard; the caller gates it on the plan permission.
*/
export default function DashTodayPlan({ plan }: { plan: TodayPlan | null }) {
const color = plan?.category_color || "var(--mui-palette-primary-main)";
return (
<Card sx={{ mb: 3 }}>
<Typography
variant="subtitle2"
sx={{ mb: 1.5, fontWeight: 600, color: "text.secondary" }}
>
Vaše dnešní zařazení
</Typography>
{plan ? (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
gap: 0.75,
px: 1.25,
py: 0.5,
borderRadius: 999,
border: 1,
borderColor: "divider",
bgcolor: `color-mix(in srgb, ${color} 12%, transparent)`,
}}
>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: color,
flexShrink: 0,
}}
/>
<Typography
component="span"
sx={{ fontWeight: 700, fontSize: "0.8rem" }}
>
{plan.category_label}
</Typography>
</Box>
{plan.rangeFrom &&
plan.rangeTo &&
plan.rangeFrom !== plan.rangeTo && (
<Typography variant="caption" color="text.secondary">
součást rozsahu {formatDate(plan.rangeFrom)} {" "}
{formatDate(plan.rangeTo)}
</Typography>
)}
</Box>
<Typography variant="h6" sx={{ lineHeight: 1.25 }}>
{projectLabel(plan)}
</Typography>
{plan.note && (
<Typography variant="body2" color="text.secondary">
{plan.note}
</Typography>
)}
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Pro dnešek nemáte naplánováno.
</Typography>
)}
</Card>
);
}

View File

@@ -5,6 +5,7 @@ import Typography from "@mui/material/Typography";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { companySettingsOptions } from "../lib/queries/settings";
import { iconBadgeSx } from "../theme";
import {
attendanceHistoryOptions,
type AttendanceRecord,
@@ -533,17 +534,18 @@ export default function AttendanceHistory() {
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: 2,
bgcolor: "info.main",
color: "#fff",
flexShrink: 0,
}}
sx={[
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: 2,
flexShrink: 0,
},
iconBadgeSx("info"),
]}
>
<svg
width="24"

View File

@@ -12,12 +12,16 @@ import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
import { Card, Button, StatusChip, PageEnter } from "../ui";
import { iconBadgeSx } from "../theme";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
import DashTodayPlan, {
type TodayPlan,
} from "../components/dashboard/DashTodayPlan";
const API_BASE = "/api/admin";
@@ -68,6 +72,7 @@ interface DashData {
pending_orders?: number;
unpaid_invoices?: number;
pending_leave_requests?: number;
today_plan?: TodayPlan | null;
[key: string]: unknown;
}
@@ -253,17 +258,18 @@ export default function Dashboard() {
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "error.main",
color: "#fff",
flexShrink: 0,
}}
sx={[
{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
iconBadgeSx("error"),
]}
>
<svg
width="20"
@@ -315,6 +321,13 @@ export default function Dashboard() {
<DashKpiCards dashData={dashData ?? null} />
)}
{/* My work plan for today — paired with the clock-in below */}
{!dashLoading &&
(hasPermission("attendance.record") ||
hasPermission("attendance.manage")) && (
<DashTodayPlan plan={dashData?.today_plan ?? null} />
)}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions

View File

@@ -64,6 +64,7 @@ import {
LoadingState,
PageEnter,
RichTextView,
headerActionsSx,
} from "../ui";
const API_BASE = "/api/admin";
@@ -1114,14 +1115,7 @@ export default function InvoiceDetail() {
/>
</Box>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={headerActionsSx}>
{hasPermission("invoices.export") && (
<Button
onClick={() => handleViewPdf(invoice.language || "cs")}
@@ -1140,7 +1134,11 @@ export default function InvoiceDetail() {
</Button>
)}
{hasPermission("invoices.delete") && (
<Button onClick={() => setDeleteConfirm(true)} color="error">
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}
@@ -1656,14 +1654,7 @@ export default function InvoiceDetail() {
)}
</Box>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={headerActionsSx}>
{isEdit && invoice && hasPermission("invoices.export") && (
<Button
onClick={() => handleViewPdf(invoice.language || "cs")}
@@ -1710,7 +1701,11 @@ export default function InvoiceDetail() {
</Button>
))}
{hasPermission("invoices.delete") && (
<Button onClick={() => setDeleteConfirm(true)} color="error">
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}

View File

@@ -74,6 +74,7 @@ import {
EmptyState,
LoadingState,
PageEnter,
headerActionsSx,
} from "../ui";
const API_BASE = "/api/admin";
@@ -1118,14 +1119,7 @@ export default function OfferDetail() {
{isCompleted && <StatusChip label="Dokončeno" color="success" />}
</Box>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={headerActionsSx}>
{isEdit && hasPermission("offers.export") && (
<Button
onClick={handlePdf}
@@ -1184,7 +1178,11 @@ export default function OfferDetail() {
</Button>
)}
{isEdit && hasPermission("offers.delete") && (
<Button onClick={() => setDeleteConfirm(true)} color="error">
<Button
onClick={() => setDeleteConfirm(true)}
variant="outlined"
color="error"
>
Smazat
</Button>
)}

View File

@@ -32,6 +32,7 @@ import {
LoadingState,
PageEnter,
RichTextView,
headerActionsSx,
type DataColumn,
} from "../ui";
@@ -424,14 +425,7 @@ export default function OrderDetail() {
/>
</Box>
</Box>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={headerActionsSx}>
{order.invoice ? (
<Button
component={RouterLink}

View File

@@ -29,6 +29,7 @@ import {
ConfirmDialog,
LoadingState,
PageEnter,
headerActionsSx,
} from "../ui";
const API_BASE = "/api/admin";
@@ -320,14 +321,7 @@ export default function ProjectDetail() {
</Box>
</Box>
{canEdit && (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
flexWrap: "wrap",
}}
>
<Box sx={headerActionsSx}>
<Button onClick={handleSave} disabled={saving}>
{saving ? "Ukládání..." : "Uložit"}
</Button>

View File

@@ -33,6 +33,7 @@ import {
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { iconBadgeSx } from "../theme";
const API_BASE = "/api/admin";
const PlusIcon = (
@@ -842,17 +843,18 @@ export default function Settings() {
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5 }}>
<Box
sx={{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: require2FA ? "success.light" : "action.hover",
color: require2FA ? "success.main" : "text.secondary",
flexShrink: 0,
}}
sx={[
{
width: 36,
height: 36,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
iconBadgeSx(require2FA ? "success" : "default"),
]}
>
<svg
width="18"

View File

@@ -157,7 +157,7 @@ export default function WarehouseInventory() {
/>
</Box>
{statusFilter && (
<Button variant="outlined" onClick={resetFilters}>
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</Button>
)}

View File

@@ -24,6 +24,7 @@ import {
PageHeader,
PageEnter,
ConfirmDialog,
headerActionsSx,
type DataColumn,
} from "../ui";
@@ -192,7 +193,7 @@ export default function WarehouseInventoryDetail() {
<PageHeader
title={s.session_number || "Inventura"}
actions={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/inventory"

View File

@@ -24,6 +24,7 @@ import {
PageHeader,
ConfirmDialog,
PageEnter,
headerActionsSx,
type DataColumn,
} from "../ui";
@@ -233,7 +234,7 @@ export default function WarehouseIssueDetail() {
: undefined
}
actions={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/issues"

View File

@@ -248,7 +248,7 @@ export default function WarehouseIssues() {
/>
</Box>
{hasActiveFilters && (
<Button variant="outlined" onClick={resetFilters}>
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</Button>
)}

View File

@@ -29,6 +29,7 @@ import {
PageHeader,
ConfirmDialog,
PageEnter,
headerActionsSx,
type DataColumn,
} from "../ui";
@@ -233,7 +234,7 @@ export default function WarehouseItemDetail() {
// Edit / view mode action buttons
const headerActions = canManage ? (
<Box sx={{ display: "flex", gap: 1 }}>
<Box sx={headerActionsSx}>
{editing || isNew ? (
<>
{!isNew && (
@@ -362,7 +363,7 @@ export default function WarehouseItemDetail() {
title={title}
subtitle={subtitle}
actions={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/items"

View File

@@ -26,6 +26,7 @@ import {
PageHeader,
ConfirmDialog,
PageEnter,
headerActionsSx,
type DataColumn,
} from "../ui";
@@ -301,7 +302,7 @@ export default function WarehouseReceiptDetail() {
title={r.receipt_number || "Nový doklad"}
subtitle={r.supplier?.name}
actions={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box sx={headerActionsSx}>
<Button
component={RouterLink}
to="/warehouse/receipts"

View File

@@ -247,7 +247,7 @@ export default function WarehouseReceipts() {
/>
</Box>
{hasActiveFilters && (
<Button variant="outlined" onClick={resetFilters}>
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</Button>
)}

View File

@@ -376,7 +376,7 @@ export default function WarehouseReservations() {
</Box>
)}
{hasActiveFilters && (
<Button variant="outlined" onClick={resetFilters}>
<Button variant="outlined" color="inherit" onClick={resetFilters}>
Zrušit filtry
</Button>
)}

View File

@@ -1,4 +1,4 @@
import { createTheme } from "@mui/material/styles";
import { createTheme, type Theme } from "@mui/material/styles";
const FONT_BODY = "'Plus Jakarta Sans', system-ui, sans-serif";
const FONT_HEADING = "'Urbanist', sans-serif";
@@ -7,6 +7,19 @@ const FONT_MONO = "'DM Mono', Menlo, monospace";
// Standard Material easing (matches the legacy --transition cubic-bezier).
const EASE = "cubic-bezier(0.4, 0, 0.2, 1)";
// Dark-mode fills for FILLED colored surfaces (contained buttons, filled chips,
// StatCard badges). The palette .main stays BRIGHT in dark mode (it drives text,
// outlined buttons and row tints), but a bright fill makes WHITE text/glyphs
// illegible — so filled surfaces drop to these darker shades (≈ the light-scheme
// values) in dark mode. One rule everywhere: filled colored = white text,
// readable in both themes. Light mode needs no override (its .main is dark).
export const FILLED_DARK_BG = {
error: { bg: "#dc2626", hover: "#b91c1c" },
success: { bg: "#15803d", hover: "#166534" },
warning: { bg: "#b45309", hover: "#92400e" },
info: { bg: "#1d4ed8", hover: "#1e40af" },
} as const;
export const theme = createTheme({
cssVariables: {
colorSchemeSelector: "[data-theme='%s']",
@@ -80,6 +93,47 @@ export const theme = createTheme({
},
"&:active": { transform: "translateY(0) scale(0.97)" },
},
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
// contrastText → near-black text on colored fills in dark mode, which
// also clashed with the always-white primary — "black text on one red
// button, white on another"). In dark mode the fill drops to a darker
// shade so white stays legible.
containedError: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.error.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.error.hover },
},
}),
}),
containedSuccess: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.success.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.success.hover },
},
}),
}),
containedWarning: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.warning.hover },
},
}),
}),
containedInfo: ({ theme }) => ({
color: "#fff",
...theme.applyStyles("dark", {
"&:not(.Mui-disabled)": {
backgroundColor: FILLED_DARK_BG.info.bg,
"&:hover": { backgroundColor: FILLED_DARK_BG.info.hover },
},
}),
}),
},
},
MuiCard: {
@@ -99,6 +153,54 @@ export const theme = createTheme({
fontWeight: 700,
transition: `background-color 200ms ${EASE}, box-shadow 200ms ${EASE}`,
},
// Filled colored chips follow the same rule as filled buttons: white
// label in both themes, darker fill in dark mode so white stays legible.
// (Chip has no per-color `filledX` key, so target the color class and
// scope to the filled variant.)
colorError: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.error.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.error.hover,
},
}),
},
}),
colorSuccess: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.success.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.success.hover,
},
}),
},
}),
colorWarning: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.warning.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.warning.hover,
},
}),
},
}),
colorInfo: ({ theme }) => ({
"&.MuiChip-filled": {
color: "#fff",
...theme.applyStyles("dark", {
backgroundColor: FILLED_DARK_BG.info.bg,
"&.MuiChip-clickable:hover": {
backgroundColor: FILLED_DARK_BG.info.hover,
},
}),
},
}),
},
},
MuiOutlinedInput: {
@@ -124,3 +226,29 @@ export const fonts = {
heading: FONT_HEADING,
mono: FONT_MONO,
};
export type IconBadgeColor =
| "default"
| "primary"
| "success"
| "warning"
| "error"
| "info";
/**
* sx fragment for a solid icon-badge tile: a `<color>.main` fill with a WHITE
* glyph, darkened in dark mode (FILLED_DARK_BG) so white stays legible on the
* otherwise-bright success/warning/info/error fills. `default` = neutral grey.
* Single source of truth for every labelled icon badge — use in an sx array
* next to the tile's size/shape so they can never drift apart:
* sx={[{ width: 40, height: 40, borderRadius: 2, display: "flex", … }, iconBadgeSx(color)]}
*/
export function iconBadgeSx(color: IconBadgeColor) {
const bg = color === "default" ? "grey.600" : `${color}.main`;
const darkBg = (FILLED_DARK_BG as Record<string, { bg: string }>)[color]?.bg;
return (theme: Theme) => ({
bgcolor: bg,
color: "common.white",
...(darkBg ? theme.applyStyles("dark", { backgroundColor: darkBg }) : {}),
});
}

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import type { SxProps, Theme } from "@mui/material/styles";
export interface PageHeaderProps {
title: string;
@@ -8,6 +9,26 @@ export interface PageHeaderProps {
actions?: ReactNode;
}
/**
* Shared sx for a header action-button group. On mobile each button gets its
* own full-width row (column + stretch); from `sm` up it's the usual right-
* aligned wrapping row. Use on the Box that wraps the header's action Buttons
* (page headers + detail-page headers) so the mobile layout is identical
* everywhere — no more buttons wrapping into a ragged grid on phones.
*/
export const headerActionsSx: SxProps<Theme> = {
display: "flex",
flexDirection: { xs: "column", sm: "row" },
alignItems: { xs: "stretch", sm: "center" },
justifyContent: { sm: "flex-end" },
flexWrap: { sm: "wrap" },
gap: 1,
width: { xs: "100%", sm: "auto" },
// Some detail-page headers keep a StatusChip in this same group — keep it at
// its natural size on mobile instead of stretching it into a full-width bar.
"& > .MuiChip-root": { alignSelf: { xs: "flex-start", sm: "auto" } },
};
/**
* Standard page header: title (+ optional subtitle) on the left,
* action controls on the right. No framer-motion — pages add their own entrance.
@@ -36,19 +57,7 @@ export default function PageHeader({
</Typography>
)}
</Box>
{actions && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "flex-end",
gap: 1,
flexWrap: "wrap",
}}
>
{actions}
</Box>
)}
{actions && <Box sx={headerActionsSx}>{actions}</Box>}
</Box>
);
}

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Card from "./Card";
import { iconBadgeSx } from "../theme";
export type StatCardColor =
| "default"
@@ -30,27 +31,23 @@ export default function StatCard({
color = "default",
footer,
}: StatCardProps) {
// Solid tile + white glyph: high-contrast in both themes (the old light tint
// left the icon dark/low-visibility, esp. for the default variant).
const iconBg = color === "default" ? "grey.600" : `${color}.main`;
const iconColor = "common.white";
return (
<Card>
<Box sx={{ display: "flex", alignItems: "flex-start", gap: 1.5 }}>
{icon && (
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 2,
bgcolor: iconBg,
color: iconColor,
flexShrink: 0,
}}
sx={[
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 40,
height: 40,
borderRadius: 2,
flexShrink: 0,
},
iconBadgeSx(color),
]}
>
{icon}
</Box>

View File

@@ -18,7 +18,7 @@ export { default as Pagination } from "./Pagination";
export { default as DateField } from "./DateField";
export { default as MonthField } from "./MonthField";
export { default as TimeField } from "./TimeField";
export { default as PageHeader } from "./PageHeader";
export { default as PageHeader, headerActionsSx } from "./PageHeader";
export { default as EmptyState } from "./EmptyState";
export { default as LoadingState } from "./LoadingState";
export { default as FilterBar } from "./FilterBar";

View File

@@ -4,6 +4,7 @@ import { requireAuth } from "../../middleware/auth";
import { success } from "../../utils/response";
import { localTimeStr } from "../../utils/date";
import { toCzk } from "../../services/exchange-rates";
import { resolveCell } from "../../services/plan.service";
export default async function dashboardRoutes(
fastify: FastifyInstance,
@@ -42,6 +43,29 @@ export default async function dashboardRoutes(
result.my_shift = { has_ongoing: myShift !== null };
}
// Today's work plan (personal) — powers the "Vaše dnešní zařazení" card.
// resolveCell applies override-beats-range precedence; null = the user has
// plan access but nothing is scheduled today. The category key is enriched
// with its label + colour so the widget needs no extra query. todayStr uses
// local (Europe/Prague) calendar date — the plan's @db.Date day.
if (has("attendance.record") || has("attendance.manage")) {
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
const cell = await resolveCell(userId, todayStr);
if (cell) {
const cat = await prisma.plan_categories.findUnique({
where: { key: cell.category },
select: { label: true, color: true },
});
result.today_plan = {
...cell,
category_label: cat?.label ?? cell.category,
category_color: cat?.color ?? null,
};
} else {
result.today_plan = null;
}
}
// Attendance admin — only for attendance.manage
if (has("attendance.manage")) {
const [todayAttendance, onLeaveToday, usersCount] = await Promise.all([