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>
302 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|