461 lines
15 KiB
TypeScript
461 lines
15 KiB
TypeScript
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 FormField from "../components/FormField";
|
|
import { formatMinutes } from "../utils/attendanceHelpers";
|
|
|
|
interface UserTotalData {
|
|
name: string;
|
|
minutes: number;
|
|
working: boolean;
|
|
vacation_hours: number;
|
|
sick_hours: number;
|
|
holiday_hours: number;
|
|
unpaid_hours: number;
|
|
fund: number | null;
|
|
worked_hours: number;
|
|
covered: number;
|
|
missing: number;
|
|
overtime: number;
|
|
}
|
|
|
|
function getFundBarBackground(data: UserTotalData) {
|
|
if (data.overtime > 0)
|
|
return "linear-gradient(135deg, var(--warning), #d97706)";
|
|
if (data.covered >= (data.fund ?? 0))
|
|
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 />;
|
|
|
|
// Show skeleton only on initial load (no data yet), not on filter changes
|
|
const isInitialLoad =
|
|
loading &&
|
|
data.records.length === 0 &&
|
|
Object.keys(data.user_totals).length === 0;
|
|
|
|
if (isInitialLoad) {
|
|
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>
|
|
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
|
|
<div
|
|
className="admin-skeleton-line h-10"
|
|
style={{ width: "120px", borderRadius: "8px" }}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line h-10"
|
|
style={{ width: "120px", borderRadius: "8px" }}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line h-10"
|
|
style={{ width: "140px", borderRadius: "8px" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="admin-card">
|
|
<div
|
|
className="admin-skeleton"
|
|
style={{ gap: "0.75rem", padding: "1rem" }}
|
|
>
|
|
<div className="admin-skeleton-row">
|
|
<div
|
|
className="admin-skeleton-line h-10"
|
|
style={{ flex: 1, borderRadius: "8px" }}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line h-10"
|
|
style={{ flex: 1, borderRadius: "8px" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="admin-grid admin-grid-3">
|
|
{[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>
|
|
<div className="admin-card">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">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 mb-6"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.06 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<div className="admin-form-row">
|
|
<FormField label="Měsíc">
|
|
<AdminDatePicker
|
|
mode="month"
|
|
value={month}
|
|
onChange={(val: string) => setMonth(val)}
|
|
/>
|
|
</FormField>
|
|
<FormField label="Zaměstnanec">
|
|
<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>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* User Totals */}
|
|
{Object.keys(data.user_totals).length > 0 && (
|
|
<motion.div
|
|
className="admin-grid admin-grid-3 mb-6"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.09 }}
|
|
>
|
|
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
|
const ut = userData as UserTotalData;
|
|
return (
|
|
<div key={uid} className="admin-card">
|
|
<div className="admin-card-body">
|
|
<div className="flex-row gap-2 mb-2">
|
|
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
|
<span
|
|
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
|
|
>
|
|
{ut.working ? "\u2713" : "\u2717"}
|
|
</span>
|
|
</div>
|
|
<div className="admin-stat-value">
|
|
{formatMinutes(ut.minutes)}
|
|
</div>
|
|
<div className="admin-stat-label">odpracováno</div>
|
|
<div
|
|
style={{
|
|
marginTop: "0.5rem",
|
|
display: "flex",
|
|
flexWrap: "wrap",
|
|
gap: "0.25rem",
|
|
}}
|
|
>
|
|
{ut.vacation_hours > 0 && (
|
|
<span className="attendance-leave-badge badge-vacation">
|
|
Dov: {ut.vacation_hours}h
|
|
</span>
|
|
)}
|
|
{ut.sick_hours > 0 && (
|
|
<span className="attendance-leave-badge badge-sick">
|
|
Nem: {ut.sick_hours}h
|
|
</span>
|
|
)}
|
|
{ut.holiday_hours > 0 && (
|
|
<span className="attendance-leave-badge badge-holiday">
|
|
Sv: {ut.holiday_hours}h
|
|
</span>
|
|
)}
|
|
{ut.unpaid_hours > 0 && (
|
|
<span className="attendance-leave-badge badge-unpaid">
|
|
Nep: {ut.unpaid_hours}h
|
|
</span>
|
|
)}
|
|
</div>
|
|
{ut.fund !== null && (
|
|
<div className="mt-2">
|
|
<div
|
|
className="text-secondary"
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
fontSize: "0.8rem",
|
|
}}
|
|
>
|
|
<span>
|
|
Fond: {ut.worked_hours}h / {ut.fund}h
|
|
</span>
|
|
{ut.overtime > 0 && (
|
|
<span className="text-warning fw-600">
|
|
+{ut.overtime}h
|
|
</span>
|
|
)}
|
|
{ut.overtime <= 0 && ut.missing > 0 && (
|
|
<span className="text-danger fw-600">
|
|
-{ut.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, (ut.covered / (ut.fund || 1)) * 100)}%`,
|
|
background: getFundBarBackground(ut),
|
|
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: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.12 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<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}
|
|
editingRecord={null}
|
|
/>
|
|
|
|
<ShiftFormModal
|
|
mode="edit"
|
|
show={showEditModal && !!editingRecord}
|
|
onClose={() => setShowEditModal(false)}
|
|
onSubmit={handleEditSubmit}
|
|
form={editForm}
|
|
setForm={setEditForm}
|
|
projectLogs={editProjectLogs}
|
|
setProjectLogs={setEditProjectLogs}
|
|
projectList={projectList}
|
|
users={data.users}
|
|
onShiftDateChange={handleCreateShiftDateChange}
|
|
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>
|
|
);
|
|
}
|