diff --git a/package.json b/package.json index 498dae0..0bd411c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "app-ts", - "version": "1.5.3", + "version": "1.5.4", "description": "", "main": "dist/server.js", "scripts": { diff --git a/src/admin/context/AuthContext.tsx b/src/admin/context/AuthContext.tsx index 7d60e25..39c38c2 100644 --- a/src/admin/context/AuthContext.tsx +++ b/src/admin/context/AuthContext.tsx @@ -95,10 +95,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(null); const refreshTimeoutRef = useRef | null>(null); - useEffect(() => { - cachedUserRef.current = user; - }, [user]); - const getAccessTokenFn = useCallback((): string | null => { if ( !tokenExpiresAtRef.current || diff --git a/src/admin/pages/InvoiceDetail.tsx b/src/admin/pages/InvoiceDetail.tsx index 56326af..1ad1640 100644 --- a/src/admin/pages/InvoiceDetail.tsx +++ b/src/admin/pages/InvoiceDetail.tsx @@ -384,7 +384,6 @@ export default function InvoiceDetail() { const [loading, setLoading] = useState(true); const [invoiceNumber, setInvoiceNumber] = useState(""); const initialSnapshotRef = useRef(null); - const hasSetInitialSnapshot = useRef(false); const [customers, setCustomers] = useState([]); 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) => ({ - _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) => ({ + _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() { ))} - {form.due_date && ( + {computedDueDate && ( Splatnost:{" "} - {new Date(form.due_date).toLocaleDateString("cs-CZ")} + {new Date(computedDueDate).toLocaleDateString("cs-CZ")} )} diff --git a/src/admin/pages/Login.tsx b/src/admin/pages/Login.tsx index 12e1893..b2d6865 100644 --- a/src/admin/pages/Login.tsx +++ b/src/admin/pages/Login.tsx @@ -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(() => { diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 1c97b9e..fdfc926 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -262,6 +262,19 @@ function SortableItemRow({ ); } +function loadOfferDraft(): { + form?: Record; + 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>({}); - const [form, setForm] = useState(emptyForm); - const [items, setItems] = useState(() => [emptyItem()]); - const [sections, setSections] = useState([]); + const [form, setForm] = useState(() => { + 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(() => { + const draft = loadOfferDraft(); + if (Array.isArray(draft?.items) && draft.items.length > 0) { + return draft.items as OfferItem[]; + } + return [emptyItem()]; + }); + const [sections, setSections] = useState(() => { + 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 | null>(null); const unlockAbortRef = useRef(null); const initialSnapshotRef = useRef(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"); diff --git a/src/admin/pages/OrderDetail.tsx b/src/admin/pages/OrderDetail.tsx index c24fc6c..74d7882 100644 --- a/src/admin/pages/OrderDetail.tsx +++ b/src/admin/pages/OrderDetail.tsx @@ -121,7 +121,6 @@ export default function OrderDetail() { const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [confirmationLoading, setConfirmationLoading] = useState(false); const initialNotesRef = useRef(null); - const hasSetInitialSnapshot = useRef(false); const blobTimeoutsRef = useRef[]>([]); 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; diff --git a/src/admin/pages/ProjectCreate.tsx b/src/admin/pages/ProjectCreate.tsx index 3032c03..ce3a4e4 100644 --- a/src/admin/pages/ProjectCreate.tsx +++ b/src/admin/pages/ProjectCreate.tsx @@ -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"); } diff --git a/src/admin/pages/ProjectDetail.tsx b/src/admin/pages/ProjectDetail.tsx index c8a5b4c..c93d2ab 100644 --- a/src/admin/pages/ProjectDetail.tsx +++ b/src/admin/pages/ProjectDetail.tsx @@ -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(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}`); diff --git a/src/admin/pages/ReceivedInvoices.tsx b/src/admin/pages/ReceivedInvoices.tsx index 77d2826..de69cd9 100644 --- a/src/admin/pages/ReceivedInvoices.tsx +++ b/src/admin/pages/ReceivedInvoices.tsx @@ -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); diff --git a/src/routes/admin/received-invoices.ts b/src/routes/admin/received-invoices.ts index fe4c983..11787f3 100644 --- a/src/routes/admin/received-invoices.ts +++ b/src/routes/admin/received-invoices.ts @@ -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 = {}; @@ -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, }); }, diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index 4e05fed..8563aeb 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -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 = { + 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" };