style: run prettier on entire codebase
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||
>
|
||||
← 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: '© OpenStreetMap contributors'
|
||||
}).addTo(map)
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
attribution: "© 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"
|
||||
>
|
||||
← 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 →</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 →
|
||||
</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 →</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 →
|
||||
</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
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
← Zpět na přihlášení
|
||||
</button>
|
||||
@@ -317,5 +390,5 @@ export default function Login() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 "{deleteConfirm.order?.order_number}"? 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 "
|
||||
{deleteConfirm.order?.order_number}"? 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "{project.project_number} – {project.name}"? Tato akce je nevratná.
|
||||
Opravdu chcete smazat projekt "{project.project_number} –{" "}
|
||||
{project.name}"? 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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} → {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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} → {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} → {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} → {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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} → {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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 účtů a oprávnění</p>
|
||||
<p className="admin-page-subtitle">
|
||||
Správa uživatelských účtů 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user