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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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á"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user