style: run prettier on entire codebase

This commit is contained in:
BOHA
2026-03-24 19:59:14 +01:00
parent 872be42107
commit 3c167cf5c4
148 changed files with 26740 additions and 13990 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,109 +1,169 @@
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import AdminDatePicker from '../components/AdminDatePicker'
import BulkAttendanceModal from '../components/BulkAttendanceModal'
import ShiftFormModal from '../components/ShiftFormModal'
import AttendanceShiftTable from '../components/AttendanceShiftTable'
import useModalLock from '../hooks/useModalLock'
import useAttendanceAdmin from '../hooks/useAttendanceAdmin'
import FormField from '../components/FormField'
import { formatMinutes } from '../utils/attendanceHelpers'
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import AdminDatePicker from "../components/AdminDatePicker";
import BulkAttendanceModal from "../components/BulkAttendanceModal";
import ShiftFormModal from "../components/ShiftFormModal";
import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers";
interface UserTotalData {
name: string
minutes: number
working: boolean
vacation_hours: number
sick_hours: number
holiday_hours: number
unpaid_hours: number
fund: number | null
worked_hours: number
covered: number
missing: number
overtime: number
name: string;
minutes: number;
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
fund: number | null;
worked_hours: number;
covered: number;
missing: number;
overtime: number;
}
function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0) return 'linear-gradient(135deg, var(--warning), #d97706)'
if (data.covered >= (data.fund ?? 0)) return 'linear-gradient(135deg, var(--success), #059669)'
return 'var(--gradient)'
if (data.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (data.covered >= (data.fund ?? 0))
return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
export default function AttendanceAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const alert = useAlert();
const { hasPermission } = useAuth();
const {
loading, month, setMonth,
filterUserId, setFilterUserId,
data, hasData,
showBulkModal, setShowBulkModal,
bulkSubmitting, bulkForm, setBulkForm,
showCreateModal, setShowCreateModal,
createForm, setCreateForm,
showEditModal, setShowEditModal,
editingRecord, editForm, setEditForm,
deleteConfirm, setDeleteConfirm,
loading,
month,
setMonth,
filterUserId,
setFilterUserId,
data,
hasData,
showBulkModal,
setShowBulkModal,
bulkSubmitting,
bulkForm,
setBulkForm,
showCreateModal,
setShowCreateModal,
createForm,
setCreateForm,
showEditModal,
setShowEditModal,
editingRecord,
editForm,
setEditForm,
deleteConfirm,
setDeleteConfirm,
projectList,
createProjectLogs, setCreateProjectLogs,
editProjectLogs, setEditProjectLogs,
openCreateModal, handleCreateShiftDateChange, handleCreateSubmit,
openBulkModal, toggleBulkUser, toggleAllBulkUsers, handleBulkSubmit,
openEditModal, handleEditSubmit,
handleDelete, handlePrint
} = useAttendanceAdmin({ alert })
createProjectLogs,
setCreateProjectLogs,
editProjectLogs,
setEditProjectLogs,
openCreateModal,
handleCreateShiftDateChange,
handleCreateSubmit,
openBulkModal,
toggleBulkUser,
toggleAllBulkUsers,
handleBulkSubmit,
openEditModal,
handleEditSubmit,
handleDelete,
handlePrint,
} = useAttendanceAdmin({ alert });
useModalLock(showBulkModal)
useModalLock(showEditModal)
useModalLock(showCreateModal)
useModalLock(showBulkModal);
useModalLock(showEditModal);
useModalLock(showCreateModal);
if (!hasPermission('attendance.admin')) return <Forbidden />
if (!hasPermission("attendance.admin")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
const isInitialLoad = loading && data.records.length === 0 && Object.keys(data.user_totals).length === 0
const isInitialLoad =
loading &&
data.records.length === 0 &&
Object.keys(data.user_totals).length === 0;
if (isInitialLoad) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<div 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 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 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"
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
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 => (
{[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" 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
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 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" />
@@ -113,7 +173,7 @@ export default function AttendanceAdmin() {
</div>
</div>
</div>
)
);
}
return (
@@ -134,7 +194,15 @@ export default function AttendanceAdmin() {
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
@@ -152,7 +220,14 @@ export default function AttendanceAdmin() {
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
@@ -185,7 +260,9 @@ export default function AttendanceAdmin() {
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>{user.name}</option>
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
@@ -202,68 +279,110 @@ export default function AttendanceAdmin() {
transition={{ duration: 0.25, delay: 0.09 }}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span className={`attendance-working-badge ${ut.working ? 'working' : 'finished'}`}>
{ut.working ? '\u2713' : '\u2717'}
<span
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
>
{ut.working ? "\u2713" : "\u2717"}
</span>
</div>
<div className="admin-stat-value">{formatMinutes(ut.minutes)}</div>
<div className="admin-stat-value">
{formatMinutes(ut.minutes)}
</div>
<div className="admin-stat-label">odpracováno</div>
<div style={{ marginTop: '0.5rem', display: 'flex', flexWrap: 'wrap', gap: '0.25rem' }}>
<div
style={{
marginTop: "0.5rem",
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
}}
>
{ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">Dov: {ut.vacation_hours}h</span>
<span className="attendance-leave-badge badge-vacation">
Dov: {ut.vacation_hours}h
</span>
)}
{ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">Nem: {ut.sick_hours}h</span>
<span className="attendance-leave-badge badge-sick">
Nem: {ut.sick_hours}h
</span>
)}
{ut.holiday_hours > 0 && (
<span className="attendance-leave-badge badge-holiday">Sv: {ut.holiday_hours}h</span>
<span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h
</span>
)}
{ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">Nep: {ut.unpaid_hours}h</span>
<span className="attendance-leave-badge badge-unpaid">
Nep: {ut.unpaid_hours}h
</span>
)}
</div>
{ut.fund !== null && (
<div className="mt-2">
<div className="text-secondary" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.8rem' }}>
<span>Fond: {ut.worked_hours}h / {ut.fund}h</span>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {ut.worked_hours}h / {ut.fund}h
</span>
{ut.overtime > 0 && (
<span className="text-warning fw-600">+{ut.overtime}h</span>
<span className="text-warning fw-600">
+{ut.overtime}h
</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">-{ut.missing}h</span>
<span className="text-danger fw-600">
-{ut.missing}h
</span>
)}
</div>
<div style={{
marginTop: '0.375rem',
height: '4px',
background: 'var(--bg-tertiary)',
borderRadius: '2px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: '2px',
transition: 'width 0.3s ease'
}} />
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
)}
{data.leave_balances[uid] && (
<div className="text-secondary" style={{ marginTop: '0.5rem', fontSize: '0.8rem' }}>
Zbývá dovolené: {data.leave_balances[uid].vacation_remaining.toFixed(1)}h / {data.leave_balances[uid].vacation_total}h
<div
className="text-secondary"
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
/ {data.leave_balances[uid].vacation_total}h
</div>
)}
</div>
</div>
)
);
})}
</motion.div>
)}
@@ -337,5 +456,5 @@ export default function AttendanceAdmin() {
confirmVariant="danger"
/>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,113 @@
import { useState, useEffect } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState, useEffect } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, Link } from "react-router-dom";
import { motion } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number | string
name: string
id: number | string;
name: string;
}
interface CreateForm {
user_id: string
shift_date: string
leave_type: string
leave_hours: number
arrival_date: string
arrival_time: string
break_start_date: string
break_start_time: string
break_end_date: string
break_end_time: string
departure_date: string
departure_time: string
notes: string
user_id: string;
shift_date: string;
leave_type: string;
leave_hours: number;
arrival_date: string;
arrival_time: string;
break_start_date: string;
break_start_time: string;
break_end_date: string;
break_end_time: string;
departure_date: string;
departure_time: string;
notes: string;
}
export default function AttendanceCreate() {
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [users, setUsers] = useState<User[]>([])
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [users, setUsers] = useState<User[]>([]);
const today = new Date().toISOString().split('T')[0]
const today = new Date().toISOString().split("T")[0];
const [form, setForm] = useState<CreateForm>({
user_id: '',
user_id: "",
shift_date: today,
leave_type: 'work',
leave_type: "work",
leave_hours: 8,
arrival_date: today,
arrival_time: '',
arrival_time: "",
break_start_date: today,
break_start_time: '',
break_start_time: "",
break_end_date: today,
break_end_time: '',
break_end_time: "",
departure_date: today,
departure_time: '',
notes: ''
})
departure_time: "",
notes: "",
});
useEffect(() => {
const fetchUsers = async () => {
try {
const response = await apiFetch(`${API_BASE}/users`)
const result = await response.json()
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 || [])
setUsers(
Array.isArray(result.data) ? result.data : result.data?.items || [],
);
}
} catch {
alert.error('Nepodařilo se načíst uživatele')
alert.error("Nepodařilo se načíst uživatele");
} finally {
setLoading(false)
setLoading(false);
}
}
};
fetchUsers()
}, [alert])
fetchUsers();
}, [alert]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
e.preventDefault();
if (!form.user_id || !form.shift_date) {
alert.error('Vyplňte zaměstnance a datum směny')
return
alert.error("Vyplňte zaměstnance a datum směny");
return;
}
setSubmitting(true)
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
alert.success(result.message)
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`)
alert.success(result.message);
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSubmitting(false)
setSubmitting(false);
}
}
};
const handleShiftDateChange = (newDate: string) => {
setForm({
@@ -114,33 +116,42 @@ export default function AttendanceCreate() {
arrival_date: newDate,
break_start_date: newDate,
break_end_date: newDate,
departure_date: newDate
})
}
departure_date: newDate,
});
};
const isWorkType = form.leave_type === 'work'
const isWorkType = form.leave_type === "work";
if (!hasPermission('attendance.admin')) return <Forbidden />
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 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 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-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
className="admin-skeleton-line h-10"
style={{ width: "120px", borderRadius: "8px" }}
/>
</div>
</div>
</div>
)
);
}
return (
@@ -155,7 +166,10 @@ export default function AttendanceCreate() {
<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">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
@@ -163,7 +177,7 @@ export default function AttendanceCreate() {
<motion.div
className="admin-card"
style={{ maxWidth: '600px' }}
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
@@ -174,13 +188,17 @@ export default function AttendanceCreate() {
<FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) => setForm({ ...form, user_id: e.target.value })}
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>
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
@@ -197,7 +215,9 @@ export default function AttendanceCreate() {
<FormField label="Typ záznamu" required>
<select
value={form.leave_type}
onChange={(e) => setForm({ ...form, leave_type: e.target.value })}
onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select"
>
<option value="work">Práce</option>
@@ -213,13 +233,20 @@ export default function AttendanceCreate() {
<input
type="number"
value={form.leave_hours}
onChange={(e) => setForm({ ...form, leave_hours: parseFloat(e.target.value) })}
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>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
@@ -230,14 +257,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) => setForm({ ...form, arrival_date: val })}
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 })}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
@@ -247,14 +278,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) => setForm({ ...form, break_start_date: val })}
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 })}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
@@ -264,14 +299,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) => setForm({ ...form, break_end_date: val })}
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 })}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
@@ -281,14 +320,18 @@ export default function AttendanceCreate() {
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) => setForm({ ...form, departure_date: val })}
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 })}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
@@ -305,7 +348,10 @@ export default function AttendanceCreate() {
</FormField>
<div className="admin-form-actions">
<Link to="/attendance/admin" className="admin-btn admin-btn-secondary">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
@@ -313,12 +359,12 @@ export default function AttendanceCreate() {
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? 'Ukládám...' : 'Uložit'}
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,164 +1,210 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
import { formatDate, formatDatetime, formatTime, calculateWorkMinutes, formatMinutes, getLeaveTypeName, getLeaveTypeBadgeClass, calculateWorkMinutesPrint, formatTimeOrDatetimePrint } from '../utils/attendanceHelpers'
import FormField from '../components/FormField'
import apiFetch from '../utils/api'
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import {
formatDate,
formatDatetime,
formatTime,
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface ProjectLog {
id?: number
project_id?: number
project_name?: string
started_at?: string
ended_at?: string | null
hours?: string | number | null
minutes?: string | number | null
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}
interface AttendanceRecord {
id: number
shift_date: string
leave_type?: string
leave_hours?: number
arrival_time?: string | null
departure_time?: string | null
break_start?: string | null
break_end?: string | null
notes?: string
project_name?: string
project_logs?: ProjectLog[]
id: number;
shift_date: string;
leave_type?: string;
leave_hours?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
project_name?: string;
project_logs?: ProjectLog[];
}
const MONTH_NAMES = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec'
]
"Leden",
"Únor",
"Březen",
"Duben",
"Květen",
"Červen",
"Červenec",
"Srpen",
"Září",
"Říjen",
"Listopad",
"Prosinec",
];
const formatBreakRange = (record: AttendanceRecord): string => {
if (record.break_start && record.break_end) {
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
}
if (record.break_start) {
return `${formatTime(record.break_start)} - ?`
return `${formatTime(record.break_start)} - ?`;
}
return '—'
}
return "—";
};
const renderProjectCell = (record: AttendanceRecord) => {
if (record.project_logs && record.project_logs.length > 0) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.125rem' }}>
<div
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
>
{record.project_logs.map((log, i) => {
let h: number, m: number, isActive = false
let h: number,
m: number,
isActive = false;
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0
m = parseInt(String(log.minutes)) || 0
h = parseInt(String(log.hours)) || 0;
m = parseInt(String(log.minutes)) || 0;
} else {
isActive = !log.ended_at
const end = log.ended_at ? new Date(log.ended_at) : new Date()
const mins = Math.floor((end.getTime() - new Date(log.started_at!).getTime()) / 60000)
h = Math.floor(mins / 60)
m = mins % 60
isActive = !log.ended_at;
const end = log.ended_at ? new Date(log.ended_at) : new Date();
const mins = Math.floor(
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
);
h = Math.floor(mins / 60);
m = mins % 60;
}
return (
<span key={log.id || i} className="admin-badge" style={{ fontSize: '0.7rem', display: 'inline-block', background: isActive ? 'var(--accent-light)' : undefined }}>
{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h{isActive ? ' ▸' : ''})
<span
key={log.id || i}
className="admin-badge"
style={{
fontSize: "0.7rem",
display: "inline-block",
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
</span>
)
);
})}
</div>
)
);
}
if (record.project_name) {
return <span className="admin-badge admin-badge-wrap" style={{ fontSize: '0.75rem' }}>{record.project_name}</span>
return (
<span
className="admin-badge admin-badge-wrap"
style={{ fontSize: "0.75rem" }}
>
{record.project_name}
</span>
);
}
return '—'
}
return "—";
};
export default function AttendanceHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const printRef = useRef<HTMLDivElement>(null)
const alert = useAlert();
const { user, hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
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 now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const [records, setRecords] = useState<AttendanceRecord[]>([]);
const fetchData = useCallback(async () => {
setLoading(true)
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()
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)
setRecords(result.data);
}
} catch {
alert.error('Nepodařilo se načíst data')
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false)
setLoading(false);
}
}, [month, alert, user?.id])
}, [month, alert, user?.id]);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
// Compute totals client-side from raw records
const computed = useMemo(() => {
const [yearStr, monthStr] = month.split('-')
const monthIndex = parseInt(monthStr, 10) - 1
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`
const [yearStr, monthStr] = month.split("-");
const monthIndex = parseInt(monthStr, 10) - 1;
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`;
let totalMinutes = 0
let vacationHours = 0
let sickHours = 0
let holidayHours = 0
let unpaidHours = 0
let totalMinutes = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const record of records) {
const leaveType = record.leave_type || 'work'
if (leaveType === 'work') {
totalMinutes += calculateWorkMinutes(record)
const leaveType = record.leave_type || "work";
if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record);
} else {
const hours = Number(record.leave_hours) || 8
if (leaveType === 'vacation') vacationHours += hours
else if (leaveType === 'sick') sickHours += hours
else if (leaveType === 'holiday') holidayHours += hours
else if (leaveType === 'unpaid') unpaidHours += hours
const hours = Number(record.leave_hours) || 8;
if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "holiday") holidayHours += hours;
else if (leaveType === "unpaid") unpaidHours += hours;
}
}
// Compute monthly fund (working days * 8h)
// Exclude holidays from business days (matching PHP CzechHolidays logic)
const yr = parseInt(yearStr, 10)
const mo = parseInt(monthStr, 10) - 1
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
// Count holiday records to subtract from business days
const holidayDays = records.filter(r => (r.leave_type || 'work') === 'holiday').length
let businessDays = 0
const cur = new Date(yr, mo, 1)
const holidayDays = records.filter(
(r) => (r.leave_type || "work") === "holiday",
).length;
let businessDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
const dow = cur.getDay()
if (dow !== 0 && dow !== 6) businessDays++
cur.setDate(cur.getDate() + 1)
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) businessDays++;
cur.setDate(cur.getDate() + 1);
}
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
businessDays = Math.max(0, businessDays - holidayDays)
const fund = businessDays * 8
const worked = Math.round((totalMinutes / 60) * 100) / 100
businessDays = Math.max(0, businessDays - holidayDays);
const fund = businessDays * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
const leaveHours = vacationHours + sickHours
const covered = Math.round((worked + leaveHours) * 100) / 100
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100)
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100)
const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100;
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
const monthlyFund = {
fund,
@@ -167,18 +213,26 @@ export default function AttendanceHistory() {
covered,
remaining,
overtime,
}
};
return { monthName, totalMinutes, vacationHours, sickHours, holidayHours, unpaidHours, monthlyFund }
}, [records, month])
return {
monthName,
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
};
}, [records, month]);
if (!hasPermission('attendance.history')) return <Forbidden />
if (!hasPermission("attendance.history")) return <Forbidden />;
const handlePrint = () => {
if (!printRef.current) return
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
if (!printRef.current) return;
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
@@ -266,12 +320,12 @@ export default function AttendanceHistory() {
${content}
</body>
</html>
`)
printWindow.document.close()
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print()
}
}
printWindow.print();
};
};
return (
<div>
@@ -292,7 +346,15 @@ export default function AttendanceHistory() {
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
@@ -332,33 +394,81 @@ export default function AttendanceHistory() {
>
<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="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
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
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">
<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
<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' }}>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
</span>
</div>
@@ -367,23 +477,41 @@ export default function AttendanceHistory() {
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)'
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' }}>
<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`}
{"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 className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
@@ -392,7 +520,14 @@ export default function AttendanceHistory() {
</div>
)}
{!loading && !computed.monthlyFund && (
<div className="text-muted" style={{ fontSize: '0.875rem', textAlign: 'center', padding: '0.5rem 0' }}>
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
@@ -408,8 +543,8 @@ export default function AttendanceHistory() {
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<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" />
@@ -440,34 +575,53 @@ export default function AttendanceHistory() {
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record)
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">{formatDate(record.shift_date)}</td>
<td className="admin-mono">
{formatDate(record.shift_date)}
</td>
<td>
<span className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>
<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)}
{isLeave ? "—" : formatDatetime(record.arrival_time)}
</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 className="admin-mono">
{isLeave ? "—" : formatBreakRange(record)}
</td>
<td style={{ maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{record.notes || ''}
<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>
@@ -478,109 +632,216 @@ export default function AttendanceHistory() {
{/* Hidden Print Content */}
{records.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<div ref={printRef} style={{ display: "none" }}>
<table className="print-wrapper-table">
<thead>
<tr><td>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
<tr>
<td>
<div className="print-header">
<div className="print-header-left">
<img
src="/images/logo-light.png"
alt="BOHA"
className="print-logo"
/>
<div className="print-header-text">
<h1>EVIDENCE DOCHÁZKY</h1>
<div className="company">BOHA Automation s.r.o.</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{computed.monthName}</div>
<div className="filters">
Zaměstnanec: {user?.fullName || ""}
</div>
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
<div className="print-header-right">
<div className="period">{computed.monthName}</div>
<div className="filters">Zaměstnanec: {user?.fullName || ''}</div>
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
</div>
</div>
</td></tr>
</td>
</tr>
</thead>
<tbody>
<tr><td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ''}</h3>
<span className="total">Odpracováno: {formatMinutes(computed.totalMinutes, true)}</span>
</div>
{(computed.vacationHours > 0 || computed.sickHours > 0 || computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && <><span className="leave-badge badge-vacation">Dovolená: {computed.vacationHours}h</span> </>}
{computed.sickHours > 0 && <><span className="leave-badge badge-sick">Nemoc: {computed.sickHours}h</span> </>}
{computed.holidayHours > 0 && <><span className="leave-badge badge-holiday">Svátek: {computed.holidayHours}h</span> </>}
<tr>
<td>
<div className="user-section">
<div className="user-header">
<h3>{user?.fullName || ""}</h3>
<span className="total">
Odpracováno:{" "}
{formatMinutes(computed.totalMinutes, true)}
</span>
</div>
)}
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '70px' }}>Typ</th>
<th className="text-center" style={{ width: '70px' }}>Příchod</th>
<th className="text-center" style={{ width: '90px' }}>Pauza</th>
<th className="text-center" style={{ width: '70px' }}>Odchod</th>
<th className="text-center" style={{ width: '80px' }}>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...records].sort((a, b) => a.shift_date.localeCompare(b.shift_date)).map((record) => {
const leaveType = record.leave_type || 'work'
const isLeave = leaveType !== 'work'
const workMinutes = calculateWorkMinutesPrint(record)
const hours = Math.floor(workMinutes / 60)
const mins = workMinutes % 60
{(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && (
<>
<span className="leave-badge badge-vacation">
Dovolená: {computed.vacationHours}h
</span>{" "}
</>
)}
{computed.sickHours > 0 && (
<>
<span className="leave-badge badge-sick">
Nemoc: {computed.sickHours}h
</span>{" "}
</>
)}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div>
)}
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td><span className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}>{getLeaveTypeName(leaveType)}</span></td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.arrival_time, record.shift_date)}</td>
<td className="text-center">
{isLeave || !record.break_start || !record.break_end
? '—'
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`
}
</td>
<td className="text-center">{isLeave ? '—' : formatTimeOrDatetimePrint(record.departure_time, record.shift_date)}</td>
<td className="text-center">{workMinutes > 0 ? `${hours}:${String(mins).padStart(2, '0')}` : '—'}</td>
<td style={{ fontSize: '8px' }}>
{(record.project_logs && record.project_logs.length > 0)
? record.project_logs.map((log, i) => {
let h: number, m: number
if (log.hours !== null && log.hours !== undefined) {
h = parseInt(String(log.hours)) || 0; m = parseInt(String(log.minutes)) || 0
} else if (log.started_at && log.ended_at) {
const mins2 = Math.max(0, Math.floor((new Date(log.ended_at).getTime() - new Date(log.started_at).getTime()) / 60000))
h = Math.floor(mins2 / 60); m = mins2 % 60
} else { h = 0; m = 0 }
return <div key={log.id || i}>{log.project_name || `#${log.project_id}`} ({h}:{String(m).padStart(2, '0')}h)</div>
})
: record.project_name || '—'}
</td>
<td>{record.notes || ''}</td>
</tr>
)
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">Odpracováno:</td>
<td className="text-center">{formatMinutes(computed.totalMinutes, true)}</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
</td></tr>
<table>
<thead>
<tr>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "70px" }}>Typ</th>
<th className="text-center" style={{ width: "70px" }}>
Příchod
</th>
<th className="text-center" style={{ width: "90px" }}>
Pauza
</th>
<th className="text-center" style={{ width: "70px" }}>
Odchod
</th>
<th className="text-center" style={{ width: "80px" }}>
Hodiny
</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{[...records]
.sort((a, b) =>
a.shift_date.localeCompare(b.shift_date),
)
.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes =
calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
return (
<tr key={record.id}>
<td>{formatDate(record.shift_date)}</td>
<td>
<span
className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
>
{getLeaveTypeName(leaveType)}
</span>
</td>
<td className="text-center">
{isLeave
? "—"
: formatTimeOrDatetimePrint(
record.arrival_time,
record.shift_date,
)}
</td>
<td className="text-center">
{isLeave ||
!record.break_start ||
!record.break_end
? "—"
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`}
</td>
<td className="text-center">
{isLeave
? "—"
: formatTimeOrDatetimePrint(
record.departure_time,
record.shift_date,
)}
</td>
<td className="text-center">
{workMinutes > 0
? `${hours}:${String(mins).padStart(2, "0")}`
: "—"}
</td>
<td style={{ fontSize: "8px" }}>
{record.project_logs &&
record.project_logs.length > 0
? record.project_logs.map((log, i) => {
let h: number, m: number;
if (
log.hours !== null &&
log.hours !== undefined
) {
h = parseInt(String(log.hours)) || 0;
m =
parseInt(String(log.minutes)) || 0;
} else if (
log.started_at &&
log.ended_at
) {
const mins2 = Math.max(
0,
Math.floor(
(new Date(
log.ended_at,
).getTime() -
new Date(
log.started_at,
).getTime()) /
60000,
),
);
h = Math.floor(mins2 / 60);
m = mins2 % 60;
} else {
h = 0;
m = 0;
}
return (
<div key={log.id || i}>
{log.project_name ||
`#${log.project_id}`}{" "}
({h}:{String(m).padStart(2, "0")}h)
</div>
);
})
: record.project_name || "—"}
</td>
<td>{record.notes || ""}</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr>
<td colSpan={6} className="text-right">
Odpracováno:
</td>
<td className="text-center">
{formatMinutes(computed.totalMinutes, true)}
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
</table>
</div>
</td>
</tr>
</tbody>
</table>
</div>
)}
</div>
)
);
}

View File

@@ -1,154 +1,158 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, useParams, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { formatDate, formatTime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
declare const L: any
declare const L: any;
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
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;
}
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)
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()
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
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 })
: raw.user_name || "";
setRecord({ ...raw, user_name: userName });
} else {
alert.error('Záznam nebyl nalezen')
navigate('/attendance/admin')
alert.error("Záznam nebyl nalezen");
navigate("/attendance/admin");
}
} catch {
alert.error('Nepodařilo se načíst data')
navigate('/attendance/admin')
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
} finally {
setLoading(false)
setLoading(false);
}
}
};
fetchData()
}, [id, alert, navigate])
fetchData();
}, [id, alert, navigate]);
useEffect(() => {
if (!record || loading) return
if (!record || loading) return;
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
if (!hasAnyLocation || !mapRef.current) return
if (!hasAnyLocation || !mapRef.current) return;
const loadLeaflet = async () => {
if ((window as unknown as Record<string, unknown>).L) {
initMap()
return
initMap();
return;
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'
document.head.appendChild(link)
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
document.head.appendChild(link);
const script = document.createElement('script')
script.src = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
script.onload = initMap
document.body.appendChild(script)
}
const script = document.createElement("script");
script.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
script.onload = initMap;
document.body.appendChild(script);
};
const initMap = () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove()
(mapInstanceRef.current as { remove: () => void }).remove();
}
const map = L.map(mapRef.current!)
mapInstanceRef.current = map
const map = L.map(mapRef.current!);
mapInstanceRef.current = map;
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; OpenStreetMap contributors'
}).addTo(map)
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
}).addTo(map);
const bounds: [number, number][] = []
const bounds: [number, number][] = [];
interface LocationPoint {
lat: number
lng: number
type: string
label: string
time: string
accuracy: number
lat: number;
lng: number;
type: string;
label: string;
time: string;
accuracy: number;
}
const locations: LocationPoint[] = []
const locations: LocationPoint[] = [];
if (hasArrivalLocation) {
locations.push({
lat: parseFloat(String(record.arrival_lat)),
lng: parseFloat(String(record.arrival_lng)),
type: 'arrival',
label: 'Příchod',
type: "arrival",
label: "Příchod",
time: formatTime(record.arrival_time),
accuracy: Number(record.arrival_accuracy) || 0
})
accuracy: Number(record.arrival_accuracy) || 0,
});
}
if (hasDepartureLocation) {
locations.push({
lat: parseFloat(String(record.departure_lat)),
lng: parseFloat(String(record.departure_lng)),
type: 'departure',
label: 'Odchod',
type: "departure",
label: "Odchod",
time: formatTime(record.departure_time),
accuracy: Number(record.departure_accuracy) || 0
})
accuracy: Number(record.departure_accuracy) || 0,
});
}
locations.forEach(loc => {
const color = loc.type === 'arrival' ? '#22c55e' : '#ef4444'
locations.forEach((loc) => {
const color = loc.type === "arrival" ? "#22c55e" : "#ef4444";
const marker = L.circleMarker([loc.lat, loc.lng], {
radius: 10,
fillColor: color,
color: '#fff',
color: "#fff",
weight: 2,
opacity: 1,
fillOpacity: 0.8
}).addTo(map)
fillOpacity: 0.8,
}).addTo(map);
marker.bindPopup(`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`)
marker.bindPopup(
`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`,
);
if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], {
@@ -157,55 +161,78 @@ export default function AttendanceLocation() {
color: color,
weight: 1,
opacity: 0.3,
fillOpacity: 0.1
}).addTo(map)
fillOpacity: 0.1,
}).addTo(map);
}
bounds.push([loc.lat, loc.lng])
})
bounds.push([loc.lat, loc.lng]);
});
if (bounds.length === 1) {
map.setView(bounds[0], 16)
map.setView(bounds[0], 16);
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [50, 50] })
map.fitBounds(bounds, { padding: [50, 50] });
}
}
};
loadLeaflet()
loadLeaflet();
return () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove()
mapInstanceRef.current = null
(mapInstanceRef.current as { remove: () => void }).remove();
mapInstanceRef.current = null;
}
}
}, [record, loading])
};
}, [record, loading]);
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return '—'
const d = new Date(datetime)
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`
}
if (!datetime) return "—";
const d = new Date(datetime);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
};
if (!hasPermission('attendance.admin')) return <Forbidden />
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 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
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
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" 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>
@@ -213,18 +240,20 @@ export default function AttendanceLocation() {
))}
</div>
</div>
)
);
}
if (!record) {
return null
return null;
}
const hasArrivalLocation = record.arrival_lat && record.arrival_lng
const hasDepartureLocation = record.departure_lat && record.departure_lng
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation
const shiftDateStr = record.shift_date.includes('T') ? record.shift_date.split('T')[0] : record.shift_date
const month = shiftDateStr.substring(0, 7)
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
const shiftDateStr = record.shift_date.includes("T")
? record.shift_date.split("T")[0]
: record.shift_date;
const month = shiftDateStr.substring(0, 7);
return (
<div>
@@ -238,7 +267,10 @@ export default function AttendanceLocation() {
<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">
<Link
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
@@ -257,18 +289,19 @@ export default function AttendanceLocation() {
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div
ref={mapRef}
className="attendance-location-map"
/>
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div className={`attendance-location-card ${!hasArrivalLocation ? 'empty' : ''}`}>
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time ? formatDatetimeLocal(record.arrival_time) : '—'}
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
{hasArrivalLocation ? (
<>
@@ -277,7 +310,8 @@ export default function AttendanceLocation() {
</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)`}
{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}`}
@@ -297,10 +331,14 @@ export default function AttendanceLocation() {
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div className={`attendance-location-card ${!hasDepartureLocation ? 'empty' : ''}`}>
<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) : '—'}
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
{hasDepartureLocation ? (
<>
@@ -309,7 +347,8 @@ export default function AttendanceLocation() {
</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)`}
{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}`}
@@ -331,5 +370,5 @@ export default function AttendanceLocation() {
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,220 +1,263 @@
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import Forbidden from '../components/Forbidden'
import Pagination from '../components/Pagination'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import { czechPlural } from '../utils/formatters'
import apiFetch from '../utils/api'
import { useState, useEffect, useCallback } from "react";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import Forbidden from "../components/Forbidden";
import Pagination from "../components/Pagination";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const ACTION_LABELS: Record<string, string> = {
create: 'Vytvoření',
update: 'Úprava',
delete: 'Smazání',
login: 'Přihlášení',
login_failed: 'Neúspěšné přihlášení',
logout: 'Odhlášení',
view: 'Zobrazení',
activate: 'Aktivace',
deactivate: 'Deaktivace',
password_change: 'Změna hesla',
permission_change: 'Změna oprávnění',
access_denied: 'Přístup odepřen',
}
create: "Vytvoření",
update: "Úprava",
delete: "Smazání",
login: "Přihlášení",
login_failed: "Neúspěšné přihlášení",
logout: "Odhlášení",
view: "Zobrazení",
activate: "Aktivace",
deactivate: "Deaktivace",
password_change: "Změna hesla",
permission_change: "Změna oprávnění",
access_denied: "Přístup odepřen",
};
const ACTION_BADGE_CLASS: Record<string, string> = {
create: 'admin-badge-success',
update: 'admin-badge-info',
delete: 'admin-badge-danger',
login: 'admin-badge-secondary',
login_failed: 'admin-badge-danger',
logout: 'admin-badge-secondary',
view: 'admin-badge-info',
activate: 'admin-badge-success',
deactivate: 'admin-badge-warning',
password_change: 'admin-badge-info',
permission_change: 'admin-badge-warning',
access_denied: 'admin-badge-danger',
}
create: "admin-badge-success",
update: "admin-badge-info",
delete: "admin-badge-danger",
login: "admin-badge-secondary",
login_failed: "admin-badge-danger",
logout: "admin-badge-secondary",
view: "admin-badge-info",
activate: "admin-badge-success",
deactivate: "admin-badge-warning",
password_change: "admin-badge-info",
permission_change: "admin-badge-warning",
access_denied: "admin-badge-danger",
};
const ENTITY_TYPE_LABELS: Record<string, string> = {
user: 'Uživatel',
attendance: 'Docházka',
leave_request: 'Žádost o nepřítomnost',
offers_quotation: 'Nabídka',
offers_customer: 'Zákazník',
offers_item_template: 'Šablona položky',
offers_scope_template: 'Šablona rozsahu',
offers_settings: 'Nastavení nabídek',
orders_order: 'Objednávka',
invoices_invoice: 'Faktura',
projects_project: 'Projekt',
role: 'Role',
trips: 'Jízda',
vehicles: 'Vozidlo',
bank_account: 'Bankovní účet',
}
user: "Uživatel",
attendance: "Docházka",
leave_request: "Žádost o nepřítomnost",
offers_quotation: "Nabídka",
offers_customer: "Zákazník",
offers_item_template: "Šablona položky",
offers_scope_template: "Šablona rozsahu",
offers_settings: "Nastavení nabídek",
orders_order: "Objednávka",
invoices_invoice: "Faktura",
projects_project: "Projekt",
role: "Role",
trips: "Jízda",
vehicles: "Vozidlo",
bank_account: "Bankovní účet",
};
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({ value, label }))
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(([value, label]) => ({ value, label }))
const ACTION_OPTIONS = Object.entries(ACTION_LABELS).map(([value, label]) => ({
value,
label,
}));
const ENTITY_OPTIONS = Object.entries(ENTITY_TYPE_LABELS).map(
([value, label]) => ({ value, label }),
);
interface AuditLogEntry {
id: number
created_at: string
username: string | null
action: string
entity_type: string | null
description: string | null
user_ip: string | null
id: number;
created_at: string;
username: string | null;
action: string;
entity_type: string | null;
description: string | null;
user_ip: string | null;
}
interface PaginationData {
total: number
page: number
per_page: number
total_pages: number
total: number;
page: number;
per_page: number;
total_pages: number;
}
interface Filters {
search: string
action: string
entity_type: string
date_from: string
date_to: string
search: string;
action: string;
entity_type: string;
date_from: string;
date_to: string;
}
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 { hasPermission } = useAuth();
const alert = useAlert();
const [logs, setLogs] = useState<AuditLogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [pagination, setPagination] = useState<PaginationData | null>(null);
const [filters, setFilters] = useState<Filters>({
search: '',
action: '',
entity_type: '',
date_from: '',
date_to: '',
})
const [showCleanup, setShowCleanup] = useState(false)
const [cleanupDays, setCleanupDays] = useState(90)
const [cleaning, setCleaning] = useState(false)
search: "",
action: "",
entity_type: "",
date_from: "",
date_to: "",
});
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 fetchLogs = useCallback(
async (page = 1, perPage = 50) => {
setLoading(true);
try {
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()
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')
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);
}
} catch {
alert.error('Chyba připojení')
} finally {
setLoading(false)
}
}, [filters, alert])
},
[filters, alert],
);
useEffect(() => {
fetchLogs()
}, [fetchLogs])
fetchLogs();
}, [fetchLogs]);
if (!hasPermission('settings.audit')) {
return <Forbidden />
if (!hasPermission("settings.audit")) {
return <Forbidden />;
}
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }))
}
setFilters((prev) => ({ ...prev, [key]: value }));
};
const handlePageChange = (newPage: number) => {
fetchLogs(newPage, pagination?.per_page || 50)
}
fetchLogs(newPage, pagination?.per_page || 50);
};
const handlePerPageChange = (newPerPage: number) => {
fetchLogs(1, newPerPage)
}
fetchLogs(1, newPerPage);
};
const handleCleanup = async () => {
setCleaning(true)
setCleaning(true);
try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days: cleanupDays }),
})
const data = await response.json()
});
const data = await response.json();
if (data.success) {
alert.success(data.message)
setShowCleanup(false)
fetchLogs()
alert.success(data.message);
setShowCleanup(false);
fetchLogs();
} else {
alert.error(data.error)
alert.error(data.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setCleaning(false)
setCleaning(false);
}
}
};
const formatDatetime = (dateString: string | null): string => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('cs-CZ')
}
if (!dateString) return "-";
return new Date(dateString).toLocaleString("cs-CZ");
};
if (loading && logs.length === 0) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<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
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
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' }} />
<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"
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
className="admin-skeleton-line"
style={{ width: "90px" }}
/>
</div>
))}
</div>
</div>
</div>
)
);
}
return (
@@ -229,7 +272,8 @@ export default function AuditLog() {
<h1 className="admin-page-title">Audit log</h1>
{pagination && (
<p className="admin-page-subtitle">
{pagination.total} {czechPlural(pagination.total, 'záznam', 'záznamy', 'záznamů')}
{pagination.total}{" "}
{czechPlural(pagination.total, "záznam", "záznamy", "záznamů")}
</p>
)}
</div>
@@ -237,7 +281,14 @@ export default function AuditLog() {
className="admin-btn admin-btn-secondary admin-btn-sm"
onClick={() => setShowCleanup(true)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="14"
height="14"
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>
@@ -247,7 +298,10 @@ export default function AuditLog() {
{showCleanup && (
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
<div className="admin-modal-backdrop" onClick={() => !cleaning && setShowCleanup(false)} />
<div
className="admin-modal-backdrop"
onClick={() => !cleaning && setShowCleanup(false)}
/>
<motion.div
className="admin-modal admin-confirm-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -256,14 +310,23 @@ export default function AuditLog() {
>
<div className="admin-modal-body admin-confirm-content">
<div className="admin-confirm-icon admin-confirm-icon-danger">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="24"
height="24"
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>
</div>
<h2 className="admin-confirm-title">Vyčistit audit log</h2>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: '0.75rem auto', maxWidth: '200px' }}>
<p className="admin-confirm-message">
Smazat záznamy starší než:
</p>
<div style={{ margin: "0.75rem auto", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
@@ -277,7 +340,12 @@ export default function AuditLog() {
<option value={0}>Vše</option>
</select>
</div>
<p className="admin-confirm-message" style={{ fontSize: '12px', opacity: 0.6 }}>Tato akce je nevratná.</p>
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</div>
<div className="admin-modal-footer">
<button
@@ -294,7 +362,7 @@ export default function AuditLog() {
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? 'Mažu...' : 'Smazat'}
{cleaning ? "Mažu..." : "Smazat"}
</button>
</div>
</motion.div>
@@ -315,18 +383,20 @@ export default function AuditLog() {
className="admin-form-input"
placeholder="Popis, uživatel..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
onChange={(e) => handleFilterChange("search", e.target.value)}
/>
</FormField>
<FormField label="Akce">
<select
className="admin-form-select"
value={filters.action}
onChange={(e) => handleFilterChange('action', e.target.value)}
onChange={(e) => handleFilterChange("action", e.target.value)}
>
<option value="">Všechny</option>
{ACTION_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
{ACTION_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
@@ -334,11 +404,15 @@ export default function AuditLog() {
<select
className="admin-form-select"
value={filters.entity_type}
onChange={(e) => handleFilterChange('entity_type', e.target.value)}
onChange={(e) =>
handleFilterChange("entity_type", e.target.value)
}
>
<option value="">Všechny</option>
{ENTITY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
{ENTITY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</FormField>
@@ -346,14 +420,14 @@ export default function AuditLog() {
<AdminDatePicker
mode="date"
value={filters.date_from}
onChange={(val: string) => handleFilterChange('date_from', val)}
onChange={(val: string) => handleFilterChange("date_from", val)}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={filters.date_to}
onChange={(val: string) => handleFilterChange('date_to', val)}
onChange={(val: string) => handleFilterChange("date_to", val)}
/>
</FormField>
</div>
@@ -380,22 +454,64 @@ export default function AuditLog() {
</tr>
</thead>
<tbody>
{loading && Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td><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" style={{ width: '70px', height: '22px', borderRadius: '10px' }} /></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 &&
Array.from({ length: 10 }, (_, i) => (
<tr key={`skeleton-${i}`}>
<td>
<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"
style={{
width: "70px",
height: "22px",
borderRadius: "10px",
}}
/>
</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">
<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" />
@@ -407,20 +523,29 @@ export default function AuditLog() {
</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>
))}
{!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>
))}
</tbody>
</table>
</div>
@@ -433,5 +558,5 @@ export default function AuditLog() {
</div>
</motion.div>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,203 +1,203 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import useModalLock from '../hooks/useModalLock'
import apiFetch from '../utils/api'
import { getCzechDate } from '../utils/dashboardHelpers'
import DashKpiCards from '../components/dashboard/DashKpiCards'
import DashQuickActions from '../components/dashboard/DashQuickActions'
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 { useState, useEffect, useCallback } from "react";
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import { getCzechDate } from "../utils/dashboardHelpers";
import DashKpiCards from "../components/dashboard/DashKpiCards";
import DashQuickActions from "../components/dashboard/DashQuickActions";
import DashActivityFeed from "../components/dashboard/DashActivityFeed";
import DashAttendanceToday from "../components/dashboard/DashAttendanceToday";
import DashProfile from "../components/dashboard/DashProfile";
import DashSessions from "../components/dashboard/DashSessions";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DashData = Record<string, any>
type DashData = Record<string, any>;
export default function Dashboard() {
const { user, updateUser, hasPermission } = useAuth()
const alert = useAlert()
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 [dashData, setDashData] = useState<DashData | null>(null);
const [dashLoading, setDashLoading] = useState(true);
const [punching, setPunching] = useState(false);
// 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)
const [totpQrUri, setTotpQrUri] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [totpSubmitting, setTotpSubmitting] = useState(false)
const [backupCodes, setBackupCodes] = useState<string[] | null>(null)
const [disableCode, setDisableCode] = useState('')
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);
const [totpQrUri, setTotpQrUri] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState("");
const [totpSubmitting, setTotpSubmitting] = useState(false);
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [disableCode, setDisableCode] = useState("");
useModalLock(show2FASetup)
useModalLock(show2FADisable)
useModalLock(show2FASetup);
useModalLock(show2FADisable);
const fetchDashboard = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/dashboard`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/dashboard`);
const data = await response.json();
if (data.success !== false) {
setDashData(data.data || data)
setDashData(data.data || data);
}
} catch (err) {
if (import.meta.env.DEV) {
console.error('Dashboard fetch error:', err)
console.error("Dashboard fetch error:", err);
}
} finally {
setDashLoading(false)
setDashLoading(false);
}
}, [])
}, []);
useEffect(() => {
fetchDashboard()
}, [fetchDashboard])
fetchDashboard();
}, [fetchDashboard]);
// 2FA status fetch
const fetch2FAStatus = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/totp/setup`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpEnabled(!!user?.totpEnabled)
setTotpEnabled(!!user?.totpEnabled);
}
} catch {
// 2FA status fetch failed silently
setTotpEnabled(!!user?.totpEnabled)
setTotpEnabled(!!user?.totpEnabled);
} finally {
setTotpLoading(false)
setTotpLoading(false);
}
}, [user?.totpEnabled])
}, [user?.totpEnabled]);
useEffect(() => {
fetch2FAStatus()
}, [fetch2FAStatus])
fetch2FAStatus();
}, [fetch2FAStatus]);
// Punch (prichod/odchod) primo z dashboardu
const handleQuickPunch = () => {
const action = dashData?.my_shift?.has_ongoing ? 'departure' : 'arrival'
setPunching(true)
const action = dashData?.my_shift?.has_ongoing ? "departure" : "arrival";
setPunching(true);
const submitPunch = async (gpsData: Record<string, unknown> = {}) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ punch_action: action, ...gpsData })
})
const result = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
});
const result = await response.json();
if (result.success) {
alert.success(result.data?.message || 'Docházka zaznamenána')
fetchDashboard()
alert.success(result.data?.message || "Docházka zaznamenána");
fetchDashboard();
} else {
alert.error(result.error || 'Chyba při záznamu docházky')
alert.error(result.error || "Chyba při záznamu docházky");
}
} catch {
alert.error('Chyba pripojeni')
alert.error("Chyba pripojeni");
} finally {
setPunching(false)
setPunching(false);
}
}
};
if (!navigator.geolocation) {
submitPunch({})
return
submitPunch({});
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const { latitude, longitude, accuracy } = pos.coords
submitPunch({ latitude, longitude, accuracy, address: '' })
const { latitude, longitude, accuracy } = pos.coords;
submitPunch({ latitude, longitude, accuracy, address: "" });
},
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
)
}
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
};
// 2FA handlery
const handleStart2FASetup = async () => {
setTotpSubmitting(true)
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/setup`)
const data = await response.json()
const response = await apiFetch(`${API_BASE}/totp/setup`);
const data = await response.json();
if (data.success) {
setTotpSecret(data.data.secret)
setTotpQrUri(data.data.uri || data.data.qr_uri)
setTotpCode('')
setBackupCodes(null)
setShow2FASetup(true)
setTotpSecret(data.data.secret);
setTotpQrUri(data.data.uri || data.data.qr_uri);
setTotpCode("");
setBackupCodes(null);
setShow2FASetup(true);
} else {
alert.error(data.error || 'Nepodařilo se vygenerovat 2FA klíč')
alert.error(data.error || "Nepodařilo se vygenerovat 2FA klíč");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false)
setTotpSubmitting(false);
}
}
};
const handleConfirm2FA = async () => {
if (!totpCode.trim()) return
setTotpSubmitting(true)
if (!totpCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() })
})
const data = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: totpSecret, code: totpCode.trim() }),
});
const data = await response.json();
if (data.success) {
setTotpEnabled(true)
setBackupCodes(data.data?.backup_codes || null)
setTotpSecret(null)
setTotpQrUri(null)
updateUser({ totpEnabled: true })
alert.success('2FA bylo aktivováno')
setTotpEnabled(true);
setBackupCodes(data.data?.backup_codes || null);
setTotpSecret(null);
setTotpQrUri(null);
updateUser({ totpEnabled: true });
alert.success("2FA bylo aktivováno");
} else {
alert.error(data.error || 'Neplatný kód')
setTotpCode('')
alert.error(data.error || "Neplatný kód");
setTotpCode("");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false)
setTotpSubmitting(false);
}
}
};
const handleDisable2FA = async () => {
if (!disableCode.trim()) return
setTotpSubmitting(true)
if (!disableCode.trim()) return;
setTotpSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/totp/disable`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: disableCode.trim() })
})
const data = await response.json()
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: disableCode.trim() }),
});
const data = await response.json();
if (data.success) {
setTotpEnabled(false)
setShow2FADisable(false)
setDisableCode('')
updateUser({ totpEnabled: false })
alert.success('2FA bylo deaktivováno')
setTotpEnabled(false);
setShow2FADisable(false);
setDisableCode("");
updateUser({ totpEnabled: false });
alert.success("2FA bylo deaktivováno");
} else {
alert.error(data.error || 'Neplatný kód')
setDisableCode('')
alert.error(data.error || "Neplatný kód");
setDisableCode("");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setTotpSubmitting(false)
setTotpSubmitting(false);
}
}
};
return (
<div className="dash">
@@ -223,29 +223,66 @@ export default function Dashboard() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ border: '2px solid var(--danger)', background: 'var(--danger-light)' }}
style={{
border: "2px solid var(--danger)",
background: "var(--danger-light)",
}}
>
<div className="admin-card-body" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '1rem', flexWrap: 'wrap' }}>
<div
className="admin-card-body"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "1rem",
flexWrap: "wrap",
}}
>
<div className="flex-row-gap">
<div style={{
width: 40, height: 40, borderRadius: '50%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'var(--danger-light)', color: 'var(--danger)', flexShrink: 0
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div
style={{
width: 40,
height: 40,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--danger-light)",
color: "var(--danger)",
flexShrink: 0,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</div>
<div>
<div className="fw-600">Dvoufaktorové ověření je povinné</div>
<div className="text-secondary" style={{ fontSize: '0.875rem' }}>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete, nemáte přístup k ostatním sekcím systému.
<div
className="text-secondary"
style={{ fontSize: "0.875rem" }}
>
Administrátor vyžaduje aktivaci 2FA. Dokud ji neaktivujete,
nemáte přístup k ostatním sekcím systému.
</div>
</div>
</div>
<button onClick={handleStart2FASetup} disabled={totpSubmitting} className="admin-btn admin-btn-primary" style={{ flexShrink: 0 }}>
{totpSubmitting ? 'Generuji...' : 'Aktivovat 2FA nyní'}
<button
onClick={handleStart2FASetup}
disabled={totpSubmitting}
className="admin-btn admin-btn-primary"
style={{ flexShrink: 0 }}
>
{totpSubmitting ? "Generuji..." : "Aktivovat 2FA nyní"}
</button>
</div>
</motion.div>
@@ -253,36 +290,70 @@ export default function Dashboard() {
{/* Skeleton loading */}
{dashLoading && (
<div className="admin-skeleton" style={{ padding: 0, gap: '1.25rem' }}>
<div className="admin-skeleton" style={{ padding: 0, gap: "1.25rem" }}>
<div className="dash-kpi-grid dash-kpi-4">
{[0, 1, 2, 3].map(i => (
<div key={i} className="admin-skeleton-line h-24" style={{ borderRadius: '10px' }} />
{[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' }} />
{[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
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
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
<div
className="admin-skeleton-line"
style={{ height: "200px", borderRadius: "10px" }}
/>
</div>
</div>
)}
{/* KPI cards — only show if user has any admin-level permissions */}
{!dashLoading && (hasPermission('offers.view') || hasPermission('invoices.view') || hasPermission('projects.view') || hasPermission('orders.view')) && (
<DashKpiCards dashData={dashData} />
)}
{!dashLoading &&
(hasPermission("offers.view") ||
hasPermission("invoices.view") ||
hasPermission("projects.view") ||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />}
{/* Quick actions */}
{!dashLoading && (
@@ -294,87 +365,125 @@ export default function Dashboard() {
)}
{/* Main content grid */}
{!dashLoading && <motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
{hasPermission('settings.audit') && <DashActivityFeed activities={dashData?.recent_activity} />}
{!dashLoading && (
<motion.div
className="dash-main-grid"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
{hasPermission("settings.audit") && (
<DashActivityFeed activities={dashData?.recent_activity} />
)}
{hasPermission('attendance.admin') && <DashAttendanceToday attendance={dashData?.attendance} />}
{hasPermission("attendance.admin") && (
<DashAttendanceToday attendance={dashData?.attendance} />
)}
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link to="/projects" className="admin-btn admin-btn-primary admin-btn-sm">Vše &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map((p: { id: number; name: string; customer_name: string | null }) => (
<Link key={p.id} to={`/projects/${p.id}`} className="dash-project-row">
<div className="dash-project-name">{p.name}</div>
{p.customer_name && <div className="dash-project-customer">{p.customer_name}</div>}
{/* Pravy sloupec: projekty + nabidky */}
<div className="dash-right-col">
{dashData?.projects && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Aktivní projekty</h2>
<Link
to="/projects"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Vše &rarr;
</Link>
))}
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
{dashData.projects.active_projects.length === 0 && (
<div className="dash-empty-row">Žádné aktivní projekty</div>
)}
{dashData.projects.active_projects.map(
(p: {
id: number;
name: string;
customer_name: string | null;
}) => (
<Link
key={p.id}
to={`/projects/${p.id}`}
className="dash-project-row"
>
<div className="dash-project-name">{p.name}</div>
{p.customer_name && (
<div className="dash-project-customer">
{p.customer_name}
</div>
)}
</Link>
),
)}
</div>
</div>
</div>
)}
)}
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link to="/offers" className="admin-btn admin-btn-primary admin-btn-sm">Zobrazit &rarr;</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">{dashData.offers.open_count}</span>
{dashData?.offers && (
<div className="admin-card">
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Nabídky</h2>
<Link
to="/offers"
className="admin-btn admin-btn-primary admin-btn-sm"
>
Zobrazit &rarr;
</Link>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">{dashData.offers.converted_count}</span>
</div>
<div className="dash-stat-row">
<span>Prošlé</span>
<span className="admin-badge admin-badge-warning">{dashData.offers.expired_count}</span>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="dash-stat-row">
<span>Otevřené</span>
<span className="admin-badge admin-badge-info">
{dashData.offers.open_count}
</span>
</div>
<div className="dash-stat-row">
<span>Převedené na objednávku</span>
<span className="admin-badge admin-badge-success">
{dashData.offers.converted_count}
</span>
</div>
<div className="dash-stat-row">
<span>Prošlé</span>
<span className="admin-badge admin-badge-warning">
{dashData.offers.expired_count}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</motion.div>}
)}
</div>
</motion.div>
)}
{/* Profile + Sessions */}
{!dashLoading && <div className="dash-bottom">
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>}
{!dashLoading && (
<div className="dash-bottom">
<DashProfile
totpEnabled={totpEnabled}
totpLoading={totpLoading}
totpSubmitting={totpSubmitting}
onStart2FASetup={handleStart2FASetup}
onConfirm2FA={handleConfirm2FA}
onDisable2FA={handleDisable2FA}
totpSecret={totpSecret}
totpQrUri={totpQrUri}
totpCode={totpCode}
setTotpCode={setTotpCode}
backupCodes={backupCodes}
setBackupCodes={setBackupCodes}
show2FASetup={show2FASetup}
setShow2FASetup={setShow2FASetup}
show2FADisable={show2FADisable}
setShow2FADisable={setShow2FADisable}
disableCode={disableCode}
setDisableCode={setDisableCode}
/>
<DashSessions />
</div>
)}
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,87 @@
import { useState, useEffect, useCallback } from 'react'
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 ConfirmModal from '../components/ConfirmModal'
import Forbidden from '../components/Forbidden'
import useModalLock from '../hooks/useModalLock'
import FormField from '../components/FormField'
import { useState, useEffect, useCallback } from "react";
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 ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import FormField from "../components/FormField";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
vacation: "Dovolená",
sick: "Nemoc",
unpaid: "Neplacené volno",
};
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
pending: "Čeká na schválení",
approved: "Schváleno",
rejected: "Zamítnuto",
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
};
interface RawLeaveRequest {
id: number
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
users_leave_requests_user_idTousers?: { first_name: string; last_name: string }
users_leave_requests_reviewer_idTousers?: { first_name: string; last_name: string } | null
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
reviewed_at?: string;
users_leave_requests_user_idTousers?: {
first_name: string;
last_name: string;
};
users_leave_requests_reviewer_idTousers?: {
first_name: string;
last_name: string;
} | null;
}
interface LeaveRequest {
id: number
employee_name: string
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_name?: string
reviewer_note?: string
created_at: string
reviewed_at?: string
id: number;
employee_name: string;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_name?: string;
reviewer_note?: string;
created_at: string;
reviewed_at?: string;
}
function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
const user = raw.users_leave_requests_user_idTousers
const reviewer = raw.users_leave_requests_reviewer_idTousers
const user = raw.users_leave_requests_user_idTousers;
const reviewer = raw.users_leave_requests_reviewer_idTousers;
return {
id: raw.id,
employee_name: user ? `${user.first_name} ${user.last_name}` : 'Neznámý',
employee_name: user ? `${user.first_name} ${user.last_name}` : "Neznámý",
leave_type: raw.leave_type,
date_from: raw.date_from,
date_to: raw.date_to,
@@ -83,152 +89,196 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
total_hours: raw.total_hours,
status: raw.status,
notes: raw.notes,
reviewer_name: reviewer ? `${reviewer.first_name} ${reviewer.last_name}` : undefined,
reviewer_name: reviewer
? `${reviewer.first_name} ${reviewer.last_name}`
: undefined,
reviewer_note: raw.reviewer_note,
created_at: raw.created_at,
reviewed_at: raw.reviewed_at,
}
};
}
export default function LeaveApproval() {
const { hasPermission } = useAuth()
const alert = useAlert()
const [loading, setLoading] = useState(true)
const [activeTab, setActiveTab] = useState<'pending' | 'processed'>('pending')
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([])
const [pendingCount, setPendingCount] = useState(0)
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>([])
const [approveModal, setApproveModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectModal, setRejectModal] = useState<{ open: boolean; request: LeaveRequest | null }>({ open: false, request: null })
const [rejectNote, setRejectNote] = useState('')
const [processing, setProcessing] = useState(false)
const { hasPermission } = useAuth();
const alert = useAlert();
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending",
);
const [pendingRequests, setPendingRequests] = useState<LeaveRequest[]>([]);
const [pendingCount, setPendingCount] = useState(0);
const [processedRequests, setProcessedRequests] = useState<LeaveRequest[]>(
[],
);
const [approveModal, setApproveModal] = useState<{
open: boolean;
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectModal, setRejectModal] = useState<{
open: boolean;
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectNote, setRejectNote] = useState("");
const [processing, setProcessing] = useState(false);
useModalLock(rejectModal.open)
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()
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)
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.error("Nepodařilo se načíst žádosti");
}
}, [alert])
}, [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 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 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) => new Date(b.reviewed_at!).getTime() - new Date(a.reviewed_at!).getTime())
...(resultApproved.success
? (resultApproved.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
...(resultRejected.success
? (resultRejected.data as RawLeaveRequest[]).map(mapLeaveRequest)
: []),
].sort(
(a: LeaveRequest, b: LeaveRequest) =>
new Date(b.reviewed_at!).getTime() -
new Date(a.reviewed_at!).getTime(),
);
setProcessedRequests(all)
setProcessedRequests(all);
} catch {
alert.error('Nepodařilo se načíst vyřízené žádosti')
alert.error("Nepodařilo se načíst vyřízené žádosti");
}
}, [alert])
}, [alert]);
useEffect(() => {
setLoading(true)
fetchPending().finally(() => setLoading(false))
}, [fetchPending])
setLoading(true);
fetchPending().finally(() => setLoading(false));
}, [fetchPending]);
useEffect(() => {
if (activeTab === 'processed' && processedRequests.length === 0) {
fetchProcessed()
if (activeTab === "processed" && processedRequests.length === 0) {
fetchProcessed();
}
}, [activeTab, processedRequests.length, fetchProcessed])
}, [activeTab, processedRequests.length, fetchProcessed]);
if (!hasPermission('attendance.approve')) return <Forbidden />
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
setProcessing(true)
setProcessing(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${approveModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' })
})
if (response.status === 401) return
const response = await apiFetch(
`${API_BASE}/leave-requests/${approveModal.request!.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "approved" }),
},
);
if (response.status === 401) return;
const result = await response.json()
const result = await response.json();
if (result.success) {
setApproveModal({ open: false, request: null })
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla schválena')
setApproveModal({ open: false, request: null });
await fetchPending();
setProcessedRequests([]);
alert.success("Žádost byla schválena");
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setProcessing(false)
setProcessing(false);
}
}
};
const handleReject = async () => {
if (!rejectNote.trim()) {
alert.error('Důvod zamítnutí je povinný')
return
alert.error("Důvod zamítnutí je povinný");
return;
}
setProcessing(true)
setProcessing(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${rejectModal.request!.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'rejected', reviewer_note: rejectNote })
})
if (response.status === 401) return
const response = await apiFetch(
`${API_BASE}/leave-requests/${rejectModal.request!.id}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
status: "rejected",
reviewer_note: rejectNote,
}),
},
);
if (response.status === 401) return;
const result = await response.json()
const result = await response.json();
if (result.success) {
setRejectModal({ open: false, request: null })
setRejectNote('')
await fetchPending()
setProcessedRequests([])
alert.success('Žádost byla zamítnuta')
setRejectModal({ open: false, request: null });
setRejectNote("");
await fetchPending();
setProcessedRequests([]);
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setProcessing(false)
setProcessing(false);
}
}
};
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" 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
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 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
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -236,7 +286,7 @@ export default function LeaveApproval() {
</div>
</div>
</div>
)
);
}
return (
@@ -251,9 +301,8 @@ export default function LeaveApproval() {
<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'
}
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</div>
</motion.div>
@@ -266,19 +315,26 @@ export default function LeaveApproval() {
>
<div className="offers-tabs mb-6">
<button
className={`offers-tab ${activeTab === 'pending' ? 'active' : ''}`}
onClick={() => setActiveTab('pending')}
className={`offers-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' }}>
<span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount}
</span>
)}
</button>
<button
className={`offers-tab ${activeTab === 'processed' ? 'active' : ''}`}
onClick={() => setActiveTab('processed')}
className={`offers-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
@@ -286,7 +342,7 @@ export default function LeaveApproval() {
</motion.div>
{/* Pending Tab */}
{activeTab === 'pending' && (
{activeTab === "pending" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
@@ -296,7 +352,15 @@ export default function LeaveApproval() {
<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">
<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>
@@ -305,43 +369,100 @@ export default function LeaveApproval() {
</div>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<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', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: '1rem' }}>
<div
className="admin-card-body"
style={{ padding: "1.25rem" }}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: "1rem",
}}
>
<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] || ''}`}>
<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' }}>
<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>
<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>
<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' }}>
<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 }}>
<div
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
}}
>
<button
onClick={() => setApproveModal({ open: true, request: req })}
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--success-light)', color: 'var(--success)', border: 'none' }}
style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
>
Schválit
</button>
<button
onClick={() => setRejectModal({ open: true, request: req })}
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{ background: 'var(--danger-light)', color: 'var(--danger)', border: 'none' }}
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
@@ -356,7 +477,7 @@ export default function LeaveApproval() {
)}
{/* Processed Tab */}
{activeTab === 'processed' && (
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
@@ -387,29 +508,46 @@ export default function LeaveApproval() {
<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] || ''}`}>
<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">
{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] || ''}`}>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || '—'}</td>
<td style={{ maxWidth: '200px' }}>
<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}
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : '—'}
) : (
"—"
)}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
@@ -428,9 +566,10 @@ export default function LeaveApproval() {
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}?`
: ''
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"
@@ -447,7 +586,13 @@ export default function LeaveApproval() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }} />
<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 }}
@@ -461,8 +606,11 @@ export default function LeaveApproval() {
<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ů)
{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>
@@ -479,7 +627,10 @@ export default function LeaveApproval() {
<div className="admin-modal-footer">
<button
type="button"
onClick={() => { setRejectModal({ open: false, request: null }); setRejectNote('') }}
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
@@ -491,7 +642,7 @@ export default function LeaveApproval() {
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? 'Zpracování...' : 'Zamítnout'}
{processing ? "Zpracování..." : "Zamítnout"}
</button>
</div>
</motion.div>
@@ -499,5 +650,5 @@ export default function LeaveApproval() {
)}
</AnimatePresence>
</div>
)
);
}

View File

@@ -1,124 +1,142 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import Forbidden from '../components/Forbidden'
import { formatDate, formatDatetime } from '../utils/attendanceHelpers'
import apiFetch from '../utils/api'
import ConfirmModal from '../components/ConfirmModal'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const leaveTypeLabels: Record<string, string> = {
vacation: 'Dovolená',
sick: 'Nemoc',
unpaid: 'Neplacené volno'
}
vacation: "Dovolená",
sick: "Nemoc",
unpaid: "Neplacené volno",
};
const statusLabels: Record<string, string> = {
pending: 'Čeká na schválení',
approved: 'Schváleno',
rejected: 'Zamítnuto',
cancelled: 'Zrušeno'
}
pending: "Čeká na schválení",
approved: "Schváleno",
rejected: "Zamítnuto",
cancelled: "Zrušeno",
};
const statusClasses: Record<string, string> = {
pending: 'badge-pending',
approved: 'badge-approved',
rejected: 'badge-rejected',
cancelled: 'badge-cancelled'
}
pending: "badge-pending",
approved: "badge-approved",
rejected: "badge-rejected",
cancelled: "badge-cancelled",
};
const leaveTypeClasses: Record<string, string> = {
vacation: 'badge-vacation',
sick: 'badge-sick',
unpaid: 'badge-unpaid'
}
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
interface LeaveRequest {
id: number
leave_type: string
date_from: string
date_to: string
total_days: number
total_hours: number
status: string
notes?: string
reviewer_note?: string
created_at: string
id: number;
leave_type: string;
date_from: string;
date_to: string;
total_days: number;
total_hours: number;
status: string;
notes?: string;
reviewer_note?: string;
created_at: string;
}
export default function LeaveRequests() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [requests, setRequests] = useState<LeaveRequest[]>([])
const [cancelModal, setCancelModal] = useState<{ open: boolean; id: number | null }>({ open: false, id: null })
const [cancelling, setCancelling] = useState(false)
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [requests, setRequests] = useState<LeaveRequest[]>([]);
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`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(`${API_BASE}/leave-requests`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setRequests(result.data)
setRequests(result.data);
}
} catch {
alert.error('Nepodařilo se načíst žádosti')
alert.error("Nepodařilo se načíst žádosti");
} finally {
setLoading(false)
setLoading(false);
}
}, [alert])
}, [alert]);
useEffect(() => {
fetchRequests()
}, [fetchRequests])
fetchRequests();
}, [fetchRequests]);
if (!hasPermission('attendance.record')) return <Forbidden />
if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => {
setCancelling(true)
setCancelling(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests/${cancelModal.id}`, {
method: 'DELETE',
})
if (response.status === 401) return
const response = await apiFetch(
`${API_BASE}/leave-requests/${cancelModal.id}`,
{
method: "DELETE",
},
);
if (response.status === 401) return;
const result = await response.json()
const result = await response.json();
if (result.success) {
setCancelModal({ open: false, id: null })
await fetchRequests()
alert.success(result.message)
setCancelModal({ open: false, id: null });
await fetchRequests();
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setCancelling(false)
setCancelling(false);
}
}
};
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<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
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
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 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
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -127,26 +145,34 @@ export default function LeaveRequests() {
</div>
</div>
</div>
)
);
}
function renderNoteCell(req: LeaveRequest) {
const truncate = (text: string) => text.length > 40 ? `${text.substring(0, 40)}...` : text
if (req.status === 'rejected' && req.reviewer_note) {
const truncate = (text: string) =>
text.length > 40 ? `${text.substring(0, 40)}...` : text;
if (req.status === "rejected" && req.reviewer_note) {
return (
<span style={{ color: 'var(--danger)', fontSize: '0.875rem' }} title={req.reviewer_note}>
<span
style={{ color: "var(--danger)", fontSize: "0.875rem" }}
title={req.reviewer_note}
>
{truncate(req.reviewer_note)}
</span>
)
);
}
if (req.notes) {
return (
<span className="text-secondary" style={{ fontSize: '0.875rem' }} title={req.notes}>
<span
className="text-secondary"
style={{ fontSize: "0.875rem" }}
title={req.notes}
>
{truncate(req.notes)}
</span>
)
);
}
return <span className="text-muted"></span>
return <span className="text-muted"></span>;
}
return (
@@ -173,7 +199,16 @@ export default function LeaveRequests() {
{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">
<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" />
@@ -181,7 +216,7 @@ export default function LeaveRequests() {
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: '0.875rem', color: 'var(--text-muted)' }}>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
@@ -205,29 +240,40 @@ export default function LeaveRequests() {
{requests.map((req) => (
<tr key={req.id}>
<td>
<span className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ''}`}>
<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_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] || ''}`}>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: '200px' }}>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td className="admin-mono" style={{ whiteSpace: 'nowrap' }}>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
{req.status === 'pending' && (
{req.status === "pending" && (
<button
onClick={() => setCancelModal({ open: true, id: req.id })}
onClick={() =>
setCancelModal({ open: true, id: req.id })
}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zrušit
@@ -254,5 +300,5 @@ export default function LeaveRequests() {
loading={cancelling}
/>
</div>
)
);
}

View File

@@ -1,44 +1,47 @@
import { useState, useEffect, useRef } from 'react'
import { Navigate } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useAuth } from '../context/AuthContext'
import { useAlert } from '../context/AlertContext'
import { useTheme } from '../../context/ThemeContext'
import { shouldShowSessionExpiredAlert, shouldShowLogoutAlert } from '../utils/api'
import FormField from '../components/FormField'
import { useState, useEffect, useRef } from "react";
import { Navigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { useTheme } from "../../context/ThemeContext";
import {
shouldShowSessionExpiredAlert,
shouldShowLogoutAlert,
} from "../utils/api";
import FormField from "../components/FormField";
export default function Login() {
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth()
const alert = useAlert()
const { theme, toggleTheme } = useTheme()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [shake, setShake] = useState(false)
const [animatingOut, setAnimatingOut] = useState(false)
const { login, verify2FA, isAuthenticated, loading: authLoading } = useAuth();
const alert = useAlert();
const { theme, toggleTheme } = useTheme();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [remember, setRemember] = useState(false);
const [loading, setLoading] = useState(false);
const [shake, setShake] = useState(false);
const [animatingOut, setAnimatingOut] = useState(false);
// 2FA state
const [show2FA, setShow2FA] = useState(false)
const [loginToken, setLoginToken] = useState<string | null>(null)
const [totpCode, setTotpCode] = useState('')
const [useBackupCode, setUseBackupCode] = useState(false)
const totpInputRef = useRef<HTMLInputElement>(null)
const [show2FA, setShow2FA] = useState(false);
const [loginToken, setLoginToken] = useState<string | null>(null);
const [totpCode, setTotpCode] = useState("");
const [useBackupCode, setUseBackupCode] = useState(false);
const totpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (shouldShowSessionExpiredAlert()) {
alert.warning('Vaše relace vypršela. Přihlaste se prosím znovu.')
alert.warning("Vaše relace vypršela. Přihlaste se prosím znovu.");
} else if (shouldShowLogoutAlert()) {
alert.success('Byli jste úspěšně odhlášeni.')
alert.success("Byli jste úspěšně odhlášeni.");
}
}, [alert])
}, [alert]);
// Auto-focus TOTP input
useEffect(() => {
if (show2FA && totpInputRef.current) {
totpInputRef.current.focus()
totpInputRef.current.focus();
}
}, [show2FA, useBackupCode])
}, [show2FA, useBackupCode]);
if (authLoading) {
return (
@@ -47,76 +50,83 @@ export default function Login() {
<div className="admin-spinner" />
</div>
</div>
)
);
}
if (isAuthenticated && !animatingOut) {
return <Navigate to="/" replace />
return <Navigate to="/" replace />;
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
e.preventDefault();
setLoading(true);
const result = await login(username, password, remember)
const result = await login(username, password, remember);
if (result.requires2FA) {
setLoginToken(result.loginToken ?? null)
setShow2FA(true)
setTotpCode('')
setLoading(false)
setLoginToken(result.loginToken ?? null);
setShow2FA(true);
setTotpCode("");
setLoading(false);
} else if (!result.success) {
alert.error(result.error ?? 'Chyba přihlášení')
setShake(true)
setTimeout(() => setShake(false), 500)
setLoading(false)
alert.error(result.error ?? "Chyba přihlášení");
setShake(true);
setTimeout(() => setShake(false), 500);
setLoading(false);
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
}
};
const handle2FASubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!totpCode.trim()) return
e.preventDefault();
if (!totpCode.trim()) return;
setLoading(true)
setLoading(true);
const result = await verify2FA(loginToken!, totpCode.trim(), remember, useBackupCode)
const result = await verify2FA(
loginToken!,
totpCode.trim(),
remember,
useBackupCode,
);
if (!result.success) {
alert.error(result.error ?? 'Chyba ověření')
setShake(true)
setTimeout(() => setShake(false), 500)
setTotpCode('')
if (totpInputRef.current) totpInputRef.current.focus()
setLoading(false)
alert.error(result.error ?? "Chyba ověření");
setShake(true);
setTimeout(() => setShake(false), 500);
setTotpCode("");
if (totpInputRef.current) totpInputRef.current.focus();
setLoading(false);
} else {
alert.success('Úspěšně přihlášeno')
setAnimatingOut(true)
setTimeout(() => setAnimatingOut(false), 400)
alert.success("Úspěšně přihlášeno");
setAnimatingOut(true);
setTimeout(() => setAnimatingOut(false), 400);
}
}
};
const handleBack = () => {
setShow2FA(false)
setLoginToken(null)
setTotpCode('')
setUseBackupCode(false)
}
setShow2FA(false);
setLoginToken(null);
setTotpCode("");
setUseBackupCode(false);
};
return (
<motion.div
className="admin-login"
initial={{ opacity: 0, scale: 0.98 }}
animate={animatingOut
? { scale: 1.5, opacity: 0, filter: 'blur(12px)' }
: { scale: 1, opacity: 1, filter: 'none' }
animate={
animatingOut
? { scale: 1.5, opacity: 0, filter: "blur(12px)" }
: { scale: 1, opacity: 1, filter: "none" }
}
transition={animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
transition={
animatingOut
? { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
: { duration: 0.25, ease: [0.4, 0, 0.2, 1] }
}
>
<div className="bg-orb bg-orb-1" />
@@ -125,16 +135,34 @@ export default function Login() {
<button
onClick={toggleTheme}
className="admin-login-theme-btn"
title={theme === 'dark' ? 'Světlý režim' : 'Tmavý režim'}
title={theme === "dark" ? "Světlý režim" : "Tmavý režim"}
>
<span className={`admin-theme-icon ${theme === 'light' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<span
className={`admin-theme-icon ${theme === "light" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</svg>
</span>
<span className={`admin-theme-icon ${theme === 'dark' ? 'visible' : ''}`}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<span
className={`admin-theme-icon ${theme === "dark" ? "visible" : ""}`}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
</span>
@@ -146,19 +174,25 @@ export default function Login() {
key="login"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<img
src={theme === 'dark' ? '/images/logo-dark.png' : '/images/logo-light.png'}
src={
theme === "dark"
? "/images/logo-dark.png"
: "/images/logo-light.png"
}
alt="Logo"
className="admin-login-logo"
/>
@@ -206,15 +240,18 @@ export default function Login() {
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
/>
Přihlašování...
</>
) : (
'Přihlásit se'
"Přihlásit se"
)}
</button>
</form>
@@ -224,19 +261,28 @@ export default function Login() {
key="2fa"
className="admin-login-card"
initial={{ opacity: 0, y: 30 }}
animate={shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
animate={
shake
? { opacity: 1, y: 0, x: [0, -12, 12, -8, 8, -4, 4, 0] }
: { opacity: 1, y: 0 }
}
exit={{ opacity: 0, y: -20 }}
transition={shake
? { x: { duration: 0.5, ease: 'easeOut' } }
: { duration: 0.3 }
transition={
shake
? { x: { duration: 0.5, ease: "easeOut" } }
: { duration: 0.3 }
}
>
<div className="admin-login-header">
<div className="admin-login-2fa-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
@@ -244,36 +290,43 @@ export default function Login() {
<h1 className="admin-login-title">Dvoufaktorové ověření</h1>
<p className="admin-login-subtitle">
{useBackupCode
? 'Zadejte jeden ze záložních kódů'
: 'Zadejte 6místný kód z autentizační aplikace'
}
? "Zadejte jeden ze záložních kódů"
: "Zadejte 6místný kód z autentizační aplikace"}
</p>
</div>
<form onSubmit={handle2FASubmit} className="admin-form">
<FormField label={useBackupCode ? 'Záložní kód' : 'Ověřovací kód'}>
<FormField
label={useBackupCode ? "Záložní kód" : "Ověřovací kód"}
>
<input
ref={totpInputRef}
id="totp-code"
type="text"
inputMode={useBackupCode ? 'text' : 'numeric'}
pattern={useBackupCode ? undefined : '[0-9]*'}
inputMode={useBackupCode ? "text" : "numeric"}
pattern={useBackupCode ? undefined : "[0-9]*"}
maxLength={useBackupCode ? 8 : 6}
value={totpCode}
onChange={(e) => {
const val = useBackupCode ? e.target.value : e.target.value.replace(/\D/g, '')
setTotpCode(val)
const val = useBackupCode
? e.target.value
: e.target.value.replace(/\D/g, "");
setTotpCode(val);
}}
required
autoComplete="one-time-code"
className="admin-form-input"
placeholder={useBackupCode ? 'XXXXXXXX' : '000000'}
style={useBackupCode ? {} : {
textAlign: 'center',
fontSize: '1.5rem',
letterSpacing: '0.5rem',
fontFamily: 'monospace'
}}
placeholder={useBackupCode ? "XXXXXXXX" : "000000"}
style={
useBackupCode
? {}
: {
textAlign: "center",
fontSize: "1.5rem",
letterSpacing: "0.5rem",
fontFamily: "monospace",
}
}
/>
</FormField>
@@ -281,34 +334,54 @@ export default function Login() {
type="submit"
disabled={loading}
className="admin-btn admin-btn-primary"
style={{ width: '100%' }}
style={{ width: "100%" }}
>
{loading ? (
<>
<div className="admin-spinner" style={{ width: 20, height: 20, borderWidth: 2 }} />
<div
className="admin-spinner"
style={{ width: 20, height: 20, borderWidth: 2 }}
/>
Ověřování...
</>
) : (
'Ověřit'
"Ověřit"
)}
</button>
</form>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
marginTop: "0.5rem",
}}
>
<button
onClick={() => {
setUseBackupCode(!useBackupCode)
setTotpCode('')
setUseBackupCode(!useBackupCode);
setTotpCode("");
}}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
{useBackupCode ? 'Použít autentizační aplikaci' : 'Použít záložní kód'}
{useBackupCode
? "Použít autentizační aplikaci"
: "Použít záložní kód"}
</button>
<button
onClick={handleBack}
className="admin-back-link"
style={{ border: 'none', background: 'none', cursor: 'pointer' }}
style={{
border: "none",
background: "none",
cursor: "pointer",
}}
>
&larr; Zpět na přihlášení
</button>
@@ -317,5 +390,5 @@ export default function Login() {
)}
</AnimatePresence>
</motion.div>
)
);
}

View File

@@ -1,30 +1,53 @@
import { Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Link } from "react-router-dom";
import { motion } from "framer-motion";
export default function NotFound() {
return (
<motion.div
className="admin-empty-state"
style={{ minHeight: '60vh', justifyContent: 'center' }}
style={{ minHeight: "60vh", justifyContent: "center" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div className="admin-empty-icon" style={{ width: 80, height: 80, marginBottom: '1.5rem' }}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<div
className="admin-empty-icon"
style={{ width: 80, height: 80, marginBottom: "1.5rem" }}
>
<svg
width="36"
height="36"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<path d="M16 16s-1.5-2-4-2-4 2-4 2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
</div>
<h2 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-primary)' }}>
<h2
style={{
fontSize: "1.5rem",
fontWeight: 600,
marginBottom: "0.5rem",
color: "var(--text-primary)",
}}
>
404
</h2>
<p>Stránka nebyla nalezena.</p>
<Link to="/" className="admin-btn admin-btn-primary" style={{ marginTop: '0.5rem' }}>
<Link
to="/"
className="admin-btn admin-btn-primary"
style={{ marginTop: "0.5rem" }}
>
Zpět na Dashboard
</Link>
</motion.div>
)
);
}

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

@@ -1,110 +1,140 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
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 Pagination from '../components/Pagination'
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 Pagination from "../components/Pagination";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
prijata: 'Přijatá',
v_realizaci: 'V realizaci',
dokoncena: 'Dokončená',
stornovana: 'Stornována'
}
prijata: "Přijatá",
v_realizaci: "V realizaci",
dokoncena: "Dokončená",
stornovana: "Stornována",
};
const STATUS_CLASSES: Record<string, string> = {
prijata: 'admin-badge-order-prijata',
v_realizaci: 'admin-badge-order-realizace',
dokoncena: 'admin-badge-order-dokoncena',
stornovana: 'admin-badge-order-stornovana'
}
prijata: "admin-badge-order-prijata",
v_realizaci: "admin-badge-order-realizace",
dokoncena: "admin-badge-order-dokoncena",
stornovana: "admin-badge-order-stornovana",
};
interface Order {
id: number
order_number: string
quotation_id: number
quotation_number: string
customer_name: string
status: string
created_at: string
total: number
currency: string
invoice_id?: number
id: number;
order_number: string;
quotation_id: number;
quotation_number: string;
customer_name: string;
status: string;
created_at: string;
total: number;
currency: string;
invoice_id?: number;
}
export default function Orders() {
const alert = useAlert()
const { hasPermission } = useAuth()
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort('order_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const { sort, order, handleSort, activeSort } = useTableSort("order_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; order: Order | null }>({ show: false, order: null })
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
order: Order | null;
}>({ show: false, order: null });
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
const { items: orders, loading, initialLoad, pagination, refetch: fetchData } = useListData('orders', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst objednávky'
})
const {
items: orders,
loading,
initialLoad,
pagination,
refetch: fetchData,
} = useListData("orders", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst objednávky",
});
if (!hasPermission('orders.view')) return <Forbidden />
if (!hasPermission("orders.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteConfirm.order) return
setDeleting(true)
if (!deleteConfirm.order) return;
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/orders/${deleteConfirm.order.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
const response = await apiFetch(
`${API_BASE}/orders/${deleteConfirm.order.id}`,
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
alert.success(result.message || 'Objednávka byla smazána')
fetchData()
setDeleteConfirm({ show: false, order: null });
setDeleteFiles(false);
alert.success(result.message || "Objednávka byla smazána");
fetchData();
} else {
alert.error(result.error || 'Nepodařilo se smazat objednávku')
alert.error(result.error || "Nepodařilo se smazat objednávku");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<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
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
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 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
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>
@@ -113,7 +143,7 @@ export default function Orders() {
</div>
</div>
</div>
)
);
}
return (
@@ -127,7 +157,13 @@ export default function Orders() {
<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')}
{pagination?.total ?? orders.length}{" "}
{czechPlural(
pagination?.total ?? orders.length,
"objednávka",
"objednávky",
"objednávek",
)}
</p>
</div>
</motion.div>
@@ -137,14 +173,17 @@ export default function Orders() {
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' }}
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) }}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, nabídky, projektu nebo zákazníka..."
/>
@@ -153,14 +192,23 @@ export default function Orders() {
{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">
<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' }}>
<p className="text-tertiary" style={{ fontSize: "0.875rem" }}>
Objednávky se vytvářejí z nabídek.
</p>
</div>
@@ -169,16 +217,40 @@ export default function Orders() {
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('order_number')}>
Číslo <SortIcon column="order_number" sort={activeSort} order={order} />
<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
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
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>
@@ -193,55 +265,116 @@ export default function Orders() {
</Link>
</td>
<td>
<Link to={`/offers/${o.quotation_id}`} className="text-secondary" style={{ textDecoration: 'none' }}>
<Link
to={`/offers/${o.quotation_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{o.quotation_number}
</Link>
</td>
<td>{o.customer_name || '—'}</td>
<td>{o.customer_name || "—"}</td>
<td>
<span className={`admin-badge ${STATUS_CLASSES[o.status] || ''}`}>
<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">{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">
<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>
</Link>
{o.invoice_id ? (
<Link to={`/invoices/${o.invoice_id}`} className="admin-btn-icon accent" title="Zobrazit fakturu" aria-label="Zobrazit fakturu">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Link
to={`/invoices/${o.invoice_id}`}
className="admin-btn-icon accent"
title="Zobrazit fakturu"
aria-label="Zobrazit 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" />
<text x="12" y="16.5" textAnchor="middle" fill="currentColor" stroke="none" fontSize="9" fontWeight="700">F</text>
</svg>
</Link>
) : 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" />
<text
x="12"
y="16.5"
textAnchor="middle"
fill="currentColor"
stroke="none"
fontSize="9"
fontWeight="700"
>
F
</text>
</svg>
</Link>
) : (
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') && (
{hasPermission("orders.delete") && (
<button
onClick={() => setDeleteConfirm({ show: true, order: o })}
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">
<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>
@@ -262,15 +395,20 @@ export default function Orders() {
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => {
setDeleteConfirm({ show: false, order: null })
setDeleteFiles(false)
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' }}>
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}
@@ -286,5 +424,5 @@ export default function Orders() {
loading={deleting}
/>
</div>
)
);
}

View File

@@ -1,58 +1,58 @@
import { useState, useEffect, useMemo } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import FormField from '../components/FormField'
import Forbidden from '../components/Forbidden'
import AdminDatePicker from '../components/AdminDatePicker'
import apiFetch from '../utils/api'
import { useState, useEffect, useMemo } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
interface Customer {
id: number
name: string
company_id?: string
city?: string
id: number;
name: string;
company_id?: string;
city?: string;
}
interface User {
id: number
name: string
id: number;
name: string;
}
interface ProjectForm {
project_number: string
name: string
customer_id: number | null
customer_name: string
start_date: string
responsible_user_id: string
project_number: string;
name: string;
customer_id: number | null;
customer_name: string;
start_date: string;
responsible_user_id: string;
}
export default function ProjectCreate() {
const navigate = useNavigate()
const alert = useAlert()
const { hasPermission } = useAuth()
const navigate = useNavigate();
const alert = useAlert();
const { hasPermission } = useAuth();
const [form, setForm] = useState<ProjectForm>({
project_number: '',
name: '',
project_number: "",
name: "",
customer_id: null,
customer_name: '',
start_date: new Date().toISOString().split('T')[0],
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
const [saving, setSaving] = useState(false)
const [errors, setErrors] = useState<Record<string, string | undefined>>({})
const [loadingNumber, setLoadingNumber] = useState(true)
customer_name: "",
start_date: new Date().toISOString().split("T")[0],
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [loadingNumber, setLoadingNumber] = useState(true);
// Customer selector state
const [customers, setCustomers] = useState<Customer[]>([])
const [customerSearch, setCustomerSearch] = useState('')
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false)
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerSearch, setCustomerSearch] = useState("");
const [showCustomerDropdown, setShowCustomerDropdown] = useState(false);
// Load initial data
useEffect(() => {
@@ -61,115 +61,142 @@ export default function ProjectCreate() {
const [numRes, custRes, usersRes] = await Promise.all([
apiFetch(`${API_BASE}/projects/next-number`),
apiFetch(`${API_BASE}/customers`),
apiFetch(`${API_BASE}/users`)
])
apiFetch(`${API_BASE}/users`),
]);
const numData = await numRes.json()
const numData = await numRes.json();
if (numData.success) {
setForm(prev => ({ ...prev, project_number: numData.data?.next_number || numData.data?.number || '' }))
setForm((prev) => ({
...prev,
project_number:
numData.data?.next_number || numData.data?.number || "",
}));
}
const custData = await custRes.json()
const custData = await custRes.json();
if (custData.success) {
setCustomers(Array.isArray(custData.data) ? custData.data : custData.data?.items || [])
setCustomers(
Array.isArray(custData.data)
? custData.data
: custData.data?.items || [],
);
}
const usersData = await usersRes.json()
const usersData = await usersRes.json();
if (usersData.success) {
const rawUsers = Array.isArray(usersData.data) ? usersData.data : usersData.data?.items || []
setUsers(rawUsers.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
const rawUsers = Array.isArray(usersData.data)
? usersData.data
: usersData.data?.items || [];
setUsers(
rawUsers.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
alert.error('Chyba při načítání dat')
alert.error("Chyba při načítání dat");
} finally {
setLoadingNumber(false)
setLoadingNumber(false);
}
}
load()
}, [alert])
};
load();
}, [alert]);
// Customer filtering
const filteredCustomers = useMemo(() => {
if (!customerSearch) return customers
const q = customerSearch.toLowerCase()
return customers.filter(c =>
(c.name || '').toLowerCase().includes(q) ||
(c.company_id || '').includes(customerSearch) ||
(c.city || '').toLowerCase().includes(q)
)
}, [customers, customerSearch])
if (!customerSearch) return customers;
const q = customerSearch.toLowerCase();
return customers.filter(
(c) =>
(c.name || "").toLowerCase().includes(q) ||
(c.company_id || "").includes(customerSearch) ||
(c.city || "").toLowerCase().includes(q),
);
}, [customers, customerSearch]);
// Close dropdown on outside click
useEffect(() => {
const handleClickOutside = () => setShowCustomerDropdown(false)
const handleClickOutside = () => setShowCustomerDropdown(false);
if (showCustomerDropdown) {
document.addEventListener('click', handleClickOutside)
return () => document.removeEventListener('click', handleClickOutside)
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
}
}, [showCustomerDropdown])
}, [showCustomerDropdown]);
if (!hasPermission('projects.create')) return <Forbidden />
if (!hasPermission("projects.create")) return <Forbidden />;
const selectCustomer = (customer: Customer) => {
setForm(prev => ({ ...prev, customer_id: customer.id, customer_name: customer.name }))
setErrors(prev => ({ ...prev, customer_id: undefined }))
setCustomerSearch('')
setShowCustomerDropdown(false)
}
setForm((prev) => ({
...prev,
customer_id: customer.id,
customer_name: customer.name,
}));
setErrors((prev) => ({ ...prev, customer_id: undefined }));
setCustomerSearch("");
setShowCustomerDropdown(false);
};
const clearCustomer = () => {
setForm(prev => ({ ...prev, customer_id: null, customer_name: '' }))
}
setForm((prev) => ({ ...prev, customer_id: null, customer_name: "" }));
};
const updateForm = (field: keyof ProjectForm, value: unknown) => {
setForm(prev => ({ ...prev, [field]: value }))
setErrors(prev => ({ ...prev, [field]: undefined }))
}
setForm((prev) => ({ ...prev, [field]: value }));
setErrors((prev) => ({ ...prev, [field]: undefined }));
};
const handleSave = async () => {
const newErrors: Record<string, string> = {}
if (!form.name.trim()) newErrors.name = 'Název projektu je povinný'
if (!form.customer_id) newErrors.customer_id = 'Vyberte zákazníka'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Název projektu je povinný";
if (!form.customer_id) newErrors.customer_id = "Vyberte zákazníka";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true)
setSaving(true);
try {
const body = {
name: form.name.trim(),
customer_id: form.customer_id,
start_date: form.start_date,
project_number: form.project_number.trim(),
responsible_user_id: form.responsible_user_id || null
}
responsible_user_id: form.responsible_user_id || null,
};
const res = await apiFetch(`${API_BASE}/projects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
})
const data = await res.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
navigate(`/projects/${data.data.project_id}`, { state: { created: true } })
navigate(`/projects/${data.data.project_id}`, {
state: { created: true },
});
} else {
alert.error(data.error || 'Nepodařilo se vytvořit projekt')
alert.error(data.error || "Nepodařilo se vytvořit projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSaving(false)
setSaving(false);
}
}
};
if (loadingNumber) {
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 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">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
@@ -178,7 +205,7 @@ export default function ProjectCreate() {
</div>
</div>
</div>
)
);
}
return (
@@ -190,8 +217,20 @@ export default function ProjectCreate() {
transition={{ duration: 0.25 }}
>
<div className="flex-row gap-4">
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<Link
to="/projects"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
@@ -206,7 +245,7 @@ export default function ProjectCreate() {
disabled={saving}
className="admin-btn admin-btn-primary"
>
{saving ? 'Ukládám...' : 'Uložit'}
{saving ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
@@ -216,7 +255,7 @@ export default function ProjectCreate() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ overflow: 'visible' }}
style={{ overflow: "visible" }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
@@ -226,7 +265,7 @@ export default function ProjectCreate() {
<input
type="text"
value={form.project_number}
onChange={(e) => updateForm('project_number', e.target.value)}
onChange={(e) => updateForm("project_number", e.target.value)}
className="admin-form-input"
placeholder="Ponechte prázdné pro automatické"
/>
@@ -235,7 +274,7 @@ export default function ProjectCreate() {
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
onChange={(e) => updateForm("name", e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
/>
@@ -247,18 +286,38 @@ export default function ProjectCreate() {
{form.customer_id ? (
<div className="offers-customer-selected">
<span>{form.customer_name}</span>
<button type="button" onClick={clearCustomer} className="admin-btn-icon" title="Odebrat zákazníka" aria-label="Odebrat zákazníka">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
<button
type="button"
onClick={clearCustomer}
className="admin-btn-icon"
title="Odebrat zákazníka"
aria-label="Odebrat zákazníka"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
) : (
<div className="offers-customer-select" onClick={(e) => e.stopPropagation()}>
<div
className="offers-customer-select"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={customerSearch}
onChange={(e) => { setCustomerSearch(e.target.value); setShowCustomerDropdown(true) }}
onChange={(e) => {
setCustomerSearch(e.target.value);
setShowCustomerDropdown(true);
}}
onFocus={() => setShowCustomerDropdown(true)}
className="admin-form-input"
placeholder="Hledat zákazníka..."
@@ -270,7 +329,7 @@ export default function ProjectCreate() {
Žádní zákazníci
</div>
) : (
filteredCustomers.slice(0, 20).map(c => (
filteredCustomers.slice(0, 20).map((c) => (
<div
key={c.id}
className="offers-customer-dropdown-item"
@@ -290,7 +349,7 @@ export default function ProjectCreate() {
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
onChange={(val: string) => updateForm("start_date", val)}
/>
</FormField>
</div>
@@ -299,12 +358,16 @@ export default function ProjectCreate() {
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
onChange={(e) =>
updateForm("responsible_user_id", e.target.value)
}
className="admin-form-select"
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
@@ -312,7 +375,6 @@ export default function ProjectCreate() {
</div>
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,292 +1,323 @@
import { useState, useEffect, useRef } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { useParams, useNavigate, useLocation, Link } from 'react-router-dom'
import { motion } from 'framer-motion'
import { useState, useEffect, useRef } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
import { motion } from "framer-motion";
import Forbidden from '../components/Forbidden'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import AdminDatePicker from '../components/AdminDatePicker'
import ProjectFileManager from '../components/ProjectFileManager'
import apiFetch from '../utils/api'
import Forbidden from "../components/Forbidden";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import ProjectFileManager from "../components/ProjectFileManager";
import apiFetch from "../utils/api";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
function formatNoteDate(dateStr: string) {
if (!dateStr) return ''
const d = new Date(dateStr)
const day = d.getDate()
const month = d.getMonth() + 1
const year = d.getFullYear()
const hours = String(d.getHours()).padStart(2, '0')
const mins = String(d.getMinutes()).padStart(2, '0')
return `${day}. ${month}. ${year} ${hours}:${mins}`
if (!dateStr) return "";
const d = new Date(dateStr);
const day = d.getDate();
const month = d.getMonth() + 1;
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, "0");
const mins = String(d.getMinutes()).padStart(2, "0");
return `${day}. ${month}. ${year} ${hours}:${mins}`;
}
interface Note {
id: number
content: string
user_name: string
created_at: string
id: number;
content: string;
user_name: string;
created_at: string;
}
interface User {
id: number
name: string
id: number;
name: string;
}
interface ProjectData {
id: number
project_number: string
name: string
status: string
start_date: string
end_date: string
customer_name: string
responsible_user_id: string
notes?: string
order_id?: number
order_number?: string
order_status?: string
quotation_id?: number
quotation_number?: string
has_nas_folder?: boolean
id: number;
project_number: string;
name: string;
status: string;
start_date: string;
end_date: string;
customer_name: string;
responsible_user_id: string;
notes?: string;
order_id?: number;
order_number?: string;
order_status?: string;
quotation_id?: number;
quotation_number?: string;
has_nas_folder?: boolean;
}
interface ProjectForm {
name: string
status: string
start_date: string
end_date: string
responsible_user_id: string
name: string;
status: string;
start_date: string;
end_date: string;
responsible_user_id: string;
}
export default function ProjectDetail() {
const { id } = useParams()
const alert = useAlert()
const { hasPermission, isAdmin } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const { id } = useParams();
const alert = useAlert();
const { hasPermission, isAdmin } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [project, setProject] = useState<ProjectData | null>(null)
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [project, setProject] = useState<ProjectData | null>(null);
const [form, setForm] = useState<ProjectForm>({
name: '',
status: 'aktivni',
start_date: '',
end_date: '',
responsible_user_id: ''
})
const [users, setUsers] = useState<User[]>([])
name: "",
status: "aktivni",
start_date: "",
end_date: "",
responsible_user_id: "",
});
const [users, setUsers] = useState<User[]>([]);
const [deleteConfirm, setDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteFiles, setDeleteFiles] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleteFiles, setDeleteFiles] = useState(false);
// Dynamic notes
const [notes, setNotes] = useState<Note[]>([])
const [notesLoading, setNotesLoading] = useState(true)
const [newNote, setNewNote] = useState('')
const [addingNote, setAddingNote] = useState(false)
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null)
const [notes, setNotes] = useState<Note[]>([]);
const [notesLoading, setNotesLoading] = useState(true);
const [newNote, setNewNote] = useState("");
const [addingNote, setAddingNote] = useState(false);
const [deletingNoteId, setDeletingNoteId] = useState<number | null>(null);
const createdShown = useRef(false)
const createdShown = useRef(false);
useEffect(() => {
if ((location.state as { created?: boolean })?.created && !createdShown.current) {
createdShown.current = true
alert.success('Projekt byl vytvořen')
navigate(location.pathname, { replace: true, state: {} })
if (
(location.state as { created?: boolean })?.created &&
!createdShown.current
) {
createdShown.current = true;
alert.success("Projekt byl vytvořen");
navigate(location.pathname, { replace: true, state: {} });
}
}, [location.state, location.pathname, alert, navigate])
}, [location.state, location.pathname, alert, navigate]);
const fetchNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setNotes(result.data.project_notes || [])
setNotes(result.data.project_notes || []);
}
} catch {
// silent - notes are supplementary
} finally {
setNotesLoading(false)
setNotesLoading(false);
}
}
};
useEffect(() => {
const fetchDetail = async () => {
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`)
if (response.status === 401) return
const result = await response.json()
const response = await apiFetch(`${API_BASE}/projects/${id}`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
const p = result.data
setProject(p)
const p = result.data;
setProject(p);
setForm({
name: p.name || '',
status: p.status || 'aktivni',
start_date: (p.start_date || '').substring(0, 10),
end_date: (p.end_date || '').substring(0, 10),
responsible_user_id: p.responsible_user_id || ''
})
name: p.name || "",
status: p.status || "aktivni",
start_date: (p.start_date || "").substring(0, 10),
end_date: (p.end_date || "").substring(0, 10),
responsible_user_id: p.responsible_user_id || "",
});
} else {
alert.error(result.error || 'Nepodařilo se načíst projekt')
navigate('/projects')
alert.error(result.error || "Nepodařilo se načíst projekt");
navigate("/projects");
}
} catch {
alert.error('Chyba připojení')
navigate('/projects')
alert.error("Chyba připojení");
navigate("/projects");
} finally {
setLoading(false)
setLoading(false);
}
}
};
const fetchUsers = async () => {
try {
const res = await apiFetch(`${API_BASE}/users`)
if (res.status === 401) return
const data = await res.json()
const res = await apiFetch(`${API_BASE}/users`);
if (res.status === 401) return;
const data = await res.json();
if (data.success) {
const raw = Array.isArray(data.data) ? data.data : data.data?.items || []
setUsers(raw.map((u: any) => ({ id: u.id, name: `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.username })))
const raw = Array.isArray(data.data)
? data.data
: data.data?.items || [];
setUsers(
raw.map((u: any) => ({
id: u.id,
name:
`${u.first_name || ""} ${u.last_name || ""}`.trim() ||
u.username,
})),
);
}
} catch {
// silent
}
}
};
fetchDetail()
fetchNotes()
fetchUsers()
}, [id, alert, navigate]) // eslint-disable-line react-hooks/exhaustive-deps
fetchDetail();
fetchNotes();
fetchUsers();
}, [id, alert, navigate]); // eslint-disable-line react-hooks/exhaustive-deps
if (!hasPermission('projects.view')) return <Forbidden />
if (!hasPermission("projects.view")) return <Forbidden />;
const updateForm = (field: keyof ProjectForm, value: string) => setForm(prev => ({ ...prev, [field]: value }))
const updateForm = (field: keyof ProjectForm, value: string) =>
setForm((prev) => ({ ...prev, [field]: value }));
const handleSave = async () => {
if (!form.name.trim()) {
alert.error('Název projektu je povinný')
return
alert.error("Název projektu je povinný");
return;
}
setSaving(true)
setSaving(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: form.name,
status: form.status,
start_date: form.start_date || null,
end_date: form.end_date || null,
responsible_user_id: form.responsible_user_id || null
})
})
const result = await response.json()
responsible_user_id: form.responsible_user_id || null,
}),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || 'Projekt byl aktualizován')
alert.success(result.message || "Projekt byl aktualizován");
} else {
alert.error(result.error || 'Nepodařilo se uložit projekt')
alert.error(result.error || "Nepodařilo se uložit projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSaving(false)
setSaving(false);
}
}
};
const handleDelete = async () => {
setDeleting(true)
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const result = await response.json()
});
const result = await response.json();
if (result.success) {
navigate('/projects')
setTimeout(() => alert.success('Projekt byl smazán'), 300)
navigate("/projects");
setTimeout(() => alert.success("Projekt byl smazán"), 300);
} else {
alert.error(result.error || 'Nepodařilo se smazat projekt')
alert.error(result.error || "Nepodařilo se smazat projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
const handleAddNote = async () => {
if (!newNote.trim()) return
if (!newNote.trim()) return;
setAddingNote(true)
setAddingNote(true);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: newNote.trim() })
})
const result = await response.json()
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: newNote.trim() }),
});
const result = await response.json();
if (result.success) {
setNotes(prev => [result.data.note, ...prev])
setNewNote('')
alert.success('Poznámka byla přidána')
setNotes((prev) => [result.data.note, ...prev]);
setNewNote("");
alert.success("Poznámka byla přidána");
} else {
alert.error(result.error || 'Nepodařilo se přidat poznámku')
alert.error(result.error || "Nepodařilo se přidat poznámku");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setAddingNote(false)
setAddingNote(false);
}
}
};
const handleDeleteNote = async (noteId: number) => {
setDeletingNoteId(noteId)
setDeletingNoteId(noteId);
try {
const response = await apiFetch(`${API_BASE}/projects/${id}/notes/${noteId}`, {
method: 'DELETE'
})
const result = await response.json()
const response = await apiFetch(
`${API_BASE}/projects/${id}/notes/${noteId}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setNotes(prev => prev.filter(n => n.id !== noteId))
alert.success('Poznámka byla smazána')
setNotes((prev) => prev.filter((n) => n.id !== noteId));
alert.success("Poznámka byla smazána");
} else {
alert.error(result.error || 'Nepodařilo se smazat poznámku')
alert.error(result.error || "Nepodařilo se smazat poznámku");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeletingNoteId(null)
setDeletingNoteId(null);
}
}
};
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" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div className="flex-row-gap">
<div className="admin-skeleton-line" style={{ width: '32px', height: '32px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-8" style={{ width: '200px' }} />
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-row" style={{ gap: '0.5rem' }}>
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-line h-10" style={{ width: '100px', borderRadius: '8px' }} />
<div className="admin-skeleton-row" style={{ gap: "0.5rem" }}>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "100px", borderRadius: "8px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3].map(i => (
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="admin-skeleton-row">
<div className="admin-skeleton-line w-1/4" />
<div className="admin-skeleton-line w-1/2" />
@@ -295,12 +326,12 @@ export default function ProjectDetail() {
</div>
</div>
</div>
)
);
}
if (!project) return null
if (!project) return null;
const canEdit = hasPermission('projects.edit')
const canEdit = hasPermission("projects.edit");
return (
<div>
@@ -311,9 +342,21 @@ export default function ProjectDetail() {
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Link to="/projects" className="admin-btn-icon" title="Zpět" aria-label="Zpět">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<Link
to="/projects"
className="admin-btn-icon"
title="Zpět"
aria-label="Zpět"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
</Link>
@@ -325,13 +368,19 @@ export default function ProjectDetail() {
</div>
{canEdit && (
<div className="admin-page-actions">
<button onClick={handleSave} className="admin-btn admin-btn-primary" disabled={saving}>
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : 'Uložit'}
) : (
"Uložit"
)}
</button>
{!project.order_id && (
<button
@@ -362,14 +411,17 @@ export default function ProjectDetail() {
value={project.project_number}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Název">
<input
type="text"
value={form.name}
onChange={(e) => updateForm('name', e.target.value)}
onChange={(e) => updateForm("name", e.target.value)}
className="admin-form-input"
placeholder="Název projektu"
disabled={!canEdit}
@@ -381,22 +433,29 @@ export default function ProjectDetail() {
<FormField label="Zákazník">
<input
type="text"
value={project.customer_name || '—'}
value={project.customer_name || "—"}
className="admin-form-input"
readOnly
style={{ backgroundColor: 'var(--bg-secondary)', cursor: 'default' }}
style={{
backgroundColor: "var(--bg-secondary)",
cursor: "default",
}}
/>
</FormField>
<FormField label="Zodpovědná osoba">
<select
value={form.responsible_user_id}
onChange={(e) => updateForm('responsible_user_id', e.target.value)}
onChange={(e) =>
updateForm("responsible_user_id", e.target.value)
}
className="admin-form-select"
disabled={!canEdit}
>
<option value=""> Nevybráno </option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</FormField>
@@ -406,7 +465,7 @@ export default function ProjectDetail() {
<FormField label="Stav">
<select
value={form.status}
onChange={(e) => updateForm('status', e.target.value)}
onChange={(e) => updateForm("status", e.target.value)}
className="admin-form-select"
disabled={!canEdit}
>
@@ -419,7 +478,7 @@ export default function ProjectDetail() {
<AdminDatePicker
mode="date"
value={form.start_date}
onChange={(val: string) => updateForm('start_date', val)}
onChange={(val: string) => updateForm("start_date", val)}
disabled={!canEdit}
/>
</FormField>
@@ -427,12 +486,11 @@ export default function ProjectDetail() {
<AdminDatePicker
mode="date"
value={form.end_date}
onChange={(val: string) => updateForm('end_date', val)}
onChange={(val: string) => updateForm("end_date", val)}
disabled={!canEdit}
/>
</FormField>
</div>
</div>
</div>
</motion.div>
@@ -455,10 +513,10 @@ export default function ProjectDetail() {
className="admin-form-input"
rows={2}
placeholder="Napište poznámku..."
style={{ resize: 'vertical', width: '100%' }}
style={{ resize: "vertical", width: "100%" }}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.ctrlKey && newNote.trim()) {
handleAddNote()
if (e.key === "Enter" && e.ctrlKey && newNote.trim()) {
handleAddNote();
}
}}
/>
@@ -471,7 +529,7 @@ export default function ProjectDetail() {
{addingNote ? (
<div className="admin-spinner admin-spinner-sm" />
) : (
'Přidat poznámku'
"Přidat poznámku"
)}
</button>
</div>
@@ -479,57 +537,107 @@ export default function ProjectDetail() {
{/* Legacy notes (read-only) */}
{project.notes && (
<div style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
marginBottom: '0.5rem',
fontSize: '0.85rem',
color: 'var(--text-secondary)'
}}>
<div style={{ fontSize: '0.75rem', color: 'var(--text-tertiary)', marginBottom: '0.25rem' }}>
<div
style={{
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
marginBottom: "0.5rem",
fontSize: "0.85rem",
color: "var(--text-secondary)",
}}
>
<div
style={{
fontSize: "0.75rem",
color: "var(--text-tertiary)",
marginBottom: "0.25rem",
}}
>
Starší poznámka (před zavedením systému)
</div>
<div style={{ whiteSpace: 'pre-wrap' }}>{project.notes}</div>
<div style={{ whiteSpace: "pre-wrap" }}>{project.notes}</div>
</div>
)}
{/* Notes list */}
{notesLoading && (
<div className="admin-skeleton" style={{ gap: '0.75rem' }}>
{[0, 1, 2].map(i => (
<div key={i} className="admin-skeleton-line" style={{ height: '52px', borderRadius: '8px' }} />
<div className="admin-skeleton" style={{ gap: "0.75rem" }}>
{[0, 1, 2].map((i) => (
<div
key={i}
className="admin-skeleton-line"
style={{ height: "52px", borderRadius: "8px" }}
/>
))}
</div>
)}
{!notesLoading && notes.length === 0 && !project.notes && (
<div style={{ color: 'var(--text-tertiary)', fontSize: '0.875rem', textAlign: 'center', padding: '1rem 0' }}>
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1rem 0",
}}
>
Zatím žádné poznámky
</div>
)}
{!notesLoading && (notes.length > 0 || project.notes) && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{notes.map(note => (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.5rem",
}}
>
{notes.map((note) => (
<div
key={note.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '0.5rem',
position: 'relative'
padding: "0.75rem",
background: "var(--bg-secondary)",
borderRadius: "0.5rem",
position: "relative",
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: "0.5rem",
}}
>
<div className="flex-1">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.25rem' }}>
<span style={{ fontWeight: 600, fontSize: '0.85rem' }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
marginBottom: "0.25rem",
}}
>
<span style={{ fontWeight: 600, fontSize: "0.85rem" }}>
{note.user_name}
</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: '0.75rem' }}>
<span
style={{
color: "var(--text-tertiary)",
fontSize: "0.75rem",
}}
>
{formatNoteDate(note.created_at)}
</span>
</div>
<div style={{ whiteSpace: 'pre-wrap', fontSize: '0.875rem', lineHeight: 1.5 }}>
<div
style={{
whiteSpace: "pre-wrap",
fontSize: "0.875rem",
lineHeight: 1.5,
}}
>
{note.content}
</div>
</div>
@@ -539,12 +647,25 @@ export default function ProjectDetail() {
className="admin-btn-icon"
title="Smazat poznámku"
disabled={deletingNoteId === note.id}
style={{ flexShrink: 0, opacity: deletingNoteId === note.id ? 0.5 : 1 }}
style={{
flexShrink: 0,
opacity: deletingNoteId === note.id ? 0.5 : 1,
}}
>
{deletingNoteId === note.id ? (
<div className="admin-spinner" style={{ width: 14, height: 14, borderWidth: 2 }} />
<div
className="admin-spinner"
style={{ width: 14, height: 14, borderWidth: 2 }}
/>
) : (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<svg
width="16"
height="16"
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" />
@@ -562,6 +683,7 @@ export default function ProjectDetail() {
{/* Project File Manager */}
<motion.div
style={{ marginBottom: "1rem" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
@@ -587,24 +709,40 @@ export default function ProjectDetail() {
<FormField label="Objednávka">
<div>
{project.order_id ? (
<Link to={`/orders/${project.order_id}`} className="link-accent">
<Link
to={`/orders/${project.order_id}`}
className="link-accent"
>
{project.order_number}
{project.order_status && (
<span className="text-tertiary" style={{ fontWeight: 400, marginLeft: '0.5rem' }}>
({STATUS_LABELS[project.order_status] || project.order_status})
<span
className="text-tertiary"
style={{ fontWeight: 400, marginLeft: "0.5rem" }}
>
(
{STATUS_LABELS[project.order_status] ||
project.order_status}
)
</span>
)}
</Link>
) : '—'}
) : (
"—"
)}
</div>
</FormField>
<FormField label="Nabídka">
<div>
{project.quotation_id ? (
<Link to={`/offers/${project.quotation_id}`} className="link-accent">
<Link
to={`/offers/${project.quotation_id}`}
className="link-accent"
>
{project.quotation_number}
</Link>
) : '—'}
) : (
"—"
)}
</div>
</FormField>
</div>
@@ -614,16 +752,20 @@ export default function ProjectDetail() {
<ConfirmModal
isOpen={deleteConfirm}
onClose={() => {
setDeleteConfirm(false)
setDeleteFiles(false)
setDeleteConfirm(false);
setDeleteFiles(false);
}}
onConfirm={handleDelete}
title="Smazat projekt"
message={
<>
Opravdu chcete smazat projekt &quot;{project.project_number} {project.name}&quot;? Tato akce je nevratná.
Opravdu chcete smazat projekt &quot;{project.project_number} {" "}
{project.name}&quot;? Tato akce je nevratná.
{project.has_nas_folder && (
<label className="admin-form-checkbox" style={{ marginTop: '1rem', display: 'flex' }}>
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
@@ -640,5 +782,5 @@ export default function ProjectDetail() {
loading={deleting}
/>
</div>
)
);
}

View File

@@ -1,107 +1,134 @@
import { useState } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import Forbidden from '../components/Forbidden'
import { motion } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
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 Pagination from '../components/Pagination'
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 Pagination from "../components/Pagination";
const API_BASE = '/api/admin'
const API_BASE = "/api/admin";
const STATUS_LABELS: Record<string, string> = {
aktivni: 'Aktivní',
dokonceny: 'Dokončený',
zruseny: 'Zrušený'
}
aktivni: "Aktivní",
dokonceny: "Dokončený",
zruseny: "Zrušený",
};
const STATUS_CLASSES: Record<string, string> = {
aktivni: 'admin-badge-project-aktivni',
dokonceny: 'admin-badge-project-dokonceny',
zruseny: 'admin-badge-project-zruseny'
}
aktivni: "admin-badge-project-aktivni",
dokonceny: "admin-badge-project-dokonceny",
zruseny: "admin-badge-project-zruseny",
};
interface Project {
id: number
project_number: string
name: string
customer_name: string
responsible_user_name: string
status: string
start_date: string
end_date: string
order_id?: number
order_number?: string
id: number;
project_number: string;
name: string;
customer_name: string;
responsible_user_name: string;
status: string;
start_date: string;
end_date: string;
order_id?: number;
order_number?: string;
}
export default function Projects() {
const alert = useAlert()
const { hasPermission } = useAuth()
const alert = useAlert();
const { hasPermission } = useAuth();
const { sort, order, handleSort, activeSort } = useTableSort('project_number')
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null)
const [deleteFiles, setDeleteFiles] = useState(false)
const { sort, order, handleSort, activeSort } =
useTableSort("project_number");
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Project | null>(null);
const [deleteFiles, setDeleteFiles] = useState(false);
const { items: projects, setItems: setProjects, loading, initialLoad, pagination } = useListData<Project>('projects', {
search, sort, order, page,
errorMsg: 'Nepodařilo se načíst projekty'
})
const {
items: projects,
setItems: setProjects,
loading,
initialLoad,
pagination,
} = useListData<Project>("projects", {
search,
sort,
order,
page,
errorMsg: "Nepodařilo se načíst projekty",
});
if (!hasPermission('projects.view')) return <Forbidden />
if (!hasPermission("projects.view")) return <Forbidden />;
const handleDelete = async () => {
if (!deleteTarget) return
setDeletingId(deleteTarget.id)
if (!deleteTarget) return;
setDeletingId(deleteTarget.id);
try {
const res = await apiFetch(`${API_BASE}/projects/${deleteTarget.id}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ delete_files: deleteFiles }),
})
const data = await res.json()
});
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))
alert.success(data.message || "Projekt byl smazán");
setProjects((prev: Project[]) =>
prev.filter((p) => p.id !== deleteTarget.id),
);
} else {
alert.error(data.error || 'Nepodařilo se smazat projekt')
alert.error(data.error || "Nepodařilo se smazat projekt");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeletingId(null)
setDeleteTarget(null)
setDeleteFiles(false)
setDeletingId(null);
setDeleteTarget(null);
setDeleteFiles(false);
}
}
};
if (initialLoad) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<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
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
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 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
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>
@@ -110,7 +137,7 @@ export default function Projects() {
</div>
</div>
</div>
)
);
}
return (
@@ -124,12 +151,25 @@ export default function Projects() {
<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ů')}
{pagination?.total ?? projects.length}{" "}
{czechPlural(
pagination?.total ?? projects.length,
"projekt",
"projekty",
"projektů",
)}
</p>
</div>
{hasPermission('projects.create') && (
{hasPermission("projects.create") && (
<Link to="/projects/new" className="admin-btn admin-btn-primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
@@ -143,14 +183,17 @@ export default function Projects() {
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' }}
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) }}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="admin-form-input"
placeholder="Hledat podle čísla, názvu nebo zákazníka..."
/>
@@ -159,13 +202,25 @@ export default function Projects() {
{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">
<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
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>
) : (
@@ -173,22 +228,58 @@ export default function Projects() {
<table className="admin-table">
<thead>
<tr>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('project_number')}>
Číslo <SortIcon column="project_number" sort={activeSort} order={order} />
<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
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
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
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
style={{ cursor: "pointer" }}
onClick={() => handleSort("end_date")}
>
Konec{" "}
<SortIcon
column="end_date"
sort={activeSort}
order={order}
/>
</th>
<th>Objednávka</th>
<th>Akce</th>
@@ -202,11 +293,13 @@ export default function Projects() {
{p.project_number}
</Link>
</td>
<td className="fw-500">{p.name || '—'}</td>
<td>{p.customer_name || '—'}</td>
<td>{p.responsible_user_name || '—'}</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] || ''}`}>
<span
className={`admin-badge ${STATUS_CLASSES[p.status] || ""}`}
>
{STATUS_LABELS[p.status] || p.status}
</span>
</td>
@@ -214,20 +307,38 @@ export default function Projects() {
<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' }}>
<Link
to={`/orders/${p.order_id}`}
className="text-secondary"
style={{ textDecoration: "none" }}
>
{p.order_number}
</Link>
) : '—'}
) : (
"—"
)}
</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">
<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') && (
{!p.order_id && hasPermission("projects.create") && (
<button
onClick={() => setDeleteTarget(p)}
className="admin-btn-icon danger"
@@ -237,7 +348,14 @@ export default function Projects() {
{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">
<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" />
@@ -260,15 +378,18 @@ export default function Projects() {
<ConfirmModal
isOpen={!!deleteTarget}
onClose={() => {
setDeleteTarget(null)
setDeleteFiles(false)
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' }}>
<label
className="admin-form-checkbox"
style={{ marginTop: "1rem", display: "flex" }}
>
<input
type="checkbox"
checked={deleteFiles}
@@ -283,5 +404,5 @@ export default function Projects() {
loading={!!deletingId}
/>
</div>
)
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,144 +1,154 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { Link } from 'react-router-dom'
import { motion, AnimatePresence } from 'framer-motion'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from '../components/AdminDatePicker'
import ConfirmModal from '../components/ConfirmModal'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import Forbidden from '../components/Forbidden'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import Forbidden from "../components/Forbidden";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance?: number | null
route_from: string
route_to: string
is_business: boolean
notes?: string | null
users?: { id: number; first_name: string; last_name: string }
vehicles?: { id: number; name: string; spz: string }
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance?: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes?: string | null;
users?: { id: number; first_name: string; last_name: string };
vehicles?: { id: number; name: string; spz: string };
}
interface TripForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
vehicle_id: string;
trip_date: string;
start_km: string | number;
end_km: string | number;
route_from: string;
route_to: string;
is_business: number;
notes: string;
}
export default function Trips() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
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<{ show: boolean; tripId: number | null }>({ show: false, tripId: null })
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
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<{
show: boolean;
tripId: number | null;
}>({ show: false, tripId: null });
const [form, setForm] = useState<TripForm>({
vehicle_id: '',
trip_date: new Date().toISOString().split('T')[0],
start_km: '',
end_km: '',
route_from: '',
route_to: '',
vehicle_id: "",
trip_date: new Date().toISOString().split("T")[0],
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: ''
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [, setLastKm] = useState(0)
notes: "",
});
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 : [])
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);
}
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])
},
[alert],
);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
useModalLock(showModal)
useModalLock(showModal);
if (!hasPermission('trips.record')) return <Forbidden />
if (!hasPermission("trips.record")) return <Forbidden />;
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0)
return
setLastKm(0);
return;
}
try {
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`)
const result = await response.json()
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
const result = await response.json();
if (result.success) {
const km = result.data?.last_km || 0
setLastKm(km)
const km = result.data?.last_km || 0;
setLastKm(km);
if (!editingTrip) {
setForm(prev => ({ ...prev, start_km: km }))
setForm((prev) => ({ ...prev, start_km: km }));
}
return
return;
}
} catch { /* fallback below */ }
setLastKm(0)
}
} catch {
/* fallback below */
}
setLastKm(0);
};
const openCreateModal = () => {
setEditingTrip(null)
const today = new Date().toISOString().split('T')[0]
setEditingTrip(null);
const today = new Date().toISOString().split("T")[0];
setForm({
vehicle_id: '',
vehicle_id: "",
trip_date: today,
start_km: '',
end_km: '',
route_from: '',
route_to: '',
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: ''
})
setLastKm(0)
setErrors({})
setShowModal(true)
}
notes: "",
});
setLastKm(0);
setErrors({});
setShowModal(true);
};
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setEditingTrip(trip);
setForm({
vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date,
@@ -147,116 +157,146 @@ export default function Trips() {
route_from: trip.route_from,
route_to: trip.route_to,
is_business: Number(trip.is_business),
notes: trip.notes || ''
})
setLastKm(trip.start_km)
setErrors({})
setShowModal(true)
}
notes: trip.notes || "",
});
setLastKm(trip.start_km);
setErrors({});
setShowModal(true);
};
const handleVehicleChange = (vehicleId: string) => {
setForm(prev => ({ ...prev, vehicle_id: vehicleId }))
fetchLastKm(vehicleId)
}
setForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
fetchLastKm(vehicleId);
};
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {}
if (!form.vehicle_id) newErrors.vehicle_id = 'Vyberte vozidlo'
if (!form.trip_date) newErrors.trip_date = 'Zadejte datum'
if (!form.start_km) newErrors.start_km = 'Zadejte počáteční km'
if (!form.end_km) newErrors.end_km = 'Zadejte konečný km'
if (form.start_km && form.end_km && parseInt(String(form.end_km)) <= parseInt(String(form.start_km))) {
newErrors.end_km = 'Musí být větší než počáteční'
const newErrors: Record<string, string> = {};
if (!form.vehicle_id) newErrors.vehicle_id = "Vyberte vozidlo";
if (!form.trip_date) newErrors.trip_date = "Zadejte datum";
if (!form.start_km) newErrors.start_km = "Zadejte počáteční km";
if (!form.end_km) newErrors.end_km = "Zadejte konečný km";
if (
form.start_km &&
form.end_km &&
parseInt(String(form.end_km)) <= parseInt(String(form.start_km))
) {
newErrors.end_km = "Musí být větší než počáteční";
}
if (!form.route_from) newErrors.route_from = 'Zadejte místo odjezdu'
if (!form.route_to) newErrors.route_to = 'Zadejte místo příjezdu'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
if (!form.route_from) newErrors.route_from = "Zadejte místo odjezdu";
if (!form.route_to) newErrors.route_to = "Zadejte místo příjezdu";
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) return
if (!validateForm()) return;
setSubmitting(true)
setSubmitting(true);
try {
const url = editingTrip
? `${API_BASE}/trips/${editingTrip.id}`
: `${API_BASE}/trips`
: `${API_BASE}/trips`;
const response = await apiFetch(url, {
method: editingTrip ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
method: editingTrip ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
setShowModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setSubmitting(false)
setSubmitting(false);
}
}
};
const handleDelete = async (tripId: number) => {
try {
const response = await apiFetch(`${API_BASE}/trips/${tripId}`, {
method: 'DELETE',
})
method: "DELETE",
});
const result = await response.json()
const result = await response.json();
if (result.success) {
await fetchData(false)
alert.success(result.message)
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleteConfirm({ show: false, tripId: null })
setDeleteConfirm({ show: false, tripId: null });
}
}
};
const calculateDistance = (): number => {
const start = parseInt(String(form.start_km)) || 0
const end = parseInt(String(form.end_km)) || 0
return end > start ? end - start : 0
}
const start = parseInt(String(form.start_km)) || 0;
const end = parseInt(String(form.end_km)) || 0;
return end > start ? end - start : 0;
};
if (loading) {
return (
<div>
<div className="admin-skeleton" style={{ padding: 0, gap: '1.5rem' }}>
<div className="admin-skeleton-row" style={{ justifyContent: 'space-between' }}>
<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
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
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 => (
{[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
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 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" />
@@ -267,20 +307,20 @@ export default function Trips() {
</div>
</div>
</div>
)
);
}
const totals = trips.reduce(
(acc, t) => {
const dist = t.distance ?? (t.end_km - t.start_km)
acc.count++
acc.total += dist
if (t.is_business) acc.business += dist
else acc.private += dist
return acc
const dist = t.distance ?? t.end_km - t.start_km;
acc.count++;
acc.total += dist;
if (t.is_business) acc.business += dist;
else acc.private += dist;
return acc;
},
{ total: 0, business: 0, private: 0, count: 0 }
)
{ total: 0, business: 0, private: 0, count: 0 },
);
return (
<div>
@@ -293,12 +333,25 @@ export default function Trips() {
<div>
<h1 className="admin-page-title">Kniha jízd</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })}
{new Date().toLocaleDateString("cs-CZ", {
month: "long",
year: "numeric",
})}
</p>
</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">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -316,7 +369,16 @@ export default function Trips() {
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
@@ -330,19 +392,39 @@ export default function Trips() {
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
@@ -351,20 +433,33 @@ export default function Trips() {
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební</span>
</div>
</div>
<div className="admin-stat-card warning">
<div className="admin-stat-icon warning">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.private)} km</span>
<span className="admin-stat-value">
{formatKm(totals.private)} km
</span>
<span className="admin-stat-label">Soukromé</span>
</div>
</div>
@@ -379,7 +474,10 @@ export default function Trips() {
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední jízdy</h2>
<Link to="/trips/history" className="admin-btn admin-btn-secondary admin-btn-sm">
<Link
to="/trips/history"
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Zobrazit historii
</Link>
</div>
@@ -387,7 +485,16 @@ export default function Trips() {
{trips.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">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<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" />
@@ -395,7 +502,10 @@ export default function Trips() {
</svg>
</div>
<p>Zatím nemáte žádné záznamy jízd.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první jízdu
</button>
</div>
@@ -416,20 +526,37 @@ export default function Trips() {
<tbody>
{trips.slice(0, 10).map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<td>
<span className="admin-badge">{trip.vehicles?.spz ?? ''}</span>
<td className="admin-mono">
{formatDate(trip.trip_date)}
</td>
<td>{trip.users ? `${trip.users.first_name} ${trip.users.last_name}` : ''}</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
<span className="admin-badge">
{trip.vehicles?.spz ?? ""}
</span>
</td>
<td>
{trip.users
? `${trip.users.first_name} ${trip.users.last_name}`
: ""}
</td>
<td>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono"><strong>{formatKm(trip.distance ?? (trip.end_km - trip.start_km))} km</strong></td>
<td className="admin-mono">
<strong>
{formatKm(
trip.distance ?? trip.end_km - trip.start_km,
)}{" "}
km
</strong>
</td>
<td>
<span className={`admin-badge ${trip.is_business ? 'admin-badge-success' : 'admin-badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
<span
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
@@ -440,18 +567,38 @@ export default function Trips() {
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">
<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, tripId: trip.id })}
onClick={() =>
setDeleteConfirm({ show: true, tripId: trip.id })
}
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">
<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>
@@ -477,7 +624,10 @@ export default function Trips() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -487,19 +637,23 @@ export default function Trips() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingTrip ? 'Upravit jízdu' : 'Přidat jízdu'}
{editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<FormField
label="Vozidlo"
error={errors.vehicle_id}
required
>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value)
setErrors(prev => ({ ...prev, vehicle_id: '' }))
handleVehicleChange(e.target.value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
@@ -512,41 +666,53 @@ export default function Trips() {
</select>
</FormField>
<FormField label="Datum jízdy" error={errors.trip_date} required>
<FormField
label="Datum jízdy"
error={errors.trip_date}
required
>
<AdminDatePicker
mode="date"
value={form.trip_date}
onChange={(val: string) => {
setForm({ ...form, trip_date: val })
setErrors(prev => ({ ...prev, trip_date: '' }))
setForm({ ...form, trip_date: val });
setErrors((prev) => ({ ...prev, trip_date: "" }));
}}
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField label="Počáteční stav km" error={errors.start_km} required>
<FormField
label="Počáteční stav km"
error={errors.start_km}
required
>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value })
setErrors(prev => ({ ...prev, start_km: '' }))
setForm({ ...form, start_km: e.target.value });
setErrors((prev) => ({ ...prev, start_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km" error={errors.end_km} required>
<FormField
label="Konečný stav km"
error={errors.end_km}
required
>
<input
type="number"
inputMode="numeric"
value={form.end_km}
onChange={(e) => {
setForm({ ...form, end_km: e.target.value })
setErrors(prev => ({ ...prev, end_km: '' }))
setForm({ ...form, end_km: e.target.value });
setErrors((prev) => ({ ...prev, end_km: "" }));
}}
className="admin-form-input"
min="0"
@@ -565,26 +731,34 @@ export default function Trips() {
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu" error={errors.route_from} required>
<FormField
label="Místo odjezdu"
error={errors.route_from}
required
>
<input
type="text"
value={form.route_from}
onChange={(e) => {
setForm({ ...form, route_from: e.target.value })
setErrors(prev => ({ ...prev, route_from: '' }))
setForm({ ...form, route_from: e.target.value });
setErrors((prev) => ({ ...prev, route_from: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<FormField label="Místo příjezdu" error={errors.route_to} required>
<FormField
label="Místo příjezdu"
error={errors.route_to}
required
>
<input
type="text"
value={form.route_to}
onChange={(e) => {
setForm({ ...form, route_to: e.target.value })
setErrors(prev => ({ ...prev, route_to: '' }))
setForm({ ...form, route_to: e.target.value });
setErrors((prev) => ({ ...prev, route_to: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
@@ -595,7 +769,12 @@ export default function Trips() {
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) => setForm({ ...form, is_business: parseInt(e.target.value) })}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
@@ -606,7 +785,9 @@ export default function Trips() {
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
@@ -630,7 +811,7 @@ export default function Trips() {
className="admin-btn admin-btn-primary"
disabled={submitting}
>
{submitting ? 'Ukládám...' : 'Uložit'}
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
@@ -649,5 +830,5 @@ export default function Trips() {
type="danger"
/>
</div>
)
);
}

View File

@@ -1,74 +1,74 @@
import { useState, useEffect, useCallback, useRef } from 'react'
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 { useState, useEffect, useCallback, useRef } from "react";
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 AdminDatePicker from '../components/AdminDatePicker'
import FormField from '../components/FormField'
import useModalLock from '../hooks/useModalLock'
import { formatDate } from '../utils/attendanceHelpers'
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import { formatDate } from "../utils/attendanceHelpers";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface UserShort {
id: number | string
name: string
id: number | string;
name: string;
}
interface Trip {
id: number
vehicle_id: number | string
trip_date: string
start_km: number
end_km: number
distance: number
route_from: string
route_to: string
is_business: number | boolean
notes?: string
spz: string
driver_name: string
id: number;
vehicle_id: number | string;
trip_date: string;
start_km: number;
end_km: number;
distance: number;
route_from: string;
route_to: string;
is_business: number | boolean;
notes?: string;
spz: string;
driver_name: string;
}
interface BackendTrip {
id: number
vehicle_id: number
user_id: number
trip_date: string
start_km: number
end_km: number
distance: number | null
route_from: string
route_to: string
is_business: boolean
notes: string | null
users: { id: number; first_name: string; last_name: string }
vehicles: { id: number; name: string; spz: string }
id: number;
vehicle_id: number;
user_id: number;
trip_date: string;
start_km: number;
end_km: number;
distance: number | null;
route_from: string;
route_to: string;
is_business: boolean;
notes: string | null;
users: { id: number; first_name: string; last_name: string };
vehicles: { id: number; name: string; spz: string };
}
interface EditForm {
vehicle_id: string
trip_date: string
start_km: string | number
end_km: string | number
route_from: string
route_to: string
is_business: number
notes: string
vehicle_id: string;
trip_date: string;
start_km: string | number;
end_km: string | number;
route_from: string;
route_to: string;
is_business: number;
notes: string;
}
function mapTrip(bt: BackendTrip): Trip {
const distance = bt.distance ?? (bt.end_km - bt.start_km)
const distance = bt.distance ?? bt.end_km - bt.start_km;
return {
id: bt.id,
vehicle_id: bt.vehicle_id,
@@ -80,38 +80,45 @@ function mapTrip(bt: BackendTrip): Trip {
route_to: bt.route_to,
is_business: bt.is_business ? 1 : 0,
notes: bt.notes || undefined,
spz: bt.vehicles?.spz ?? '',
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : '',
}
spz: bt.vehicles?.spz ?? "",
driver_name: bt.users ? `${bt.users.first_name} ${bt.users.last_name}` : "",
};
}
export default function TripsAdmin() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [filterMonth, setFilterMonth] = useState(() => String(new Date().getMonth() + 1))
const [filterYear, setFilterYear] = useState(() => String(new Date().getFullYear()))
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 alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
const [filterYear, setFilterYear] = useState(() =>
String(new Date().getFullYear()),
);
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)
const [editingTrip, setEditingTrip] = useState<Trip | null>(null)
const [showEditModal, setShowEditModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<Trip | null>(null);
const [editForm, setEditForm] = useState<EditForm>({
vehicle_id: '',
trip_date: '',
start_km: '',
end_km: '',
route_from: '',
route_to: '',
vehicle_id: "",
trip_date: "",
start_km: "",
end_km: "",
route_from: "",
route_to: "",
is_business: 1,
notes: ''
})
notes: "",
});
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; trip: Trip | null }>({ show: false, trip: null })
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
trip: Trip | null;
}>({ show: false, trip: null });
// Fetch vehicles and users once on mount
useEffect(() => {
@@ -120,53 +127,60 @@ export default function TripsAdmin() {
const [vRes, uRes] = await Promise.all([
apiFetch(`${API_BASE}/vehicles`),
apiFetch(`${API_BASE}/users?limit=1000`),
])
const vJson = await vRes.json()
const uJson = await uRes.json()
if (vJson.success) setVehicles(vJson.data)
]);
const vJson = await vRes.json();
const uJson = await uRes.json();
if (vJson.success) setVehicles(vJson.data);
if (uJson.success) {
setUsers(uJson.data.map((u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
})))
setUsers(
uJson.data.map(
(u: { id: number; first_name: string; last_name: string }) => ({
id: u.id,
name: `${u.first_name} ${u.last_name}`,
}),
),
);
}
} catch {
// silently fail, filters will just be empty
}
}
fetchLookups()
}, [])
};
fetchLookups();
}, []);
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 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 response = await apiFetch(url)
const result = await response.json()
if (result.success) {
const mapped = (result.data as BackendTrip[]).map(mapTrip)
setTrips(mapped)
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);
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [filterMonth, filterYear, filterVehicleId, filterUserId, alert])
},
[filterMonth, filterYear, filterVehicleId, filterUserId, alert],
);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
useModalLock(showEditModal)
useModalLock(showEditModal);
if (!hasPermission('trips.admin')) return <Forbidden />
if (!hasPermission("trips.admin")) return <Forbidden />;
const openEditModal = (trip: Trip) => {
setEditingTrip(trip)
setEditingTrip(trip);
setEditForm({
vehicle_id: String(trip.vehicle_id),
trip_date: trip.trip_date,
@@ -175,82 +189,91 @@ export default function TripsAdmin() {
route_from: trip.route_from,
route_to: trip.route_to,
is_business: Number(trip.is_business),
notes: trip.notes || ''
})
setShowEditModal(true)
}
notes: trip.notes || "",
});
setShowEditModal(true);
};
const handleEditSubmit = async () => {
if (!editingTrip) return
if (parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))) {
alert.error('Konečný stav km musí být větší než počáteční')
return
if (!editingTrip) return;
if (
parseInt(String(editForm.end_km)) <= parseInt(String(editForm.start_km))
) {
alert.error("Konečný stav km musí být větší než počáteční");
return;
}
try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editForm)
})
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
setShowEditModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setShowEditModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const handleDelete = async () => {
if (!deleteConfirm.trip) return
if (!deleteConfirm.trip) return;
try {
const response = await apiFetch(`${API_BASE}/trips/${deleteConfirm.trip.id}`, {
method: 'DELETE',
})
const response = await apiFetch(
`${API_BASE}/trips/${deleteConfirm.trip.id}`,
{
method: "DELETE",
},
);
const result = await response.json()
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, trip: null })
await fetchData(false)
alert.success(result.message)
setDeleteConfirm({ show: false, trip: null });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const getPeriodName = () => new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString('cs-CZ', { month: 'long', year: 'numeric' })
const getPeriodName = () =>
new Date(Number(filterYear), Number(filterMonth) - 1).toLocaleString(
"cs-CZ",
{ month: "long", year: "numeric" },
);
const getSelectedVehicleName = () => {
if (!filterVehicleId) return null
const v = vehicles.find(v => String(v.id) === filterVehicleId)
return v ? `${v.spz} - ${v.name}` : null
}
if (!filterVehicleId) return null;
const v = vehicles.find((v) => String(v.id) === filterVehicleId);
return v ? `${v.spz} - ${v.name}` : null;
};
const getSelectedUserName = () => {
if (!filterUserId) return null
const u = users.find(u => String(u.id) === filterUserId)
return u?.name || null
}
if (!filterUserId) return null;
const u = users.find((u) => String(u.id) === filterUserId);
return u?.name || null;
};
const handlePrint = () => {
const periodName = getPeriodName()
const periodName = getPeriodName();
setTimeout(() => {
if (printRef.current) {
const content = printRef.current.innerHTML
const printWindow = window.open('', '_blank')
if (!printWindow) return
const content = printRef.current.innerHTML;
const printWindow = window.open("", "_blank");
if (!printWindow) return;
printWindow.document.write(`
<!DOCTYPE html>
<html lang="cs">
@@ -324,26 +347,28 @@ export default function TripsAdmin() {
${content}
</body>
</html>
`)
printWindow.document.close()
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print()
}
printWindow.print();
};
}
}, 100)
}
}, 100);
};
const calculateDistance = (): number => {
const start = parseInt(String(editForm.start_km)) || 0
const end = parseInt(String(editForm.end_km)) || 0
return end > start ? end - start : 0
}
const start = parseInt(String(editForm.start_km)) || 0;
const end = parseInt(String(editForm.end_km)) || 0;
return end > start ? end - start : 0;
};
const totals = {
count: trips.length,
total: trips.reduce((sum, t) => sum + t.distance, 0),
business: trips.filter(t => Number(t.is_business)).reduce((sum, t) => sum + t.distance, 0),
}
business: trips
.filter((t) => Number(t.is_business))
.reduce((sum, t) => sum + t.distance, 0),
};
return (
<div>
@@ -363,7 +388,15 @@ export default function TripsAdmin() {
className="admin-btn admin-btn-secondary"
title="Tisk knihy jízd"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ marginRight: '0.5rem' }}>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
>
<polyline points="6 9 6 2 18 2 18 9" />
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
<rect x="6" y="14" width="12" height="8" />
@@ -394,7 +427,9 @@ export default function TripsAdmin() {
>
{Array.from({ length: 12 }, (_, i) => (
<option key={i + 1} value={i + 1}>
{new Date(2000, i).toLocaleString('cs-CZ', { month: 'long' })}
{new Date(2000, i).toLocaleString("cs-CZ", {
month: "long",
})}
</option>
))}
</select>
@@ -406,8 +441,12 @@ export default function TripsAdmin() {
className="admin-form-select"
>
{Array.from({ length: 5 }, (_, i) => {
const y = new Date().getFullYear() - 2 + i
return <option key={y} value={y}>{y}</option>
const y = new Date().getFullYear() - 2 + i;
return (
<option key={y} value={y}>
{y}
</option>
);
})}
</select>
</FormField>
@@ -451,7 +490,16 @@ export default function TripsAdmin() {
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
@@ -464,18 +512,38 @@ export default function TripsAdmin() {
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
@@ -484,7 +552,9 @@ export default function TripsAdmin() {
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
@@ -499,8 +569,8 @@ export default function TripsAdmin() {
>
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton" style={{ gap: '1.25rem' }}>
{[0, 1, 2, 3, 4].map(i => (
<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" />
@@ -532,25 +602,31 @@ export default function TripsAdmin() {
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<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' }}>
<span style={{ whiteSpace: "nowrap" }}>
{trip.route_from} &rarr; {trip.route_to}
</span>
</td>
<td className="admin-mono">
<span style={{ whiteSpace: 'nowrap' }}>
<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 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
className={`admin-badge ${trip.is_business ? "admin-badge-success" : "admin-badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>
@@ -561,18 +637,38 @@ export default function TripsAdmin() {
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">
<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 })}
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">
<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>
@@ -598,7 +694,10 @@ export default function TripsAdmin() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowEditModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -608,7 +707,12 @@ export default function TripsAdmin() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2>
<p style={{ color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
>
{editingTrip.driver_name}
</p>
</div>
@@ -619,7 +723,12 @@ export default function TripsAdmin() {
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) => setEditForm({ ...editForm, vehicle_id: e.target.value })}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{vehicles.map((v) => (
@@ -634,7 +743,9 @@ export default function TripsAdmin() {
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) => setEditForm({ ...editForm, trip_date: val })}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
@@ -645,7 +756,9 @@ export default function TripsAdmin() {
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) => setEditForm({ ...editForm, start_km: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
@@ -656,7 +769,9 @@ export default function TripsAdmin() {
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) => setEditForm({ ...editForm, end_km: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
@@ -678,7 +793,12 @@ export default function TripsAdmin() {
<input
type="text"
value={editForm.route_from}
onChange={(e) => setEditForm({ ...editForm, route_from: e.target.value })}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
@@ -687,7 +807,9 @@ export default function TripsAdmin() {
<input
type="text"
value={editForm.route_to}
onChange={(e) => setEditForm({ ...editForm, route_to: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
@@ -696,7 +818,12 @@ export default function TripsAdmin() {
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) => setEditForm({ ...editForm, is_business: parseInt(e.target.value) })}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
@@ -707,7 +834,9 @@ export default function TripsAdmin() {
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) => setEditForm({ ...editForm, notes: e.target.value })}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
@@ -742,17 +871,25 @@ export default function TripsAdmin() {
onClose={() => setDeleteConfirm({ show: false, trip: null })}
onConfirm={handleDelete}
title="Smazat záznam"
message={deleteConfirm.trip ? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?` : ''}
message={
deleteConfirm.trip
? `Opravdu chcete smazat záznam jízdy z ${formatDate(deleteConfirm.trip.trip_date)}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
{/* Hidden Print Content */}
{trips.length > 0 && (
<div ref={printRef} style={{ display: 'none' }}>
<div ref={printRef} style={{ display: "none" }}>
<div className="print-header">
<div className="print-header-left">
<img src="/images/logo-light.png" alt="BOHA" className="print-logo" />
<img
src="/images/logo-light.png"
alt="BOHA"
className="print-logo"
/>
<div className="print-header-text">
<h1>KNIHA JÍZD</h1>
<div className="company">BOHA Automation s.r.o.</div>
@@ -760,9 +897,17 @@ export default function TripsAdmin() {
</div>
<div className="print-header-right">
<div className="period">{getPeriodName()}</div>
{getSelectedVehicleName() && <div className="filters">Vozidlo: {getSelectedVehicleName()}</div>}
{getSelectedUserName() && <div className="filters">Řidič: {getSelectedUserName()}</div>}
<div className="generated">Vygenerováno: {new Date().toLocaleString('cs-CZ')}</div>
{getSelectedVehicleName() && (
<div className="filters">
Vozidlo: {getSelectedVehicleName()}
</div>
)}
{getSelectedUserName() && (
<div className="filters">Řidič: {getSelectedUserName()}</div>
)}
<div className="generated">
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
</div>
</div>
</div>
@@ -776,11 +921,15 @@ export default function TripsAdmin() {
<div className="summary-label">Celkem</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.business)} km</div>
<div className="summary-value">
{formatKm(totals.business)} km
</div>
<div className="summary-label">Služební</div>
</div>
<div className="summary-item">
<div className="summary-value">{formatKm(totals.total - totals.business)} km</div>
<div className="summary-value">
{formatKm(totals.total - totals.business)} km
</div>
<div className="summary-label">Soukromé</div>
</div>
</div>
@@ -788,13 +937,19 @@ export default function TripsAdmin() {
<table>
<thead>
<tr>
<th style={{ width: '70px' }}>Datum</th>
<th style={{ width: '80px' }}>Řidič</th>
<th style={{ width: '70px' }}>Vozidlo</th>
<th style={{ width: "70px" }}>Datum</th>
<th style={{ width: "80px" }}>Řidič</th>
<th style={{ width: "70px" }}>Vozidlo</th>
<th>Trasa</th>
<th style={{ width: '70px' }} className="text-right">Stav km</th>
<th style={{ width: '60px' }} className="text-right">Vzdálenost</th>
<th style={{ width: '55px' }} className="text-center">Typ</th>
<th style={{ width: "70px" }} className="text-right">
Stav km
</th>
<th style={{ width: "60px" }} className="text-right">
Vzdálenost
</th>
<th style={{ width: "55px" }} className="text-center">
Typ
</th>
<th>Poznámka</th>
</tr>
</thead>
@@ -804,22 +959,34 @@ export default function TripsAdmin() {
<td>{formatDate(trip.trip_date)}</td>
<td>{trip.driver_name}</td>
<td>{trip.spz}</td>
<td>{trip.route_from} &rarr; {trip.route_to}</td>
<td className="text-right">{formatKm(trip.start_km)} - {formatKm(trip.end_km)}</td>
<td className="text-right"><strong>{formatKm(trip.distance)} km</strong></td>
<td>
{trip.route_from} &rarr; {trip.route_to}
</td>
<td className="text-right">
{formatKm(trip.start_km)} - {formatKm(trip.end_km)}
</td>
<td className="text-right">
<strong>{formatKm(trip.distance)} km</strong>
</td>
<td className="text-center">
<span className={`badge ${trip.is_business ? 'badge-success' : 'badge-warning'}`}>
{trip.is_business ? 'Služební' : 'Soukromá'}
<span
className={`badge ${trip.is_business ? "badge-success" : "badge-warning"}`}
>
{trip.is_business ? "Služební" : "Soukromá"}
</span>
</td>
<td>{trip.notes || ''}</td>
<td>{trip.notes || ""}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="text-right">Celkem:</td>
<td className="text-right"><strong>{formatKm(totals.total)} km</strong></td>
<td colSpan={5} className="text-right">
Celkem:
</td>
<td className="text-right">
<strong>{formatKm(totals.total)} km</strong>
</td>
<td colSpan={2}></td>
</tr>
</tfoot>
@@ -827,5 +994,5 @@ export default function TripsAdmin() {
</div>
)}
</div>
)
);
}

View File

@@ -1,103 +1,110 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import { motion } from 'framer-motion'
import AdminDatePicker from '../components/AdminDatePicker'
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'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
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'
const API_BASE = "/api/admin";
interface Vehicle {
id: number | string
spz: string
name: string
id: number | string;
spz: string;
name: string;
}
interface Trip {
id: number
trip_date: string
spz: string
driver_name: string
route_from: string
route_to: string
start_km: number
end_km: number
distance: number
is_business: number | boolean
notes?: string
id: number;
trip_date: string;
spz: string;
driver_name: string;
route_from: string;
route_to: string;
start_km: number;
end_km: number;
distance: number;
is_business: number | boolean;
notes?: string;
}
export default function TripsHistory() {
const alert = useAlert()
const { user, hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
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 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 totals = trips.reduce(
(acc, t) => ({
total: acc.total + (t.distance || 0),
business: acc.business + (t.is_business ? (t.distance || 0) : 0),
business: acc.business + (t.is_business ? t.distance || 0 : 0),
count: acc.count + 1,
}),
{ total: 0, business: 0, count: 0 }
)
{ total: 0, business: 0, count: 0 },
);
const fetchData = useCallback(async () => {
setLoading(true)
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 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 (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),
})))
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 : [])
setVehicles(
Array.isArray(vehiclesResult.data) ? vehiclesResult.data : [],
);
}
} catch {
alert.error('Nepodařilo se načíst data')
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false)
setLoading(false);
}
}, [month, vehicleId, alert, user?.id])
}, [month, vehicleId, alert, user?.id]);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
if (!hasPermission('trips.history')) return <Forbidden />
if (!hasPermission("trips.history")) return <Forbidden />;
const getMonthName = (monthStr: string): string => {
const [yearStr, monthNum] = monthStr.split('-')
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1)
return date.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
}
const [yearStr, monthNum] = monthStr.split("-");
const date = new Date(parseInt(yearStr), parseInt(monthNum) - 1);
return date.toLocaleDateString("cs-CZ", { month: "long", year: "numeric" });
};
return (
<div>
@@ -155,7 +162,16 @@ export default function TripsHistory() {
>
<div className="admin-stat-card info">
<div className="admin-stat-icon info">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="20" x2="12" y2="10" />
<line x1="18" y1="20" x2="18" y2="4" />
<line x1="6" y1="20" x2="6" y2="16" />
@@ -168,18 +184,38 @@ export default function TripsHistory() {
</div>
<div className="admin-stat-card">
<div className="admin-stat-icon">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.total)} km</span>
<span className="admin-stat-value">
{formatKm(totals.total)} km
</span>
<span className="admin-stat-label">Celkem naježděno</span>
</div>
</div>
<div className="admin-stat-card success">
<div className="admin-stat-icon success">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" rx="2" ry="2" />
<path d="M16 8h2a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-1" />
<circle cx="5.5" cy="18" r="2" />
@@ -188,7 +224,9 @@ export default function TripsHistory() {
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-value">{formatKm(totals.business)} km</span>
<span className="admin-stat-value">
{formatKm(totals.business)} km
</span>
<span className="admin-stat-label">Služební km</span>
</div>
</div>
@@ -204,7 +242,7 @@ export default function TripsHistory() {
<div className="admin-card-body">
{loading && (
<div className="admin-skeleton gap-5">
{[0, 1, 2, 3, 4].map(i => (
{[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" />
@@ -236,29 +274,47 @@ export default function TripsHistory() {
<tbody>
{trips.map((trip) => (
<tr key={trip.id}>
<td className="admin-mono">{formatDate(trip.trip_date)}</td>
<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 style={{ color: "var(--text-secondary)" }}>
{trip.driver_name}
</td>
<td>
<span style={{ whiteSpace: 'nowrap' }}>
<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)' }}>
<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 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
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
style={{
color: "var(--text-secondary)",
maxWidth: "200px",
}}
>
{trip.notes || "—"}
</td>
</tr>
))}
@@ -269,5 +325,5 @@ export default function TripsHistory() {
</div>
</motion.div>
</div>
)
);
}

View File

@@ -1,252 +1,284 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
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 { useState, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
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 apiFetch from '../utils/api'
const API_BASE = '/api/admin'
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface User {
id: number
username: string
email: string
first_name: string
last_name: string
role_id: number
roles?: { id: number; name: string; display_name: string } | null
is_active: boolean
id: number;
username: string;
email: string;
first_name: string;
last_name: string;
role_id: number;
roles?: { id: number; name: string; display_name: string } | null;
is_active: boolean;
}
interface Role {
id: number
name: string
display_name: string
id: number;
name: string;
display_name: string;
}
interface FormData {
username: string
email: string
password: string
first_name: string
last_name: string
role_id: number | string
is_active: boolean
username: string;
email: string;
password: string;
first_name: string;
last_name: string;
role_id: number | string;
is_active: boolean;
}
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 [showModal, setShowModal] = useState(false)
const [editingUser, setEditingUser] = useState<User | null>(null)
const [deleteModal, setDeleteModal] = useState<{ isOpen: boolean; user: User | null }>({ isOpen: false, user: null })
const [deleting, setDeleting] = useState(false)
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 [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [deleteModal, setDeleteModal] = useState<{
isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [formData, setFormData] = useState<FormData>({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: '',
is_active: true
})
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role_id: "",
is_active: true,
});
useModalLock(showModal)
useModalLock(showModal);
const fetchUsers = useCallback(async () => {
try {
const usersRes = await apiFetch(`${API_BASE}/users`)
const usersData = await usersRes.json()
const usersRes = await apiFetch(`${API_BASE}/users`);
const usersData = await usersRes.json();
if (usersData.success) {
setUsers(Array.isArray(usersData.data) ? usersData.data : [])
setUsers(Array.isArray(usersData.data) ? usersData.data : []);
} else {
alert.error(usersData.error || 'Nepodařilo se načíst uživatele')
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()
const rolesRes = await apiFetch(`${API_BASE}/roles`);
const rolesData = await rolesRes.json();
if (rolesData.success) {
setRoles(Array.isArray(rolesData.data) ? rolesData.data : [])
setRoles(Array.isArray(rolesData.data) ? rolesData.data : []);
}
} catch { /* roles not accessible */ }
} catch {
/* roles not accessible */
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setLoading(false)
setLoading(false);
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
}, []); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchUsers()
}, [fetchUsers])
fetchUsers();
}, [fetchUsers]);
if (!hasPermission('users.view')) return <Forbidden />
if (!hasPermission("users.view")) return <Forbidden />;
const openCreateModal = () => {
setEditingUser(null)
setEditingUser(null);
setFormData({
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role_id: roles[0]?.id || '',
is_active: true
})
setShowModal(true)
}
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role_id: roles[0]?.id || "",
is_active: true,
});
setShowModal(true);
};
const openEditModal = (user: User) => {
setEditingUser(user)
setEditingUser(user);
setFormData({
username: user.username,
email: user.email,
password: '',
password: "",
first_name: user.first_name,
last_name: user.last_name,
role_id: user.role_id,
is_active: user.is_active
})
setShowModal(true)
}
is_active: user.is_active,
});
setShowModal(true);
};
const closeModal = () => {
setShowModal(false)
setEditingUser(null)
}
setShowModal(false);
setEditingUser(null);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
e?.preventDefault();
const dataToSave = { ...formData }
const wasEditing = editingUser
const editingId = editingUser?.id
const dataToSave = { ...formData };
const wasEditing = editingUser;
const editingId = editingUser?.id;
try {
const url = wasEditing
? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users`
: `${API_BASE}/users`;
const method = wasEditing ? 'PUT' : 'POST'
const method = wasEditing ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(dataToSave)
})
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
const data = await response.json()
const data = await response.json();
if (data.success) {
if (wasEditing && currentUser && Number(editingId) === Number(currentUser.id)) {
if (
wasEditing &&
currentUser &&
Number(editingId) === Number(currentUser.id)
) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim()
})
fullName: `${dataToSave.first_name} ${dataToSave.last_name}`.trim(),
});
}
closeModal()
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(wasEditing ? 'Uživatel byl upraven' : 'Uživatel byl vytvořen')
fetchUsers()
closeModal();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
wasEditing ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
fetchUsers();
} else {
alert.error(data.error || 'Nepodařilo se uložit uživatele')
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const openDeleteModal = (user: User) => {
setDeleteModal({ isOpen: true, user })
}
setDeleteModal({ isOpen: true, user });
};
const closeDeleteModal = () => {
setDeleteModal({ isOpen: false, user: null })
}
setDeleteModal({ isOpen: false, user: null });
};
const handleDelete = async () => {
if (!deleteModal.user) return
if (!deleteModal.user) return;
setDeleting(true)
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/users/${deleteModal.user.id}`, {
method: 'DELETE',
})
const response = await apiFetch(
`${API_BASE}/users/${deleteModal.user.id}`,
{
method: "DELETE",
},
);
const data = await response.json()
const data = await response.json();
if (data.success) {
closeDeleteModal()
fetchUsers()
alert.success('Uživatel byl smazán')
closeDeleteModal();
fetchUsers();
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || 'Nepodařilo se smazat uživatele')
alert.error(data.error || "Nepodařilo se smazat uživatele");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
} finally {
setDeleting(false)
setDeleting(false);
}
}
};
const toggleActive = async (user: User) => {
try {
const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
is_active: !user.is_active
})
})
is_active: !user.is_active,
}),
});
const data = await response.json()
const data = await response.json();
if (data.success) {
fetchUsers()
alert.success(user.is_active ? 'Uživatel byl deaktivován' : 'Uživatel byl aktivován')
fetchUsers();
alert.success(
user.is_active
? "Uživatel byl deaktivován"
: "Uživatel byl aktivován",
);
} else {
alert.error(data.error || 'Nepodařilo se změnit stav uživatele')
alert.error(data.error || "Nepodařilo se změnit stav uživatele");
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const getRoleBadgeClass = (roleName: string): string => {
switch (roleName) {
case 'admin': return 'admin-badge admin-badge-admin'
default: return 'admin-badge admin-badge-viewer'
case "admin":
return "admin-badge admin-badge-admin";
default:
return "admin-badge admin-badge-viewer";
}
}
};
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" 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
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: '160px', borderRadius: '8px' }} />
<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 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
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -254,7 +286,7 @@ export default function Users() {
</div>
</div>
</div>
)
);
}
return (
@@ -267,10 +299,22 @@ export default function Users() {
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">Správa uživatelských úč a oprávnění</p>
<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"
>
<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>
@@ -302,30 +346,43 @@ export default function Users() {
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username).charAt(0).toUpperCase()}
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</div>
<div className="admin-table-username">@{user.username}</div>
<div className="admin-table-username">
@{user.username}
</div>
</div>
</div>
</td>
<td>{user.email}</td>
<td>
<span className={getRoleBadgeClass(user.roles?.name ?? '')}>
{user.roles?.display_name || user.roles?.name || '—'}
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
<button
onClick={() => user.id !== currentUser?.id && toggleActive(user)}
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' }}
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í'}
{user.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
@@ -336,7 +393,14 @@ export default function Users() {
title="Upravit"
aria-label="Upravit"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
@@ -348,7 +412,14 @@ export default function Users() {
title="Smazat"
aria-label="Smazat"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<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>
@@ -383,7 +454,9 @@ export default function Users() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingUser ? 'Upravit uživatele' : 'Přidat nového uživatele'}
{editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2>
</div>
@@ -394,7 +467,12 @@ export default function Users() {
<input
type="text"
value={formData.first_name}
onChange={(e) => setFormData({ ...formData, first_name: e.target.value })}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
@@ -403,7 +481,12 @@ export default function Users() {
<input
type="text"
value={formData.last_name}
onChange={(e) => setFormData({ ...formData, last_name: e.target.value })}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
@@ -414,7 +497,9 @@ export default function Users() {
<input
type="text"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
@@ -424,17 +509,23 @@ export default function Users() {
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
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)' : ''}`}>
<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 })}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
@@ -443,7 +534,9 @@ export default function Users() {
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) => setFormData({ ...formData, role_id: e.target.value })}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
@@ -459,7 +552,12 @@ export default function Users() {
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
@@ -467,11 +565,19 @@ export default function Users() {
</div>
<div className="admin-modal-footer">
<button type="button" onClick={closeModal} className="admin-btn admin-btn-secondary">
<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
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button>
</div>
</motion.div>
@@ -491,5 +597,5 @@ export default function Users() {
loading={deleting}
/>
</div>
)
);
}

View File

@@ -1,209 +1,234 @@
import { useState, useEffect, useCallback } from 'react'
import { useAlert } from '../context/AlertContext'
import { useAuth } from '../context/AuthContext'
import Forbidden from '../components/Forbidden'
import { motion, AnimatePresence } from 'framer-motion'
import ConfirmModal from '../components/ConfirmModal'
import useModalLock from '../hooks/useModalLock'
import { useState, useEffect, useCallback } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import { formatKm } from '../utils/formatters'
import apiFetch from '../utils/api'
import FormField from '../components/FormField'
const API_BASE = '/api/admin'
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
const API_BASE = "/api/admin";
interface Vehicle {
id: number
spz: string
name: string
brand?: string
model?: string
initial_km: number
current_km: number
trip_count: number
is_active: boolean | number
id: number;
spz: string;
name: string;
brand?: string;
model?: string;
initial_km: number;
current_km: number;
trip_count: number;
is_active: boolean | number;
}
interface VehicleForm {
spz: string
name: string
brand: string
model: string
initial_km: number
is_active: boolean
spz: string;
name: string;
brand: string;
model: string;
initial_km: number;
is_active: boolean;
}
export default function Vehicles() {
const alert = useAlert()
const { hasPermission } = useAuth()
const [loading, setLoading] = useState(true)
const [vehicles, setVehicles] = useState<Vehicle[]>([])
const alert = useAlert();
const { hasPermission } = useAuth();
const [loading, setLoading] = useState(true);
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
const [showModal, setShowModal] = useState(false)
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null)
const [showModal, setShowModal] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
const [form, setForm] = useState<VehicleForm>({
spz: '',
name: '',
brand: '',
model: '',
spz: "",
name: "",
brand: "",
model: "",
initial_km: 0,
is_active: true
})
is_active: true,
});
const [errors, setErrors] = useState<Record<string, string>>({})
const [deleteConfirm, setDeleteConfirm] = useState<{ show: boolean; vehicle: Vehicle | null }>({ show: false, vehicle: null })
const [errors, setErrors] = useState<Record<string, string>>({});
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
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 : [])
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);
}
} catch {
alert.error('Nepodařilo se načíst data')
} finally {
if (showLoading) setLoading(false)
}
}, [alert])
},
[alert],
);
useEffect(() => {
fetchData()
}, [fetchData])
fetchData();
}, [fetchData]);
useModalLock(showModal)
useModalLock(showModal);
if (!hasPermission('trips.vehicles')) return <Forbidden />
if (!hasPermission("trips.vehicles")) return <Forbidden />;
const openCreateModal = () => {
setEditingVehicle(null)
setEditingVehicle(null);
setForm({
spz: '',
name: '',
brand: '',
model: '',
spz: "",
name: "",
brand: "",
model: "",
initial_km: 0,
is_active: true
})
setErrors({})
setShowModal(true)
}
is_active: true,
});
setErrors({});
setShowModal(true);
};
const openEditModal = (vehicle: Vehicle) => {
setEditingVehicle(vehicle)
setEditingVehicle(vehicle);
setForm({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
brand: vehicle.brand || "",
model: vehicle.model || "",
initial_km: vehicle.initial_km,
is_active: Boolean(vehicle.is_active)
})
setErrors({})
setShowModal(true)
}
is_active: Boolean(vehicle.is_active),
});
setErrors({});
setShowModal(true);
};
const handleSubmit = async () => {
const newErrors: Record<string, string> = {}
if (!form.spz) newErrors.spz = 'Zadejte SPZ'
if (!form.name) newErrors.name = 'Zadejte název'
setErrors(newErrors)
if (Object.keys(newErrors).length > 0) return
const newErrors: Record<string, string> = {};
if (!form.spz) newErrors.spz = "Zadejte SPZ";
if (!form.name) newErrors.name = "Zadejte název";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
try {
const url = editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles`
const method = editingVehicle ? 'PUT' : 'POST'
: `${API_BASE}/vehicles`;
const method = editingVehicle ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form)
})
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
setShowModal(false)
await fetchData(false)
await new Promise(resolve => setTimeout(resolve, 300))
alert.success(result.message)
setShowModal(false);
await fetchData(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const handleDelete = async () => {
if (!deleteConfirm.vehicle) return
if (!deleteConfirm.vehicle) return;
try {
const response = await apiFetch(`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`, {
method: 'DELETE',
})
const response = await apiFetch(
`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`,
{
method: "DELETE",
},
);
const result = await response.json()
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null })
await fetchData(false)
alert.success(result.message)
setDeleteConfirm({ show: false, vehicle: null });
await fetchData(false);
alert.success(result.message);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
const toggleActive = async (vehicle: Vehicle) => {
try {
const response = await apiFetch(`${API_BASE}/vehicles/${vehicle.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
spz: vehicle.spz,
name: vehicle.name,
brand: vehicle.brand || '',
model: vehicle.model || '',
brand: vehicle.brand || "",
model: vehicle.model || "",
initial_km: vehicle.initial_km,
is_active: !vehicle.is_active
})
})
is_active: !vehicle.is_active,
}),
});
const result = await response.json()
const result = await response.json();
if (result.success) {
fetchData(false)
alert.success(vehicle.is_active ? 'Vozidlo bylo deaktivováno' : 'Vozidlo bylo aktivováno')
fetchData(false);
alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"
: "Vozidlo bylo aktivováno",
);
} else {
alert.error(result.error)
alert.error(result.error);
}
} catch {
alert.error('Chyba připojení')
alert.error("Chyba připojení");
}
}
};
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" 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' }} />
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
<div className="admin-skeleton-line h-10" style={{ width: '150px', borderRadius: '8px' }} />
<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 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
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div className="admin-skeleton-line w-1/4" />
</div>
@@ -211,7 +236,7 @@ export default function Vehicles() {
</div>
</div>
</div>
)
);
}
return (
@@ -226,8 +251,18 @@ export default function Vehicles() {
<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">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
@@ -246,7 +281,16 @@ export default function Vehicles() {
{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">
<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" />
@@ -254,7 +298,10 @@ export default function Vehicles() {
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<button onClick={openCreateModal} className="admin-btn admin-btn-primary">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat první vozidlo
</button>
</div>
@@ -276,23 +323,32 @@ export default function Vehicles() {
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr key={vehicle.id} className={!vehicle.is_active ? 'admin-table-row-inactive' : ''}>
<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()
: '—'}
? `${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">{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'}`}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? 'Aktivní' : 'Neaktivní'}
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
@@ -303,18 +359,38 @@ export default function Vehicles() {
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">
<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 })}
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">
<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>
@@ -340,7 +416,10 @@ export default function Vehicles() {
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={() => setShowModal(false)} />
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -350,7 +429,7 @@ export default function Vehicles() {
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingVehicle ? 'Upravit vozidlo' : 'Přidat vozidlo'}
{editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
</h2>
</div>
@@ -362,8 +441,11 @@ export default function Vehicles() {
type="text"
value={form.spz}
onChange={(e) => {
setForm({ ...form, spz: e.target.value.toUpperCase() })
setErrors(prev => ({ ...prev, spz: '' }))
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
@@ -376,8 +458,8 @@ export default function Vehicles() {
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value })
setErrors(prev => ({ ...prev, name: '' }))
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
@@ -391,7 +473,9 @@ export default function Vehicles() {
<input
type="text"
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
onChange={(e) =>
setForm({ ...form, brand: e.target.value })
}
className="admin-form-input"
placeholder="Škoda"
/>
@@ -401,7 +485,9 @@ export default function Vehicles() {
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
onChange={(e) =>
setForm({ ...form, model: e.target.value })
}
className="admin-form-input"
placeholder="Octavia Combi"
/>
@@ -409,12 +495,19 @@ export default function Vehicles() {
</div>
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<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 })}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
@@ -427,7 +520,9 @@ export default function Vehicles() {
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/>
<span>Vozidlo je aktivní</span>
</label>
@@ -461,10 +556,14 @@ export default function Vehicles() {
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={deleteConfirm.vehicle ? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?` : ''}
message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
)
);
}