fix: useEffect anti-patterns, attendance permissions, and received-invoices schema mismatch

- Remove ref-mirror useEffect in AuthContext (cachedUserRef already written at mutation sites)
- Replace useEffect slide direction in ReceivedInvoices with render-time computation
- Fix Login.tsx useEffect dependency array (mount-only alert should have [] deps)
- Move "project created" alert to navigation source in ProjectCreate, remove useEffect in ProjectDetail
- Move companySettings defaults into fetch callbacks in InvoiceDetail and OfferDetail
- Replace due_date useEffect with useMemo in InvoiceDetail
- Capture initial snapshots from API data instead of useEffect in InvoiceDetail, OfferDetail, OrderDetail
- Replace localStorage draft useEffect with lazy useState initializer in OfferDetail
- Fix attendance dropdown to filter by attendance.record permission only
- Fix clock-out 404 on update-address (remove departure_time filter for departure action)
- Fix received-invoices stats endpoint referencing non-existent is_deleted and amount_czk columns

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 10:28:15 +02:00
parent d7c7fbad88
commit 3481b97d47
11 changed files with 173 additions and 198 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "app-ts",
"version": "1.5.3",
"version": "1.5.4",
"description": "",
"main": "dist/server.js",
"scripts": {

View File

@@ -95,10 +95,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [error, setError] = useState<string | null>(null);
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
cachedUserRef.current = user;
}, [user]);
const getAccessTokenFn = useCallback((): string | null => {
if (
!tokenExpiresAtRef.current ||

View File

@@ -384,7 +384,6 @@ export default function InvoiceDetail() {
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("");
@@ -401,7 +400,22 @@ export default function InvoiceDetail() {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? d.data.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (d.data.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}
})
.catch(() => {});
}, []);
@@ -413,22 +427,6 @@ export default function InvoiceDetail() {
label: `${v}%`,
}));
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const DRAFT_KEY = "boha_invoice_draft";
const clearDraft = useCallback(() => {
@@ -604,7 +602,7 @@ export default function InvoiceDetail() {
}
// Populate form state from existing invoice
setForm({
const formData = {
customer_id: inv.customer_id || null,
customer_name: inv.customer_name || "",
order_id: inv.order_id || null,
@@ -631,7 +629,8 @@ export default function InvoiceDetail() {
bank_swift: inv.bank_swift || "",
bank_iban: inv.bank_iban || "",
bank_account: inv.bank_account || "",
});
};
setForm(formData);
// Calculate dueDays from existing dates
if (inv.issue_date && inv.due_date) {
@@ -644,19 +643,27 @@ export default function InvoiceDetail() {
}
// Populate items from existing invoice
if (inv.items?.length > 0) {
setItems(
inv.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
})),
);
const mappedItems =
inv.items?.length > 0
? inv.items.map((item: Record<string, unknown>) => ({
_key: `inv-${++keyCounterRef.current}`,
id: item.id as number | undefined,
description: (item.description as string) || "",
quantity: Number(item.quantity) || 1,
unit: (item.unit as string) || "",
unit_price: Number(item.unit_price) || 0,
vat_rate: Number(item.vat_rate) || Number(inv.vat_rate) || 21,
}))
: [];
if (mappedItems.length > 0) {
setItems(mappedItems);
}
// Capture initial snapshot for dirty-checking
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
});
} else {
alert.error(result.error || "Nepodařilo se načíst fakturu");
navigate("/invoices");
@@ -673,16 +680,12 @@ 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]);
// Capture initial snapshot for dirty-checking once data finishes loading.
// Edit mode: captured inside fetchDetail from raw API data.
// Create mode: captured on first stable render after loading completes.
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
@@ -699,12 +702,11 @@ export default function InvoiceDetail() {
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
// ─── Due date calculation from issue date + days ───
useEffect(() => {
if (!form.issue_date) return;
const computedDueDate = useMemo(() => {
if (!form.issue_date) return "";
const d = new Date(form.issue_date);
d.setDate(d.getDate() + dueDays);
setForm((prev) => ({ ...prev, due_date: d.toISOString().split("T")[0] }));
return d.toISOString().split("T")[0];
}, [form.issue_date, dueDays]);
// ─── Create mode: customer filtering ───
@@ -831,6 +833,7 @@ export default function InvoiceDetail() {
try {
const payload: any = {
...form,
due_date: computedDueDate || form.due_date,
items: items
.filter((i) => i.description.trim())
.map((item, i) => ({
@@ -862,7 +865,6 @@ export default function InvoiceDetail() {
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
hasSetInitialSnapshot.current = true;
if (isEdit) {
fetchDetail();
} else {
@@ -1601,10 +1603,10 @@ export default function InvoiceDetail() {
</option>
))}
</select>
{form.due_date && (
{computedDueDate && (
<span className="text-tertiary text-xs mt-1">
Splatnost:{" "}
{new Date(form.due_date).toLocaleDateString("cs-CZ")}
{new Date(computedDueDate).toLocaleDateString("cs-CZ")}
</span>
)}
</FormField>

View File

@@ -34,7 +34,8 @@ export default function Login() {
} else if (shouldShowLogoutAlert()) {
alert.success("Byli jste úspěšně odhlášeni.");
}
}, [alert]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-focus TOTP input
useEffect(() => {

View File

@@ -262,6 +262,19 @@ function SortableItemRow({
);
}
function loadOfferDraft(): {
form?: Record<string, unknown>;
items?: unknown[];
sections?: unknown[];
} | null {
try {
const raw = localStorage.getItem("boha_offer_draft");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
export default function OfferDetail() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -293,9 +306,39 @@ export default function OfferDetail() {
const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm);
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]);
const [sections, setSections] = useState<ScopeSection[]>([]);
const [form, setForm] = useState<OfferForm>(() => {
const draft = loadOfferDraft();
if (draft?.form) {
return {
...emptyForm,
project_code:
(draft.form.project_code as string) || emptyForm.project_code,
customer_name:
(draft.form.customer_name as string) || emptyForm.customer_name,
created_at: (draft.form.created_at as string) || emptyForm.created_at,
valid_until:
(draft.form.valid_until as string) || emptyForm.valid_until,
currency: (draft.form.currency as string) || emptyForm.currency,
customer_id:
(draft.form.customer_id as number | null) ?? emptyForm.customer_id,
};
}
return emptyForm;
});
const [items, setItems] = useState<OfferItem[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) {
return draft.items as OfferItem[];
}
return [emptyItem()];
});
const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[];
}
return [];
});
const [scopeTemplates, setScopeTemplates] = useState<
Array<{
id: number;
@@ -338,7 +381,6 @@ export default function OfferDetail() {
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);
@@ -352,27 +394,26 @@ export default function OfferDetail() {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
if (d.success) {
setCompanySettings(d.data);
if (!isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? d.data.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (d.data.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}
})
.catch(() => {});
}, []);
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({
...prev,
currency:
prev.currency === "CZK"
? companySettings.default_currency || "CZK"
: prev.currency,
vat_rate:
prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21)
: prev.vat_rate,
}));
}
}, [companySettings, isEdit]);
const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy;
const isExpiredNotInvalidated =
@@ -390,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
const formData = {
quotation_number: d.quotation_number || "",
project_code: d.project_code || "",
customer_id: d.customer_id || null,
@@ -406,24 +447,28 @@ export default function OfferDetail() {
exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "",
scope_description: d.scope_description || "",
};
setForm(formData);
const mappedItems = d.items?.length
? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()];
setItems(mappedItems);
const mappedSections = d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [];
setSections(mappedSections);
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setItems(
d.items?.length
? d.items.map((it: any) => ({
...it,
_key: `item-${++itemKeyCounter.current}`,
}))
: [emptyItem()],
);
setSections(
d.sections?.length
? d.sections.map((s: any) => ({
title: s.title || "",
title_cz: s.title_cz || "",
content: s.content || "",
}))
: [],
);
setOfferStatus(d.status || "");
setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null);
@@ -477,16 +522,10 @@ 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]);
// Capture initial snapshot after loading completes (create mode)
if (!loading && !initialSnapshotRef.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections });
}
const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false;
@@ -564,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber();
}, [isEdit]);
// Restore draft from localStorage on mount (create mode only)
const draftRestoredRef = useRef(false);
useEffect(() => {
if (isEdit || draftRestoredRef.current) return;
draftRestoredRef.current = true;
try {
const raw = localStorage.getItem(DRAFT_KEY);
if (!raw) return;
const draft = JSON.parse(raw);
if (draft && draft.form) {
setForm((prev) => ({
...prev,
project_code: draft.form.project_code || prev.project_code,
customer_name: draft.form.customer_name || prev.customer_name,
created_at: draft.form.created_at || prev.created_at,
valid_until: draft.form.valid_until || prev.valid_until,
currency: draft.form.currency || prev.currency,
}));
if (draft.form.customer_id) {
setForm((prev) => ({ ...prev, customer_id: draft.form.customer_id }));
}
}
if (draft && Array.isArray(draft.items) && draft.items.length > 0) {
setItems(draft.items);
}
if (draft && Array.isArray(draft.sections) && draft.sections.length > 0) {
setSections(draft.sections);
}
} catch {
/* ignore corrupt data */
}
}, [isEdit]);
// Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -722,7 +728,6 @@ export default function OfferDetail() {
items,
sections,
});
hasSetInitialSnapshot.current = true;
}
} else {
alert.error(result.error || "Nepodařilo se uložit nabídku");

View File

@@ -121,7 +121,6 @@ export default function OrderDetail() {
const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [confirmationLoading, setConfirmationLoading] = useState(false);
const initialNotesRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
useEffect(() => {
@@ -138,6 +137,7 @@ export default function OrderDetail() {
if (result.success) {
setOrder(result.data);
setNotes(result.data.notes || "");
initialNotesRef.current = result.data.notes || "";
} else {
alert.error(result.error || "Nepodařilo se načíst objednávku");
navigate("/orders");
@@ -154,17 +154,6 @@ 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;

View File

@@ -171,9 +171,8 @@ export default function ProjectCreate() {
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.id}`, {
state: { created: true },
});
alert.success("Projekt byl vytvořen");
navigate(`/projects/${data.data.id}`);
} else {
alert.error(data.error || "Nepodařilo se vytvořit projekt");
}

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
import { useParams, useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden";
@@ -73,7 +73,6 @@ export default function ProjectDetail() {
const alert = useAlert();
const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
@@ -98,18 +97,6 @@ export default function ProjectDetail() {
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const createdShown = useRef(false);
useEffect(() => {
if (
(location.state as { created?: boolean })?.created &&
!createdShown.current
) {
createdShown.current = true;
alert.success("Projekt byl vytvořen");
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, location.pathname, alert, navigate]);
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`);

View File

@@ -192,18 +192,14 @@ export default function ReceivedInvoices({
};
}, []);
useEffect(() => {
const prev = prevYear.current * 12 + prevMonth.current;
const curr = statsYear * 12 + statsMonth;
if (curr > prev) {
slideDirection.current = 1;
}
if (curr < prev) {
slideDirection.current = -1;
}
prevMonth.current = statsMonth;
prevYear.current = statsYear;
}, [statsMonth, statsYear]);
// Compute slide direction during render (not in effect) so it's
// available for the current frame instead of one render late.
const prevTotal = prevYear.current * 12 + prevMonth.current;
const currTotal = statsYear * 12 + statsMonth;
if (currTotal > prevTotal) slideDirection.current = 1;
else if (currTotal < prevTotal) slideDirection.current = -1;
prevMonth.current = statsMonth;
prevYear.current = statsYear;
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true);

View File

@@ -99,7 +99,7 @@ export default async function receivedInvoicesRoutes(
// Aggregate by currency → CurrencyAmount[] format
const aggregateByCurrency = (
invs: typeof monthInvoices,
invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount",
) => {
const map: Record<string, number> = {};
@@ -116,7 +116,7 @@ export default async function receivedInvoicesRoutes(
};
const sumCzk = async (
invs: typeof monthInvoices,
invs: { currency: string; [key: string]: unknown }[],
field: "amount" | "vat_amount",
) => {
let total = 0;
@@ -127,16 +127,12 @@ export default async function receivedInvoicesRoutes(
return Math.round(total * 100) / 100;
};
// Also get all-time unpaid — use DB-level aggregation for count/sums
const stats = await prisma.received_invoices.aggregate({
where: { status: { not: "paid" }, is_deleted: false },
_sum: { amount: true, amount_czk: true },
_count: true,
// All-time unpaid invoices (no soft-delete column, so just filter by status)
const unpaidCount = await prisma.received_invoices.count({
where: { status: { not: "paid" } },
});
// We still need per-currency breakdown for unpaid, so fetch only those
const allUnpaid = await prisma.received_invoices.findMany({
where: { status: { not: "paid" }, is_deleted: false },
where: { status: { not: "paid" } },
select: { amount: true, currency: true },
});
@@ -146,10 +142,8 @@ export default async function receivedInvoicesRoutes(
vat_month: aggregateByCurrency(monthInvoices, "vat_amount"),
vat_month_czk: await sumCzk(monthInvoices, "vat_amount"),
unpaid: aggregateByCurrency(allUnpaid, "amount"),
unpaid_czk: stats._sum.amount_czk
? Math.round(Number(stats._sum.amount_czk) * 100) / 100
: await sumCzk(allUnpaid, "amount"),
unpaid_count: stats._count,
unpaid_czk: await sumCzk(allUnpaid, "amount"),
unpaid_count: unpaidCount,
month_count: monthInvoices.length,
});
},

View File

@@ -381,12 +381,18 @@ export async function updateAddress(
address: string | null,
punchAction: string,
) {
// When updating departure address, the punch already set departure_time,
// so we can't filter on departure_time: null. Find the latest record instead.
const where: Record<string, unknown> = {
user_id: userId,
arrival_time: { not: null },
};
if (punchAction === "arrival") {
where.departure_time = null;
}
const latest = await prisma.attendance.findFirst({
where: {
user_id: userId,
departure_time: null,
arrival_time: { not: null },
},
where,
orderBy: { created_at: "desc" },
});
if (!latest) return { error: "Nenalezen záznam" };