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:
BOHA
2026-06-09 06:45:26 +02:00
parent c454d1a3fc
commit 519edce373
179 changed files with 7179 additions and 2844 deletions

View File

@@ -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}

View File

@@ -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

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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();
}
};

View File

@@ -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) => {

View File

@@ -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"

View File

@@ -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,

View File

@@ -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

View File

@@ -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={{

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;
}

View File

@@ -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",

View File

@@ -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>

View File

@@ -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",

View File

@@ -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" }}>

View File

@@ -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

View File

@@ -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 ?? [];

View File

@@ -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>