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