v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config

- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones
- Remove skeleton.css and @keyframes shimmer from base.css
- Add <Skeleton> wrappers with fixtures to all 25+ page components
- Generate 20 bone captures via boneyard CLI (CDP auth-gated capture)
- Refactor data fetching from useEffect+useState to TanStack Query
- Extract query hooks into src/admin/lib/queries/ and apiAdapter
- Add usePaginatedQuery hook replacing useApiCall/useListData
- Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs
- Fix customer_id mandatory validation on offer creation
- Fix leave-requests comma-separated status filter (Prisma enum in: [])
- Add cross-entity cache invalidation for orders/offers/invoices/projects
- Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.)
- Add boneyard.config.json with routes and breakpoints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-04-28 22:35:43 +02:00
parent 12289bdce3
commit ba95723b61
109 changed files with 26410 additions and 10159 deletions

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
@@ -14,6 +15,9 @@ import {
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { Skeleton } from "boneyard-js/react";
import AttendanceFixture from "../fixtures/AttendanceFixture";
const API_BASE = "/api/admin";
@@ -92,22 +96,20 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [data, setData] = useState<AttendanceData>({
ongoing_shift: null,
today_shifts: [],
date: "",
leave_balance: {
vacation_total: 160,
vacation_used: 0,
vacation_remaining: 160,
sick_used: 0,
},
monthly_fund: null,
project_logs: [],
active_project_id: null,
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
});
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
@@ -117,10 +119,7 @@ export default function Attendance() {
});
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [notes, setNotes] = useState("");
const [projects, setProjects] = useState<Project[]>([]);
const [switchingProject, setSwitchingProject] = useState(false);
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{
isOpen: boolean;
action: string | null;
@@ -139,45 +138,12 @@ export default function Attendance() {
};
}, []);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/status`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setData(result.data);
setNotes(result.data.ongoing_shift?.notes || "");
setProjectLogs(result.data.project_logs || []);
setActiveProjectId(result.data.active_project_id || null);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
// Sync notes from query data when the shift changes
useEffect(() => {
if (statusQuery.data) {
setNotes(statusQuery.data.ongoing_shift?.notes || "");
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=projects`,
);
const result = await response.json();
if (result.success) {
const items = Array.isArray(result.data) ? result.data : [];
setProjects(items);
}
} catch {
// silent - projects are supplementary
}
};
loadProjects();
}, []);
}, [statusQuery.data]);
useModalLock(isLeaveModalOpen);
@@ -277,7 +243,9 @@ export default function Attendance() {
setSubmitting(false);
if (result.success) {
await fetchData();
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
@@ -302,7 +270,9 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await fetchData();
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
@@ -348,7 +318,9 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
await fetchData();
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
@@ -390,7 +362,9 @@ export default function Attendance() {
const result = await response.json();
if (result.success) {
setIsLeaveModalOpen(false);
await fetchData();
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
@@ -411,103 +385,15 @@ export default function Attendance() {
}
};
if (loading) {
if (!statusQuery.data) {
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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div style={{ display: "flex", gap: "1.5rem" }}>
<div className="admin-card" style={{ flex: 2 }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "120px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "180px" }}
/>
<div className="admin-skeleton-row">
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
</div>
</div>
</div>
<Skeleton
name="attendance"
loading={statusQuery.isPending}
fixture={<AttendanceFixture />}
>
<div />
</Skeleton>
);
}
@@ -515,7 +401,11 @@ export default function Attendance() {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
} = data;
} = statusQuery.data;
const data = statusQuery.data;
const projects = projectsQuery.data ?? [];
const projectLogs = data.project_logs ?? [];
const activeProjectId = data.active_project_id ?? null;
const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
const completedToday = todayShifts.filter((s) => s.departure_time);
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);

View File

@@ -11,6 +11,8 @@ import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers";
import { Skeleton } from "boneyard-js/react";
import AttendanceAdminFixture from "../fixtures/AttendanceAdminFixture";
interface UserTotalData {
name: string;
@@ -95,84 +97,13 @@ export default function AttendanceAdmin() {
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>
<Skeleton
name="attendance-admin"
loading={isInitialLoad}
fixture={<AttendanceAdminFixture />}
>
<div />
</Skeleton>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -6,8 +6,16 @@ import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
const API_BASE = "/api/admin";
interface BalanceEntry {
@@ -134,23 +142,20 @@ const getProgressBackground = (
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
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 { data: balancesRaw, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundRaw, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectRaw, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const balancesData = balancesRaw as BalancesData | undefined;
const fundData = fundRaw as FundData | undefined;
const projectData = projectRaw as ProjectData | undefined;
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{
@@ -169,67 +174,6 @@ export default function AttendanceBalances() {
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(() => {
const loadAll = async () => {
await Promise.all([fetchData(), fetchFundData(), fetchProjectData()]);
};
loadAll();
}, [fetchData, fetchFundData, fetchProjectData]);
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />;
@@ -265,8 +209,7 @@ export default function AttendanceBalances() {
if (result.success) {
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -297,7 +240,7 @@ export default function AttendanceBalances() {
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await fetchData(false);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -315,7 +258,7 @@ export default function AttendanceBalances() {
}
const getYearFundTotals = (userId: string) => {
if (!fundData.months || Object.keys(fundData.months).length === 0)
if (!fundData?.months || Object.keys(fundData.months).length === 0)
return null;
let totalFund = 0;
let totalWorked = 0;
@@ -380,132 +323,137 @@ export default function AttendanceBalances() {
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>
)}
<Skeleton
name="attendance-balances"
loading={balancesPending}
fixture={<AttendanceBalancesFixture />}
>
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
<div className="admin-empty-state">
<p>Žádní uživatelé k zobrazení.</p>
</div>
)}
{balancesData &&
Object.keys(balancesData.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(balancesData.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>
)}
</>
</Skeleton>
</div>
</motion.div>
{/* Monthly Fund Overview */}
{!fundLoading &&
fundData.months &&
{!fundPending &&
fundData?.months &&
Object.keys(fundData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -587,7 +535,7 @@ export default function AttendanceBalances() {
gap: "0.375rem",
}}
>
{fundData.users &&
{fundData?.users &&
fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)];
if (!us) return null;
@@ -668,23 +616,19 @@ export default function AttendanceBalances() {
</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>
{fundPending && (
<Skeleton
name="attendance-balances-fund"
loading={fundPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)}
{/* Monthly Project Overview */}
{!projectLoading &&
projectData.months &&
{!projectPending &&
projectData?.months &&
Object.keys(projectData.months).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
@@ -876,18 +820,14 @@ export default function AttendanceBalances() {
</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>
{projectPending && (
<Skeleton
name="attendance-balances-projects"
loading={projectPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
)}
{/* Edit Modal */}

View File

@@ -1,4 +1,6 @@
import { useState, useEffect } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -8,6 +10,8 @@ import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
const API_BASE = "/api/admin";
interface User {
@@ -35,9 +39,9 @@ export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData as unknown as User[] | undefined) ?? [];
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0];
@@ -58,26 +62,6 @@ export default function AttendanceCreate() {
};
});
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users`);
const result = await response.json();
if (result.success) {
setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
}
} catch {
alert.error("Nepodařilo se načíst uživatele");
} finally {
setLoading(false);
}
};
fetchUsers();
}, [alert]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -125,247 +109,223 @@ export default function AttendanceCreate() {
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="admin-skeleton-line h-8" style={{ width: "200px" }} />
</div>
<div className="admin-card" style={{ maxWidth: "600px" }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i}>
<div
className="admin-skeleton-line w-1/4"
style={{ marginBottom: "0.5rem", height: "10px" }}
/>
<div className="admin-skeleton-line w-full h-10" />
</div>
))}
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</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">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<Skeleton
name="attendance-create"
loading={loading}
fixture={<AttendanceCreateFixture />}
>
<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">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<motion.div
className="admin-card"
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<select
value={form.user_id}
value={form.leave_type}
onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
{!isWorkType && (
<FormField label="Počet hodin">
<input
type="number"
value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<select
value={form.leave_type}
onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
{!isWorkType && (
<FormField label="Počet hodin">
<input
type="number"
value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
<div className="admin-form-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
<div className="admin-form-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
</Skeleton>
);
}

View File

@@ -1,9 +1,11 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { attendanceHistoryOptions } from "../lib/queries/attendance";
import {
formatDate,
formatDatetime,
@@ -16,10 +18,8 @@ import {
formatTimeOrDatetimePrint,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
interface ProjectLog {
id?: number;
project_id?: number;
@@ -193,48 +193,21 @@ const renderProjectCell = (record: AttendanceRecord) => {
};
export default function AttendanceHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [companyName, setCompanyName] = useState("");
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) || "";
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [records, setRecords] = useState<AttendanceRecord[]>([]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [yearStr, monthStr] = month.split("-");
const response = await apiFetch(
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRecords(result.data);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanyName(d.data.company_name || "");
})
.catch(() => {});
}, []);
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = (data as AttendanceRecord[] | undefined) ?? [];
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split("-");
@@ -459,144 +432,123 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line"
style={{
width: "48px",
height: "48px",
borderRadius: "12px",
flexShrink: 0,
}}
/>
<div className="flex-1">
<div
className="admin-skeleton-line w-1/2"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-full"
style={{ height: "6px", borderRadius: "3px" }}
/>
<div
className="admin-skeleton-line w-1/3"
style={{ height: "10px", marginTop: "0.5rem" }}
/>
</div>
</div>
</div>
)}
{!loading && computed.monthlyFund && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<Skeleton
name="attendance-history-fund"
loading={isPending}
fixture={<AttendanceHistoryFixture />}
>
<>
{computed.monthlyFund && (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
className="text-muted"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
}}
>
<span>
{"Pokryto: "}
{computed.monthlyFund.covered}h (práce{" "}
{computed.monthlyFund.worked}h
{computed.vacationHours > 0 &&
` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 &&
` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 &&
` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 &&
` + neplacené ${computed.unpaidHours}h`}
)
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
</div>
</div>
</div>
)}
{!computed.monthlyFund && (
<div
className="text-muted"
style={{
display: "flex",
justifyContent: "space-between",
fontSize: "0.75rem",
marginTop: "0.375rem",
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
<span>
{"Pokryto: "}
{computed.monthlyFund.covered}h (práce{" "}
{computed.monthlyFund.worked}h
{computed.vacationHours > 0 &&
` + dovolená ${computed.vacationHours}h`}
{computed.sickHours > 0 &&
` + nemoc ${computed.sickHours}h`}
{computed.holidayHours > 0 &&
` + svátek ${computed.holidayHours}h`}
{computed.unpaidHours > 0 &&
` + neplacené ${computed.unpaidHours}h`}
)
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
Fond měsíce není k dispozici
</div>
</div>
</div>
)}
{!loading && !computed.monthlyFund && (
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
)}
</>
</Skeleton>
</div>
</motion.div>
@@ -608,91 +560,90 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<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" />
<Skeleton
name="attendance-history-table"
loading={isPending}
fixture={<AttendanceHistoryFixture />}
>
<>
{records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
))}
</div>
)}
{!loading && records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{!loading && records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
)}
{records.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.arrival_time)}
</td>
<td className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
</Skeleton>
</div>
</motion.div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -9,65 +10,35 @@ import L from "leaflet";
import "leaflet/dist/leaflet.css";
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface LocationRecord {
user_name: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
import {
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
import { Skeleton } from "boneyard-js/react";
import AttendanceLocationFixture from "../fixtures/AttendanceLocationFixture";
export default function AttendanceLocation() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(true);
const [record, setRecord] = useState<LocationRecord | null>(null);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<unknown>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (result.success) {
const raw = result.data.record || result.data;
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || "";
setRecord({ ...raw, user_name: userName });
} else {
alert.error("Záznam nebyl nalezen");
navigate("/attendance/admin");
}
} catch {
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
} finally {
setLoading(false);
}
};
const locationQuery = useQuery(attendanceLocationOptions(id));
const record = locationQuery.data ?? null;
const isPending = locationQuery.isPending;
fetchData();
}, [id, alert, navigate]);
// Navigate away on fetch error
useEffect(() => {
if (locationQuery.error) {
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
}
}, [locationQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!record || loading) return;
if (!record || isPending) return;
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
@@ -175,7 +146,7 @@ export default function AttendanceLocation() {
mapInstanceRef.current = null;
}
};
}, [record, loading]);
}, [record, isPending]);
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
@@ -185,56 +156,6 @@ export default function AttendanceLocation() {
if (!hasPermission("attendance.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
>
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.25rem",
}}
>
{[0, 1].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "50%" }}
/>
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
</div>
))}
</div>
</div>
);
}
if (!record) {
return null;
}
@@ -248,102 +169,70 @@ export default function AttendanceLocation() {
const month = shiftDateStr.substring(0, 7);
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">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</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-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
<Skeleton
name="attendance-location"
loading={isPending}
fixture={<AttendanceLocationFixture />}
>
<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">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<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-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Odchod</h3>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.departure_time
? formatDatetimeLocal(record.departure_time)
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
{hasDepartureLocation ? (
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
{record.departure_address || <em>Adresa nezjištěna</em>}
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
@@ -357,10 +246,48 @@ export default function AttendanceLocation() {
</div>
)}
</div>
)}
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
)}
</div>
</div>
</div>
</motion.div>
</div>
</motion.div>
</div>
</Skeleton>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
@@ -8,6 +9,8 @@ import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AuditLogFixture from "../fixtures/AuditLogFixture";
const API_BASE = "/api/admin";
@@ -77,13 +80,6 @@ interface AuditLogEntry {
user_ip: string | null;
}
interface PaginationData {
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface Filters {
search: string;
action: string;
@@ -95,9 +91,7 @@ interface Filters {
export default function AuditLog() {
const { hasPermission } = useAuth();
const alert = useAlert();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const queryClient = useQueryClient();
const [filters, setFilters] = useState<Filters>({
search: "",
action: "",
@@ -105,53 +99,57 @@ export default function AuditLog() {
date_from: "",
date_to: "",
});
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90);
const [cleaning, setCleaning] = useState(false);
const fetchLogs = useCallback(
async (page = 1, perPage = 50) => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
const { data: logsData, isPending } = useQuery({
queryKey: [
"audit-log",
{
search: filters.search,
action: filters.action,
entityType: filters.entity_type,
dateFrom: filters.date_from,
dateTo: filters.date_to,
page,
perPage,
},
],
queryFn: async () => {
const params = new URLSearchParams({
page: String(page),
per_page: String(perPage),
});
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
if (filters.search) params.set("search", filters.search);
if (filters.action) params.set("action", filters.action);
if (filters.entity_type) params.set("entity_type", filters.entity_type);
if (filters.date_from) params.set("date_from", filters.date_from);
if (filters.date_to) params.set("date_to", filters.date_to);
const response = await apiFetch(
`${API_BASE}/audit-log?${params.toString()}`,
);
const data = await response.json();
if (data.success) {
setLogs(Array.isArray(data.data) ? data.data : []);
setPagination({
total: data.pagination?.total ?? 0,
page: data.pagination?.page ?? 1,
per_page: data.pagination?.limit ?? 50,
total_pages: data.pagination?.total_pages ?? 1,
});
} else {
alert.error(data.error || "Nepodařilo se načíst audit log");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
const response = await apiFetch(
`${API_BASE}/audit-log?${params.toString()}`,
);
if (response.status === 401) throw new Error("Unauthorized");
const result = await response.json();
if (!result.success)
throw new Error(result.error || "Nepodařilo se načíst audit log");
return {
data: Array.isArray(result.data) ? result.data : [],
pagination: {
total: result.pagination?.total ?? 0,
page: result.pagination?.page ?? 1,
per_page: result.pagination?.limit ?? perPage,
total_pages: result.pagination?.total_pages ?? 1,
},
};
},
[filters, alert],
);
});
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
const logs = logsData?.data ?? [];
const pagination = logsData?.pagination ?? null;
if (!hasPermission("settings.audit")) {
return <Forbidden />;
@@ -159,14 +157,16 @@ export default function AuditLog() {
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1);
};
const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50);
setPage(newPage);
};
const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage);
setPage(1);
setPerPage(newPerPage);
};
const handleCleanup = async () => {
@@ -181,7 +181,7 @@ export default function AuditLog() {
if (data.success) {
alert.success(data.message);
setShowCleanup(false);
fetchLogs();
queryClient.invalidateQueries({ queryKey: ["audit-log"] });
} else {
alert.error(data.error);
}
@@ -197,66 +197,15 @@ export default function AuditLog() {
return new Date(dateString).toLocaleString("cs-CZ");
};
if (loading && logs.length === 0) {
if (isPending && logs.length === 0) {
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: "160px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "100px" }} />
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton"
style={{ gap: "0.75rem", padding: "1rem" }}
>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "4px" }}
/>
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "120px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "70px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line flex-1" />
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
</div>
))}
</div>
</div>
</div>
<Skeleton
name="audit-log"
loading={isPending && logs.length === 0}
fixture={<AuditLogFixture />}
>
<div />
</Skeleton>
);
}
@@ -454,98 +403,123 @@ export default function AuditLog() {
</tr>
</thead>
<tbody>
{loading &&
Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td>
<Skeleton
name="audit-log-rows"
loading={isPending}
fixture={
<div style={{ padding: "1rem" }}>
{Array.from({ length: 10 }, (_, i) => (
<div
className="admin-skeleton-line"
style={{ width: "110px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
key={i}
style={{
width: "70px",
height: "22px",
borderRadius: "10px",
display: "flex",
gap: "1rem",
marginBottom: "0.75rem",
}}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "80px", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "60%", height: "14px" }}
/>
</td>
<td>
<div
className="admin-skeleton-line"
style={{ width: "90px", height: "14px" }}
/>
</td>
</tr>
))}
{!loading && logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td>
</tr>
)}
{!loading &&
logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">
{formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
<div
style={{
width: 110,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
<div
style={{
width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
<div
style={{
width: 70,
height: 22,
background: "var(--bg-tertiary)",
borderRadius: 10,
}}
/>
<div
style={{
width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
<div
style={{
flex: 1,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
<div
style={{
width: 90,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</div>
))}
</div>
}
>
<>
{logs.length === 0 && (
<tr>
<td colSpan={6}>
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<p>Žádné záznamy k zobrazení</p>
</div>
</td>
</tr>
)}
{logs.length > 0 &&
logs.map((log) => (
<tr key={log.id}>
<td className="admin-mono">
{formatDatetime(log.created_at)}
</td>
<td className="fw-500">{log.username || "-"}</td>
<td>
<span
className={`admin-badge ${ACTION_BADGE_CLASS[log.action] || "admin-badge-secondary"}`}
>
{ACTION_LABELS[log.action] || log.action}
</span>
</td>
<td>
{ENTITY_TYPE_LABELS[log.entity_type || ""] ||
log.entity_type ||
"-"}
</td>
<td>{log.description || "-"}</td>
<td className="admin-mono">{log.user_ip || "-"}</td>
</tr>
))}
</>
</Skeleton>
</tbody>
</table>
</div>

View File

@@ -1,12 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import FormField from "../components/FormField";
import ConfirmModal from "../components/ConfirmModal";
import { companySettingsOptions } from "../lib/queries/settings";
import { bankAccountsOptions } from "../lib/queries/common";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import CompanySettingsFixture from "../fixtures/CompanySettingsFixture";
const API_BASE = "/api/admin";
const DEFAULT_FIELD_ORDER = [
@@ -68,7 +73,7 @@ export default function CompanySettings({
}: { embedded?: boolean } = {}) {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingLogoDark, setUploadingLogoDark] = useState(false);
@@ -89,14 +94,12 @@ export default function CompanySettings({
const [fieldOrder, setFieldOrder] = useState<string[]>([
...DEFAULT_FIELD_ORDER,
]);
const [bankAccounts, setBankAccounts] = useState<BankAccount[]>([]);
const [availableCurrencies, setAvailableCurrencies] = useState<string[]>([
"CZK",
"EUR",
"USD",
"GBP",
]);
const [bankLoading, setBankLoading] = useState(true);
const [bankSaving, setBankSaving] = useState(false);
const [editingBank, setEditingBank] = useState<number | null>(null);
const [bankDeleteConfirm, setBankDeleteConfirm] = useState<{
@@ -182,84 +185,63 @@ export default function CompanySettings({
}
}, []);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/company-settings`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const d = result.data;
setForm({
company_name: d.company_name || "",
street: d.street || "",
city: d.city || "",
postal_code: d.postal_code || "",
country: d.country || "",
company_id: d.company_id || "",
vat_id: d.vat_id || "",
});
const cf =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? d.custom_fields.map(
(
f: {
name: string;
value: string;
showLabel?: boolean;
_key?: string;
},
i: number,
) => ({
...f,
_key: f._key || `cf-${Date.now()}-${i}`,
}),
)
: [];
setCustomFields(cf);
if (
Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0
) {
setFieldOrder(d.supplier_field_order);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies);
}
if (d.has_logo) {
fetchLogo("light");
}
if (d.has_logo_dark) {
fetchLogo("dark");
}
} else {
alert.error(result.error || "Nepodařilo se načíst nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert, fetchLogo]);
// ── TanStack Query: company settings ──
const { data: settingsData, isPending: settingsLoading } = useQuery(
companySettingsOptions(),
);
const fetchBankAccounts = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/bank-accounts`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setBankAccounts(result.data);
}
} catch {
// ignore
} finally {
setBankLoading(false);
// ── TanStack Query: bank accounts ──
const { data: bankAccountsData, isPending: bankLoading } = useQuery(
bankAccountsOptions(),
);
const bankAccountsList: BankAccount[] = Array.isArray(bankAccountsData)
? (bankAccountsData as unknown as BankAccount[])
: [];
// Populate form state when settings data arrives
useEffect(() => {
if (!settingsData) return;
const d = settingsData as Record<string, unknown>;
setForm({
company_name: (d.company_name as string) || "",
street: (d.street as string) || "",
city: (d.city as string) || "",
postal_code: (d.postal_code as string) || "",
country: (d.country as string) || "",
company_id: (d.company_id as string) || "",
vat_id: (d.vat_id as string) || "",
});
const cf: CustomField[] =
Array.isArray(d.custom_fields) && d.custom_fields.length > 0
? (d.custom_fields as CustomField[]).map((f, i) => ({
...f,
showLabel: f.showLabel !== false,
_key: f._key || `cf-${Date.now()}-${i}`,
}))
: [];
setCustomFields(cf);
if (
Array.isArray(d.supplier_field_order) &&
d.supplier_field_order.length > 0
) {
setFieldOrder(d.supplier_field_order as string[]);
} else {
setFieldOrder([...DEFAULT_FIELD_ORDER]);
}
}, []);
if (
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
) {
setAvailableCurrencies(d.available_currencies as string[]);
}
if (d.has_logo) {
fetchLogo("light");
}
if (d.has_logo_dark) {
fetchLogo("dark");
}
}, [settingsData, fetchLogo]);
const resetBankForm = () => {
setEditingBank(null);
@@ -294,7 +276,7 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message);
resetBankForm();
fetchBankAccounts();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
@@ -322,7 +304,7 @@ export default function CompanySettings({
if (result.success) {
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
fetchBankAccounts();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
@@ -346,11 +328,6 @@ export default function CompanySettings({
});
};
useEffect(() => {
fetchData();
fetchBankAccounts();
}, [fetchData, fetchBankAccounts]);
// Cleanup blob URLs on unmount
useEffect(() => {
return () => {
@@ -377,6 +354,7 @@ export default function CompanySettings({
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -411,6 +389,7 @@ export default function CompanySettings({
const result = await response.json();
if (result.success) {
alert.success(result.message || "Logo bylo nahráno");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
fetchLogo(variant);
} else {
alert.error(result.error || "Nepodařilo se nahrát logo");
@@ -429,50 +408,15 @@ export default function CompanySettings({
if (!embedded && !hasPermission("settings.manage")) return <Forbidden />;
if (loading) {
if (settingsLoading) {
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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "1.25rem",
}}
>
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "60%" }}
/>
{[0, 1, 2].map((j) => (
<div key={j} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/2" />
</div>
))}
</div>
</div>
))}
</div>
</div>
<Skeleton
name="company-settings"
loading={settingsLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
);
}
@@ -774,18 +718,16 @@ export default function CompanySettings({
</div>
<div className="admin-card-body">
{bankLoading ? (
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/3" />
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
<Skeleton
name="company-settings-bank"
loading={bankLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
) : (
<>
{bankAccounts.length > 0 && (
{bankAccountsList.length > 0 && (
<div className="admin-table-responsive mb-4">
<table className="admin-table">
<thead>
@@ -801,7 +743,7 @@ export default function CompanySettings({
</tr>
</thead>
<tbody>
{bankAccounts.map((acc) => (
{bankAccountsList.map((acc) => (
<tr
key={acc.id}
style={

View File

@@ -1,10 +1,13 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useCallback } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
@@ -12,6 +15,8 @@ import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
import { Skeleton } from "boneyard-js/react";
import DashboardFixture from "../fixtures/DashboardFixture";
const API_BASE = "/api/admin";
@@ -69,13 +74,17 @@ export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [dashData, setDashData] = useState<DashData | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [punching, setPunching] = useState(false);
const queryClient = useQueryClient();
const { data: dashDataRaw, isPending: dashLoading } =
useQuery(dashboardOptions());
const dashData = dashDataRaw as DashData | undefined;
const { data: totpData, isPending: totpLoading } =
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
// 2FA state - sdileny mezi profilem a bannerem
const [totpEnabled, setTotpEnabled] = useState(false);
const [totpLoading, setTotpLoading] = useState(true);
const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
@@ -88,46 +97,6 @@ export default function Dashboard() {
useModalLock(show2FASetup);
useModalLock(show2FADisable);
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`);
const data = await response.json();
if (data.success !== false) {
setDashData(data.data || data);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error("Dashboard fetch error:", err);
}
} finally {
setDashLoading(false);
}
}, []);
useEffect(() => {
fetchDashboard();
}, [fetchDashboard]);
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpEnabled(!!user?.totpEnabled);
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled);
} finally {
setTotpLoading(false);
}
}, [user?.totpEnabled]);
useEffect(() => {
fetch2FAStatus();
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = useCallback(() => {
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
@@ -143,7 +112,7 @@ export default function Dashboard() {
const result = await response.json();
if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána");
fetchDashboard();
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
@@ -167,7 +136,7 @@ export default function Dashboard() {
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
}, [dashData, alert, fetchDashboard]);
}, [dashData, alert, queryClient]);
// 2FA handlery
const handleStart2FASetup = async () => {
@@ -202,7 +171,7 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
setTotpEnabled(true);
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
@@ -230,7 +199,7 @@ export default function Dashboard() {
});
const data = await response.json();
if (data.success) {
setTotpEnabled(false);
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
@@ -337,62 +306,13 @@ export default function Dashboard() {
{/* Skeleton loading */}
{dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line h-24"
style={{ borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-quick-actions">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "10px" }}
/>
))}
</div>
<div className="dash-main-grid">
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "320px", borderRadius: "10px" }}
/>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1.25rem",
}}
>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "150px", borderRadius: "10px" }}
/>
</div>
</div>
<div className="dash-bottom">
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
</div>
</div>
<Skeleton
name="dashboard"
loading={dashLoading}
fixture={<DashboardFixture />}
>
<div />
</Skeleton>
)}
{/* KPI cards — only show if user has any admin-level permissions */}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,5 @@
import {
useState,
useEffect,
useCallback,
useRef,
lazy,
Suspense,
} from "react";
import { useState, useEffect, useRef, lazy, Suspense } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link, useSearchParams } from "react-router-dom";
@@ -17,8 +11,18 @@ import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import {
invoiceListOptions,
invoiceStatsOptions,
type Invoice,
type InvoiceStats,
type CurrencyAmount,
} from "../lib/queries/invoices";
import Pagination from "../components/Pagination";
import { Skeleton } from "boneyard-js/react";
import InvoicesFixture from "../fixtures/InvoicesFixture";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const ReceivedInvoices = lazy(() => import("./ReceivedInvoices"));
const API_BASE = "/api/admin";
@@ -39,11 +43,6 @@ const MONTH_NAMES = [
"prosinec",
];
interface CurrencyAmount {
amount: number;
currency: string;
}
function formatMultiCurrency(amounts: CurrencyAmount[]): string {
if (!Array.isArray(amounts) || amounts.length === 0) return "0 Kč";
return amounts.map((a) => formatCurrency(a.amount, a.currency)).join(" · ");
@@ -84,31 +83,6 @@ const STATUS_FILTERS = [
{ value: "overdue", label: "Po splatnosti" },
];
interface Invoice {
id: number;
invoice_number: string;
customer_name: string | null;
status: string;
issue_date: string;
due_date: string;
total: number;
currency: string;
}
interface InvoiceStats {
paid_month: CurrencyAmount[];
paid_month_czk: number;
paid_month_count: number;
awaiting: CurrencyAmount[];
awaiting_czk: number;
awaiting_count: number;
overdue: CurrencyAmount[];
overdue_czk: number;
overdue_count: number;
vat_month: CurrencyAmount[];
vat_month_czk: number;
}
interface DraftData {
form: Record<string, unknown>;
items: Record<string, unknown>[];
@@ -134,8 +108,6 @@ export default function Invoices() {
const now = new Date();
const [statsMonth, setStatsMonth] = useState(now.getMonth() + 1);
const [statsYear, setStatsYear] = useState(now.getFullYear());
const [stats, setStats] = useState<InvoiceStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobUrlRef = useRef<string | null>(null);
@@ -154,28 +126,15 @@ export default function Invoices() {
statsMonth === now.getMonth() + 1 && statsYear === now.getFullYear();
const monthLabel = `${MONTH_NAMES[statsMonth - 1]} ${statsYear}`;
const fetchStats = useCallback(async () => {
setStatsLoading(true);
try {
const res = await apiFetch(
`${API_BASE}/invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
} catch {
/* ignore */
} finally {
setStatsLoading(false);
}
}, [statsMonth, statsYear]);
const statsQuery = useQuery(invoiceStatsOptions(statsMonth, statsYear));
const stats = statsQuery.data ?? null;
useEffect(() => {
fetchStats();
}, [fetchStats]);
if (statsQuery.data) {
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
}, [statsQuery.data]);
const prevMonth = () => {
slideDirection.current = -1;
@@ -225,24 +184,23 @@ export default function Invoices() {
setDraft(null);
};
const queryClient = useQueryClient();
const {
items: invoices,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData<Invoice>("invoices", {
search,
sort,
order,
page,
extraParams: {
month: String(statsMonth),
year: String(statsYear),
...(statusFilter ? { status: statusFilter } : {}),
},
errorMsg: "Nepodařilo se načíst faktury",
});
isPending: initialLoad,
isFetching: loading,
} = usePaginatedQuery<Invoice>(
invoiceListOptions({
search,
sort,
order,
page,
month: statsMonth,
year: statsYear,
status: statusFilter || undefined,
}),
);
if (!hasPermission("invoices.view")) return <Forbidden />;
@@ -260,8 +218,8 @@ export default function Invoices() {
if (result.success) {
setDeleteConfirm({ show: false, invoice: null });
alert.success(result.message || "Faktura byla smazána");
fetchData();
fetchStats();
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
@@ -283,8 +241,8 @@ export default function Invoices() {
const data = await res.json();
if (data.success) {
alert.success("Faktura označena jako zaplacená");
fetchData();
fetchStats();
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
@@ -323,81 +281,13 @@ export default function Invoices() {
if (initialLoad) {
return (
<div>
<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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-kpi-grid admin-kpi-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="admin-skeleton-row">
<div
className="admin-skeleton-line"
style={{ width: "80px" }}
/>
<div className="admin-skeleton-line w-1/4" />
<div
className="admin-skeleton-line"
style={{ width: "70px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100px" }}
/>
</div>
))}
</div>
</div>
</div>
</div>
<Skeleton
name="invoices"
loading={initialLoad}
fixture={<InvoicesFixture />}
>
<div />
</Skeleton>
);
}
@@ -528,35 +418,13 @@ export default function Invoices() {
>
<Suspense
fallback={
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
<Skeleton
name="invoices-received-kpi"
loading={true}
fixture={<ReceivedInvoicesFixture />}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div />
</Skeleton>
}
>
<ReceivedInvoices
@@ -574,36 +442,14 @@ export default function Invoices() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
>
{!hasLoadedOnce.current && statsLoading ? (
<div
className="admin-kpi-grid admin-kpi-4"
style={{ marginBottom: "1.5rem" }}
{statsQuery.isPending && !hasLoadedOnce.current ? (
<Skeleton
name="invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<InvoicesFixture />}
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<div />
</Skeleton>
) : (
stats && (
<div style={{ overflow: "hidden", marginBottom: "1.5rem" }}>

View File

@@ -1,14 +1,21 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { motion, AnimatePresence } from "framer-motion";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import { czechPlural } from "../utils/formatters";
import {
leavePendingOptions,
leaveProcessedOptions,
} from "../lib/queries/leave";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import LeaveApprovalFixture from "../fixtures/LeaveApprovalFixture";
const API_BASE = "/api/admin";
@@ -101,15 +108,24 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
export default function LeaveApproval() {
const { hasPermission } = useAuth();
const alert = useAlert();
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending",
);
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
[],
const { data: pendingData, isPending: loading } = useQuery(
leavePendingOptions(),
);
const { data: processedData } = useQuery({
...leaveProcessedOptions(),
enabled: activeTab === "processed",
});
const pendingRequests =
(pendingData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ?? [];
const pendingCount = pendingRequests.length;
const processedRequests =
(processedData as RawLeaveRequest[] | undefined)?.map(mapLeaveRequest) ??
[];
const [approveModal, setApproveModal] = useState<{
open: boolean;
request: LeaveRequest | null;
@@ -123,67 +139,6 @@ export default function LeaveApproval() {
useModalLock(rejectModal.open);
const fetchPending = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/leave-requests?status=pending`,
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const mapped = (result.data as RawLeaveRequest[]).map(mapLeaveRequest);
setPendingRequests(mapped);
setPendingCount(result.pagination?.total ?? mapped.length);
}
} catch {
alert.error("Nepodařilo se načíst žádosti");
}
}, [alert]);
const fetchProcessed = useCallback(async () => {
try {
const response = await apiFetch(
`${API_BASE}/leave-requests?status=approved`,
);
if (response.status === 401) return;
const resultApproved = await response.json();
const response2 = await apiFetch(
`${API_BASE}/leave-requests?status=rejected`,
);
if (response2.status === 401) return;
const resultRejected = await response2.json();
const all = [
...(resultApproved.success
? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
...(resultRejected.success
? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
].sort(
(a: LeaveRequest, b: LeaveRequest) =>
(b.reviewed_at ? new Date(b.reviewed_at).getTime() : 0) -
(a.reviewed_at ? new Date(a.reviewed_at).getTime() : 0),
);
setProcessedRequests(all);
} catch {
alert.error("Nepodařilo se načíst vyřízené žádosti");
}
}, [alert]);
useEffect(() => {
setLoading(true);
fetchPending().finally(() => setLoading(false));
}, [fetchPending]);
useEffect(() => {
if (activeTab === "processed" && processedRequests.length === 0) {
fetchProcessed();
}
}, [activeTab, processedRequests.length, fetchProcessed]);
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
@@ -202,8 +157,7 @@ export default function LeaveApproval() {
const result = await response.json();
if (result.success) {
setApproveModal({ open: false, request: null });
await fetchPending();
setProcessedRequests([]);
await queryClient.invalidateQueries({ queryKey: ["leave"] });
alert.success("Žádost byla schválena");
} else {
alert.error(result.error);
@@ -240,8 +194,7 @@ export default function LeaveApproval() {
if (result.success) {
setRejectModal({ open: false, request: null });
setRejectNote("");
await fetchPending();
setProcessedRequests([]);
await queryClient.invalidateQueries({ queryKey: ["leave"] });
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error);
@@ -253,402 +206,378 @@ export default function LeaveApproval() {
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
return (
<Skeleton
name="leave-approval"
loading={loading}
fixture={<LeaveApprovalFixture />}
>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
);
}
</motion.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">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</div>
</motion.div>
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-tabs mb-6">
<button
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")}
>
Ke schválení
{pendingCount > 0 && (
<span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount}
</span>
)}
</button>
<button
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
</div>
</motion.div>
{/* Pending Tab */}
{activeTab === "pending" && (
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted mb-4"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
<div className="admin-tabs mb-6">
<button
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")}
>
Ke schválení
{pendingCount > 0 && (
<span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount}
</span>
)}
</button>
<button
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
</div>
</motion.div>
{/* Pending Tab */}
{activeTab === "pending" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted mb-4"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
</div>
</div>
</div>
</div>
) : (
<div
style={{ display: "flex", flexDirection: "column", gap: "1rem" }}
>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
<div
className="admin-card-body"
style={{ padding: "1.25rem" }}
>
) : (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: "1rem",
}}
className="admin-card-body"
style={{ padding: "1.25rem" }}
>
<div className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: "1rem" }}>
{req.employee_name}
</strong>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</div>
<div
className="text-secondary"
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
fontSize: "0.875rem",
}}
>
<span>
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</span>
<span>
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")} (
{req.total_hours}h)
</span>
<span className="text-muted">
Podáno: {formatDatetime(req.created_at)}
</span>
</div>
{req.notes && (
<div
className="text-secondary"
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
fontStyle: "italic",
}}
>
{req.notes}
</div>
)}
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: "1rem",
}}
>
<button
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
<div className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: "1rem" }}>
{req.employee_name}
</strong>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] ||
req.leave_type}
</span>
</div>
<div
className="text-secondary"
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
fontSize: "0.875rem",
}}
>
<span>
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</span>
<span>
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")}{" "}
({req.total_hours}h)
</span>
<span className="text-muted">
Podáno: {formatDatetime(req.created_at)}
</span>
</div>
{req.notes && (
<div
className="text-secondary"
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
fontStyle: "italic",
}}
>
{req.notes}
</div>
)}
</div>
<div
style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
display: "flex",
gap: "0.5rem",
flexShrink: 0,
}}
>
Schválit
</button>
<button
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
<button
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
>
Schválit
</button>
<button
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</motion.div>
)}
{/* Processed Tab */}
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td>
<strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : (
"—"
)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
))}
</div>
)}
</div>
</motion.div>
)}
{/* Approve Confirmation */}
<ConfirmModal
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={
approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
: ""
}
confirmText="Schválit"
type="info"
loading={processing}
/>
{/* Reject Modal */}
<AnimatePresence>
{rejectModal.open && (
<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={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
/>
<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">Zamítnout žádost</h2>
</div>
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
Zrušit
</button>
<button
type="button"
onClick={handleReject}
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? "Zpracování..." : "Zamítnout"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Processed Tab */}
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td>
<strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] ||
req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : (
"—"
)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
)}
{/* Approve Confirmation */}
<ConfirmModal
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={
approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
: ""
}
confirmText="Schválit"
type="info"
loading={processing}
/>
{/* Reject Modal */}
<AnimatePresence>
{rejectModal.open && (
<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={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
/>
<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">Zamítnout žádost</h2>
</div>
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
Zrušit
</button>
<button
type="button"
onClick={handleReject}
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? "Zpracování..." : "Zamítnout"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</Skeleton>
);
}

View File

@@ -1,11 +1,15 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import LeaveRequestsFixture from "../fixtures/LeaveRequestsFixture";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions } from "../lib/queries/leave";
const API_BASE = "/api/admin";
@@ -51,33 +55,16 @@ interface LeaveRequest {
export default function LeaveRequests() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<LeaveRequest[]>([]);
const queryClient = useQueryClient();
const { data: requests = [], isPending } = useQuery(
leaveRequestsOptions(true),
) as { data: LeaveRequest[]; isPending: boolean };
const [cancelModal, setCancelModal] = useState<{
open: boolean;
id: number | null;
}>({ open: false, id: null });
const [cancelling, setCancelling] = useState(false);
const fetchRequests = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/leave-requests?mine=1`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRequests(result.data);
}
} catch {
alert.error("Nepodařilo se načíst žádosti");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchRequests();
}, [fetchRequests]);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => {
@@ -94,7 +81,7 @@ export default function LeaveRequests() {
const result = await response.json();
if (result.success) {
setCancelModal({ open: false, id: null });
await fetchRequests();
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -106,48 +93,6 @@ export default function LeaveRequests() {
}
};
if (loading) {
return (
<div>
<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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
</div>
);
}
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
@@ -176,129 +121,139 @@ export default function LeaveRequests() {
}
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">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
</div>
</motion.div>
<Skeleton
name="leave-requests"
loading={isPending}
fixture={<LeaveRequestsFixture />}
>
<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">Moje žádosti</h1>
<p className="admin-page-subtitle">
Přehled žádostí o nepřítomnost
</p>
</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">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
<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">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th></th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === "pending" && (
<button
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
</td>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th></th>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === "pending" && (
<button
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelling}
/>
</div>
<ConfirmModal
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelling}
/>
</div>
</Skeleton>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import OrdersFixture from "../fixtures/OrdersFixture";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
@@ -10,7 +12,9 @@ import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { orderListOptions } from "../lib/queries/orders";
import Pagination from "../components/Pagination";
const API_BASE = "/api/admin";
@@ -57,19 +61,13 @@ export default function Orders() {
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
const queryClient = useQueryClient();
const {
items: orders,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData("orders", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst objednávky",
});
isPending,
isFetching,
} = usePaginatedQuery<Order>(orderListOptions({ search, sort, order, page }));
if (!hasPermission("orders.view")) return <Forbidden />;
@@ -90,7 +88,9 @@ export default function Orders() {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(result.message || "Objednávka byla smazána");
fetchData();
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se smazat objednávku");
}
@@ -101,216 +101,155 @@ export default function Orders() {
}
};
if (initialLoad) {
return (
<div>
<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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</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 circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</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">Objednávky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? orders.length}{" "}
{czechPlural(
pagination?.total ?? orders.length,
"objednávka",
"objednávky",
"objednávek",
)}
</p>
</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 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
/>
<Skeleton name="orders" loading={isPending} fixture={<OrdersFixture />}>
<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">Objednávky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? orders.length}{" "}
{czechPlural(
pagination?.total ?? orders.length,
"objednávka",
"objednávky",
"objednávek",
)}
</p>
</div>
</motion.div>
{orders.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
</div>
<p>Zatím nejsou žádné objednávky.</p>
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
Objednávky se vytvářejí z nabídek.
</p>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
/>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("order_number")}
>
Číslo{" "}
<SortIcon
column="order_number"
sort={activeSort}
order={order}
/>
</th>
<th>Nabídka</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("created_at")}
>
Datum{" "}
<SortIcon
column="created_at"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(orders as Order[]).map((o) => (
<tr key={o.id}>
<td className="admin-mono">
<Link to={`/orders/${o.id}`} className="link-accent">
{o.order_number}
</Link>
</td>
<td>
<Link
to={`/offers/${o.quotation_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{o.quotation_number}
</Link>
</td>
<td>{o.customer_name || "—"}</td>
<td>
<span
className={`admin-badge ${STATUS_CLASSES[o.status] || ""}`}
>
{STATUS_LABELS[o.status] || o.status}
</span>
</td>
<td className="admin-mono">{formatDate(o.created_at)}</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(o.total, o.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/orders/${o.id}`}
className="admin-btn-icon"
title="Detail"
aria-label="Detail"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
{orders.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z" />
<line x1="3" y1="6" x2="21" y2="6" />
<path d="M16 10a4 4 0 0 1-8 0" />
</svg>
</div>
<p>Zatím nejsou žádné objednávky.</p>
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
Objednávky se vytvářejí z nabídek.
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("order_number")}
>
Číslo{" "}
<SortIcon
column="order_number"
sort={activeSort}
order={order}
/>
</th>
<th>Nabídka</th>
<th>Zákazník</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("created_at")}
>
Datum{" "}
<SortIcon
column="created_at"
sort={activeSort}
order={order}
/>
</th>
<th className="text-right">Celkem</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(orders as Order[]).map((o) => (
<tr key={o.id}>
<td className="admin-mono">
<Link to={`/orders/${o.id}`} className="link-accent">
{o.order_number}
</Link>
{o.invoice_id ? (
</td>
<td>
<Link
to={`/offers/${o.quotation_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{o.quotation_number}
</Link>
</td>
<td>{o.customer_name || "—"}</td>
<td>
<span
className={`admin-badge ${STATUS_CLASSES[o.status] || ""}`}
>
{STATUS_LABELS[o.status] || o.status}
</span>
</td>
<td className="admin-mono">
{formatDate(o.created_at)}
</td>
<td className="admin-mono text-right fw-500">
{formatCurrency(o.total, o.currency)}
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/invoices/${o.invoice_id}`}
className="admin-btn-icon accent"
title="Zobrazit fakturu"
aria-label="Zobrazit fakturu"
to={`/orders/${o.id}`}
className="admin-btn-icon"
title="Detail"
aria-label="Detail"
>
<svg
width="18"
@@ -320,28 +259,16 @@ export default function Orders() {
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</Link>
) : (
hasPermission("invoices.create") && (
{o.invoice_id ? (
<Link
to={`/invoices/new?fromOrder=${o.id}`}
className="admin-btn-icon"
title="Vytvořit fakturu"
aria-label="Vytvořit fakturu"
to={`/invoices/${o.invoice_id}`}
className="admin-btn-icon accent"
title="Zobrazit fakturu"
aria-label="Zobrazit fakturu"
>
<svg
width="18"
@@ -353,76 +280,108 @@ export default function Orders() {
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
</Link>
)
)}
{hasPermission("orders.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, order: o })
}
className="admin-btn-icon danger"
title="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
) : (
hasPermission("invoices.create") && (
<Link
to={`/invoices/new?fromOrder=${o.id}`}
className="admin-btn-icon"
title="Vytvořit fakturu"
aria-label="Vytvořit fakturu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</svg>
</Link>
)
)}
{hasPermission("orders.delete") && (
<button
onClick={() =>
setDeleteConfirm({ show: true, order: o })
}
className="admin-btn-icon danger"
title="Smazat"
>
<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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;
{deleteConfirm.order?.order_number}&quot;? Bude smazán i přidružený
projekt. Tato akce je nevratná.
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat objednávku"
message={
<>
Opravdu chcete smazat objednávku &quot;
{deleteConfirm.order?.order_number}&quot;? Bude smazán i
přidružený projekt. Tato akce je nevratná.
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory projektu na disku</span>
</label>
</>
}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
</Skeleton>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,8 @@ import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import ProjectsFixture from "../fixtures/ProjectsFixture";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
@@ -10,7 +12,9 @@ import apiFetch from "../utils/api";
import { formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useListData from "../hooks/useListData";
import { useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { projectListOptions } from "../lib/queries/projects";
import Pagination from "../components/Pagination";
const API_BASE = "/api/admin";
@@ -52,19 +56,15 @@ export default function Projects() {
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
const queryClient = useQueryClient();
const {
items: projects,
setItems: setProjects,
loading,
initialLoad,
pagination,
} = useListData<Project>("projects", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst projekty",
});
isPending,
isFetching,
} = usePaginatedQuery<Project>(
projectListOptions({ search, sort, order, page }),
);
if (!hasPermission("projects.view")) return <Forbidden />;
@@ -80,9 +80,9 @@ export default function Projects() {
const data = await res.json();
if (data.success) {
alert.success(data.message || "Projekt byl smazán");
setProjects((prev: Project[]) =>
prev.filter((p) => p.id !== deleteTarget.id),
);
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
} else {
alert.error(data.error || "Nepodařilo se smazat projekt");
}
@@ -95,298 +95,268 @@ export default function Projects() {
}
};
if (initialLoad) {
return (
<div>
<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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</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 circle" />
<div className="flex-1">
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</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">Projekty</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? projects.length}{" "}
{czechPlural(
pagination?.total ?? projects.length,
"projekt",
"projekty",
"projektů",
)}
</p>
</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 }}
style={{ opacity: loading ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
/>
<Skeleton name="projects" loading={isPending} fixture={<ProjectsFixture />}>
<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">Projekty</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? projects.length}{" "}
{czechPlural(
pagination?.total ?? projects.length,
"projekt",
"projekty",
"projektů",
)}
</p>
</div>
</motion.div>
{projects.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<p>Zatím nejsou žádné projekty.</p>
<p
style={{ color: "var(--text-tertiary)", fontSize: "0.875rem" }}
>
Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky.
</p>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
<div className="admin-search-bar mb-4">
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
/>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("project_number")}
>
Číslo{" "}
<SortIcon
column="project_number"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
Název{" "}
<SortIcon column="name" sort={activeSort} order={order} />
</th>
<th>Zákazník</th>
<th>Zodpovědná osoba</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("start_date")}
>
Začátek{" "}
<SortIcon
column="start_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("end_date")}
>
Konec{" "}
<SortIcon
column="end_date"
sort={activeSort}
order={order}
/>
</th>
<th>Objednávka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(projects as Project[]).map((p) => (
<tr key={p.id}>
<td className="admin-mono">
<Link to={`/projects/${p.id}`} className="link-accent">
{p.project_number}
</Link>
</td>
<td className="fw-500">{p.name || "—"}</td>
<td>{p.customer_name || "—"}</td>
<td>{p.responsible_user_name || ""}</td>
<td>
<span
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
>
{STATUS_LABELS[p.status] || p.status}
</span>
</td>
<td className="admin-mono">{formatDate(p.start_date)}</td>
<td className="admin-mono">{formatDate(p.end_date)}</td>
<td>
{p.order_id ? (
<Link
to={`/orders/${p.order_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{p.order_number}
</Link>
) : (
"—"
)}
</td>
<td>
<div className="admin-table-actions">
{projects.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
</svg>
</div>
<p>Zatím nejsou žádné projekty.</p>
<p
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
Vytvořte první projekt tlačítkem výše nebo automaticky při
vytvoření objednávky.
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("project_number")}
>
Číslo{" "}
<SortIcon
column="project_number"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("name")}
>
Název{" "}
<SortIcon
column="name"
sort={activeSort}
order={order}
/>
</th>
<th>Zákazník</th>
<th>Zodpovědná osoba</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("status")}
>
Stav{" "}
<SortIcon
column="status"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("start_date")}
>
Začátek{" "}
<SortIcon
column="start_date"
sort={activeSort}
order={order}
/>
</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("end_date")}
>
Konec{" "}
<SortIcon
column="end_date"
sort={activeSort}
order={order}
/>
</th>
<th>Objednávka</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{(projects as Project[]).map((p) => (
<tr key={p.id}>
<td className="admin-mono">
<Link
to={`/projects/${p.id}`}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
className="link-accent"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
{p.project_number}
</Link>
{!p.order_id && hasPermission("projects.create") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
title="Smazat projekt"
disabled={deletingId === p.id}
</td>
<td className="fw-500">{p.name || "—"}</td>
<td>{p.customer_name || "—"}</td>
<td>{p.responsible_user_name || "—"}</td>
<td>
<span
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
>
{STATUS_LABELS[p.status] || p.status}
</span>
</td>
<td className="admin-mono">
{formatDate(p.start_date)}
</td>
<td className="admin-mono">{formatDate(p.end_date)}</td>
<td>
{p.order_id ? (
<Link
to={`/orders/${p.order_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{deletingId === p.id ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
</button>
{p.order_number}
</Link>
) : (
"—"
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
</td>
<td>
<div className="admin-table-actions">
<Link
to={`/projects/${p.id}`}
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"
>
<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>
</Link>
{!p.order_id &&
hasPermission("projects.create") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
title="Smazat projekt"
disabled={deletingId === p.id}
>
{deletingId === p.id ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
)}
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
}
confirmText="Smazat"
type="danger"
loading={!!deletingId}
/>
</div>
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt {deleteTarget?.project_number}?
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
onChange={(e) => setDeleteFiles(e.target.checked)}
/>
<span>Smazat i soubory na disku</span>
</label>
</>
}
confirmText="Smazat"
type="danger"
loading={!!deletingId}
/>
</div>
</Skeleton>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion, AnimatePresence } from "framer-motion";
@@ -10,6 +11,14 @@ import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import useModalLock from "../hooks/useModalLock";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import { supplierListOptions } from "../lib/queries/common";
import {
receivedInvoiceListOptions,
receivedInvoiceStatsOptions,
} from "../lib/queries/invoices";
import { Skeleton } from "boneyard-js/react";
import ReceivedInvoicesFixture from "../fixtures/ReceivedInvoicesFixture";
const API_BASE = "/api/admin";
@@ -131,7 +140,7 @@ interface CompanySettings {
available_vat_rates: number[];
}
function emptyMeta(settings: CompanySettings | null): UploadMeta {
function emptyMeta(settings: CompanySettings | null | undefined): UploadMeta {
return {
supplier_name: "",
invoice_number: "",
@@ -155,10 +164,16 @@ export default function ReceivedInvoices({
const { sort, order, handleSort, activeSort } = useTableSort("created_at");
const [search, setSearch] = useState("");
const [invoices, setInvoices] = useState<ReceivedInvoice[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<ReceivedStats | null>(null);
const [statsLoading, setStatsLoading] = useState(true);
const queryClient = useQueryClient();
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [saving, setSaving] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const hasLoadedOnce = useRef(false);
const slideDirection = useRef(0);
const blobTimeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
@@ -166,18 +181,42 @@ export default function ReceivedInvoices({
const prevMonth = useRef(statsMonth);
const prevYear = useRef(statsYear);
const [editOpen, setEditOpen] = useState(false);
const [editInvoice, setEditInvoice] = useState<EditInvoice | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
invoice: ReceivedInvoice | null;
}>({ show: false, invoice: null });
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const { data: supplierNames = [] } = useQuery(supplierListOptions());
const companySettings = useQuery(companySettingsOptions()).data as unknown as
| CompanySettings
| undefined;
const [supplierNames, setSupplierNames] = useState<string[]>([]);
const [companySettings, setCompanySettings] =
useState<CompanySettings | null>(null);
// List query — auto-refetches when filters change
const listQuery = useQuery(
receivedInvoiceListOptions({
month: statsMonth,
year: statsYear,
search,
sort,
order,
}),
);
// Stats query — auto-refetches when month/year change
const statsQuery = useQuery(
receivedInvoiceStatsOptions(statsMonth, statsYear),
);
// Derive list data from query (paginatedJsonQuery returns { data, pagination })
const invoices = (listQuery.data?.data ?? []) as ReceivedInvoice[];
if (listQuery.data || statsQuery.data) hasLoadedOnce.current = true;
// Derive stats from query
const stats = (statsQuery.data as unknown as ReceivedStats) ?? null;
// Trigger slide animation when stats data changes
useEffect(() => {
if (statsQuery.data) {
setSlideKey((k) => k + 1);
}
}, [statsQuery.data]);
const showListSkeleton = listQuery.isPending && !hasLoadedOnce.current;
const [uploadFiles, setUploadFiles] = useState<File[]>([]);
const [uploadMeta, setUploadMeta] = useState<UploadMeta[]>([]);
@@ -201,57 +240,6 @@ export default function ReceivedInvoices({
prevMonth.current = statsMonth;
prevYear.current = statsYear;
const fetchList = useCallback(async () => {
if (!hasLoadedOnce.current) setLoading(true);
try {
const params = new URLSearchParams({
month: String(statsMonth),
year: String(statsYear),
});
if (search) {
params.set("search", search);
}
if (sort) {
params.set("sort", sort);
}
if (order) {
params.set("order", order);
}
const res = await apiFetch(`${API_BASE}/received-invoices?${params}`);
const data = await res.json();
if (data.success) {
setInvoices(Array.isArray(data.data) ? data.data : []);
}
} catch {
/* ignore */
} finally {
setLoading(false);
hasLoadedOnce.current = true;
}
}, [statsMonth, statsYear, search, sort, order]);
useEffect(() => {
fetchList();
}, [fetchList]);
useEffect(() => {
apiFetch(`${API_BASE}/received-invoices/suppliers`)
.then((r) => r.json())
.then((d) => {
if (d.success) setSupplierNames(d.data || []);
})
.catch(() => {});
}, []);
useEffect(() => {
apiFetch(`${API_BASE}/company-settings`)
.then((r) => r.json())
.then((d) => {
if (d.success) setCompanySettings(d.data);
})
.catch(() => {});
}, []);
const currencyOptions =
companySettings?.available_currencies || DEFAULT_CURRENCIES;
const vatRateOptions =
@@ -259,45 +247,6 @@ export default function ReceivedInvoices({
const defaultCurrency = companySettings?.default_currency || "CZK";
const defaultVatRate = String(companySettings?.default_vat_rate ?? 21);
// Fetch stats (silent refresh without animation)
const refreshStats = useCallback(async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
}
} catch {
/* ignore */
}
}, [statsMonth, statsYear]);
// Fetch stats on month change (with slide animation)
useEffect(() => {
setStatsLoading(true);
const load = async () => {
try {
const res = await apiFetch(
`${API_BASE}/received-invoices/stats?month=${statsMonth}&year=${statsYear}`,
);
const data = await res.json();
if (data.success) {
setStats(data.data);
hasLoadedOnce.current = true;
setSlideKey((k) => k + 1);
}
} catch {
/* ignore */
} finally {
setStatsLoading(false);
}
};
load();
}, [statsMonth, statsYear]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
if (selected.length === 0) {
@@ -395,8 +344,9 @@ export default function ReceivedInvoices({
setUploadFiles([]);
setUploadMeta([]);
setUploadErrors({});
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Chyba při nahrávání");
}
@@ -465,8 +415,9 @@ export default function ReceivedInvoices({
alert.success(data.message || "Faktura byla aktualizována");
setEditOpen(false);
setEditInvoice(null);
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Chyba při ukládání");
}
@@ -493,8 +444,9 @@ export default function ReceivedInvoices({
if (data.success) {
alert.success(data.message || "Faktura byla smazána");
setDeleteConfirm({ show: false, invoice: null });
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Chyba při mazání");
}
@@ -538,8 +490,9 @@ export default function ReceivedInvoices({
const data = await res.json();
if (data.success) {
alert.success("Faktura označena jako uhrazená");
fetchList();
refreshStats();
queryClient.invalidateQueries({
queryKey: ["invoices", "received"],
});
} else {
alert.error(data.error || "Nepodařilo se změnit stav");
}
@@ -551,26 +504,15 @@ export default function ReceivedInvoices({
const monthLabel = `${MONTH_NAMES[statsMonth - 1]}`;
const renderKpi = () => {
if (!hasLoadedOnce.current && statsLoading) {
if (statsQuery.isPending && !hasLoadedOnce.current) {
return (
<div className="admin-kpi-grid admin-kpi-4 mb-6">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{ width: "60%", height: "11px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "40%", height: "28px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</div>
))}
</div>
<Skeleton
name="received-invoices-kpi"
loading={statsQuery.isPending && !hasLoadedOnce.current}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
);
}
if (!stats) {
@@ -680,18 +622,16 @@ export default function ReceivedInvoices({
/>
</div>
{loading && (
<div className="admin-skeleton" style={{ gap: "1rem" }}>
{[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/4" />
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
{showListSkeleton && (
<Skeleton
name="received-invoices-list"
loading={showListSkeleton}
fixture={<ReceivedInvoicesFixture />}
>
<div />
</Skeleton>
)}
{!loading && invoices.length === 0 && (
{!showListSkeleton && invoices.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
@@ -723,7 +663,7 @@ export default function ReceivedInvoices({
)}
</div>
)}
{!loading && invoices.length > 0 && (
{!showListSkeleton && invoices.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>

View File

@@ -1,14 +1,22 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useNavigate, Navigate, useSearchParams } from "react-router-dom";
import { Navigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import CompanySettings from "./CompanySettings";
import {
companySettingsOptions,
systemInfoOptions,
require2FAOptions,
} from "../lib/queries/settings";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import SettingsFixture from "../fixtures/SettingsFixture";
const API_BASE = "/api/admin";
interface SystemSettingsData {
@@ -70,20 +78,108 @@ interface RoleForm {
permissions: string[];
}
const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
break_threshold_hours: 6,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
invoice_alert_email: "",
leave_notify_email: "",
smtp_from: "",
smtp_from_name: "",
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
default_currency: "CZK",
default_vat_rate: 21,
available_vat_rates: [0, 10, 12, 15, 21],
available_currencies: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: "NA",
order_type_code: "71",
invoice_type_code: "81",
offer_number_pattern: "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern: "{YY}{CODE}{NNNN}",
invoice_number_pattern: "{YY}{CODE}{NNNN}",
};
export default function Settings() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [roles, setRoles] = useState<Role[]>([]);
const [users, setUsers] = useState<{ role_id: number }[]>([]);
const [, setAllPermissions] = useState<Permission[]>([]);
const [permissionGroups, setPermissionGroups] = useState<
Record<string, Permission[]>
>({});
const queryClient = useQueryClient();
const [require2FA, setRequire2FA] = useState(false);
const [require2FALoading, setRequire2FALoading] = useState(true);
const canManage = hasPermission("settings.manage");
// ── TanStack Query: roles, permissions, users ──
const { data: rolesData, isPending: rolesLoading } = useQuery({
queryKey: ["roles"],
queryFn: async () => {
const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success)
throw new Error(permsResult.error || "Nepodařilo se načíst oprávnění");
if (!usersResult.success)
throw new Error(usersResult.error || "Nepodařilo se načíst uživatele");
return {
roles: Array.isArray(rolesResult.data) ? rolesResult.data : [],
permissions: Array.isArray(permsResult.data) ? permsResult.data : [],
users: Array.isArray(usersResult.data) ? usersResult.data : [],
};
},
enabled: canManage,
});
const roles = rolesData?.roles ?? [];
const users = rolesData?.users ?? [];
// Group permissions by module
const permissionGroups = useMemo<Record<string, Permission[]>>(() => {
const perms: Permission[] = rolesData?.permissions ?? [];
const groups: Record<string, Permission[]> = {};
for (const p of perms) {
const mod = p.name.split(".")[0] || "other";
if (!groups[mod]) groups[mod] = [];
groups[mod].push(p);
}
return groups;
}, [rolesData?.permissions]);
// ── TanStack Query: 2FA required ──
const { data: totpData, isPending: require2FALoading } =
useQuery(require2FAOptions());
const require2FA = totpData?.require_2fa ?? false;
// ── TanStack Query: system settings (lazy, only on system/security tab) ──
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const activeTab = (
tabParam === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const { data: sysSettingsData, isPending: sysSettingsLoading } = useQuery({
...companySettingsOptions(),
enabled: canManage && (activeTab === "system" || activeTab === "security"),
});
const { data: systemInfo } = useQuery({
...systemInfoOptions(),
enabled: canManage && activeTab === "system",
});
// ── Local state ──
const [require2FASaving, setRequire2FASaving] = useState(false);
const [showModal, setShowModal] = useState(false);
@@ -102,194 +198,56 @@ export default function Settings() {
}>({ show: false, role: null });
const [deleting, setDeleting] = useState(false);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get("tab");
const activeTab = (
tabParam === "system"
? "system"
: tabParam === "firma"
? "firma"
: "security"
) as "security" | "system" | "firma";
const setActiveTab = (tab: "security" | "system" | "firma") =>
setSearchParams({ tab }, { replace: true });
const [sysSettings, setSysSettings] = useState<SystemSettingsData | null>(
null,
);
const [sysSettingsLoading, setSysSettingsLoading] = useState(false);
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
const [systemInfo, setSystemInfo] = useState<Record<string, any> | null>(
null,
);
const [sysForm, setSysForm] = useState<
Omit<SystemSettingsData, "app_version">
>({
break_threshold_hours: 6,
break_duration_short: 15,
break_duration_long: 30,
clock_rounding_minutes: 15,
invoice_alert_email: "",
leave_notify_email: "",
smtp_from: "",
smtp_from_name: "",
max_login_attempts: 5,
lockout_minutes: 15,
max_requests_per_minute: 300,
default_currency: "CZK",
default_vat_rate: 21,
available_vat_rates: [0, 10, 12, 15, 21],
available_currencies: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: "NA",
order_type_code: "71",
invoice_type_code: "81",
offer_number_pattern: "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern: "{YY}{CODE}{NNNN}",
invoice_number_pattern: "{YY}{CODE}{NNNN}",
});
const [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
const [sysFormInitialized, setSysFormInitialized] = useState(false);
const canManage = hasPermission("settings.manage");
if (!canManage) {
return <Navigate to="/" replace />;
}
// ── Populate sysForm from query data ──
useEffect(() => {
if (!sysSettingsData || sysFormInitialized) return;
const d = sysSettingsData as Record<string, unknown>;
setSysForm({
break_threshold_hours: (d.break_threshold_hours as number) ?? 6,
break_duration_short: (d.break_duration_short as number) ?? 15,
break_duration_long: (d.break_duration_long as number) ?? 30,
clock_rounding_minutes: (d.clock_rounding_minutes as number) ?? 15,
invoice_alert_email: (d.invoice_alert_email as string) || "",
leave_notify_email: (d.leave_notify_email as string) || "",
smtp_from: (d.smtp_from as string) || "",
smtp_from_name: (d.smtp_from_name as string) || "",
max_login_attempts: (d.max_login_attempts as number) ?? 5,
lockout_minutes: (d.lockout_minutes as number) ?? 15,
max_requests_per_minute: (d.max_requests_per_minute as number) ?? 300,
default_currency: (d.default_currency as string) || "CZK",
default_vat_rate: (d.default_vat_rate as number) ?? 21,
available_vat_rates:
Array.isArray(d.available_vat_rates) && d.available_vat_rates.length > 0
? (d.available_vat_rates as number[])
: [0, 10, 12, 15, 21],
available_currencies:
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
? (d.available_currencies as string[])
: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: (d.quotation_prefix as string) || "NA",
order_type_code: (d.order_type_code as string) || "71",
invoice_type_code: (d.invoice_type_code as string) || "81",
offer_number_pattern:
(d.offer_number_pattern as string) || "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern:
(d.order_number_pattern as string) || "{YY}{CODE}{NNNN}",
invoice_number_pattern:
(d.invoice_number_pattern as string) || "{YY}{CODE}{NNNN}",
});
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
useModalLock(showModal);
const fetchData = useCallback(async () => {
if (!canManage) {
setLoading(false);
return;
}
try {
const [rolesRes, permsRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/users`),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const usersResult = await usersRes.json();
if (rolesResult.success) {
setRoles(Array.isArray(rolesResult.data) ? rolesResult.data : []);
} else {
alert.error(rolesResult.error || "Nepodařilo se načíst role");
}
if (permsResult.success) {
const perms: Permission[] = Array.isArray(permsResult.data)
? permsResult.data
: [];
setAllPermissions(perms);
// Group by module (part before '.')
const groups: Record<string, Permission[]> = {};
for (const p of perms) {
const mod = p.name.split(".")[0] || "other";
if (!groups[mod]) groups[mod] = [];
groups[mod].push(p);
}
setPermissionGroups(groups);
}
if (usersResult.success) {
setUsers(Array.isArray(usersResult.data) ? usersResult.data : []);
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, [alert, canManage]);
useEffect(() => {
fetchData();
}, [fetchData]);
const fetch2FARequired = useCallback(async () => {
if (!canManage) {
setRequire2FALoading(false);
return;
}
try {
const response = await apiFetch(`${API_BASE}/totp/required`);
const result = await response.json();
if (result.success) {
setRequire2FA(result.data.require_2fa);
}
} catch {
/* ignore */
} finally {
setRequire2FALoading(false);
}
}, [canManage]);
useEffect(() => {
fetch2FARequired();
}, [fetch2FARequired]);
const fetchSystemSettings = useCallback(async () => {
if (!canManage) return;
setSysSettingsLoading(true);
try {
const response = await apiFetch(`${API_BASE}/company-settings`);
const result = await response.json();
if (result.success) {
const d: SystemSettingsData = result.data;
setSysSettings(d);
setSysForm({
break_threshold_hours: d.break_threshold_hours ?? 6,
break_duration_short: d.break_duration_short ?? 15,
break_duration_long: d.break_duration_long ?? 30,
clock_rounding_minutes: d.clock_rounding_minutes ?? 15,
invoice_alert_email: d.invoice_alert_email || "",
leave_notify_email: d.leave_notify_email || "",
smtp_from: d.smtp_from || "",
smtp_from_name: d.smtp_from_name || "",
max_login_attempts: d.max_login_attempts ?? 5,
lockout_minutes: d.lockout_minutes ?? 15,
max_requests_per_minute: d.max_requests_per_minute ?? 300,
default_currency: d.default_currency || "CZK",
default_vat_rate: d.default_vat_rate ?? 21,
available_vat_rates:
Array.isArray(d.available_vat_rates) &&
d.available_vat_rates.length > 0
? d.available_vat_rates
: [0, 10, 12, 15, 21],
available_currencies:
Array.isArray(d.available_currencies) &&
d.available_currencies.length > 0
? d.available_currencies
: ["CZK", "EUR", "USD", "GBP"],
quotation_prefix: d.quotation_prefix || "NA",
order_type_code: d.order_type_code || "71",
invoice_type_code: d.invoice_type_code || "81",
offer_number_pattern:
d.offer_number_pattern || "{YYYY}/{PREFIX}/{NNN}",
order_number_pattern: d.order_number_pattern || "{YY}{CODE}{NNNN}",
invoice_number_pattern:
d.invoice_number_pattern || "{YY}{CODE}{NNNN}",
});
} else {
alert.error(result.error || "Nepodařilo se načíst systémová nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSysSettingsLoading(false);
}
// Fetch system info
apiFetch(`${API_BASE}/company-settings/system-info`)
.then((r) => r.json())
.then((d) => {
if (d.success) setSystemInfo(d.data);
})
.catch(() => {});
}, [alert, canManage]);
useEffect(() => {
fetchSystemSettings();
}, [fetchSystemSettings]);
// ── Early return after all hooks ──
if (!canManage) {
return <Navigate to="/" replace />;
}
const handleSaveSystemSettings = async () => {
setSysSettingsSaving(true);
@@ -302,7 +260,7 @@ export default function Settings() {
const result = await response.json();
if (result.success) {
alert.success(result.message || "Systémová nastavení byla uložena");
fetchSystemSettings();
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -323,8 +281,8 @@ export default function Settings() {
});
const result = await response.json();
if (result.success) {
setRequire2FA(!require2FA);
alert.success(result.message || "2FA nastavení uloženo");
queryClient.invalidateQueries({ queryKey: ["settings", "2fa"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
@@ -339,7 +297,7 @@ export default function Settings() {
return text
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
};
@@ -443,7 +401,7 @@ export default function Settings() {
result.message ||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
);
fetchData();
queryClient.invalidateQueries({ queryKey: ["roles"] });
} else {
alert.error(result.error || "Nepodařilo se uložit roli");
}
@@ -471,7 +429,7 @@ export default function Settings() {
if (result.success) {
setDeleteConfirm({ show: false, role: null });
alert.success(result.message || "Role byla smazána");
fetchData();
queryClient.invalidateQueries({ queryKey: ["roles"] });
} else {
alert.error(result.error || "Nepodařilo se smazat roli");
}
@@ -482,39 +440,15 @@ export default function Settings() {
}
};
if (loading) {
if (rolesLoading) {
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 className="admin-skeleton-line" style={{ width: "140px" }} />
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
))}
</div>
</div>
</div>
<Skeleton
name="settings"
loading={rolesLoading}
fixture={<SettingsFixture />}
>
<div />
</Skeleton>
);
}
@@ -523,10 +457,13 @@ export default function Settings() {
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return (
<div
className="admin-skeleton-line"
style={{ width: "200px", height: "12px" }}
/>
<Skeleton
name="settings-2fa"
loading={require2FALoading}
fixture={<SettingsFixture />}
>
<span />
</Skeleton>
);
}
if (require2FA)
@@ -783,7 +720,7 @@ export default function Settings() {
</tr>
</thead>
<tbody>
{roles.map((role) => (
{roles.map((role: Role) => (
<tr key={role.id}>
<td>
<div
@@ -804,7 +741,7 @@ export default function Settings() {
</div>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{role.description || "\u2014"}
{role.description || ""}
</td>
<td>
<span className="admin-badge admin-badge-info">
@@ -815,7 +752,11 @@ export default function Settings() {
</td>
<td>
<span className="admin-badge admin-badge-secondary">
{users.filter((u) => u.role_id === role.id).length}
{
users.filter(
(u: { role_id: number }) => u.role_id === role.id,
).length
}
</span>
</td>
<td>
@@ -845,20 +786,26 @@ export default function Settings() {
}
className="admin-btn-icon danger"
title={
users.filter((u) => u.role_id === role.id)
.length > 0
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
aria-label={
users.filter((u) => u.role_id === role.id)
.length > 0
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
? "Nelze smazat roli s přiřazenými uživateli"
: "Smazat"
}
disabled={
users.filter((u) => u.role_id === role.id)
.length > 0
users.filter(
(u: { role_id: number }) =>
u.role_id === role.id,
).length > 0
}
>
<svg
@@ -888,21 +835,14 @@ export default function Settings() {
{/* System Settings Tab */}
{activeTab === "system" && canManage && (
<>
{sysSettingsLoading ? (
<div
className="admin-skeleton"
style={{ padding: 0, gap: "1.5rem" }}
{sysSettingsLoading && !sysFormInitialized ? (
<Skeleton
name="settings-system"
loading={sysSettingsLoading && !sysFormInitialized}
fixture={<SettingsFixture />}
>
{[0, 1, 2].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div className="admin-skeleton-line w-1/3 mb-2" />
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-full" />
</div>
</div>
))}
</div>
<div />
</Skeleton>
) : (
<>
{/* Section 1: Docházka */}
@@ -1374,12 +1314,33 @@ export default function Settings() {
<tbody>
{(
[
["Verze", systemInfo.app_version],
["Node.js", systemInfo.node_version],
["Platforma", systemInfo.platform],
["Uptime", systemInfo.uptime],
["Prostředí", systemInfo.environment],
["Časová zóna", systemInfo.timezone],
[
"Verze",
(systemInfo as Record<string, unknown>)
.app_version,
],
[
"Node.js",
(systemInfo as Record<string, unknown>)
.node_version,
],
[
"Platforma",
(systemInfo as Record<string, unknown>).platform,
],
[
"Uptime",
(systemInfo as Record<string, unknown>).uptime,
],
[
"Prostředí",
(systemInfo as Record<string, unknown>)
.environment,
],
[
"Časová zóna",
(systemInfo as Record<string, unknown>).timezone,
],
] as [string, string][]
).map(([label, val]) => (
<tr key={label}>
@@ -1415,14 +1376,22 @@ export default function Settings() {
</tr>
{(
[
["Proces (RSS)", systemInfo.memory?.rss],
[
"Proces (RSS)",
(
systemInfo as Record<
string,
Record<string, unknown>
>
).memory?.rss as string,
],
[
"Heap",
`${systemInfo.memory?.heap_used} / ${systemInfo.memory?.heap_total}`,
`${(systemInfo as Record<string, Record<string, unknown>>).memory?.heap_used} / ${(systemInfo as Record<string, Record<string, unknown>>).memory?.heap_total}`,
],
[
"Systém",
`${systemInfo.memory?.system_free} volné z ${systemInfo.memory?.system_total}`,
`${(systemInfo as Record<string, Record<string, unknown>>).memory?.system_free} volné z ${(systemInfo as Record<string, Record<string, unknown>>).memory?.system_total}`,
],
] as [string, string][]
).map(([label, val]) => (
@@ -1464,9 +1433,14 @@ export default function Settings() {
</td>
<td style={{ padding: "4px 0" }}>
<span
className={`admin-badge ${systemInfo.database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`}
className={`admin-badge ${(systemInfo as Record<string, Record<string, unknown>>).database?.status === "ok" ? "admin-badge-success" : "admin-badge-danger"}`}
>
{systemInfo.database?.status === "ok"
{(
systemInfo as Record<
string,
Record<string, unknown>
>
).database?.status === "ok"
? "Připojeno"
: "Chyba"}
</span>
@@ -1482,7 +1456,14 @@ export default function Settings() {
Migrace
</td>
<td style={{ padding: "4px 0" }}>
{systemInfo.database?.migrations_applied}
{
(
systemInfo as Record<
string,
Record<string, unknown>
>
).database?.migrations_applied as string
}
</td>
</tr>
<tr>
@@ -1502,9 +1483,33 @@ export default function Settings() {
</tr>
{(
[
["Projekty", systemInfo.nas?.projects],
["Finance", systemInfo.nas?.financials],
["Nabídky", systemInfo.nas?.offers],
[
"Projekty",
(
systemInfo as Record<
string,
Record<string, Record<string, unknown>>
>
).nas?.projects,
],
[
"Finance",
(
systemInfo as Record<
string,
Record<string, Record<string, unknown>>
>
).nas?.financials,
],
[
"Nabídky",
(
systemInfo as Record<
string,
Record<string, Record<string, unknown>>
>
).nas?.offers,
],
] as [string, Record<string, any>][]
).map(([label, info]) => (
<tr key={label}>
@@ -1541,10 +1546,15 @@ export default function Settings() {
</tbody>
</table>
) : (
<div
className="admin-skeleton-line"
style={{ width: "60%", height: 14 }}
/>
<Skeleton
name="settings-permissions"
loading={
!role.permissions || role.permissions.length === 0
}
fixture={<span>...</span>}
>
<span>{role.permissions?.length || 0} oprávnění</span>
</Skeleton>
)}
</div>
</motion.div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
@@ -12,6 +13,9 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import { tripListOptions, tripVehiclesOptions } from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsFixture from "../fixtures/TripsFixture";
const API_BASE = "/api/admin";
interface Vehicle {
@@ -49,10 +53,20 @@ interface TripForm {
export default function Trips() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
const { data: tripsData, isPending: tripsLoading } = useQuery(
tripListOptions({}),
);
const { data: vehiclesData } = useQuery(tripVehiclesOptions());
const trips = (tripsData ?? []) as Record<string, unknown>[] as Trip[];
const vehicles = (vehiclesData ?? []) as Record<
string,
unknown
>[] as Vehicle[];
const [submitting, setSubmitting] = useState(false);
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -72,37 +86,6 @@ export default function Trips() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0);
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips`),
apiFetch(`${API_BASE}/vehicles`),
]);
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
setTrips(Array.isArray(tripsResult.data) ? tripsResult.data : []);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[alert],
);
useEffect(() => {
fetchData();
}, [fetchData]);
useModalLock(showModal);
if (!hasPermission("trips.record")) return <Forbidden />;
@@ -208,8 +191,7 @@ export default function Trips() {
if (result.success) {
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -230,7 +212,7 @@ export default function Trips() {
const result = await response.json();
if (result.success) {
await fetchData(false);
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -248,65 +230,11 @@ export default function Trips() {
return end > start ? end - start : 0;
};
if (loading) {
if (tripsLoading) {
return (
<div>
<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 className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "140px", borderRadius: "8px" }}
/>
</div>
<div className="admin-grid admin-grid-4">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-stat-card">
<div
className="admin-skeleton-line"
style={{
width: "60%",
height: "11px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{
width: "40%",
height: "28px",
marginBottom: "0.5rem",
}}
/>
<div
className="admin-skeleton-line"
style={{ width: "50%", height: "12px" }}
/>
</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>
</div>
<Skeleton name="trips" loading={tripsLoading} fixture={<TripsFixture />}>
<div />
</Skeleton>
);
}

View File

@@ -1,10 +1,17 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import {
tripListOptions,
tripVehiclesOptions,
tripUsersOptions,
} from "../lib/queries/trips";
import { companySettingsOptions } from "../lib/queries/settings";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
@@ -12,6 +19,8 @@ import useModalLock from "../hooks/useModalLock";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
const API_BASE = "/api/admin";
interface Vehicle {
@@ -88,8 +97,7 @@ function mapTrip(bt: BackendTrip): Trip {
export default function TripsAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [companyName, setCompanyName] = useState("");
const queryClient = useQueryClient();
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
@@ -98,9 +106,6 @@ export default function TripsAdmin() {
);
const [filterVehicleId, setFilterVehicleId] = useState("");
const [filterUserId, setFilterUserId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [users, setUsers] = useState<UserShort[]>([]);
const printRef = useRef<HTMLDivElement>(null);
const [showEditModal, setShowEditModal] = useState(false);
@@ -121,56 +126,27 @@ export default function TripsAdmin() {
trip: Trip | null;
}>({ show: false, trip: null });
// Fetch vehicles and users once on mount
useEffect(() => {
const fetchLookups = async () => {
try {
const [vRes, uRes, csRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/trips/users`),
apiFetch(`${API_BASE}/company-settings`),
]);
const vJson = await vRes.json();
const uJson = await uRes.json();
const csJson = await csRes.json();
if (vJson.success) setVehicles(vJson.data);
if (csJson.success) setCompanyName(csJson.data.company_name || "");
if (uJson.success) {
setUsers(uJson.data);
}
} catch {
// silently fail, filters will just be empty
}
};
fetchLookups();
}, []);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
let url = `${API_BASE}/trips?limit=1000&month=${filterMonth}&year=${filterYear}`;
if (filterVehicleId) url += `&vehicle_id=${filterVehicleId}`;
if (filterUserId) url += `&user_id=${filterUserId}`;
const { data: tripUsersData = [] } = useQuery(tripUsersOptions());
const tripUsers = tripUsersData as UserShort[];
const response = await apiFetch(url);
const result = await response.json();
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip);
setTrips(mapped);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[filterMonth, filterYear, filterVehicleId, filterUserId, alert],
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName =
((companySettings as Record<string, unknown> | undefined)
?.company_name as string) ?? "";
const { data: tripsData, isPending } = useQuery(
tripListOptions({
month: Number(filterMonth) || undefined,
year: Number(filterYear) || undefined,
vehicleId: filterVehicleId ? Number(filterVehicleId) : undefined,
userId: filterUserId ? Number(filterUserId) : undefined,
perPage: 100,
}),
);
useEffect(() => {
fetchData();
}, [fetchData]);
const trips = ((tripsData ?? []) as BackendTrip[]).map(mapTrip);
useModalLock(showEditModal);
@@ -211,8 +187,7 @@ export default function TripsAdmin() {
if (result.success) {
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -237,7 +212,7 @@ export default function TripsAdmin() {
if (result.success) {
setDeleteConfirm({ show: false, trip: null });
await fetchData(false);
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -259,7 +234,7 @@ export default function TripsAdmin() {
};
const getSelectedUserName = () => {
if (!filterUserId) return null;
const u = users.find((u) => String(u.id) === filterUserId);
const u = tripUsers.find((u) => String(u.id) === filterUserId);
return u?.name || null;
};
@@ -468,7 +443,7 @@ export default function TripsAdmin() {
className="admin-form-select"
>
<option value="">Všichni řidiči</option>
{users.map((u) => (
{tripUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
@@ -565,119 +540,117 @@ export default function TripsAdmin() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<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" />
<Skeleton
name="trips-admin"
loading={isPending}
fixture={<TripsAdminFixture />}
>
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
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"
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Řidič</th>
<th>Vozidlo</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.driver_name}</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: "nowrap" }}>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
<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={() =>
setDeleteConfirm({ show: true, trip })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<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>
)}
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(trip)}
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={() =>
setDeleteConfirm({ show: true, trip })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<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>
)}
</>
</Skeleton>
</div>
</motion.div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
@@ -7,9 +8,9 @@ import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
import { tripHistoryOptions, tripVehiclesOptions } from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
interface Vehicle {
id: number | string;
@@ -34,14 +35,30 @@ interface Trip {
export default function TripsHistory() {
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [vehicleId, setVehicleId] = useState("");
const [trips, setTrips] = useState<Trip[]>([]);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const { data: vehiclesData = [] } = useQuery(tripVehiclesOptions());
const vehicles = vehiclesData as Vehicle[];
const { data: tripsData, isPending } = useQuery(
tripHistoryOptions({
month,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
userId: user?.id,
}),
);
const trips = ((tripsData ?? []) as Record<string, unknown>[]).map((t) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
: "",
distance: ((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})) as Trip[];
const totals = trips.reduce(
(acc, t) => ({
@@ -52,52 +69,6 @@ export default function TripsHistory() {
{ total: 0, business: 0, count: 0 },
);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({ month });
if (user?.id) params.set("user_id", String(user.id));
if (vehicleId) params.set("vehicle_id", vehicleId);
const [tripsRes, vehiclesRes] = await Promise.all([
apiFetch(`${API_BASE}/trips?${params}`),
apiFetch(`${API_BASE}/vehicles`),
]);
if (tripsRes.status === 401) return;
const tripsResult = await tripsRes.json();
const vehiclesResult = await vehiclesRes.json();
if (tripsResult.success) {
const raw = Array.isArray(tripsResult.data)
? tripsResult.data
: tripsResult.data?.items || [];
setTrips(
raw.map((t: Record<string, unknown>) => ({
...t,
spz: (t.vehicles as Record<string, string>)?.spz || "",
driver_name: t.users
? `${(t.users as Record<string, string>).first_name || ""} ${(t.users as Record<string, string>).last_name || ""}`.trim()
: "",
distance:
((t.end_km as number) || 0) - ((t.start_km as number) || 0),
})),
);
}
if (vehiclesResult.success) {
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [month, vehicleId, alert, user?.id]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!hasPermission("trips.history")) return <Forbidden />;
const getMonthName = (monthStr: string): string => {
@@ -240,88 +211,86 @@ export default function TripsHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[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" />
<Skeleton
name="trips-history"
loading={isPending}
fixture={<TripsHistoryFixture />}
>
<>
{trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
))}
</div>
)}
{!loading && trips.length === 0 && (
<div className="admin-empty-state">
<p>Žádné záznamy jízd pro vybrané období.</p>
</div>
)}
{!loading && trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
)}
{trips.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Vozidlo</th>
<th>Řidič</th>
<th>Trasa</th>
<th>Stav km</th>
<th>Vzdálenost</th>
<th>Typ</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>
<span className="admin-badge">{trip.spz}</span>
</td>
<td style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span
style={{
whiteSpace: "nowrap",
color: "var(--text-secondary)",
}}
>
{formatKm(trip.start_km)} -{" "}
{formatKm(trip.end_km)}
</span>
</td>
<td className="admin-mono">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td>
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
</Skeleton>
</div>
</motion.div>
</div>

View File

@@ -1,11 +1,15 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import { Skeleton } from "boneyard-js/react";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import { userListOptions, roleListOptions } from "../lib/queries/users";
import UsersFixture from "../fixtures/UsersFixture";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
@@ -40,9 +44,12 @@ interface FormData {
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const [users, setUsers] = useState<User[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [loading, setLoading] = useState(true);
const queryClient = useQueryClient();
const { data: usersData, isPending } = useQuery(userListOptions());
const users = ((usersData as Record<string, unknown>)?.items ??
usersData ??
[]) as User[];
const { data: roles = [] } = useQuery(roleListOptions()) as { data: Role[] };
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{
@@ -62,38 +69,6 @@ export default function Users() {
useModalLock(showModal);
const fetchUsers = useCallback(async () => {
try {
const usersRes = await apiFetch(`${API_BASE}/users`);
const usersData = await usersRes.json();
if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : []);
} else {
alert.error(usersData.error || "Nepodařilo se načíst uživatele");
}
// Roles fetch — gracefully handle 403 if user lacks settings.roles permission
try {
const rolesRes = await apiFetch(`${API_BASE}/roles`);
const rolesData = await rolesRes.json();
if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : []);
}
} catch {
/* roles not accessible */
}
} catch {
alert.error("Chyba připojení");
} finally {
setLoading(false);
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
if (!hasPermission("users.view")) return <Forbidden />;
const openCreateModal = () => {
@@ -168,7 +143,7 @@ export default function Users() {
alert.success(
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
fetchUsers();
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
@@ -201,7 +176,7 @@ export default function Users() {
if (data.success) {
closeDeleteModal();
fetchUsers();
queryClient.invalidateQueries({ queryKey: ["users"] });
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || "Nepodařilo se smazat uživatele");
@@ -226,7 +201,7 @@ export default function Users() {
const data = await response.json();
if (data.success) {
fetchUsers();
queryClient.invalidateQueries({ queryKey: ["users"] });
alert.success(
user.is_active
? "Uživatel byl deaktivován"
@@ -249,168 +224,110 @@ export default function Users() {
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
return (
<Skeleton name="users" loading={isPending} fixture={<UsersFixture />}>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "160px", borderRadius: "8px" }}
/>
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<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">Uživatelé</h1>
<p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div>
<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"
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat uživatele
</button>
</motion.div>
<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 uživatele
</button>
</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">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
<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">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div>
<div className="admin-table-username">
@{user.username}
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</div>
<div className="admin-table-username">
@{user.username}
</div>
</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
<button
onClick={() =>
user.id !== currentUser?.id && toggleActive(user)
}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
style={{
cursor:
user.id === currentUser?.id
? "not-allowed"
: "pointer",
}}
>
{user.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(user)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
</td>
<td>{user.email}</td>
<td>
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
<button
onClick={() =>
user.id !== currentUser?.id && toggleActive(user)
}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
style={{
cursor:
user.id === currentUser?.id
? "not-allowed"
: "pointer",
}}
>
{user.is_active ? "Aktivní" : "Neaktivní"}
</button>
{user.id !== currentUser?.id && (
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openDeleteModal(user)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
onClick={() => openEditModal(user)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
@@ -420,182 +337,202 @@ export default function Users() {
stroke="currentColor"
strokeWidth="2"
>
<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" />
<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>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
{user.id !== currentUser?.id && (
<button
onClick={() => openDeleteModal(user)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<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>
</div>
</motion.div>
</motion.div>
<AnimatePresence>
{showModal && (
<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={closeModal} />
<AnimatePresence>
{showModal && (
<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 }}
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
<div className="admin-modal-backdrop" onClick={closeModal} />
<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">
{editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button>
</div>
</motion.div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
</Skeleton>
);
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect, useCallback } from "react";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import VehiclesFixture from "../fixtures/VehiclesFixture";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
@@ -9,6 +12,8 @@ import useModalLock from "../hooks/useModalLock";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
import { vehicleListOptions } from "../lib/queries/vehicles";
const API_BASE = "/api/admin";
interface Vehicle {
@@ -35,8 +40,9 @@ interface VehicleForm {
export default function Vehicles() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const queryClient = useQueryClient();
const { data: vehicles = [], isPending } = useQuery(vehicleListOptions());
const [showModal, setShowModal] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
@@ -55,28 +61,6 @@ export default function Vehicles() {
vehicle: Vehicle | null;
}>({ show: false, vehicle: null });
const fetchData = useCallback(
async (showLoading = true) => {
if (showLoading) setLoading(true);
try {
const response = await apiFetch(`${API_BASE}/vehicles`);
const result = await response.json();
if (result.success) {
setVehicles(Array.isArray(result.data) ? result.data : []);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
if (showLoading) setLoading(false);
}
},
[alert],
);
useEffect(() => {
fetchData();
}, [fetchData]);
useModalLock(showModal);
if (!hasPermission("trips.vehicles")) return <Forbidden />;
@@ -132,8 +116,7 @@ export default function Vehicles() {
if (result.success) {
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -158,7 +141,7 @@ export default function Vehicles() {
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null });
await fetchData(false);
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
alert.success(result.message);
} else {
alert.error(result.error);
@@ -179,7 +162,7 @@ export default function Vehicles() {
const result = await response.json();
if (result.success) {
fetchData(false);
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"
@@ -193,370 +176,333 @@ export default function Vehicles() {
}
};
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
return (
<Skeleton name="vehicles" loading={isPending} fixture={<VehiclesFixture />}>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "150px", borderRadius: "8px" }}
/>
</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 circle" />
<div className="flex-1">
<div className="admin-skeleton-line w-1/3 mb-2" />
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<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 vozidel</h1>
</div>
<div className="admin-page-actions">
<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"
<div className="admin-page-actions">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</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">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<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"
>
Přidat první vozidlo
</button>
</div>
)}
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
: "—"}
</td>
<td className="admin-mono">
{formatKm(vehicle.initial_km)} km
</td>
<td className="admin-mono fw-500">
{formatKm(vehicle.current_km)} km
</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
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={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<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>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</div>
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(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">
{editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
value={form.brand}
onChange={(e) =>
setForm({ ...form, brand: e.target.value })
}
className="admin-form-input"
placeholder="Škoda"
/>
</FormField>
<FormField label="Model">
<input
type="text"
value={form.model}
onChange={(e) =>
setForm({ ...form, model: e.target.value })
}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</FormField>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Počáteční stav km
</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/>
<span>Vozidlo je aktivní</span>
</label>
<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">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
</div>
<div className="admin-modal-footer">
<p>Zatím nejsou žádná vozidla.</p>
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Uložit
Přidat první vozidlo
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)}
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
: "—"}
</td>
<td className="admin-mono">
{formatKm(vehicle.initial_km)} km
</td>
<td className="admin-mono fw-500">
{formatKm(vehicle.current_km)} km
</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
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={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<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>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<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={() => setShowModal(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">
{editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
value={form.brand}
onChange={(e) =>
setForm({ ...form, brand: e.target.value })
}
className="admin-form-input"
placeholder="Škoda"
/>
</FormField>
<FormField label="Model">
<input
type="text"
value={form.model}
onChange={(e) =>
setForm({ ...form, model: e.target.value })
}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</FormField>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Počáteční stav km
</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/>
<span>Vozidlo je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
</Skeleton>
);
}