Files
app/src/admin/components/dashboard/DashKpiCards.tsx
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00

177 lines
4.5 KiB
TypeScript

import { motion, useReducedMotion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { StatCard, type StatCardColor } from "../../ui";
import { formatCurrency } from "../../utils/formatters";
interface KpiCard {
label: string;
value: string;
sub?: string;
color: string;
footer: string | null;
}
interface RevenueItem {
amount: number;
currency: string;
}
interface InvoicesData {
revenue_this_month: RevenueItem[];
revenue_czk?: number | null;
unpaid_count: number;
}
interface DashData {
attendance?: {
present_today: number;
total_active: number;
on_leave: number;
};
offers?: {
open_count: number;
created_this_month: number;
};
invoices?: InvoicesData;
leave_pending?: {
count: number;
};
}
interface DashKpiCardsProps {
dashData: DashData | null;
}
function buildKpiCards(dashData: DashData | null): KpiCard[] {
const cards: KpiCard[] = [];
if (dashData?.attendance) {
cards.push({
label: "Přítomní dnes",
value: `${dashData.attendance.present_today}`,
sub: `/ ${dashData.attendance.total_active}`,
color: "success",
footer:
dashData.attendance.on_leave > 0
? `${dashData.attendance.on_leave} nepřítomných`
: null,
});
}
if (dashData?.offers) {
cards.push({
label: "Otevřené nabídky",
value: `${dashData.offers.open_count}`,
color: "info",
footer:
dashData.offers.created_this_month > 0
? `${dashData.offers.created_this_month} tento měsíc`
: null,
});
}
if (dashData?.invoices) {
cards.push(buildInvoiceKpi(dashData.invoices));
}
if (dashData?.leave_pending) {
cards.push({
label: "Žádosti o volno",
value: `${dashData.leave_pending.count}`,
color: "danger",
footer: dashData.leave_pending.count > 0 ? "čeká na schválení" : null,
});
}
return cards;
}
function buildInvoiceKpi(invoices: InvoicesData): KpiCard {
const rev = invoices.revenue_this_month || [];
const hasForeign = rev.some((r) => r.currency !== "CZK");
const hasCzkTotal =
hasForeign &&
invoices.revenue_czk !== null &&
invoices.revenue_czk !== undefined;
const fallbackText =
rev.length > 0
? rev.map((r) => formatCurrency(r.amount, r.currency)).join(" · ")
: "0 Kč";
const revenueText = hasCzkTotal
? formatCurrency(invoices.revenue_czk!, "CZK")
: fallbackText;
const detailText =
hasForeign && rev.length > 0
? rev.map((r) => formatCurrency(r.amount, r.currency)).join(" · ")
: null;
const unpaidText =
invoices.unpaid_count > 0 ? `${invoices.unpaid_count} neuhrazených` : null;
const footerParts = [detailText, unpaidText].filter(Boolean);
return {
label: "Tržby (měsíc)",
value: revenueText,
color: "warning",
footer: footerParts.length > 0 ? footerParts.join(" · ") : null,
};
}
// Maps the legacy KPI color tokens to the StatCard color palette
// (danger → error; the rest are 1:1).
const KPI_COLOR_MAP: Record<string, StatCardColor> = {
success: "success",
info: "info",
warning: "warning",
danger: "error",
};
export default function DashKpiCards({ dashData }: DashKpiCardsProps) {
const reduce = useReducedMotion();
const kpiCards = buildKpiCards(dashData);
if (kpiCards.length === 0) {
return null;
}
return (
<Box
component={motion.div}
initial={reduce ? false : { opacity: 0, y: 12 }}
animate={reduce ? undefined : { opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: `repeat(${Math.min(kpiCards.length, 4)}, 1fr)`,
},
gap: 2,
mb: 3,
}}
>
{kpiCards.map((kpi) => (
<StatCard
key={kpi.label}
color={KPI_COLOR_MAP[kpi.color] ?? "default"}
label={kpi.label}
value={
<>
{kpi.value}
{kpi.sub && (
<Typography
component="span"
color="text.secondary"
sx={{
fontSize: "0.75em",
fontWeight: 500,
ml: 0.5,
fontFamily: "inherit",
}}
>
{kpi.sub}
</Typography>
)}
</>
}
footer={kpi.footer ?? undefined}
/>
))}
</Box>
);
}