initial commit
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
929
src/admin/pages/Attendance.tsx
Normal file
929
src/admin/pages/Attendance.tsx
Normal file
@@ -0,0 +1,929 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useAlert } from '../context/AlertContext'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import AdminDatePicker from '../components/AdminDatePicker'
|
||||
import ConfirmModal from '../components/ConfirmModal'
|
||||
import useModalLock from '../hooks/useModalLock'
|
||||
import { formatTime, calculateWorkMinutes, formatMinutes } from '../utils/attendanceHelpers'
|
||||
import FormField from '../components/FormField'
|
||||
import Forbidden from '../components/Forbidden'
|
||||
import apiFetch from '../utils/api'
|
||||
|
||||
const API_BASE = '/api/admin'
|
||||
|
||||
interface ShiftRecord {
|
||||
id: number
|
||||
user_id: number
|
||||
shift_date: string
|
||||
arrival_time?: string | null
|
||||
departure_time?: string | null
|
||||
break_start?: string | null
|
||||
break_end?: string | null
|
||||
notes?: string | null
|
||||
project_id?: number | null
|
||||
project_logs?: ProjectLog[]
|
||||
}
|
||||
|
||||
interface ProjectLog {
|
||||
id?: number
|
||||
project_id?: number
|
||||
project_name?: string
|
||||
started_at?: string
|
||||
ended_at?: string | null
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: number
|
||||
name: string
|
||||
project_number: string
|
||||
}
|
||||
|
||||
interface LeaveBalance {
|
||||
vacation_total: number
|
||||
vacation_used: number
|
||||
vacation_remaining: number
|
||||
sick_used: number
|
||||
}
|
||||
|
||||
interface MonthlyFund {
|
||||
month_name: string
|
||||
fund: number
|
||||
worked: number
|
||||
covered: number
|
||||
remaining: number
|
||||
overtime: number
|
||||
leave_hours: number
|
||||
vacation_hours: number
|
||||
sick_hours: number
|
||||
holiday_hours: number
|
||||
unpaid_hours: number
|
||||
}
|
||||
|
||||
interface AttendanceData {
|
||||
ongoing_shift: ShiftRecord | null
|
||||
today_shifts: ShiftRecord[]
|
||||
date: string
|
||||
leave_balance: LeaveBalance
|
||||
monthly_fund: MonthlyFund | null
|
||||
project_logs: ProjectLog[]
|
||||
active_project_id: number | null
|
||||
}
|
||||
|
||||
function pluralizeDays(n: number) {
|
||||
if (n === 1) return 'den'
|
||||
if (n >= 2 && n <= 4) return 'dny'
|
||||
return 'dnů'
|
||||
}
|
||||
|
||||
function getFundBarBackground(fund: MonthlyFund) {
|
||||
if (fund.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
|
||||
if (fund.covered >= fund.fund) return 'linear-gradient(135deg, var(--success), #059669)'
|
||||
return 'var(--gradient)'
|
||||
}
|
||||
|
||||
export default function Attendance() {
|
||||
const alert = useAlert()
|
||||
const { hasPermission } = useAuth()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [data, setData] = useState<AttendanceData>({
|
||||
ongoing_shift: null,
|
||||
today_shifts: [],
|
||||
date: '',
|
||||
leave_balance: { vacation_total: 160, vacation_used: 0, vacation_remaining: 160, sick_used: 0 },
|
||||
monthly_fund: null,
|
||||
project_logs: [],
|
||||
active_project_id: null,
|
||||
})
|
||||
const [showLeaveModal, setShowLeaveModal] = useState(false)
|
||||
const [leaveForm, setLeaveForm] = useState({
|
||||
leave_type: 'vacation',
|
||||
date_from: new Date().toISOString().split('T')[0],
|
||||
date_to: new Date().toISOString().split('T')[0],
|
||||
notes: '',
|
||||
})
|
||||
const [requestSubmitting, setRequestSubmitting] = useState(false)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [projects, setProjects] = useState<Project[]>([])
|
||||
const [switchingProject, setSwitchingProject] = useState(false)
|
||||
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([])
|
||||
const [activeProjectId, setActiveProjectId] = useState<number | null>(null)
|
||||
const [gpsConfirm, setGpsConfirm] = useState<{ show: boolean; action: string | null }>({ show: false, action: null })
|
||||
const geoAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (geoAbortRef.current) geoAbortRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance/status`)
|
||||
if (response.status === 401) return
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setData(result.data)
|
||||
setNotes(result.data.ongoing_shift?.notes || '')
|
||||
setProjectLogs(result.data.project_logs || [])
|
||||
setActiveProjectId(result.data.active_project_id || null)
|
||||
}
|
||||
} catch {
|
||||
alert.error('Nepodařilo se načíst data')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [alert])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
useEffect(() => {
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance?action=projects`)
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
const items = Array.isArray(result.data) ? result.data : []
|
||||
setProjects(items)
|
||||
}
|
||||
} catch {
|
||||
// silent - projects are supplementary
|
||||
}
|
||||
}
|
||||
loadProjects()
|
||||
}, [])
|
||||
|
||||
useModalLock(showLeaveModal)
|
||||
|
||||
if (!hasPermission('attendance.record')) return <Forbidden />
|
||||
|
||||
const handlePunch = (action: string) => {
|
||||
setSubmitting(true)
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
alert.warning('GPS není dostupná')
|
||||
submitPunch(action, {})
|
||||
return
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
const { latitude, longitude, accuracy } = position.coords
|
||||
submitPunch(action, { latitude, longitude, accuracy, address: '' })
|
||||
|
||||
if (geoAbortRef.current) geoAbortRef.current.abort()
|
||||
const controller = new AbortController()
|
||||
geoAbortRef.current = controller
|
||||
fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`, {
|
||||
headers: { 'Accept-Language': 'cs' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(geoData => {
|
||||
if (geoData.display_name) {
|
||||
apiFetch(`${API_BASE}/attendance/update-address`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ latitude, longitude, address: geoData.display_name, punch_action: action }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
},
|
||||
(geoError) => {
|
||||
let errorMsg = 'Nepodařilo se získat polohu'
|
||||
if (geoError.code === geoError.PERMISSION_DENIED) {
|
||||
errorMsg = 'Přístup k poloze byl zamítnut'
|
||||
} else if (geoError.code === geoError.TIMEOUT) {
|
||||
errorMsg = 'Vypršel časový limit'
|
||||
}
|
||||
alert.error(errorMsg)
|
||||
setGpsConfirm({ show: true, action })
|
||||
},
|
||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
|
||||
)
|
||||
}
|
||||
|
||||
const submitPunch = async (action: string, 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 }),
|
||||
})
|
||||
if (response.status === 401) return
|
||||
|
||||
const result = await response.json()
|
||||
setSubmitting(false)
|
||||
|
||||
if (result.success) {
|
||||
await fetchData()
|
||||
setTimeout(() => {
|
||||
alert.success(result.data?.message || result.message || 'Uloženo')
|
||||
}, 300)
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
}
|
||||
} catch {
|
||||
setSubmitting(false)
|
||||
alert.error('Chyba připojení')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBreak = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ punch_action: 'break_start' }),
|
||||
})
|
||||
if (response.status === 401) return
|
||||
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
await fetchData()
|
||||
alert.success(result.data?.message || result.message || 'Přestávka zaznamenána')
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveNotes = async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes }),
|
||||
})
|
||||
if (response.status === 401) return
|
||||
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
alert.success('Poznámka byla uložena')
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSwitchProject = async (newProjectId: string | null) => {
|
||||
setSwitchingProject(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: newProjectId || null }),
|
||||
})
|
||||
if (response.status === 401) return
|
||||
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
await fetchData()
|
||||
alert.success(result.data?.message || result.message || 'Projekt přepnut')
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setSwitchingProject(false)
|
||||
}
|
||||
}
|
||||
|
||||
const calculateBusinessDays = (from: string, to: string) => {
|
||||
if (!from || !to) return 0
|
||||
const start = new Date(from)
|
||||
const end = new Date(to)
|
||||
if (end < start) return 0
|
||||
let days = 0
|
||||
const current = new Date(start)
|
||||
while (current <= end) {
|
||||
const day = current.getDay()
|
||||
if (day !== 0 && day !== 6) days++
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
const handleRequestSubmit = async () => {
|
||||
setRequestSubmitting(true)
|
||||
try {
|
||||
const response = await apiFetch(`${API_BASE}/leave-requests`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(leaveForm),
|
||||
})
|
||||
if (response.status === 401) return
|
||||
|
||||
const result = await response.json()
|
||||
if (result.success) {
|
||||
setShowLeaveModal(false)
|
||||
await fetchData()
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
alert.success(result.data?.message || result.message || 'Žádost odeslána')
|
||||
setLeaveForm({
|
||||
leave_type: 'vacation',
|
||||
date_from: new Date().toISOString().split('T')[0],
|
||||
date_to: new Date().toISOString().split('T')[0],
|
||||
notes: '',
|
||||
})
|
||||
} else {
|
||||
alert.error(result.error)
|
||||
}
|
||||
} catch {
|
||||
alert.error('Chyba připojení')
|
||||
} finally {
|
||||
setRequestSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
|
||||
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '200px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '140px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '1.5rem' }}>
|
||||
<div className="admin-card" style={{ flex: 2 }}>
|
||||
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '120px', marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '180px' }} />
|
||||
<div className="admin-skeleton-row">
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.5rem' }} />
|
||||
<div className="admin-skeleton-line w-1/4" style={{ height: '10px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-skeleton-line h-10" style={{ width: '100%', borderRadius: '8px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.25rem' }} />
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '100%', height: '6px', borderRadius: '3px' }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card">
|
||||
<div className="admin-skeleton" style={{ gap: '1rem' }}>
|
||||
<div className="admin-skeleton-line w-1/3" style={{ marginBottom: '0.25rem' }} />
|
||||
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
|
||||
<div className="admin-skeleton-line" style={{ width: '100%', height: '6px', borderRadius: '3px' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { ongoing_shift: ongoingShift, today_shifts: todayShifts, leave_balance: leaveBalance } = data
|
||||
const isOngoingShift = ongoingShift && !ongoingShift.departure_time
|
||||
const completedToday = todayShifts.filter(s => s.departure_time)
|
||||
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8)
|
||||
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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">Docházka</h1>
|
||||
<p className="admin-page-subtitle">
|
||||
{new Date().toLocaleDateString('cs-CZ', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="attendance-layout">
|
||||
{/* Left Column - Clock In/Out */}
|
||||
<motion.div
|
||||
className="attendance-main"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
<div className="attendance-clock-card">
|
||||
<div className="attendance-clock-header">
|
||||
<div className="attendance-clock-status">
|
||||
{isOngoingShift ? (
|
||||
<>
|
||||
<span className="attendance-status-dot active" />
|
||||
<span>Pracuji</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="attendance-status-dot" />
|
||||
<span>Nepracuji</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="attendance-clock-time">
|
||||
{new Date().toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOngoingShift ? (
|
||||
<>
|
||||
<div className="attendance-shift-info">
|
||||
<div className="attendance-shift-row">
|
||||
<div className="attendance-shift-item">
|
||||
<span className="attendance-shift-label">Příchod</span>
|
||||
<span className="attendance-shift-value success">
|
||||
{formatTime(ongoingShift.arrival_time)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-shift-item">
|
||||
<span className="attendance-shift-label">Pauza</span>
|
||||
<span className={`attendance-shift-value ${ongoingShift.break_start ? 'success' : ''}`}>
|
||||
{ongoingShift.break_start
|
||||
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-shift-item">
|
||||
<span className="attendance-shift-label">Odchod</span>
|
||||
<span className="attendance-shift-value">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{projects.length > 0 && (
|
||||
<div className="attendance-project-section">
|
||||
<div className="attendance-project-header">
|
||||
<span className="attendance-shift-label">Projekt</span>
|
||||
{activeProjectId ? (
|
||||
<span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.8125rem' }}>
|
||||
{projects.find(p => String(p.id) === String(activeProjectId))
|
||||
? `${projects.find(p => String(p.id) === String(activeProjectId))!.project_number} – ${projects.find(p => String(p.id) === String(activeProjectId))!.name}`
|
||||
: `Projekt #${activeProjectId}`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted" style={{ fontSize: '0.8125rem' }}>Žádný</span>
|
||||
)}
|
||||
</div>
|
||||
<select
|
||||
value={activeProjectId || ''}
|
||||
onChange={(e) => handleSwitchProject(e.target.value || null)}
|
||||
disabled={switchingProject}
|
||||
className="admin-form-select"
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
<option value="">— Bez projektu —</option>
|
||||
{projects.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.project_number} – {p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{projectLogs.length > 0 && (
|
||||
<div className="attendance-project-logs">
|
||||
{projectLogs.map((log, i) => {
|
||||
const start = new Date(log.started_at!)
|
||||
const end = log.ended_at ? new Date(log.ended_at) : new Date()
|
||||
const mins = Math.floor((end.getTime() - start.getTime()) / 60000)
|
||||
const h = Math.floor(mins / 60)
|
||||
const mm = mins % 60
|
||||
return (
|
||||
<div key={log.id || i} className="attendance-project-log-item">
|
||||
<span className="attendance-project-log-name">{log.project_name || `Projekt #${log.project_id}`}</span>
|
||||
<span className="attendance-project-log-time">
|
||||
{formatTime(log.started_at)} – {log.ended_at ? formatTime(log.ended_at) : 'nyní'}
|
||||
</span>
|
||||
<span className="attendance-project-log-duration">{h}:{String(mm).padStart(2, '0')} h</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="attendance-clock-actions">
|
||||
{!ongoingShift.break_start && (
|
||||
<button
|
||||
onClick={handleBreak}
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Pauza (30 min)
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handlePunch('departure')}
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{submitting ? 'Zpracovávám...' : 'Odchod'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Žádost o nepřítomnost
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="attendance-notes">
|
||||
<label className="attendance-notes-label">Poznámka ke směně</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Co jste dělali během směny..."
|
||||
className="admin-form-textarea"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={handleSaveNotes}
|
||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
||||
>
|
||||
Uložit poznámku
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="attendance-clock-actions">
|
||||
<button
|
||||
onClick={() => handlePunch('arrival')}
|
||||
disabled={submitting}
|
||||
className="admin-btn admin-btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{submitting ? 'Zpracovávám...' : 'Příchod'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Žádost o nepřítomnost
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Completed Today */}
|
||||
{completedToday.length > 0 && (
|
||||
<div className="admin-card mt-6">
|
||||
<div className="admin-card-header">
|
||||
<h2 className="admin-card-title">Dnešní dokončené směny</h2>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<div className="admin-table-responsive">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Příchod</th>
|
||||
<th>Pauza</th>
|
||||
<th>Odchod</th>
|
||||
<th>Odpracováno</th>
|
||||
{projects.length > 0 && <th>Projekty</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{completedToday.map((shift) => {
|
||||
const shiftLogs = shift.project_logs || []
|
||||
return (
|
||||
<tr key={shift.id}>
|
||||
<td className="admin-mono">{formatTime(shift.arrival_time)}</td>
|
||||
<td className="admin-mono">
|
||||
{shift.break_start && shift.break_end
|
||||
? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="admin-mono">{formatTime(shift.departure_time)}</td>
|
||||
<td className="admin-mono">{formatMinutes(calculateWorkMinutes(shift as any), true)}</td>
|
||||
{projects.length > 0 && (
|
||||
<td>
|
||||
{shiftLogs.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||
{shiftLogs.map((log, i) => {
|
||||
const mins = log.ended_at ? Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at!).getTime()) / 60000) : 0
|
||||
const h = Math.floor(mins / 60)
|
||||
const mm = mins % 60
|
||||
return (
|
||||
<span key={log.id || i} style={{ fontSize: '12px' }}>
|
||||
{log.project_name || `#${log.project_id}`} ({h}:{String(mm).padStart(2, '0')}h)
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : '—'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Right Column - Stats & Quick Links */}
|
||||
<motion.div
|
||||
className="attendance-sidebar"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25, delay: 0.06 }}
|
||||
>
|
||||
{/* Leave Balance Card */}
|
||||
<div className="attendance-balance-card">
|
||||
<h3 className="attendance-balance-title">Dovolená {new Date().getFullYear()}</h3>
|
||||
<div className="attendance-balance-value">
|
||||
<span className="attendance-balance-number">{vacationDaysRemaining}</span>
|
||||
<span className="attendance-balance-unit">
|
||||
{pluralizeDays(vacationDaysRemaining)}
|
||||
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="attendance-balance-detail">
|
||||
<span>Celkem: {leaveBalance.vacation_total}h</span>
|
||||
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{ width: `${(leaveBalance.vacation_remaining / leaveBalance.vacation_total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Monthly Fund Card */}
|
||||
{data.monthly_fund && (
|
||||
<div className="admin-stat-card" style={{ flexDirection: 'column', alignItems: 'stretch' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
<div className="admin-stat-icon info">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-label">{data.monthly_fund.month_name}</span>
|
||||
<span className="admin-stat-value">{data.monthly_fund.worked}h / {data.monthly_fund.fund}h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.8125rem', marginBottom: '0.5rem' }}>
|
||||
<span>Odpracováno: {data.monthly_fund.worked}h</span>
|
||||
{data.monthly_fund.overtime > 0 ? (
|
||||
<span className="text-warning fw-600">Přesčas: +{data.monthly_fund.overtime}h</span>
|
||||
) : (
|
||||
<span>Zbývá: {data.monthly_fund.remaining}h</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="attendance-balance-bar">
|
||||
<div
|
||||
className="attendance-balance-progress"
|
||||
style={{
|
||||
width: `${Math.min(100, (data.monthly_fund.covered / data.monthly_fund.fund) * 100)}%`,
|
||||
background: getFundBarBackground(data.monthly_fund),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.monthly_fund.leave_hours > 0 && (
|
||||
<div className="text-muted" style={{ fontSize: '0.75rem', marginTop: '0.375rem' }}>
|
||||
{'Pokryto: '}{data.monthly_fund.covered}h (práce {data.monthly_fund.worked}h
|
||||
{data.monthly_fund.vacation_hours > 0 && ` + dovolená ${data.monthly_fund.vacation_hours}h`}
|
||||
{data.monthly_fund.sick_hours > 0 && ` + nemoc ${data.monthly_fund.sick_hours}h`}
|
||||
{data.monthly_fund.holiday_hours > 0 && ` + svátek ${data.monthly_fund.holiday_hours}h`}
|
||||
{data.monthly_fund.unpaid_hours > 0 && ` + neplacené ${data.monthly_fund.unpaid_hours}h`}
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sick Leave Card */}
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-icon danger">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="admin-stat-content">
|
||||
<span className="admin-stat-label">Nemoc {new Date().getFullYear()}</span>
|
||||
<span className="admin-stat-value">{leaveBalance.sick_used}h čerpáno</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="attendance-quick-links">
|
||||
<h4 className="attendance-quick-title">Rychlé odkazy</h4>
|
||||
<Link to="/attendance/requests" className="attendance-quick-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
<span>Moje žádosti</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link to="/attendance/history" className="attendance-quick-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
|
||||
</svg>
|
||||
<span>Historie docházky</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
{hasPermission('attendance.admin') && (
|
||||
<Link to="/attendance/admin" className="attendance-quick-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
<span>Správa docházky</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission('attendance.balances') && (
|
||||
<Link to="/attendance/balances" className="attendance-quick-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
<span>Správa bilancí</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 18l6-6-6-6" />
|
||||
</svg>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Leave Modal */}
|
||||
<AnimatePresence>
|
||||
{showLeaveModal && (
|
||||
<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={() => setShowLeaveModal(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">Žádost o nepřítomnost</h2>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-body">
|
||||
<div className="admin-form">
|
||||
<FormField label="Typ nepřítomnosti">
|
||||
<select
|
||||
value={leaveForm.leave_type}
|
||||
onChange={(e) => setLeaveForm({ ...leaveForm, leave_type: e.target.value })}
|
||||
className="admin-form-select"
|
||||
>
|
||||
<option value="vacation">Dovolená</option>
|
||||
<option value="sick">Nemoc</option>
|
||||
<option value="unpaid">Neplacené volno</option>
|
||||
</select>
|
||||
</FormField>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
||||
<FormField label="Od">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={leaveForm.date_from}
|
||||
onChange={(val: string) => {
|
||||
setLeaveForm(prev => ({
|
||||
...prev,
|
||||
date_from: val,
|
||||
date_to: prev.date_to < val ? val : prev.date_to,
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="Do">
|
||||
<AdminDatePicker
|
||||
mode="date"
|
||||
value={leaveForm.date_to}
|
||||
minDate={leaveForm.date_from}
|
||||
onChange={(val: string) => setLeaveForm({ ...leaveForm, date_to: val })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{leaveForm.date_from && leaveForm.date_to && (
|
||||
<div className="admin-form-group">
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
fontSize: '0.875rem',
|
||||
}}>
|
||||
<span>
|
||||
<strong>{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)}</strong>{' '}
|
||||
{(() => {
|
||||
const d = calculateBusinessDays(leaveForm.date_from, leaveForm.date_to)
|
||||
if (d === 1) return 'pracovní den'
|
||||
if (d >= 2 && d <= 4) return 'pracovní dny'
|
||||
return 'pracovních dnů'
|
||||
})()}
|
||||
</span>
|
||||
<span className="text-muted">
|
||||
{calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) * 8} hodin
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormField label="Poznámka">
|
||||
<textarea
|
||||
value={leaveForm.notes}
|
||||
onChange={(e) => setLeaveForm({ ...leaveForm, notes: e.target.value })}
|
||||
placeholder="Volitelná poznámka..."
|
||||
className="admin-form-textarea"
|
||||
rows={2}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLeaveModal(false)}
|
||||
className="admin-btn admin-btn-secondary"
|
||||
disabled={requestSubmitting}
|
||||
>
|
||||
Zrušit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRequestSubmit}
|
||||
disabled={requestSubmitting || calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
{requestSubmitting ? 'Odesílám...' : 'Odeslat žádost'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={gpsConfirm.show}
|
||||
onClose={() => { setGpsConfirm({ show: false, action: null }); setSubmitting(false) }}
|
||||
onConfirm={() => { setGpsConfirm({ show: false, action: null }); submitPunch(gpsConfirm.action!, {}) }}
|
||||
title="GPS nedostupná"
|
||||
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
|
||||
confirmText="Pokračovat"
|
||||
cancelText="Zrušit"
|
||||
type="warning"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user