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

@@ -630,6 +630,7 @@ model invoice_alert_log {
invoice_id Int
alert_type String @db.VarChar(20) // "3days" or "due"
sent_at DateTime @default(now()) @db.DateTime(0)
created_at DateTime @default(now()) @db.DateTime(0)
@@unique([invoice_type, invoice_id, alert_type])
}

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

View File

@@ -5,6 +5,8 @@ dotenv.config();
process.env.TZ = process.env.TZ || "Europe/Prague";
// Override Date.toJSON so JSON.stringify outputs local time (Europe/Prague).
// This is intentional and required for PHP migration compatibility: the legacy
// PHP API returned local Czech times, and the frontend relies on this format.
// Prisma stores UTC in MySQL DATETIME columns. When reading, it creates
// JS Date objects with correct UTC internals. The default toJSON() calls
// toISOString() which returns UTC — this override uses local getters instead,
@@ -50,6 +52,13 @@ export const config = {
totp: {
encryptionKey: required("TOTP_ENCRYPTION_KEY"),
algorithm: (process.env.TOTP_ALGORITHM || "SHA1") as "SHA1",
digits: parseInt(process.env.TOTP_DIGITS || "6", 10),
period: parseInt(process.env.TOTP_PERIOD || "30", 10),
loginTokenExpiryMinutes: parseInt(
process.env.LOGIN_TOKEN_EXPIRY_MINUTES || "5",
10,
),
},
nas: {

View File

@@ -77,8 +77,12 @@ export default async function companySettingsRoutes(
else if (
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x45
buf[9] === 0x45 &&
buf[10] === 0x42 &&
buf[11] === 0x50
)
mime = "image/webp";

View File

@@ -200,23 +200,25 @@ export default async function dashboardRoutes(
(revenueByCurrency[currency] || 0) + amount;
}
const revenueConversions = await Promise.all(
Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
czk: await toCzk(Math.round(amount * 100) / 100, currency),
})),
);
result.invoices = {
revenue_this_month: Object.entries(revenueByCurrency).map(
([currency, amount]) => ({
amount: Math.round(amount * 100) / 100,
currency,
}),
),
revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
amount,
currency,
})),
unpaid_count: unpaidCount,
revenue_czk: await (async () => {
let total = 0;
for (const [cur, amount] of Object.entries(revenueByCurrency)) {
total += await toCzk(Math.round(amount * 100) / 100, cur);
}
return Math.round(total * 100) / 100;
})(),
revenue_czk:
Math.round(
revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
) / 100,
};
result.unpaid_invoices = unpaidCount;
}
// Orders — only for orders.view
@@ -232,7 +234,6 @@ export default async function dashboardRoutes(
where: { status: "pending" },
});
result.leave_pending = { count };
result.pending_leave_requests = count;
}
// Recent activity — only for settings.audit (admin)

View File

@@ -267,7 +267,7 @@ export default async function invoicesPdfRoutes(
): Promise<void> {
fastify.get<{ Params: { id: string } }>(
"/:id",
{ preHandler: requirePermission("invoices.export") },
{ preHandler: requirePermission("invoices.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;

View File

@@ -383,7 +383,9 @@ export default async function ordersPdfRoutes(
})
.join("");
const paymentMethod = lang === "cs" ? "převodem" : "Bank transfer";
const paymentMethod =
String((order as Record<string, unknown>).payment_method || "") ||
(lang === "cs" ? "převodem" : "Bank transfer");
let vatDetailHtml = "";
if (applyVat) {

View File

@@ -1,9 +1,10 @@
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { config } from "../../config/env";
import {
CreateOrderFromQuotationSchema,
CreateOrderSchema,
@@ -25,7 +26,9 @@ import multipart from "@fastify/multipart";
export default async function ordersRoutes(
fastify: FastifyInstance,
): Promise<void> {
await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await fastify.register(multipart, {
limits: { fileSize: config.nas.maxUploadSize },
});
// GET /api/admin/orders/next-number
fastify.get(
@@ -54,15 +57,11 @@ export default async function ordersRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(
result.total,
result.page,
result.limit,
),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, result.page, result.limit),
);
},
);

View File

@@ -129,6 +129,9 @@ export default async function projectsRoutes(
lastName: authData.lastName,
content: parsed.data.content ?? undefined,
});
if (note && "error" in note) {
return error(reply, note.error, (note as any).status ?? 400);
}
return success(reply, { note }, 201, "Poznámka byla přidána");
},

View File

@@ -1,7 +1,7 @@
import { FastifyInstance } from "fastify";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import {
@@ -44,11 +44,11 @@ export default async function quotationsRoutes(
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
});
return reply.send({
success: true,
data: result.data,
pagination: buildPaginationMeta(result.total, page, limit),
});
return paginated(
reply,
result.data,
buildPaginationMeta(result.total, page, limit),
);
},
);

View File

@@ -1,12 +1,8 @@
import { FastifyInstance } from "fastify";
import crypto from "crypto";
import prisma from "../../config/database";
import { requireAuth } from "../../middleware/auth";
import { success, error } from "../../utils/response";
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
import { hashToken } from "../../services/auth";
/** Parse user-agent string into browser, OS, and device icon */
function parseUserAgent(ua: string | null): {

View File

@@ -175,7 +175,7 @@ export default async function tripsRoutes(
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
fastify.get<{ Params: { vehicleId: string } }>(
"/last-km/:vehicleId",
{ preHandler: requireAuth },
{ preHandler: requirePermission("trips.view") },
async (request, reply) => {
const vehicleId = parseInt(request.params.vehicleId, 10);
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);

View File

@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requirePermission } from "../../middleware/auth";
import { logAudit } from "../../services/audit";
import { success, error, parseId } from "../../utils/response";
import { success, error, parseId, paginated } from "../../utils/response";
import { parsePagination, buildPaginationMeta } from "../../utils/pagination";
import { parseBody } from "../../schemas/common";
import { CreateUserSchema, UpdateUserSchema } from "../../schemas/users.schema";
@@ -25,15 +25,11 @@ export default async function usersRoutes(
const params = parsePagination(request.query as Record<string, unknown>);
const result = await listUsers(params);
return reply.send({
success: true,
data: result.users,
pagination: buildPaginationMeta(
result.total,
result.page,
result.limit,
),
});
return paginated(
reply,
result.users,
buildPaginationMeta(result.total, result.page, result.limit),
);
},
);

View File

@@ -22,7 +22,7 @@ export const AttendanceBalancesSchema = z.object({
.union([z.number(), z.string()])
.transform((v) => Number(v))
.optional(),
action_type: z.string(),
action_type: z.enum(["edit", "reset"]),
vacation_total: z
.union([z.number(), z.string()])
.transform((v) => Number(v))

View File

@@ -577,6 +577,7 @@ export async function getWorkfund(year: number) {
let worked = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
for (const rec of recs) {
const lt = (rec.leave_type as string) || "work";
@@ -593,12 +594,14 @@ export async function getWorkfund(year: number) {
vacationHours += Number(rec.leave_hours) || 8;
} else if (lt === "sick") {
sickHours += Number(rec.leave_hours) || 8;
} else if (lt === "holiday") {
holidayHours += Number(rec.leave_hours) || 8;
}
}
const userFund = fundToDate;
const workedRound = Math.round(worked * 10) / 10;
const leaveHours = vacationHours + sickHours;
const leaveHours = vacationHours + sickHours + holidayHours;
const covered = Math.round((worked + leaveHours) * 10) / 10;
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
@@ -1153,7 +1156,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
continue;
}
const shiftDate = new Date(Date.UTC(yr, mo - 1, day, 12, 0, 0));
const shiftDate = new Date(yr, mo - 1, day, 12, 0, 0);
if (isHoliday(dateStr)) {
await prisma.attendance.create({
@@ -1215,14 +1218,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
if (dow !== 0 && dow !== 6) {
const dateStr = localDateStr(current);
const shiftDate = new Date(
Date.UTC(
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
),
current.getFullYear(),
current.getMonth(),
current.getDate(),
12,
0,
0,
);
const duplicate = await prisma.attendance.findFirst({
where: { user_id: userId, shift_date: shiftDate },
@@ -1287,7 +1288,7 @@ export async function punchAction(userId: number, data: PunchData) {
const y = now.getFullYear(),
m = now.getMonth(),
d = now.getDate();
const today = new Date(Date.UTC(y, m, d, 12, 0, 0));
const today = new Date(y, m, d, 12, 0, 0);
const gpsLat =
data.latitude != null && data.latitude !== ""

View File

@@ -54,6 +54,16 @@ export async function logAudit(params: {
},
});
} catch (err) {
console.error("Failed to write audit log:", err);
console.error(
"Failed to write audit log:",
{
action: params.action,
entityType: params.entityType,
entityId: params.entityId,
description: params.description,
userId: params.authData?.userId,
},
err,
);
}
}

View File

@@ -185,7 +185,9 @@ export async function login(
data: {
user_id: user.id,
token_hash: tokenHash,
expires_at: new Date(Date.now() + 5 * 60_000), // 5 minutes
expires_at: new Date(
Date.now() + config.totp.loginTokenExpiryMinutes * 60_000,
),
},
});
@@ -255,16 +257,18 @@ export async function refreshAccessToken(
user_id: number;
expires_at: Date;
replaced_at: Date | null;
replaced_by_hash: string | null;
remember_me: boolean | null;
}>
>`
SELECT id, user_id, expires_at, replaced_at, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
SELECT id, user_id, expires_at, replaced_at, replaced_by_hash, remember_me FROM refresh_tokens WHERE token_hash = ${tokenHash} FOR UPDATE
`;
const storedToken = tokens[0] ?? null;
if (
!storedToken ||
storedToken.replaced_at ||
storedToken.replaced_by_hash ||
new Date(storedToken.expires_at) < new Date()
) {
return { type: "error", message: "Neplatný refresh token", status: 401 };
@@ -335,7 +339,8 @@ export async function verifyAccessToken(
config.jwt.secret,
) as unknown as JwtPayload;
return loadAuthData(payload.sub);
} catch {
} catch (err) {
console.error("JWT verification error:", err);
return null;
}
}

View File

@@ -11,34 +11,43 @@ interface CnbRate {
}
const rateCache = new Map<string, Record<string, number>>();
const inflight = new Map<string, Promise<Record<string, number>>>();
async function fetchRatesForDate(
date?: string,
): Promise<Record<string, number>> {
const key = date || "today";
if (rateCache.has(key)) return rateCache.get(key)!;
if (inflight.has(key)) return inflight.get(key)!;
try {
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
if (date) url += `&date=${date}`;
const promise = (async () => {
try {
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
if (date) url += `&date=${date}`;
const response = await fetch(url);
if (!response.ok) throw new Error(`CNB API: ${response.status}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`CNB API: ${response.status}`);
const data = (await response.json()) as { rates: CnbRate[] };
const rates: Record<string, number> = { CZK: 1 };
const data = (await response.json()) as { rates: CnbRate[] };
const rates: Record<string, number> = { CZK: 1 };
for (const r of data.rates) {
rates[r.currencyCode] = r.rate / r.amount;
for (const r of data.rates) {
rates[r.currencyCode] = r.rate / r.amount;
}
rateCache.set(key, rates);
return rates;
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache.has("today")) return rateCache.get("today")!;
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
} finally {
inflight.delete(key);
}
})();
rateCache.set(key, rates);
return rates;
} catch (err) {
console.error("Failed to fetch CNB exchange rates:", err);
if (rateCache.has("today")) return rateCache.get("today")!;
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
}
inflight.set(key, promise);
return promise;
}
/** Convert an amount from a given currency to CZK using CNB rates */

View File

@@ -260,9 +260,6 @@ export async function getInvoiceStats(queryMonth?: number, queryYear?: number) {
amount: Math.round(amount * 100) / 100,
currency,
}));
let vatCzk = 0;
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
// VAT also needs conversion
let vatCzkConverted = 0;
for (const [cur, amount] of Object.entries(vatMap)) {

View File

@@ -220,6 +220,14 @@ export async function createProjectNote(
content?: string;
},
) {
const project = await prisma.projects.findUnique({
where: { id: projectId },
select: { id: true },
});
if (!project) {
return { error: "Projekt nenalezen" as const, status: 404 };
}
const note = await prisma.project_notes.create({
data: {
project_id: projectId,

View File

@@ -1,5 +1,6 @@
import * as OTPAuthLib from "otpauth";
import { decrypt } from "./encryption";
import { config } from "../config/env";
export const OTPAuth = {
verify(
@@ -10,15 +11,15 @@ export const OTPAuth = {
const secret = decrypt(encryptedSecret);
const totp = new OTPAuthLib.TOTP({
secret: OTPAuthLib.Secret.fromBase32(secret),
algorithm: "SHA1",
digits: 6,
period: 30,
algorithm: config.totp.algorithm,
digits: config.totp.digits,
period: config.totp.period,
});
const delta = totp.validate({ token: code, window: 1 });
if (delta === null) {
return { valid: false, counter: null };
}
const currentCounter = Math.floor(Date.now() / 1000 / 30);
const currentCounter = Math.floor(Date.now() / 1000 / config.totp.period);
return { valid: true, counter: currentCounter + delta };
} catch (err) {
console.error("TOTP verification error:", err);