Files
app/src/admin/pages/Dashboard.tsx
BOHA 87dbde5c59 fix: remove as-any casts, type Dashboard data properly
- Route handlers: add exhaustive return after error checks so TypeScript
  narrows the union and result properties are accessible without casts
- attendance.service: use Prisma.attendanceGetPayload for included relations
- projects.service: remove unnecessary cast on orders relation
- Dashboard.tsx: replace Record<string,any> with proper DashData interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 20:20:43 +01:00

537 lines
17 KiB
TypeScript

import { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import { getCzechDate } from "../utils/dashboardHelpers";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
const API_BASE = "/api/admin";
interface DashData {
my_shift?: { has_ongoing: boolean };
attendance?: {
present_today: number;
total_active: number;
on_leave: number;
users: Array<{
user_id: number | string;
name: string;
initials?: string;
status: string;
leave_type?: string;
arrived_at?: string;
}>;
};
offers?: {
open_count: number;
converted_count: number;
expired_count: number;
created_this_month: number;
};
projects?: {
active_projects: Array<{
id: number;
name: string;
customer_name: string | null;
}>;
};
invoices?: {
revenue_this_month: Array<{ amount: number; currency: string }>;
unpaid_count: number;
revenue_czk: number | null;
};
leave_pending?: { count: number };
recent_activity?: Array<{
id: number | string;
action: string;
entity_type: string;
description: string;
username?: string;
created_at: string;
}>;
users_count?: number;
active_projects?: number;
pending_orders?: number;
unpaid_invoices?: number;
pending_leave_requests?: number;
[key: string]: unknown;
}
export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [dashData, setDashData] = useState<DashData | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [punching, setPunching] = useState(false);
// 2FA state - sdileny mezi profilem a bannerem
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpLoading, setTotpLoading] = useState(true);
const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
const [totpQrUri, setTotpQrUri] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState("");
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [disableCode, setDisableCode] = useState("");
useModalLock(show2FASetup);
useModalLock(show2FADisable);
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`);
const data = await response.json();
if (data.success !== false) {
setDashData(data.data || data);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error("Dashboard fetch error:", err);
}
} finally {
setDashLoading(false);
}
}, []);
useEffect(() => {
fetchDashboard();
}, [fetchDashboard]);
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpEnabled(!!user?.totpEnabled);
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled);
} finally {
setTotpLoading(false);
}
}, [user?.totpEnabled]);
useEffect(() => {
fetch2FAStatus();
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true);
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
});
const result = await response.json();
if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána");
fetchDashboard();
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
} catch {
alert.error("Chyba pripojeni");
} finally {
setPunching(false);
}
};
if (!navigator.geolocation) {
submitPunch({});
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords;
submitPunch({ latitude, longitude, accuracy, address: "" });
},
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
};
// 2FA handlery
const handleStart2FASetup = async () => {
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpSecret(data.data.secret);
setTotpQrUri(data.data.uri || data.data.qr_uri);
setTotpCode("");
setBackupCodes(null);
setShow2FASetup(true);
} else {
alert.error(data.error || "Nepodařilo se vygenerovat 2FA klíč");
}
} catch {
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false);
}
};
const handleConfirm2FA = async () => {
if (!totpCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/enable`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() }),
});
const data = await response.json();
if (data.success) {
setTotpEnabled(true);
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
updateUser({ totpEnabled: true });
alert.success("2FA bylo aktivováno");
} else {
alert.error(data.error || "Neplatný kód");
setTotpCode("");
}
} catch {
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false);
}
};
const handleDisable2FA = async () => {
if (!disableCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/disable`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: disableCode.trim() }),
});
const data = await response.json();
if (data.success) {
setTotpEnabled(false);
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
alert.success("2FA bylo deaktivováno");
} else {
alert.error(data.error || "Neplatný kód");
setDisableCode("");
}
} catch {
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false);
}
};
return (
<div className="dash">
{/* Header */}
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">
Vítejte zpět, {user?.fullName || user?.username}
</h1>
<p className="admin-page-subtitle">{getCzechDate()}</p>
</div>
</motion.div>
{/* 2FA Required Banner */}
{user?.require2FA && !user?.totpEnabled && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{
border: "2px solid var(--danger)",
background: "var(--danger-light)",
}}
>
<div
className="admin-card-body"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="flex-row-gap">
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--danger-light)",
color: "var(--danger)",
flexShrink: 0,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<div>
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
<div
className="text-secondary"
style={{ fontSize: "0.875rem" }}
>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</div>
</div>
</div>
<button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary"
style={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
</button>
</div>
</motion.div>
)}
{/* Skeleton loading */}
{dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}>
<div className="dash-kpi-grid dash-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line h-24"
style={{ borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-quick-actions">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-main-grid">
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
</div>
</div>
<div className="dash-bottom">
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
</div>
</div>
)}
{/* KPI cards — only show if user has any admin-level permissions */}
{!dashLoading &&
(hasPermission("offers.view") ||
hasPermission("invoices.view") ||
hasPermission("projects.view") ||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions
dashData={dashData}
punching={punching}
onPunch={handleQuickPunch}
/>
)}
{/* Main content grid */}
{!dashLoading && (
<motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
{hasPermission("settings.audit") && (
<DashActivityFeed activities={dashData?.recent_activity ?? null} />
)}
{hasPermission("attendance.admin") && (
<DashAttendanceToday attendance={dashData?.attendance ?? null} />
)}
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link
to="/projects"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Vše &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map(
(p: {
id: number;
name: string;
customer_name: string | null;
}) => (
<Link
key={p.id}
to={`/projects/${p.id}`}
className="dash-project-row"
>
<div className="dash-project-name">{p.name}</div>
{p.customer_name && (
<div className="dash-project-customer">
{p.customer_name}
</div>
)}
</Link>
),
)}
</div>
</div>
)}
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link
to="/offers"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Zobrazit &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">
{dashData.offers.open_count}
</span>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">
{dashData.offers.converted_count}
</span>
</div>
<div className="dash-stat-row">
<span>Prošlé</span>
<span className="admin-badge admin-badge-warning">
{dashData.offers.expired_count}
</span>
</div>
</div>
</div>
)}
</div>
</motion.div>
)}
{/* Profile + Sessions */}
{!dashLoading && (
<div className="dash-bottom">
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>
)}
</div>
);
}