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 175e333f41
11 changed files with 173 additions and 198 deletions

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,8 @@ export default function Login() {
} else if (shouldShowLogoutAlert()) { } else if (shouldShowLogoutAlert()) {
alert.success("Byli jste úspěšně odhlášeni."); alert.success("Byli jste úspěšně odhlášeni.");
} }
}, [alert]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-focus TOTP input // Auto-focus TOTP input
useEffect(() => { 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() { export default function OfferDetail() {
const { id } = useParams(); const { id } = useParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
@@ -293,9 +306,39 @@ export default function OfferDetail() {
const [loading, setLoading] = useState(isEdit); const [loading, setLoading] = useState(isEdit);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(emptyForm); const [form, setForm] = useState<OfferForm>(() => {
const [items, setItems] = useState<OfferItem[]>(() => [emptyItem()]); const draft = loadOfferDraft();
const [sections, setSections] = useState<ScopeSection[]>([]); 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< const [scopeTemplates, setScopeTemplates] = useState<
Array<{ Array<{
id: number; id: number;
@@ -338,7 +381,6 @@ export default function OfferDetail() {
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 initialSnapshotRef = useRef<string | null>(null);
const hasSetInitialSnapshot = useRef(false);
useModalLock(showOrderModal); useModalLock(showOrderModal);
@@ -352,26 +394,25 @@ export default function OfferDetail() {
apiFetch(`${API_BASE}/company-settings`) apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json()) .then((r) => r.json())
.then((d) => { .then((d) => {
if (d.success) setCompanySettings(d.data); if (d.success) {
}) setCompanySettings(d.data);
.catch(() => {}); if (!isEdit) {
}, []);
useEffect(() => {
if (companySettings && !isEdit) {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
currency: currency:
prev.currency === "CZK" prev.currency === "CZK"
? companySettings.default_currency || "CZK" ? d.data.default_currency || "CZK"
: prev.currency, : prev.currency,
vat_rate: vat_rate:
prev.vat_rate === 21 prev.vat_rate === 21
? (companySettings.default_vat_rate ?? 21) ? (d.data.default_vat_rate ?? 21)
: prev.vat_rate, : prev.vat_rate,
})); }));
} }
}, [companySettings, isEdit]); }
})
.catch(() => {});
}, []);
const isInvalidated = offerStatus === "invalidated"; const isInvalidated = offerStatus === "invalidated";
const isLockedByOther = !!lockedBy; const isLockedByOther = !!lockedBy;
@@ -390,7 +431,7 @@ export default function OfferDetail() {
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
const d = result.data; const d = result.data;
setForm({ const formData = {
quotation_number: d.quotation_number || "", quotation_number: d.quotation_number || "",
project_code: d.project_code || "", project_code: d.project_code || "",
customer_id: d.customer_id || null, customer_id: d.customer_id || null,
@@ -406,24 +447,28 @@ export default function OfferDetail() {
exchange_rate: d.exchange_rate || "", exchange_rate: d.exchange_rate || "",
scope_title: d.scope_title || "", scope_title: d.scope_title || "",
scope_description: d.scope_description || "", scope_description: d.scope_description || "",
}); };
setItems( setForm(formData);
d.items?.length const mappedItems = d.items?.length
? d.items.map((it: any) => ({ ? d.items.map((it: any) => ({
...it, ...it,
_key: `item-${++itemKeyCounter.current}`, _key: `item-${++itemKeyCounter.current}`,
})) }))
: [emptyItem()], : [emptyItem()];
); setItems(mappedItems);
setSections( const mappedSections = d.sections?.length
d.sections?.length
? d.sections.map((s: any) => ({ ? d.sections.map((s: any) => ({
title: s.title || "", title: s.title || "",
title_cz: s.title_cz || "", title_cz: s.title_cz || "",
content: s.content || "", content: s.content || "",
})) }))
: [], : [];
); setSections(mappedSections);
initialSnapshotRef.current = JSON.stringify({
form: formData,
items: mappedItems,
sections: mappedSections,
});
setOfferStatus(d.status || ""); setOfferStatus(d.status || "");
setOrderInfo(d.order || null); setOrderInfo(d.order || null);
setLockedBy(d.locked_by || null); setLockedBy(d.locked_by || null);
@@ -477,16 +522,10 @@ export default function OfferDetail() {
if (isEdit) fetchDetail(); if (isEdit) fetchDetail();
}, [isEdit, fetchDetail]); }, [isEdit, fetchDetail]);
useEffect(() => { // Capture initial snapshot after loading completes (create mode)
if (loading) { if (!loading && !initialSnapshotRef.current) {
hasSetInitialSnapshot.current = false;
return;
}
if (!hasSetInitialSnapshot.current) {
initialSnapshotRef.current = JSON.stringify({ form, items, sections }); initialSnapshotRef.current = JSON.stringify({ form, items, sections });
hasSetInitialSnapshot.current = true;
} }
}, [loading, form, items, sections]);
const isDirty = useMemo(() => { const isDirty = useMemo(() => {
if (!initialSnapshotRef.current) return false; if (!initialSnapshotRef.current) return false;
@@ -564,39 +603,6 @@ export default function OfferDetail() {
fetchNextNumber(); fetchNextNumber();
}, [isEdit]); }, [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) // Auto-save draft to localStorage (create mode only)
const draftPayload = JSON.stringify({ form, items, sections }); const draftPayload = JSON.stringify({ form, items, sections });
const debouncedDraft = useDebounce(draftPayload, 1500); const debouncedDraft = useDebounce(draftPayload, 1500);
@@ -722,7 +728,6 @@ export default function OfferDetail() {
items, items,
sections, 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");

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext"; import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext"; 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 { motion } from "framer-motion";
import Forbidden from "../components/Forbidden"; import Forbidden from "../components/Forbidden";
@@ -73,7 +73,6 @@ export default function ProjectDetail() {
const alert = useAlert(); const alert = useAlert();
const { hasPermission, isAdmin } = useAuth(); const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -98,18 +97,6 @@ export default function ProjectDetail() {
const [addingNote, setAddingNote] = useState(false); const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null); 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 () => { const fetchNotes = async () => {
try { try {
const response = await apiFetch(`${API_BASE}/projects/${id}`); const response = await apiFetch(`${API_BASE}/projects/${id}`);

View File

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

View File

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

View File

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