diff --git a/src/admin/pages/Attendance.tsx b/src/admin/pages/Attendance.tsx
index 0194937..93c0102 100644
--- a/src/admin/pages/Attendance.tsx
+++ b/src/admin/pages/Attendance.tsx
@@ -1,23 +1,40 @@
import { useState, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
+import { Link as RouterLink } from "react-router-dom";
+import { motion } from "framer-motion";
+import { parseISO, isValid } from "date-fns";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
-import { Link } from "react-router-dom";
-import { motion } from "framer-motion";
-import AdminDatePicker from "../components/AdminDatePicker";
-import ConfirmModal from "../components/ConfirmModal";
-import FormModal from "../components/FormModal";
import {
formatTime,
calculateWorkMinutes,
formatMinutes,
} from "../utils/attendanceHelpers";
-import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { useApiMutation } from "../lib/queries/mutations";
import { czechPlural, todayLocalStr } from "../utils/formatters";
+import {
+ Button,
+ Card,
+ DataTable,
+ DateField,
+ Field,
+ Modal,
+ ConfirmDialog,
+ Select,
+ StatCard,
+ StatusChip,
+ TextField,
+ ProgressBar,
+ EmptyState,
+ LoadingState,
+ type DataColumn,
+ type ProgressColor,
+} from "../ui";
const API_BASE = "/api/admin";
@@ -78,12 +95,162 @@ interface AttendanceData {
active_project_id: number | null;
}
-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)";
+/** Maps the monthly-fund state to a ProgressBar color token. Mirrors the legacy
+ * getFundBarBackground gradient conditional: overtime→warning, covered≥fund→
+ * success, otherwise→primary (was the accent gradient). */
+function getFundBarColor(fund: MonthlyFund): ProgressColor {
+ if (fund.overtime > 0) return "warning";
+ if (fund.covered >= fund.fund) return "success";
+ return "primary";
+}
+
+const CalendarIcon = (
+
+);
+
+const VacationIcon = (
+
+);
+
+const SickIcon = (
+
+);
+
+const RequestsIcon = (
+
+);
+
+const HistoryIcon = (
+
+);
+
+const ManageIcon = (
+
+);
+
+const BalancesIcon = (
+
+);
+
+const ChevronIcon = (
+
+);
+
+/** Sidebar navigation row: icon + label on the left, chevron on the right. */
+function QuickLink({
+ to,
+ icon,
+ label,
+}: {
+ to: string;
+ icon: React.ReactNode;
+ label: string;
+}) {
+ return (
+
+ );
}
export default function Attendance() {
@@ -344,11 +511,7 @@ export default function Attendance() {
};
if (statusQuery.isPending) {
- return (
-
- );
+ return ;
}
if (!statusQuery.data) {
return null;
@@ -368,118 +531,312 @@ export default function Attendance() {
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8;
+ const businessDays = calculateBusinessDays(
+ leaveForm.date_from,
+ leaveForm.date_to,
+ );
+
+ const completedColumns: DataColumn[] = [
+ {
+ key: "arrival",
+ header: "Příchod",
+ mono: true,
+ render: (shift) => formatTime(shift.arrival_time),
+ },
+ {
+ key: "break",
+ header: "Pauza",
+ mono: true,
+ render: (shift) =>
+ shift.break_start && shift.break_end
+ ? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
+ : "—",
+ },
+ {
+ key: "departure",
+ header: "Odchod",
+ mono: true,
+ render: (shift) => formatTime(shift.departure_time),
+ },
+ {
+ key: "worked",
+ header: "Odpracováno",
+ mono: true,
+ render: (shift) =>
+ formatMinutes(
+ calculateWorkMinutes({
+ ...shift,
+ notes: shift.notes ?? undefined,
+ }),
+ true,
+ ),
+ },
+ ...(projects.length > 0
+ ? [
+ {
+ key: "projects",
+ header: "Projekty",
+ render: (shift: ShiftRecord) => {
+ const shiftLogs = shift.project_logs || [];
+ if (shiftLogs.length === 0) return "—";
+ return (
+
+ {shiftLogs.map((log, i) => {
+ if (!log.started_at) return null;
+ 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 (
+
+ {log.project_name || `#${log.project_id}`} ({h}:
+ {String(mm).padStart(2, "0")}h)
+
+ );
+ })}
+
+ );
+ },
+ } as DataColumn,
+ ]
+ : []),
+ ];
+
return (
-
+
-
-
Docházka
-
+
+ Docházka
+
{new Date().toLocaleDateString("cs-CZ", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
-
-
+
+
-
+
{/* Left Column - Clock In/Out */}
-
-
-
- {isOngoingShift ? (
- <>
-
- Pracuji
- >
- ) : (
- <>
-
- Nepracuji
- >
- )}
-
-
+
+
+
+ `0 0 0 4px ${theme.palette.success.main}33`
+ : "none",
+ }}
+ />
+
+ {isOngoingShift ? "Pracuji" : "Nepracuji"}
+
+
+
{new Date().toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
-
-
+
+
{isOngoingShift ? (
<>
-
-
-
- Příchod
-
- {formatTime(ongoingShift.arrival_time)}
-
-
-
- Pauza
-
- {ongoingShift.break_start
- ? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
- : "—"}
-
-
-
- Odchod
- —
-
-
-
+
+
+
+ Příchod
+
+
+ {formatTime(ongoingShift.arrival_time)}
+
+
+
+
+ Pauza
+
+
+ {ongoingShift.break_start
+ ? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
+ : "—"}
+
+
+
+
+ Odchod
+
+
+ —
+
+
+
{projects.length > 0 && (
-
-
- Projekt
- {activeProjectId ? (
-
- {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}`}
-
- ) : (
- Žádný
- )}
-
-
+
+ Projekt
+
+ {activeProjectId ? (
+
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}`
+ }
+ sx={{
+ height: "auto",
+ "& .MuiChip-label": {
+ whiteSpace: "normal",
+ py: 0.25,
+ },
+ }}
+ />
+ ) : (
+
+ Žádný
+
+ )}
+
+
+
)}
-
+
{!ongoingShift.break_start && (
-
+
)}
-
-
+
+
-
-
-
+
+
+
>
) : (
-
-
+ handlePunch("arrival")}
disabled={submitting}
- className="admin-btn admin-btn-primary w-full"
+ fullWidth
>
{submitting ? "Zpracovávám..." : "Příchod"}
-
+
- setIsLeaveModalOpen(true)}
- className="admin-btn admin-btn-secondary w-full"
+ variant="outlined"
+ color="inherit"
+ fullWidth
>
Žádost o nepřítomnost
-
-
+
+
)}
-
+
{/* Completed Today */}
{completedToday.length > 0 && (
-
-
-
Dnešní dokončené směny
-
-
-
-
-
-
- | Příchod |
- Pauza |
- Odchod |
- Odpracováno |
- {projects.length > 0 && Projekty | }
-
-
-
- {completedToday.map((shift) => {
- const shiftLogs = shift.project_logs || [];
- return (
-
- |
- {formatTime(shift.arrival_time)}
- |
-
- {shift.break_start && shift.break_end
- ? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
- : "—"}
- |
-
- {formatTime(shift.departure_time)}
- |
-
- {formatMinutes(
- calculateWorkMinutes({
- ...shift,
- notes: shift.notes ?? undefined,
- }),
- true,
- )}
- |
- {projects.length > 0 && (
-
- {shiftLogs.length > 0 ? (
-
- {shiftLogs.map((log, i) => {
- if (!log.started_at) return null;
- 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 (
-
- {log.project_name ||
- `#${log.project_id}`}{" "}
- ({h}:{String(mm).padStart(2, "0")}h)
-
- );
- })}
-
- ) : (
- "—"
- )}
- |
- )}
-
- );
- })}
-
-
-
-
-
+
+
+ Dnešní dokončené směny
+
+
+ columns={completedColumns}
+ rows={completedToday}
+ rowKey={(shift) => shift.id}
+ empty={}
+ />
+
)}
{/* Right Column - Stats & Quick Links */}
- {/* Leave Balance Card */}
-
-
- Dovolená {new Date().getFullYear()}
-
-
-
- {vacationDaysRemaining}
-
-
- {czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
- {vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
-
-
-
- Celkem: {leaveBalance.vacation_total}h
- Čerpáno: {leaveBalance.vacation_used}h
-
-
-
-
- {/* Monthly Fund Card */}
- {data.monthly_fund && (
-
-
-
-
-
-
-
- {data.monthly_fund.month_name}
-
-
- {data.monthly_fund.worked}h / {data.monthly_fund.fund}h
-
-
-
-
-
- Odpracováno: {data.monthly_fund.worked}h
- {data.monthly_fund.overtime > 0 ? (
-
- Přesčas: +{data.monthly_fund.overtime}h
-
- ) : (
- Zbývá: {data.monthly_fund.remaining}h
- )}
-
-
-
0 &&
+ ` ${vacationHoursRemaining}h`}
+
+ >
+ }
+ footer={
+
+
-
- {data.monthly_fund.leave_hours > 0 && (
-
- {"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.unpaid_hours > 0 &&
- ` + neplacené ${data.monthly_fund.unpaid_hours}h`}
- )
-
+
Celkem: {leaveBalance.vacation_total}h
+
Čerpáno: {leaveBalance.vacation_used}h
+
+
0
+ ? (leaveBalance.vacation_remaining /
+ leaveBalance.vacation_total) *
+ 100
+ : 0
+ }
+ color="success"
+ height={6}
+ />
+
+ }
+ />
+
+ {/* Monthly Fund Card */}
+ {data.monthly_fund && (
+
+
+ Odpracováno: {data.monthly_fund.worked}h
+ {data.monthly_fund.overtime > 0 ? (
+
+ Přesčas: +{data.monthly_fund.overtime}h
+
+ ) : (
+ Zbývá: {data.monthly_fund.remaining}h
+ )}
+
+ 0
+ ? Math.min(
+ 100,
+ (data.monthly_fund.covered /
+ data.monthly_fund.fund) *
+ 100,
+ )
+ : 0
+ }
+ color={getFundBarColor(data.monthly_fund)}
+ height={6}
+ />
+ {data.monthly_fund.leave_hours > 0 && (
+
+ {"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.unpaid_hours > 0 &&
+ ` + neplacené ${data.monthly_fund.unpaid_hours}h`}
+ )
+
+ )}
+
+ }
+ />
+ )}
+
+ {/* Sick Leave Card */}
+
+
+ {/* Quick Links */}
+
+
+ Rychlé odkazy
+
+
+
+
+ {hasPermission("attendance.manage") && (
+
)}
-
-
- )}
-
- {/* Sick Leave Card */}
-
-
-
-
- Nemoc {new Date().getFullYear()}
-
-
- {leaveBalance.sick_used}h čerpáno
-
-
-
-
- {/* Quick Links */}
-
-
Rychlé odkazy
-
-
-
Moje žádosti
-
-
-
-
-
Historie docházky
-
-
- {hasPermission("attendance.manage") && (
-
-
-
Správa docházky
-
-
- )}
- {hasPermission("attendance.balances") && (
-
-
-
Správa bilancí
-
-
- )}
-
+ {hasPermission("attendance.balances") && (
+
+ )}
+
+
+
-
+
{/* Leave Modal */}
-
setIsLeaveModalOpen(false)}
title="Žádost o nepřítomnost"
onSubmit={() => {
// Preserve original guard: do not submit when there are 0 business days.
- if (
- calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
- )
- return;
+ if (businessDays === 0) return;
handleRequestSubmit();
}}
- submitLabel="Odeslat žádost"
- submitDisabled={
- calculateBusinessDays(leaveForm.date_from, leaveForm.date_to) === 0
- }
+ submitText="Odeslat žádost"
+ submitDisabled={businessDays === 0}
loading={requestSubmitting}
>
-
-
-
-
+
+
-
+
+ {
+ setLeaveForm((prev) => ({
+ ...prev,
+ date_from: val,
+ date_to: prev.date_to < val ? val : prev.date_to,
+ }));
+ }}
+ />
+
+
+ setLeaveForm({ ...leaveForm, date_to: val })}
+ />
+
+
+
+ {leaveForm.date_from && leaveForm.date_to && (
+
-
- {
- setLeaveForm((prev) => ({
- ...prev,
- date_from: val,
- date_to: prev.date_to < val ? val : prev.date_to,
- }));
- }}
- />
-
-
-
- setLeaveForm({ ...leaveForm, date_to: val })
- }
- />
-
-
+
+ {businessDays}{" "}
+ {czechPlural(
+ businessDays,
+ "pracovní den",
+ "pracovní dny",
+ "pracovních dnů",
+ )}
+
+
+ {businessDays * 8} hodin
+
+
+ )}
- {leaveForm.date_from && leaveForm.date_to && (
-
-
-
-
- {calculateBusinessDays(
- leaveForm.date_from,
- leaveForm.date_to,
- )}
- {" "}
- {czechPlural(
- calculateBusinessDays(
- leaveForm.date_from,
- leaveForm.date_to,
- ),
- "pracovní den",
- "pracovní dny",
- "pracovních dnů",
- )}
-
-
- {calculateBusinessDays(
- leaveForm.date_from,
- leaveForm.date_to,
- ) * 8}{" "}
- hodin
-
-
-
- )}
+
+
+ setLeaveForm({ ...leaveForm, notes: e.target.value })
+ }
+ placeholder="Volitelná poznámka..."
+ />
+
+
-
-
-
-
-
-
{
setGpsConfirm({ isOpen: false, action: null });
@@ -1065,8 +1271,8 @@ export default function Attendance() {
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
confirmText="Pokračovat"
cancelText="Zrušit"
- type="warning"
+ confirmVariant="primary"
/>
-
+
);
}