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