chore(release): v2.4.35 — lint cleanup (68→0) + duplicate-Zavřít modal fix

UI fix:
- Close-only modals showing a redundant second close button now use
  hideCancel: ReceivedInvoices paid-detail modal (was two identical "Zavřít"
  buttons) and PlanCellModal "Den je součástí rozsahu".
- Add modal-duplicate-close test enforcing close-only modals set hideCancel.

Lint: cleared all 68 warnings → 0.
- preserve-caught-error: attach { cause } in ai.service / exchange-rates.
- no-require-imports: package.json version read via fs (APP_VERSION) instead
  of require(), avoiding a rootDir-expanding static JSON import.
- react-hooks/exhaustive-deps (11): ref-in-cleanup copies, derived-value
  useMemo wrapping, PlanGrid field extraction, stable nextKey useCallback,
  AuthContext documented cycle-break.
- no-explicit-any (53): precise route param/Prisma types, generic enrich*()
  preserving payload shape, minimal vite module type, frontend body/query-key
  types, SystemInfo for Settings.

Refactor (test enablement): shift-form types moved to dependency-free
shiftFormTypes.ts so the print-HTML builders are unit-testable without the
component graph; characterization test pins their output.

Gates: 649 tests pass, tsc -b clean, lint 0. Verified touched flows live
via Playwright (PlanWork CRUD + optimistic cache, warehouse form keys,
Settings system info, invoice detail).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-13 18:35:31 +02:00
parent 907ee574e6
commit 1ee59b54bc
36 changed files with 954 additions and 140 deletions

View File

@@ -210,7 +210,7 @@ export default function AttendanceHistory() {
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = data ?? [];
const records = useMemo(() => data ?? [], [data]);
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");

View File

@@ -621,7 +621,10 @@ export default function InvoiceDetail() {
const customers = customersQuery.data ?? [];
const bankAccountsQuery = useQuery(bankAccountsOptions());
const bankAccounts = bankAccountsQuery.data ?? [];
const bankAccounts = useMemo(
() => bankAccountsQuery.data ?? [],
[bankAccountsQuery.data],
);
const invoiceQuery = useQuery(invoiceDetailOptions(id));
const invoice = invoiceQuery.data ?? null;
@@ -656,8 +659,9 @@ export default function InvoiceDetail() {
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);

View File

@@ -121,8 +121,9 @@ export default function OrderDetail() {
}, [orderQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);

View File

@@ -10,6 +10,10 @@ import {
getCell,
getCells,
type ModalMode as HookModalMode,
type PlanEntryBody,
type PlanEntryPatchBody,
type PlanOverrideBody,
type PlanOverridePatchBody,
} from "../hooks/usePlanWork";
import type { PlanCellModalMode } from "../components/PlanCellModal";
import PlanGrid from "../components/PlanGrid";
@@ -182,7 +186,13 @@ export default function PlanWork() {
if (bulkForm.user_ids.length === 0) return;
setBulkSubmitting(true);
try {
const data: any = await bulkCreate.mutateAsync({
// Counts can arrive either at the envelope root or nested under .data.
type BulkSummary = {
created_days?: number;
skipped_days?: number;
users?: number;
};
const data = (await bulkCreate.mutateAsync({
user_ids: bulkForm.user_ids.map(Number),
date_from: bulkForm.date_from,
date_to: bulkForm.is_range ? bulkForm.date_to : bulkForm.date_from,
@@ -190,12 +200,12 @@ export default function PlanWork() {
project_id: bulkForm.project_id ? Number(bulkForm.project_id) : null,
category: bulkForm.category,
note: bulkForm.note,
});
})) as BulkSummary & { data?: BulkSummary };
// apiCall returns the full envelope { success, data, message };
// the summary counts are under .data
const summary = data?.data ?? data;
const summary: BulkSummary = data?.data ?? data;
const skipped =
summary?.skipped_days > 0
(summary?.skipped_days ?? 0) > 0
? ` ${summary.skipped_days} dní přeskočeno (limit 3 na den).`
: "";
alert.success(
@@ -392,7 +402,7 @@ export default function PlanWork() {
// optimistic cache patch the hook already applied.
const saveEntry = useCallback(
async (body: any) => {
async (body: PlanEntryBody) => {
try {
await createEntry.mutateAsync(body);
// Pulse the first day of the new entry — for multi-day entries
@@ -409,7 +419,7 @@ export default function PlanWork() {
);
const updateEntryFn = useCallback(
async (id: number, body: any) => {
async (id: number, body: PlanEntryPatchBody) => {
// Find the user for this entry so we know which cell to pulse /
// which cache to roll back. The hook's optimistic patch already
// discovered this; we read it from the current grid.
@@ -473,7 +483,7 @@ export default function PlanWork() {
);
const saveOverride = useCallback(
async (body: any) => {
async (body: PlanOverrideBody) => {
try {
await createOverride.mutateAsync(body);
firePulse(body.user_id, body.shift_date);
@@ -488,7 +498,7 @@ export default function PlanWork() {
);
const updateOverrideFn = useCallback(
async (id: number, body: any) => {
async (id: number, body: PlanOverridePatchBody) => {
// Find the user/date for the pulse + rollback.
let userId: number | null = null;
let date: string | null = null;

View File

@@ -362,8 +362,9 @@ export default function ReceivedInvoices({
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const blobTimeouts = blobTimeoutsRef.current;
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
blobTimeouts.forEach(clearTimeout);
};
}, []);
@@ -1149,11 +1150,9 @@ export default function ReceivedInvoices({
? "Zavřít"
: "Uložit"
}
cancelText={
editInvoice && editInvoice._originalStatus === "paid"
? "Zavřít"
: "Zrušit"
}
// Read-only (paid) detail has only a close action — a single "Zavřít"
// button, not a redundant cancel button beside it.
hideCancel={editInvoice?._originalStatus === "paid"}
loading={saving}
>
{editInvoice &&

View File

@@ -715,7 +715,25 @@ export default function Settings() {
</Box>
);
}
const si = systemInfo as Record<string, any>;
type NasInfo = { configured?: boolean; path?: string | null };
type SystemInfo = {
app_version?: string;
node_version?: string;
platform?: string;
uptime?: string;
environment?: string;
timezone?: string;
memory?: {
rss?: string;
heap_used?: string;
heap_total?: string;
system_free?: string;
system_total?: string;
};
database?: { status?: string; migrations_applied?: number | string };
nas?: Record<string, NasInfo | undefined>;
};
const si = systemInfo as SystemInfo;
const Row = ({
label,
children,
@@ -800,7 +818,7 @@ export default function Settings() {
["Faktury", si.nas?.invoices],
["Objednávky", si.nas?.orders],
["Nabídky", si.nas?.offers],
] as [string, Record<string, any>][]
] as [string, NasInfo | undefined][]
).map(([label, info]) => (
<Row key={label} label={label}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useId } from "react";
import { useState, useEffect, useRef, useId, useCallback } from "react";
import {
useParams,
useNavigate,
@@ -174,7 +174,10 @@ export default function WarehouseIssueForm() {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
@@ -262,7 +265,7 @@ export default function WarehouseIssueForm() {
}
formInitialized.current = true;
}
}, [isEdit, issue]);
}, [isEdit, issue, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,

View File

@@ -126,7 +126,10 @@ export default function WarehouseReceiptForm() {
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const nextKey = useCallback(
() => `${baseId}-item-${counterRef.current++}`,
[baseId],
);
const [form, setForm] = useState<ReceiptForm>({
supplier_id: null,
@@ -198,7 +201,7 @@ export default function WarehouseReceiptForm() {
}
formInitialized.current = true;
}
}, [isEdit, receipt]);
}, [isEdit, receipt, nextKey]);
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,