Files
app/src/admin/pages/AttendanceAdmin.jsx
Simon df506dfea4 refactor: P3 dekompozice velkych komponent
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>
2026-03-12 18:22:38 +01:00

302 lines
12 KiB
JavaScript

import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock'
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import { formatMinutes } from '../utils/attendanceHelpers'
function getFundBarBackground(data) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (data.covered >= data.fund) return 'linear-gradient(135deg, var(--success), #059669)'
return 'var(--gradient)'
}
export default function AttendanceAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
} = useAttendanceAdmin({ alert })
useModalLock(showBulkModal)
useModalLock(showEditModal)
useModalLock(showCreateModal)
if (!hasPermission('attendance.admin')) return <Forbidden />
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<div>
<h1 className="admin-page-title">Správa docházky</h1>
</div>
<div className="admin-page-actions">
{hasData && (
<>
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
</svg>
Tisk
</button>
</>
)}
<button
onClick={openBulkModal}
className="admin-btn admin-btn-secondary"
>
Vyplnit měsíc
</button>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat záznam
</button>
</div>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card"
style={{ marginBottom: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Měsíc</label>
<AdminDatePicker
mode="month"
value={month}
onChange={(val) => setMonth(val)}
/>
</div>
<div className="admin-form-group" style={{ marginBottom: 0 }}>
<label className="admin-form-label">Zaměstnanec</label>
<select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
))}
</select>
</div>
</div>
</div>
</motion.div>
{/* User Totals */}
{loading && (
<div className="admin-grid admin-grid-3" style={{ marginBottom: '1.5rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-card">
<div className="admin-card-body">
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
<div className="admin-skeleton-line w-1/2" />
<div className="admin-skeleton-line h-8" style={{ width: '80px' }} />
<div className="admin-skeleton-line w-1/3" style={{ height: '10px' }} />
<div className="admin-skeleton-line w-full" style={{ height: '4px' }} />
</div>
</div>
</div>
))}
</div>
)}
{!loading && Object.keys(data.user_totals).length > 0 && (
<motion.div
className="admin-grid admin-grid-3"
style={{ marginBottom: '1.5rem' }}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
>
{Object.entries(data.user_totals).map(([uid, userData]) => (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<span style={{ fontWeight: 600 }}>{userData.name}</span>
<span className={`attendance-working-badge ${userData.working ? 'working' : 'finished'}`}>
{userData.working ? '✓' : '✗'}
</span>
</div>
<div className="admin-stat-value">{formatMinutes(userData.minutes)}</div>
<div className="admin-stat-label">odpracováno</div>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
{userData.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">Dov: {userData.vacation_hours}h</span>
)}
{userData.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">Nem: {userData.sick_hours}h</span>
)}
{userData.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">Sv: {userData.holiday_hours}h</span>
)}
{userData.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">Nep: {userData.unpaid_hours}h</span>
)}
</div>
{userData.fund !== null && (
<div style={{ marginTop: '0.5rem' }}>
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem' }}>
<span>Fond: {userData.worked_hours}h / {userData.fund}h</span>
{userData.overtime > 0 && (
<span className="text-warning fw-600">+{userData.overtime}h</span>
)}
{userData.overtime <= 0 && userData.missing > 0 && (
<span className="text-danger fw-600">-{userData.missing}h</span>
)}
</div>
<div style={{
marginTop: '0.375rem',
height: '4px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${Math.min(100, (userData.covered / userData.fund) * 100)}%`,
background: getFundBarBackground(userData),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
</div>
</div>
)}
{data.leave_balances[uid] && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
Zbývá dovolené: {data.leave_balances[uid].vacation_remaining.toFixed(1)}h / {data.leave_balances[uid].vacation_total}h
</div>
)}
</div>
</div>
))}
</motion.div>
)}
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.2 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
)}
{!loading && (
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
)}
</div>
</motion.div>
{/* Modals */}
<BulkAttendanceModal
show={showBulkModal}
onClose={() => setShowBulkModal(false)}
form={bulkForm}
setForm={setBulkForm}
users={data.users}
onSubmit={handleBulkSubmit}
submitting={bulkSubmitting}
toggleUser={toggleBulkUser}
toggleAllUsers={toggleAllBulkUsers}
/>
<ShiftFormModal
mode="create"
show={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreateSubmit}
form={createForm}
setForm={setCreateForm}
projectLogs={createProjectLogs}
setProjectLogs={setCreateProjectLogs}
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
/>
<ShiftFormModal
mode="edit"
show={showEditModal && !!editingRecord}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
form={editForm}
setForm={setEditForm}
projectLogs={editProjectLogs}
setProjectLogs={setEditProjectLogs}
projectList={projectList}
editingRecord={editingRecord}
/>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message="Opravdu chcete smazat tento záznam docházky?"
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
}