initial commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-03-23 08:46:51 +01:00
commit 4608494a3f
130 changed files with 40361 additions and 0 deletions

View File

@@ -0,0 +1,378 @@
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'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DashData = Record<string, any>
export default function Dashboard() {
const { user, updateUser } = 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 */}
{!dashLoading && <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 }}
>
<DashActivityFeed activities={dashData?.recent_activity} />
<DashAttendanceToday attendance={dashData?.attendance} />
{/* 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>
)
}