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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user