fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,15 @@ export default function AdminApp() {
|
||||
{import.meta.env.DEV && UiKit && (
|
||||
<Route path="ui-kit" element={<UiKit />} />
|
||||
)}
|
||||
{/*
|
||||
Auth gating is delegated to <AppShell>: every authenticated
|
||||
page is nested under this layout route, and AppShell
|
||||
redirects to /login when there is no user (per-page
|
||||
permission checks live in the pages themselves). NOTE: any
|
||||
NEW top-level route added OUTSIDE this <AppShell> wrapper
|
||||
(like `login`/`ui-kit` above) is PUBLIC by default — wrap it
|
||||
in AppShell or add its own guard if it needs auth.
|
||||
*/}
|
||||
<Route element={<AppShell />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="odin" element={<Odin />} />
|
||||
|
||||
@@ -12,7 +12,10 @@ export default function AlertContainer() {
|
||||
key={alert.id}
|
||||
open
|
||||
anchorOrigin={{ vertical: "bottom", horizontal: "right" }}
|
||||
sx={{ bottom: { xs: 16 + index * 72 } }}
|
||||
// Offset each stacked toast by its position in the list. Applied at
|
||||
// every breakpoint (not just xs) so larger screens don't fall back to
|
||||
// MUI's default bottom position and overlap with 3+ alerts.
|
||||
sx={{ bottom: 16 + index * 72 }}
|
||||
>
|
||||
<MuiAlert
|
||||
severity={alert.type}
|
||||
|
||||
@@ -116,7 +116,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||
}
|
||||
return (
|
||||
<StatusChip
|
||||
key={log.id ?? i}
|
||||
// Prefer the DB id; fall back to a project_id+index composite so
|
||||
// logs without an id (e.g. active/unsaved rows) still get a key
|
||||
// that's more stable than a bare index.
|
||||
key={log.id ?? `${log.project_id}-${i}`}
|
||||
color={isActive ? "info" : "default"}
|
||||
label={`${log.project_name || `#${log.project_id}`} ${
|
||||
durationValid
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function BulkAttendanceModal({
|
||||
color: "primary.main",
|
||||
}}
|
||||
>
|
||||
{form.user_ids.length === users.length
|
||||
{users.length > 0 && form.user_ids.length === users.length
|
||||
? "Odznačit vše"
|
||||
: "Vybrat vše"}
|
||||
</Typography>
|
||||
|
||||
@@ -19,6 +19,9 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
// Logged to the console only — the app has no client telemetry/error
|
||||
// reporting service today. This is the hook to forward to one (Sentry,
|
||||
// etc.) if/when it's added.
|
||||
console.error("ErrorBoundary caught:", error, info);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -48,17 +48,33 @@ export default function OrderConfirmationModal({
|
||||
const [items, setItems] = useState<ConfirmationItem[]>(initialItems);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// The modal is permanently mounted and merely toggled via `isOpen`, so its
|
||||
// local state survives between opens and the `useState(initialItems)` snapshot
|
||||
// goes stale. Re-seed everything from the current props each time the modal
|
||||
// (re)opens so a fresh open always reflects the latest order data and starts
|
||||
// on the "choose" step.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setStep("choose");
|
||||
setLang("cs");
|
||||
setApplyVatState(applyVat);
|
||||
setItems(initialItems);
|
||||
// initialItems/applyVat are captured at open time; intentionally not in the
|
||||
// dep array so an unrelated parent re-render doesn't clobber the user's edits.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen]);
|
||||
|
||||
const handleUseExisting = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onGenerate(lang, applyVatState, undefined);
|
||||
// Only close on success — a generation error must keep the modal open.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Chyba při generování potvrzení:", err);
|
||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStep("choose");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -66,13 +82,13 @@ export default function OrderConfirmationModal({
|
||||
setLoading(true);
|
||||
try {
|
||||
await onGenerate(lang, applyVatState, items);
|
||||
// Only close on success — on error keep the user's edited items intact.
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Chyba při generování potvrzení:", err);
|
||||
alert.error("Nepodařilo se vygenerovat potvrzení");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setStep("choose");
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -145,6 +145,10 @@ export default function PlanCategoriesModal({
|
||||
aria-label={`Barva – ${c.label}`}
|
||||
/>
|
||||
<TextField
|
||||
// Uncontrolled defaultValue won't update if the label changes
|
||||
// externally (e.g. another tab renames it). Keying on c.label
|
||||
// remounts the input so it re-seeds, mirroring the color input.
|
||||
key={c.label}
|
||||
defaultValue={c.label}
|
||||
disabled={busy}
|
||||
onBlur={(e) => {
|
||||
|
||||
@@ -28,6 +28,35 @@ interface Project {
|
||||
project_number?: string;
|
||||
}
|
||||
|
||||
/** Fields shared by every plan-record save payload the modal emits. */
|
||||
interface PlanRecordFields {
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/** Body for creating a plan entry (a single day or a multi-day range). */
|
||||
export interface PlanEntryCreateBody extends PlanRecordFields {
|
||||
user_id: number;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
/** Body for updating an existing plan entry (the entry id is passed separately). */
|
||||
export interface PlanEntryUpdateBody extends PlanRecordFields {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
}
|
||||
|
||||
/** Body for creating a single-day override. */
|
||||
export interface PlanOverrideCreateBody extends PlanRecordFields {
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
}
|
||||
|
||||
/** Body for updating an existing override (the override id is passed separately). */
|
||||
export type PlanOverrideUpdateBody = PlanRecordFields;
|
||||
|
||||
export type PlanCellModalMode =
|
||||
| { kind: "closed" }
|
||||
| { kind: "create"; userId: number; date: string }
|
||||
@@ -74,11 +103,11 @@ interface Props {
|
||||
projects: Project[];
|
||||
categories: PlanCategory[];
|
||||
onClose: () => void;
|
||||
onSaveEntry: (body: any) => Promise<void>;
|
||||
onUpdateEntry: (id: number, body: any) => Promise<void>;
|
||||
onSaveEntry: (body: PlanEntryCreateBody) => Promise<void>;
|
||||
onUpdateEntry: (id: number, body: PlanEntryUpdateBody) => Promise<void>;
|
||||
onDeleteEntry: (id: number) => Promise<void>;
|
||||
onSaveOverride: (body: any) => Promise<void>;
|
||||
onUpdateOverride: (id: number, body: any) => Promise<void>;
|
||||
onSaveOverride: (body: PlanOverrideCreateBody) => Promise<void>;
|
||||
onUpdateOverride: (id: number, body: PlanOverrideUpdateBody) => Promise<void>;
|
||||
onDeleteOverride: (id: number) => Promise<void>;
|
||||
onCreateOverrideFromRange: (
|
||||
entryId: number,
|
||||
@@ -128,6 +157,71 @@ export default function PlanCellModal(props: Props) {
|
||||
return <EditForm {...props} />;
|
||||
}
|
||||
|
||||
interface EditFormInitial {
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
is_range: boolean;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial form values for every mode kind. Unhandled kinds get a harmless
|
||||
* default (EditForm renders null for them — but only AFTER its hooks have run).
|
||||
* Keeping this a plain function (not an inline IIFE with early returns) lets
|
||||
* EditForm call its useState hooks unconditionally, satisfying the Rules of
|
||||
* Hooks.
|
||||
*/
|
||||
function computeEditFormInitial(
|
||||
mode: PlanCellModalMode,
|
||||
activeCategories: { key: string }[],
|
||||
): EditFormInitial {
|
||||
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: null,
|
||||
// Default to "work" when it's active, otherwise the first active
|
||||
// category, so a new entry never starts on a retired key.
|
||||
category: activeCategories.some((c) => c.key === "work")
|
||||
? "work"
|
||||
: (activeCategories[0]?.key ?? "work"),
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
if (mode.kind === "edit-range") {
|
||||
return {
|
||||
date_from: mode.range.date_from,
|
||||
date_to: mode.range.date_to,
|
||||
is_range: mode.range.date_from !== mode.range.date_to,
|
||||
project_id: mode.range.project_id,
|
||||
category: mode.range.category,
|
||||
note: mode.range.note,
|
||||
};
|
||||
}
|
||||
if (mode.kind === "edit-override") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: mode.cell.project_id,
|
||||
category: mode.cell.category,
|
||||
note: mode.cell.note,
|
||||
};
|
||||
}
|
||||
// Unhandled kinds: harmless defaults (EditForm returns null after its hooks).
|
||||
return {
|
||||
date_from: "",
|
||||
date_to: "",
|
||||
is_range: false,
|
||||
project_id: null,
|
||||
category: "work",
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
|
||||
function EditForm(props: Props) {
|
||||
const {
|
||||
mode,
|
||||
@@ -140,13 +234,27 @@ function EditForm(props: Props) {
|
||||
onDeleteOverride,
|
||||
projects,
|
||||
} = props;
|
||||
// An override is a single-day record — both editing one ("edit-override")
|
||||
// and creating one ("create-override") lock the date and hide the range UI.
|
||||
const isOverride =
|
||||
mode.kind === "edit-override" || mode.kind === "create-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
// Computed for ALL mode kinds (the helper has a fallback) so the hooks below
|
||||
// are unconditional.
|
||||
const initial = computeEditFormInitial(mode, activeCategories);
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||||
const [dateTo, setDateTo] = useState(initial.date_to);
|
||||
const [isRange, setIsRange] = useState(initial.is_range);
|
||||
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
|
||||
const [category, setCategory] = useState(initial.category);
|
||||
const [note, setNote] = useState(initial.note);
|
||||
|
||||
// Narrow mode to the kinds EditForm actually handles. The call site in
|
||||
// PlanCellModal only passes "create" | "create-override" | "edit-range" |
|
||||
// "edit-override" but the parameter type is the full union, so we narrow
|
||||
// explicitly here.
|
||||
// Narrow mode to the kinds EditForm actually handles. The call site only ever
|
||||
// passes the four editable kinds; we guard AFTER all hooks so the hook order
|
||||
// is identical on every render (Rules of Hooks).
|
||||
if (
|
||||
mode.kind !== "create" &&
|
||||
mode.kind !== "create-override" &&
|
||||
@@ -156,55 +264,6 @@ function EditForm(props: Props) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An override is a single-day record — both editing one ("edit-override")
|
||||
// and creating one ("create-override") lock the date and hide the range UI.
|
||||
const isOverride =
|
||||
mode.kind === "edit-override" || mode.kind === "create-override";
|
||||
const activeCategories = props.categories.filter((c) => c.is_active);
|
||||
|
||||
const initial = (() => {
|
||||
if (mode.kind === "create" || mode.kind === "create-override") {
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: null as number | null,
|
||||
// Default to "work" when it's active, otherwise the first active
|
||||
// category, so a new entry never starts on a retired key.
|
||||
category: activeCategories.some((c) => c.key === "work")
|
||||
? "work"
|
||||
: (activeCategories[0]?.key ?? "work"),
|
||||
note: "",
|
||||
};
|
||||
}
|
||||
if (mode.kind === "edit-range") {
|
||||
return {
|
||||
date_from: mode.range.date_from,
|
||||
date_to: mode.range.date_to,
|
||||
is_range: mode.range.date_from !== mode.range.date_to,
|
||||
project_id: mode.range.project_id,
|
||||
category: mode.range.category,
|
||||
note: mode.range.note,
|
||||
};
|
||||
}
|
||||
// mode.kind === "edit-override" (narrowed above)
|
||||
return {
|
||||
date_from: mode.date,
|
||||
date_to: mode.date,
|
||||
is_range: false,
|
||||
project_id: mode.cell.project_id,
|
||||
category: mode.cell.category,
|
||||
note: mode.cell.note,
|
||||
};
|
||||
})();
|
||||
|
||||
const [dateFrom, setDateFrom] = useState(initial.date_from);
|
||||
const [dateTo, setDateTo] = useState(initial.date_to);
|
||||
const [isRange, setIsRange] = useState(initial.is_range);
|
||||
const [projectId, setProjectId] = useState<number | null>(initial.project_id);
|
||||
const [category, setCategory] = useState(initial.category);
|
||||
const [note, setNote] = useState(initial.note);
|
||||
|
||||
const title =
|
||||
mode.kind === "create"
|
||||
? "Nový záznam plánu"
|
||||
|
||||
@@ -466,9 +466,23 @@ export default function PlanGrid({
|
||||
}: Props) {
|
||||
const today = useMemo(() => todayIso(), []);
|
||||
const catMap = useMemo(() => categoryMap(categories), [categories]);
|
||||
// Index projects by id once per projects change instead of a linear
|
||||
// projects.find() inside the per-cell render loop (which was
|
||||
// O(days * users * cells * projects)). Hooks must run before the early
|
||||
// return below, so this is computed unconditionally.
|
||||
const projectMap = useMemo(() => {
|
||||
const m = new Map<number, Project>();
|
||||
for (const p of projects) m.set(p.id, p);
|
||||
return m;
|
||||
}, [projects]);
|
||||
// Memoize the day list so it isn't rebuilt on every render (only when the
|
||||
// visible range changes). Stable identity also keeps the row map cheap.
|
||||
const days = useMemo(
|
||||
() => (data ? eachDay(data.date_from, data.date_to) : []),
|
||||
[data?.date_from, data?.date_to],
|
||||
);
|
||||
|
||||
if (!data) return <LoadingState />;
|
||||
const days = eachDay(data.date_from, data.date_to);
|
||||
const users = data.users;
|
||||
// pulseKey?.nonce is included in the data-pulse attribute so a second
|
||||
// mutation on the same cell re-triggers the animation (CSS animations
|
||||
@@ -610,12 +624,9 @@ export default function PlanGrid({
|
||||
cell={c}
|
||||
project={
|
||||
c.project_id
|
||||
? (projects.find(
|
||||
(p) => p.id === c.project_id,
|
||||
) ?? null)
|
||||
? (projectMap.get(c.project_id) ?? null)
|
||||
: null
|
||||
}
|
||||
readonly={!canEdit}
|
||||
categoryLabel={planCategoryLabel(
|
||||
c.category,
|
||||
catMap,
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { Project } from "../lib/queries/projects";
|
||||
interface Props {
|
||||
cell: ResolvedCell | null;
|
||||
project: Project | null;
|
||||
readonly?: boolean;
|
||||
categoryLabel: string;
|
||||
}
|
||||
|
||||
// Purely presentational — renders the category/project chips and note for a
|
||||
// single resolved plan cell. It has no interactive handlers of its own (the
|
||||
// click target is the cell <button> in PlanGrid, which already applies the
|
||||
// read-only styling/behaviour), so there is no read-only state to gate here.
|
||||
export default function PlanRangeChips({
|
||||
cell,
|
||||
project,
|
||||
readonly,
|
||||
categoryLabel,
|
||||
}: Props) {
|
||||
void readonly;
|
||||
if (!cell) return null;
|
||||
// Prefer the server-embedded project label (always present). Fall back to
|
||||
// the looked-up `project` prop only if the cell predates the embed (stale
|
||||
|
||||
@@ -250,6 +250,11 @@ export default function ProjectFileManager({
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const isCancelling = useRef(false);
|
||||
// dragenter/dragleave fire on every child boundary crossed during a drag.
|
||||
// Counting enter/leave events keeps the overlay steady instead of flickering
|
||||
// as the cursor moves over nested elements; the overlay only hides when the
|
||||
// depth returns to 0 (the cursor truly left the drop zone).
|
||||
const dragDepth = useRef(0);
|
||||
|
||||
const [currentPath, setCurrentPath] = useState("");
|
||||
|
||||
@@ -333,7 +338,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
errorMsg = data.error || "Chyba při nahrávání";
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: upload failed", e);
|
||||
errorMsg = "Chyba připojení";
|
||||
}
|
||||
}
|
||||
@@ -362,21 +368,32 @@ export default function ProjectFileManager({
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
if (!canManage) return;
|
||||
handleUpload(e.dataTransfer.files);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canManage) return;
|
||||
dragDepth.current += 1;
|
||||
setDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
// Must preventDefault on dragover too, otherwise the browser rejects the
|
||||
// drop. The overlay state is driven by enter/leave (see dragDepth).
|
||||
e.preventDefault();
|
||||
if (canManage) {
|
||||
setDragOver(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
if (!canManage) return;
|
||||
dragDepth.current = Math.max(0, dragDepth.current - 1);
|
||||
if (dragDepth.current === 0) {
|
||||
setDragOver(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
@@ -405,7 +422,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se vytvořit složku");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: create folder failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setCreatingFolder(false);
|
||||
@@ -435,7 +453,8 @@ export default function ProjectFileManager({
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: download failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
};
|
||||
@@ -468,7 +487,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se smazat");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: delete failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
@@ -505,7 +525,8 @@ export default function ProjectFileManager({
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se přejmenovat");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("ProjectFileManager: rename failed", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setRenamingItem(null);
|
||||
@@ -887,6 +908,7 @@ export default function ProjectFileManager({
|
||||
{/* Drop zone + table */}
|
||||
<Box
|
||||
onDrop={handleDrop}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
sx={{
|
||||
|
||||
@@ -88,7 +88,9 @@ export default function RichEditor({
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(content: string, _delta: any, source: string) => {
|
||||
// `_delta` is react-quill-new's loosely-typed Delta object; we never read
|
||||
// it, so `unknown` is enough and avoids an `any` escape hatch.
|
||||
(content: string, _delta: unknown, source: string) => {
|
||||
if (source !== "user") return;
|
||||
if (content === lastValueRef.current) return;
|
||||
lastValueRef.current = content;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRef } from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -17,8 +18,6 @@ import {
|
||||
formatDate,
|
||||
} from "../utils/attendanceHelpers";
|
||||
|
||||
let _logKeyCounter = 0;
|
||||
|
||||
// ---------- Shared types ----------
|
||||
|
||||
export interface ShiftFormData {
|
||||
@@ -226,6 +225,11 @@ export default function ShiftFormModal({
|
||||
const isCreate = mode === "create";
|
||||
const isWorkType = form.leave_type === "work";
|
||||
|
||||
// Per-instance counter for stable React keys on newly-added project rows.
|
||||
// (Was a module-level mutable `let`, which is shared across every modal
|
||||
// instance and fragile under StrictMode / concurrent instances.)
|
||||
const logKeyCounter = useRef(0);
|
||||
|
||||
const updateField = (field: keyof ShiftFormData, value: string | number) => {
|
||||
setForm({ ...form, [field]: value });
|
||||
};
|
||||
@@ -244,7 +248,7 @@ export default function ShiftFormModal({
|
||||
setProjectLogs([
|
||||
...projectLogs,
|
||||
{
|
||||
_key: `log-${++_logKeyCounter}`,
|
||||
_key: `log-${++logKeyCounter.current}`,
|
||||
project_id: "",
|
||||
hours: "",
|
||||
minutes: "",
|
||||
@@ -327,7 +331,9 @@ export default function ShiftFormModal({
|
||||
inputMode="decimal"
|
||||
value={form.leave_hours}
|
||||
onChange={(e) =>
|
||||
updateField("leave_hours", parseFloat(e.target.value))
|
||||
// Empty input -> parseFloat returns NaN, which serializes to an
|
||||
// invalid value; fall back to 0 like the Number(...)||0 pattern.
|
||||
updateField("leave_hours", Number(e.target.value) || 0)
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
/>
|
||||
@@ -431,7 +437,10 @@ export default function ShiftFormModal({
|
||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||
{projectLogs.map((log, i) => (
|
||||
<ProjectLogRow
|
||||
key={log._key || i}
|
||||
// Stable key: client-added rows carry `_key`; server-loaded rows
|
||||
// carry a DB `id`. Fall back to the index only when neither is
|
||||
// present (shouldn't happen, but avoids a key collision).
|
||||
key={log._key ?? (log.id != null ? `id-${log.id}` : i)}
|
||||
log={log}
|
||||
index={i}
|
||||
projectList={projectList}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Intentional no-op: this component is still mounted by AppShell.tsx as a
|
||||
// placeholder for a future keyboard-shortcuts help overlay. It renders nothing
|
||||
// today. Keep the harmless `null` return (and the AppShell import) until the
|
||||
// overlay is actually built — do not delete the file while AppShell references it.
|
||||
export default function ShortcutsHelp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import { StatCard, type StatCardColor } from "../../ui";
|
||||
@@ -121,6 +121,7 @@ const KPI_COLOR_MAP: Record<string, StatCardColor> = {
|
||||
};
|
||||
|
||||
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
const reduce = useReducedMotion();
|
||||
const kpiCards = buildKpiCards(dashData);
|
||||
if (kpiCards.length === 0) {
|
||||
return null;
|
||||
@@ -129,8 +130,8 @@ export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
|
||||
return (
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -125,6 +126,8 @@ export default function DashProfile({
|
||||
}: DashProfileProps) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const reduce = useReducedMotion();
|
||||
const totpSetupRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The 2FA setup dialog is bespoke (multi-step: setup → backup codes) and
|
||||
@@ -156,21 +159,30 @@ export default function DashProfile({
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
const dataToSave = { ...formData };
|
||||
|
||||
if (dataToSave.new_password && !dataToSave.current_password) {
|
||||
if (formData.new_password && !formData.current_password) {
|
||||
alert.error("Pro změnu hesla zadejte aktuální heslo");
|
||||
return;
|
||||
}
|
||||
if (dataToSave.current_password && !dataToSave.new_password) {
|
||||
if (formData.current_password && !formData.new_password) {
|
||||
alert.error("Pro změnu hesla zadejte nové heslo");
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip empty password fields so Zod doesn't reject ""
|
||||
if (!dataToSave.current_password)
|
||||
delete (dataToSave as any).current_password;
|
||||
if (!dataToSave.new_password) delete (dataToSave as any).new_password;
|
||||
// Build the payload with the password fields optional so empty ones can be
|
||||
// omitted (Zod rejects ""), without resorting to `as any` deletes.
|
||||
const dataToSave: Partial<
|
||||
Pick<ProfileFormData, "current_password" | "new_password">
|
||||
> &
|
||||
Omit<ProfileFormData, "current_password" | "new_password"> = {
|
||||
username: formData.username,
|
||||
email: formData.email,
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
};
|
||||
if (formData.current_password)
|
||||
dataToSave.current_password = formData.current_password;
|
||||
if (formData.new_password) dataToSave.new_password = formData.new_password;
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/profile`, {
|
||||
@@ -185,13 +197,21 @@ export default function DashProfile({
|
||||
email: dataToSave.email,
|
||||
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
|
||||
});
|
||||
// Refresh anything keyed on the current user's data so stale views
|
||||
// (dashboard widgets, user lists) pick up the edited profile.
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
setShowModal(false);
|
||||
// The 300ms wait is load-bearing: it lets the modal's close fade finish
|
||||
// before the success toast appears, so the toast doesn't flash over the
|
||||
// still-fading dialog.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
alert.success("Profil byl upraven");
|
||||
} else {
|
||||
alert.error(data.error || "Nepodařilo se uložit profil");
|
||||
}
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error("DashProfile: uložení profilu selhalo", err);
|
||||
alert.error("Chyba připojení");
|
||||
}
|
||||
};
|
||||
@@ -216,8 +236,8 @@ export default function DashProfile({
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<Card>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { Link as RouterLink } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import { useAuth } from "../../context/AuthContext";
|
||||
import { useAlert } from "../../context/AlertContext";
|
||||
@@ -16,6 +17,14 @@ interface Vehicle {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Standard { success, data, error, message } envelope returned by the API.
|
||||
interface ApiResult<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
interface TripForm {
|
||||
vehicle_id: string;
|
||||
trip_date: string;
|
||||
@@ -61,6 +70,8 @@ export default function DashQuickActions({
|
||||
}: DashQuickActionsProps) {
|
||||
const { hasPermission } = useAuth();
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const [showTripModal, setShowTripModal] = useState(false);
|
||||
const [tripSubmitting, setTripSubmitting] = useState(false);
|
||||
@@ -93,16 +104,17 @@ export default function DashQuickActions({
|
||||
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/vehicles`);
|
||||
const result = await response.json();
|
||||
const result: ApiResult<Vehicle[] | { vehicles?: Vehicle[] }> =
|
||||
await response.json();
|
||||
if (result.success) {
|
||||
setTripVehicles(
|
||||
Array.isArray(result.data)
|
||||
? result.data
|
||||
: result.data?.vehicles || [],
|
||||
: (result.data?.vehicles ?? []),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// vozidla se nenacetla
|
||||
} catch (e) {
|
||||
console.error("DashQuickActions: nepodařilo se načíst vozidla", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -113,12 +125,16 @@ export default function DashQuickActions({
|
||||
}
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
|
||||
const result: ApiResult<{ last_km: number | string }> =
|
||||
await response.json();
|
||||
if (result.success && result.data) {
|
||||
setTripForm((prev) => ({
|
||||
...prev,
|
||||
start_km: String(result.data!.last_km ?? ""),
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
// last_km se nenacetlo
|
||||
} catch (e) {
|
||||
console.error("DashQuickActions: nepodařilo se načíst poslední km", e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,7 +155,7 @@ export default function DashQuickActions({
|
||||
if (
|
||||
tripForm.start_km &&
|
||||
tripForm.end_km &&
|
||||
parseInt(tripForm.end_km) <= parseInt(tripForm.start_km)
|
||||
parseInt(tripForm.end_km, 10) <= parseInt(tripForm.start_km, 10)
|
||||
) {
|
||||
errs.end_km = "Musí být větší než počáteční";
|
||||
}
|
||||
@@ -161,14 +177,19 @@ export default function DashQuickActions({
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(tripForm),
|
||||
});
|
||||
const result = await response.json();
|
||||
const result: ApiResult<unknown> = await response.json();
|
||||
if (result.success) {
|
||||
// A new trip changes the trip list and the dashboard vehicle widgets —
|
||||
// invalidate the broad domains (prefix-matching covers sub-queries).
|
||||
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
||||
setShowTripModal(false);
|
||||
alert.success(result.message);
|
||||
alert.success(result.message ?? "Jízda uložena");
|
||||
} else {
|
||||
alert.error(result.error);
|
||||
alert.error(result.error ?? "Uložení jízdy selhalo");
|
||||
}
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error("DashQuickActions: uložení jízdy selhalo", e);
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setTripSubmitting(false);
|
||||
@@ -176,8 +197,8 @@ export default function DashQuickActions({
|
||||
};
|
||||
|
||||
const tripDistance = (): number => {
|
||||
const s = parseInt(tripForm.start_km) || 0;
|
||||
const e = parseInt(tripForm.end_km) || 0;
|
||||
const s = parseInt(tripForm.start_km, 10) || 0;
|
||||
const e = parseInt(tripForm.end_km, 10) || 0;
|
||||
return e > s ? e - s : 0;
|
||||
};
|
||||
|
||||
@@ -294,8 +315,8 @@ export default function DashQuickActions({
|
||||
<>
|
||||
<Box
|
||||
component={motion.div}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.08 }}
|
||||
sx={{
|
||||
display: "grid",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
@@ -69,6 +69,7 @@ function getDeviceIcon(iconType?: string) {
|
||||
export default function DashSessions() {
|
||||
const alert = useAlert();
|
||||
const queryClient = useQueryClient();
|
||||
const reduce = useReducedMotion();
|
||||
|
||||
const { data: sessions = [], isPending: sessionsLoading } =
|
||||
useQuery(sessionsOptions());
|
||||
@@ -128,8 +129,8 @@ export default function DashSessions() {
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
initial={reduce ? false : { opacity: 0, y: 12 }}
|
||||
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.15 }}
|
||||
>
|
||||
<Card sx={{ display: "flex", flexDirection: "column" }}>
|
||||
|
||||
@@ -125,7 +125,19 @@ export default function OdinSidebar({
|
||||
return (
|
||||
<Box
|
||||
key={conv.id}
|
||||
role="button"
|
||||
tabIndex={busy ? -1 : 0}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => !busy && onSelect(conv.id)}
|
||||
onKeyDown={(e) => {
|
||||
// Activate the row on Enter/Space like a native button. The
|
||||
// nested kebab IconButton stays a real <button> and handles its
|
||||
// own keys (its onClick stopPropagation prevents row selection).
|
||||
if (!busy && (e.key === "Enter" || e.key === " ")) {
|
||||
e.preventDefault();
|
||||
onSelect(conv.id);
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
@@ -139,6 +151,7 @@ export default function OdinSidebar({
|
||||
bgcolor: isActive ? "action.selected" : "action.hover",
|
||||
},
|
||||
"&:hover .odin-conv-menu": { opacity: 1 },
|
||||
"&:focus-visible .odin-conv-menu": { opacity: 1 },
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
|
||||
@@ -5,6 +5,7 @@ import TextField from "@mui/material/TextField";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import useDebounce from "../../hooks/useDebounce";
|
||||
import {
|
||||
warehouseItemListOptions,
|
||||
type WarehouseItem,
|
||||
@@ -26,9 +27,11 @@ export default function ItemPicker({
|
||||
itemName,
|
||||
}: ItemPickerProps) {
|
||||
const [inputValue, setInputValue] = useState(itemName ?? "");
|
||||
// Debounce the search so the list query doesn't fire on every keystroke.
|
||||
const debouncedSearch = useDebounce(inputValue, 300);
|
||||
|
||||
const { data, isFetching } = useQuery(
|
||||
warehouseItemListOptions({ search: inputValue, perPage: 20 }),
|
||||
warehouseItemListOptions({ search: debouncedSearch, perPage: 20 }),
|
||||
);
|
||||
const options: ItemOption[] = data?.data ?? [];
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import { Select } from "../../ui";
|
||||
@@ -16,7 +17,7 @@ export default function ReservationPicker({
|
||||
value,
|
||||
onChange,
|
||||
}: ReservationPickerProps) {
|
||||
const { data: result } = useQuery(
|
||||
const { data: result, isFetching } = useQuery(
|
||||
warehouseReservationListOptions({
|
||||
item_id: itemId ?? undefined,
|
||||
project_id: projectId ?? undefined,
|
||||
@@ -27,6 +28,22 @@ export default function ReservationPicker({
|
||||
|
||||
const reservations = result?.data ?? [];
|
||||
|
||||
// When item/project change, the loaded reservation list changes. If the
|
||||
// currently-selected reservation is no longer available, clear it and notify
|
||||
// the parent so it doesn't keep holding a stale reservationId. Only act once
|
||||
// the query has settled (result present, not fetching) to avoid resetting
|
||||
// while the new list is still loading.
|
||||
useEffect(() => {
|
||||
if (
|
||||
value != null &&
|
||||
result !== undefined &&
|
||||
!isFetching &&
|
||||
!reservations.some((r) => r.id === value)
|
||||
) {
|
||||
onChange(null, 0);
|
||||
}
|
||||
}, [value, result, isFetching, reservations, onChange]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={value == null ? "" : String(value)}
|
||||
@@ -38,7 +55,7 @@ export default function ReservationPicker({
|
||||
const reservationId = Number(val);
|
||||
const reservation = reservations.find((r) => r.id === reservationId);
|
||||
if (reservation) {
|
||||
onChange(reservationId, Number(reservation.remaining_qty));
|
||||
onChange(reservationId, reservation.remaining_qty);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -46,7 +63,7 @@ export default function ReservationPicker({
|
||||
{reservations.map((r) => (
|
||||
<MenuItem key={r.id} value={String(r.id)}>
|
||||
R{r.id} — {r.project?.name ?? `Projekt #${r.project_id}`} — zbývá:{" "}
|
||||
{Number(r.remaining_qty)} {r.item?.unit ?? "ks"}
|
||||
{r.remaining_qty} {r.item?.unit ?? "ks"}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
@@ -122,7 +122,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
},
|
||||
[],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
);
|
||||
|
||||
const silentRefresh = useCallback(async (): Promise<boolean> => {
|
||||
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
||||
@@ -315,6 +315,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [getAccessTokenFn]);
|
||||
|
||||
// NOTE: this is a SECOND 401-refresh-and-retry path that parallels
|
||||
// `apiFetch` in src/admin/utils/api.ts (which most pages use via the query
|
||||
// helpers). The two diverge: `apiRequest` ignores AbortSignals and does NOT
|
||||
// set the session-expired flag, whereas `apiFetch` dedupes concurrent
|
||||
// refreshes, honors abort, and surfaces the 499 session-expired sentinel.
|
||||
// Consolidating onto `apiFetch` is a tracked follow-up but is deferred here
|
||||
// because it touches the core auth/refresh flow (high regression risk). If
|
||||
// you change refresh/retry semantics in one, mirror it in the other.
|
||||
const apiRequest = useCallback(
|
||||
async (endpoint: string, options: RequestInit = {}) => {
|
||||
let token = getAccessTokenFn();
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// NOTE: this hook locks `document.body.style.overflow` only. The app layout
|
||||
// scrolls on `<html>` (GlobalStyles sets `html { overflow-x: hidden }`, which
|
||||
// makes `<html>` the scroll container), so a body-only lock may not fully
|
||||
// prevent background scroll. The kit's Modal/ConfirmDialog use
|
||||
// `useDialogScrollLock`, which correctly locks `<html>`. This hook is retained
|
||||
// only for the two non-kit in-page edit modals that still call it
|
||||
// (AttendanceAdmin, WarehouseItemDetail); prefer `useDialogScrollLock` for new
|
||||
// dialogs. Behavior intentionally left unchanged to avoid regressing those
|
||||
// pages — see REVIEW_FINDINGS (useModalLock LOW).
|
||||
let activeLocks = 0;
|
||||
|
||||
export default function useModalLock(isOpen: boolean): void {
|
||||
|
||||
@@ -19,9 +19,18 @@ interface PaginatedResult<T> {
|
||||
* Wrapper around useQuery for paginated list endpoints.
|
||||
* Accepts the return value of queryOptions() from lib/queries/*
|
||||
* and extracts items + pagination from the response.
|
||||
*
|
||||
* `options` is intentionally `any`: the query helpers pass a `queryOptions(...)`
|
||||
* object whose `queryKey` is a concrete mutable tuple, and TanStack's `enabled`
|
||||
* predicate is contravariant in the key type — pinning the key generic here
|
||||
* (even via an inferred `TKey`) fails to unify for several callers. The data
|
||||
* shape we rely on (`PaginatedResult<T>`) is enforced by the cast below, so the
|
||||
* loss of input typing on `options` is contained to this thin wrapper.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function usePaginatedQuery<T>(options: any) {
|
||||
export function usePaginatedQuery<T>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: any,
|
||||
) {
|
||||
const query = useQuery({
|
||||
...options,
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -59,6 +59,49 @@ async function apiCall(
|
||||
}
|
||||
}
|
||||
|
||||
// Request-body shapes for the plan mutations. The server validates the full
|
||||
// schema; these capture the fields the optimistic patches read.
|
||||
export interface PlanEntryBody {
|
||||
user_id: number;
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface PlanEntryPatchBody {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
project_id?: number | null;
|
||||
category?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PlanOverrideBody {
|
||||
user_id: number;
|
||||
shift_date: string;
|
||||
project_id?: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface PlanOverridePatchBody {
|
||||
project_id?: number | null;
|
||||
category?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface BulkCreateBody {
|
||||
user_ids: number[];
|
||||
date_from: string;
|
||||
date_to: string;
|
||||
include_weekends: boolean;
|
||||
project_id: number | null;
|
||||
category: string;
|
||||
note: string;
|
||||
}
|
||||
|
||||
export interface UsePlanWorkArgs {
|
||||
initialDate?: Date;
|
||||
canEdit: boolean;
|
||||
@@ -88,10 +131,24 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
|
||||
const gridQuery = useQuery({
|
||||
queryKey: planKeys.grid(isoDate(range.from), isoDate(range.to), view),
|
||||
queryFn: () =>
|
||||
apiFetch(
|
||||
queryFn: async (): Promise<GridData> => {
|
||||
const res = await apiFetch(
|
||||
`/api/admin/plan/grid?date_from=${isoDate(range.from)}&date_to=${isoDate(range.to)}&view=${view}`,
|
||||
).then((r) => r.json().then((b: any) => b.data as GridData)),
|
||||
);
|
||||
if (!res.ok) {
|
||||
let message = "Chyba serveru";
|
||||
try {
|
||||
const errBody = await res.json();
|
||||
if (errBody && typeof errBody.error === "string")
|
||||
message = errBody.error;
|
||||
} catch {
|
||||
// body wasn't JSON; use default
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const body = (await res.json()) as { data: GridData };
|
||||
return body.data;
|
||||
},
|
||||
// keepPreviousData holds the old range's data visible while the new
|
||||
// range's fetch is in flight. Without this, `gridQuery.data` is
|
||||
// immediately undefined on key change and the grid flashes empty.
|
||||
@@ -232,20 +289,54 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
}
|
||||
|
||||
// --- Mutations ---
|
||||
//
|
||||
// Optimistic-update pattern (idiomatic TanStack Query):
|
||||
// onMutate → apply the optimistic grid patch and RETURN the rollback
|
||||
// snapshot as this invocation's context (concurrency-safe:
|
||||
// each in-flight mutation owns its own context object, so two
|
||||
// concurrent mutations can't clobber each other's snapshot —
|
||||
// the previous `(mutation as any)._rolled = …` stash could).
|
||||
// onError → restore the cells captured in onMutate's context.
|
||||
// onSettled → invalidate the domain so the server (source of truth)
|
||||
// reconciles the patch on both success AND error.
|
||||
// The visible UX is unchanged: the new/updated cell still appears
|
||||
// immediately and the broad invalidate still refetches the grid.
|
||||
|
||||
const createEntry = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
type CellRollback = {
|
||||
key: readonly unknown[];
|
||||
userId: number;
|
||||
rolled: Record<string, ResolvedCell[]> | null;
|
||||
} | null;
|
||||
|
||||
// Restore the cells a patch overwrote, from an onMutate-returned context.
|
||||
// `ctx` may be `undefined` (e.g. if onMutate itself threw) — guarded below.
|
||||
function rollbackCells(ctx: CellRollback | undefined) {
|
||||
if (!ctx || !ctx.rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(ctx.key as any);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[ctx.userId] ?? {};
|
||||
const userNext = { ...userPrev };
|
||||
for (const [date, cells] of Object.entries(ctx.rolled)) {
|
||||
userNext[date] = cells;
|
||||
}
|
||||
qc.setQueryData(ctx.key, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [ctx.userId]: userNext },
|
||||
});
|
||||
}
|
||||
|
||||
const createEntry = useMutation<unknown, Error, PlanEntryBody, CellRollback>({
|
||||
mutationFn: (body: PlanEntryBody) =>
|
||||
apiCall("/api/admin/plan/entries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (data: any, body: any) => {
|
||||
// Patch the visible grid with the new entry. We use the response
|
||||
// id (server-assigned) for the new entry's entryId; the rest of
|
||||
// the ResolvedCell mirrors the request body.
|
||||
onMutate: (body): CellRollback => {
|
||||
// Patch the visible grid with the new entry. We don't have the
|
||||
// server-assigned id yet (entryId stays null) — the refetch fills it
|
||||
// within ~200ms; PlanGrid only uses entryId for the edit flow.
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
const rolled = patchCells(currentGridKey, body.user_id, days, (prev) => [
|
||||
makeEntryCell({
|
||||
userId: body.user_id,
|
||||
@@ -255,135 +346,134 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
note: body.note,
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
entryId: null,
|
||||
}),
|
||||
...prev,
|
||||
]);
|
||||
// Stash the rollback on the mutation object so PlanWork can call
|
||||
// it from onError.
|
||||
(createEntry as any)._rolled = rolled;
|
||||
invalidate();
|
||||
return { key: currentGridKey, userId: body.user_id, rolled };
|
||||
},
|
||||
onError: (_err, _body, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const updateEntry = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
const updateEntry = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; body: PlanEntryPatchBody; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, body, force }) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: ({ id, body }): CellRollback => {
|
||||
// We don't know the new full range without the response, but we
|
||||
// do have the body's date_from/date_to. If those are present,
|
||||
// patch the new range. Old cells outside the new range are NOT
|
||||
// cleared here — they'll either still be valid (date_from/to
|
||||
// were partial updates) or the refetch will fix them.
|
||||
const { id, body } = vars;
|
||||
if (body.date_from && body.date_to) {
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
// Find the user that owns this entry in the current grid by
|
||||
// looking for any cell with entryId === id (we already know
|
||||
// the id from vars; it doesn't change across the update).
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
let ownerUserId: number | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const cells of Object.values(byDate)) {
|
||||
if (cells.some((c) => c.entryId === id)) {
|
||||
ownerUserId = Number(uidStr);
|
||||
break;
|
||||
}
|
||||
if (!body.date_from || !body.date_to) return null;
|
||||
const days = eachDay(body.date_from, body.date_to);
|
||||
// Find the user that owns this entry in the current grid by
|
||||
// looking for any cell with entryId === id (we already know
|
||||
// the id from vars; it doesn't change across the update).
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
let ownerUserId: number | null = null;
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const cells of Object.values(byDate)) {
|
||||
if (cells.some((c) => c.entryId === id)) {
|
||||
ownerUserId = Number(uidStr);
|
||||
break;
|
||||
}
|
||||
if (ownerUserId !== null) break;
|
||||
}
|
||||
}
|
||||
if (ownerUserId !== null) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
ownerUserId,
|
||||
days,
|
||||
(prev) => {
|
||||
const existing = prev.find((c) => c.entryId === id) ?? null;
|
||||
const updated = makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
date: days[0],
|
||||
projectId:
|
||||
body.project_id === undefined
|
||||
? (existing?.project_id ?? null)
|
||||
: body.project_id,
|
||||
category: body.category ?? existing?.category ?? "work",
|
||||
note: body.note ?? existing?.note ?? "",
|
||||
rangeFrom: body.date_from,
|
||||
rangeTo: body.date_to,
|
||||
entryId: id,
|
||||
});
|
||||
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
||||
},
|
||||
);
|
||||
(updateEntry as any)._rolled = rolled;
|
||||
if (ownerUserId !== null) break;
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
if (ownerUserId === null) return null;
|
||||
const rolled = patchCells(currentGridKey, ownerUserId, days, (prev) => {
|
||||
const existing = prev.find((c) => c.entryId === id) ?? null;
|
||||
const updated = makeEntryCell({
|
||||
userId: ownerUserId!,
|
||||
date: days[0],
|
||||
projectId:
|
||||
body.project_id === undefined
|
||||
? (existing?.project_id ?? null)
|
||||
: body.project_id,
|
||||
category: body.category ?? existing?.category ?? "work",
|
||||
note: body.note ?? existing?.note ?? "",
|
||||
rangeFrom: body.date_from!,
|
||||
rangeTo: body.date_to!,
|
||||
entryId: id,
|
||||
});
|
||||
return [updated, ...prev.filter((c) => c.entryId !== id)];
|
||||
});
|
||||
return { key: currentGridKey, userId: ownerUserId, rolled };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const deleteEntry = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
const deleteEntry = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, force }) =>
|
||||
apiCall(`/api/admin/plan/entries/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: (vars): CellRollback => {
|
||||
// For delete we need to know the entry's user_id and full range.
|
||||
// Look it up from the current grid: find the user that has a cell
|
||||
// with entryId === id, and read rangeFrom/rangeTo from that cell.
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
let range: { from: string; to: string } | null = null;
|
||||
for (const cells of Object.values(byDate)) {
|
||||
const hit = cells.find(
|
||||
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
||||
);
|
||||
if (hit) {
|
||||
range = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (range) {
|
||||
const days = eachDay(range.from, range.to);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
||||
);
|
||||
(deleteEntry as any)._rolled = rolled;
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
let entryRange: { from: string; to: string } | null = null;
|
||||
for (const cells of Object.values(byDate)) {
|
||||
const hit = cells.find(
|
||||
(c) => c.entryId === vars.id && c.rangeFrom && c.rangeTo,
|
||||
);
|
||||
if (hit) {
|
||||
entryRange = { from: hit.rangeFrom!, to: hit.rangeTo! };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (entryRange) {
|
||||
const days = eachDay(entryRange.from, entryRange.to);
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
days,
|
||||
(prev) => prev.filter((c) => c.entryId !== vars.id),
|
||||
);
|
||||
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return null;
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const createOverride = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
const createOverride = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
PlanOverrideBody,
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: (body: PlanOverrideBody) =>
|
||||
apiCall("/api/admin/plan/overrides", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (data: any, vars: any) => {
|
||||
const id = data && typeof data.id === "number" ? data.id : null;
|
||||
onMutate: (vars): CellRollback => {
|
||||
// overrideId stays null optimistically; the refetch fills the real id.
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
vars.user_id,
|
||||
@@ -395,101 +485,103 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
projectId: vars.project_id ?? null,
|
||||
category: vars.category,
|
||||
note: vars.note,
|
||||
overrideId: id,
|
||||
overrideId: null,
|
||||
}),
|
||||
...prev,
|
||||
],
|
||||
);
|
||||
(createOverride as any)._rolled = rolled;
|
||||
invalidate();
|
||||
return { key: currentGridKey, userId: vars.user_id, rolled };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const updateOverride = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
force,
|
||||
}: {
|
||||
id: number;
|
||||
body: any;
|
||||
force?: boolean;
|
||||
}) =>
|
||||
const updateOverride = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; body: PlanOverridePatchBody; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, body, force }) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: (vars): CellRollback => {
|
||||
// Find the user/date for this overrideId in the current grid, then
|
||||
// patch that single cell with the new values.
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) => {
|
||||
const existing =
|
||||
prev.find((c) => c.overrideId === vars.id) ?? null;
|
||||
const updated = makeOverrideCell({
|
||||
userId: Number(uidStr),
|
||||
date,
|
||||
projectId:
|
||||
vars.body.project_id ?? existing?.project_id ?? null,
|
||||
category:
|
||||
vars.body.category ?? existing?.category ?? "work",
|
||||
note: vars.body.note ?? existing?.note ?? "",
|
||||
overrideId: vars.id,
|
||||
});
|
||||
return [
|
||||
updated,
|
||||
...prev.filter((c) => c.overrideId !== vars.id),
|
||||
];
|
||||
},
|
||||
);
|
||||
(updateOverride as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) => {
|
||||
const existing =
|
||||
prev.find((c) => c.overrideId === vars.id) ?? null;
|
||||
const updated = makeOverrideCell({
|
||||
userId: Number(uidStr),
|
||||
date,
|
||||
projectId:
|
||||
vars.body.project_id ?? existing?.project_id ?? null,
|
||||
category: vars.body.category ?? existing?.category ?? "work",
|
||||
note: vars.body.note ?? existing?.note ?? "",
|
||||
overrideId: vars.id,
|
||||
});
|
||||
return [
|
||||
updated,
|
||||
...prev.filter((c) => c.overrideId !== vars.id),
|
||||
];
|
||||
},
|
||||
);
|
||||
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return null;
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const deleteOverride = useMutation({
|
||||
mutationFn: ({ id, force }: { id: number; force?: boolean }) =>
|
||||
const deleteOverride = useMutation<
|
||||
unknown,
|
||||
Error,
|
||||
{ id: number; force?: boolean },
|
||||
CellRollback
|
||||
>({
|
||||
mutationFn: ({ id, force }) =>
|
||||
apiCall(`/api/admin/plan/overrides/${id}${force ? "?force=1" : ""}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
onSuccess: (_data, vars) => {
|
||||
onMutate: (vars): CellRollback => {
|
||||
const grid = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (grid) {
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
||||
);
|
||||
(deleteOverride as any)._rolled = rolled;
|
||||
break;
|
||||
}
|
||||
if (!grid) return null;
|
||||
for (const [uidStr, byDate] of Object.entries(grid.cells)) {
|
||||
for (const [date, cells] of Object.entries(byDate)) {
|
||||
if (cells.some((c) => c.overrideId === vars.id)) {
|
||||
const rolled = patchCells(
|
||||
currentGridKey,
|
||||
Number(uidStr),
|
||||
[date],
|
||||
(prev) => prev.filter((c) => c.overrideId !== vars.id),
|
||||
);
|
||||
return { key: currentGridKey, userId: Number(uidStr), rolled };
|
||||
}
|
||||
}
|
||||
}
|
||||
invalidate();
|
||||
return null;
|
||||
},
|
||||
onError: (_err, _vars, ctx) => rollbackCells(ctx),
|
||||
onSettled: () => invalidate(),
|
||||
});
|
||||
|
||||
const bulkCreate = useMutation({
|
||||
mutationFn: (body: any) =>
|
||||
const bulkCreate = useMutation<unknown, Error, BulkCreateBody>({
|
||||
mutationFn: (body: BulkCreateBody) =>
|
||||
apiCall("/api/admin/plan/entries/bulk", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
@@ -502,29 +594,15 @@ export function usePlanWork({ initialDate, canEdit }: UsePlanWorkArgs) {
|
||||
},
|
||||
});
|
||||
|
||||
// Roll back an optimistic patch on mutation error. Called from
|
||||
// PlanWork's mutation wrappers via `rollbackMutation(mutation, key)`.
|
||||
// `mutation` is a TanStack mutation result with a `_rolled` snapshot stashed
|
||||
// on it (see the `(createEntry as any)._rolled = …` writes above). Typed as
|
||||
// `unknown` + an explicit cast so callers can pass the mutation object
|
||||
// directly without the "weak type" mismatch a `{ _rolled? }` param causes.
|
||||
function rollbackMutation(mutation: unknown, userId: number) {
|
||||
const stash = mutation as {
|
||||
_rolled?: Record<string, ResolvedCell[]> | null;
|
||||
};
|
||||
if (!stash._rolled) return;
|
||||
const prev = qc.getQueryData<GridData>(currentGridKey as any);
|
||||
if (!prev) return;
|
||||
const userPrev = prev.cells[userId] ?? {};
|
||||
const userNext = { ...userPrev };
|
||||
for (const [date, cells] of Object.entries(stash._rolled)) {
|
||||
userNext[date] = cells;
|
||||
}
|
||||
qc.setQueryData(currentGridKey, {
|
||||
...prev,
|
||||
cells: { ...prev.cells, [userId]: userNext },
|
||||
});
|
||||
stash._rolled = null;
|
||||
// Rollback is now handled idiomatically inside each mutation's `onError`
|
||||
// from its `onMutate`-returned context (concurrency-safe per-invocation
|
||||
// snapshot). This is kept as a no-op for the existing call sites in
|
||||
// PlanWork.tsx so they continue to compile unchanged; by the time a
|
||||
// `mutateAsync` promise rejects, the mutation's own `onError` has already
|
||||
// restored the patched cells, so there is nothing left to do here.
|
||||
|
||||
function rollbackMutation(_mutation: unknown, _userId: number) {
|
||||
/* no-op — see onMutate/onError above */
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,16 +4,22 @@ import { useEffect, useState } from "react";
|
||||
* Returns true when the user has expressed a preference for reduced motion
|
||||
* (OS-level setting: `prefers-reduced-motion: reduce`).
|
||||
*
|
||||
* SSR-safe: starts as `false` (the default state), so the first render uses
|
||||
* the normal animation. The effect then reconciles with the live matchMedia
|
||||
* state on the client and re-renders if needed.
|
||||
* SSR-safe: the lazy initializer reads the live matchMedia value on the client
|
||||
* (and falls back to `false` when `window`/`matchMedia` is unavailable, e.g.
|
||||
* during SSR), so a reduced-motion user does NOT get one frame of animation
|
||||
* before the effect reconciles. The effect then only listens for changes.
|
||||
*/
|
||||
export default function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false);
|
||||
const [reduced, setReduced] = useState(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return false;
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined" || !window.matchMedia) return;
|
||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
// Reconcile in case the value changed between the lazy init and mount
|
||||
// (or after SSR hydration).
|
||||
setReduced(mq.matches);
|
||||
const onChange = () => setReduced(mq.matches);
|
||||
mq.addEventListener("change", onChange);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
interface LocationRaw {
|
||||
@@ -167,14 +166,15 @@ export const attendanceLocationOptions = (id: string | undefined) =>
|
||||
queryOptions({
|
||||
queryKey: ["attendance", "location", id],
|
||||
queryFn: async (): Promise<LocationRecord> => {
|
||||
const response = await apiFetch(
|
||||
// Route through jsonQuery so the response.ok / 401 / non-JSON cases are
|
||||
// normalized into the same errors as every other options helper, then
|
||||
// apply the location-specific shape transform on the unwrapped data.
|
||||
const data = await jsonQuery<LocationRaw | { record: LocationRaw }>(
|
||||
`/api/admin/attendance?action=location&id=${id}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || "Záznam nebyl nalezen");
|
||||
}
|
||||
const raw = (result.data.record || result.data) as LocationRaw;
|
||||
const raw = (
|
||||
"record" in data && data.record ? data.record : data
|
||||
) as LocationRaw;
|
||||
const userName = raw.users
|
||||
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
|
||||
: raw.user_name || "";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { queryOptions } from "@tanstack/react-query";
|
||||
import apiFetch from "../../utils/api";
|
||||
import { jsonQuery } from "../apiAdapter";
|
||||
|
||||
export const dashboardOptions = () =>
|
||||
@@ -28,11 +27,12 @@ export const sessionsOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ["sessions"],
|
||||
queryFn: async (): Promise<Session[]> => {
|
||||
const response = await apiFetch("/api/admin/sessions");
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
return Array.isArray(data.data) ? data.data : data.data?.sessions || [];
|
||||
}
|
||||
throw new Error(data.error || "Nepodařilo se načíst relace");
|
||||
// Route through jsonQuery for the shared response.ok / 401 / non-JSON
|
||||
// guards, then normalize the two possible payload shapes (a bare array
|
||||
// or `{ sessions: [...] }`).
|
||||
const data = await jsonQuery<Session[] | { sessions?: Session[] }>(
|
||||
"/api/admin/sessions",
|
||||
);
|
||||
return Array.isArray(data) ? data : (data.sessions ?? []);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -300,7 +300,7 @@ export default function Attendance() {
|
||||
>({
|
||||
url: () => `${API_BASE}/leave-requests`,
|
||||
method: () => "POST",
|
||||
invalidate: ["attendance", "leave-requests", "users"],
|
||||
invalidate: ["attendance", "leave-requests", "leave", "users"],
|
||||
});
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -323,6 +323,10 @@ export default function Attendance() {
|
||||
const mountedRef = useRef(true);
|
||||
const latestActionRef = useRef<string | null>(null);
|
||||
|
||||
// Live wall-clock display (HH:MM) — re-render every 30s so the shown time
|
||||
// actually advances instead of freezing at first paint.
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -332,6 +336,11 @@ export default function Attendance() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// Sync notes from query data when the shift changes
|
||||
useEffect(() => {
|
||||
if (statusQuery.data) {
|
||||
@@ -475,10 +484,20 @@ export default function Attendance() {
|
||||
}
|
||||
};
|
||||
|
||||
// Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")`
|
||||
// would parse as UTC midnight; reading it back with local getDay()/getDate()
|
||||
// can land on the wrong calendar day in the evening Prague window.
|
||||
const parseLocalDate = (s: string): Date | null => {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
|
||||
if (!m) return null;
|
||||
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
||||
};
|
||||
|
||||
const calculateBusinessDays = (from: string, to: string) => {
|
||||
if (!from || !to) return 0;
|
||||
const start = new Date(from);
|
||||
const end = new Date(to);
|
||||
const start = parseLocalDate(from);
|
||||
const end = parseLocalDate(to);
|
||||
if (!start || !end) return 0;
|
||||
if (end < start) return 0;
|
||||
let days = 0;
|
||||
const current = new Date(start);
|
||||
@@ -673,7 +692,7 @@ export default function Attendance() {
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{new Date().toLocaleTimeString("cs-CZ", {
|
||||
{now.toLocaleTimeString("cs-CZ", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
|
||||
@@ -168,8 +168,6 @@ export default function AttendanceBalances() {
|
||||
userName: string;
|
||||
}>({ show: false, userId: null, userName: "" });
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
|
||||
const editMutation = useApiMutation<
|
||||
{
|
||||
user_id: string;
|
||||
@@ -199,6 +197,8 @@ export default function AttendanceBalances() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||||
|
||||
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
||||
setEditingUser({ id: userId, name: balance.name });
|
||||
setEditForm({
|
||||
@@ -767,7 +767,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vacation_total: parseFloat(e.target.value),
|
||||
vacation_total: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
|
||||
@@ -781,7 +781,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
vacation_used: parseFloat(e.target.value),
|
||||
vacation_used: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||
@@ -795,7 +795,7 @@ export default function AttendanceBalances() {
|
||||
onChange={(e) =>
|
||||
setEditForm({
|
||||
...editForm,
|
||||
sick_used: parseFloat(e.target.value),
|
||||
sick_used: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function AttendanceCreate() {
|
||||
onChange={(e) =>
|
||||
setForm({
|
||||
...form,
|
||||
leave_hours: parseFloat(e.target.value),
|
||||
leave_hours: parseFloat(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||
|
||||
@@ -184,6 +184,13 @@ export default function AttendanceLocation() {
|
||||
|
||||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||||
|
||||
// Show the spinner while the record is still loading — this must precede the
|
||||
// `!record` null-return below, otherwise a pending query (record === undefined)
|
||||
// renders a blank page and this branch is dead.
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
@@ -196,10 +203,6 @@ export default function AttendanceLocation() {
|
||||
: record.shift_date;
|
||||
const month = shiftDateStr.substring(0, 7);
|
||||
|
||||
if (isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PageEnter>
|
||||
{/* Header */}
|
||||
|
||||
@@ -182,10 +182,6 @@ export default function AuditLog() {
|
||||
const logs: AuditLogEntry[] = logsData?.data ?? [];
|
||||
const pagination = logsData?.pagination ?? null;
|
||||
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const cleanupMutation = useApiMutation<
|
||||
{ days: number; confirm?: string },
|
||||
{ message?: string; error?: string }
|
||||
@@ -199,6 +195,10 @@ export default function AuditLog() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("settings.audit")) {
|
||||
return <Forbidden />;
|
||||
}
|
||||
|
||||
const handleFilterChange = (key: keyof Filters, value: string) => {
|
||||
setFilters((prev) => ({ ...prev, [key]: value }));
|
||||
setPage(1);
|
||||
|
||||
@@ -1309,7 +1309,7 @@ export default function CompanySettings({
|
||||
onChange={(e) =>
|
||||
updateField("default_vat_rate", Number(e.target.value))
|
||||
}
|
||||
inputProps={{ min: 0, step: 1 }}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Dostupné měny">
|
||||
|
||||
@@ -51,6 +51,7 @@ import { offerCustomersOptions, type Customer } from "../lib/queries/offers";
|
||||
import { bankAccountsOptions } from "../lib/queries/common";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { formatCurrency, formatDate, todayLocalStr } from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
@@ -69,6 +70,33 @@ import {
|
||||
|
||||
const API_BASE = "/api/admin";
|
||||
|
||||
/**
|
||||
* Add `days` to a base date and return `YYYY-MM-DD` in LOCAL (Prague) time.
|
||||
* `base` is an optional `YYYY-MM-DD` string; when omitted, "today" (local) is
|
||||
* used. We build the Date from the local year/month/day parts (so it's a local
|
||||
* midnight, not the UTC midnight `new Date("YYYY-MM-DD")` would give) and read
|
||||
* it back with local getters — never via `toISOString()`, which would shift a
|
||||
* day during the evening Prague window. See utils/formatters.todayLocalStr.
|
||||
*/
|
||||
function addDaysLocalStr(days: number, base?: string): string {
|
||||
let y: number, m: number, d: number;
|
||||
const parts = base ? /^(\d{4})-(\d{2})-(\d{2})/.exec(base) : null;
|
||||
if (parts) {
|
||||
y = Number(parts[1]);
|
||||
m = Number(parts[2]) - 1;
|
||||
d = Number(parts[3]);
|
||||
} else {
|
||||
const now = new Date();
|
||||
y = now.getFullYear();
|
||||
m = now.getMonth();
|
||||
d = now.getDate();
|
||||
}
|
||||
const result = new Date(y, m, d + days);
|
||||
const mm = String(result.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(result.getDate()).padStart(2, "0");
|
||||
return `${result.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
issued: "Vystavena",
|
||||
paid: "Zaplacena",
|
||||
@@ -491,7 +519,7 @@ export default function InvoiceDetail() {
|
||||
customer_name: "",
|
||||
order_id: fromOrderId ? Number(fromOrderId) : null,
|
||||
issue_date: todayLocalStr(),
|
||||
due_date: new Date(Date.now() + 14 * 86400000).toISOString().split("T")[0],
|
||||
due_date: addDaysLocalStr(14),
|
||||
tax_date: todayLocalStr(),
|
||||
currency: "CZK",
|
||||
apply_vat: 1,
|
||||
@@ -641,15 +669,9 @@ export default function InvoiceDetail() {
|
||||
customer_id: inv.customer_id || null,
|
||||
customer_name: inv.customer_name || "",
|
||||
order_id: inv.order_id || null,
|
||||
issue_date: inv.issue_date
|
||||
? new Date(inv.issue_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
due_date: inv.due_date
|
||||
? new Date(inv.due_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
tax_date: inv.tax_date
|
||||
? new Date(inv.tax_date).toISOString().split("T")[0]
|
||||
: "",
|
||||
issue_date: normalizeDateStr(inv.issue_date),
|
||||
due_date: normalizeDateStr(inv.due_date),
|
||||
tax_date: normalizeDateStr(inv.tax_date),
|
||||
currency: inv.currency || "CZK",
|
||||
apply_vat: Number(inv.apply_vat) ? 1 : 0,
|
||||
vat_rate: Number(inv.vat_rate) || 21,
|
||||
@@ -713,7 +735,7 @@ export default function InvoiceDetail() {
|
||||
bankAccountsQuery.isLoading,
|
||||
bankAccountsQuery.data,
|
||||
customersQuery.isLoading,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// Create mode: populate form from query data
|
||||
useEffect(() => {
|
||||
@@ -791,7 +813,7 @@ export default function InvoiceDetail() {
|
||||
orderDataQuery.data,
|
||||
companySettings,
|
||||
bankAccounts,
|
||||
]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
]);
|
||||
|
||||
// Capture initial snapshot for dirty-checking once data sync completes.
|
||||
// Edit mode: captured inside the sync effect from raw query data.
|
||||
@@ -817,9 +839,7 @@ export default function InvoiceDetail() {
|
||||
|
||||
const computedDueDate = useMemo(() => {
|
||||
if (!form.issue_date) return "";
|
||||
const d = new Date(form.issue_date);
|
||||
d.setDate(d.getDate() + dueDays);
|
||||
return d.toISOString().split("T")[0];
|
||||
return addDaysLocalStr(dueDays, form.issue_date);
|
||||
}, [form.issue_date, dueDays]);
|
||||
|
||||
// ─── Create mode: customer filtering ───
|
||||
|
||||
@@ -10,7 +10,13 @@ import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDate,
|
||||
czechPlural,
|
||||
todayLocalStr,
|
||||
} from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import {
|
||||
@@ -499,12 +505,14 @@ export default function Invoices() {
|
||||
};
|
||||
|
||||
// Per-row overdue tint (replaces offers-expired-row).
|
||||
// Compare as YYYY-MM-DD strings in LOCAL time (lexicographic === chronological)
|
||||
// to avoid the UTC-vs-local off-by-one near midnight Prague.
|
||||
const rowSx = (inv: Invoice) => {
|
||||
const isOverdue =
|
||||
inv.status === "overdue" ||
|
||||
(inv.status === "issued" &&
|
||||
inv.due_date &&
|
||||
new Date(inv.due_date) < new Date(new Date().toDateString()));
|
||||
!!inv.due_date &&
|
||||
normalizeDateStr(inv.due_date) < todayLocalStr());
|
||||
if (isOverdue) {
|
||||
return {
|
||||
backgroundColor: "rgba(var(--mui-palette-warning-mainChannel) / 0.12)",
|
||||
|
||||
@@ -155,8 +155,6 @@ export default function LeaveApproval() {
|
||||
}>({ open: false, request: null });
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const approveMutation = useApiMutation<
|
||||
{ id: number; status: "approved" },
|
||||
unknown
|
||||
@@ -184,6 +182,8 @@ export default function LeaveApproval() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("attendance.approve")) return <Forbidden />;
|
||||
|
||||
const handleApprove = async () => {
|
||||
if (!approveModal.request) return;
|
||||
try {
|
||||
|
||||
@@ -99,10 +99,20 @@ export default function Login() {
|
||||
e.preventDefault();
|
||||
if (!totpCode.trim()) return;
|
||||
|
||||
// Defensive: the 2FA form only renders after a loginToken is issued, but
|
||||
// guard against a null token (e.g. server returns requires2FA without one)
|
||||
// rather than passing `null!` through to verify2FA.
|
||||
if (!loginToken) {
|
||||
alert.error("Relace vypršela, přihlaste se prosím znovu");
|
||||
setShow2FA(false);
|
||||
setTotpCode("");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const result = await verify2FA(
|
||||
loginToken!,
|
||||
loginToken,
|
||||
totpCode.trim(),
|
||||
remember,
|
||||
useBackupCode,
|
||||
@@ -131,6 +141,7 @@ export default function Login() {
|
||||
setLoading,
|
||||
setShake,
|
||||
setTotpCode,
|
||||
setShow2FA,
|
||||
setAnimatingOut,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -758,7 +758,7 @@ export default function OfferDetail() {
|
||||
signal: controller.signal,
|
||||
}).catch(() => {});
|
||||
};
|
||||
}, [isEdit, id, isLockedByOther, isInvalidated]);
|
||||
}, [isEdit, id, isLockedByOther, isInvalidated, isCompleted]);
|
||||
|
||||
// Capture initial snapshot after loading completes (create mode)
|
||||
if (!loading && !initialSnapshotRef.current) {
|
||||
|
||||
@@ -236,7 +236,7 @@ export default function OffersCustomers() {
|
||||
|
||||
return order.filter((key) => {
|
||||
if (key.startsWith("custom_")) {
|
||||
const idx = parseInt(key.split("_")[1]);
|
||||
const idx = parseInt(key.split("_")[1], 10);
|
||||
return idx < customFields.length;
|
||||
}
|
||||
return true;
|
||||
@@ -255,7 +255,7 @@ export default function OffersCustomers() {
|
||||
const getFieldDisplayName = (key: string) => {
|
||||
if (CUSTOMER_FIELD_LABELS[key]) return CUSTOMER_FIELD_LABELS[key];
|
||||
if (key.startsWith("custom_")) {
|
||||
const idx = parseInt(key.split("_")[1]);
|
||||
const idx = parseInt(key.split("_")[1], 10);
|
||||
const cf = customFields[idx];
|
||||
if (cf)
|
||||
return cf.name
|
||||
@@ -672,7 +672,7 @@ export default function OffersCustomers() {
|
||||
.filter((k) => k !== key)
|
||||
.map((k) => {
|
||||
if (k.startsWith("custom_")) {
|
||||
const ki = parseInt(k.split("_")[1]);
|
||||
const ki = parseInt(k.split("_")[1], 10);
|
||||
if (ki > idx) return `custom_${ki - 1}`;
|
||||
}
|
||||
return k;
|
||||
|
||||
@@ -176,8 +176,6 @@ export default function OrderDetail() {
|
||||
return { subtotal, vatAmount, total: subtotal + vatAmount };
|
||||
}, [order]);
|
||||
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
const statusMutation = useApiMutation<{ status: string }, unknown>({
|
||||
url: () => `${API_BASE}/orders/${id}`,
|
||||
method: () => "PUT",
|
||||
@@ -199,6 +197,8 @@ export default function OrderDetail() {
|
||||
invalidate: ["orders", "invoices"],
|
||||
});
|
||||
|
||||
if (!hasPermission("orders.view")) return <Forbidden />;
|
||||
|
||||
const handleStatusChange = async () => {
|
||||
if (!statusConfirm.status) return;
|
||||
const newStatus = statusConfirm.status;
|
||||
@@ -462,8 +462,8 @@ export default function OrderDetail() {
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission("orders.edit") &&
|
||||
order.valid_transitions?.filter((s) => s !== "stornovana")
|
||||
.length! > 0 &&
|
||||
(order.valid_transitions?.filter((s) => s !== "stornovana")
|
||||
.length ?? 0) > 0 &&
|
||||
order
|
||||
.valid_transitions!.filter((s) => s !== "stornovana")
|
||||
.map((status) => (
|
||||
|
||||
@@ -174,8 +174,6 @@ export default function ProjectDetail() {
|
||||
}
|
||||
}, [projectQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
const projectSaveMutation = useApiMutation<
|
||||
{
|
||||
name: string;
|
||||
@@ -212,6 +210,8 @@ export default function ProjectDetail() {
|
||||
invalidate: ["projects", "warehouse"],
|
||||
});
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
const updateForm = (field: keyof ProjectForm, value: string) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
import { formatDate } from "../utils/formatters";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import useDebounce from "../hooks/useDebounce";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import {
|
||||
projectListOptions,
|
||||
@@ -117,6 +118,7 @@ export default function Projects() {
|
||||
|
||||
const { sort, order, handleSort } = useTableSort("project_number");
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
|
||||
const [deleteFiles, setDeleteFiles] = useState(false);
|
||||
@@ -216,7 +218,7 @@ export default function Projects() {
|
||||
isPending,
|
||||
isFetching,
|
||||
} = usePaginatedQuery<Project>(
|
||||
projectListOptions({ search, sort, order, page }),
|
||||
projectListOptions({ search: debouncedSearch, sort, order, page }),
|
||||
);
|
||||
|
||||
if (!hasPermission("projects.view")) return <Forbidden />;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
|
||||
import { normalizeDateStr } from "../utils/attendanceHelpers";
|
||||
import useTableSort from "../hooks/useTableSort";
|
||||
import {
|
||||
companySettingsOptions,
|
||||
@@ -276,7 +277,14 @@ export default function ReceivedInvoices({
|
||||
|
||||
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
|
||||
const invoices = listQuery.data?.data ?? [];
|
||||
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
|
||||
|
||||
// Track first successful load (used to suppress the skeleton on later
|
||||
// refetches). Set in an effect rather than during render to avoid a
|
||||
// render-phase ref mutation.
|
||||
const hasData = !!(listQuery.data || statsQuery.data);
|
||||
useEffect(() => {
|
||||
if (hasData) hasLoadedOnce.current = true;
|
||||
}, [hasData]);
|
||||
|
||||
// Derive stats from query
|
||||
const stats = statsQuery.data ?? null;
|
||||
@@ -446,12 +454,11 @@ export default function ReceivedInvoices({
|
||||
}
|
||||
};
|
||||
|
||||
const toDateInput = (d: string | null | undefined): string => {
|
||||
if (!d) return "";
|
||||
const date = new Date(d);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toISOString().split("T")[0];
|
||||
};
|
||||
// Seed edit-form date inputs from stored values. Use normalizeDateStr (pure
|
||||
// string slice) instead of new Date(...).toISOString() — the latter converts
|
||||
// to UTC and is a day early in the late-evening Prague window.
|
||||
const toDateInput = (d: string | null | undefined): string =>
|
||||
normalizeDateStr(d);
|
||||
|
||||
const openEdit = (inv: ReceivedInvoice) => {
|
||||
setEditInvoice({
|
||||
|
||||
@@ -311,11 +311,6 @@ export default function Settings() {
|
||||
setSysFormInitialized(true);
|
||||
}, [sysSettingsData, sysFormInitialized]);
|
||||
|
||||
// ── Early return after all hooks ──
|
||||
if (!canAccessSettings) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const saveSystemSettingsMutation = useApiMutation<
|
||||
typeof sysForm,
|
||||
{ message?: string; error?: string }
|
||||
@@ -400,6 +395,11 @@ export default function Settings() {
|
||||
},
|
||||
});
|
||||
|
||||
// ── Early return after all hooks ──
|
||||
if (!canAccessSettings) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSaveSystemSettings = async () => {
|
||||
try {
|
||||
await saveSystemSettingsMutation.mutateAsync(sysForm);
|
||||
|
||||
@@ -185,8 +185,6 @@ export default function Trips() {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [, setLastKm] = useState(0);
|
||||
|
||||
if (!hasPermission("trips.record")) return <Forbidden />;
|
||||
|
||||
const submitMutation = useApiMutation<
|
||||
TripForm,
|
||||
{ message?: string; error?: string }
|
||||
@@ -214,6 +212,8 @@ export default function Trips() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("trips.record")) return <Forbidden />;
|
||||
|
||||
const fetchLastKm = async (vehicleId: string) => {
|
||||
if (!vehicleId) {
|
||||
setLastKm(0);
|
||||
@@ -284,7 +284,7 @@ export default function Trips() {
|
||||
if (
|
||||
form.start_km &&
|
||||
form.end_km &&
|
||||
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
|
||||
parseInt(String(form.end_km), 10) <= parseInt(String(form.start_km), 10)
|
||||
) {
|
||||
newErrors.end_km = "Musí být větší než počáteční";
|
||||
}
|
||||
@@ -312,8 +312,8 @@ export default function Trips() {
|
||||
};
|
||||
|
||||
const calculateDistance = (): number => {
|
||||
const start = parseInt(String(form.start_km)) || 0;
|
||||
const end = parseInt(String(form.end_km)) || 0;
|
||||
const start = parseInt(String(form.start_km), 10) || 0;
|
||||
const end = parseInt(String(form.end_km), 10) || 0;
|
||||
return end > start ? end - start : 0;
|
||||
};
|
||||
|
||||
|
||||
@@ -228,8 +228,6 @@ export default function TripsAdmin() {
|
||||
);
|
||||
const trips = (tripsData ?? []).map(mapTrip);
|
||||
|
||||
if (!hasPermission("trips.manage")) return <Forbidden />;
|
||||
|
||||
// useApiMutation JSON.stringifies the whole TIn as the request body, so
|
||||
// TIn must match the backend schema (UpdateTripSchema) shape directly —
|
||||
// NOT a wrapper that nests the body. id is captured in the URL closure.
|
||||
@@ -265,6 +263,8 @@ export default function TripsAdmin() {
|
||||
},
|
||||
});
|
||||
|
||||
if (!hasPermission("trips.manage")) return <Forbidden />;
|
||||
|
||||
const openEditModal = (trip: Trip) => {
|
||||
setEditingTrip(trip);
|
||||
setEditForm({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
Field,
|
||||
TextField,
|
||||
SwitchField,
|
||||
StatusChip,
|
||||
LoadingState,
|
||||
PageEnter,
|
||||
type DataColumn,
|
||||
@@ -261,12 +261,10 @@ export default function Vehicles() {
|
||||
key: "status",
|
||||
header: "Stav",
|
||||
render: (v) => (
|
||||
<Chip
|
||||
<StatusChip
|
||||
label={v.is_active ? "Aktivní" : "Neaktivní"}
|
||||
size="small"
|
||||
color={v.is_active ? "success" : "default"}
|
||||
onClick={() => toggleActive(v)}
|
||||
sx={{ cursor: "pointer", fontWeight: 600 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@@ -401,7 +399,10 @@ export default function Vehicles() {
|
||||
value={form.initial_km}
|
||||
slotProps={{ htmlInput: { min: 0, inputMode: "numeric" } }}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, initial_km: parseInt(e.target.value) || 0 })
|
||||
setForm({
|
||||
...form,
|
||||
initial_km: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
warehouseReservationListOptions,
|
||||
warehouseMovementLogOptions,
|
||||
type WarehouseItem,
|
||||
type MovementLogRow,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
@@ -72,15 +73,8 @@ interface BelowMinRow {
|
||||
min_quantity: number;
|
||||
}
|
||||
|
||||
interface MovementRow {
|
||||
_idx: number;
|
||||
date: string;
|
||||
type: string;
|
||||
document_number: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
supplier_or_project: string;
|
||||
}
|
||||
// Dashboard movement row = the shared lib row plus a stable list index for rowKey.
|
||||
type MovementRow = MovementLogRow & { _idx: number };
|
||||
|
||||
export default function Warehouse() {
|
||||
const { hasPermission } = useAuth();
|
||||
|
||||
@@ -99,7 +99,6 @@ export default function WarehouseCategories() {
|
||||
show: boolean;
|
||||
category: WarehouseCategory | null;
|
||||
}>({ show: false, category: null });
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const submitMutation = useApiMutation<CategoryForm, void>({
|
||||
url: () =>
|
||||
@@ -156,13 +155,10 @@ export default function WarehouseCategories() {
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await submitMutation.mutateAsync(form);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,7 +270,7 @@ export default function WarehouseCategories() {
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
|
||||
loading={saving}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
@@ -302,12 +298,13 @@ export default function WarehouseCategories() {
|
||||
<TextField
|
||||
type="number"
|
||||
value={String(form.sort_order)}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setForm({
|
||||
...form,
|
||||
sort_order: parseInt(e.target.value) || 0,
|
||||
})
|
||||
}
|
||||
sort_order: Number.isFinite(n) ? n : 0,
|
||||
});
|
||||
}}
|
||||
inputProps={{ min: 0 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -122,7 +122,12 @@ export default function WarehouseInventory() {
|
||||
header: "Položky",
|
||||
width: "20%",
|
||||
mono: true,
|
||||
render: (s) => String(s.items?.length ?? 0),
|
||||
// List endpoint returns `_count` (not `items`), matching the issues/receipts lists.
|
||||
render: (s) =>
|
||||
String(
|
||||
(s as WarehouseInventorySession & { _count?: { items: number } })
|
||||
._count?.items ?? 0,
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -67,13 +67,9 @@ export default function WarehouseInventoryDetail() {
|
||||
error,
|
||||
} = useQuery(warehouseInventoryDetailOptions(id));
|
||||
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
session
|
||||
? `/api/admin/warehouse/inventory-sessions/${session.id}/confirm`
|
||||
: "/api/admin/warehouse/inventory-sessions/0/confirm",
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (sessionId) =>
|
||||
`/api/admin/warehouse/inventory-sessions/${sessionId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -82,10 +78,13 @@ export default function WarehouseInventoryDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!session) return;
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(session.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ interface InventoryItem {
|
||||
actual_qty: number;
|
||||
}
|
||||
|
||||
function parseDecimal(raw: string): number {
|
||||
const cleaned = raw.replace(/[eE+\-]/g, "");
|
||||
const n = Number(cleaned);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
const BackIcon = (
|
||||
<svg
|
||||
width="20"
|
||||
@@ -80,17 +86,6 @@ export default function WarehouseInventoryForm() {
|
||||
|
||||
const { data: locations } = useQuery(warehouseLocationListOptions());
|
||||
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const validate = (): boolean => {
|
||||
const validItems = items.filter((l) => l.item_id !== null);
|
||||
if (validItems.length === 0) {
|
||||
alert.error("Přidejte alespoň jednu položku");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const createMutation = useApiMutation<
|
||||
{
|
||||
notes: string | null;
|
||||
@@ -111,6 +106,24 @@ export default function WarehouseInventoryForm() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
|
||||
|
||||
const validate = (): boolean => {
|
||||
const validItems = items.filter((l) => l.item_id !== null);
|
||||
if (validItems.length === 0) {
|
||||
alert.error("Přidejte alespoň jednu položku");
|
||||
return false;
|
||||
}
|
||||
for (const l of validItems) {
|
||||
if (!Number.isFinite(l.actual_qty) || l.actual_qty < 0) {
|
||||
alert.error("Skutečné množství musí být platné číslo");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
setSaving(true);
|
||||
@@ -246,7 +259,7 @@ export default function WarehouseInventoryForm() {
|
||||
type="number"
|
||||
value={item.actual_qty || ""}
|
||||
onChange={(e) =>
|
||||
updateItem(index, "actual_qty", Number(e.target.value))
|
||||
updateItem(index, "actual_qty", parseDecimal(e.target.value))
|
||||
}
|
||||
inputProps={{
|
||||
min: 0,
|
||||
|
||||
@@ -70,15 +70,8 @@ export default function WarehouseIssueDetail() {
|
||||
error,
|
||||
} = useQuery(warehouseIssueDetailOptions(id));
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
issue
|
||||
? `/api/admin/warehouse/issues/${issue.id}/confirm`
|
||||
: "/api/admin/warehouse/issues/0/confirm",
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -86,11 +79,8 @@ export default function WarehouseIssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
issue
|
||||
? `/api/admin/warehouse/issues/${issue.id}/cancel`
|
||||
: "/api/admin/warehouse/issues/0/cancel",
|
||||
const cancelMutation = useApiMutation<number, void>({
|
||||
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/cancel`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -99,10 +89,15 @@ export default function WarehouseIssueDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!issue) return;
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(issue.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -111,7 +106,7 @@ export default function WarehouseIssueDetail() {
|
||||
const handleCancel = async () => {
|
||||
if (!issue) return;
|
||||
try {
|
||||
await cancelMutation.mutateAsync(undefined);
|
||||
await cancelMutation.mutateAsync(issue.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -315,8 +310,8 @@ export default function WarehouseIssueDetail() {
|
||||
{iss.project ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
component="a"
|
||||
href={`/projects/${iss.project.id}`}
|
||||
component={RouterLink}
|
||||
to={`/projects/${iss.project.id}`}
|
||||
sx={{
|
||||
fontFamily: "'DM Mono', Menlo, monospace",
|
||||
fontWeight: 500,
|
||||
|
||||
@@ -264,6 +264,28 @@ export default function WarehouseIssueForm() {
|
||||
}
|
||||
}, [isEdit, issue]);
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/issues/${id}`
|
||||
: "/api/admin/warehouse/issues",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (issueId) => `/api/admin/warehouse/issues/${issueId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Výdejka byla potvrzena");
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||||
|
||||
const addItem = () => {
|
||||
@@ -332,31 +354,6 @@ export default function WarehouseIssueForm() {
|
||||
})),
|
||||
});
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/issues/${id}`
|
||||
: "/api/admin/warehouse/issues",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
confirmIssueId
|
||||
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
|
||||
: "/api/admin/warehouse/issues/0/confirm",
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Výdejka byla potvrzena");
|
||||
},
|
||||
});
|
||||
|
||||
const saveIssue = async (): Promise<number | null> => {
|
||||
try {
|
||||
const data = await saveMutation.mutateAsync(buildPayload());
|
||||
@@ -370,13 +367,10 @@ export default function WarehouseIssueForm() {
|
||||
};
|
||||
|
||||
const confirmIssue = async (issueId: number) => {
|
||||
setConfirmIssueId(issueId);
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(issueId);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setConfirmIssueId(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -154,13 +154,6 @@ export default function WarehouseItemDetail() {
|
||||
|
||||
useModalLock(editing);
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canManage = hasPermission("warehouse.manage");
|
||||
|
||||
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
Record<string, unknown>,
|
||||
{ id?: number; message?: string }
|
||||
@@ -187,6 +180,14 @@ export default function WarehouseItemDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canManage = hasPermission("warehouse.manage");
|
||||
|
||||
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
|
||||
setForm((prev) => ({ ...prev, [field]: value }));
|
||||
|
||||
const handleSave = async () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.name.trim()) newErrors.name = "Zadejte název položky";
|
||||
|
||||
@@ -233,13 +233,8 @@ export default function WarehouseLocations() {
|
||||
}
|
||||
|
||||
const total = locations.length;
|
||||
const subtitle = `${total} ${
|
||||
total === 1
|
||||
? "umístění"
|
||||
: total >= 2 && total <= 4
|
||||
? "umístění"
|
||||
: "umístění"
|
||||
}`;
|
||||
// "umístění" is invariant across grammatical number in Czech — no plural form.
|
||||
const subtitle = `${total} umístění`;
|
||||
|
||||
const columns: DataColumn<WarehouseLocation>[] = [
|
||||
{
|
||||
|
||||
@@ -94,15 +94,8 @@ export default function WarehouseReceiptDetail() {
|
||||
error,
|
||||
} = useQuery(warehouseReceiptDetailOptions(id));
|
||||
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
receipt
|
||||
? `/api/admin/warehouse/receipts/${receipt.id}/confirm`
|
||||
: "/api/admin/warehouse/receipts/0/confirm",
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -110,11 +103,8 @@ export default function WarehouseReceiptDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
receipt
|
||||
? `/api/admin/warehouse/receipts/${receipt.id}/cancel`
|
||||
: "/api/admin/warehouse/receipts/0/cancel",
|
||||
const cancelMutation = useApiMutation<number, void>({
|
||||
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/cancel`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -124,13 +114,11 @@ export default function WarehouseReceiptDetail() {
|
||||
});
|
||||
|
||||
const deleteAttachmentMutation = useApiMutation<
|
||||
{ attachmentId: number },
|
||||
{ receiptId: number; attachmentId: number },
|
||||
void
|
||||
>({
|
||||
url: ({ attachmentId }) =>
|
||||
receipt
|
||||
? `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`
|
||||
: `/api/admin/warehouse/receipts/0/attachments/0`,
|
||||
url: ({ receiptId, attachmentId }) =>
|
||||
`/api/admin/warehouse/receipts/${receiptId}/attachments/${attachmentId}`,
|
||||
method: () => "DELETE",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
@@ -138,10 +126,15 @@ export default function WarehouseReceiptDetail() {
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(receipt.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -150,7 +143,7 @@ export default function WarehouseReceiptDetail() {
|
||||
const handleCancel = async () => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
await cancelMutation.mutateAsync(undefined);
|
||||
await cancelMutation.mutateAsync(receipt.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
@@ -159,7 +152,10 @@ export default function WarehouseReceiptDetail() {
|
||||
const handleDeleteAttachment = async (attachmentId: number) => {
|
||||
if (!receipt) return;
|
||||
try {
|
||||
await deleteAttachmentMutation.mutateAsync({ attachmentId });
|
||||
await deleteAttachmentMutation.mutateAsync({
|
||||
receiptId: receipt.id,
|
||||
attachmentId,
|
||||
});
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
}
|
||||
|
||||
@@ -200,6 +200,75 @@ export default function WarehouseReceiptForm() {
|
||||
}
|
||||
}, [isEdit, receipt]);
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/receipts/${id}`
|
||||
: "/api/admin/warehouse/receipts",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const confirmMutation = useApiMutation<number, void>({
|
||||
url: (receiptId) => `/api/admin/warehouse/receipts/${receiptId}/confirm`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Doklad byl potvrzen");
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
if (!isEdit || !id) return;
|
||||
setUploadingFiles(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/receipts/${id}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
alert.error(
|
||||
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
alert.success("Soubory byly nahrány");
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setUploadingFiles(false);
|
||||
}
|
||||
},
|
||||
[id, isEdit, alert, queryClient],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFileUpload(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.operate")) return <Forbidden />;
|
||||
|
||||
const addItem = () => {
|
||||
@@ -266,31 +335,6 @@ export default function WarehouseReceiptForm() {
|
||||
})),
|
||||
});
|
||||
|
||||
const saveMutation = useApiMutation<
|
||||
ReturnType<typeof buildPayload>,
|
||||
{ id?: number }
|
||||
>({
|
||||
url: () =>
|
||||
isEdit
|
||||
? `/api/admin/warehouse/receipts/${id}`
|
||||
: "/api/admin/warehouse/receipts",
|
||||
method: () => (isEdit ? "PUT" : "POST"),
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
|
||||
const [confirmReceiptId, setConfirmReceiptId] = useState<number | null>(null);
|
||||
const confirmMutation = useApiMutation<void, void>({
|
||||
url: () =>
|
||||
confirmReceiptId
|
||||
? `/api/admin/warehouse/receipts/${confirmReceiptId}/confirm`
|
||||
: "/api/admin/warehouse/receipts/0/confirm",
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
alert.success("Doklad byl potvrzen");
|
||||
},
|
||||
});
|
||||
|
||||
const saveReceipt = async (): Promise<number | null> => {
|
||||
try {
|
||||
const data = await saveMutation.mutateAsync(buildPayload());
|
||||
@@ -304,13 +348,10 @@ export default function WarehouseReceiptForm() {
|
||||
};
|
||||
|
||||
const confirmReceipt = async (receiptId: number) => {
|
||||
setConfirmReceiptId(receiptId);
|
||||
try {
|
||||
await confirmMutation.mutateAsync(undefined);
|
||||
await confirmMutation.mutateAsync(receiptId);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setConfirmReceiptId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -342,53 +383,6 @@ export default function WarehouseReceiptForm() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = useCallback(
|
||||
async (files: FileList | File[]) => {
|
||||
if (!isEdit || !id) return;
|
||||
setUploadingFiles(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/receipts/${id}/attachments`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!result.success) {
|
||||
alert.error(
|
||||
result.error || `Nepodařilo se nahrát soubor ${file.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
alert.success("Soubory byly nahrány");
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} finally {
|
||||
setUploadingFiles(false);
|
||||
}
|
||||
},
|
||||
[id, isEdit, alert, queryClient],
|
||||
);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.dataTransfer.files.length > 0) {
|
||||
handleFileUpload(e.dataTransfer.files);
|
||||
}
|
||||
},
|
||||
[handleFileUpload],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
if (isEdit && isPending) {
|
||||
return <LoadingState />;
|
||||
}
|
||||
|
||||
@@ -8,11 +8,14 @@ import {
|
||||
warehouseStockStatusOptions,
|
||||
warehouseBelowMinimumOptions,
|
||||
warehouseCategoryListOptions,
|
||||
warehouseMovementLogOptions,
|
||||
type WarehouseItem,
|
||||
type MovementLogRow,
|
||||
} from "../lib/queries/warehouse";
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatCurrency } from "../utils/formatters";
|
||||
import { jsonQuery } from "../lib/apiAdapter";
|
||||
import { formatCurrency, formatDate } from "../utils/formatters";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DataTable,
|
||||
@@ -43,16 +46,6 @@ interface ProjectConsumptionRow {
|
||||
total_value: number;
|
||||
}
|
||||
|
||||
interface MovementLogRow {
|
||||
date: string;
|
||||
type: string;
|
||||
document_number: string;
|
||||
item_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
supplier_or_project: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ value: "stock-status", label: "Stav zásob" },
|
||||
{ value: "project-consumption", label: "Spotřeba projektů" },
|
||||
@@ -260,34 +253,36 @@ function ProjectConsumptionTab({
|
||||
name: string;
|
||||
}[]) ?? [];
|
||||
|
||||
const [data, setData] = useState<ProjectConsumptionRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
// Filters applied on "Zobrazit"; the query key carries them so results land in
|
||||
// the ["warehouse"] cache and refresh on warehouse mutations.
|
||||
const [applied, setApplied] = useState<{
|
||||
projectId: number | "";
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setFetched(true);
|
||||
try {
|
||||
const {
|
||||
data,
|
||||
isFetching: loading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["warehouse", "project-consumption", applied],
|
||||
enabled: applied !== null,
|
||||
queryFn: () => {
|
||||
const params = new URLSearchParams();
|
||||
if (projectId) params.set("project_id", String(projectId));
|
||||
if (dateFrom) params.set("date_from", dateFrom);
|
||||
if (dateTo) params.set("date_to", dateTo);
|
||||
if (applied?.projectId)
|
||||
params.set("project_id", String(applied.projectId));
|
||||
if (applied?.dateFrom) params.set("date_from", applied.dateFrom);
|
||||
if (applied?.dateTo) params.set("date_to", applied.dateTo);
|
||||
const qs = params.toString();
|
||||
const response = await apiFetch(
|
||||
return jsonQuery<ProjectConsumptionRow[]>(
|
||||
`/api/admin/warehouse/reports/project-consumption${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setData(result.data ?? []);
|
||||
} else {
|
||||
setData([]);
|
||||
}
|
||||
} catch {
|
||||
setData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const fetched = applied !== null;
|
||||
const handleFetch = () => setApplied({ projectId, dateFrom, dateTo });
|
||||
|
||||
const columns: DataColumn<ProjectConsumptionRow & { _idx: number }>[] = [
|
||||
{
|
||||
@@ -352,7 +347,15 @@ function ProjectConsumptionTab({
|
||||
|
||||
{fetched && loading && <LoadingState />}
|
||||
|
||||
{fetched && !loading && data && (
|
||||
{fetched && !loading && error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Nepodařilo se načíst spotřebu projektů"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetched && !loading && !error && data && (
|
||||
<Card>
|
||||
<DataTable<ProjectConsumptionRow & { _idx: number }>
|
||||
columns={columns}
|
||||
@@ -383,34 +386,29 @@ function MovementLogTab({
|
||||
dateTo: string;
|
||||
setDateTo: (v: string) => void;
|
||||
}) {
|
||||
const [data, setData] = useState<MovementLogRow[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetched, setFetched] = useState(false);
|
||||
// Filters applied on "Zobrazit"; uses the shared movement-log query option so
|
||||
// results land in the ["warehouse"] cache and refresh on warehouse mutations.
|
||||
const [applied, setApplied] = useState<{
|
||||
type: string;
|
||||
dateFrom: string;
|
||||
dateTo: string;
|
||||
} | null>(null);
|
||||
|
||||
const handleFetch = async () => {
|
||||
setLoading(true);
|
||||
setFetched(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (type) params.set("type", type);
|
||||
if (dateFrom) params.set("date_from", dateFrom);
|
||||
if (dateTo) params.set("date_to", dateTo);
|
||||
const qs = params.toString();
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/reports/movement-log${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
setData(result.data ?? []);
|
||||
} else {
|
||||
setData([]);
|
||||
}
|
||||
} catch {
|
||||
setData([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const {
|
||||
data,
|
||||
isFetching: loading,
|
||||
error,
|
||||
} = useQuery({
|
||||
...warehouseMovementLogOptions({
|
||||
type: applied?.type || undefined,
|
||||
date_from: applied?.dateFrom || undefined,
|
||||
date_to: applied?.dateTo || undefined,
|
||||
}),
|
||||
enabled: applied !== null,
|
||||
});
|
||||
|
||||
const fetched = applied !== null;
|
||||
const handleFetch = () => setApplied({ type, dateFrom, dateTo });
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
receipt: "Příjem",
|
||||
@@ -423,7 +421,7 @@ function MovementLogTab({
|
||||
header: "Datum",
|
||||
width: "12%",
|
||||
mono: true,
|
||||
render: (row) => row.date,
|
||||
render: (row) => formatDate(row.date),
|
||||
},
|
||||
{
|
||||
key: "type",
|
||||
@@ -501,7 +499,15 @@ function MovementLogTab({
|
||||
|
||||
{fetched && loading && <LoadingState />}
|
||||
|
||||
{fetched && !loading && data && (
|
||||
{fetched && !loading && error && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Nepodařilo se načíst pohyby"}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fetched && !loading && !error && data && (
|
||||
<Card>
|
||||
<DataTable<MovementLogRow & { _idx: number }>
|
||||
columns={columns}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAlert } from "../context/AlertContext";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
import Forbidden from "../components/Forbidden";
|
||||
@@ -9,12 +9,12 @@ import IconButton from "@mui/material/IconButton";
|
||||
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
|
||||
import ItemPicker from "../components/warehouse/ItemPicker";
|
||||
|
||||
import apiFetch from "../utils/api";
|
||||
import { formatDate, czechPlural } from "../utils/formatters";
|
||||
import {
|
||||
warehouseReservationListOptions,
|
||||
type WarehouseReservation,
|
||||
} from "../lib/queries/warehouse";
|
||||
import { useApiMutation } from "../lib/queries/mutations";
|
||||
import { projectListOptions } from "../lib/queries/projects";
|
||||
import {
|
||||
Button,
|
||||
@@ -106,11 +106,17 @@ interface ReservationForm {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface CreateReservationPayload {
|
||||
item_id: number;
|
||||
project_id: number;
|
||||
quantity: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export default function WarehouseReservations() {
|
||||
const navigate = useNavigate();
|
||||
const alert = useAlert();
|
||||
const { hasPermission } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
@@ -148,6 +154,29 @@ export default function WarehouseReservations() {
|
||||
}),
|
||||
);
|
||||
|
||||
const createMutation = useApiMutation<CreateReservationPayload, void>({
|
||||
url: () => "/api/admin/warehouse/reservations",
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
setShowCreateModal(false);
|
||||
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
|
||||
alert.success("Rezervace byla vytvořena");
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = useApiMutation<number, void>({
|
||||
url: (reservationId) =>
|
||||
`/api/admin/warehouse/reservations/${reservationId}/cancel`,
|
||||
method: () => "POST",
|
||||
invalidate: ["warehouse"],
|
||||
onSuccess: () => {
|
||||
setCancelTarget(null);
|
||||
alert.success("Rezervace byla zrušena");
|
||||
},
|
||||
});
|
||||
|
||||
// All hooks above this line — permission gate is the last thing before render.
|
||||
if (!hasPermission("warehouse.view")) return <Forbidden />;
|
||||
|
||||
const canOperate = hasPermission("warehouse.operate");
|
||||
@@ -161,33 +190,25 @@ export default function WarehouseReservations() {
|
||||
const hasActiveFilters = statusFilter || projectId;
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!form.item_id || !form.project_id || form.quantity <= 0) {
|
||||
if (
|
||||
!form.item_id ||
|
||||
!form.project_id ||
|
||||
!Number.isFinite(form.quantity) ||
|
||||
form.quantity <= 0
|
||||
) {
|
||||
alert.error("Vyplňte položku, projekt a množství");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const response = await apiFetch("/api/admin/warehouse/reservations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
item_id: form.item_id,
|
||||
project_id: form.project_id,
|
||||
quantity: form.quantity,
|
||||
notes: form.notes.trim() || null,
|
||||
}),
|
||||
await createMutation.mutateAsync({
|
||||
item_id: form.item_id,
|
||||
project_id: form.project_id,
|
||||
quantity: form.quantity,
|
||||
notes: form.notes.trim() || null,
|
||||
});
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
setShowCreateModal(false);
|
||||
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
|
||||
alert.success("Rezervace byla vytvořena");
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se vytvořit rezervaci");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -197,20 +218,9 @@ export default function WarehouseReservations() {
|
||||
if (!cancelTarget) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
const response = await apiFetch(
|
||||
`/api/admin/warehouse/reservations/${cancelTarget.id}/cancel`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
|
||||
setCancelTarget(null);
|
||||
alert.success("Rezervace byla zrušena");
|
||||
} else {
|
||||
alert.error(result.error || "Nepodařilo se zrušit rezervaci");
|
||||
}
|
||||
} catch {
|
||||
alert.error("Chyba připojení");
|
||||
await cancelMutation.mutateAsync(cancelTarget.id);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
@@ -451,12 +461,13 @@ export default function WarehouseReservations() {
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.quantity || ""}
|
||||
onChange={(e) =>
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
quantity: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
quantity: Number.isFinite(n) ? n : 0,
|
||||
}));
|
||||
}}
|
||||
inputProps={{ min: 0, step: 0.001 }}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@@ -129,10 +129,6 @@ export default function WarehouseSuppliers() {
|
||||
show: boolean;
|
||||
supplier: WarehouseSupplier | null;
|
||||
}>({ show: false, supplier: null });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [toggleActiveSupplierId, setToggleActiveSupplierId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
const submitMutation = useApiMutation<
|
||||
SupplierForm,
|
||||
@@ -158,14 +154,14 @@ export default function WarehouseSuppliers() {
|
||||
},
|
||||
});
|
||||
|
||||
// id is captured from the mutation variable (built into the URL), so it stays
|
||||
// correct even when called immediately after a state update. The non-strict
|
||||
// UpdateSupplierSchema strips the `id` field from the body.
|
||||
const toggleActiveMutation = useApiMutation<
|
||||
{ is_active: boolean },
|
||||
{ id: number; is_active: boolean },
|
||||
{ message?: string }
|
||||
>({
|
||||
url: () => {
|
||||
if (!toggleActiveSupplierId) return API_BASE;
|
||||
return `${API_BASE}/${toggleActiveSupplierId}`;
|
||||
},
|
||||
url: ({ id }) => `${API_BASE}/${id}`,
|
||||
method: () => "PUT",
|
||||
invalidate: ["warehouse"],
|
||||
});
|
||||
@@ -210,13 +206,10 @@ export default function WarehouseSuppliers() {
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await submitMutation.mutateAsync(form);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -231,9 +224,9 @@ export default function WarehouseSuppliers() {
|
||||
};
|
||||
|
||||
const toggleActive = async (supplier: WarehouseSupplier) => {
|
||||
setToggleActiveSupplierId(supplier.id);
|
||||
try {
|
||||
await toggleActiveMutation.mutateAsync({
|
||||
id: supplier.id,
|
||||
is_active: !supplier.is_active,
|
||||
});
|
||||
alert.success(
|
||||
@@ -243,8 +236,6 @@ export default function WarehouseSuppliers() {
|
||||
);
|
||||
} catch (e) {
|
||||
alert.error(e instanceof Error ? e.message : "Chyba připojení");
|
||||
} finally {
|
||||
setToggleActiveSupplierId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -403,7 +394,7 @@ export default function WarehouseSuppliers() {
|
||||
onClose={() => setShowModal(false)}
|
||||
onSubmit={handleSubmit}
|
||||
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
|
||||
loading={saving}
|
||||
loading={submitMutation.isPending}
|
||||
>
|
||||
<Field label="Název" required error={errors.name}>
|
||||
<TextField
|
||||
|
||||
@@ -92,6 +92,12 @@ export const theme = createTheme({
|
||||
boxShadow: "0 8px 20px rgba(214,48,49,0.42)",
|
||||
},
|
||||
"&:active": { transform: "translateY(0) scale(0.97)" },
|
||||
// Honor reduced-motion like MuiButton.root / MuiOutlinedInput: drop
|
||||
// the lift/press transform (glow + brightness stay, they don't move).
|
||||
"@media (prefers-reduced-motion: reduce)": {
|
||||
"&:hover": { transform: "none" },
|
||||
"&:active": { transform: "none" },
|
||||
},
|
||||
},
|
||||
// Filled colored buttons: WHITE text in BOTH themes (was per-scheme
|
||||
// contrastText → near-black text on colored fills in dark mode, which
|
||||
|
||||
@@ -15,7 +15,13 @@ export default function Alert({
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<MuiAlert severity={severity} onClose={onClose} sx={{ borderRadius: 2 }}>
|
||||
<MuiAlert
|
||||
severity={severity}
|
||||
onClose={onClose}
|
||||
// Czech label for the close button (MUI defaults to English "Close").
|
||||
slotProps={{ closeButton: { "aria-label": "Zavřít" } }}
|
||||
sx={{ borderRadius: 2 }}
|
||||
>
|
||||
{children}
|
||||
</MuiAlert>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { Outlet, Navigate, useLocation } from "react-router-dom";
|
||||
import { motion } from "framer-motion";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -19,14 +19,24 @@ export default function AppShell() {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [loggingOut, setLoggingOut] = useState(false);
|
||||
const location = useLocation();
|
||||
const logoutTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
setLoggingOut(true);
|
||||
setMobileOpen(false);
|
||||
setLogoutAlert();
|
||||
setTimeout(() => logout(), 400);
|
||||
// Delay so the blur/scale exit animation can play; store the id so an
|
||||
// unmount within the 400ms window cancels it (avoids logout() firing on
|
||||
// an unmounted tree).
|
||||
logoutTimer.current = setTimeout(() => logout(), 400);
|
||||
}, [logout]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (logoutTimer.current) clearTimeout(logoutTimer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<ScopedCssBaseline>
|
||||
@@ -138,6 +148,7 @@ export default function AppShell() {
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import MuiCard, { type CardProps } from "@mui/material/Card";
|
||||
import CardContent from "@mui/material/CardContent";
|
||||
import CardContent, { type CardContentProps } from "@mui/material/CardContent";
|
||||
|
||||
export default function Card({ children, ...props }: CardProps) {
|
||||
export interface AppCardProps extends CardProps {
|
||||
/**
|
||||
* Skip the default CardContent wrapper so children sit edge-to-edge (e.g. for
|
||||
* media, tables, or a custom layout). Defaults to false — the standard padded
|
||||
* content behavior is unchanged for existing call sites.
|
||||
*/
|
||||
disableContentPadding?: boolean;
|
||||
/** Props forwarded to the inner CardContent (ignored when disableContentPadding). */
|
||||
contentProps?: CardContentProps;
|
||||
}
|
||||
|
||||
export default function Card({
|
||||
children,
|
||||
disableContentPadding = false,
|
||||
contentProps,
|
||||
...props
|
||||
}: AppCardProps) {
|
||||
return (
|
||||
<MuiCard {...props}>
|
||||
<CardContent>{children}</CardContent>
|
||||
{disableContentPadding ? (
|
||||
children
|
||||
) : (
|
||||
<CardContent {...contentProps}>{children}</CardContent>
|
||||
)}
|
||||
</MuiCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,20 @@ export function CheckboxField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
name,
|
||||
id,
|
||||
indeterminate,
|
||||
required,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
id?: string;
|
||||
indeterminate?: boolean;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
@@ -18,9 +28,16 @@ export function CheckboxField({
|
||||
<MuiCheckbox
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
id={id}
|
||||
indeterminate={indeterminate}
|
||||
required={required}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
required={required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,15 +41,31 @@ export default function ConfirmDialog({
|
||||
// this the button would flash back to "Smazat"/confirmText). React
|
||||
// "adjust state from props during render": update only while open. Also keeps
|
||||
// content from flashing empty when the caller clears its row on close.
|
||||
const [shown, setShown] = useState({ title, message, confirmText, loading });
|
||||
const [shown, setShown] = useState({
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
confirmVariant,
|
||||
loading,
|
||||
});
|
||||
if (
|
||||
isOpen &&
|
||||
(shown.title !== title ||
|
||||
shown.message !== message ||
|
||||
shown.confirmText !== confirmText ||
|
||||
shown.cancelText !== cancelText ||
|
||||
shown.confirmVariant !== confirmVariant ||
|
||||
shown.loading !== loading)
|
||||
) {
|
||||
setShown({ title, message, confirmText, loading });
|
||||
setShown({
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
confirmVariant,
|
||||
loading,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -66,12 +82,12 @@ export default function ConfirmDialog({
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
|
||||
{cancelText}
|
||||
{shown.cancelText}
|
||||
</MuiButton>
|
||||
<MuiButton
|
||||
onClick={onConfirm}
|
||||
variant="contained"
|
||||
color={confirmVariant === "danger" ? "error" : "primary"}
|
||||
color={shown.confirmVariant === "danger" ? "error" : "primary"}
|
||||
disabled={shown.loading}
|
||||
>
|
||||
{shown.loading ? "Zpracovávám…" : shown.confirmText}
|
||||
|
||||
@@ -11,6 +11,9 @@ import TableContainer from "@mui/material/TableContainer";
|
||||
import TableHead from "@mui/material/TableHead";
|
||||
import TableRow from "@mui/material/TableRow";
|
||||
import TableSortLabel from "@mui/material/TableSortLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import MuiTextField from "@mui/material/TextField";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
|
||||
export interface DataColumn<T> {
|
||||
key: string;
|
||||
@@ -78,8 +81,56 @@ export default function DataTable<T>({
|
||||
if (isMobile) {
|
||||
const actionCol = columns.find((c) => c.key === "actions");
|
||||
const dataCols = columns.filter((c) => c.key !== "actions");
|
||||
// The header row (with its sort labels) is gone in the card layout, so
|
||||
// surface the same columns[].sortKey/onSort wiring through a compact
|
||||
// dropdown + direction toggle, otherwise mobile users can't sort at all.
|
||||
const sortCols = columns.filter((c) => c.sortKey);
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
||||
{onSort && sortCols.length > 0 && (
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
<MuiTextField
|
||||
select
|
||||
size="small"
|
||||
label="Řadit podle"
|
||||
value={sortBy ?? ""}
|
||||
onChange={(e) => onSort(e.target.value)}
|
||||
sx={{ flex: 1 }}
|
||||
>
|
||||
{sortCols.map((c) => (
|
||||
<MenuItem key={c.key} value={c.sortKey!}>
|
||||
{c.header}
|
||||
</MenuItem>
|
||||
))}
|
||||
</MuiTextField>
|
||||
<IconButton
|
||||
aria-label={
|
||||
sortDir === "desc" ? "Řadit vzestupně" : "Řadit sestupně"
|
||||
}
|
||||
disabled={!sortBy}
|
||||
onClick={() => sortBy && onSort(sortBy)}
|
||||
size="small"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
transform:
|
||||
sortDir === "desc" ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform .15s",
|
||||
}}
|
||||
>
|
||||
<line x1="12" y1="19" x2="12" y2="5" />
|
||||
<polyline points="5 12 12 5 19 12" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
{rows.map((row) => {
|
||||
const extra = rowSx?.(row);
|
||||
return (
|
||||
@@ -255,6 +306,14 @@ export default function DataTable<T>({
|
||||
<TableCell
|
||||
key={c.key}
|
||||
align={c.align}
|
||||
// The actions cell must not bubble its click to the row: an
|
||||
// action button inside an onRowClick row would otherwise
|
||||
// fire both the action AND the row navigation.
|
||||
onClick={
|
||||
onRowClick && c.key === "actions"
|
||||
? (e) => e.stopPropagation()
|
||||
: undefined
|
||||
}
|
||||
sx={{
|
||||
fontSize: ".8rem",
|
||||
...(c.bold ? { fontWeight: 500 } : {}),
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useId,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import Box from "@mui/material/Box";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||
@@ -18,10 +24,44 @@ export function Field({
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
// Associate the <label> with the control. Generate a stable id, render it via
|
||||
// `htmlFor`, and inject the same id onto the single input child so click-to-
|
||||
// focus works and screen readers announce the label. Wire error/hint via
|
||||
// aria-describedby + aria-invalid for assistive tech. We only inject onto a
|
||||
// valid element that hasn't already set `id`, so explicit caller ids win.
|
||||
const reactId = useId();
|
||||
const generatedId = `field-${reactId}`;
|
||||
// If the child already carries an id, keep it as the htmlFor target so the
|
||||
// label still points at the real input; otherwise inject the generated one.
|
||||
const childId = isValidElement(children)
|
||||
? (children.props as { id?: string }).id
|
||||
: undefined;
|
||||
const inputId = childId ?? generatedId;
|
||||
const describedById = error
|
||||
? `${inputId}-error`
|
||||
: hint
|
||||
? `${inputId}-hint`
|
||||
: undefined;
|
||||
|
||||
let control = children;
|
||||
if (isValidElement(children)) {
|
||||
const extra: Record<string, unknown> = {};
|
||||
if (childId == null) extra.id = inputId;
|
||||
if (describedById) extra["aria-describedby"] = describedById;
|
||||
if (error) extra["aria-invalid"] = true;
|
||||
if (Object.keys(extra).length > 0) {
|
||||
control = cloneElement(
|
||||
children as ReactElement<Record<string, unknown>>,
|
||||
extra,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography
|
||||
component="label"
|
||||
htmlFor={inputId}
|
||||
variant="body2"
|
||||
sx={{
|
||||
display: "block",
|
||||
@@ -37,13 +77,13 @@ export function Field({
|
||||
</Box>
|
||||
)}
|
||||
</Typography>
|
||||
{children}
|
||||
{control}
|
||||
{error ? (
|
||||
<Typography variant="caption" color="error">
|
||||
<Typography id={describedById} variant="caption" color="error">
|
||||
{error}
|
||||
</Typography>
|
||||
) : hint ? (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<Typography id={describedById} variant="caption" color="text.secondary">
|
||||
{hint}
|
||||
</Typography>
|
||||
) : null}
|
||||
@@ -56,20 +96,24 @@ export function SwitchField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function LoadingState({ label }: LoadingStateProps) {
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={36} />
|
||||
<CircularProgress size={36} aria-label={label ?? "Načítání"} />
|
||||
{label && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{label}
|
||||
|
||||
@@ -46,15 +46,22 @@ export default function Modal({
|
||||
// title/submitText NOR the loading state flips during the close fade-out
|
||||
// (e.g. on save, `loading` goes false a beat before the dialog finishes
|
||||
// closing — without this the button would flash back to "Uložit změny").
|
||||
const [shown, setShown] = useState({ title, subtitle, submitText, loading });
|
||||
const [shown, setShown] = useState({
|
||||
title,
|
||||
subtitle,
|
||||
submitText,
|
||||
cancelText,
|
||||
loading,
|
||||
});
|
||||
if (
|
||||
isOpen &&
|
||||
(shown.title !== title ||
|
||||
shown.subtitle !== subtitle ||
|
||||
shown.submitText !== submitText ||
|
||||
shown.cancelText !== cancelText ||
|
||||
shown.loading !== loading)
|
||||
) {
|
||||
setShown({ title, subtitle, submitText, loading });
|
||||
setShown({ title, subtitle, submitText, cancelText, loading });
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -78,7 +85,7 @@ export default function Modal({
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
{!hideCancel && (
|
||||
<MuiButton onClick={onClose} color="inherit" disabled={shown.loading}>
|
||||
{cancelText}
|
||||
{shown.cancelText}
|
||||
</MuiButton>
|
||||
)}
|
||||
<MuiButton
|
||||
|
||||
@@ -5,10 +5,15 @@ import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
||||
import { cs } from "date-fns/locale";
|
||||
import { theme } from "../theme";
|
||||
import AppGlobalStyles from "../GlobalStyles";
|
||||
import { DEFAULT_THEME_MODE } from "../../context/ThemeContext";
|
||||
|
||||
export default function MuiProvider({ children }: { children: ReactNode }) {
|
||||
// `defaultMode` is only the first-paint fallback before a stored preference is
|
||||
// read. It is pinned to ThemeContext's single `DEFAULT_THEME_MODE` constant
|
||||
// (the real source of truth for `<html data-theme>` / the `mui-mode` storage
|
||||
// key) so the two defaults can never drift and cause a one-frame mismatch.
|
||||
return (
|
||||
<ThemeProvider theme={theme} defaultMode="dark">
|
||||
<ThemeProvider theme={theme} defaultMode={DEFAULT_THEME_MODE}>
|
||||
<AppGlobalStyles />
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={cs}>
|
||||
{children}
|
||||
|
||||
@@ -13,10 +13,13 @@ export default function ProgressBar({
|
||||
value,
|
||||
color = "primary",
|
||||
height = 8,
|
||||
label,
|
||||
}: {
|
||||
value: number;
|
||||
color?: ProgressColor;
|
||||
height?: number;
|
||||
/** Accessible name for the progress bar (announced by screen readers). */
|
||||
label?: string;
|
||||
}) {
|
||||
const clamped = Number.isFinite(value)
|
||||
? Math.max(0, Math.min(100, value))
|
||||
@@ -27,6 +30,7 @@ export default function ProgressBar({
|
||||
variant="determinate"
|
||||
value={clamped}
|
||||
color={color}
|
||||
aria-label={label ?? `Průběh ${Math.round(clamped)} %`}
|
||||
sx={{ height, borderRadius: height / 2 }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface TabDef {
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Deterministic ids so the Tab control and its TabPanel can cross-reference for
|
||||
// a11y (aria-controls <-> aria-labelledby). Values are app-controlled strings.
|
||||
const tabId = (value: string) => `tab-${value}`;
|
||||
const panelId = (value: string) => `tabpanel-${value}`;
|
||||
|
||||
/** Tab bar over MUI Tabs. Pair with <TabPanel> for the content. */
|
||||
export function Tabs({
|
||||
value,
|
||||
@@ -30,7 +35,13 @@ export function Tabs({
|
||||
}}
|
||||
>
|
||||
{tabs.map((t) => (
|
||||
<Tab key={t.value} value={t.value} label={t.label} />
|
||||
<Tab
|
||||
key={t.value}
|
||||
value={t.value}
|
||||
label={t.label}
|
||||
id={tabId(t.value)}
|
||||
aria-controls={panelId(t.value)}
|
||||
/>
|
||||
))}
|
||||
</MuiTabs>
|
||||
);
|
||||
@@ -47,5 +58,14 @@ export function TabPanel({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (value !== current) return null;
|
||||
return <Box sx={{ pt: 2.5 }}>{children}</Box>;
|
||||
return (
|
||||
<Box
|
||||
role="tabpanel"
|
||||
id={panelId(value)}
|
||||
aria-labelledby={tabId(value)}
|
||||
sx={{ pt: 2.5 }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -634,7 +634,13 @@ export function isItemActive(item: MenuItem, pathname: string): boolean {
|
||||
return true;
|
||||
}
|
||||
if (item.end) return pathname === item.path;
|
||||
return pathname.startsWith(item.path);
|
||||
// Boundary-safe prefix match: exact, or a child route under item.path (so a
|
||||
// sibling like "/x-foo" can't spuriously activate "/x"). Avoids a double "/"
|
||||
// when item.path is the root.
|
||||
return (
|
||||
pathname === item.path ||
|
||||
pathname.startsWith(item.path === "/" ? "/" : `${item.path}/`)
|
||||
);
|
||||
}
|
||||
|
||||
/** Permission check: true if no permission, or any listed permission is held. */
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
// Ref-counted lock of the <html> scroll. MUI's Dialog only locks <body>, but
|
||||
// base.css sets `html { overflow-x: hidden }`, which (per the CSS spec) forces
|
||||
// html's overflow-y to `auto` — making <html> the vertical scroll container.
|
||||
// GlobalStyles.tsx sets `html { overflow-x: hidden }`, which (per the CSS spec)
|
||||
// forces html's overflow-y to `auto` — making <html> the vertical scroll
|
||||
// container.
|
||||
// So body-locking alone leaves the page scrollable behind the modal. We lock
|
||||
// <html> directly and pad for the removed scrollbar to avoid a layout shift.
|
||||
let lockCount = 0;
|
||||
|
||||
@@ -104,8 +104,10 @@ export const getDatePart = (datetime: string | null | undefined): string => {
|
||||
|
||||
export const getTimePart = (datetime: string | null | undefined): string => {
|
||||
if (!datetime) return "";
|
||||
const d = new Date(datetime);
|
||||
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
||||
// Parse HH:MM directly from the string (no `new Date(...).getHours()`), so
|
||||
// the displayed time matches the stored wall-clock time regardless of the
|
||||
// browser timezone — same ethos as the sibling `extractTime` helper.
|
||||
return extractTime(datetime);
|
||||
};
|
||||
|
||||
export const calcProjectMinutesTotal = (
|
||||
@@ -252,7 +254,7 @@ export const calculateNightMinutes = (record: AttendanceRecord): number => {
|
||||
let intervals: Array<[number, number]> = [[startMs, endMs]];
|
||||
if (record.break_start && record.break_end) {
|
||||
const bStart = new Date(record.break_start).getTime();
|
||||
let bEnd = new Date(record.break_end).getTime();
|
||||
const bEnd = new Date(record.break_end).getTime();
|
||||
if (Number.isFinite(bStart) && Number.isFinite(bEnd) && bEnd > bStart) {
|
||||
const next: Array<[number, number]> = [];
|
||||
for (const [s, e] of intervals) {
|
||||
|
||||
@@ -4,13 +4,6 @@ export const LEAVE_TYPE_LABELS: Record<string, string> = {
|
||||
unpaid: "Neplacené volno",
|
||||
};
|
||||
|
||||
export const STATUS_DOT_CLASS: Record<string, string> = {
|
||||
in: "dash-status-in",
|
||||
away: "dash-status-away",
|
||||
out: "dash-status-out",
|
||||
leave: "dash-status-leave",
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<string, string> = {
|
||||
in: "Přítomen",
|
||||
away: "Přestávka",
|
||||
@@ -57,6 +50,10 @@ export function getCzechDate(): string {
|
||||
];
|
||||
const day = days[now.getDay()];
|
||||
const oneJan = new Date(now.getFullYear(), 0, 1);
|
||||
// Naive week-of-year (NOT true ISO-8601 week-numbering): can be off by one
|
||||
// around the year boundary. Intentionally left as-is — this string is
|
||||
// display-only in the dashboard header; a correct ISO-week implementation
|
||||
// (Thursday-anchored, week-year aware) is more than this cosmetic line warrants.
|
||||
const week = Math.ceil(
|
||||
((now.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user