refactor: fix all Low findings from FLAWS_REPORT audit

- Auth: TOTP params from config, JWT error logging, audit log failure
  logging, replaced_by_hash validation on token rotation
- Invoices: remove dead VAT code, consistent PDF permissions,
  WebP magic-byte detection, deduped exchange-rate fetches
- Orders/Offers: multipart limit from config, use paginated() helper,
  payment method from DB in PDF
- Projects: verify project exists before creating note
- Attendance: action_type enum validation, consistent local-time
  shift_date construction, holiday attendance in work fund,
  trips.view permission on last-km query
- Users: paginated() helper usage, remove duplicate dashboard keys,
  parallel currency conversion, single hashToken implementation
- Frontend: memoized customInput, reliable print onload, modal prop
  standardization (isOpen), ConfirmModal type icons, id===0 key
  fallback, Login useCallback, CompanySettings ConfirmModal,
  Attendance timeout cleanup, Dashboard memoization, beforeunload
  dirty-state warnings on Invoice/Offer/Order detail
- Schema: invoice_alert_log timestamp, config/env comment on
  Date.prototype.toJSON override
- Utils: exchange-rate inflight dedup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-24 08:45:37 +02:00
parent 4f4b12f039
commit aa6c1b5094
35 changed files with 466 additions and 206 deletions

View File

@@ -165,17 +165,22 @@ export default function AdminDatePicker({
return undefined;
};
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput: (
const customInput = useMemo(
() => (
<CustomInput
required={required}
placeholder={placeholder}
disabled={disabled}
/>
),
[required, placeholder, disabled],
);
const commonProps = {
selected: toDate(value),
onChange: handleChange,
locale: "cs",
customInput,
minDate: parseMinMax(minDate),
maxDate: parseMinMax(maxDate),
popperPlacement: "bottom-start" as const,

View File

@@ -87,7 +87,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
}
return (
<span
key={log.id || i}
key={log.id ?? i}
className="admin-badge"
style={{
fontSize: "0.7rem",
@@ -95,7 +95,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} {durationValid ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})` : "—"}
{log.project_name || `#${log.project_id}`}{" "}
{durationValid
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " \u25B8" : ""})`
: "—"}
</span>
);
})}
@@ -257,4 +260,3 @@ export default function AttendanceShiftTable({
</div>
);
}

View File

@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
}
interface BulkAttendanceModalProps {
show: boolean;
isOpen: boolean;
onClose: () => void;
form: BulkAttendanceForm;
setForm: (form: BulkAttendanceForm) => void;
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
}
export default function BulkAttendanceModal({
show,
isOpen,
onClose,
form,
setForm,
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
toggleUser,
toggleAllUsers,
}: BulkAttendanceModalProps) {
useModalLock(show);
useModalLock(isOpen);
return (
<AnimatePresence>
{show && (
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}

View File

@@ -14,6 +14,71 @@ interface ConfirmModalProps {
loading?: boolean;
}
function ConfirmIcon({ type }: { type: string }) {
switch (type) {
case "danger":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
);
case "info":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
case "warning":
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
);
default:
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);
}
}
export default function ConfirmModal({
isOpen,
onClose,
@@ -49,18 +114,7 @@ export default function ConfirmModal({
>
<div className="admin-modal-body admin-confirm-content">
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
<ConfirmIcon type={type} />
</div>
<h2 id="confirm-modal-title" className="admin-confirm-title">
{title}

View File

@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
export interface ShiftFormModalProps {
mode: "create" | "edit";
show: boolean;
isOpen: boolean;
onClose: () => void;
onSubmit: () => void;
form: ShiftFormData;
@@ -196,7 +196,7 @@ function ProjectLogRow({
export default function ShiftFormModal({
mode,
show,
isOpen,
onClose,
onSubmit,
form,
@@ -208,7 +208,7 @@ export default function ShiftFormModal({
onShiftDateChange,
editingRecord,
}: ShiftFormModalProps) {
useModalLock(show);
useModalLock(isOpen);
const isCreate = mode === "create";
const isWorkType = form.leave_type === "work";
@@ -240,7 +240,7 @@ export default function ShiftFormModal({
return (
<AnimatePresence>
{show && (
{isOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}

View File

@@ -1060,7 +1060,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
printWindow.document.open();
printWindow.document.write(bodyContent);
printWindow.document.close();
printWindow.onload = () => printWindow.print();
printWindow.addEventListener("load", () => printWindow.print(), {
once: true,
});
}
}
} catch {

View File

@@ -108,7 +108,7 @@ export default function Attendance() {
project_logs: [],
active_project_id: null,
});
const [showLeaveModal, setShowLeaveModal] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
@@ -122,10 +122,11 @@ export default function Attendance() {
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{
show: boolean;
isOpen: boolean;
action: string | null;
}>({ show: false, action: null });
}>({ isOpen: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null);
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
@@ -133,6 +134,7 @@ export default function Attendance() {
return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
};
}, []);
@@ -176,7 +178,7 @@ export default function Attendance() {
loadProjects();
}, []);
useModalLock(showLeaveModal);
useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />;
@@ -234,7 +236,7 @@ export default function Attendance() {
errorMsg = "Vypršel časový limit";
}
alert.error(errorMsg);
setGpsConfirm({ show: true, action });
setGpsConfirm({ isOpen: true, action });
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
@@ -257,7 +259,7 @@ export default function Attendance() {
if (result.success) {
await fetchData();
setTimeout(() => {
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
@@ -368,7 +370,7 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
setShowLeaveModal(false);
setIsLeaveModalOpen(false);
await fetchData();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
@@ -669,7 +671,7 @@ export default function Attendance() {
{submitting ? "Zpracovávám..." : "Odchod"}
</button>
<button
onClick={() => setShowLeaveModal(true)}
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
>
Žádost o nepřítomnost
@@ -708,7 +710,7 @@ export default function Attendance() {
</button>
<button
onClick={() => setShowLeaveModal(true)}
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
>
Žádost o nepřítomnost
@@ -1056,7 +1058,7 @@ export default function Attendance() {
{/* Leave Modal */}
<AnimatePresence>
{showLeaveModal && (
{isLeaveModalOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
@@ -1066,7 +1068,7 @@ export default function Attendance() {
>
<div
className="admin-modal-backdrop"
onClick={() => setShowLeaveModal(false)}
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
@@ -1187,7 +1189,7 @@ export default function Attendance() {
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowLeaveModal(false)}
onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
@@ -1214,13 +1216,13 @@ export default function Attendance() {
</AnimatePresence>
<ConfirmModal
isOpen={gpsConfirm.show}
isOpen={gpsConfirm.isOpen}
onClose={() => {
setGpsConfirm({ show: false, action: null });
setGpsConfirm({ isOpen: false, action: null });
setSubmitting(false);
}}
onConfirm={() => {
setGpsConfirm({ show: false, action: null });
setGpsConfirm({ isOpen: false, action: null });
submitPunch(gpsConfirm.action!, {});
}}
title="GPS nedostupná"

View File

@@ -405,7 +405,7 @@ export default function AttendanceAdmin() {
{/* Modals */}
<BulkAttendanceModal
show={showBulkModal}
isOpen={showBulkModal}
onClose={() => setShowBulkModal(false)}
form={bulkForm}
setForm={setBulkForm}
@@ -418,7 +418,7 @@ export default function AttendanceAdmin() {
<ShiftFormModal
mode="create"
show={showCreateModal}
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSubmit}
form={createForm}
@@ -433,7 +433,7 @@ export default function AttendanceAdmin() {
<ShiftFormModal
mode="edit"
show={showEditModal && !!editingRecord}
isOpen={showEditModal && !!editingRecord}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
form={editForm}

View File

@@ -39,22 +39,23 @@ export default function AttendanceCreate() {
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const today = new Date().toISOString().split("T")[0];
const [form, setForm] = useState<CreateForm>({
user_id: "",
shift_date: today,
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: "",
break_start_date: today,
break_start_time: "",
break_end_date: today,
break_end_time: "",
departure_date: today,
departure_time: "",
notes: "",
const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0];
return {
user_id: "",
shift_date: today,
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: "",
break_start_date: today,
break_start_time: "",
break_end_date: today,
break_end_time: "",
departure_date: today,
departure_time: "",
notes: "",
};
});
useEffect(() => {

View File

@@ -3,6 +3,7 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
@@ -98,6 +99,10 @@ export default function CompanySettings({
const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
isOpen: boolean;
id: number | null;
}>({ isOpen: false, id: null });
const [bankForm, setBankForm] = useState<BankForm>({
account_name: "",
bank_name: "",
@@ -300,22 +305,31 @@ export default function CompanySettings({
}
};
const handleBankDelete = async (id: number) => {
if (!confirm("Opravdu smazat tento bankovní účet?")) return;
const handleBankDelete = (id: number) => {
setBankDeleteConfirm({ isOpen: true, id });
};
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try {
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, {
method: "DELETE",
});
const response = await apiFetch(
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message);
if (editingBank === id) resetBankForm();
if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts();
} else {
alert.error(result.error || "Chyba při mazání");
}
} catch {
alert.error("Chyba připojení");
} finally {
setBankDeleteConfirm({ isOpen: false, id: null });
}
};
@@ -1215,6 +1229,17 @@ export default function CompanySettings({
</button>
</motion.div>
)}
<ConfirmModal
isOpen={bankDeleteConfirm.isOpen}
onClose={() => setBankDeleteConfirm({ isOpen: false, id: null })}
onConfirm={confirmBankDelete}
title="Smazat bankovní účet"
message="Opravdu chcete smazat tento bankovní účet?"
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
/>
</div>
);
}

View File

@@ -129,7 +129,7 @@ export default function Dashboard() {
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => {
const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true);
@@ -167,7 +167,7 @@ export default function Dashboard() {
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
};
}, [dashData, alert, fetchDashboard]);
// 2FA handlery
const handleStart2FASetup = async () => {

View File

@@ -383,6 +383,8 @@ export default function InvoiceDetail() {
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [invoiceNumber, setInvoiceNumber] = useState("");
const initialSnapshotRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
@@ -664,6 +666,32 @@ export default function InvoiceDetail() {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
useEffect(() => {
if (loading) {
hasSetInitialSnapshot.current = false;
return;
}
if (!hasSetInitialSnapshot.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
hasSetInitialSnapshot.current = true;
}
}, [loading, form, items]);
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return JSON.stringify({ form, items }) !== initialSnapshotRef.current;
}, [form, items]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// ─── Due date calculation from issue date + days ───
useEffect(() => {
if (!form.issue_date) return;
@@ -826,6 +854,8 @@ export default function InvoiceDetail() {
result.message ||
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
hasSetInitialSnapshot.current = true;
if (isEdit) {
fetchDetail();
} else {

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useCallback } from "react";
import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
@@ -57,62 +57,91 @@ export default function Login() {
return <Navigate to="/" replace />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const result = await login(username, password, remember);
const result = await login(username, password, remember);
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null);
setShow2FA(true);
setTotpCode("");
setLoading(false);
} else if (!result.success) {
alert.error(result.error ?? "Chyba přihlášení");
setShake(true);
setTimeout(() => setShake(false), 500);
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
};
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null);
setShow2FA(true);
setTotpCode("");
setLoading(false);
} else if (!result.success) {
alert.error(result.error ?? "Chyba přihlášení");
setShake(true);
setTimeout(() => setShake(false), 500);
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
},
[
username,
password,
remember,
login,
alert,
setLoading,
setShake,
setAnimatingOut,
setLoginToken,
setShow2FA,
setTotpCode,
],
);
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!totpCode.trim()) return;
const handle2FASubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!totpCode.trim()) return;
setLoading(true);
setLoading(true);
const result = await verify2FA(
loginToken!,
totpCode.trim(),
const result = await verify2FA(
loginToken!,
totpCode.trim(),
remember,
useBackupCode,
);
if (!result.success) {
alert.error(result.error ?? "Chyba ověření");
setShake(true);
setTimeout(() => setShake(false), 500);
setTotpCode("");
if (totpInputRef.current) totpInputRef.current.focus();
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
},
[
totpCode,
loginToken,
remember,
useBackupCode,
);
verify2FA,
alert,
setLoading,
setShake,
setTotpCode,
setAnimatingOut,
],
);
if (!result.success) {
alert.error(result.error ?? "Chyba ověření");
setShake(true);
setTimeout(() => setShake(false), 500);
setTotpCode("");
if (totpInputRef.current) totpInputRef.current.focus();
setLoading(false);
} else {
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
};
const handleBack = () => {
const handleBack = useCallback(() => {
setShow2FA(false);
setLoginToken(null);
setTotpCode("");
setUseBackupCode(false);
};
}, [setShow2FA, setLoginToken, setTotpCode, setUseBackupCode]);
return (
<motion.div

View File

@@ -2,6 +2,7 @@ import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
type ChangeEvent,
} from "react";
@@ -335,6 +336,8 @@ export default function OfferDetail() {
} | null>(null);
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
useModalLock(showOrderModal);
@@ -467,6 +470,34 @@ export default function OfferDetail() {
if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]);
useEffect(() => {
if (loading) {
hasSetInitialSnapshot.current = false;
return;
}
if (!hasSetInitialSnapshot.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
hasSetInitialSnapshot.current = true;
}
}, [loading, form, items, sections]);
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
return (
JSON.stringify({ form, items, sections }) !== initialSnapshotRef.current
);
}, [form, items, sections]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
useEffect(() => {
const loadCustomers = async () => {
try {
@@ -678,6 +709,14 @@ export default function OfferDetail() {
if (!isEdit && result.data?.id) {
navigate(`/offers/${result.data.id}`);
}
if (isEdit) {
initialSnapshotRef.current = JSON.stringify({
form,
items,
sections,
});
hasSetInitialSnapshot.current = true;
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");
}

View File

@@ -119,6 +119,8 @@ export default function OrderDetail() {
const [deleteFiles, setDeleteFiles] = useState(false);
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
const fetchDetail = useCallback(async () => {
try {
@@ -144,6 +146,32 @@ export default function OrderDetail() {
fetchDetail();
}, [fetchDetail]);
useEffect(() => {
if (loading) {
hasSetInitialSnapshot.current = false;
return;
}
if (!hasSetInitialSnapshot.current) {
initialNotesRef.current = notes;
hasSetInitialSnapshot.current = true;
}
}, [loading, notes]);
const isDirty = useMemo(() => {
if (!initialNotesRef.current) return false;
return notes !== initialNotesRef.current;
}, [notes]);
useEffect(() => {
if (!isDirty) return;
const handler = (e: BeforeUnloadEvent) => {
e.preventDefault();
e.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
const totals = useMemo(() => {
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
const subtotal = order.items.reduce((sum, item) => {
@@ -197,6 +225,7 @@ export default function OrderDetail() {
const result = await response.json();
if (result.success) {
alert.success("Poznámky byly uloženy");
initialNotesRef.current = notes;
} else {
alert.error(result.error || "Nepodařilo se uložit poznámky");
}