Dashboard.jsx (1346 -> 378 LOC): - DashKpiCards, DashQuickActions, DashActivityFeed, DashAttendanceToday, DashProfile, DashSessions - dashboardHelpers.js (konstanty + helper funkce) OfferDetail.jsx (1061 -> ~530 LOC): - useOfferForm hook (form state, draft, items/sections, submit) - OfferCustomerPicker (customer search/select dropdown) AttendanceAdmin.jsx (1036 -> ~275 LOC): - useAttendanceAdmin hook (data fetching, filters, CRUD, print) - AttendanceShiftTable (shift records table) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
311 lines
17 KiB
JavaScript
311 lines
17 KiB
JavaScript
import { useState, useRef } from 'react'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import { useAuth } from '../../context/AuthContext'
|
|
import { useAlert } from '../../context/AlertContext'
|
|
import useModalLock from '../../hooks/useModalLock'
|
|
import apiFetch from '../../utils/api'
|
|
|
|
const API_BASE = '/api/admin'
|
|
|
|
export default function DashProfile({
|
|
totpEnabled, totpLoading, totpSubmitting,
|
|
onStart2FASetup, onConfirm2FA, onDisable2FA,
|
|
totpSecret, totpQrUri, totpCode, setTotpCode,
|
|
backupCodes, setBackupCodes,
|
|
show2FASetup, setShow2FASetup,
|
|
show2FADisable, setShow2FADisable,
|
|
disableCode, setDisableCode,
|
|
}) {
|
|
const { user, updateUser } = useAuth()
|
|
const alert = useAlert()
|
|
const totpSetupRef = useRef(null)
|
|
|
|
const [showModal, setShowModal] = useState(false)
|
|
const [formData, setFormData] = useState({
|
|
username: '', email: '', password: '', first_name: '', last_name: ''
|
|
})
|
|
|
|
useModalLock(showModal)
|
|
|
|
const openEditModal = () => {
|
|
const nameParts = (user?.fullName || '').split(' ')
|
|
setFormData({
|
|
username: user?.username || '',
|
|
email: user?.email || '',
|
|
password: '',
|
|
first_name: nameParts[0] || '',
|
|
last_name: nameParts.slice(1).join(' ') || ''
|
|
})
|
|
setShowModal(true)
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
e?.preventDefault()
|
|
const dataToSave = { ...formData }
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/profile.php`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(dataToSave)
|
|
})
|
|
const data = await response.json()
|
|
if (data.success) {
|
|
updateUser({
|
|
username: dataToSave.username,
|
|
email: dataToSave.email,
|
|
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
|
|
})
|
|
setShowModal(false)
|
|
await new Promise(resolve => setTimeout(resolve, 300))
|
|
alert.success('Profil byl upraven')
|
|
} else {
|
|
alert.error(data.error || 'Nepodařilo se uložit profil')
|
|
}
|
|
} catch {
|
|
alert.error('Chyba připojení')
|
|
}
|
|
}
|
|
|
|
function getTotpStatusText() {
|
|
if (totpLoading) {
|
|
return 'Načítání...'
|
|
}
|
|
return totpEnabled ? 'Aktivní' : 'Neaktivní'
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.4, delay: 0.25 }}
|
|
>
|
|
<div className="admin-card-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<h2 className="admin-card-title">Váš účet</h2>
|
|
<button onClick={openEditModal} className="admin-btn admin-btn-secondary admin-btn-sm">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
Upravit
|
|
</button>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
<div className="dash-profile-grid">
|
|
<div className="dash-profile-item">
|
|
<span className="dash-profile-label">Uživatel</span>
|
|
<span className="dash-profile-value">{user?.username}</span>
|
|
</div>
|
|
<div className="dash-profile-item">
|
|
<span className="dash-profile-label">E-mail</span>
|
|
<span className="dash-profile-value">{user?.email}</span>
|
|
</div>
|
|
<div className="dash-profile-item">
|
|
<span className="dash-profile-label">Jméno</span>
|
|
<span className="dash-profile-value">{user?.fullName}</span>
|
|
</div>
|
|
<div className="dash-profile-item">
|
|
<span className="dash-profile-label">Role</span>
|
|
<span className="dash-profile-value">{user?.roleDisplay || user?.role}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 2FA Section */}
|
|
<div style={{ borderTop: '1px solid var(--border-color)', marginTop: '1rem', paddingTop: '1rem' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
|
<div style={{
|
|
width: 36, height: 36, borderRadius: '50%',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
background: totpEnabled ? 'var(--success-light)' : 'rgba(var(--text-secondary-rgb, 107, 114, 128), 0.1)',
|
|
color: totpEnabled ? 'var(--success)' : 'var(--text-secondary)'
|
|
}}>
|
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<div style={{ fontWeight: 500, fontSize: '0.875rem' }}>Dvoufaktorové ověření (2FA)</div>
|
|
<div className={totpEnabled ? 'text-success' : 'text-secondary'} style={{ fontSize: '0.75rem' }}>
|
|
{getTotpStatusText()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!totpLoading && (
|
|
totpEnabled ? (
|
|
<button onClick={() => { setDisableCode(''); setShow2FADisable(true) }} className="admin-btn admin-btn-primary admin-btn-sm">
|
|
Deaktivovat
|
|
</button>
|
|
) : (
|
|
<button onClick={onStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary admin-btn-sm">
|
|
{totpSubmitting ? 'Generuji...' : 'Aktivovat'}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Edit Profile Modal */}
|
|
<AnimatePresence>
|
|
{showModal && (
|
|
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
|
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
|
|
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
|
<div className="admin-modal-header"><h2 className="admin-modal-title">Upravit profil</h2></div>
|
|
<div className="admin-modal-body">
|
|
<div className="admin-form">
|
|
<div className="admin-form-row">
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Jméno</label>
|
|
<input type="text" value={formData.first_name} onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required className="admin-form-input" />
|
|
</div>
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Příjmení</label>
|
|
<input type="text" value={formData.last_name} onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required className="admin-form-input" />
|
|
</div>
|
|
</div>
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Uživatelské jméno</label>
|
|
<input type="text" value={formData.username} onChange={(e) => setFormData({ ...formData, username: e.target.value })} required className="admin-form-input" />
|
|
</div>
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">E-mail</label>
|
|
<input type="email" value={formData.email} onChange={(e) => setFormData({ ...formData, email: e.target.value })} required className="admin-form-input" />
|
|
</div>
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Nové heslo (ponechte prázdné pro zachování stávajícího)</label>
|
|
<input type="password" value={formData.password} onChange={(e) => setFormData({ ...formData, password: e.target.value })} className="admin-form-input" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="admin-modal-footer">
|
|
<button type="button" onClick={() => setShowModal(false)} className="admin-btn admin-btn-secondary">Zrušit</button>
|
|
<button type="button" onClick={handleSubmit} className="admin-btn admin-btn-primary">Uložit změny</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 2FA Setup Modal */}
|
|
<AnimatePresence>
|
|
{show2FASetup && (
|
|
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
|
<div className="admin-modal-backdrop" onClick={() => { if (!backupCodes) { setShow2FASetup(false) } }} />
|
|
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
|
<div className="admin-modal-header">
|
|
<h2 className="admin-modal-title">{backupCodes ? 'Záložní kódy' : 'Nastavení 2FA'}</h2>
|
|
</div>
|
|
<div className="admin-modal-body">
|
|
{backupCodes ? (
|
|
<div>
|
|
<div className="admin-role-locked-notice" style={{ marginBottom: '1rem' }}>
|
|
<svg width="16" height="16" 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>
|
|
Uložte si tyto kódy na bezpečné místo. Každý kód lze použít pouze jednou. Po zavření tohoto okna je již neuvidíte.
|
|
</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '0.5rem', padding: '1rem', background: 'var(--bg-secondary)', borderRadius: '0.5rem', fontFamily: 'monospace', fontSize: '1rem' }}>
|
|
{backupCodes.map((code) => (
|
|
<div key={code} style={{ padding: '0.25rem 0.5rem', textAlign: 'center', color: 'var(--text-primary)' }}>{code}</div>
|
|
))}
|
|
</div>
|
|
<div style={{ marginTop: '0.75rem' }}>
|
|
<button onClick={() => { navigator.clipboard?.writeText(backupCodes.join('\n')); alert.success('Kódy zkopírovány') }} className="admin-btn admin-btn-secondary admin-btn-sm">
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
</svg>
|
|
Kopírovat kódy
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-secondary" style={{ fontSize: '0.875rem', marginBottom: '1rem' }}>
|
|
Naskenujte QR kód v autentizační aplikaci (Google Authenticator, Authy, Microsoft Authenticator apod.)
|
|
</p>
|
|
{totpQrUri && (
|
|
<div style={{ textAlign: 'center', marginBottom: '1rem' }}>
|
|
<canvas
|
|
ref={(canvas) => {
|
|
if (canvas && totpQrUri) {
|
|
import('../../../utils/qrcode.js').then(({ renderQR }) => renderQR(canvas, totpQrUri))
|
|
}
|
|
}}
|
|
style={{ width: 200, height: 200, borderRadius: '0.5rem', border: '1px solid var(--border-color)' }}
|
|
/>
|
|
</div>
|
|
)}
|
|
{totpSecret && (
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<label className="admin-form-label" style={{ fontSize: '0.75rem' }}>Nebo zadejte klíč ručně:</label>
|
|
<div style={{ padding: '0.5rem 0.75rem', background: 'var(--bg-secondary)', borderRadius: '0.375rem', fontFamily: 'monospace', fontSize: '0.875rem', wordBreak: 'break-all', color: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.5rem' }}>
|
|
<span>{totpSecret}</span>
|
|
<button onClick={() => { navigator.clipboard?.writeText(totpSecret); alert.success('Klíč zkopírován') }} className="admin-btn-icon" title="Kopírovat" aria-label="Kopírovat" style={{ flexShrink: 0 }}>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Ověřovací kód z aplikace</label>
|
|
<input ref={totpSetupRef} type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totpCode} onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && totpCode.length === 6) { onConfirm2FA() } }} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="admin-modal-footer">
|
|
{backupCodes ? (
|
|
<button onClick={() => { setShow2FASetup(false); setBackupCodes(null) }} className="admin-btn admin-btn-primary">
|
|
Rozumím, uložil jsem si kódy
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button onClick={() => setShow2FASetup(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
|
|
<button onClick={onConfirm2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || totpCode.length !== 6}>
|
|
{totpSubmitting ? 'Ověřuji...' : 'Aktivovat 2FA'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 2FA Disable Modal */}
|
|
<AnimatePresence>
|
|
{show2FADisable && (
|
|
<motion.div className="admin-modal-overlay" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.2 }}>
|
|
<div className="admin-modal-backdrop" onClick={() => setShow2FADisable(false)} />
|
|
<motion.div className="admin-modal" initial={{ opacity: 0, scale: 0.95, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.95, y: 20 }} transition={{ duration: 0.2 }}>
|
|
<div className="admin-modal-header"><h2 className="admin-modal-title">Deaktivovat 2FA</h2></div>
|
|
<div className="admin-modal-body">
|
|
<p style={{ color: 'var(--text-secondary)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
|
Pro deaktivaci dvoufaktorového ověření zadejte aktuální kód z autentizační aplikace.
|
|
</p>
|
|
<div className="admin-form-group">
|
|
<label className="admin-form-label">Ověřovací kód</label>
|
|
<input type="text" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={disableCode} onChange={(e) => setDisableCode(e.target.value.replace(/\D/g, ''))} placeholder="000000" className="admin-form-input" style={{ textAlign: 'center', fontSize: '1.25rem', letterSpacing: '0.4rem', fontFamily: 'monospace' }} onKeyDown={(e) => { if (e.key === 'Enter' && disableCode.length === 6) { onDisable2FA() } }} autoFocus />
|
|
</div>
|
|
</div>
|
|
<div className="admin-modal-footer">
|
|
<button onClick={() => setShow2FADisable(false)} className="admin-btn admin-btn-secondary" disabled={totpSubmitting}>Zrušit</button>
|
|
<button onClick={onDisable2FA} className="admin-btn admin-btn-primary" disabled={totpSubmitting || disableCode.length !== 6}>
|
|
{totpSubmitting ? 'Deaktivuji...' : 'Deaktivovat 2FA'}
|
|
</button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</>
|
|
)
|
|
}
|