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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "app-ts",
|
||||
"version": "1.5.3",
|
||||
"version": "1.5.4",
|
||||
"description": "",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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" };
|
||||
|
||||
Reference in New Issue
Block a user