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
|
invoice_id Int
|
||||||
alert_type String @db.VarChar(20) // "3days" or "due"
|
alert_type String @db.VarChar(20) // "3days" or "due"
|
||||||
sent_at DateTime @default(now()) @db.DateTime(0)
|
sent_at DateTime @default(now()) @db.DateTime(0)
|
||||||
|
created_at DateTime @default(now()) @db.DateTime(0)
|
||||||
|
|
||||||
@@unique([invoice_type, invoice_id, alert_type])
|
@@unique([invoice_type, invoice_id, alert_type])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,17 +165,22 @@ export default function AdminDatePicker({
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
const commonProps = {
|
const customInput = useMemo(
|
||||||
selected: toDate(value),
|
() => (
|
||||||
onChange: handleChange,
|
|
||||||
locale: "cs",
|
|
||||||
customInput: (
|
|
||||||
<CustomInput
|
<CustomInput
|
||||||
required={required}
|
required={required}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
[required, placeholder, disabled],
|
||||||
|
);
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
selected: toDate(value),
|
||||||
|
onChange: handleChange,
|
||||||
|
locale: "cs",
|
||||||
|
customInput,
|
||||||
minDate: parseMinMax(minDate),
|
minDate: parseMinMax(minDate),
|
||||||
maxDate: parseMinMax(maxDate),
|
maxDate: parseMinMax(maxDate),
|
||||||
popperPlacement: "bottom-start" as const,
|
popperPlacement: "bottom-start" as const,
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={log.id || i}
|
key={log.id ?? i}
|
||||||
className="admin-badge"
|
className="admin-badge"
|
||||||
style={{
|
style={{
|
||||||
fontSize: "0.7rem",
|
fontSize: "0.7rem",
|
||||||
@@ -95,7 +95,10 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
background: isActive ? "var(--accent-light)" : undefined,
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -257,4 +260,3 @@ export default function AttendanceShiftTable({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface BulkAttendanceUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface BulkAttendanceModalProps {
|
interface BulkAttendanceModalProps {
|
||||||
show: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
form: BulkAttendanceForm;
|
form: BulkAttendanceForm;
|
||||||
setForm: (form: BulkAttendanceForm) => void;
|
setForm: (form: BulkAttendanceForm) => void;
|
||||||
@@ -29,7 +29,7 @@ interface BulkAttendanceModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BulkAttendanceModal({
|
export default function BulkAttendanceModal({
|
||||||
show,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
form,
|
form,
|
||||||
setForm,
|
setForm,
|
||||||
@@ -39,11 +39,11 @@ export default function BulkAttendanceModal({
|
|||||||
toggleUser,
|
toggleUser,
|
||||||
toggleAllUsers,
|
toggleAllUsers,
|
||||||
}: BulkAttendanceModalProps) {
|
}: BulkAttendanceModalProps) {
|
||||||
useModalLock(show);
|
useModalLock(isOpen);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{isOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal-overlay"
|
className="admin-modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|||||||
@@ -14,6 +14,71 @@ interface ConfirmModalProps {
|
|||||||
loading?: boolean;
|
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({
|
export default function ConfirmModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -49,18 +114,7 @@ export default function ConfirmModal({
|
|||||||
>
|
>
|
||||||
<div className="admin-modal-body admin-confirm-content">
|
<div className="admin-modal-body admin-confirm-content">
|
||||||
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
<div className={`admin-confirm-icon admin-confirm-icon-${type}`}>
|
||||||
<svg
|
<ConfirmIcon type={type} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
<h2 id="confirm-modal-title" className="admin-confirm-title">
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ interface ProjectLogRowProps {
|
|||||||
|
|
||||||
export interface ShiftFormModalProps {
|
export interface ShiftFormModalProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
show: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
form: ShiftFormData;
|
form: ShiftFormData;
|
||||||
@@ -196,7 +196,7 @@ function ProjectLogRow({
|
|||||||
|
|
||||||
export default function ShiftFormModal({
|
export default function ShiftFormModal({
|
||||||
mode,
|
mode,
|
||||||
show,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
form,
|
form,
|
||||||
@@ -208,7 +208,7 @@ export default function ShiftFormModal({
|
|||||||
onShiftDateChange,
|
onShiftDateChange,
|
||||||
editingRecord,
|
editingRecord,
|
||||||
}: ShiftFormModalProps) {
|
}: ShiftFormModalProps) {
|
||||||
useModalLock(show);
|
useModalLock(isOpen);
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
const isWorkType = form.leave_type === "work";
|
const isWorkType = form.leave_type === "work";
|
||||||
|
|
||||||
@@ -240,7 +240,7 @@ export default function ShiftFormModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{show && (
|
{isOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal-overlay"
|
className="admin-modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
|
|||||||
@@ -1060,7 +1060,9 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
|
|||||||
printWindow.document.open();
|
printWindow.document.open();
|
||||||
printWindow.document.write(bodyContent);
|
printWindow.document.write(bodyContent);
|
||||||
printWindow.document.close();
|
printWindow.document.close();
|
||||||
printWindow.onload = () => printWindow.print();
|
printWindow.addEventListener("load", () => printWindow.print(), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function Attendance() {
|
|||||||
project_logs: [],
|
project_logs: [],
|
||||||
active_project_id: null,
|
active_project_id: null,
|
||||||
});
|
});
|
||||||
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
|
||||||
const [leaveForm, setLeaveForm] = useState({
|
const [leaveForm, setLeaveForm] = useState({
|
||||||
leave_type: "vacation",
|
leave_type: "vacation",
|
||||||
date_from: new Date().toISOString().split("T")[0],
|
date_from: new Date().toISOString().split("T")[0],
|
||||||
@@ -122,10 +122,11 @@ export default function Attendance() {
|
|||||||
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
|
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
|
||||||
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
|
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
|
||||||
const [gpsConfirm, setGpsConfirm] = useState<{
|
const [gpsConfirm, setGpsConfirm] = useState<{
|
||||||
show: boolean;
|
isOpen: boolean;
|
||||||
action: string | null;
|
action: string | null;
|
||||||
}>({ show: false, action: null });
|
}>({ isOpen: false, action: null });
|
||||||
const geoAbortRef = useRef<AbortController | null>(null);
|
const geoAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const mountedRef = useRef(true);
|
const mountedRef = useRef(true);
|
||||||
const latestActionRef = useRef<string | null>(null);
|
const latestActionRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -133,6 +134,7 @@ export default function Attendance() {
|
|||||||
return () => {
|
return () => {
|
||||||
mountedRef.current = false;
|
mountedRef.current = false;
|
||||||
if (geoAbortRef.current) geoAbortRef.current.abort();
|
if (geoAbortRef.current) geoAbortRef.current.abort();
|
||||||
|
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -176,7 +178,7 @@ export default function Attendance() {
|
|||||||
loadProjects();
|
loadProjects();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useModalLock(showLeaveModal);
|
useModalLock(isLeaveModalOpen);
|
||||||
|
|
||||||
if (!hasPermission("attendance.record")) return <Forbidden />;
|
if (!hasPermission("attendance.record")) return <Forbidden />;
|
||||||
|
|
||||||
@@ -234,7 +236,7 @@ export default function Attendance() {
|
|||||||
errorMsg = "Vypršel časový limit";
|
errorMsg = "Vypršel časový limit";
|
||||||
}
|
}
|
||||||
alert.error(errorMsg);
|
alert.error(errorMsg);
|
||||||
setGpsConfirm({ show: true, action });
|
setGpsConfirm({ isOpen: true, action });
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
||||||
);
|
);
|
||||||
@@ -257,7 +259,7 @@ export default function Attendance() {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
await fetchData();
|
await fetchData();
|
||||||
setTimeout(() => {
|
punchTimeoutRef.current = setTimeout(() => {
|
||||||
alert.success(result.data?.message || result.message || "Uloženo");
|
alert.success(result.data?.message || result.message || "Uloženo");
|
||||||
}, 300);
|
}, 300);
|
||||||
} else {
|
} else {
|
||||||
@@ -368,7 +370,7 @@ export default function Attendance() {
|
|||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setShowLeaveModal(false);
|
setIsLeaveModalOpen(false);
|
||||||
await fetchData();
|
await fetchData();
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
alert.success(
|
alert.success(
|
||||||
@@ -669,7 +671,7 @@ export default function Attendance() {
|
|||||||
{submitting ? "Zpracovávám..." : "Odchod"}
|
{submitting ? "Zpracovávám..." : "Odchod"}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLeaveModal(true)}
|
onClick={() => setIsLeaveModalOpen(true)}
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
className="admin-btn admin-btn-secondary w-full"
|
||||||
>
|
>
|
||||||
Žádost o nepřítomnost
|
Žádost o nepřítomnost
|
||||||
@@ -708,7 +710,7 @@ export default function Attendance() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowLeaveModal(true)}
|
onClick={() => setIsLeaveModalOpen(true)}
|
||||||
className="admin-btn admin-btn-secondary w-full"
|
className="admin-btn admin-btn-secondary w-full"
|
||||||
>
|
>
|
||||||
Žádost o nepřítomnost
|
Žádost o nepřítomnost
|
||||||
@@ -1056,7 +1058,7 @@ export default function Attendance() {
|
|||||||
|
|
||||||
{/* Leave Modal */}
|
{/* Leave Modal */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showLeaveModal && (
|
{isLeaveModalOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal-overlay"
|
className="admin-modal-overlay"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -1066,7 +1068,7 @@ export default function Attendance() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="admin-modal-backdrop"
|
className="admin-modal-backdrop"
|
||||||
onClick={() => setShowLeaveModal(false)}
|
onClick={() => setIsLeaveModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-modal"
|
className="admin-modal"
|
||||||
@@ -1187,7 +1189,7 @@ export default function Attendance() {
|
|||||||
<div className="admin-modal-footer">
|
<div className="admin-modal-footer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowLeaveModal(false)}
|
onClick={() => setIsLeaveModalOpen(false)}
|
||||||
className="admin-btn admin-btn-secondary"
|
className="admin-btn admin-btn-secondary"
|
||||||
disabled={requestSubmitting}
|
disabled={requestSubmitting}
|
||||||
>
|
>
|
||||||
@@ -1214,13 +1216,13 @@ export default function Attendance() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={gpsConfirm.show}
|
isOpen={gpsConfirm.isOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setGpsConfirm({ show: false, action: null });
|
setGpsConfirm({ isOpen: false, action: null });
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}}
|
}}
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setGpsConfirm({ show: false, action: null });
|
setGpsConfirm({ isOpen: false, action: null });
|
||||||
submitPunch(gpsConfirm.action!, {});
|
submitPunch(gpsConfirm.action!, {});
|
||||||
}}
|
}}
|
||||||
title="GPS nedostupná"
|
title="GPS nedostupná"
|
||||||
|
|||||||
@@ -405,7 +405,7 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<BulkAttendanceModal
|
<BulkAttendanceModal
|
||||||
show={showBulkModal}
|
isOpen={showBulkModal}
|
||||||
onClose={() => setShowBulkModal(false)}
|
onClose={() => setShowBulkModal(false)}
|
||||||
form={bulkForm}
|
form={bulkForm}
|
||||||
setForm={setBulkForm}
|
setForm={setBulkForm}
|
||||||
@@ -418,7 +418,7 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
<ShiftFormModal
|
<ShiftFormModal
|
||||||
mode="create"
|
mode="create"
|
||||||
show={showCreateModal}
|
isOpen={showCreateModal}
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
onSubmit={handleCreateSubmit}
|
onSubmit={handleCreateSubmit}
|
||||||
form={createForm}
|
form={createForm}
|
||||||
@@ -433,7 +433,7 @@ export default function AttendanceAdmin() {
|
|||||||
|
|
||||||
<ShiftFormModal
|
<ShiftFormModal
|
||||||
mode="edit"
|
mode="edit"
|
||||||
show={showEditModal && !!editingRecord}
|
isOpen={showEditModal && !!editingRecord}
|
||||||
onClose={() => setShowEditModal(false)}
|
onClose={() => setShowEditModal(false)}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
form={editForm}
|
form={editForm}
|
||||||
|
|||||||
@@ -39,9 +39,9 @@ export default function AttendanceCreate() {
|
|||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreateForm>(() => {
|
||||||
const today = new Date().toISOString().split("T")[0];
|
const today = new Date().toISOString().split("T")[0];
|
||||||
|
return {
|
||||||
const [form, setForm] = useState<CreateForm>({
|
|
||||||
user_id: "",
|
user_id: "",
|
||||||
shift_date: today,
|
shift_date: today,
|
||||||
leave_type: "work",
|
leave_type: "work",
|
||||||
@@ -55,6 +55,7 @@ export default function AttendanceCreate() {
|
|||||||
departure_date: today,
|
departure_date: today,
|
||||||
departure_time: "",
|
departure_time: "",
|
||||||
notes: "",
|
notes: "",
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAlert } from "../context/AlertContext";
|
|||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import FormField from "../components/FormField";
|
import FormField from "../components/FormField";
|
||||||
|
import ConfirmModal from "../components/ConfirmModal";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import apiFetch from "../utils/api";
|
import apiFetch from "../utils/api";
|
||||||
@@ -98,6 +99,10 @@ export default function CompanySettings({
|
|||||||
const [bankLoading, setBankLoading] = useState(true);
|
const [bankLoading, setBankLoading] = useState(true);
|
||||||
const [bankSaving, setBankSaving] = useState(false);
|
const [bankSaving, setBankSaving] = useState(false);
|
||||||
const [editingBank, setEditingBank] = useState<number | null>(null);
|
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>({
|
const [bankForm, setBankForm] = useState<BankForm>({
|
||||||
account_name: "",
|
account_name: "",
|
||||||
bank_name: "",
|
bank_name: "",
|
||||||
@@ -300,22 +305,31 @@ export default function CompanySettings({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBankDelete = async (id: number) => {
|
const handleBankDelete = (id: number) => {
|
||||||
if (!confirm("Opravdu smazat tento bankovní účet?")) return;
|
setBankDeleteConfirm({ isOpen: true, id });
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmBankDelete = async () => {
|
||||||
|
if (bankDeleteConfirm.id == null) return;
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch(`${API_BASE}/bank-accounts/${id}`, {
|
const response = await apiFetch(
|
||||||
|
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
|
||||||
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
},
|
||||||
|
);
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success(result.message);
|
alert.success(result.message);
|
||||||
if (editingBank === id) resetBankForm();
|
if (editingBank === bankDeleteConfirm.id) resetBankForm();
|
||||||
fetchBankAccounts();
|
fetchBankAccounts();
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Chyba při mazání");
|
alert.error(result.error || "Chyba při mazání");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
alert.error("Chyba připojení");
|
alert.error("Chyba připojení");
|
||||||
|
} finally {
|
||||||
|
setBankDeleteConfirm({ isOpen: false, id: null });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1215,6 +1229,17 @@ export default function CompanySettings({
|
|||||||
</button>
|
</button>
|
||||||
</motion.div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export default function Dashboard() {
|
|||||||
}, [fetch2FAStatus]);
|
}, [fetch2FAStatus]);
|
||||||
|
|
||||||
// Punch (prichod/odchod) primo z dashboardu
|
// Punch (prichod/odchod) primo z dashboardu
|
||||||
const handleQuickPunch = () => {
|
const handleQuickPunch = useCallback(() => {
|
||||||
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
|
||||||
setPunching(true);
|
setPunching(true);
|
||||||
|
|
||||||
@@ -167,7 +167,7 @@ export default function Dashboard() {
|
|||||||
() => submitPunch({}),
|
() => submitPunch({}),
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
||||||
);
|
);
|
||||||
};
|
}, [dashData, alert, fetchDashboard]);
|
||||||
|
|
||||||
// 2FA handlery
|
// 2FA handlery
|
||||||
const handleStart2FASetup = async () => {
|
const handleStart2FASetup = async () => {
|
||||||
|
|||||||
@@ -383,6 +383,8 @@ export default function InvoiceDetail() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [invoiceNumber, setInvoiceNumber] = useState("");
|
const [invoiceNumber, setInvoiceNumber] = useState("");
|
||||||
|
const initialSnapshotRef = useRef<string | null>(null);
|
||||||
|
const hasSetInitialSnapshot = useRef(false);
|
||||||
|
|
||||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
const [customerSearch, setCustomerSearch] = useState("");
|
const [customerSearch, setCustomerSearch] = useState("");
|
||||||
@@ -664,6 +666,32 @@ export default function InvoiceDetail() {
|
|||||||
if (isEdit) fetchDetail();
|
if (isEdit) fetchDetail();
|
||||||
}, [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 ───
|
// ─── Due date calculation from issue date + days ───
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!form.issue_date) return;
|
if (!form.issue_date) return;
|
||||||
@@ -826,6 +854,8 @@ export default function InvoiceDetail() {
|
|||||||
result.message ||
|
result.message ||
|
||||||
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
|
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
|
||||||
);
|
);
|
||||||
|
initialSnapshotRef.current = JSON.stringify({ form, items });
|
||||||
|
hasSetInitialSnapshot.current = true;
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
fetchDetail();
|
fetchDetail();
|
||||||
} else {
|
} 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 { Navigate } from "react-router-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -57,7 +57,8 @@ export default function Login() {
|
|||||||
return <Navigate to="/" replace />;
|
return <Navigate to="/" replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
@@ -78,9 +79,24 @@ export default function Login() {
|
|||||||
setAnimatingOut(true);
|
setAnimatingOut(true);
|
||||||
setTimeout(() => setAnimatingOut(false), 400);
|
setTimeout(() => setAnimatingOut(false), 400);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
remember,
|
||||||
|
login,
|
||||||
|
alert,
|
||||||
|
setLoading,
|
||||||
|
setShake,
|
||||||
|
setAnimatingOut,
|
||||||
|
setLoginToken,
|
||||||
|
setShow2FA,
|
||||||
|
setTotpCode,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handle2FASubmit = async (e: React.FormEvent) => {
|
const handle2FASubmit = useCallback(
|
||||||
|
async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!totpCode.trim()) return;
|
if (!totpCode.trim()) return;
|
||||||
|
|
||||||
@@ -105,14 +121,27 @@ export default function Login() {
|
|||||||
setAnimatingOut(true);
|
setAnimatingOut(true);
|
||||||
setTimeout(() => setAnimatingOut(false), 400);
|
setTimeout(() => setAnimatingOut(false), 400);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[
|
||||||
|
totpCode,
|
||||||
|
loginToken,
|
||||||
|
remember,
|
||||||
|
useBackupCode,
|
||||||
|
verify2FA,
|
||||||
|
alert,
|
||||||
|
setLoading,
|
||||||
|
setShake,
|
||||||
|
setTotpCode,
|
||||||
|
setAnimatingOut,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = useCallback(() => {
|
||||||
setShow2FA(false);
|
setShow2FA(false);
|
||||||
setLoginToken(null);
|
setLoginToken(null);
|
||||||
setTotpCode("");
|
setTotpCode("");
|
||||||
setUseBackupCode(false);
|
setUseBackupCode(false);
|
||||||
};
|
}, [setShow2FA, setLoginToken, setTotpCode, setUseBackupCode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
useEffect,
|
useEffect,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
@@ -335,6 +336,8 @@ export default function OfferDetail() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const unlockAbortRef = useRef<AbortController | null>(null);
|
const unlockAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const initialSnapshotRef = useRef<string | null>(null);
|
||||||
|
const hasSetInitialSnapshot = useRef(false);
|
||||||
|
|
||||||
useModalLock(showOrderModal);
|
useModalLock(showOrderModal);
|
||||||
|
|
||||||
@@ -467,6 +470,34 @@ export default function OfferDetail() {
|
|||||||
if (isEdit) fetchDetail();
|
if (isEdit) fetchDetail();
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const loadCustomers = async () => {
|
const loadCustomers = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -678,6 +709,14 @@ export default function OfferDetail() {
|
|||||||
if (!isEdit && result.data?.id) {
|
if (!isEdit && result.data?.id) {
|
||||||
navigate(`/offers/${result.data.id}`);
|
navigate(`/offers/${result.data.id}`);
|
||||||
}
|
}
|
||||||
|
if (isEdit) {
|
||||||
|
initialSnapshotRef.current = JSON.stringify({
|
||||||
|
form,
|
||||||
|
items,
|
||||||
|
sections,
|
||||||
|
});
|
||||||
|
hasSetInitialSnapshot.current = true;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit nabídku");
|
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 [deleteFiles, setDeleteFiles] = useState(false);
|
||||||
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
|
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
|
||||||
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
const [confirmationLoading, setConfirmationLoading] = useState(false);
|
||||||
|
const initialNotesRef = useRef<string | null>(null);
|
||||||
|
const hasSetInitialSnapshot = useRef(false);
|
||||||
|
|
||||||
const fetchDetail = useCallback(async () => {
|
const fetchDetail = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -144,6 +146,32 @@ export default function OrderDetail() {
|
|||||||
fetchDetail();
|
fetchDetail();
|
||||||
}, [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(() => {
|
const totals = useMemo(() => {
|
||||||
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
|
if (!order?.items) return { subtotal: 0, vatAmount: 0, total: 0 };
|
||||||
const subtotal = order.items.reduce((sum, item) => {
|
const subtotal = order.items.reduce((sum, item) => {
|
||||||
@@ -197,6 +225,7 @@ export default function OrderDetail() {
|
|||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
alert.success("Poznámky byly uloženy");
|
alert.success("Poznámky byly uloženy");
|
||||||
|
initialNotesRef.current = notes;
|
||||||
} else {
|
} else {
|
||||||
alert.error(result.error || "Nepodařilo se uložit poznámky");
|
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";
|
process.env.TZ = process.env.TZ || "Europe/Prague";
|
||||||
|
|
||||||
// Override Date.toJSON so JSON.stringify outputs local time (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
|
// Prisma stores UTC in MySQL DATETIME columns. When reading, it creates
|
||||||
// JS Date objects with correct UTC internals. The default toJSON() calls
|
// JS Date objects with correct UTC internals. The default toJSON() calls
|
||||||
// toISOString() which returns UTC — this override uses local getters instead,
|
// toISOString() which returns UTC — this override uses local getters instead,
|
||||||
@@ -50,6 +52,13 @@ export const config = {
|
|||||||
|
|
||||||
totp: {
|
totp: {
|
||||||
encryptionKey: required("TOTP_ENCRYPTION_KEY"),
|
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: {
|
nas: {
|
||||||
|
|||||||
@@ -77,8 +77,12 @@ export default async function companySettingsRoutes(
|
|||||||
else if (
|
else if (
|
||||||
buf[0] === 0x52 &&
|
buf[0] === 0x52 &&
|
||||||
buf[1] === 0x49 &&
|
buf[1] === 0x49 &&
|
||||||
|
buf[2] === 0x46 &&
|
||||||
|
buf[3] === 0x46 &&
|
||||||
buf[8] === 0x57 &&
|
buf[8] === 0x57 &&
|
||||||
buf[9] === 0x45
|
buf[9] === 0x45 &&
|
||||||
|
buf[10] === 0x42 &&
|
||||||
|
buf[11] === 0x50
|
||||||
)
|
)
|
||||||
mime = "image/webp";
|
mime = "image/webp";
|
||||||
|
|
||||||
|
|||||||
@@ -200,23 +200,25 @@ export default async function dashboardRoutes(
|
|||||||
(revenueByCurrency[currency] || 0) + amount;
|
(revenueByCurrency[currency] || 0) + amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
result.invoices = {
|
const revenueConversions = await Promise.all(
|
||||||
revenue_this_month: Object.entries(revenueByCurrency).map(
|
Object.entries(revenueByCurrency).map(async ([currency, amount]) => ({
|
||||||
([currency, amount]) => ({
|
|
||||||
amount: Math.round(amount * 100) / 100,
|
amount: Math.round(amount * 100) / 100,
|
||||||
currency,
|
currency,
|
||||||
}),
|
czk: await toCzk(Math.round(amount * 100) / 100, currency),
|
||||||
),
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
result.invoices = {
|
||||||
|
revenue_this_month: revenueConversions.map(({ amount, currency }) => ({
|
||||||
|
amount,
|
||||||
|
currency,
|
||||||
|
})),
|
||||||
unpaid_count: unpaidCount,
|
unpaid_count: unpaidCount,
|
||||||
revenue_czk: await (async () => {
|
revenue_czk:
|
||||||
let total = 0;
|
Math.round(
|
||||||
for (const [cur, amount] of Object.entries(revenueByCurrency)) {
|
revenueConversions.reduce((sum, r) => sum + r.czk, 0) * 100,
|
||||||
total += await toCzk(Math.round(amount * 100) / 100, cur);
|
) / 100,
|
||||||
}
|
|
||||||
return Math.round(total * 100) / 100;
|
|
||||||
})(),
|
|
||||||
};
|
};
|
||||||
result.unpaid_invoices = unpaidCount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orders — only for orders.view
|
// Orders — only for orders.view
|
||||||
@@ -232,7 +234,6 @@ export default async function dashboardRoutes(
|
|||||||
where: { status: "pending" },
|
where: { status: "pending" },
|
||||||
});
|
});
|
||||||
result.leave_pending = { count };
|
result.leave_pending = { count };
|
||||||
result.pending_leave_requests = count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recent activity — only for settings.audit (admin)
|
// Recent activity — only for settings.audit (admin)
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ export default async function invoicesPdfRoutes(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
fastify.get<{ Params: { id: string } }>(
|
fastify.get<{ Params: { id: string } }>(
|
||||||
"/:id",
|
"/:id",
|
||||||
{ preHandler: requirePermission("invoices.export") },
|
{ preHandler: requirePermission("invoices.view") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const id = parseId(request.params.id, reply);
|
const id = parseId(request.params.id, reply);
|
||||||
if (id === null) return;
|
if (id === null) return;
|
||||||
|
|||||||
@@ -383,7 +383,9 @@ export default async function ordersPdfRoutes(
|
|||||||
})
|
})
|
||||||
.join("");
|
.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 = "";
|
let vatDetailHtml = "";
|
||||||
if (applyVat) {
|
if (applyVat) {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
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 { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
|
import { config } from "../../config/env";
|
||||||
import {
|
import {
|
||||||
CreateOrderFromQuotationSchema,
|
CreateOrderFromQuotationSchema,
|
||||||
CreateOrderSchema,
|
CreateOrderSchema,
|
||||||
@@ -25,7 +26,9 @@ import multipart from "@fastify/multipart";
|
|||||||
export default async function ordersRoutes(
|
export default async function ordersRoutes(
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
): Promise<void> {
|
): 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
|
// GET /api/admin/orders/next-number
|
||||||
fastify.get(
|
fastify.get(
|
||||||
@@ -54,15 +57,11 @@ export default async function ordersRoutes(
|
|||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.send({
|
return paginated(
|
||||||
success: true,
|
reply,
|
||||||
data: result.data,
|
result.data,
|
||||||
pagination: buildPaginationMeta(
|
buildPaginationMeta(result.total, result.page, result.limit),
|
||||||
result.total,
|
);
|
||||||
result.page,
|
|
||||||
result.limit,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ export default async function projectsRoutes(
|
|||||||
lastName: authData.lastName,
|
lastName: authData.lastName,
|
||||||
content: parsed.data.content ?? undefined,
|
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");
|
return success(reply, { note }, 201, "Poznámka byla přidána");
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
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 { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import {
|
import {
|
||||||
@@ -44,11 +44,11 @@ export default async function quotationsRoutes(
|
|||||||
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
customer_id: query.customer_id ? Number(query.customer_id) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return reply.send({
|
return paginated(
|
||||||
success: true,
|
reply,
|
||||||
data: result.data,
|
result.data,
|
||||||
pagination: buildPaginationMeta(result.total, page, limit),
|
buildPaginationMeta(result.total, page, limit),
|
||||||
});
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
import crypto from "crypto";
|
|
||||||
import prisma from "../../config/database";
|
import prisma from "../../config/database";
|
||||||
import { requireAuth } from "../../middleware/auth";
|
import { requireAuth } from "../../middleware/auth";
|
||||||
import { success, error } from "../../utils/response";
|
import { success, error } from "../../utils/response";
|
||||||
|
import { hashToken } from "../../services/auth";
|
||||||
function hashToken(token: string): string {
|
|
||||||
return crypto.createHash("sha256").update(token).digest("hex");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse user-agent string into browser, OS, and device icon */
|
/** Parse user-agent string into browser, OS, and device icon */
|
||||||
function parseUserAgent(ua: string | null): {
|
function parseUserAgent(ua: string | null): {
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ export default async function tripsRoutes(
|
|||||||
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
|
// Matches PHP: COALESCE(MAX(end_km), vehicle.initial_km, 0)
|
||||||
fastify.get<{ Params: { vehicleId: string } }>(
|
fastify.get<{ Params: { vehicleId: string } }>(
|
||||||
"/last-km/:vehicleId",
|
"/last-km/:vehicleId",
|
||||||
{ preHandler: requireAuth },
|
{ preHandler: requirePermission("trips.view") },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const vehicleId = parseInt(request.params.vehicleId, 10);
|
const vehicleId = parseInt(request.params.vehicleId, 10);
|
||||||
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
|
if (isNaN(vehicleId)) return error(reply, "Neplatné ID vozidla", 400);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { FastifyInstance } from "fastify";
|
|||||||
import prisma from "../../config/database";
|
import prisma from "../../config/database";
|
||||||
import { requirePermission } from "../../middleware/auth";
|
import { requirePermission } from "../../middleware/auth";
|
||||||
import { logAudit } from "../../services/audit";
|
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 { parsePagination, buildPaginationMeta } from "../../utils/pagination";
|
||||||
import { parseBody } from "../../schemas/common";
|
import { parseBody } from "../../schemas/common";
|
||||||
import { CreateUserSchema, UpdateUserSchema } from "../../schemas/users.schema";
|
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 params = parsePagination(request.query as Record<string, unknown>);
|
||||||
const result = await listUsers(params);
|
const result = await listUsers(params);
|
||||||
|
|
||||||
return reply.send({
|
return paginated(
|
||||||
success: true,
|
reply,
|
||||||
data: result.users,
|
result.users,
|
||||||
pagination: buildPaginationMeta(
|
buildPaginationMeta(result.total, result.page, result.limit),
|
||||||
result.total,
|
);
|
||||||
result.page,
|
|
||||||
result.limit,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export const AttendanceBalancesSchema = z.object({
|
|||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
.optional(),
|
.optional(),
|
||||||
action_type: z.string(),
|
action_type: z.enum(["edit", "reset"]),
|
||||||
vacation_total: z
|
vacation_total: z
|
||||||
.union([z.number(), z.string()])
|
.union([z.number(), z.string()])
|
||||||
.transform((v) => Number(v))
|
.transform((v) => Number(v))
|
||||||
|
|||||||
@@ -577,6 +577,7 @@ export async function getWorkfund(year: number) {
|
|||||||
let worked = 0;
|
let worked = 0;
|
||||||
let vacationHours = 0;
|
let vacationHours = 0;
|
||||||
let sickHours = 0;
|
let sickHours = 0;
|
||||||
|
let holidayHours = 0;
|
||||||
|
|
||||||
for (const rec of recs) {
|
for (const rec of recs) {
|
||||||
const lt = (rec.leave_type as string) || "work";
|
const lt = (rec.leave_type as string) || "work";
|
||||||
@@ -593,12 +594,14 @@ export async function getWorkfund(year: number) {
|
|||||||
vacationHours += Number(rec.leave_hours) || 8;
|
vacationHours += Number(rec.leave_hours) || 8;
|
||||||
} else if (lt === "sick") {
|
} else if (lt === "sick") {
|
||||||
sickHours += Number(rec.leave_hours) || 8;
|
sickHours += Number(rec.leave_hours) || 8;
|
||||||
|
} else if (lt === "holiday") {
|
||||||
|
holidayHours += Number(rec.leave_hours) || 8;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userFund = fundToDate;
|
const userFund = fundToDate;
|
||||||
const workedRound = Math.round(worked * 10) / 10;
|
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 covered = Math.round((worked + leaveHours) * 10) / 10;
|
||||||
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
|
const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10);
|
||||||
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
|
const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10);
|
||||||
@@ -1153,7 +1156,7 @@ export async function bulkCreateAttendance(data: BulkAttendanceData) {
|
|||||||
continue;
|
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)) {
|
if (isHoliday(dateStr)) {
|
||||||
await prisma.attendance.create({
|
await prisma.attendance.create({
|
||||||
@@ -1215,14 +1218,12 @@ export async function createLeave(data: LeaveData, authUserId: number) {
|
|||||||
if (dow !== 0 && dow !== 6) {
|
if (dow !== 0 && dow !== 6) {
|
||||||
const dateStr = localDateStr(current);
|
const dateStr = localDateStr(current);
|
||||||
const shiftDate = new Date(
|
const shiftDate = new Date(
|
||||||
Date.UTC(
|
|
||||||
current.getFullYear(),
|
current.getFullYear(),
|
||||||
current.getMonth(),
|
current.getMonth(),
|
||||||
current.getDate(),
|
current.getDate(),
|
||||||
12,
|
12,
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const duplicate = await prisma.attendance.findFirst({
|
const duplicate = await prisma.attendance.findFirst({
|
||||||
where: { user_id: userId, shift_date: shiftDate },
|
where: { user_id: userId, shift_date: shiftDate },
|
||||||
@@ -1287,7 +1288,7 @@ export async function punchAction(userId: number, data: PunchData) {
|
|||||||
const y = now.getFullYear(),
|
const y = now.getFullYear(),
|
||||||
m = now.getMonth(),
|
m = now.getMonth(),
|
||||||
d = now.getDate();
|
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 =
|
const gpsLat =
|
||||||
data.latitude != null && data.latitude !== ""
|
data.latitude != null && data.latitude !== ""
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ export async function logAudit(params: {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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: {
|
data: {
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
token_hash: tokenHash,
|
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;
|
user_id: number;
|
||||||
expires_at: Date;
|
expires_at: Date;
|
||||||
replaced_at: Date | null;
|
replaced_at: Date | null;
|
||||||
|
replaced_by_hash: string | null;
|
||||||
remember_me: boolean | 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;
|
const storedToken = tokens[0] ?? null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!storedToken ||
|
!storedToken ||
|
||||||
storedToken.replaced_at ||
|
storedToken.replaced_at ||
|
||||||
|
storedToken.replaced_by_hash ||
|
||||||
new Date(storedToken.expires_at) < new Date()
|
new Date(storedToken.expires_at) < new Date()
|
||||||
) {
|
) {
|
||||||
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
return { type: "error", message: "Neplatný refresh token", status: 401 };
|
||||||
@@ -335,7 +339,8 @@ export async function verifyAccessToken(
|
|||||||
config.jwt.secret,
|
config.jwt.secret,
|
||||||
) as unknown as JwtPayload;
|
) as unknown as JwtPayload;
|
||||||
return loadAuthData(payload.sub);
|
return loadAuthData(payload.sub);
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("JWT verification error:", err);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,13 +11,16 @@ interface CnbRate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rateCache = new Map<string, Record<string, number>>();
|
const rateCache = new Map<string, Record<string, number>>();
|
||||||
|
const inflight = new Map<string, Promise<Record<string, number>>>();
|
||||||
|
|
||||||
async function fetchRatesForDate(
|
async function fetchRatesForDate(
|
||||||
date?: string,
|
date?: string,
|
||||||
): Promise<Record<string, number>> {
|
): Promise<Record<string, number>> {
|
||||||
const key = date || "today";
|
const key = date || "today";
|
||||||
if (rateCache.has(key)) return rateCache.get(key)!;
|
if (rateCache.has(key)) return rateCache.get(key)!;
|
||||||
|
if (inflight.has(key)) return inflight.get(key)!;
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
|
let url = "https://api.cnb.cz/cnbapi/exrates/daily?lang=EN";
|
||||||
if (date) url += `&date=${date}`;
|
if (date) url += `&date=${date}`;
|
||||||
@@ -38,7 +41,13 @@ async function fetchRatesForDate(
|
|||||||
console.error("Failed to fetch CNB exchange rates:", err);
|
console.error("Failed to fetch CNB exchange rates:", err);
|
||||||
if (rateCache.has("today")) return rateCache.get("today")!;
|
if (rateCache.has("today")) return rateCache.get("today")!;
|
||||||
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
|
throw new Error("Nepodařilo se získat aktuální kurzy z ČNB");
|
||||||
|
} finally {
|
||||||
|
inflight.delete(key);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
inflight.set(key, promise);
|
||||||
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Convert an amount from a given currency to CZK using CNB rates */
|
/** 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,
|
amount: Math.round(amount * 100) / 100,
|
||||||
currency,
|
currency,
|
||||||
}));
|
}));
|
||||||
let vatCzk = 0;
|
|
||||||
for (const [, v] of Object.entries(vatMap)) vatCzk += v;
|
|
||||||
|
|
||||||
// VAT also needs conversion
|
// VAT also needs conversion
|
||||||
let vatCzkConverted = 0;
|
let vatCzkConverted = 0;
|
||||||
for (const [cur, amount] of Object.entries(vatMap)) {
|
for (const [cur, amount] of Object.entries(vatMap)) {
|
||||||
|
|||||||
@@ -220,6 +220,14 @@ export async function createProjectNote(
|
|||||||
content?: string;
|
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({
|
const note = await prisma.project_notes.create({
|
||||||
data: {
|
data: {
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as OTPAuthLib from "otpauth";
|
import * as OTPAuthLib from "otpauth";
|
||||||
import { decrypt } from "./encryption";
|
import { decrypt } from "./encryption";
|
||||||
|
import { config } from "../config/env";
|
||||||
|
|
||||||
export const OTPAuth = {
|
export const OTPAuth = {
|
||||||
verify(
|
verify(
|
||||||
@@ -10,15 +11,15 @@ export const OTPAuth = {
|
|||||||
const secret = decrypt(encryptedSecret);
|
const secret = decrypt(encryptedSecret);
|
||||||
const totp = new OTPAuthLib.TOTP({
|
const totp = new OTPAuthLib.TOTP({
|
||||||
secret: OTPAuthLib.Secret.fromBase32(secret),
|
secret: OTPAuthLib.Secret.fromBase32(secret),
|
||||||
algorithm: "SHA1",
|
algorithm: config.totp.algorithm,
|
||||||
digits: 6,
|
digits: config.totp.digits,
|
||||||
period: 30,
|
period: config.totp.period,
|
||||||
});
|
});
|
||||||
const delta = totp.validate({ token: code, window: 1 });
|
const delta = totp.validate({ token: code, window: 1 });
|
||||||
if (delta === null) {
|
if (delta === null) {
|
||||||
return { valid: false, counter: 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 };
|
return { valid: true, counter: currentCounter + delta };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("TOTP verification error:", err);
|
console.error("TOTP verification error:", err);
|
||||||
|
|||||||
Reference in New Issue
Block a user