Critical: - InvoiceDetail: sanitize notes HTML with DOMPurify - OrderDetail: use proper DOMPurify import instead of window fallback Important: - AttendanceBalances: add fund_to_date to interface, remove as-any casts - All schemas: replace z.any() with z.preprocess for boolean fields - Routes: simplify boolean coercion (Zod handles it now) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1011 lines
35 KiB
TypeScript
1011 lines
35 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
||
import { useAlert } from "../context/AlertContext";
|
||
import { useAuth } from "../context/AuthContext";
|
||
import Forbidden from "../components/Forbidden";
|
||
import { motion, AnimatePresence } from "framer-motion";
|
||
import ConfirmModal from "../components/ConfirmModal";
|
||
import useModalLock from "../hooks/useModalLock";
|
||
import FormField from "../components/FormField";
|
||
|
||
import apiFetch from "../utils/api";
|
||
const API_BASE = "/api/admin";
|
||
|
||
interface BalanceEntry {
|
||
name: string;
|
||
vacation_total: number;
|
||
vacation_used: number;
|
||
vacation_remaining: number;
|
||
sick_used: number;
|
||
}
|
||
|
||
interface UserShort {
|
||
id: number | string;
|
||
name: string;
|
||
}
|
||
|
||
interface FundUserData {
|
||
name: string;
|
||
worked: number;
|
||
covered: number;
|
||
overtime: number;
|
||
missing: number;
|
||
}
|
||
|
||
interface MonthFundData {
|
||
month_name: string;
|
||
fund: number;
|
||
fund_to_date: number;
|
||
business_days: number;
|
||
users?: Record<string, FundUserData>;
|
||
}
|
||
|
||
interface ProjectUser {
|
||
user_id: number;
|
||
user_name: string;
|
||
hours: number;
|
||
}
|
||
|
||
interface ProjectEntry {
|
||
project_id: number | null;
|
||
project_number?: string;
|
||
project_name?: string;
|
||
hours: number;
|
||
users: ProjectUser[];
|
||
}
|
||
|
||
interface MonthProjectData {
|
||
month_name: string;
|
||
projects: ProjectEntry[];
|
||
}
|
||
|
||
interface BalancesData {
|
||
users: UserShort[];
|
||
balances: Record<string, BalanceEntry>;
|
||
}
|
||
|
||
interface FundData {
|
||
months: Record<string, MonthFundData>;
|
||
holidays: unknown[];
|
||
users: UserShort[];
|
||
balances: Record<string, unknown>;
|
||
}
|
||
|
||
interface ProjectData {
|
||
months: Record<string, MonthProjectData>;
|
||
}
|
||
|
||
const getVacationClass = (remaining: number): string => {
|
||
if (remaining <= 0) return "text-danger";
|
||
if (remaining < 20) return "text-warning";
|
||
return "";
|
||
};
|
||
|
||
const renderFundDiff = (data: { overtime: number; missing: number }) => {
|
||
if (data.overtime > 0) {
|
||
return <span className="text-warning fw-600">+{data.overtime}h</span>;
|
||
}
|
||
if (data.missing > 0) {
|
||
return <span className="text-danger">-{data.missing}h</span>;
|
||
}
|
||
return <span className="text-success">0h</span>;
|
||
};
|
||
|
||
const renderMonthlyStatus = (
|
||
us: FundUserData,
|
||
isFulfilled: boolean,
|
||
isCurrentMonth: boolean,
|
||
) => {
|
||
if (us.overtime > 0) {
|
||
return (
|
||
<span className="text-warning fw-600" style={{ fontSize: "11px" }}>
|
||
+{us.overtime}h
|
||
</span>
|
||
);
|
||
}
|
||
if (us.missing > 0) {
|
||
return (
|
||
<span className="text-danger fw-600" style={{ fontSize: "11px" }}>
|
||
-{us.missing}h
|
||
</span>
|
||
);
|
||
}
|
||
if (isFulfilled && !isCurrentMonth) {
|
||
return (
|
||
<span className="text-success" style={{ fontSize: "11px" }}>
|
||
OK
|
||
</span>
|
||
);
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const getProgressBackground = (
|
||
us: FundUserData,
|
||
isFulfilled: boolean,
|
||
isCurrentMonth: boolean,
|
||
): string => {
|
||
if (us.overtime > 0)
|
||
return "linear-gradient(135deg, var(--warning), #d97706)";
|
||
if (isFulfilled) return "linear-gradient(135deg, var(--success), #059669)";
|
||
if (isCurrentMonth) return "var(--gradient)";
|
||
return "var(--danger)";
|
||
};
|
||
|
||
export default function AttendanceBalances() {
|
||
const alert = useAlert();
|
||
const { hasPermission } = useAuth();
|
||
const [loading, setLoading] = useState(true);
|
||
const [year, setYear] = useState(new Date().getFullYear());
|
||
const [data, setData] = useState<BalancesData>({
|
||
users: [],
|
||
balances: {},
|
||
});
|
||
|
||
const [fundLoading, setFundLoading] = useState(true);
|
||
const [fundData, setFundData] = useState<FundData>({
|
||
months: {},
|
||
holidays: [],
|
||
users: [],
|
||
balances: {},
|
||
});
|
||
|
||
const [projectLoading, setProjectLoading] = useState(true);
|
||
const [projectData, setProjectData] = useState<ProjectData>({ months: {} });
|
||
|
||
const [showEditModal, setShowEditModal] = useState(false);
|
||
const [editingUser, setEditingUser] = useState<{
|
||
id: string;
|
||
name: string;
|
||
} | null>(null);
|
||
const [editForm, setEditForm] = useState({
|
||
vacation_total: 160,
|
||
vacation_used: 0,
|
||
sick_used: 0,
|
||
});
|
||
|
||
const [resetConfirm, setResetConfirm] = useState<{
|
||
show: boolean;
|
||
userId: string | null;
|
||
userName: string;
|
||
}>({ show: false, userId: null, userName: "" });
|
||
|
||
const fetchData = useCallback(
|
||
async (showLoading = true) => {
|
||
if (showLoading) setLoading(true);
|
||
try {
|
||
const response = await apiFetch(
|
||
`${API_BASE}/attendance?action=balances&year=${year}`,
|
||
);
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
setData(result.data);
|
||
}
|
||
} catch {
|
||
alert.error("Nepodařilo se načíst data");
|
||
} finally {
|
||
if (showLoading) setLoading(false);
|
||
}
|
||
},
|
||
[year, alert],
|
||
);
|
||
|
||
const fetchFundData = useCallback(async () => {
|
||
setFundLoading(true);
|
||
try {
|
||
const response = await apiFetch(
|
||
`${API_BASE}/attendance?action=workfund&year=${year}`,
|
||
);
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
setFundData(result.data);
|
||
}
|
||
} catch {
|
||
// silent - fund data is supplementary
|
||
} finally {
|
||
setFundLoading(false);
|
||
}
|
||
}, [year]);
|
||
|
||
const fetchProjectData = useCallback(async () => {
|
||
setProjectLoading(true);
|
||
try {
|
||
const response = await apiFetch(
|
||
`${API_BASE}/attendance?action=project_report&year=${year}`,
|
||
);
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
setProjectData(result.data);
|
||
}
|
||
} catch {
|
||
// silent - project data is supplementary
|
||
} finally {
|
||
setProjectLoading(false);
|
||
}
|
||
}, [year]);
|
||
|
||
useEffect(() => {
|
||
fetchData();
|
||
fetchFundData();
|
||
fetchProjectData();
|
||
}, [fetchData, fetchFundData, fetchProjectData]);
|
||
|
||
useModalLock(showEditModal);
|
||
|
||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||
|
||
const openEditModal = (userId: string, balance: BalanceEntry) => {
|
||
setEditingUser({ id: userId, name: balance.name });
|
||
setEditForm({
|
||
vacation_total: balance.vacation_total,
|
||
vacation_used: balance.vacation_used,
|
||
sick_used: balance.sick_used,
|
||
});
|
||
setShowEditModal(true);
|
||
};
|
||
|
||
const handleEditSubmit = async () => {
|
||
if (!editingUser) return;
|
||
try {
|
||
const response = await apiFetch(
|
||
`${API_BASE}/attendance?action=balances`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
user_id: editingUser.id,
|
||
year,
|
||
action_type: "edit",
|
||
...editForm,
|
||
}),
|
||
},
|
||
);
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
setShowEditModal(false);
|
||
await fetchData(false);
|
||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||
alert.success(result.message);
|
||
} else {
|
||
alert.error(result.error);
|
||
}
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
}
|
||
};
|
||
|
||
const handleReset = async () => {
|
||
if (!resetConfirm.userId) return;
|
||
|
||
try {
|
||
const response = await apiFetch(
|
||
`${API_BASE}/attendance?action=balances`,
|
||
{
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
user_id: resetConfirm.userId,
|
||
year,
|
||
action_type: "reset",
|
||
}),
|
||
},
|
||
);
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
setResetConfirm({ show: false, userId: null, userName: "" });
|
||
await fetchData(false);
|
||
alert.success(result.message);
|
||
} else {
|
||
alert.error(result.error);
|
||
}
|
||
} catch {
|
||
alert.error("Chyba připojení");
|
||
}
|
||
};
|
||
|
||
const years: number[] = [];
|
||
const currentYear = new Date().getFullYear();
|
||
const currentMonth = new Date().getMonth() + 1;
|
||
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
|
||
years.push(y);
|
||
}
|
||
|
||
const getYearFundTotals = (userId: string) => {
|
||
if (!fundData.months || Object.keys(fundData.months).length === 0)
|
||
return null;
|
||
let totalFund = 0;
|
||
let totalWorked = 0;
|
||
let totalCovered = 0;
|
||
for (const monthData of Object.values(fundData.months)) {
|
||
// Use prorated fund (fund_to_date) for current month, full fund for past
|
||
totalFund += monthData.fund_to_date ?? monthData.fund;
|
||
const us = monthData.users?.[userId];
|
||
if (us) {
|
||
totalWorked += us.worked;
|
||
totalCovered += us.covered;
|
||
}
|
||
}
|
||
const missing = Math.max(
|
||
0,
|
||
Math.round((totalFund - totalCovered) * 10) / 10,
|
||
);
|
||
const overtime = Math.max(
|
||
0,
|
||
Math.round((totalCovered - totalFund) * 10) / 10,
|
||
);
|
||
return {
|
||
fund: totalFund,
|
||
worked: Math.round(totalWorked * 10) / 10,
|
||
covered: Math.round(totalCovered * 10) / 10,
|
||
missing,
|
||
overtime,
|
||
};
|
||
};
|
||
|
||
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 bilancí</h1>
|
||
</div>
|
||
<div className="admin-page-actions">
|
||
<select
|
||
value={year}
|
||
onChange={(e) => setYear(parseInt(e.target.value))}
|
||
className="admin-form-select"
|
||
style={{ minWidth: "100px" }}
|
||
>
|
||
{years.map((y) => (
|
||
<option key={y} value={y}>
|
||
{y}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</motion.div>
|
||
|
||
<motion.div
|
||
className="admin-card"
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25, delay: 0.06 }}
|
||
>
|
||
<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 && Object.keys(data.balances).length === 0 && (
|
||
<div className="admin-empty-state">
|
||
<p>Žádní uživatelé k zobrazení.</p>
|
||
</div>
|
||
)}
|
||
{!loading && Object.keys(data.balances).length > 0 && (
|
||
<div className="admin-table-responsive">
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Zaměstnanec</th>
|
||
<th>Nárok (h)</th>
|
||
<th>Čerpáno (h)</th>
|
||
<th>Zbývá (h)</th>
|
||
<th>Nemoc (h)</th>
|
||
<th>Fond roku</th>
|
||
<th>Pokryto</th>
|
||
<th>+/−</th>
|
||
<th>Akce</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{Object.entries(data.balances).map(([userId, balance]) => {
|
||
const yf = getYearFundTotals(userId);
|
||
return (
|
||
<tr key={userId}>
|
||
<td className="fw-500">{balance.name}</td>
|
||
<td className="admin-mono">{balance.vacation_total}</td>
|
||
<td className="admin-mono">
|
||
{balance.vacation_used.toFixed(1)}
|
||
</td>
|
||
<td className="admin-mono">
|
||
<span
|
||
className={getVacationClass(
|
||
balance.vacation_remaining,
|
||
)}
|
||
>
|
||
{balance.vacation_remaining.toFixed(1)}
|
||
</span>
|
||
</td>
|
||
<td className="admin-mono">
|
||
{balance.sick_used.toFixed(1)}
|
||
</td>
|
||
<td className="admin-mono">
|
||
{yf ? `${yf.fund}h` : "—"}
|
||
</td>
|
||
<td className="admin-mono">
|
||
{yf ? `${yf.covered}h` : "—"}
|
||
</td>
|
||
<td className="admin-mono">
|
||
{yf ? renderFundDiff(yf) : "—"}
|
||
</td>
|
||
<td>
|
||
<div className="admin-table-actions">
|
||
<button
|
||
onClick={() => openEditModal(userId, balance)}
|
||
className="admin-btn-icon"
|
||
title="Upravit"
|
||
aria-label="Upravit"
|
||
>
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
onClick={() =>
|
||
setResetConfirm({
|
||
show: true,
|
||
userId,
|
||
userName: balance.name,
|
||
})
|
||
}
|
||
className="admin-btn-icon danger"
|
||
title="Resetovat"
|
||
aria-label="Resetovat"
|
||
>
|
||
<svg
|
||
width="18"
|
||
height="18"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
>
|
||
<polyline points="3 6 5 6 21 6" />
|
||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
|
||
{/* Monthly Fund Overview */}
|
||
{!fundLoading &&
|
||
fundData.months &&
|
||
Object.keys(fundData.months).length > 0 && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25, delay: 0.12 }}
|
||
className="mt-6"
|
||
>
|
||
<h2
|
||
className="admin-page-title mb-4"
|
||
style={{ fontSize: "1.25rem" }}
|
||
>
|
||
Měsíční přehled fondu {year}
|
||
</h2>
|
||
<div className="admin-grid admin-grid-3">
|
||
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
|
||
const isCurrentMonth =
|
||
year === currentYear && parseInt(monthKey) === currentMonth;
|
||
return (
|
||
<div
|
||
key={monthKey}
|
||
className="admin-card"
|
||
style={
|
||
isCurrentMonth
|
||
? {
|
||
borderColor: "var(--accent-color)",
|
||
boxShadow: "0 0 0 1px var(--accent-color)",
|
||
}
|
||
: {}
|
||
}
|
||
>
|
||
<div className="admin-card-body">
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: "0.75rem",
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: "1rem",
|
||
margin: 0,
|
||
}}
|
||
>
|
||
{monthData.month_name}
|
||
{isCurrentMonth && (
|
||
<span
|
||
style={{
|
||
marginLeft: "0.5rem",
|
||
fontSize: "0.7rem",
|
||
padding: "0.125rem 0.375rem",
|
||
background: "var(--accent-light)",
|
||
color: "var(--accent-color)",
|
||
borderRadius: "var(--border-radius-sm)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
aktuální
|
||
</span>
|
||
)}
|
||
</h3>
|
||
<span
|
||
className="text-secondary"
|
||
style={{ fontSize: "12px" }}
|
||
>
|
||
{monthData.fund_to_date ?? monthData.fund}h (
|
||
{Math.round(
|
||
(monthData.fund_to_date ?? monthData.fund) / 8,
|
||
)}{" "}
|
||
dnů)
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "0.375rem",
|
||
}}
|
||
>
|
||
{fundData.users &&
|
||
fundData.users.map((user) => {
|
||
const us = monthData.users?.[String(user.id)];
|
||
if (!us) return null;
|
||
const effectiveFund =
|
||
monthData.fund_to_date ?? monthData.fund;
|
||
const pct =
|
||
effectiveFund > 0
|
||
? Math.min(
|
||
100,
|
||
(us.covered / effectiveFund) * 100,
|
||
)
|
||
: 0;
|
||
const isFulfilled = us.covered >= effectiveFund;
|
||
return (
|
||
<div key={user.id}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
fontSize: "12px",
|
||
}}
|
||
>
|
||
<span
|
||
style={{ color: "var(--text-primary)" }}
|
||
>
|
||
{us.name}
|
||
</span>
|
||
<span
|
||
style={{
|
||
display: "flex",
|
||
gap: "0.5rem",
|
||
alignItems: "center",
|
||
}}
|
||
>
|
||
<span className="text-secondary">
|
||
{us.worked}h
|
||
</span>
|
||
{renderMonthlyStatus(
|
||
us,
|
||
isFulfilled,
|
||
isCurrentMonth,
|
||
)}
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: "0.125rem",
|
||
height: "3px",
|
||
background: "var(--bg-tertiary)",
|
||
borderRadius: "2px",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
height: "100%",
|
||
width: `${pct}%`,
|
||
background: getProgressBackground(
|
||
us,
|
||
isFulfilled,
|
||
isCurrentMonth,
|
||
),
|
||
borderRadius: "2px",
|
||
transition: "width 0.3s ease",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{fundLoading && (
|
||
<div className="mt-6">
|
||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||
{[0, 1, 2].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>
|
||
)}
|
||
|
||
{/* Monthly Project Overview */}
|
||
{!projectLoading &&
|
||
projectData.months &&
|
||
Object.keys(projectData.months).length > 0 && (
|
||
<motion.div
|
||
initial={{ opacity: 0, y: 12 }}
|
||
animate={{ opacity: 1, y: 0 }}
|
||
transition={{ duration: 0.25, delay: 0.15 }}
|
||
className="mt-6"
|
||
>
|
||
<h2
|
||
className="admin-page-title mb-4"
|
||
style={{ fontSize: "1.25rem" }}
|
||
>
|
||
Měsíční přehled projektů {year}
|
||
</h2>
|
||
<div className="admin-grid admin-grid-3">
|
||
{Object.entries(projectData.months).map(
|
||
([monthKey, monthInfo]) => {
|
||
const isCurrentMonth =
|
||
year === currentYear && parseInt(monthKey) === currentMonth;
|
||
const totalHours = monthInfo.projects.reduce(
|
||
(sum, p) => sum + p.hours,
|
||
0,
|
||
);
|
||
if (monthInfo.projects.length === 0) return null;
|
||
return (
|
||
<div
|
||
key={monthKey}
|
||
className="admin-card"
|
||
style={
|
||
isCurrentMonth
|
||
? {
|
||
borderColor: "var(--accent-color)",
|
||
boxShadow: "0 0 0 1px var(--accent-color)",
|
||
}
|
||
: {}
|
||
}
|
||
>
|
||
<div className="admin-card-body">
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: "0.75rem",
|
||
}}
|
||
>
|
||
<h3
|
||
style={{
|
||
fontWeight: 600,
|
||
fontSize: "1rem",
|
||
margin: 0,
|
||
}}
|
||
>
|
||
{monthInfo.month_name}
|
||
{isCurrentMonth && (
|
||
<span
|
||
style={{
|
||
marginLeft: "0.5rem",
|
||
fontSize: "0.7rem",
|
||
padding: "0.125rem 0.375rem",
|
||
background: "var(--accent-light)",
|
||
color: "var(--accent-color)",
|
||
borderRadius: "var(--border-radius-sm)",
|
||
fontWeight: 500,
|
||
}}
|
||
>
|
||
aktuální
|
||
</span>
|
||
)}
|
||
</h3>
|
||
<span
|
||
className="text-secondary fw-600"
|
||
style={{ fontSize: "12px" }}
|
||
>
|
||
{totalHours.toFixed(1)}h
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "0.75rem",
|
||
}}
|
||
>
|
||
{monthInfo.projects.map((proj) => (
|
||
<div key={proj.project_id || "no-project"}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
marginBottom: "0.25rem",
|
||
}}
|
||
>
|
||
<span
|
||
style={{
|
||
fontSize: "12px",
|
||
fontWeight: 600,
|
||
color: "var(--text-primary)",
|
||
}}
|
||
>
|
||
{proj.project_id
|
||
? proj.project_number
|
||
: "Bez projektu"}
|
||
</span>
|
||
<span
|
||
className="text-secondary fw-600"
|
||
style={{ fontSize: "12px" }}
|
||
>
|
||
{proj.hours.toFixed(1)}h
|
||
</span>
|
||
</div>
|
||
{proj.project_id && proj.project_name && (
|
||
<div
|
||
className="text-muted"
|
||
style={{
|
||
fontSize: "0.7rem",
|
||
marginBottom: "0.25rem",
|
||
}}
|
||
>
|
||
{proj.project_name}
|
||
</div>
|
||
)}
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
flexDirection: "column",
|
||
gap: "0.125rem",
|
||
}}
|
||
>
|
||
{proj.users.map((u) => {
|
||
const pct =
|
||
proj.hours > 0
|
||
? Math.min(
|
||
100,
|
||
(u.hours / proj.hours) * 100,
|
||
)
|
||
: 0;
|
||
return (
|
||
<div key={u.user_id}>
|
||
<div
|
||
style={{
|
||
display: "flex",
|
||
justifyContent: "space-between",
|
||
alignItems: "center",
|
||
fontSize: "11px",
|
||
}}
|
||
>
|
||
<span className="text-secondary">
|
||
{u.user_name}
|
||
</span>
|
||
<span className="text-secondary">
|
||
{u.hours.toFixed(1)}h
|
||
</span>
|
||
</div>
|
||
<div
|
||
style={{
|
||
marginTop: "1px",
|
||
height: "3px",
|
||
background: "var(--bg-tertiary)",
|
||
borderRadius: "2px",
|
||
overflow: "hidden",
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
height: "100%",
|
||
width: `${pct}%`,
|
||
background: proj.project_id
|
||
? "var(--gradient)"
|
||
: "#94a3b8",
|
||
borderRadius: "2px",
|
||
transition: "width 0.3s ease",
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
|
||
{projectLoading && (
|
||
<div className="mt-6">
|
||
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
||
{[0, 1, 2].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>
|
||
)}
|
||
|
||
{/* Edit Modal */}
|
||
<AnimatePresence>
|
||
{showEditModal && editingUser && (
|
||
<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={() => setShowEditModal(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">Upravit dovolenou</h2>
|
||
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
|
||
{editingUser.name}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="admin-modal-body">
|
||
<div className="admin-form">
|
||
<FormField label="Nárok na dovolenou (hodiny)">
|
||
<input
|
||
type="number"
|
||
value={editForm.vacation_total}
|
||
onChange={(e) =>
|
||
setEditForm({
|
||
...editForm,
|
||
vacation_total: parseFloat(e.target.value),
|
||
})
|
||
}
|
||
min="0"
|
||
max="500"
|
||
step="1"
|
||
className="admin-form-input"
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label="Čerpáno dovolené (hodiny)">
|
||
<input
|
||
type="number"
|
||
value={editForm.vacation_used}
|
||
onChange={(e) =>
|
||
setEditForm({
|
||
...editForm,
|
||
vacation_used: parseFloat(e.target.value),
|
||
})
|
||
}
|
||
min="0"
|
||
max="500"
|
||
step="0.5"
|
||
className="admin-form-input"
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label="Čerpáno nemocenské (hodiny)">
|
||
<input
|
||
type="number"
|
||
value={editForm.sick_used}
|
||
onChange={(e) =>
|
||
setEditForm({
|
||
...editForm,
|
||
sick_used: parseFloat(e.target.value),
|
||
})
|
||
}
|
||
min="0"
|
||
max="500"
|
||
step="0.5"
|
||
className="admin-form-input"
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="admin-modal-footer">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEditModal(false)}
|
||
className="admin-btn admin-btn-secondary"
|
||
>
|
||
Zrušit
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleEditSubmit}
|
||
className="admin-btn admin-btn-primary"
|
||
>
|
||
Uložit
|
||
</button>
|
||
</div>
|
||
</motion.div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
{/* Reset Confirmation */}
|
||
<ConfirmModal
|
||
isOpen={resetConfirm.show}
|
||
onClose={() =>
|
||
setResetConfirm({ show: false, userId: null, userName: "" })
|
||
}
|
||
onConfirm={handleReset}
|
||
title="Resetovat bilanci"
|
||
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
|
||
confirmText="Resetovat"
|
||
confirmVariant="danger"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|