v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix

Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
This commit is contained in:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
@@ -16,8 +16,8 @@ import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { Skeleton } from "boneyard-js/react";
import AttendanceFixture from "../fixtures/AttendanceFixture";
import { useApiMutation } from "../lib/queries/mutations";
import { czechPlural } from "../utils/formatters";
const API_BASE = "/api/admin";
@@ -65,7 +65,6 @@ interface MonthlyFund {
leave_hours: number;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
}
@@ -79,12 +78,6 @@ interface AttendanceData {
active_project_id: number | null;
}
function pluralizeDays(n: number) {
if (n === 1) return "den";
if (n >= 2 && n <= 4) return "dny";
return "dnů";
}
function getFundBarBackground(fund: MonthlyFund) {
if (fund.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
@@ -96,7 +89,6 @@ function getFundBarBackground(fund: MonthlyFund) {
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
@@ -109,6 +101,41 @@ export default function Attendance() {
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
{
url: () => `${API_BASE}/attendance/notes`,
method: () => "POST",
invalidate: ["attendance"],
},
);
const switchProjectMutation = useApiMutation<
{ project_id: number | null },
{ message?: string }
>({
url: () => `${API_BASE}/attendance/switch-project`,
method: () => "POST",
invalidate: ["attendance"],
});
const leaveRequestMutation = useApiMutation<
{ leave_type: string; date_from: string; date_to: string; notes: string },
{ message?: string }
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
invalidate: ["attendance", "leave-requests", "users"],
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
@@ -232,55 +259,29 @@ export default function Attendance() {
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 punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
if (response.status === 401) return;
const result = await response.json();
setSubmitting(false);
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
alert.error(result.error);
}
} catch {
punchTimeoutRef.current = setTimeout(() => {
alert.success(result?.message || "Uloženo");
}, 300);
} catch (e) {
setSubmitting(false);
alert.error("Chyba připojení");
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleBreak = async () => {
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: "break_start" }),
const result = await punchMutation.mutateAsync({
punch_action: "break_start",
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Přestávka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -288,48 +289,22 @@ export default function Attendance() {
const handleSaveNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await notesMutation.mutateAsync({ notes });
alert.success("Poznámka byla uložena");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true);
try {
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: newProjectId || null }),
const result = await switchProjectMutation.mutateAsync({
project_id: newProjectId ? Number(newProjectId) : null,
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Projekt přepnut");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSwitchingProject(false);
}
@@ -353,56 +328,33 @@ export default function Attendance() {
const handleRequestSubmit = async () => {
setRequestSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(leaveForm),
const result = await leaveRequestMutation.mutateAsync(leaveForm);
setIsLeaveModalOpen(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result?.message || "Žádost odeslána");
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setIsLeaveModalOpen(false);
await queryClient.invalidateQueries({
queryKey: ["attendance"],
});
await queryClient.invalidateQueries({
queryKey: ["leave-requests"],
});
await queryClient.invalidateQueries({
queryKey: ["leave"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
);
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setRequestSubmitting(false);
}
};
if (!statusQuery.data) {
if (statusQuery.isPending) {
return (
<Skeleton
name="attendance"
loading={statusQuery.isPending}
fixture={<AttendanceFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (!statusQuery.data) {
return null;
}
const {
ongoing_shift: ongoingShift,
@@ -671,7 +623,13 @@ export default function Attendance() {
{formatTime(shift.departure_time)}
</td>
<td className="admin-mono">
{formatMinutes(calculateWorkMinutes(shift), true)}
{formatMinutes(
calculateWorkMinutes({
...shift,
notes: shift.notes ?? undefined,
}),
true,
)}
</td>
{projects.length > 0 && (
<td>
@@ -741,7 +699,7 @@ export default function Attendance() {
{vacationDaysRemaining}
</span>
<span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)}
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
@@ -831,8 +789,6 @@ export default function Attendance() {
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 &&
` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
@@ -1069,15 +1025,15 @@ export default function Attendance() {
leaveForm.date_to,
)}
</strong>{" "}
{(() => {
const d = calculateBusinessDays(
{czechPlural(
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
);
if (d === 1) return "pracovní den";
if (d >= 2 && d <= 4) return "pracovní dny";
return "pracovních dnů";
})()}
),
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
</span>
<span className="text-muted">
{calculateBusinessDays(

View File

@@ -10,9 +10,7 @@ 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 { Skeleton } from "boneyard-js/react";
import AttendanceAdminFixture from "../fixtures/AttendanceAdminFixture";
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
interface UserTotalData {
name: string;
@@ -20,20 +18,34 @@ interface UserTotalData {
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;
// mzda-style summary fields (set by computeUserTotals in useAttendanceAdmin)
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
worked_holiday_hours?: 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)";
// fondUsed mirrors the Fond "used" line in the IIFE below:
// real_worked + vacation + worked_holiday + free_holiday
// (delta is fondUsed - fund; 0 means exactly at the fund).
const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed =
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0);
const fundVal = data.fund ?? 0;
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
@@ -89,7 +101,7 @@ export default function AttendanceAdmin() {
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
// Show spinner only on initial load (no data yet), not on filter changes
const isInitialLoad =
loading &&
data.records.length === 0 &&
@@ -97,13 +109,9 @@ export default function AttendanceAdmin() {
if (isInitialLoad) {
return (
<Skeleton
name="attendance-admin"
loading={isInitialLoad}
fixture={<AttendanceAdminFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -212,8 +220,15 @@ export default function AttendanceAdmin() {
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div key={uid} className="admin-card" style={{ display: "flex" }}>
<div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span
@@ -229,9 +244,11 @@ export default function AttendanceAdmin() {
<div
style={{
marginTop: "0.5rem",
marginBottom: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
gap: "0.4rem",
minHeight: "2rem",
}}
>
{ut.vacation_hours > 0 && (
@@ -244,9 +261,9 @@ export default function AttendanceAdmin() {
Nem: {ut.sick_hours}h
</span>
)}
{ut.holiday_hours > 0 && (
{ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h
Sv: {Math.round(ut.svatek)}h
</span>
)}
{ut.unpaid_hours > 0 && (
@@ -255,62 +272,93 @@ export default function AttendanceAdmin() {
</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>
{ut.overtime > 0 && (
<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>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
{ut.fund !== null &&
(() => {
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
// (everything the user actually got paid for this month:
// raw worked time with sub-day remainders, plus
// vacation days, plus hours worked on holidays, plus
// the 8h per unworked holiday from the fund).
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
const fondUsed =
realWorked +
(ut.vacation_hours ?? 0) +
(ut.worked_holiday_hours ?? 0) +
(ut.svatek ?? 0);
const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal;
return (
<div
className="kpi-card-footer"
style={{
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
}}
/>
</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>
)}
>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</span>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}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,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
</div>
</div>
);
@@ -374,7 +422,14 @@ export default function AttendanceAdmin() {
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={editingRecord}
editingRecord={
editingRecord
? {
user_name: editingRecord.user_name ?? "",
shift_date: editingRecord.shift_date,
}
: null
}
/>
<ConfirmModal

View File

@@ -2,11 +2,11 @@ import { useState } from "react";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
@@ -20,9 +20,8 @@ import {
type UserShort,
} from "../lib/queries/attendance";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceBalancesFixture from "../fixtures/AttendanceBalancesFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const getVacationClass = (remaining: number): string => {
@@ -85,7 +84,6 @@ const getProgressBackground = (
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
@@ -114,10 +112,37 @@ export default function AttendanceBalances() {
userName: string;
}>({ show: false, userId: null, userName: "" });
useModalLock(showEditModal);
if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
year: number;
action_type: "edit";
vacation_total: number;
vacation_used: number;
sick_used: number;
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const resetMutation = useApiMutation<
{ user_id: string; year: number; action_type: "reset" },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
onSuccess: (data) => {
setResetConfirm({ show: false, userId: null, userName: "" });
alert.success(data?.message || "Operace dokončena");
},
});
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
@@ -131,62 +156,29 @@ export default function AttendanceBalances() {
const handleEditSubmit = async () => {
if (!editingUser) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
}),
},
);
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
const result = await editMutation.mutateAsync({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
});
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=balances`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
user_id: resetConfirm.userId,
year,
action_type: "reset",
}),
},
);
const result = await response.json();
if (result.success) {
setResetConfirm({ show: false, userId: null, userName: "" });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await resetMutation.mutateAsync({
user_id: resetConfirm.userId,
year,
action_type: "reset",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -263,11 +255,11 @@ export default function AttendanceBalances() {
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-balances"
loading={balancesPending}
fixture={<AttendanceBalancesFixture />}
>
{balancesPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{balancesData &&
Object.keys(balancesData.balances).length === 0 && (
@@ -387,7 +379,7 @@ export default function AttendanceBalances() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
@@ -557,13 +549,9 @@ export default function AttendanceBalances() {
)}
{fundPending && (
<Skeleton
name="attendance-balances-fund"
loading={fundPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* Monthly Project Overview */}
@@ -761,118 +749,82 @@ export default function AttendanceBalances() {
)}
{projectPending && (
<Skeleton
name="attendance-balances-projects"
loading={projectPending}
fixture={<AttendanceBalancesFixture />}
>
<div className="mt-6" />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingUser && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showEditModal && !!editingUser}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit dovolenou"
loading={editMutation.isPending}
>
{editingUser && (
<>
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit dovolenou</h2>
<p className="text-secondary" style={{ marginTop: "0.25rem" }}>
{editingUser.name}
</p>
</div>
{editingUser.name}
</p>
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Nárok na dovolenou (hodiny)">
<input
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="1"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno dovolené (hodiny)">
<input
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
<FormField label="Čerpáno nemocenské (hodiny)">
<input
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
min="0"
max="500"
step="0.5"
className="admin-form-input"
/>
</FormField>
</div>
</>
)}
</AnimatePresence>
</FormModal>
{/* Reset Confirmation */}
<ConfirmModal
@@ -885,6 +837,7 @@ export default function AttendanceBalances() {
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
loading={resetMutation.isPending}
/>
</div>
);

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { userListOptions } from "../lib/queries/users";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -9,9 +9,7 @@ import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AttendanceCreateFixture from "../fixtures/AttendanceCreateFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface CreateForm {
@@ -34,7 +32,6 @@ export default function AttendanceCreate() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { data: usersData, isPending: loading } = useQuery(userListOptions());
const users = (usersData ?? []).map((u) => ({
id: u.id,
@@ -42,6 +39,12 @@ export default function AttendanceCreate() {
}));
const [submitting, setSubmitting] = useState(false);
const createMutation = useApiMutation<CreateForm, { message?: string }>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const [form, setForm] = useState<CreateForm>(() => {
const today = new Date().toISOString().split("T")[0];
return {
@@ -72,23 +75,11 @@ export default function AttendanceCreate() {
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
alert.success(result.message);
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
const result = await createMutation.mutateAsync(form);
alert.success(result?.message || "Uloženo");
navigate(`/attendance/admin?month=${form.shift_date.substring(0, 7)}`);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSubmitting(false);
}
@@ -109,223 +100,224 @@ export default function AttendanceCreate() {
if (!hasPermission("attendance.manage")) return <Forbidden />;
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<Skeleton
name="attendance-create"
loading={loading}
fixture={<AttendanceCreateFixture />}
>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Přidat záznam docházky</h1>
</div>
<div className="admin-page-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<select
value={form.user_id}
onChange={(e) =>
setForm({ ...form, user_id: e.target.value })
}
className="admin-form-select"
required
>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<FormField label="Typ záznamu" required>
<motion.div
className="admin-card"
style={{ maxWidth: "600px" }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<form onSubmit={handleSubmit} className="admin-form">
<div className="admin-form-row">
<FormField label="Zaměstnanec" required>
<select
value={form.leave_type}
value={form.user_id}
onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
setForm({ ...form, user_id: e.target.value })
}
className="admin-form-select"
required
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="holiday">Svátek</option>
<option value="unpaid">Neplacené volno</option>
<option value="">Vyberte zaměstnance</option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
{!isWorkType && (
<FormField label="Počet hodin">
<input
type="number"
value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
<FormField label="Datum směny" required>
<AdminDatePicker
mode="date"
value={form.shift_date}
onChange={(val: string) => handleShiftDateChange(val)}
required
/>
</FormField>
</div>
<div className="admin-form-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
</Skeleton>
<FormField label="Typ záznamu" required>
<select
value={form.leave_type}
onChange={(e) =>
setForm({ ...form, leave_type: e.target.value })
}
className="admin-form-select"
>
<option value="work">Práce</option>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
{!isWorkType && (
<FormField label="Počet hodin">
<input
type="number"
value={form.leave_hours}
onChange={(e) =>
setForm({
...form,
leave_hours: parseFloat(e.target.value),
})
}
min="0.5"
max="24"
step="0.5"
className="admin-form-input"
/>
<small className="admin-form-hint">
Výchozí 8 hodin pro celý den
</small>
</FormField>
)}
{isWorkType && (
<>
<div className="admin-form-row">
<FormField label="Příchod - datum">
<AdminDatePicker
mode="date"
value={form.arrival_date}
onChange={(val: string) =>
setForm({ ...form, arrival_date: val })
}
/>
</FormField>
<FormField label="Příchod - čas">
<AdminDatePicker
mode="time"
value={form.arrival_time}
onChange={(val: string) =>
setForm({ ...form, arrival_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Začátek pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_start_date}
onChange={(val: string) =>
setForm({ ...form, break_start_date: val })
}
/>
</FormField>
<FormField label="Začátek pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_start_time}
onChange={(val: string) =>
setForm({ ...form, break_start_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Konec pauzy - datum">
<AdminDatePicker
mode="date"
value={form.break_end_date}
onChange={(val: string) =>
setForm({ ...form, break_end_date: val })
}
/>
</FormField>
<FormField label="Konec pauzy - čas">
<AdminDatePicker
mode="time"
value={form.break_end_time}
onChange={(val: string) =>
setForm({ ...form, break_end_time: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Odchod - datum">
<AdminDatePicker
mode="date"
value={form.departure_date}
onChange={(val: string) =>
setForm({ ...form, departure_date: val })
}
/>
</FormField>
<FormField label="Odchod - čas">
<AdminDatePicker
mode="time"
value={form.departure_time}
onChange={(val: string) =>
setForm({ ...form, departure_time: val })
}
/>
</FormField>
</div>
</>
)}
<FormField label="Poznámka">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={3}
/>
</FormField>
<div className="admin-form-actions">
<Link
to="/attendance/admin"
className="admin-btn admin-btn-secondary"
>
Zrušit
</Link>
<button
type="submit"
disabled={submitting}
className="admin-btn admin-btn-primary"
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</form>
</div>
</motion.div>
</div>
);
}

View File

@@ -20,10 +20,12 @@ import {
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
calculateFreeHolidayHours,
holidaysInMonth,
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
const MONTH_NAMES = [
"Leden",
@@ -196,7 +198,6 @@ export default function AttendanceHistory() {
let totalMinutes = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const record of records) {
@@ -208,26 +209,51 @@ export default function AttendanceHistory() {
record.leave_hours != null ? 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;
// "holiday" is no longer a user-selectable leave_type — it is
// auto-computed from Czech public holidays by the print/KPI code.
// Old records may still exist; skip so they don't double-count.
}
}
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
const businessDays = getBusinessDaysInMonth(yr, mo);
const fund = businessDays * 8;
// Fund counts Mon-Fri + holidays (holidays are paid via the fond).
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
const fund = (businessDays + holidayCount) * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
// Covered = worked + vacation + sick (NOT unpaid — 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);
// fondUsed = real_worked + vacation + worked_holiday + free_holiday.
// (the all-in number: everything the user got paid for this month).
const realWorked = totalMinutes / 60;
const freeHolidayHours = calculateFreeHolidayHours(
records as unknown as Parameters<typeof calculateFreeHolidayHours>[0],
holidayDates,
);
const workedHolidayHours = records
.filter(
(r) =>
(r.leave_type || "work") === "work" &&
holidayDates.includes(normalizeDateStr(r.shift_date)),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
const fondUsed =
realWorked + vacationHours + workedHolidayHours + freeHolidayHours;
const delta = fondUsed - fund;
const missing = Math.max(0, Math.round(-delta * 100) / 100);
const overtime = Math.max(0, Math.round(delta * 100) / 100);
const remaining = missing;
const monthlyFund = {
fund,
business_days: businessDays,
worked,
holiday_count: holidayCount,
worked: Math.round(fondUsed * 100) / 100,
covered,
remaining,
overtime,
@@ -238,9 +264,12 @@ export default function AttendanceHistory() {
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
fondUsed,
freeHolidayHours,
workedHolidayHours,
delta,
};
}, [records, month]);
@@ -324,7 +353,6 @@ export default function AttendanceHistory() {
}
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
.badge-sick { background: #fee2e2; color: #dc2626; }
.badge-holiday { background: #dcfce7; color: #16a34a; }
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
.badge-overtime { background: #fef3c7; color: #d97706; }
@media print {
@@ -411,11 +439,11 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-history-fund"
loading={isPending}
fixture={<AttendanceHistoryFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{computed.monthlyFund && (
<div
@@ -465,51 +493,90 @@ export default function AttendanceHistory() {
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
className="text-muted"
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
minHeight: "1.5rem",
alignItems: "center",
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`}
)
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
{computed.totalMinutes > 0 && (
<span
className="attendance-leave-badge"
title="Odpracováno (reálně)"
>
Práce: {formatHoursDecimal(computed.totalMinutes)}h
</span>
)}
{computed.vacationHours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {computed.vacationHours}h
</span>
)}
{computed.sickHours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {computed.sickHours}h
</span>
)}
{computed.freeHolidayHours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span>
{computed.monthlyFund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{computed.monthlyFund.overtime}h
</span>
) : (
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
)}
</div>
</div>
</div>
@@ -527,7 +594,7 @@ export default function AttendanceHistory() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
@@ -539,11 +606,11 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-history-table"
loading={isPending}
fixture={<AttendanceHistoryFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{records.length === 0 && (
<div className="admin-empty-state">
@@ -622,7 +689,7 @@ export default function AttendanceHistory() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
@@ -670,9 +737,7 @@ export default function AttendanceHistory() {
</span>
</div>
{(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && (
<>
@@ -688,13 +753,6 @@ export default function AttendanceHistory() {
</span>{" "}
</>
)}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div>
)}

View File

@@ -14,8 +14,6 @@ import {
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
import { Skeleton } from "boneyard-js/react";
import AttendanceLocationFixture from "../fixtures/AttendanceLocationFixture";
export default function AttendanceLocation() {
const alert = useAlert();
@@ -168,71 +166,111 @@ export default function AttendanceLocation() {
: record.shift_date;
const month = shiftDateStr.substring(0, 7);
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<Skeleton
name="attendance-location"
loading={isPending}
fixture={<AttendanceLocationFixture />}
>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Poloha záznamu</h1>
</div>
<div className="admin-page-actions">
<Link
to={`/attendance/admin?month=${month}`}
className="admin-btn admin-btn-secondary"
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
>
&larr; Zpět na správu
</Link>
</div>
</motion.div>
<h3 className="attendance-location-title">Příchod</h3>
<div className="attendance-location-time">
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</div>
{hasArrivalLocation ? (
<>
<div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-header">
<h2 className="admin-card-title">
{record.user_name} {formatDate(record.shift_date)}
</h2>
</div>
<div className="admin-card-body">
{hasAnyLocation && (
<div ref={mapRef} className="attendance-location-map" />
)}
<div className="attendance-location-grid">
{/* Arrival */}
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Příchod</h3>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
{hasArrivalLocation ? (
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
{record.arrival_address || <em>Adresa nezjištěna</em>}
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
@@ -246,48 +284,10 @@ export default function AttendanceLocation() {
</div>
)}
</div>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<div
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
>
<h3 className="attendance-location-title">Odchod</h3>
<div className="attendance-location-time">
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</div>
{hasDepartureLocation ? (
<>
<div className="attendance-location-address">
{record.departure_address || <em>Adresa nezjištěna</em>}
</div>
<div className="attendance-location-coords">
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</div>
<a
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
>
Otevřít v Google Maps
</a>
</>
) : (
<div className="attendance-location-address">
<em>Poloha nebyla zaznamenána</em>
</div>
)}
</div>
)}
</div>
)}
</div>
</motion.div>
</div>
</Skeleton>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,16 +1,16 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
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 FormModal from "../components/FormModal";
import AdminDatePicker from "../components/AdminDatePicker";
import { czechPlural } from "../utils/formatters";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import AuditLogFixture from "../fixtures/AuditLogFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -91,7 +91,6 @@ interface Filters {
export default function AuditLog() {
const { hasPermission } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const [filters, setFilters] = useState<Filters>({
search: "",
action: "",
@@ -103,7 +102,6 @@ export default function AuditLog() {
const [perPage, setPerPage] = useState(50);
const [showCleanup, setShowCleanup] = useState(false);
const [cleanupDays, setCleanupDays] = useState(90);
const [cleaning, setCleaning] = useState(false);
const { data: logsData, isPending } = useQuery({
queryKey: [
@@ -148,13 +146,26 @@ export default function AuditLog() {
},
});
const logs = logsData?.data ?? [];
const logs: AuditLogEntry[] = logsData?.data ?? [];
const pagination = logsData?.pagination ?? null;
if (!hasPermission("settings.audit")) {
return <Forbidden />;
}
const cleanupMutation = useApiMutation<
{ days: number; confirm?: string },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/audit-log/cleanup`,
method: () => "POST",
invalidate: ["audit-log"],
onSuccess: (data) => {
alert.success(data?.message || "Hotovo");
setShowCleanup(false);
},
});
const handleFilterChange = (key: keyof Filters, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
setPage(1);
@@ -170,25 +181,14 @@ export default function AuditLog() {
};
const handleCleanup = async () => {
setCleaning(true);
try {
const response = await apiFetch(`${API_BASE}/audit-log/cleanup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days: cleanupDays }),
// Backend requires this literal confirmation token when wiping everything.
await cleanupMutation.mutateAsync({
days: cleanupDays,
...(cleanupDays === 0 ? { confirm: "DELETE_ALL_AUDIT" } : {}),
});
const data = await response.json();
if (data.success) {
alert.success(data.message);
setShowCleanup(false);
queryClient.invalidateQueries({ queryKey: ["audit-log"] });
} else {
alert.error(data.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setCleaning(false);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -199,13 +199,9 @@ export default function AuditLog() {
if (isPending && logs.length === 0) {
return (
<Skeleton
name="audit-log"
loading={isPending && logs.length === 0}
fixture={<AuditLogFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -245,78 +241,36 @@ export default function AuditLog() {
</button>
</motion.div>
{showCleanup && (
<div className="admin-modal-overlay" style={{ opacity: 1 }}>
<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 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showCleanup}
onClose={() => !cleanupMutation.isPending && setShowCleanup(false)}
onSubmit={handleCleanup}
title="Vyčistit audit log"
submitLabel="Smazat"
loading={cleanupMutation.isPending}
>
<p className="admin-confirm-message">Smazat záznamy starší než:</p>
<div style={{ margin: "0.75rem 0", maxWidth: "200px" }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<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"
>
<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" }}>
<select
className="admin-form-select"
value={cleanupDays}
onChange={(e) => setCleanupDays(parseInt(e.target.value))}
>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowCleanup(false)}
className="admin-btn admin-btn-secondary"
disabled={cleaning}
>
Zrušit
</button>
<button
type="button"
onClick={handleCleanup}
className="admin-btn admin-btn-primary"
disabled={cleaning}
>
{cleaning ? "Mažu..." : "Smazat"}
</button>
</div>
</motion.div>
<option value={30}>30 dní</option>
<option value={60}>60 dní</option>
<option value={90}>90 dní</option>
<option value={180}>180 dní</option>
<option value={365}>1 rok</option>
<option value={0}>Vše</option>
</select>
</div>
)}
<p
className="admin-confirm-message"
style={{ fontSize: "12px", opacity: 0.6 }}
>
Tato akce je nevratná.
</p>
</FormModal>
<motion.div
className="admin-card mb-4"
@@ -391,90 +345,11 @@ export default function AuditLog() {
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<Skeleton
name="audit-log-rows"
loading={isPending}
fixture={
<table className="admin-table">
<thead>
<tr>
<th>Čas</th>
<th>Uživatel</th>
<th>Akce</th>
<th>Typ entity</th>
<th>Popis</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{Array.from({ length: 10 }, (_, i) => (
<tr key={i}>
<td>
<div
style={{
width: 110,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: 70,
height: 22,
background: "var(--bg-tertiary)",
borderRadius: 10,
}}
/>
</td>
<td>
<div
style={{
width: 80,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: "100%",
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
<td>
<div
style={{
width: 90,
height: 14,
background: "var(--bg-tertiary)",
borderRadius: 4,
}}
/>
</td>
</tr>
))}
</tbody>
</table>
}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<table className="admin-table">
<thead>
<tr>
@@ -536,7 +411,7 @@ export default function AuditLog() {
))}
</tbody>
</table>
</Skeleton>
)}
</div>
<Pagination

View File

@@ -13,8 +13,7 @@ import { bankAccountsOptions, type BankAccount } from "../lib/queries/common";
import { motion } from "framer-motion";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import CompanySettingsFixture from "../fixtures/CompanySettingsFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const DEFAULT_FIELD_ORDER = [
@@ -54,6 +53,12 @@ interface CompanyForm {
quotation_prefix: string;
order_type_code: string;
invoice_type_code: string;
warehouse_receipt_prefix: string;
warehouse_receipt_number_pattern: string;
warehouse_issue_prefix: string;
warehouse_issue_number_pattern: string;
warehouse_inventory_prefix: string;
warehouse_inventory_number_pattern: string;
default_currency: string;
default_vat_rate: number;
available_currencies: string[];
@@ -99,6 +104,12 @@ export default function CompanySettings({
quotation_prefix: "",
order_type_code: "",
invoice_type_code: "",
warehouse_receipt_prefix: "",
warehouse_receipt_number_pattern: "",
warehouse_issue_prefix: "",
warehouse_issue_number_pattern: "",
warehouse_inventory_prefix: "",
warehouse_inventory_number_pattern: "",
default_currency: "CZK",
default_vat_rate: 21,
available_currencies: ["CZK", "EUR", "USD", "GBP"],
@@ -124,6 +135,40 @@ export default function CompanySettings({
is_default: false,
});
const saveSettingsMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/company-settings`,
method: () => "PUT",
invalidate: [
"settings",
"company-settings",
"attendance",
"offers",
"orders",
"invoices",
],
});
const bankMutation = useApiMutation<
{ id?: number; bank: BankForm },
{ message?: string }
>({
url: (input) =>
input.id != null
? `${API_BASE}/bank-accounts/${input.id}`
: `${API_BASE}/bank-accounts`,
method: (input) => (input.id != null ? "PUT" : "POST"),
invalidate: ["settings", "bank-accounts", "invoices"],
});
const bankDeleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/bank-accounts/${id}`,
method: () => "DELETE",
invalidate: ["settings", "bank-accounts", "invoices"],
});
const getFullFieldOrder = useCallback((): string[] => {
const allBuiltIn = [...DEFAULT_FIELD_ORDER];
const order = [...fieldOrder].filter((k) => k !== "company_name");
@@ -224,6 +269,15 @@ export default function CompanySettings({
quotation_prefix: settingsData.quotation_prefix || "",
order_type_code: settingsData.order_type_code || "",
invoice_type_code: settingsData.invoice_type_code || "",
warehouse_receipt_prefix: settingsData.warehouse_receipt_prefix || "",
warehouse_receipt_number_pattern:
settingsData.warehouse_receipt_number_pattern || "",
warehouse_issue_prefix: settingsData.warehouse_issue_prefix || "",
warehouse_issue_number_pattern:
settingsData.warehouse_issue_number_pattern || "",
warehouse_inventory_prefix: settingsData.warehouse_inventory_prefix || "",
warehouse_inventory_number_pattern:
settingsData.warehouse_inventory_number_pattern || "",
default_currency: settingsData.default_currency || "CZK",
default_vat_rate: settingsData.default_vat_rate ?? 21,
available_currencies:
@@ -283,26 +337,14 @@ export default function CompanySettings({
}
setBankSaving(true);
try {
const isEdit = editingBank !== null;
const url = isEdit
? `${API_BASE}/bank-accounts/${editingBank}`
: `${API_BASE}/bank-accounts`;
const response = await apiFetch(url, {
method: isEdit ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(bankForm),
const result = await bankMutation.mutateAsync({
id: editingBank ?? undefined,
bank: bankForm,
});
const result = await response.json();
if (result.success) {
alert.success(result.message);
resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při ukládání");
}
} catch {
alert.error("Chyba připojení");
alert.success(result?.message || "Uloženo");
resetBankForm();
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setBankSaving(false);
}
@@ -315,23 +357,11 @@ export default function CompanySettings({
const confirmBankDelete = async () => {
if (bankDeleteConfirm.id == null) return;
try {
const response = await apiFetch(
`${API_BASE}/bank-accounts/${bankDeleteConfirm.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message);
if (editingBank === bankDeleteConfirm.id) resetBankForm();
queryClient.invalidateQueries({ queryKey: ["bank-accounts"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Chyba při mazání");
}
} catch {
alert.error("Chyba připojení");
const result = await bankDeleteMutation.mutateAsync(bankDeleteConfirm.id);
alert.success(result?.message || "Smazáno");
if (editingBank === bankDeleteConfirm.id) resetBankForm();
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setBankDeleteConfirm({ isOpen: false, id: null });
}
@@ -368,23 +398,12 @@ export default function CompanySettings({
),
supplier_field_order: getFullFieldOrder(),
};
const response = await apiFetch(`${API_BASE}/company-settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nastavení bylo uloženo");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
const result = await saveSettingsMutation.mutateAsync(payload);
alert.success(result?.message || "Nastavení bylo uloženo");
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Nepodařilo se uložit nastavení",
);
} finally {
setSaving(false);
}
@@ -438,13 +457,9 @@ export default function CompanySettings({
if (settingsLoading) {
return (
<Skeleton
name="company-settings"
loading={settingsLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -755,13 +770,9 @@ export default function CompanySettings({
</div>
<div className="admin-card-body">
{bankLoading ? (
<Skeleton
name="company-settings-bank"
loading={bankLoading}
fixture={<CompanySettingsFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{bankAccountsList.length > 0 && (
@@ -1106,7 +1117,7 @@ export default function CompanySettings({
>
<strong>Dostupné zástupné znaky:</strong>{" "}
<code>{"{YYYY}"}</code> rok, <code>{"{YY}"}</code> rok (2
číslice), <code>{"{PREFIX}"}</code> prefix nabídky,{" "}
číslice), <code>{"{PREFIX}"}</code> prefix (nabídky / sklad),{" "}
<code>{"{CODE}"}</code> typový kód, <code>{"{NNN}"}</code>{" "}
pořadí (3 číslice), <code>{"{NNNN}"}</code> pořadí (4
číslice), <code>{"{NNNNN}"}</code> pořadí (5 číslic)
@@ -1136,6 +1147,30 @@ export default function CompanySettings({
codeKey: "invoice_type_code" as const,
codeLabel: "Typový kód",
},
{
label: "Sklad — Příjmy",
patternKey: "warehouse_receipt_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_receipt_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Sklad — Výdeje",
patternKey: "warehouse_issue_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_issue_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
{
label: "Sklad — Inventarizace",
patternKey: "warehouse_inventory_number_pattern" as const,
defaultPattern: "{PREFIX}-{YYYY}-{NNN}",
prefixKey: "warehouse_inventory_prefix" as const,
prefixLabel: "Prefix",
codeKey: null,
},
].map((cfg, idx) => {
const pattern = form[cfg.patternKey] || cfg.defaultPattern;
const yyyy = String(new Date().getFullYear());

View File

@@ -9,14 +9,13 @@ import apiFetch from "../utils/api";
import { dashboardOptions } from "../lib/queries/dashboard";
import { require2FAOptions } from "../lib/queries/settings";
import { getCzechDate } from "../utils/dashboardHelpers";
import { useApiMutation } from "../lib/queries/mutations";
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 { Skeleton } from "boneyard-js/react";
import DashboardFixture from "../fixtures/DashboardFixture";
const API_BASE = "/api/admin";
@@ -84,6 +83,15 @@ export default function Dashboard() {
useQuery(require2FAOptions());
const totpEnabled = totpData?.require_2fa ?? !!user?.totpEnabled;
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
invalidate: ["attendance", "dashboard"],
});
// 2FA state - sdileny mezi profilem a bannerem
const [show2FASetup, setShow2FASetup] = useState(false);
const [show2FADisable, setShow2FADisable] = useState(false);
@@ -104,21 +112,13 @@ export default function Dashboard() {
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 punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
const result = await response.json();
if (result.success) {
alert.success(result.data?.message || "Docházka zaznamenána");
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
} else {
alert.error(result.error || "Chyba při záznamu docházky");
}
} catch {
alert.error("Chyba pripojeni");
alert.success(result?.message || "Docházka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba pripojeni");
} finally {
setPunching(false);
}
@@ -137,7 +137,7 @@ export default function Dashboard() {
() => submitPunch({}),
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 },
);
}, [dashData, alert, queryClient]);
}, [dashData, alert, punchMutation]);
// 2FA handlery
const handleStart2FASetup = async () => {
@@ -309,15 +309,11 @@ export default function Dashboard() {
</motion.div>
)}
{/* Skeleton loading */}
{/* Loading spinner */}
{dashLoading && (
<Skeleton
name="dashboard"
loading={dashLoading}
fixture={<DashboardFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
)}
{/* KPI cards — only show if user has any admin-level permissions */}
@@ -325,12 +321,14 @@ export default function Dashboard() {
(hasPermission("offers.view") ||
hasPermission("invoices.view") ||
hasPermission("projects.view") ||
hasPermission("orders.view")) && <DashKpiCards dashData={dashData} />}
hasPermission("orders.view")) && (
<DashKpiCards dashData={dashData ?? null} />
)}
{/* Quick actions */}
{!dashLoading && (
<DashQuickActions
dashData={dashData}
dashData={dashData ?? null}
punching={punching}
onPunch={handleQuickPunch}
/>

View File

@@ -5,8 +5,9 @@ import {
useParams,
Link,
} from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import DOMPurify from "dompurify";
import { useApiMutation } from "../lib/queries/mutations";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -349,6 +350,7 @@ export default function InvoiceDetail() {
{
_key: "inv-1",
description: "",
item_description: "",
quantity: 1,
unit: "ks",
unit_price: 0,
@@ -400,7 +402,6 @@ export default function InvoiceDetail() {
}, []);
// ─── TanStack Query ───
const queryClient = useQueryClient();
const customersQuery = useQuery(offerCustomersOptions());
const customers = customersQuery.data ?? [];
@@ -762,6 +763,34 @@ export default function InvoiceDetail() {
}, [items, form.apply_vat]);
// ─── Create/Edit mode: submit ───
const saveMutation = useApiMutation<
Record<string, unknown>,
{ invoice_id: number }
>({
url: () => (isEdit ? `${API_BASE}/invoices/${id}` : `${API_BASE}/invoices`),
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["invoices", "orders"],
onSuccess: (data) => {
const invoiceId = isEdit ? Number(id) : data.invoice_id;
// PDF binary generation — KEEP as raw apiFetch
void apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
},
});
const statusMutation = useApiMutation<{ status: string }, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "PUT",
invalidate: ["invoices", "orders"],
});
const invoiceDeleteMutation = useApiMutation<void, unknown>({
url: () => `${API_BASE}/invoices/${id}`,
method: () => "DELETE",
invalidate: ["invoices", "orders"],
});
const handleCreateSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
@@ -779,7 +808,7 @@ export default function InvoiceDetail() {
setSaving(true);
try {
const payload: any = {
const payload: Record<string, unknown> = {
...form,
due_date: computedDueDate || form.due_date,
items: items
@@ -791,48 +820,21 @@ export default function InvoiceDetail() {
};
if (isEdit) payload.invoice_number = invoiceNumber;
const url = isEdit
? `${API_BASE}/invoices/${id}`
: `${API_BASE}/invoices`;
const method = isEdit ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
if (!isEdit) clearDraft();
const invoiceId = isEdit ? id : result.data.invoice_id;
await apiFetch(
`${API_BASE}/invoices-pdf/${invoiceId}?lang=${form.language}&save=1`,
).catch(() => {});
alert.success(
result.message ||
(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena"),
);
initialSnapshotRef.current = JSON.stringify({ form, items });
if (isEdit) {
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate(`/invoices/${result.data.invoice_id}`);
}
} else {
alert.error(
result.error ||
(isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu"),
);
const data = await saveMutation.mutateAsync(payload);
if (!isEdit) clearDraft();
alert.success(isEdit ? "Faktura byla uložena" : "Faktura byla vytvořena");
initialSnapshotRef.current = JSON.stringify({ form, items });
if (!isEdit) {
navigate(`/invoices/${data.invoice_id}`);
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(
e instanceof Error
? e.message
: isEdit
? "Nepodařilo se uložit fakturu"
: "Nepodařilo se vytvořit fakturu",
);
} finally {
setSaving(false);
}
@@ -841,25 +843,14 @@ export default function InvoiceDetail() {
// ─── Edit mode: status change ───
const handleStatusChange = async () => {
if (!statusConfirm.status) return;
setStatusChanging(statusConfirm.status);
const newStatus = statusConfirm.status;
setStatusChanging(newStatus);
setStatusConfirm({ show: false, status: null });
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: statusConfirm.status }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Stav byl změněn");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
await statusMutation.mutateAsync({ status: newStatus });
alert.success("Stav byl změněn");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setStatusChanging(null);
}
@@ -893,21 +884,11 @@ export default function InvoiceDetail() {
const handleDelete = async () => {
setDeleting(true);
try {
const response = await apiFetch(`${API_BASE}/invoices/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Faktura byla smazána");
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
navigate("/invoices");
} else {
alert.error(result.error || "Nepodařilo se smazat fakturu");
}
} catch {
alert.error("Chyba připojení");
await invoiceDeleteMutation.mutateAsync(undefined);
alert.success("Faktura byla smazána");
navigate("/invoices");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
setDeleteConfirm(false);
@@ -1101,7 +1082,7 @@ export default function InvoiceDetail() {
<div className="admin-card-header flex-between">
<h3 className="admin-card-title">Položky</h3>
</div>
{invoice.items?.length > 0 ? (
{invoice.items && invoice.items.length > 0 ? (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>

View File

@@ -1,10 +1,9 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import { useAlert } from "../context/AlertContext";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import { czechPlural } from "../utils/formatters";
import {
leavePendingOptions,
@@ -12,10 +11,9 @@ import {
} from "../lib/queries/leave";
import ConfirmModal from "../components/ConfirmModal";
import Forbidden from "../components/Forbidden";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import LeaveApprovalFixture from "../fixtures/LeaveApprovalFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -108,7 +106,6 @@ function mapLeaveRequest(raw: RawLeaveRequest): LeaveRequest {
export default function LeaveApproval() {
const { hasPermission } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"pending" | "processed">(
"pending",
);
@@ -135,39 +132,45 @@ export default function LeaveApproval() {
request: LeaveRequest | null;
}>({ open: false, request: null });
const [rejectNote, setRejectNote] = useState("");
const [processing, setProcessing] = useState(false);
useModalLock(rejectModal.open);
if (!hasPermission("attendance.approve")) return <Forbidden />;
const handleApprove = async () => {
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 approveMutation = useApiMutation<
{ id: number; status: "approved" },
unknown
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
onSuccess: () => {
setApproveModal({ open: false, request: null });
alert.success("Žádost byla schválena");
},
});
const result = await response.json();
if (result.success) {
setApproveModal({ open: false, request: null });
await queryClient.invalidateQueries({ queryKey: ["leave"] });
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Žádost byla schválena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setProcessing(false);
const rejectMutation = useApiMutation<
{ id: number; status: "rejected"; reviewer_note: string },
unknown
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "PUT",
invalidate: ["leave-requests", "leave", "attendance", "users"],
onSuccess: () => {
setRejectModal({ open: false, request: null });
setRejectNote("");
alert.success("Žádost byla zamítnuta");
},
});
const handleApprove = async () => {
if (!approveModal.request) return;
try {
await approveMutation.mutateAsync({
id: approveModal.request.id,
status: "approved",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -176,412 +179,351 @@ export default function LeaveApproval() {
alert.error("Důvod zamítnutí je povinný");
return;
}
setProcessing(true);
if (!rejectModal.request) return;
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 result = await response.json();
if (result.success) {
setRejectModal({ open: false, request: null });
setRejectNote("");
await queryClient.invalidateQueries({ queryKey: ["leave"] });
await queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
await queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success("Žádost byla zamítnuta");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setProcessing(false);
await rejectMutation.mutateAsync({
id: rejectModal.request.id,
status: "rejected",
reviewer_note: rejectNote,
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (loading) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<Skeleton
name="leave-approval"
loading={loading}
fixture={<LeaveApprovalFixture />}
>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</div>
</motion.div>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Schvalování nepřítomnosti</h1>
<p className="admin-page-subtitle">
{pendingCount > 0
? `${pendingCount} ${czechPlural(pendingCount, "žádost čeká", "žádosti čekají", "žádostí čeká")} na schválení`
: "Žádné čekající žádosti"}
</p>
</div>
</motion.div>
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-tabs mb-6">
<button
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")}
>
Ke schválení
{pendingCount > 0 && (
<span
className="admin-badge badge-pending"
style={{
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingCount}
</span>
)}
</button>
<button
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
</div>
</motion.div>
{/* Pending Tab */}
{activeTab === "pending" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
{/* Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-tabs mb-6">
<button
className={`admin-tab ${activeTab === "pending" ? "active" : ""}`}
onClick={() => setActiveTab("pending")}
>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted mb-4"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
</div>
</div>
</div>
) : (
<div
Ke schválení
{pendingCount > 0 && (
<span
className="admin-badge badge-pending"
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
marginLeft: "0.5rem",
fontSize: "0.7rem",
padding: "0.15rem 0.5rem",
}}
>
{pendingRequests.map((req) => (
<div key={req.id} className="admin-card">
{pendingCount}
</span>
)}
</button>
<button
className={`admin-tab ${activeTab === "processed" ? "active" : ""}`}
onClick={() => setActiveTab("processed")}
>
Vyřízené
</button>
</div>
</motion.div>
{/* Pending Tab */}
{activeTab === "pending" && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
{pendingRequests.length === 0 ? (
<div className="admin-card">
<div className="admin-card-body">
<div className="admin-empty-state">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
className="text-muted mb-4"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<p>Žádné čekající žádosti</p>
</div>
</div>
</div>
) : (
<div
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
className="admin-card-body"
style={{ padding: "1.25rem" }}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
flexWrap: "wrap",
gap: "1rem",
}}
>
<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] || ""}`}
>
{leaveTypeLabels[req.leave_type] ||
req.leave_type}
</span>
</div>
<div className="flex-1">
<div className="flex-row-gap mb-2">
<strong style={{ fontSize: "1rem" }}>
{req.employee_name}
</strong>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</div>
<div
className="text-secondary"
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
fontSize: "0.875rem",
}}
>
<span>
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</span>
<span>
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")} (
{req.total_hours}h)
</span>
<span className="text-muted">
Podáno: {formatDatetime(req.created_at)}
</span>
</div>
{req.notes && (
<div
className="text-secondary"
style={{
display: "flex",
gap: "1.5rem",
flexWrap: "wrap",
marginTop: "0.5rem",
fontSize: "0.875rem",
fontStyle: "italic",
}}
>
<span>
<strong>{formatDate(req.date_from)}</strong> {" "}
<strong>{formatDate(req.date_to)}</strong>
</span>
<span>
{req.total_days}{" "}
{czechPlural(req.total_days, "den", "dny", "dnů")}{" "}
({req.total_hours}h)
</span>
<span className="text-muted">
Podáno: {formatDatetime(req.created_at)}
</span>
{req.notes}
</div>
{req.notes && (
<div
className="text-secondary"
style={{
marginTop: "0.5rem",
fontSize: "0.875rem",
fontStyle: "italic",
}}
>
{req.notes}
</div>
)}
</div>
<div
)}
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
}}
>
<button
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
display: "flex",
gap: "0.5rem",
flexShrink: 0,
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
>
<button
onClick={() =>
setApproveModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--success-light)",
color: "var(--success)",
border: "none",
}}
>
Schválit
</button>
<button
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
</div>
Schválit
</button>
<button
onClick={() =>
setRejectModal({ open: true, request: req })
}
className="admin-btn admin-btn-sm"
style={{
background: "var(--danger-light)",
color: "var(--danger)",
border: "none",
}}
>
Zamítnout
</button>
</div>
</div>
</div>
))}
</div>
))}
</div>
)}
</motion.div>
)}
{/* Processed Tab */}
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td>
<strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : (
"—"
)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
)}
</div>
</motion.div>
)}
{/* Processed Tab */}
{activeTab === "processed" && (
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
{processedRequests.length === 0 ? (
<div className="admin-empty-state">
<p>Zatím žádné vyřízené žádosti</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Zaměstnanec</th>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Stav</th>
<th>Schválil</th>
<th>Poznámka</th>
<th>Vyřízeno</th>
</tr>
</thead>
<tbody>
{processedRequests.map((req) => (
<tr key={req.id}>
<td>
<strong>{req.employee_name}</strong>
</td>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] ||
req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td>{req.reviewer_name || "—"}</td>
<td style={{ maxWidth: "200px" }}>
{req.reviewer_note ? (
<span title={req.reviewer_note}>
{req.reviewer_note.length > 40
? `${req.reviewer_note.substring(0, 40)}...`
: req.reviewer_note}
</span>
) : (
"—"
)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.reviewed_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
)}
{/* Approve Confirmation */}
<ConfirmModal
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={
approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
: ""
}
confirmText="Schválit"
type="info"
loading={approveMutation.isPending}
/>
{/* Approve Confirmation */}
<ConfirmModal
isOpen={approveModal.open}
onClose={() => setApproveModal({ open: false, request: null })}
onConfirm={handleApprove}
title="Schválit žádost"
message={
approveModal.request
? `Schválit ${approveModal.request.total_days} ${czechPlural(approveModal.request.total_days, "den", "dny", "dnů")} ${leaveTypeLabels[approveModal.request.leave_type]?.toLowerCase() || ""} pro ${approveModal.request.employee_name}?`
: ""
}
confirmText="Schválit"
type="info"
loading={processing}
/>
{/* Reject Modal */}
<AnimatePresence>
{rejectModal.open && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
{/* Reject Modal */}
<FormModal
isOpen={rejectModal.open && !!rejectModal.request}
onClose={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
onSubmit={handleReject}
title="Zamítnout žádost"
submitLabel="Zamítnout"
loading={rejectMutation.isPending}
>
{rejectModal.request && (
<>
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Zamítnout žádost</h2>
</div>
<div className="admin-modal-body">
{rejectModal.request && (
<p className="text-secondary mb-4">
{rejectModal.request.employee_name} {" "}
{leaveTypeLabels[rejectModal.request.leave_type]},{" "}
{formatDate(rejectModal.request.date_from)} {" "}
{formatDate(rejectModal.request.date_to)} (
{rejectModal.request.total_days} dnů)
</p>
)}
<FormField label="Důvod zamítnutí" required>
<textarea
value={rejectNote}
onChange={(e) => setRejectNote(e.target.value)}
placeholder="Uveďte důvod zamítnutí..."
className="admin-form-textarea"
rows={3}
autoFocus
/>
</FormField>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => {
setRejectModal({ open: false, request: null });
setRejectNote("");
}}
className="admin-btn admin-btn-secondary"
disabled={processing}
>
Zrušit
</button>
<button
type="button"
onClick={handleReject}
disabled={processing || !rejectNote.trim()}
className="admin-btn admin-btn-primary"
>
{processing ? "Zpracování..." : "Zamítnout"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
</Skeleton>
</FormField>
</>
)}
</FormModal>
</div>
);
}

View File

@@ -1,15 +1,13 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { motion } from "framer-motion";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import LeaveRequestsFixture from "../fixtures/LeaveRequestsFixture";
import { formatDate, formatDatetime } from "../utils/attendanceHelpers";
import apiFetch from "../utils/api";
import ConfirmModal from "../components/ConfirmModal";
import { leaveRequestsOptions, type LeaveRequest } from "../lib/queries/leave";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -42,7 +40,6 @@ const leaveTypeClasses: Record<string, string> = {
export default function LeaveRequests() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: requests = [], isPending } = useQuery(
leaveRequestsOptions(true),
);
@@ -50,35 +47,28 @@ export default function LeaveRequests() {
open: boolean;
id: number | null;
}>({ open: false, id: null });
const [cancelling, setCancelling] = useState(false);
const cancelMutation = useApiMutation<
{ id: number },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/leave-requests/${id}`,
method: () => "DELETE",
invalidate: ["leave-requests", "leave", "attendance"],
onSuccess: (data) => {
setCancelModal({ open: false, id: null });
alert.success(data?.message || "Žádost byla zrušena");
},
});
if (!hasPermission("attendance.record")) return <Forbidden />;
const handleCancel = async () => {
setCancelling(true);
if (!cancelModal.id) return;
try {
const response = await apiFetch(
`${API_BASE}/leave-requests/${cancelModal.id}`,
{
method: "DELETE",
},
);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setCancelModal({ open: false, id: null });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setCancelling(false);
await cancelMutation.mutateAsync({ id: cancelModal.id });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -109,109 +99,109 @@ export default function LeaveRequests() {
return <span className="text-muted"></span>;
}
return (
<Skeleton
name="leave-requests"
loading={isPending}
fixture={<LeaveRequestsFixture />}
>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">
Přehled žádostí o nepřítomnost
</p>
</div>
</motion.div>
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Moje žádosti</h1>
<p className="admin-page-subtitle">Přehled žádostí o nepřítomnost</p>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{requests.length === 0 ? (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th></th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">
{formatDate(req.date_to)}
</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
<p>Zatím nemáte žádné žádosti</p>
<p style={{ fontSize: "0.875rem", color: "var(--text-muted)" }}>
Novou žádost můžete podat na stránce Docházka
</p>
</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Typ</th>
<th>Od</th>
<th>Do</th>
<th>Dny</th>
<th>Hodiny</th>
<th>Stav</th>
<th>Poznámka</th>
<th>Podáno</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{requests.map((req) => (
<tr key={req.id}>
<td>
<span
className={`attendance-leave-badge ${leaveTypeClasses[req.leave_type] || ""}`}
>
{formatDatetime(req.created_at)}
</td>
<td>
{leaveTypeLabels[req.leave_type] || req.leave_type}
</span>
</td>
<td className="admin-mono">
{formatDate(req.date_from)}
</td>
<td className="admin-mono">{formatDate(req.date_to)}</td>
<td className="admin-mono">{req.total_days}</td>
<td className="admin-mono">{req.total_hours}h</td>
<td>
<span
className={`admin-badge ${statusClasses[req.status] || ""}`}
>
{statusLabels[req.status] || req.status}
</span>
</td>
<td style={{ maxWidth: "200px" }}>
{renderNoteCell(req)}
</td>
<td
className="admin-mono"
style={{ whiteSpace: "nowrap" }}
>
{formatDatetime(req.created_at)}
</td>
<td>
<div className="admin-table-actions">
{req.status === "pending" && (
<button
onClick={() =>
@@ -222,27 +212,27 @@ export default function LeaveRequests() {
Zrušit
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
<ConfirmModal
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelling}
/>
</div>
</Skeleton>
<ConfirmModal
isOpen={cancelModal.open}
onClose={() => setCancelModal({ open: false, id: null })}
onConfirm={handleCancel}
title="Zrušit žádost"
message="Opravdu chcete zrušit tuto žádost o nepřítomnost?"
confirmText="Zrušit žádost"
type="warning"
loading={cancelMutation.isPending}
/>
</div>
);
}

View File

@@ -49,11 +49,11 @@ import {
} from "@dnd-kit/modifiers";
import { CSS } from "@dnd-kit/utilities";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import RichEditor from "../components/RichEditor";
import useModalLock from "../hooks/useModalLock";
import useDebounce from "../hooks/useDebounce";
import apiFetch from "../utils/api";
import { formatCurrency } from "../utils/formatters";
@@ -61,6 +61,7 @@ import {
companySettingsOptions,
type CompanySettingsData,
} from "../lib/queries/settings";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
@@ -403,9 +404,13 @@ export default function OfferDetail() {
const heartbeatRef = useRef<ReturnType<typeof setInterval> | null>(null);
const unlockAbortRef = useRef<AbortController | null>(null);
const initialSnapshotRef = useRef<string | null>(null);
// Set to true right after a successful delete so the detail-page error
// effect doesn't fire "Nepodařilo se načíst nabídku" on top of the
// success toast (the 404 from a refetched-already-deleted row is
// expected here, not a real error).
const deletingRef = useRef(false);
useModalLock(showOrderModal);
// Note: FormModal applies useModalLock internally.
useEffect(() => {
return () => {
blobTimeoutsRef.current.forEach(clearTimeout);
@@ -476,9 +481,12 @@ export default function OfferDetail() {
formInitializedRef.current = true;
}, [offerQuery.data, companySettings, hasPermission, id, emptyItem]);
// Redirect on offer fetch error (edit mode)
// Redirect on offer fetch error (edit mode).
// Suppress when deletingRef is set: the in-flight refetch of a row we
// just deleted will 404, and we don't want a "load failed" toast on
// top of the success toast.
useEffect(() => {
if (isEdit && offerQuery.isError) {
if (isEdit && offerQuery.isError && !deletingRef.current) {
alert.error("Nepodařilo se načíst nabídku");
navigate("/offers");
}
@@ -670,7 +678,7 @@ export default function OfferDetail() {
}
}
if (!isEdit && result.data?.id) {
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -682,7 +690,7 @@ export default function OfferDetail() {
items,
sections,
});
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -713,7 +721,7 @@ export default function OfferDetail() {
formData.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: formData };
} else {
// Without attachment: send as JSON (avoids multipart content-type issues)
// Without attachment: send as JSON
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -728,7 +736,7 @@ export default function OfferDetail() {
if (result.success) {
setShowOrderModal(false);
alert.success(result.message || "Objednávka byla vytvořena");
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -754,7 +762,7 @@ export default function OfferDetail() {
setInvalidateConfirm(false);
setOfferStatus("invalidated");
alert.success(result.message || "Nabídka byla zneplatněna");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -776,8 +784,12 @@ export default function OfferDetail() {
});
const result = await response.json();
if (result.success) {
// Mark the row as gone before the list invalidation refetches the
// detail key (which would 404 and trigger the error-toast effect).
deletingRef.current = true;
queryClient.removeQueries({ queryKey: ["offers", id] });
alert.success(result.message || "Nabídka byla smazána");
await queryClient.invalidateQueries({ queryKey: ["offers"] });
await queryClient.invalidateQueries({ queryKey: ["offers", "list"] });
await queryClient.invalidateQueries({ queryKey: ["orders"] });
await queryClient.invalidateQueries({ queryKey: ["projects"] });
await queryClient.invalidateQueries({ queryKey: ["invoices"] });
@@ -1374,7 +1386,7 @@ export default function OfferDetail() {
readOnly={readOnly}
canDelete={items.length > 1}
onUpdate={(field, value) =>
updateItem(index, field, value)
updateItem(index, field as keyof OfferItem, value)
}
onRemove={() => removeItem(index)}
/>
@@ -1678,126 +1690,85 @@ export default function OfferDetail() {
</motion.div>
{/* Order modal */}
<AnimatePresence>
{showOrderModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => !creatingOrder && setShowOrderModal(false)}
<FormModal
isOpen={showOrderModal}
onClose={() => !creatingOrder && setShowOrderModal(false)}
onSubmit={handleCreateOrder}
title="Vytvořit objednávku"
submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"}
loading={creatingOrder}
>
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" && !creatingOrder && handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vytvořit objednávku</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setCustomerOrderNumber(e.target.value)
}
onKeyDown={(e) =>
e.key === "Enter" &&
!creatingOrder &&
handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small
className="admin-form-hint"
style={{ marginTop: "0.25rem" }}
>
Max 10 MB
</small>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
onClick={() => setShowOrderModal(false)}
className="admin-btn admin-btn-secondary"
disabled={creatingOrder}
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
Zrušit
</button>
<button
onClick={handleCreateOrder}
className="admin-btn admin-btn-primary"
disabled={creatingOrder || !customerOrderNumber.trim()}
>
{creatingOrder ? "Vytváření..." : "Vytvořit"}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm"
style={{
cursor: "pointer",
display: "inline-flex",
alignItems: "center",
gap: "0.4rem",
}}
>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small className="admin-form-hint" style={{ marginTop: "0.25rem" }}>
Max 10 MB
</small>
</FormField>
</div>
</FormModal>
<ConfirmModal
isOpen={invalidateConfirm}

View File

@@ -4,18 +4,19 @@ import { useAuth } from "../context/AuthContext";
import { Link, useNavigate } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate, czechPlural } from "../utils/formatters";
import SortIcon from "../components/SortIcon";
import useTableSort from "../hooks/useTableSort";
import { useQueryClient, useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { offerListOptions, offerCustomersOptions } from "../lib/queries/offers";
import useModalLock from "../hooks/useModalLock";
import Pagination from "../components/Pagination";
import FormField from "../components/FormField";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
const DRAFT_KEY = "boha_offer_draft";
@@ -27,12 +28,6 @@ const STATUS_FILTERS = [
{ value: "invalidated", label: "Zneplatněná" },
];
const ORDER_FILTERS = [
{ value: "", label: "Vše" },
{ value: "yes", label: "S objednávkou" },
{ value: "no", label: "Bez objednávky" },
];
interface Quotation {
id: number;
quotation_number: string;
@@ -70,7 +65,6 @@ export default function Offers() {
const [page, setPage] = useState(1);
const [statusFilter, setStatusFilter] = useState("");
const [customerFilter, setCustomerFilter] = useState<number | "">("");
const [orderFilter, setOrderFilter] = useState("");
const { data: customers } = useQuery(offerCustomersOptions());
@@ -101,7 +95,6 @@ export default function Offers() {
show: boolean;
quotation: Quotation | null;
}>({ show: false, quotation: null });
useModalLock(orderModal.show);
const [customerOrderNumber, setCustomerOrderNumber] = useState("");
const [orderAttachment, setOrderAttachment] = useState<File | null>(null);
const [draft, setDraft] = useState<Draft | null>(() => {
@@ -130,10 +123,47 @@ export default function Offers() {
page,
status: statusFilter || undefined,
customer_id: customerFilter || undefined,
has_order: orderFilter || undefined,
}),
);
const duplicateMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers/${id}/duplicate`,
method: () => "POST",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: (data) => {
alert.success(data?.message || "Nabídka byla duplikována");
},
});
const deleteOfferMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers/${id}`,
method: () => "DELETE",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, quotation: null });
alert.success(data?.message || "Nabídka byla smazána");
},
});
const invalidateMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/offers/${id}/invalidate`,
method: () => "POST",
invalidate: ["offers", "orders", "projects", "invoices"],
onSuccess: (data) => {
setInvalidateConfirm({ show: false, quotation: null });
alert.success(data?.message || "Nabídka byla zneplatněna");
},
});
const discardDraft = () => {
try {
localStorage.removeItem(DRAFT_KEY);
@@ -159,25 +189,9 @@ export default function Offers() {
const handleDuplicate = async (quotation: Quotation) => {
setDuplicating(quotation.id);
try {
const response = await apiFetch(
`${API_BASE}/offers/${quotation.id}/duplicate`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
},
);
const result = await response.json();
if (result.success) {
alert.success(result.message || "Nabídka byla duplikována");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se duplikovat nabídku");
}
} catch {
alert.error("Chyba připojení");
await duplicateMutation.mutateAsync(quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDuplicating(null);
}
@@ -187,16 +201,25 @@ export default function Offers() {
if (!customerOrderNumber.trim() || !orderModal.quotation) return;
setCreatingOrder(orderModal.quotation.id);
try {
const formData = new FormData();
formData.append("quotationId", String(orderModal.quotation.id));
formData.append("customerOrderNumber", customerOrderNumber.trim());
let fetchOptions: RequestInit;
if (orderAttachment) {
// With attachment: send as multipart/form-data
const formData = new FormData();
formData.append("quotationId", String(orderModal.quotation.id));
formData.append("customerOrderNumber", customerOrderNumber.trim());
formData.append("attachment", orderAttachment);
fetchOptions = { method: "POST", body: formData };
} else {
fetchOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
quotationId: orderModal.quotation.id,
customerOrderNumber: customerOrderNumber.trim(),
}),
};
}
const response = await apiFetch(`${API_BASE}/orders`, {
method: "POST",
body: formData,
});
const response = await apiFetch(`${API_BASE}/orders`, fetchOptions);
const result = await response.json();
if (result.success) {
setOrderModal({ show: false, quotation: null });
@@ -220,25 +243,9 @@ export default function Offers() {
if (!deleteConfirm.quotation) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/offers/${deleteConfirm.quotation.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, quotation: null });
alert.success(result.message || "Nabídka byla smazána");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se smazat nabídku");
}
} catch {
alert.error("Chyba připojení");
await deleteOfferMutation.mutateAsync(deleteConfirm.quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setDeleting(false);
}
@@ -248,25 +255,9 @@ export default function Offers() {
if (!invalidateConfirm.quotation) return;
setInvalidating(true);
try {
const response = await apiFetch(
`${API_BASE}/offers/${invalidateConfirm.quotation.id}/invalidate`,
{
method: "POST",
},
);
const result = await response.json();
if (result.success) {
setInvalidateConfirm({ show: false, quotation: null });
alert.success(result.message || "Nabídka byla zneplatněna");
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
} else {
alert.error(result.error || "Nepodařilo se zneplatnit nabídku");
}
} catch {
alert.error("Chyba připojení");
await invalidateMutation.mutateAsync(invalidateConfirm.quotation.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setInvalidating(false);
}
@@ -433,28 +424,11 @@ export default function Offers() {
))}
</select>
</div>
<div className="admin-filter-group">
<label className="admin-filter-label">Objednávka</label>
<select
value={orderFilter}
onChange={(e) => {
setOrderFilter(e.target.value);
setPage(1);
}}
className="admin-form-select"
>
{ORDER_FILTERS.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${statusFilter}-${customerFilter}-${orderFilter}-${search}-${page}-${quotations.length}`}
key={`${statusFilter}-${customerFilter}-${search}-${page}-${quotations.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
@@ -959,157 +933,110 @@ export default function Offers() {
loading={invalidating}
/>
<AnimatePresence>
{orderModal.show && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() =>
!creatingOrder &&
setOrderModal({ show: false, quotation: null })
<FormModal
isOpen={orderModal.show}
onClose={() => setOrderModal({ show: false, quotation: null })}
onSubmit={handleCreateOrder}
title="Vytvořit objednávku"
submitLabel={creatingOrder ? "Vytváření..." : "Vytvořit"}
loading={!!creatingOrder}
>
<p
className="text-secondary text-md"
style={{ marginTop: "-0.5rem", marginBottom: "0.5rem" }}
>
Nabídka: <strong>{orderModal.quotation?.quotation_number}</strong>
</p>
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e) => setCustomerOrderNumber(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" && !creatingOrder && handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Vytvořit objednávku</h2>
<p
className="text-secondary text-md"
style={{ marginTop: "0.25rem" }}
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent-color)"
strokeWidth="2"
>
Nabídka:{" "}
<strong>{orderModal.quotation?.quotation_number}</strong>
</p>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Číslo objednávky zákazníka" required>
<input
type="text"
value={customerOrderNumber}
onChange={(e) => setCustomerOrderNumber(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
!creatingOrder &&
handleCreateOrder()
}
className="admin-form-input"
placeholder="Např. PO-2026-001"
autoFocus
/>
</FormField>
<FormField label="Příloha (PDF)">
{orderAttachment ? (
<div className="flex-row gap-2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="var(--accent-color)"
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" />
</svg>
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm inline-flex"
style={{
cursor: "pointer",
gap: "0.4rem",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e) =>
setOrderAttachment(e.target.files?.[0] || null)
}
style={{ display: "none" }}
/>
</label>
)}
<small
className="admin-form-hint"
style={{ marginTop: "0.25rem" }}
>
Max 10 MB
</small>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<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" />
</svg>
<span className="text-md">
{orderAttachment.name}{" "}
<span className="text-tertiary">
({(orderAttachment.size / 1024).toFixed(0)} KB)
</span>
</span>
<button
onClick={() =>
setOrderModal({ show: false, quotation: null })
type="button"
onClick={() => setOrderAttachment(null)}
className="admin-btn-icon"
title="Odebrat"
style={{ marginLeft: "auto" }}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
) : (
<label
className="admin-btn admin-btn-secondary admin-btn-sm inline-flex"
style={{
cursor: "pointer",
gap: "0.4rem",
}}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Vybrat soubor
<input
type="file"
accept="application/pdf"
onChange={(e) =>
setOrderAttachment(e.target.files?.[0] || null)
}
className="admin-btn admin-btn-secondary"
disabled={!!creatingOrder}
>
Zrušit
</button>
<button
onClick={handleCreateOrder}
className="admin-btn admin-btn-primary"
disabled={!!creatingOrder || !customerOrderNumber.trim()}
>
{creatingOrder ? "Vytváření..." : "Vytvořit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
style={{ display: "none" }}
/>
</label>
)}
<small className="admin-form-hint" style={{ marginTop: "0.25rem" }}>
Max 10 MB
</small>
</FormField>
</div>
</FormModal>
</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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
import { useState, useEffect, useMemo } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Navigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import CompanySettings from "./CompanySettings";
import {
companySettingsOptions,
@@ -14,9 +14,8 @@ import {
require2FAOptions,
} from "../lib/queries/settings";
import { useApiMutation } from "../lib/queries/mutations";
import apiFetch from "../utils/api";
import { Skeleton } from "boneyard-js/react";
import SettingsFixture from "../fixtures/SettingsFixture";
const API_BASE = "/api/admin";
interface SystemSettingsData {
@@ -108,7 +107,6 @@ const DEFAULT_SYS_FORM: Omit<SystemSettingsData, "app_version"> = {
export default function Settings() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const canManageRoles = hasPermission("settings.roles");
const canManageCompany = hasPermission("settings.company");
@@ -130,11 +128,11 @@ export default function Settings() {
queryKey: ["settings", "roles"],
queryFn: async () => {
const [rolesRes, permsRes] = await Promise.all([
apiFetch(`${API_BASE}/roles`),
apiFetch(`${API_BASE}/roles/permissions`),
apiFetch(`${API_BASE}/roles`).then((r) => r.json()),
apiFetch(`${API_BASE}/roles/permissions`).then((r) => r.json()),
]);
const rolesResult = await rolesRes.json();
const permsResult = await permsRes.json();
const rolesResult = await rolesRes;
const permsResult = await permsRes;
if (!rolesResult.success)
throw new Error(rolesResult.error || "Nepodařilo se načíst role");
if (!permsResult.success)
@@ -194,11 +192,8 @@ export default function Settings() {
});
// ── Local state ──
const [require2FASaving, setRequire2FASaving] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<RoleForm>({
name: "",
display_name: "",
@@ -210,9 +205,7 @@ export default function Settings() {
show: boolean;
role: Role | null;
}>({ show: false, role: null });
const [deleting, setDeleting] = useState(false);
const [sysSettingsSaving, setSysSettingsSaving] = useState(false);
const [sysForm, setSysForm] = useState(DEFAULT_SYS_FORM);
const [sysFormInitialized, setSysFormInitialized] = useState(false);
@@ -256,61 +249,108 @@ export default function Settings() {
setSysFormInitialized(true);
}, [sysSettingsData, sysFormInitialized]);
useModalLock(showModal);
// ── Early return after all hooks ──
if (!canAccessSettings) {
return <Navigate to="/" replace />;
}
const saveSystemSettingsMutation = useApiMutation<
typeof sysForm,
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/company-settings`,
method: () => "PUT",
invalidate: [
"settings",
"company-settings",
"attendance",
"offers",
"orders",
"invoices",
"leave",
],
onSuccess: (data) => {
alert.success(data?.message || "Systémová nastavení byla uložena");
},
});
const toggle2FARoleMutation = useApiMutation<
{ required: boolean },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/totp/required`,
method: () => "POST",
invalidate: ["settings", "company-settings", "dashboard", "users"],
onSuccess: (data) => {
alert.success(data?.message || "2FA nastavení uloženo");
},
});
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema shape (CreateRoleSchema / UpdateRoleSchema)
// directly — NOT a wrapper that nests the body. id is captured in the URL closure.
const createRoleMutation = useApiMutation<
{
name: string;
display_name: string;
description?: string;
permission_ids: number[];
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/roles`,
method: () => "POST",
invalidate: ["settings", "users"],
onSuccess: (data) => {
closeModal();
alert.success(data?.message || "Role byla vytvořena");
},
});
const updateRoleMutation = useApiMutation<
{
id: number;
display_name?: string;
description?: string;
permission_ids: number[];
},
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/roles/${id}`,
method: () => "PUT",
invalidate: ["settings", "users"],
onSuccess: (data) => {
closeModal();
alert.success(data?.message || "Role byla aktualizována");
},
});
const deleteRoleMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/roles/${id}`,
method: () => "DELETE",
invalidate: ["settings", "users"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, role: null });
alert.success(data?.message || "Role byla smazána");
},
});
const handleSaveSystemSettings = async () => {
setSysSettingsSaving(true);
try {
const response = await apiFetch(`${API_BASE}/company-settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(sysForm),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "Systémová nastavení byla uložena");
queryClient.invalidateQueries({ queryKey: ["company-settings"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["orders"] });
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSysSettingsSaving(false);
await saveSystemSettingsMutation.mutateAsync(sysForm);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleToggle2FARequired = async () => {
setRequire2FASaving(true);
try {
const response = await apiFetch(`${API_BASE}/totp/required`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ required: !require2FA }),
});
const result = await response.json();
if (result.success) {
alert.success(result.message || "2FA nastavení uloženo");
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se uložit nastavení");
}
} catch {
alert.error("Chyba připojení");
} finally {
setRequire2FASaving(false);
await toggle2FARoleMutation.mutateAsync({ required: !require2FA });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -389,91 +429,53 @@ export default function Settings() {
return;
}
setSaving(true);
const permission_ids = form.permissions
.map((name) => {
// Find permission ID by name from groups
for (const perms of Object.values(permissionGroups)) {
const found = perms.find((p) => p.name === name);
if (found) return found.id;
}
return null;
})
.filter((id): id is number => id !== null);
try {
const url = editingRole
? `${API_BASE}/roles/${editingRole.id}`
: `${API_BASE}/roles`;
const response = await apiFetch(url, {
method: editingRole ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
permission_ids: form.permissions
.map((name) => {
// Find permission ID by name from groups
for (const perms of Object.values(permissionGroups)) {
const found = perms.find((p) => p.name === name);
if (found) return found.id;
}
return null;
})
.filter(Boolean),
}),
});
const result = await response.json();
if (result.success) {
closeModal();
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.message ||
(editingRole ? "Role byla aktualizována" : "Role byla vytvořena"),
);
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
if (editingRole) {
await updateRoleMutation.mutateAsync({
id: editingRole.id,
display_name: form.display_name,
description: form.description,
permission_ids,
});
} else {
alert.error(result.error || "Nepodařilo se uložit roli");
await createRoleMutation.mutateAsync({
name: form.name,
display_name: form.display_name,
description: form.description,
permission_ids,
});
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.role) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/roles/${deleteConfirm.role.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, role: null });
alert.success(result.message || "Role byla smazána");
queryClient.invalidateQueries({ queryKey: ["settings", "roles"] });
queryClient.invalidateQueries({ queryKey: ["roles"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
} else {
alert.error(result.error || "Nepodařilo se smazat roli");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
await deleteRoleMutation.mutateAsync(deleteConfirm.role.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
if (canManageRoles && rolesLoading) {
return (
<Skeleton
name="settings"
loading={rolesLoading}
fixture={<SettingsFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -482,13 +484,9 @@ export default function Settings() {
const get2FADescription = (): React.ReactNode => {
if (require2FALoading) {
return (
<Skeleton
name="settings-2fa"
loading={require2FALoading}
fixture={<SettingsFixture />}
>
<span />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
if (require2FA)
@@ -497,12 +495,14 @@ export default function Settings() {
};
const get2FAButtonLabel = (): string => {
if (require2FASaving) return "Ukládání...";
if (toggle2FARoleMutation.isPending) return "Ukládání...";
return require2FA ? "Vypnout" : "Zapnout";
};
const renderRoleButtonContent = (): React.ReactNode => {
if (saving) {
const isPending =
createRoleMutation.isPending || updateRoleMutation.isPending;
if (isPending) {
return (
<>
<div className="admin-spinner admin-spinner-sm" />
@@ -634,7 +634,7 @@ export default function Settings() {
{!require2FALoading && (
<button
onClick={handleToggle2FARequired}
disabled={require2FASaving}
disabled={toggle2FARoleMutation.isPending}
className={`admin-btn admin-btn-sm ${require2FA ? "admin-btn-secondary" : "admin-btn-primary"}`}
style={require2FA ? { color: "var(--danger)" } : {}}
>
@@ -693,9 +693,11 @@ export default function Settings() {
<button
onClick={handleSaveSystemSettings}
className="admin-btn admin-btn-primary admin-btn-sm"
disabled={sysSettingsSaving}
disabled={saveSystemSettingsMutation.isPending}
>
{sysSettingsSaving ? "Ukládání..." : "Uložit"}
{saveSystemSettingsMutation.isPending
? "Ukládání..."
: "Uložit"}
</button>
</div>
</div>
@@ -852,13 +854,9 @@ export default function Settings() {
{activeTab === "system" && (
<>
{sysSettingsLoading && !sysFormInitialized ? (
<Skeleton
name="settings-system"
loading={sysSettingsLoading && !sysFormInitialized}
fixture={<SettingsFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{/* Section 1: Docházka */}
@@ -1346,10 +1344,10 @@ export default function Settings() {
>
<button
onClick={handleSaveSystemSettings}
disabled={sysSettingsSaving}
disabled={saveSystemSettingsMutation.isPending}
className="admin-btn admin-btn-primary w-full"
>
{sysSettingsSaving ? (
{saveSystemSettingsMutation.isPending ? (
<>
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
@@ -1368,218 +1366,163 @@ export default function Settings() {
{activeTab === "firma" && <CompanySettings embedded />}
{/* Create/Edit Modal */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-backdrop" onClick={closeModal} />
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingRole ? "Upravit roli" : "Nová role"}
size="lg"
loading={createRoleMutation.isPending || updateRoleMutation.isPending}
>
<div className="admin-form">
{editingRole && isAdminRole(editingRole) && (
<div className="admin-role-locked-notice">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Administrátor vždy plný přístup ke všem funkcím
</div>
)}
<FormField label="Zobrazovaný název">
<input
type="text"
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="admin-form-input"
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<FormField label="Systémový název (slug)">
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
className="admin-form-input"
placeholder="např. manager"
disabled={!!editingRole}
/>
{!editingRole && (
<small
className="text-xs"
style={{
color: "var(--text-tertiary)",
}}
>
Pouze malá písmena, čísla a pomlčky. Nelze později změnit.
</small>
)}
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({
...prev,
description: e.target.value,
}))
}
className="admin-form-input"
rows={2}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<div className="admin-form-group">
<label
className="admin-form-label"
style={{ marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingRole ? "Upravit roli" : "Nová role"}
</h2>
</div>
Oprávnění
</label>
<div className="admin-modal-body">
<div className="admin-form">
{editingRole && isAdminRole(editingRole) && (
<div className="admin-role-locked-notice">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
Administrátor vždy plný přístup ke všem funkcím
</div>
)}
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === "settings") return 1;
if (b === "settings") return -1;
const aMin = Math.min(...aPerms.map((p) => p.id));
const bMin = Math.min(...bPerms.map((p) => p.id));
return aMin - bMin;
})
.map(([module, perms], index) => {
const modulePerms = perms.map((p) => p.name);
const allChecked = modulePerms.every((p) =>
form.permissions.includes(p),
);
const someChecked = modulePerms.some((p) =>
form.permissions.includes(p),
);
const disabled = !!(editingRole && isAdminRole(editingRole));
<FormField label="Zobrazovaný název">
<input
type="text"
value={form.display_name}
onChange={(e) => handleDisplayNameChange(e.target.value)}
className="admin-form-input"
placeholder="např. Manažer"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<FormField label="Systémový název (slug)">
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
className="admin-form-input"
placeholder="např. manager"
disabled={!!editingRole}
/>
{!editingRole && (
<small
className="text-xs"
return (
<div key={module}>
{index > 0 && (
<hr
style={{
color: "var(--text-tertiary)",
border: "none",
borderTop: "1px solid var(--border-color, #e0e0e0)",
margin: "0.75rem 0",
}}
>
Pouze malá písmena, čísla a pomlčky. Nelze později
změnit.
</small>
/>
)}
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm((prev) => ({
...prev,
description: e.target.value,
}))
}
className="admin-form-input"
rows={2}
placeholder="Volitelný popis role"
disabled={!!(editingRole && isAdminRole(editingRole))}
/>
</FormField>
<div className="admin-form-group">
<label
className="admin-form-label"
style={{ marginBottom: "0.75rem" }}
>
Oprávnění
</label>
{Object.entries(permissionGroups)
.sort(([a, aPerms], [b, bPerms]) => {
if (a === "settings") return 1;
if (b === "settings") return -1;
const aMin = Math.min(...aPerms.map((p) => p.id));
const bMin = Math.min(...bPerms.map((p) => p.id));
return aMin - bMin;
})
.map(([module, perms], index) => {
const modulePerms = perms.map((p) => p.name);
const allChecked = modulePerms.every((p) =>
form.permissions.includes(p),
);
const someChecked = modulePerms.some((p) =>
form.permissions.includes(p),
);
const disabled = !!(
editingRole && isAdminRole(editingRole)
);
return (
<div key={module}>
{index > 0 && (
<hr
style={{
border: "none",
borderTop:
"1px solid var(--border-color, #e0e0e0)",
margin: "0.75rem 0",
}}
<div className="admin-permission-group">
<div className="admin-permission-group-title">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el)
el.indeterminate = someChecked && !allChecked;
}}
onChange={() => toggleModulePermissions(module)}
disabled={disabled}
/>
<span>{MODULE_LABELS[module] || module}</span>
</label>
</div>
<div className="admin-permission-list">
{perms.map((perm) => (
<div key={perm.id} className="admin-permission-item">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.permissions.includes(perm.name)}
onChange={() => togglePermission(perm.name)}
disabled={disabled}
/>
<span>{perm.display_name}</span>
</label>
{perm.description && (
<div className="admin-permission-desc">
{perm.description}
</div>
)}
<div className="admin-permission-group">
<div className="admin-permission-group-title">
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={allChecked}
ref={(el) => {
if (el)
el.indeterminate =
someChecked && !allChecked;
}}
onChange={() =>
toggleModulePermissions(module)
}
disabled={disabled}
/>
<span>{MODULE_LABELS[module] || module}</span>
</label>
</div>
<div className="admin-permission-list">
{perms.map((perm) => (
<div
key={perm.id}
className="admin-permission-item"
>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.permissions.includes(
perm.name,
)}
onChange={() =>
togglePermission(perm.name)
}
disabled={disabled}
/>
<span>{perm.display_name}</span>
</label>
{perm.description && (
<div className="admin-permission-desc">
{perm.description}
</div>
)}
</div>
))}
</div>
</div>
</div>
);
})}
))}
</div>
</div>
</div>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
{!(editingRole && isAdminRole(editingRole)) && (
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{renderRoleButtonContent()}
</button>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
})}
</div>
</div>
</FormModal>
{/* Delete Confirm Modal */}
<ConfirmModal
@@ -1591,7 +1534,7 @@ export default function Settings() {
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
loading={deleteRoleMutation.isPending}
/>
</div>
);

View File

@@ -1,14 +1,14 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
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";
@@ -19,8 +19,7 @@ import {
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsFixture from "../fixtures/TripsFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface TripForm {
@@ -37,7 +36,6 @@ interface TripForm {
export default function Trips() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: tripsData, isPending: tripsLoading } = useQuery(
tripListOptions({}),
@@ -47,7 +45,6 @@ export default function Trips() {
const trips = tripsData ?? [];
const vehicles = vehiclesData ?? [];
const [submitting, setSubmitting] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editingTrip, setEditingTrip] = useState<BackendTrip | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -67,10 +64,35 @@ export default function Trips() {
const [errors, setErrors] = useState<Record<string, string>>({});
const [, setLastKm] = useState(0);
useModalLock(showModal);
if (!hasPermission("trips.record")) return <Forbidden />;
const submitMutation = useApiMutation<
TripForm,
{ message?: string; error?: string }
>({
url: (formData) =>
editingTrip ? `${API_BASE}/trips/${editingTrip.id}` : `${API_BASE}/trips`,
method: () => (editingTrip ? "PUT" : "POST"),
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Hotovo");
},
});
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/trips/${id}`,
method: () => "DELETE",
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
alert.success(data?.message || "Smazáno");
setDeleteConfirm({ show: false, tripId: null });
},
});
const fetchLastKm = async (vehicleId: string) => {
if (!vehicleId) {
setLastKm(0);
@@ -154,54 +176,18 @@ export default function Trips() {
const handleSubmit = async () => {
if (!validateForm()) return;
setSubmitting(true);
try {
const url = editingTrip
? `${API_BASE}/trips/${editingTrip.id}`
: `${API_BASE}/trips`;
const response = await apiFetch(url, {
method: editingTrip ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSubmitting(false);
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async (tripId: number) => {
try {
const response = await apiFetch(`${API_BASE}/trips/${tripId}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleteConfirm({ show: false, tripId: null });
await deleteMutation.mutateAsync(tripId);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -213,9 +199,9 @@ export default function Trips() {
if (tripsLoading) {
return (
<Skeleton name="trips" loading={tripsLoading} fixture={<TripsFixture />}>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -524,209 +510,145 @@ export default function Trips() {
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
loading={submitMutation.isPending}
size="lg"
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo" error={errors.vehicle_id} required>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<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: "" }));
}}
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<FormField
label="Počáteční stav km"
error={errors.start_km}
required
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingTrip ? "Upravit jízdu" : "Přidat jízdu"}
</h2>
</div>
<input
type="number"
inputMode="numeric"
value={form.start_km}
onChange={(e) => {
setForm({ ...form, start_km: e.target.value });
setErrors((prev) => ({ ...prev, start_km: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField
label="Vozidlo"
error={errors.vehicle_id}
required
>
<select
value={form.vehicle_id}
onChange={(e) => {
handleVehicleChange(e.target.value);
setErrors((prev) => ({ ...prev, vehicle_id: "" }));
}}
className="admin-form-select"
>
<option value="">Vyberte vozidlo</option>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<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: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<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: "" }));
}}
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row admin-form-row-3">
<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: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-form-row">
<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: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<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: "" }));
}}
className="admin-form-input"
min="0"
/>
</FormField>
<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: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<div className="admin-form-row">
<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: "" }));
}}
className="admin-form-input"
placeholder="Např. Praha"
/>
</FormField>
<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: "" }));
}}
className="admin-form-input"
placeholder="Např. Brno"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={form.is_business}
onChange={(e) =>
setForm({
...form,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
disabled={submitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
disabled={submitting}
>
{submitting ? "Ukládám..." : "Uložit"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-textarea"
rows={2}
placeholder="Volitelné poznámky..."
/>
</FormField>
</div>
</FormModal>
<ConfirmModal
isOpen={deleteConfirm.show}
@@ -737,6 +659,7 @@ export default function Trips() {
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleteMutation.isPending}
/>
</div>
);

View File

@@ -1,11 +1,12 @@
import { useState, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import {
tripListOptions,
tripVehiclesOptions,
@@ -21,12 +22,9 @@ import {
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";
import { Skeleton } from "boneyard-js/react";
import TripsAdminFixture from "../fixtures/TripsAdminFixture";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
interface Trip {
@@ -76,7 +74,6 @@ function mapTrip(bt: BackendTrip): Trip {
export default function TripsAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [filterMonth, setFilterMonth] = useState(() =>
String(new Date().getMonth() + 1),
);
@@ -125,10 +122,43 @@ export default function TripsAdmin() {
);
const trips = (tripsData ?? []).map(mapTrip);
useModalLock(showEditModal);
if (!hasPermission("trips.manage")) return <Forbidden />;
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (UpdateTripSchema) shape directly —
// NOT a wrapper that nests the body. id is captured in the URL closure.
const editMutation = useApiMutation<
{
id: number;
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;
},
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/trips/${id}`,
method: () => "PUT",
invalidate: ["trips", "vehicles"],
});
const deleteMutation = useApiMutation<
number,
{ message?: string; error?: string }
>({
url: (id) => `${API_BASE}/trips/${id}`,
method: () => "DELETE",
invalidate: ["trips", "vehicles"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, trip: null });
alert.success(data?.message || "Smazáno");
},
});
const openEditModal = (trip: Trip) => {
setEditingTrip(trip);
setEditForm({
@@ -154,48 +184,30 @@ export default function TripsAdmin() {
}
try {
const response = await apiFetch(`${API_BASE}/trips/${editingTrip.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(editForm),
const result = await editMutation.mutateAsync({
id: editingTrip.id,
vehicle_id: editForm.vehicle_id,
trip_date: editForm.trip_date,
start_km: editForm.start_km,
end_km: editForm.end_km,
route_from: editForm.route_from,
route_to: editForm.route_to,
is_business: editForm.is_business,
notes: editForm.notes,
});
const result = await response.json();
if (result.success) {
setShowEditModal(false);
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.trip) return;
try {
const response = await apiFetch(
`${API_BASE}/trips/${deleteConfirm.trip.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, trip: null });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.trip.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -517,11 +529,11 @@ export default function TripsAdmin() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Skeleton
name="trips-admin"
loading={isPending}
fixture={<TripsAdminFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
@@ -627,190 +639,152 @@ export default function TripsAdmin() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
{/* Edit Modal */}
<AnimatePresence>
{showEditModal && editingTrip && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowEditModal(false)}
/>
<motion.div
className="admin-modal admin-modal-lg"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showEditModal && !!editingTrip}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit jízdu"
loading={editMutation.isPending}
size="lg"
>
{editingTrip && (
<div className="admin-form">
<p
className="text-secondary"
style={{ marginTop: "0", marginBottom: "0.75rem" }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Upravit jízdu</h2>
<p
style={{
color: "var(--text-secondary)",
marginTop: "0.25rem",
}}
{editingTrip.driver_name}
</p>
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{editingTrip.driver_name}
</p>
</div>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Vozidlo">
<select
value={editForm.vehicle_id}
onChange={(e) =>
setEditForm({
...editForm,
vehicle_id: e.target.value,
})
}
className="admin-form-select"
>
{vehicles.map((v) => (
<option key={v.id} value={v.id}>
{v.spz} - {v.name}
</option>
))}
</select>
</FormField>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
<FormField label="Datum jízdy">
<AdminDatePicker
mode="date"
value={editForm.trip_date}
onChange={(val: string) =>
setEditForm({ ...editForm, trip_date: val })
}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Počáteční stav km">
<input
type="number"
inputMode="numeric"
value={editForm.start_km}
onChange={(e) =>
setEditForm({ ...editForm, start_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Konečný stav km">
<input
type="number"
inputMode="numeric"
value={editForm.end_km}
onChange={(e) =>
setEditForm({ ...editForm, end_km: e.target.value })
}
className="admin-form-input"
min="0"
/>
</FormField>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<FormField label="Vzdálenost">
<input
type="text"
value={`${formatKm(calculateDistance())} km`}
className="admin-form-input"
readOnly
disabled
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Místo odjezdu">
<input
type="text"
value={editForm.route_from}
onChange={(e) =>
setEditForm({
...editForm,
route_from: e.target.value,
})
}
className="admin-form-input"
/>
</FormField>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Místo příjezdu">
<input
type="text"
value={editForm.route_to}
onChange={(e) =>
setEditForm({ ...editForm, route_to: e.target.value })
}
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Typ jízdy">
<select
value={editForm.is_business}
onChange={(e) =>
setEditForm({
...editForm,
is_business: parseInt(e.target.value),
})
}
className="admin-form-select"
>
<option value={1}>Služební</option>
<option value={0}>Soukromá</option>
</select>
</FormField>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleEditSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
<FormField label="Poznámky">
<textarea
value={editForm.notes}
onChange={(e) =>
setEditForm({ ...editForm, notes: e.target.value })
}
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
)}
</AnimatePresence>
</FormModal>
{/* Delete Confirmation */}
<ConfirmModal
@@ -825,6 +799,7 @@ export default function TripsAdmin() {
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
{/* Hidden Print Content */}

View File

@@ -14,8 +14,6 @@ import {
type BackendTrip,
type TripVehicle,
} from "../lib/queries/trips";
import { Skeleton } from "boneyard-js/react";
import TripsHistoryFixture from "../fixtures/TripsHistoryFixture";
interface Trip {
id: number;
@@ -217,11 +215,11 @@ export default function TripsHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Skeleton
name="trips-history"
loading={isPending}
fixture={<TripsHistoryFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{trips.length === 0 && (
<div className="admin-empty-state">
@@ -296,7 +294,7 @@ export default function TripsHistory() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
</div>

View File

@@ -1,22 +1,19 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion, AnimatePresence } from "framer-motion";
import { Skeleton } from "boneyard-js/react";
import { useQuery } from "@tanstack/react-query";
import { motion } 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 FormModal from "../components/FormModal";
import { useApiMutation } from "../lib/queries/mutations";
import {
userListOptions,
roleListOptions,
type User,
type Role,
} from "../lib/queries/users";
import UsersFixture from "../fixtures/UsersFixture";
import apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface FormData {
@@ -32,7 +29,6 @@ interface FormData {
export default function Users() {
const { user: currentUser, updateUser, hasPermission } = useAuth();
const alert = useAlert();
const queryClient = useQueryClient();
const { data: usersData, isPending } = useQuery(userListOptions());
const users = usersData ?? [];
const { data: roles = [] } = useQuery(roleListOptions());
@@ -42,7 +38,7 @@ export default function Users() {
isOpen: boolean;
user: User | null;
}>({ isOpen: false, user: null });
const [deleting, setDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState<FormData>({
username: "",
email: "",
@@ -53,7 +49,61 @@ export default function Users() {
is_active: true,
});
useModalLock(showModal);
const USER_INVALIDATE = [
"users",
"trips",
"attendance",
"leave-requests",
"leave",
"projects",
];
const saveUser = useApiMutation<FormData, void>({
url: (input) =>
editingUser ? `${API_BASE}/users/${editingUser.id}` : `${API_BASE}/users`,
method: () => (editingUser ? "PUT" : "POST"),
invalidate: USER_INVALIDATE,
onSuccess: async (_data, input) => {
if (
editingUser &&
currentUser &&
Number(editingUser.id) === Number(currentUser.id)
) {
updateUser({
username: input.username,
email: input.email,
fullName: `${input.first_name} ${input.last_name}`.trim(),
});
}
setShowModal(false);
setEditingUser(null);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
editingUser ? "Uživatel byl upraven" : "Uživatel byl vytvořen",
);
},
});
const deleteUser = useApiMutation<number, void>({
url: (id) => `${API_BASE}/users/${id}`,
method: () => "DELETE",
invalidate: USER_INVALIDATE,
onSuccess: () => {
setDeleteModal({ isOpen: false, user: null });
alert.success("Uživatel byl smazán");
},
});
const toggleUser = useApiMutation<{ id: number; is_active: boolean }, void>({
url: (input) => `${API_BASE}/users/${input.id}`,
method: () => "PUT",
invalidate: USER_INVALIDATE,
onSuccess: (_data, input) => {
alert.success(
input.is_active ? "Uživatel byl aktivován" : "Uživatel byl deaktivován",
);
},
});
if (!hasPermission("users.view")) return <Forbidden />;
@@ -90,56 +140,14 @@ export default function Users() {
setEditingUser(null);
};
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
const dataToSave = { ...formData };
const wasEditing = editingUser;
const editingId = editingUser?.id;
const handleSubmit = async () => {
setSaving(true);
try {
const url = wasEditing
? `${API_BASE}/users/${editingId}`
: `${API_BASE}/users`;
const method = wasEditing ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(dataToSave),
});
const data = await response.json();
if (data.success) {
if (
wasEditing &&
currentUser &&
Number(editingId) === Number(currentUser.id)
) {
updateUser({
username: dataToSave.username,
email: dataToSave.email,
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",
);
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
} else {
alert.error(data.error || "Nepodařilo se uložit uživatele");
}
} catch {
alert.error("Chyba připojení");
await saveUser.mutateAsync(formData);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
@@ -153,67 +161,18 @@ export default function Users() {
const handleDelete = async () => {
if (!deleteModal.user) return;
setDeleting(true);
try {
const response = await apiFetch(
`${API_BASE}/users/${deleteModal.user.id}`,
{
method: "DELETE",
},
);
const data = await response.json();
if (data.success) {
closeDeleteModal();
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
alert.success("Uživatel byl smazán");
} else {
alert.error(data.error || "Nepodařilo se smazat uživatele");
}
} catch {
alert.error("Chyba připojení");
} finally {
setDeleting(false);
await deleteUser.mutateAsync(deleteModal.user.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const toggleActive = async (user: User) => {
try {
const response = await apiFetch(`${API_BASE}/users/${user.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
is_active: !user.is_active,
}),
});
const data = await response.json();
if (data.success) {
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
queryClient.invalidateQueries({ queryKey: ["attendance"] });
queryClient.invalidateQueries({ queryKey: ["leave-requests"] });
queryClient.invalidateQueries({ queryKey: ["leave"] });
queryClient.invalidateQueries({ queryKey: ["projects"] });
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");
}
} catch {
alert.error("Chyba připojení");
}
const toggleActive = (user: User) => {
toggleUser.mutate({
id: user.id,
is_active: !user.is_active,
});
};
const getRoleBadgeClass = (roleName: string): string => {
@@ -225,110 +184,138 @@ export default function Users() {
}
};
return (
<Skeleton name="users" loading={isPending} fixture={<UsersFixture />}>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat uživatele
</button>
</motion.div>
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
return (
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Uživatelé</h1>
<p className="admin-page-subtitle">
Správa uživatelských úč a oprávnění
</p>
</div>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat uživatele
</button>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Uživatel</th>
<th>E-mail</th>
<th>Role</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>
<div className="admin-table-user">
<div className="admin-table-avatar">
{(user.first_name || user.username)
.charAt(0)
.toUpperCase()}
</div>
<div>
<div className="admin-table-name">
{user.first_name} {user.last_name}
</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>
</td>
<td>{user.email}</td>
<td>
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
</div>
</td>
<td>{user.email}</td>
<td>
<span
className={getRoleBadgeClass(user.roles?.name ?? "")}
>
{user.roles?.display_name || user.roles?.name || "—"}
</span>
</td>
<td>
<button
onClick={() =>
user.id !== currentUser?.id && toggleActive(user)
}
disabled={user.id === currentUser?.id}
className={`admin-badge ${user.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
style={{
cursor:
user.id === currentUser?.id
? "not-allowed"
: "pointer",
}}
>
{user.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() =>
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",
}}
onClick={() => openEditModal(user)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
{user.is_active ? "Aktivní" : "Neaktivní"}
<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>
</td>
<td>
<div className="admin-table-actions">
{user.id !== currentUser?.id && (
<button
onClick={() => openEditModal(user)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
onClick={() => openDeleteModal(user)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
@@ -337,203 +324,146 @@ export default function Users() {
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" />
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
{user.id !== currentUser?.id && (
<button
onClick={() => openDeleteModal(user)}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</motion.div>
</div>
</motion.div>
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
<FormModal
isOpen={showModal}
onClose={closeModal}
onSubmit={handleSubmit}
title={editingUser ? "Upravit uživatele" : "Přidat nového uživatele"}
submitLabel={editingUser ? "Uložit změny" : "Vytvořit uživatele"}
loading={saving}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
</div>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
<div className="admin-modal-backdrop" onClick={closeModal} />
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingUser
? "Upravit uživatele"
: "Přidat nového uživatele"}
</h2>
</div>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Jméno">
<input
type="text"
value={formData.first_name}
onChange={(e) =>
setFormData({
...formData,
first_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="Příjmení">
<input
type="text"
value={formData.last_name}
onChange={(e) =>
setFormData({
...formData,
last_name: e.target.value,
})
}
required
className="admin-form-input"
/>
</FormField>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
</div>
</FormModal>
<FormField label="Uživatelské jméno">
<input
type="text"
value={formData.username}
onChange={(e) =>
setFormData({ ...formData, username: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={formData.email}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
required
className="admin-form-input"
/>
</FormField>
<FormField
label={`Heslo ${editingUser ? "(ponechte prázdné pro zachování stávajícího)" : ""}`}
>
<input
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
required={!editingUser}
className="admin-form-input"
/>
</FormField>
<FormField label="Role">
<select
value={formData.role_id}
onChange={(e) =>
setFormData({ ...formData, role_id: e.target.value })
}
required
className="admin-form-select"
>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.display_name}
</option>
))}
</select>
</FormField>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) =>
setFormData({
...formData,
is_active: e.target.checked,
})
}
/>
<span>Účet je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={closeModal}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
{editingUser ? "Uložit změny" : "Vytvořit uživatele"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleting}
/>
</div>
</Skeleton>
<ConfirmModal
isOpen={deleteModal.isOpen}
onClose={closeDeleteModal}
onConfirm={handleDelete}
title="Smazat uživatele"
message={`Opravdu chcete smazat uživatele "${deleteModal.user?.first_name} ${deleteModal.user?.last_name}"? Tato akce je nevratná.`}
confirmText="Smazat"
cancelText="Zrušit"
type="danger"
loading={deleteUser.isPending}
/>
</div>
);
}

View File

@@ -1,18 +1,16 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { Skeleton } from "boneyard-js/react";
import VehiclesFixture from "../fixtures/VehiclesFixture";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import { formatKm } from "../utils/formatters";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
import { vehicleListOptions } from "../lib/queries/vehicles";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin";
@@ -40,9 +38,11 @@ interface VehicleForm {
export default function Vehicles() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: vehicles = [], isPending } = useQuery(vehicleListOptions());
const { data: vehicles = [], isPending } = useQuery({
...vehicleListOptions(),
select: (data) => data as unknown as Vehicle[],
});
const [showModal, setShowModal] = useState(false);
const [editingVehicle, setEditingVehicle] = useState<Vehicle | null>(null);
@@ -60,8 +60,48 @@ export default function Vehicles() {
show: boolean;
vehicle: Vehicle | null;
}>({ show: false, vehicle: null });
const [saving, setSaving] = useState(false);
useModalLock(showModal);
const saveVehicle = useApiMutation<VehicleForm, void>({
url: () =>
editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles`,
method: () => (editingVehicle ? "PUT" : "POST"),
invalidate: ["vehicles", "trips"],
onSuccess: () => {
setShowModal(false);
alert.success(
editingVehicle ? "Vozidlo bylo upraveno" : "Vozidlo bylo vytvořeno",
);
},
});
const deleteVehicle = useApiMutation<number, void>({
url: (id) => `${API_BASE}/vehicles/${id}`,
method: () => "DELETE",
invalidate: ["vehicles", "trips"],
onSuccess: () => {
setDeleteConfirm({ show: false, vehicle: null });
alert.success("Vozidlo bylo smazáno");
},
});
const toggleVehicle = useApiMutation<
{ id: number; is_active: boolean },
void
>({
url: (input) => `${API_BASE}/vehicles/${input.id}`,
method: () => "PUT",
invalidate: ["vehicles", "trips"],
onSuccess: (_data, input) => {
alert.success(
input.is_active
? "Vozidlo bylo aktivováno"
: "Vozidlo bylo deaktivováno",
);
},
});
if (!hasPermission("vehicles.manage")) return <Forbidden />;
@@ -100,412 +140,318 @@ export default function Vehicles() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const url = editingVehicle
? `${API_BASE}/vehicles/${editingVehicle.id}`
: `${API_BASE}/vehicles`;
const method = editingVehicle ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await saveVehicle.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteConfirm.vehicle) return;
try {
const response = await apiFetch(
`${API_BASE}/vehicles/${deleteConfirm.vehicle.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, vehicle: null });
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteVehicle.mutateAsync(deleteConfirm.vehicle.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "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" },
body: JSON.stringify({ is_active: !vehicle.is_active }),
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
queryClient.invalidateQueries({ queryKey: ["trips"] });
alert.success(
vehicle.is_active
? "Vozidlo bylo deaktivováno"
: "Vozidlo bylo aktivováno",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
const toggleActive = (vehicle: Vehicle) => {
toggleVehicle.mutate({
id: vehicle.id,
is_active: !vehicle.is_active,
});
};
if (isPending) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
return (
<Skeleton name="vehicles" loading={isPending} fixture={<VehiclesFixture />}>
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div className="admin-page-actions">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
<div>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa vozidel</h1>
</div>
<div className="admin-page-actions">
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</div>
</motion.div>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
Přidat vozidlo
</button>
</div>
</motion.div>
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
<p>Zatím nejsou žádná vozidla.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
{vehicles.length === 0 && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
Přidat první vozidlo
</button>
<rect x="1" y="3" width="15" height="13" />
<polygon points="16 8 20 8 23 11 23 16 16 16 16 8" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
)}
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
: "—"}
</td>
<td className="admin-mono">
{formatKm(vehicle.initial_km)} km
</td>
<td className="admin-mono fw-500">
{formatKm(vehicle.current_km)} km
</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
<p>Zatím nejsou žádná vozidla.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<div className="admin-form-row">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
value={form.brand}
onChange={(e) =>
setForm({ ...form, brand: e.target.value })
}
className="admin-form-input"
placeholder="Škoda"
/>
</FormField>
<FormField label="Model">
<input
type="text"
value={form.model}
onChange={(e) =>
setForm({ ...form, model: e.target.value })
}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</FormField>
</div>
<div className="admin-form-group">
<label className="admin-form-label">
Počáteční stav km
</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/>
<span>Vozidlo je aktivní</span>
</label>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
Přidat první vozidlo
</button>
</div>
)}
</AnimatePresence>
{vehicles.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>SPZ</th>
<th>Název</th>
<th>Značka / Model</th>
<th>Počáteční km</th>
<th>Aktuální km</th>
<th>Počet jízd</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{vehicles.map((vehicle) => (
<tr
key={vehicle.id}
className={
!vehicle.is_active ? "admin-table-row-inactive" : ""
}
>
<td className="admin-mono fw-500">{vehicle.spz}</td>
<td>{vehicle.name}</td>
<td>
{vehicle.brand || vehicle.model
? `${vehicle.brand || ""} ${vehicle.model || ""}`.trim()
: "—"}
</td>
<td className="admin-mono">
{formatKm(vehicle.initial_km)} km
</td>
<td className="admin-mono fw-500">
{formatKm(vehicle.current_km)} km
</td>
<td className="admin-mono">{vehicle.trip_count}</td>
<td>
<button
onClick={() => toggleActive(vehicle)}
className={`admin-badge ${vehicle.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
{vehicle.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(vehicle)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeleteConfirm({ show: true, vehicle })
}
className="admin-btn-icon danger"
title="Smazat"
aria-label="Smazat"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</motion.div>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
</Skeleton>
{/* Add/Edit Modal */}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingVehicle ? "Upravit vozidlo" : "Přidat vozidlo"}
loading={saving}
>
<div className="admin-form">
<div className="admin-form-row">
<FormField label="SPZ" error={errors.spz} required>
<input
type="text"
value={form.spz}
onChange={(e) => {
setForm({
...form,
spz: e.target.value.toUpperCase(),
});
setErrors((prev) => ({ ...prev, spz: "" }));
}}
className="admin-form-input"
placeholder="1AB 2345"
aria-invalid={!!errors.spz}
/>
</FormField>
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Služební #1"
aria-invalid={!!errors.name}
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Značka">
<input
type="text"
value={form.brand}
onChange={(e) => setForm({ ...form, brand: e.target.value })}
className="admin-form-input"
placeholder="Škoda"
/>
</FormField>
<FormField label="Model">
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
className="admin-form-input"
placeholder="Octavia Combi"
/>
</FormField>
</div>
<div className="admin-form-group">
<label className="admin-form-label">Počáteční stav km</label>
<input
type="number"
inputMode="numeric"
value={form.initial_km}
onChange={(e) =>
setForm({
...form,
initial_km: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Stav tachometru při přidání vozidla
</small>
</div>
<label className="admin-form-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) =>
setForm({ ...form, is_active: e.target.checked })
}
/>
<span>Vozidlo je aktivní</span>
</label>
</div>
</FormModal>
{/* Delete Confirmation */}
<ConfirmModal
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, vehicle: null })}
onConfirm={handleDelete}
title="Smazat vozidlo"
message={
deleteConfirm.vehicle
? `Opravdu chcete smazat vozidlo ${deleteConfirm.vehicle.spz} - ${deleteConfirm.vehicle.name}?`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
@@ -8,29 +7,26 @@ import {
warehouseStockStatusOptions,
warehouseBelowMinimumOptions,
warehouseReservationListOptions,
warehouseMovementLogOptions,
type WarehouseItem,
type WarehouseReservation,
} from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
interface MovementLogRow {
date: string;
type: string;
document_number: string;
item_name: string;
quantity: number;
unit_price: number;
supplier_or_project: string;
}
import useReducedMotion from "../hooks/useReducedMotion";
const TYPE_LABEL: Record<string, string> = {
receipt: "Příjem",
issue: "Výdej",
};
const TYPE_TONE: Record<string, string> = {
receipt: "admin-badge-incoming",
issue: "admin-badge-outgoing",
};
export default function Warehouse() {
const { hasPermission } = useAuth();
const reducedMotion = useReducedMotion();
const { data: stockItems, isPending: stockPending } = useQuery(
warehouseStockStatusOptions(),
@@ -42,27 +38,9 @@ export default function Warehouse() {
warehouseReservationListOptions({ status: "ACTIVE", perPage: 1 }),
);
const [recentMovements, setRecentMovements] = useState<MovementLogRow[]>([]);
const [movementsLoading, setMovementsLoading] = useState(true);
useEffect(() => {
async function fetchMovements() {
try {
const response = await apiFetch(
"/api/admin/warehouse/reports/movement-log?limit=10",
);
const result = await response.json();
if (result.success) {
setRecentMovements(result.data ?? []);
}
} catch {
// Non-fatal, show empty
} finally {
setMovementsLoading(false);
}
}
fetchMovements();
}, []);
const { data: recentMovements = [], isPending: movementsLoading } = useQuery(
warehouseMovementLogOptions({ limit: 10 }),
);
if (!hasPermission("warehouse.view")) return <Forbidden />;
@@ -95,6 +73,52 @@ export default function Warehouse() {
<p className="admin-page-subtitle">Přehled skladových zásob</p>
</div>
<div className="admin-page-actions">
{hasPermission("warehouse.operate") && (
<>
<Link
to="/warehouse/receipts/new"
className="admin-btn admin-btn-primary"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="17 11 12 6 7 11" />
<line x1="12" y1="6" x2="12" y2="18" />
</svg>
Nový příjem
</Link>
<Link
to="/warehouse/issues/new"
className="admin-btn admin-btn-secondary"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
Nový výdej
</Link>
</>
)}
{hasPermission("warehouse.manage") && (
<Link
to="/warehouse/categories"
className="admin-btn admin-btn-secondary"
>
Kategorie
</Link>
)}
<Link to="/warehouse/items" className="admin-btn admin-btn-secondary">
Položky
</Link>
@@ -108,135 +132,64 @@ export default function Warehouse() {
</motion.div>
{/* KPI cards */}
<div
className="dash-kpi-cards"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
gap: "1rem",
marginBottom: "1.5rem",
}}
>
<div className="admin-kpi-grid admin-kpi-4">
<motion.div
className="admin-card"
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.06,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Celkem položek
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : totalItems}
</div>
<div className="admin-stat-label">Celkem položek</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : totalItems}
</div>
</motion.div>
<motion.div
className="admin-card"
className="admin-stat-card success"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.08 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.08,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Hodnota zásob
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
<div className="admin-stat-label">Hodnota zásob</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : formatCurrency(totalStockValue, "CZK")}
</div>
</motion.div>
<motion.div
className="admin-card"
className={`admin-stat-card ${belowMinimumCount > 0 ? "danger" : "warning"}`}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.1 }}
style={
belowMinimumCount > 0
? { border: "2px solid var(--danger)" }
: undefined
}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.1,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color:
belowMinimumCount > 0
? "var(--danger)"
: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Pod minimem
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color:
belowMinimumCount > 0
? "var(--danger)"
: "var(--text-primary)",
}}
>
{isLoading ? "—" : belowMinimumCount}
</div>
<div className="admin-stat-label">Pod minimem</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : belowMinimumCount}
</div>
</motion.div>
<motion.div
className="admin-card"
className="admin-stat-card info"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.12,
}}
>
<div className="admin-card-body" style={{ textAlign: "center" }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--text-secondary)",
marginBottom: "0.25rem",
}}
>
Aktivní rezervace
</div>
<div
style={{
fontSize: "1.75rem",
fontWeight: 600,
color: "var(--text-primary)",
}}
>
{isLoading ? "—" : activeReservationCount}
</div>
<div className="admin-stat-label">Aktivní rezervace</div>
<div className="admin-stat-value admin-mono">
{isLoading ? "—" : activeReservationCount}
</div>
</motion.div>
</div>
@@ -244,11 +197,13 @@ export default function Warehouse() {
{/* Below minimum alert */}
{belowMin.length > 0 && (
<motion.div
className="admin-card"
className="admin-card admin-card-danger"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.14 }}
style={{ border: "2px solid var(--danger)" }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.14,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title" style={{ color: "var(--danger)" }}>
@@ -261,29 +216,24 @@ export default function Warehouse() {
Reporty &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th className="text-right">K dispozici</th>
<th className="text-right">Min. množství</th>
<th>K dispozici</th>
<th>Min. množství</th>
</tr>
</thead>
<tbody>
{belowMin.map((item) => (
<tr key={item.id}>
<tr key={item.id} className="admin-warehouse-row-danger">
<td className="fw-500">{item.name}</td>
<td
className="admin-mono text-right"
style={{ color: "var(--danger)" }}
>
<td className="admin-mono admin-warehouse-danger-value">
{item.available_quantity ?? 0}
</td>
<td className="admin-mono text-right">
{item.min_quantity ?? 0}
</td>
<td className="admin-mono">{item.min_quantity ?? 0}</td>
</tr>
))}
</tbody>
@@ -298,8 +248,10 @@ export default function Warehouse() {
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.16 }}
style={{ marginTop: "1.5rem" }}
transition={{
duration: reducedMotion ? 0 : 0.25,
delay: reducedMotion ? 0 : 0.16,
}}
>
<div className="admin-card-header flex-between">
<h2 className="admin-card-title">Poslední pohyby</h2>
@@ -310,28 +262,13 @@ export default function Warehouse() {
Vše &rarr;
</Link>
</div>
<div className="admin-card-body" style={{ padding: 0 }}>
<div className="admin-card-body">
{movementsLoading ? (
<div
style={{
textAlign: "center",
padding: "2rem",
color: "var(--text-tertiary)",
}}
>
<div className="admin-spinner admin-spinner-sm" />
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : recentMovements.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "2rem",
color: "var(--text-tertiary)",
fontSize: "0.875rem",
}}
>
Žádné nedávné pohyby
</div>
<div className="admin-empty-state">Žádné nedávné pohyby</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
@@ -341,17 +278,17 @@ export default function Warehouse() {
<th>Typ</th>
<th>Doklad</th>
<th>Položka</th>
<th className="text-right">Množství</th>
<th>Množství</th>
<th>Dodavatel / Projekt</th>
</tr>
</thead>
<tbody>
{recentMovements.map((row, i) => (
<tr key={i}>
<td className="admin-mono">{row.date}</td>
<td className="admin-mono">{formatDate(row.date)}</td>
<td>
<span
className={`admin-badge ${row.type === "receipt" ? "admin-badge-active" : "admin-badge-info"}`}
className={`admin-badge ${TYPE_TONE[row.type] ?? ""}`}
>
{TYPE_LABEL[row.type] ?? row.type}
</span>
@@ -360,7 +297,7 @@ export default function Warehouse() {
{row.document_number || "—"}
</td>
<td className="fw-500">{row.item_name}</td>
<td className="admin-mono text-right">{row.quantity}</td>
<td className="admin-mono">{row.quantity}</td>
<td>{row.supplier_or_project || "—"}</td>
</tr>
))}

View File

@@ -1,18 +1,18 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import FormModal from "../components/FormModal";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
import {
warehouseCategoryListOptions,
type WarehouseCategory,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin/warehouse/categories";
@@ -25,7 +25,6 @@ interface CategoryForm {
export default function WarehouseCategories() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: categories = [], isPending } = useQuery(
warehouseCategoryListOptions(),
@@ -45,8 +44,32 @@ export default function WarehouseCategories() {
show: boolean;
category: WarehouseCategory | null;
}>({ show: false, category: null });
const [saving, setSaving] = useState(false);
useModalLock(showModal);
const submitMutation = useApiMutation<CategoryForm, void>({
url: () =>
editingCategory ? `${API_BASE}/${editingCategory.id}` : API_BASE,
method: () => (editingCategory ? "PUT" : "POST"),
invalidate: ["warehouse"],
onSuccess: () => {
setShowModal(false);
alert.success(
editingCategory
? "Kategorie byla upravena"
: "Kategorie byla vytvořena",
);
},
});
const deleteMutation = useApiMutation<number, void>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: () => {
setDeleteConfirm({ show: false, category: null });
alert.success("Kategorie byla smazána");
},
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;
@@ -78,60 +101,22 @@ export default function WarehouseCategories() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const url = editingCategory
? `${API_BASE}/${editingCategory.id}`
: API_BASE;
const method = editingCategory ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({
queryKey: ["warehouse", "categories"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!deleteConfirm.category) return;
try {
const response = await apiFetch(
`${API_BASE}/${deleteConfirm.category.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, category: null });
queryClient.invalidateQueries({
queryKey: ["warehouse", "categories"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deleteConfirm.category.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -280,101 +265,60 @@ export default function WarehouseCategories() {
</motion.div>
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
loading={saving}
>
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název kategorie"
aria-invalid={!!errors.name}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingCategory ? "Upravit kategorii" : "Přidat kategorii"}
</h2>
</div>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název kategorie"
aria-invalid={!!errors.name}
/>
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className="admin-form-input"
rows={3}
placeholder="Volitelný popis kategorie"
/>
</FormField>
<FormField label="Popis">
<textarea
value={form.description}
onChange={(e) =>
setForm({ ...form, description: e.target.value })
}
className="admin-form-input"
rows={3}
placeholder="Volitelný popis kategorie"
/>
</FormField>
<FormField label="Pořadí řazení">
<input
type="number"
inputMode="numeric"
value={form.sort_order}
onChange={(e) =>
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Nižší hodnota = výše v seznamu
</small>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<FormField label="Pořadí řazení">
<input
type="number"
inputMode="numeric"
value={form.sort_order}
onChange={(e) =>
setForm({
...form,
sort_order: parseInt(e.target.value) || 0,
})
}
className="admin-form-input"
min="0"
/>
<small className="admin-form-hint">
Nižší hodnota = výše v seznamu
</small>
</FormField>
</div>
</FormModal>
{/* Delete Confirmation */}
<ConfirmModal

View File

@@ -118,7 +118,6 @@ export default function WarehouseInventory() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 140 }}
>
<option value="">Všechny stavy</option>
<option value="DRAFT">Návrh</option>
@@ -186,7 +185,7 @@ export default function WarehouseInventory() {
<th>Číslo inventury</th>
<th>Stav</th>
<th>Vytvořeno</th>
<th className="text-right">Řádků</th>
<th>Položky</th>
</tr>
</thead>
<tbody>
@@ -209,8 +208,8 @@ export default function WarehouseInventory() {
<td className="admin-mono">
{formatDate(session.created_at)}
</td>
<td className="admin-mono text-right">
{session.lines?.length ?? 0}
<td className="admin-mono">
{session.items?.length ?? 0}
</td>
</tr>
))}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -8,12 +8,12 @@ import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { formatDate } from "../utils/formatters";
import {
warehouseInventoryDetailOptions,
type WarehouseInventorySession,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
@@ -29,9 +29,7 @@ export default function WarehouseInventoryDetail() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [confirming, setConfirming] = useState(false);
const [showConfirmModal, setShowConfirmModal] = useState(false);
const {
@@ -42,29 +40,30 @@ export default function WarehouseInventoryDetail() {
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const confirmMutation = useApiMutation<void, void>({
url: () =>
session
? `/api/admin/warehouse/inventory-sessions/${session.id}/confirm`
: "/api/admin/warehouse/inventory-sessions/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setShowConfirmModal(false);
alert.success("Inventura byla potvrzena");
},
});
const handleConfirm = async () => {
if (!session) return;
setConfirming(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/inventory-sessions/${session.id}/confirm`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "inventory"] });
setShowConfirmModal(false);
alert.success("Inventura byla potvrzena");
} else {
alert.error(result.error || "Nepodařilo se potvrdit inventuru");
}
} catch {
alert.error("Chyba připojení");
} finally {
setConfirming(false);
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
if (isPending) {
return (
<div className="admin-loading">
@@ -108,7 +107,7 @@ export default function WarehouseInventoryDetail() {
}
const s = session as WarehouseInventorySession;
const lines = s.lines ?? [];
const items = s.items ?? [];
return (
<div>
@@ -174,7 +173,7 @@ export default function WarehouseInventoryDetail() {
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo inventury">
<div className="admin-mono" style={{ fontWeight: 500 }}>
<div className="admin-mono fw-500">
{s.session_number || "—"}
</div>
</FormField>
@@ -199,18 +198,9 @@ export default function WarehouseInventoryDetail() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky inventury</h3>
{lines.length === 0 ? (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1.5rem 0",
}}
>
Žádné řádky
</div>
<h3 className="admin-card-title">Položky</h3>
{items.length === 0 ? (
<div className="admin-empty-state">Žádné řádky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
@@ -218,15 +208,15 @@ export default function WarehouseInventoryDetail() {
<tr>
<th>Položka</th>
<th>Lokace</th>
<th className="text-right">Systémové množství</th>
<th className="text-right">Skutečné množství</th>
<th className="text-right">Rozdíl</th>
<th>Systémové množství</th>
<th>Skutečné množství</th>
<th>Rozdíl</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{lines.map((line) => {
const diff = Number(line.difference);
{items.map((item) => {
const diff = Number(item.difference);
const diffColor =
diff > 0
? "var(--success)"
@@ -234,26 +224,26 @@ export default function WarehouseInventoryDetail() {
? "var(--danger)"
: "var(--text-secondary)";
return (
<tr key={line.id}>
<tr key={item.id}>
<td className="fw-500">
{line.item?.name || `ID: ${line.item_id}`}
{item.item?.name || `ID: ${item.item_id}`}
</td>
<td className="admin-mono">
{line.location?.code || "—"}
{item.location?.code || "—"}
</td>
<td className="admin-mono text-right">
{Number(line.system_qty)}
<td className="admin-mono">
{Number(item.system_qty)}
</td>
<td className="admin-mono text-right">
{Number(line.actual_qty)}
<td className="admin-mono">
{Number(item.actual_qty)}
</td>
<td
className="admin-mono text-right fw-500"
className="admin-mono fw-500"
style={{ color: diffColor }}
>
{diff > 0 ? `+${diff}` : diff}
</td>
<td>{line.notes || "—"}</td>
<td>{item.notes || "—"}</td>
</tr>
);
})}

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -8,27 +8,31 @@ import { motion } from "framer-motion";
import FormField from "../components/FormField";
import ItemPicker from "../components/warehouse/ItemPicker";
import LocationSelect from "../components/warehouse/LocationSelect";
import WarehouseMovementTable from "../components/warehouse/WarehouseMovementTable";
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
import type { WarehouseLocation } from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import { useApiMutation } from "../lib/queries/mutations";
interface InventoryLine {
interface InventoryItem {
key: string;
item_id: number | null;
location_id: number | null;
actual_qty: number;
}
let lineCounter = 0;
const defaultInventoryItem = () => ({
item_id: null,
location_id: null,
actual_qty: 0,
});
export default function WarehouseInventoryForm() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [lines, setLines] = useState<InventoryLine[]>([]);
const [items, setItems] = useState<InventoryItem[]>([]);
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
@@ -36,77 +40,51 @@ export default function WarehouseInventoryForm() {
if (!hasPermission("warehouse.inventory")) return <Forbidden />;
const addEmptyLine = () => {
setLines((prev) => [
...prev,
{
key: `line-${++lineCounter}`,
item_id: null,
location_id: null,
actual_qty: 0,
},
]);
};
// Start with one empty line
if (lines.length === 0) {
addEmptyLine();
}
const removeLine = (index: number) => {
setLines((prev) => prev.filter((_, i) => i !== index));
};
const updateLine = (index: number, field: string, value: unknown) => {
setLines((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const validate = (): boolean => {
const validLines = lines.filter((l) => l.item_id !== null);
if (validLines.length === 0) {
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
alert.error("Přidejte alespoň jednu položku");
return false;
}
return true;
};
const createMutation = useApiMutation<
{
notes: string | null;
items: {
item_id: number;
location_id: number | null;
actual_qty: number;
}[];
},
{ id?: number }
>({
url: () => "/api/admin/warehouse/inventory-sessions",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: (data) => {
alert.success("Inventura byla vytvořena");
navigate(`/warehouse/inventory/${data?.id}`);
},
});
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
try {
const payload = {
await createMutation.mutateAsync({
notes: notes.trim() || null,
lines: lines
items: items
.filter((l) => l.item_id !== null)
.map((l) => ({
item_id: l.item_id!,
location_id: l.location_id,
actual_qty: l.actual_qty,
})),
};
const response = await apiFetch(
"/api/admin/warehouse/inventory-sessions",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "inventory"] });
alert.success("Inventura byla vytvořena");
navigate(`/warehouse/inventory/${result.data?.id}`);
} else {
alert.error(result.error || "Nepodařilo se vytvořit inventuru");
}
} catch {
alert.error("Chyba připojení");
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
@@ -184,72 +162,77 @@ export default function WarehouseInventoryForm() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky inventury</h3>
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th>Lokace</th>
<th>Skutečné množství</th>
<th></th>
</tr>
</thead>
<tbody>
{lines.map((line, index) => (
<tr key={line.key}>
<td>
<ItemPicker
value={line.item_id}
onChange={(id) => updateLine(index, "item_id", id)}
/>
</td>
<td>
<LocationSelect
value={line.location_id}
onChange={(locId) =>
updateLine(index, "location_id", locId)
}
/>
</td>
<td>
<input
type="number"
className="admin-form-input"
value={line.actual_qty || ""}
onChange={(e) =>
updateLine(
index,
"actual_qty",
Number(e.target.value),
)
}
min="0"
step="0.001"
/>
</td>
<td>
<button
type="button"
className="admin-btn-danger-sm"
onClick={() => removeLine(index)}
<h3 className="admin-card-title">Položky</h3>
<WarehouseMovementTable<InventoryItem>
items={items}
onChange={setItems}
mode="inventory"
defaultItem={defaultInventoryItem}
renderHeader={() => (
<>
<th className="admin-warehouse-col-item">Položka</th>
<th className="admin-warehouse-col-location">Lokace</th>
<th className="admin-warehouse-col-qty">Skutečné množství</th>
<th>Akce</th>
</>
)}
renderRow={(item, _index, updateItem, removeItem) => (
<>
<td className="admin-warehouse-col-item">
<ItemPicker
value={item.item_id}
onChange={(id) => updateItem("item_id", id)}
/>
</td>
<td className="admin-warehouse-col-location">
<LocationSelect
value={item.location_id}
onChange={(locId) => updateItem("location_id", locId)}
/>
</td>
<td className="admin-warehouse-col-qty">
<input
type="number"
className="admin-form-input"
value={item.actual_qty || ""}
onChange={(e) =>
updateItem("actual_qty", Number(e.target.value))
}
min="0"
step="0.001"
inputMode="decimal"
pattern="[0-9]+([\.,][0-9]+)?"
aria-label={`Skutečné množství, řádek ${_index + 1}`}
/>
</td>
<td>
<div className="admin-table-actions">
<button
type="button"
className="admin-btn-icon danger"
onClick={removeItem}
title="Odebrat řádek"
aria-label="Odebrat řádek"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
&times;
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
className="admin-btn-secondary"
onClick={addEmptyLine}
style={{ marginTop: "0.75rem" }}
>
+ Přidat řádek
</button>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</td>
</>
)}
/>
</div>
</motion.div>
</div>

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -8,12 +8,12 @@ import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseIssueDetailOptions,
type WarehouseIssue,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
@@ -32,11 +32,8 @@ export default function WarehouseIssueDetail() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [cancelConfirm, setCancelConfirm] = useState(false);
const [confirming, setConfirming] = useState(false);
const [cancelling, setCancelling] = useState(false);
const {
data: issue,
@@ -48,51 +45,52 @@ export default function WarehouseIssueDetail() {
const canOperate = hasPermission("warehouse.operate");
const confirmMutation = useApiMutation<void, void>({
url: () =>
issue
? `/api/admin/warehouse/issues/${issue.id}/confirm`
: "/api/admin/warehouse/issues/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
const cancelMutation = useApiMutation<void, void>({
url: () =>
issue
? `/api/admin/warehouse/issues/${issue.id}/cancel`
: "/api/admin/warehouse/issues/0/cancel",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setCancelConfirm(false);
alert.success("Výdejka byla zrušena");
},
});
const handleConfirm = async () => {
if (!issue) return;
setConfirming(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/issues/${issue.id}/confirm`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] });
alert.success("Výdejka byla potvrzena");
} else {
alert.error(result.error || "Nepodařilo se potvrdit výdejku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setConfirming(false);
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleCancel = async () => {
if (!issue) return;
setCancelling(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/issues/${issue.id}/cancel`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] });
setCancelConfirm(false);
alert.success("Výdejka byla zrušena");
} else {
alert.error(result.error || "Nepodařilo se zrušit výdejku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCancelling(false);
await cancelMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending;
if (isPending) {
return (
<div className="admin-loading">
@@ -136,7 +134,7 @@ export default function WarehouseIssueDetail() {
}
const iss = issue as WarehouseIssue;
const lines = iss.lines ?? [];
const items = iss.items ?? [];
return (
<div>
@@ -231,28 +229,35 @@ export default function WarehouseIssueDetail() {
<div className="admin-card-body">
<h3 className="admin-card-title">Základní údaje</h3>
<div className="admin-form">
<div className="admin-form-row">
<div className="admin-form-row admin-form-row-3">
<FormField label="Číslo dokladu">
<div className="admin-mono" style={{ fontWeight: 500 }}>
<div className="admin-mono fw-500">
{iss.issue_number || "—"}
</div>
</FormField>
<FormField label="Projekt">
<div style={{ fontWeight: 500 }}>
{iss.project
? `${iss.project.project_number} ${iss.project.name}`
: "—"}
<div>
{iss.project ? (
<a
className="link-accent admin-mono fw-500"
href={`/projects/${iss.project.id}`}
>
{iss.project.project_number} {iss.project.name}
</a>
) : (
"—"
)}
</div>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Vydal">
<div>
<div className="admin-mono fw-500">
{iss.issued_by_user
? `${iss.issued_by_user.first_name} ${iss.issued_by_user.last_name}`
: "—"}
</div>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Vytvořeno">
<div className="admin-mono">{formatDate(iss.created_at)}</div>
</FormField>
@@ -274,18 +279,9 @@ export default function WarehouseIssueDetail() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky výdejky</h3>
{lines.length === 0 ? (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1.5rem 0",
}}
>
Žádné řádky
</div>
<h3 className="admin-card-title">Položky</h3>
{items.length === 0 ? (
<div className="admin-empty-state">Žádné řádky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
@@ -293,51 +289,49 @@ export default function WarehouseIssueDetail() {
<tr>
<th>Položka</th>
<th>Šarže</th>
<th className="text-right">Množství</th>
<th className="text-right">Cena/ks</th>
<th className="text-right">Celkem</th>
<th>Množství</th>
<th>Cena/ks</th>
<th>Celkem</th>
<th>Lokace</th>
<th>Rezervace</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id}>
{items.map((item) => (
<tr key={item.id}>
<td className="fw-500">
{line.item?.name || `ID: ${line.item_id}`}
{item.item?.name || `ID: ${item.item_id}`}
</td>
<td className="admin-mono">
{line.batch
? `${formatDate(line.batch.received_at)} ${formatCurrency(Number(line.batch.unit_price), "CZK")}`
{item.batch
? `${formatDate(item.batch.received_at)} ${formatCurrency(Number(item.batch.unit_price), "CZK")}`
: "—"}
</td>
<td className="admin-mono text-right">
{Number(line.quantity)}
</td>
<td className="admin-mono text-right">
{line.batch
? formatCurrency(Number(line.batch.unit_price), "CZK")
<td className="admin-mono">{Number(item.quantity)}</td>
<td className="admin-mono">
{item.batch
? formatCurrency(Number(item.batch.unit_price), "CZK")
: "—"}
</td>
<td className="admin-mono text-right fw-500">
{line.batch
<td className="admin-mono fw-500">
{item.batch
? formatCurrency(
Number(line.quantity) *
Number(line.batch.unit_price),
Number(item.quantity) *
Number(item.batch.unit_price),
"CZK",
)
: "—"}
</td>
<td className="admin-mono">
{line.location?.code || "—"}
{item.location?.code || "—"}
</td>
<td className="admin-mono">
{line.reservation
? `ID: ${line.reservation.id} (${Number(line.reservation.remaining_qty)} ks)`
{item.reservation
? `ID: ${item.reservation.id} (${Number(item.reservation.remaining_qty)} ks)`
: "—"}
</td>
<td>{line.notes || "—"}</td>
<td>{item.notes || "—"}</td>
</tr>
))}
</tbody>

View File

@@ -1,44 +1,80 @@
import { useState, useEffect, useRef } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect, useRef, useId } from "react";
import { useParams, useNavigate, useLocation, Link } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import WarehouseMovementTable, {
type MovementLine,
type MovementItem,
} from "../components/warehouse/WarehouseMovementTable";
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import {
warehouseIssueDetailOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { projectListOptions } from "../lib/queries/projects";
import { warehouseIssueDetailOptions } from "../lib/queries/warehouse";
import { projectListOptions, type Project } from "../lib/queries/projects";
import { useApiMutation } from "../lib/queries/mutations";
interface IssueForm {
project_id: number | null;
notes: string;
}
let lineCounter = 0;
export default function WarehouseIssueForm() {
const { id } = useParams();
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const location = useLocation();
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
// Pre-fill data from reservation ("Create výdej" button)
const prefill = location.state as {
reservationId?: number;
itemId?: number;
projectId?: number;
quantity?: number;
itemName?: string;
} | null;
const isEdit = id !== undefined && id !== "new";
const [form, setForm] = useState<IssueForm>({
project_id: null,
project_id: prefill?.projectId ?? null,
notes: "",
});
const [lines, setLines] = useState<MovementLine[]>([]);
const [items, setItems] = useState<MovementItem[]>(() =>
isEdit
? []
: prefill?.itemId
? [
{
key: nextKey(),
item_id: prefill.itemId,
item_name: prefill.itemName,
quantity: prefill.quantity ?? 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: prefill.reservationId ?? null,
notes: null,
},
]
: [
{
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
@@ -48,8 +84,6 @@ export default function WarehouseIssueForm() {
warehouseIssueDetailOptions(isEdit ? id : undefined),
);
const { data: locations = [] } = useQuery(warehouseLocationListOptions());
const { data: projectsData } = useQuery(projectListOptions({ perPage: 100 }));
const projects = projectsData?.data ?? [];
@@ -63,18 +97,18 @@ export default function WarehouseIssueForm() {
project_id: issue.project_id,
notes: issue.notes || "",
});
if (issue.lines && issue.lines.length > 0) {
setLines(
issue.lines.map((line) => ({
key: `line-${++lineCounter}`,
item_id: line.item_id,
item_name: line.item?.name,
quantity: Number(line.quantity),
unit_price: line.batch ? Number(line.batch.unit_price) : 0,
location_id: line.location_id,
batch_id: line.batch_id,
reservation_id: line.reservation_id,
notes: line.notes,
if (issue.items && issue.items.length > 0) {
setItems(
issue.items.map((item) => ({
key: nextKey(),
item_id: item.item_id,
item_name: item.item?.name,
quantity: Number(item.quantity),
unit_price: item.batch ? Number(item.batch.unit_price) : 0,
location_id: item.location_id,
batch_id: item.batch_id,
reservation_id: item.reservation_id,
notes: item.notes,
})),
);
}
@@ -84,11 +118,11 @@ export default function WarehouseIssueForm() {
if (!hasPermission("warehouse.operate")) return <Forbidden />;
const addEmptyLine = () => {
setLines((prev) => [
const addItem = () => {
setItems((prev) => [
...prev,
{
key: `line-${++lineCounter}`,
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
@@ -100,25 +134,18 @@ export default function WarehouseIssueForm() {
]);
};
// Start with one empty line in create mode
useEffect(() => {
if (!isEdit && lines.length === 0) {
addEmptyLine();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!form.project_id) {
newErrors.project_id = "Projekt je povinný";
}
const validLines = lines.filter((l) => l.item_id !== null);
if (validLines.length === 0) {
newErrors.lines = "Přidejte alespoň jednu položku";
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
newErrors.items = "Přidejte alespoň jednu položku";
}
for (const line of validLines) {
if (line.quantity <= 0) {
newErrors.lines = "Množství musí být větší než 0";
for (const item of validItems) {
if (item.quantity <= 0) {
newErrors.items = "Množství musí být větší než 0";
break;
}
}
@@ -129,7 +156,7 @@ export default function WarehouseIssueForm() {
const buildPayload = () => ({
project_id: form.project_id,
notes: form.notes.trim() || null,
lines: lines
items: items
.filter((l) => l.item_id !== null)
.map((l) => ({
item_id: l.item_id!,
@@ -141,47 +168,51 @@ export default function WarehouseIssueForm() {
})),
});
const saveIssue = async (): Promise<number | null> => {
const url = isEdit
? `/api/admin/warehouse/issues/${id}`
: "/api/admin/warehouse/issues";
const method = isEdit ? "PUT" : "POST";
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/issues/${id}`
: "/api/admin/warehouse/issues",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const [confirmIssueId, setConfirmIssueId] = useState<number | null>(null);
const confirmMutation = useApiMutation<void, void>({
url: () =>
confirmIssueId
? `/api/admin/warehouse/issues/${confirmIssueId}/confirm`
: "/api/admin/warehouse/issues/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Výdejka byla potvrzena");
},
});
const saveIssue = async (): Promise<number | null> => {
try {
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildPayload()),
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] });
return result.data?.id ?? Number(id);
} else {
alert.error(result.error || "Nepodařilo se uložit výdejku");
return null;
}
} catch {
alert.error("Chyba připojení");
const data = await saveMutation.mutateAsync(buildPayload());
return data?.id ?? Number(id);
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Nepodařilo se uložit výdejku",
);
return null;
}
};
const confirmIssue = async (issueId: number) => {
setConfirmIssueId(issueId);
try {
const response = await apiFetch(
`/api/admin/warehouse/issues/${issueId}/confirm`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "issues"] });
alert.success("Výdejka byla potvrzena");
} else {
alert.error(result.error || "Nepodařilo se potvrdit výdejku");
}
} catch {
alert.error("Chyba připojení");
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setConfirmIssueId(null);
}
};
@@ -297,22 +328,14 @@ export default function WarehouseIssueForm() {
className="admin-form-select"
>
<option value="">Vyberte projekt...</option>
{projects.map((p) => (
{projects.map((p: Project) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
{errors.project_id && (
<div
style={{
color: "var(--danger)",
fontSize: "0.875rem",
marginTop: "0.25rem",
}}
>
{errors.project_id}
</div>
<div className="admin-form-error">{errors.project_id}</div>
)}
</FormField>
<FormField label="Poznámky">
@@ -338,23 +361,19 @@ export default function WarehouseIssueForm() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky výdejky</h3>
{errors.lines && (
<h3 className="admin-card-title">Položky</h3>
{errors.items && (
<div
style={{
color: "var(--danger)",
fontSize: "0.875rem",
marginBottom: "0.75rem",
}}
className="admin-form-error"
style={{ marginBottom: "0.75rem" }}
>
{errors.lines}
{errors.items}
</div>
)}
<WarehouseMovementTable
lines={lines}
onChange={setLines}
items={items}
onChange={setItems}
mode="issue"
locations={locations as WarehouseLocation[]}
/>
</div>
</motion.div>

View File

@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import { motion, AnimatePresence } from "framer-motion";
import Pagination from "../components/Pagination";
import useDebounce from "../hooks/useDebounce";
@@ -14,7 +15,7 @@ import {
warehouseIssueListOptions,
type WarehouseIssue,
} from "../lib/queries/warehouse";
import { projectListOptions } from "../lib/queries/projects";
import { projectListOptions, type Project } from "../lib/queries/projects";
const PER_PAGE = 20;
@@ -171,7 +172,6 @@ export default function WarehouseIssues() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 140 }}
>
<option value="">Všechny stavy</option>
<option value="DRAFT">Návrh</option>
@@ -188,36 +188,31 @@ export default function WarehouseIssues() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 180 }}
>
<option value="">Všechny projekty</option>
{projects.map((p) => (
{projects.map((p: Project) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
)}
<input
type="date"
<AdminDatePicker
mode="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
onChange={(val) => {
setDateFrom(val);
setPage(1);
}}
className="admin-form-input"
style={{ width: 140 }}
placeholder="Od"
/>
<input
type="date"
<AdminDatePicker
mode="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
onChange={(val) => {
setDateTo(val);
setPage(1);
}}
className="admin-form-input"
style={{ width: 140 }}
placeholder="Do"
/>
{hasActiveFilters && (
@@ -284,7 +279,7 @@ export default function WarehouseIssues() {
<th>Stav</th>
<th>Vydal</th>
<th>Vytvořeno</th>
<th className="text-right">Řádků</th>
<th>Položek</th>
</tr>
</thead>
<tbody>
@@ -317,8 +312,8 @@ export default function WarehouseIssues() {
<td className="admin-mono">
{formatDate(issue.created_at)}
</td>
<td className="admin-mono text-right">
{issue.lines?.length ?? 0}
<td className="admin-mono">
{issue._count?.items ?? 0}
</td>
</tr>
))}

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -8,13 +8,13 @@ import { motion } from "framer-motion";
import FormField from "../components/FormField";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseItemDetailOptions,
warehouseCategoryListOptions,
type WarehouseItem,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
interface Batch {
id: number;
@@ -50,7 +50,6 @@ interface ItemForm {
unit: string;
min_quantity: string;
notes: string;
is_active: boolean;
}
export default function WarehouseItemDetail() {
@@ -58,10 +57,8 @@ export default function WarehouseItemDetail() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState<ItemForm>({
item_number: "",
name: "",
@@ -70,11 +67,11 @@ export default function WarehouseItemDetail() {
unit: "ks",
min_quantity: "",
notes: "",
is_active: true,
});
const [errors, setErrors] = useState<Record<string, string>>({});
const itemQuery = useQuery(warehouseItemDetailOptions(id));
const isNew = id === "new";
const itemQuery = useQuery(warehouseItemDetailOptions(id, !!id && !isNew));
const item = itemQuery.data as ItemDetail | undefined;
const isPending = itemQuery.isPending;
@@ -96,14 +93,13 @@ export default function WarehouseItemDetail() {
min_quantity:
item.min_quantity != null ? String(item.min_quantity) : "",
notes: item.notes || "",
is_active: item.is_active,
});
formInitialized.current = true;
}
}, [item]);
useEffect(() => {
if (itemQuery.error) {
if (itemQuery.error && !isNew) {
alert.error("Položka nenalezena");
navigate("/warehouse/items");
}
@@ -118,6 +114,32 @@ export default function WarehouseItemDetail() {
const updateForm = (field: keyof ItemForm, value: string | boolean) =>
setForm((prev) => ({ ...prev, [field]: value }));
const saveMutation = useApiMutation<
Record<string, unknown>,
{ id?: number; message?: string }
>({
url: () => (isNew ? API_BASE : `${API_BASE}/${id}`),
method: () => (isNew ? "POST" : "PUT"),
invalidate: ["warehouse"],
onSuccess: (data) => {
setEditing(false);
alert.success(data?.message || "Položka byla uložena");
if (isNew && data?.id) {
navigate(`/warehouse/items/${data.id}`, { replace: true });
}
},
});
const deleteMutation = useApiMutation<void, void>({
url: () => `${API_BASE}/${id ?? ""}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: () => {
navigate("/warehouse/items");
setTimeout(() => alert.success("Položka byla deaktivována"), 300);
},
});
const handleSave = async () => {
const newErrors: Record<string, string> = {};
if (!form.name.trim()) newErrors.name = "Zadejte název položky";
@@ -125,93 +147,35 @@ export default function WarehouseItemDetail() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
const payload: Record<string, unknown> = {
item_number: form.item_number.trim() || null,
name: form.name.trim(),
description: form.description.trim() || null,
category_id: form.category_id ? Number(form.category_id) : null,
unit: form.unit.trim(),
min_quantity: form.min_quantity ? Number(form.min_quantity) : null,
notes: form.notes.trim() || null,
};
try {
const isNew = id === "new";
const url = isNew ? API_BASE : `${API_BASE}/${id}`;
const method = isNew ? "POST" : "PUT";
const payload: Record<string, unknown> = {
item_number: form.item_number.trim() || null,
name: form.name.trim(),
description: form.description.trim() || null,
category_id: form.category_id ? Number(form.category_id) : null,
unit: form.unit.trim(),
min_quantity: form.min_quantity ? Number(form.min_quantity) : null,
notes: form.notes.trim() || null,
is_active: form.is_active,
};
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await response.json();
if (result.success) {
setEditing(false);
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message || "Položka byla uložena");
if (isNew && result.data?.id) {
navigate(`/warehouse/items/${result.data.id}`, { replace: true });
}
} else {
alert.error(result.error || "Nepodařilo se uložit položku");
}
} catch {
alert.error("Chyba připojení");
} finally {
setSaving(false);
await saveMutation.mutateAsync(payload);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!item) return;
try {
const response = await apiFetch(`${API_BASE}/${id}`, {
method: "DELETE",
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
navigate("/warehouse/items");
setTimeout(() => alert.success("Položka byla smazána"), 300);
} else {
alert.error(result.error || "Nepodařilo se smazat položku");
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleToggleActive = async () => {
if (!item) return;
try {
const response = await apiFetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !item.is_active }),
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(
item.is_active
? "Položka byla deaktivována"
: "Položka byla aktivována",
);
} else {
alert.error(result.error || "Nepodařilo se změnit stav");
}
} catch {
alert.error("Chyba připojení");
}
};
const saving = saveMutation.isPending;
if (isPending && id !== "new") {
if (isPending && !isNew) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
@@ -264,35 +228,36 @@ export default function WarehouseItemDetail() {
</div>
{canManage && (
<div className="admin-page-actions">
{editing ? (
{editing || isNew ? (
<>
<button
onClick={() => {
setEditing(false);
setErrors({});
if (item) {
setForm({
item_number: item.item_number || "",
name: item.name,
description: item.description || "",
category_id: item.category_id
? String(item.category_id)
: "",
unit: item.unit,
min_quantity:
item.min_quantity != null
? String(item.min_quantity)
{!isNew && (
<button
onClick={() => {
setEditing(false);
setErrors({});
if (item) {
setForm({
item_number: item.item_number || "",
name: item.name,
description: item.description || "",
category_id: item.category_id
? String(item.category_id)
: "",
notes: item.notes || "",
is_active: item.is_active,
});
}
}}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
unit: item.unit,
min_quantity:
item.min_quantity != null
? String(item.min_quantity)
: "",
notes: item.notes || "",
});
}
}}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
)}
<button
onClick={handleSave}
className="admin-btn admin-btn-primary"
@@ -303,6 +268,8 @@ export default function WarehouseItemDetail() {
<div className="admin-spinner admin-spinner-sm" />
Ukládání...
</>
) : isNew ? (
"Vytvořit"
) : (
"Uložit"
)}
@@ -310,21 +277,14 @@ export default function WarehouseItemDetail() {
</>
) : (
<>
{id !== "new" && item && (
<>
<button
onClick={handleToggleActive}
className="admin-btn admin-btn-secondary"
>
{item.is_active ? "Deaktivovat" : "Aktivovat"}
</button>
<button
onClick={handleDelete}
className="admin-btn admin-btn-secondary"
>
Smazat
</button>
</>
{item && (
<button
onClick={handleDelete}
className="admin-btn admin-btn-secondary"
style={{ color: "var(--danger)" }}
>
Smazat
</button>
)}
<button
onClick={() => setEditing(true)}
@@ -484,19 +444,6 @@ export default function WarehouseItemDetail() {
</div>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Stav">
{item?.is_active ? (
<span className="admin-badge admin-badge-active">
Aktivní
</span>
) : (
<span className="admin-badge admin-badge-inactive">
Neaktivní
</span>
)}
</FormField>
</div>
{item?.notes && (
<FormField label="Poznámky">
<div style={{ whiteSpace: "pre-wrap" }}>{item.notes}</div>
@@ -563,26 +510,17 @@ export default function WarehouseItemDetail() {
<div className="admin-card-body">
<h3 className="admin-card-title">Dávky (FIFO)</h3>
{batches.length === 0 ? (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1.5rem 0",
}}
>
Zatím žádné dávky
</div>
<div className="admin-empty-state">Zatím žádné dávky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Datum příjmu</th>
<th className="text-right">Původní množství</th>
<th className="text-right">Zůstatek</th>
<th className="text-right">Cena za jednotku</th>
<th className="text-right">Hodnota</th>
<th>Původní množství</th>
<th>Zůstatek</th>
<th>Cena za jednotku</th>
<th>Hodnota</th>
</tr>
</thead>
<tbody>
@@ -591,16 +529,16 @@ export default function WarehouseItemDetail() {
<td className="admin-mono">
{formatDate(batch.received_at)}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">
{Number(batch.original_qty)}
</td>
<td className="admin-mono text-right fw-500">
<td className="admin-mono fw-500">
{Number(batch.quantity)}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">
{formatCurrency(Number(batch.unit_price), "CZK")}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">
{formatCurrency(
Number(batch.quantity) * Number(batch.unit_price),
"CZK",
@@ -627,16 +565,7 @@ export default function WarehouseItemDetail() {
<div className="admin-card-body">
<h3 className="admin-card-title">Umístění</h3>
{locations.length === 0 ? (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1.5rem 0",
}}
>
Zatím žádná umístění
</div>
<div className="admin-empty-state">Zatím žádná umístění</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
@@ -644,7 +573,7 @@ export default function WarehouseItemDetail() {
<tr>
<th>Kód</th>
<th>Název</th>
<th className="text-right">Množství</th>
<th>Množství</th>
</tr>
</thead>
<tbody>
@@ -654,7 +583,7 @@ export default function WarehouseItemDetail() {
{loc.location?.code || "—"}
</td>
<td>{loc.location?.name || "—"}</td>
<td className="admin-mono text-right fw-500">
<td className="admin-mono fw-500">
{Number(loc.quantity)}
</td>
</tr>

View File

@@ -11,7 +11,7 @@ import useTableSort from "../hooks/useTableSort";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import { formatCurrency } from "../utils/formatters";
import { formatCurrency, czechPlural } from "../utils/formatters";
import {
warehouseItemListOptions,
warehouseCategoryListOptions,
@@ -74,13 +74,12 @@ export default function WarehouseItems() {
<h1 className="admin-page-title">Skladové položky</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? items.length}{" "}
{pagination?.total === 1
? "položka"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
? "položky"
: "položek"}
{czechPlural(
pagination?.total ?? items.length,
"položka",
"položky",
"položek",
)}
</p>
</div>
<div className="admin-page-actions">
@@ -150,7 +149,6 @@ export default function WarehouseItems() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 180 }}
>
<option value="">Všechny kategorie</option>
{categories.map((cat) => (
@@ -235,7 +233,6 @@ export default function WarehouseItems() {
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("total_quantity")}
className="text-right"
>
Množství{" "}
<SortIcon
@@ -244,11 +241,10 @@ export default function WarehouseItems() {
order={order}
/>
</th>
<th className="text-right">K dispozici</th>
<th>K dispozici</th>
<th
style={{ cursor: "pointer" }}
onClick={() => handleSort("stock_value")}
className="text-right"
>
Hodnota{" "}
<SortIcon
@@ -276,13 +272,13 @@ export default function WarehouseItems() {
<td className="fw-500">{item.name}</td>
<td>{item.category?.name || "—"}</td>
<td>{item.unit}</td>
<td className="admin-mono text-right">
<td className="admin-mono">
{item.total_quantity ?? 0}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">
{item.available_quantity ?? 0}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">
{item.stock_value != null
? formatCurrency(item.stock_value, "CZK")
: "0,00 Kč"}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -7,12 +7,12 @@ import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin/warehouse/locations";
@@ -25,10 +25,9 @@ interface LocationForm {
export default function WarehouseLocations() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: locations = [], isPending } = useQuery(
warehouseLocationListOptions(),
warehouseLocationListOptions({ active: "all" }),
);
const [showModal, setShowModal] = useState(false);
@@ -46,6 +45,57 @@ export default function WarehouseLocations() {
location: WarehouseLocation | null;
}>({ show: false, location: null });
// useApiMutation JSON.stringifies the whole TIn as the request body, so
// TIn must match the backend schema (CreateLocationSchema / UpdateLocationSchema)
// directly — NOT a wrapper that nests the body. id is captured in the URL closure.
const createLocationMutation = useApiMutation<
LocationForm,
{ message?: string; error?: string }
>({
url: () => API_BASE,
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Umístění bylo vytvořeno");
},
});
const updateLocationMutation = useApiMutation<
{ id: number; code?: string; name?: string; description?: string },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Umístění bylo aktualizováno");
},
});
const deleteMutation = useApiMutation<
{ id: number },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: (data) => {
setDeleteConfirm({ show: false, location: null });
alert.success(data?.message || "Umístění bylo smazáno");
},
});
const toggleMutation = useApiMutation<
{ id: number; is_active: boolean },
{ message?: string; error?: string }
>({
url: ({ id }) => `${API_BASE}/${id}`,
method: () => "PUT",
invalidate: ["warehouse"],
});
useModalLock(showModal);
if (!hasPermission("warehouse.manage")) return <Forbidden />;
@@ -80,81 +130,47 @@ export default function WarehouseLocations() {
if (Object.keys(newErrors).length > 0) return;
try {
const url = editingLocation
? `${API_BASE}/${editingLocation.id}`
: API_BASE;
const method = editingLocation ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({ queryKey: ["warehouse", "locations"] });
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
if (editingLocation) {
await updateLocationMutation.mutateAsync({
id: editingLocation.id,
code: form.code,
name: form.name,
description: form.description,
});
} else {
alert.error(result.error);
await createLocationMutation.mutateAsync({
code: form.code,
name: form.name,
description: form.description,
});
}
} catch {
alert.error("Chyba připojení");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.location) return;
try {
const response = await apiFetch(
`${API_BASE}/${deleteConfirm.location.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeleteConfirm({ show: false, location: null });
queryClient.invalidateQueries({ queryKey: ["warehouse", "locations"] });
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync({ id: deleteConfirm.location.id });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const toggleActive = async (location: WarehouseLocation) => {
try {
const response = await apiFetch(`${API_BASE}/${location.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !location.is_active }),
await toggleMutation.mutateAsync({
id: location.id,
is_active: !location.is_active,
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "locations"] });
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(
location.is_active
? "Umístění bylo deaktivováno"
: "Umístění bylo aktivováno",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(
location.is_active
? "Umístění bylo deaktivováno"
: "Umístění bylo aktivováno",
);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
@@ -436,6 +452,7 @@ export default function WarehouseLocations() {
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</div>
);

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
@@ -8,12 +8,12 @@ import { motion } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormField from "../components/FormField";
import apiFetch from "../utils/api";
import { formatCurrency, formatDate } from "../utils/formatters";
import {
warehouseReceiptDetailOptions,
type WarehouseReceipt,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const STATUS_BADGE: Record<string, string> = {
DRAFT: "admin-badge-warning",
@@ -38,11 +38,8 @@ export default function WarehouseReceiptDetail() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [cancelConfirm, setCancelConfirm] = useState(false);
const [confirming, setConfirming] = useState(false);
const [cancelling, setCancelling] = useState(false);
const {
data: receipt,
@@ -54,70 +51,76 @@ export default function WarehouseReceiptDetail() {
const canOperate = hasPermission("warehouse.operate");
const confirmMutation = useApiMutation<void, void>({
url: () =>
receipt
? `/api/admin/warehouse/receipts/${receipt.id}/confirm`
: "/api/admin/warehouse/receipts/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Doklad byl potvrzen");
},
});
const cancelMutation = useApiMutation<void, void>({
url: () =>
receipt
? `/api/admin/warehouse/receipts/${receipt.id}/cancel`
: "/api/admin/warehouse/receipts/0/cancel",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
setCancelConfirm(false);
alert.success("Doklad byl zrušen");
},
});
const deleteAttachmentMutation = useApiMutation<
{ attachmentId: number },
void
>({
url: ({ attachmentId }) =>
receipt
? `/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`
: `/api/admin/warehouse/receipts/0/attachments/0`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Příloha byla smazána");
},
});
const handleConfirm = async () => {
if (!receipt) return;
setConfirming(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/receipts/${receipt.id}/confirm`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] });
alert.success("Doklad byl potvrzen");
} else {
alert.error(result.error || "Nepodařilo se potvrdit doklad");
}
} catch {
alert.error("Chyba připojení");
} finally {
setConfirming(false);
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleCancel = async () => {
if (!receipt) return;
setCancelling(true);
try {
const response = await apiFetch(
`/api/admin/warehouse/receipts/${receipt.id}/cancel`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] });
setCancelConfirm(false);
alert.success("Doklad byl zrušen");
} else {
alert.error(result.error || "Nepodařilo se zrušit doklad");
}
} catch {
alert.error("Chyba připojení");
} finally {
setCancelling(false);
await cancelMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDeleteAttachment = async (attachmentId: number) => {
if (!receipt) return;
try {
const response = await apiFetch(
`/api/admin/warehouse/receipts/${receipt.id}/attachments/${attachmentId}`,
{ method: "DELETE" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] });
alert.success("Příloha byla smazána");
} else {
alert.error(result.error || "Nepodařilo se smazat přílohu");
}
} catch {
alert.error("Chyba připojení");
await deleteAttachmentMutation.mutateAsync({ attachmentId });
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const confirming = confirmMutation.isPending;
const cancelling = cancelMutation.isPending;
if (isPending) {
return (
<div className="admin-loading">
@@ -161,7 +164,7 @@ export default function WarehouseReceiptDetail() {
}
const r = receipt as WarehouseReceipt;
const lines = r.lines ?? [];
const items = r.items ?? [];
const attachments = r.attachments ?? [];
return (
@@ -257,12 +260,14 @@ export default function WarehouseReceiptDetail() {
<div className="admin-form">
<div className="admin-form-row">
<FormField label="Číslo dokladu">
<div className="admin-mono" style={{ fontWeight: 500 }}>
<div className="admin-mono fw-500">
{r.receipt_number || "—"}
</div>
</FormField>
<FormField label="Dodavatel">
<div style={{ fontWeight: 500 }}>{r.supplier?.name || "—"}</div>
<div className="admin-mono fw-500">
{r.supplier?.name || "—"}
</div>
</FormField>
</div>
<div className="admin-form-row">
@@ -279,7 +284,7 @@ export default function WarehouseReceiptDetail() {
</div>
<div className="admin-form-row">
<FormField label="Přijal">
<div>
<div className="admin-mono fw-500">
{r.received_by_user
? `${r.received_by_user.first_name} ${r.received_by_user.last_name}`
: "—"}
@@ -306,53 +311,42 @@ export default function WarehouseReceiptDetail() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky dokladu</h3>
{lines.length === 0 ? (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1.5rem 0",
}}
>
Žádné řádky
</div>
<h3 className="admin-card-title">Položky</h3>
{items.length === 0 ? (
<div className="admin-empty-state">Žádné řádky</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Položka</th>
<th className="text-right">Množství</th>
<th className="text-right">Cena/ks</th>
<th className="text-right">Celkem</th>
<th>Množství</th>
<th>Cena/ks</th>
<th>Celkem</th>
<th>Lokace</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{lines.map((line) => (
<tr key={line.id}>
{items.map((item) => (
<tr key={item.id}>
<td className="fw-500">
{line.item?.name || `ID: ${line.item_id}`}
{item.item?.name || `ID: ${item.item_id}`}
</td>
<td className="admin-mono text-right">
{Number(line.quantity)}
<td className="admin-mono">{Number(item.quantity)}</td>
<td className="admin-mono">
{formatCurrency(Number(item.unit_price), "CZK")}
</td>
<td className="admin-mono text-right">
{formatCurrency(Number(line.unit_price), "CZK")}
</td>
<td className="admin-mono text-right fw-500">
<td className="admin-mono fw-500">
{formatCurrency(
Number(line.quantity) * Number(line.unit_price),
Number(item.quantity) * Number(item.unit_price),
"CZK",
)}
</td>
<td className="admin-mono">
{line.location?.code || "—"}
{item.location?.code || "—"}
</td>
<td>{line.notes || "—"}</td>
<td>{item.notes || "—"}</td>
</tr>
))}
</tbody>
@@ -372,16 +366,7 @@ export default function WarehouseReceiptDetail() {
<div className="admin-card-body">
<h3 className="admin-card-title">Přílohy</h3>
{attachments.length === 0 ? (
<div
style={{
color: "var(--text-tertiary)",
fontSize: "0.875rem",
textAlign: "center",
padding: "1.5rem 0",
}}
>
Žádné přílohy
</div>
<div className="admin-empty-state">Žádné přílohy</div>
) : (
<div className="admin-table-responsive">
<table className="admin-table">
@@ -390,7 +375,7 @@ export default function WarehouseReceiptDetail() {
<th>Název souboru</th>
<th>Velikost</th>
<th>Datum</th>
<th></th>
<th>Akce</th>
</tr>
</thead>
<tbody>
@@ -412,28 +397,30 @@ export default function WarehouseReceiptDetail() {
{formatDate(att.created_at)}
</td>
<td>
{r.status === "DRAFT" && canOperate && (
<button
onClick={() => handleDeleteAttachment(att.id)}
className="admin-btn-icon danger"
title="Smazat přílohu"
aria-label="Smazat přílohu"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<div className="admin-table-actions">
{r.status === "DRAFT" && canOperate && (
<button
onClick={() => handleDeleteAttachment(att.id)}
className="admin-btn-icon danger"
title="Smazat přílohu"
aria-label="Smazat přílohu"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
)}
</div>
</td>
</tr>
))}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useId } from "react";
import { useParams, useNavigate, Link } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
@@ -6,17 +6,15 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import SupplierSelect from "../components/warehouse/SupplierSelect";
import WarehouseMovementTable, {
type MovementLine,
type MovementItem,
} from "../components/warehouse/WarehouseMovementTable";
import { warehouseLocationListOptions } from "../lib/queries/warehouse";
import apiFetch from "../utils/api";
import {
warehouseReceiptDetailOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { warehouseReceiptDetailOptions } from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
interface ReceiptForm {
supplier_id: number | null;
@@ -25,8 +23,6 @@ interface ReceiptForm {
notes: string;
}
let lineCounter = 0;
export default function WarehouseReceiptForm() {
const { id } = useParams();
const alert = useAlert();
@@ -36,13 +32,32 @@ export default function WarehouseReceiptForm() {
const isEdit = id !== undefined && id !== "new";
const baseId = useId();
const counterRef = useRef(0);
const nextKey = () => `${baseId}-item-${counterRef.current++}`;
const [form, setForm] = useState<ReceiptForm>({
supplier_id: null,
delivery_note_number: "",
delivery_note_date: "",
notes: "",
});
const [lines, setLines] = useState<MovementLine[]>([]);
const [items, setItems] = useState<MovementItem[]>(() =>
isEdit
? []
: [
{
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
location_id: null,
batch_id: null,
reservation_id: null,
notes: null,
},
],
);
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const [uploadingFiles, setUploadingFiles] = useState(false);
@@ -53,8 +68,6 @@ export default function WarehouseReceiptForm() {
warehouseReceiptDetailOptions(isEdit ? id : undefined),
);
const { data: locations = [] } = useQuery(warehouseLocationListOptions());
useEffect(() => {
formInitialized.current = false;
}, [id]);
@@ -69,18 +82,18 @@ export default function WarehouseReceiptForm() {
: "",
notes: receipt.notes || "",
});
if (receipt.lines && receipt.lines.length > 0) {
setLines(
receipt.lines.map((line) => ({
key: `line-${++lineCounter}`,
item_id: line.item_id,
item_name: line.item?.name,
quantity: Number(line.quantity),
unit_price: Number(line.unit_price),
location_id: line.location_id,
if (receipt.items && receipt.items.length > 0) {
setItems(
receipt.items.map((item) => ({
key: nextKey(),
item_id: item.item_id,
item_name: item.item?.name,
quantity: Number(item.quantity),
unit_price: Number(item.unit_price),
location_id: item.location_id,
batch_id: null,
reservation_id: null,
notes: line.notes,
notes: item.notes,
})),
);
}
@@ -90,11 +103,11 @@ export default function WarehouseReceiptForm() {
if (!hasPermission("warehouse.operate")) return <Forbidden />;
const addEmptyLine = () => {
setLines((prev) => [
const addItem = () => {
setItems((prev) => [
...prev,
{
key: `line-${++lineCounter}`,
key: nextKey(),
item_id: null,
quantity: 0,
unit_price: 0,
@@ -106,22 +119,15 @@ export default function WarehouseReceiptForm() {
]);
};
// Start with one empty line in create mode
useEffect(() => {
if (!isEdit && lines.length === 0) {
addEmptyLine();
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
const validLines = lines.filter((l) => l.item_id !== null);
if (validLines.length === 0) {
newErrors.lines = "Přidejte alespoň jednu položku";
const validItems = items.filter((l) => l.item_id !== null);
if (validItems.length === 0) {
newErrors.items = "Přidejte alespoň jednu položku";
}
for (const line of validLines) {
if (line.quantity <= 0) {
newErrors.lines = "Množství musí být větší než 0";
for (const item of validItems) {
if (item.quantity <= 0) {
newErrors.items = "Množství musí být větší než 0";
break;
}
}
@@ -134,7 +140,7 @@ export default function WarehouseReceiptForm() {
delivery_note_number: form.delivery_note_number.trim() || null,
delivery_note_date: form.delivery_note_date || null,
notes: form.notes.trim() || null,
lines: lines
items: items
.filter((l) => l.item_id !== null)
.map((l) => ({
item_id: l.item_id!,
@@ -145,47 +151,51 @@ export default function WarehouseReceiptForm() {
})),
});
const saveReceipt = async (): Promise<number | null> => {
const url = isEdit
? `/api/admin/warehouse/receipts/${id}`
: "/api/admin/warehouse/receipts";
const method = isEdit ? "PUT" : "POST";
const saveMutation = useApiMutation<
ReturnType<typeof buildPayload>,
{ id?: number }
>({
url: () =>
isEdit
? `/api/admin/warehouse/receipts/${id}`
: "/api/admin/warehouse/receipts",
method: () => (isEdit ? "PUT" : "POST"),
invalidate: ["warehouse"],
});
const [confirmReceiptId, setConfirmReceiptId] = useState<number | null>(null);
const confirmMutation = useApiMutation<void, void>({
url: () =>
confirmReceiptId
? `/api/admin/warehouse/receipts/${confirmReceiptId}/confirm`
: "/api/admin/warehouse/receipts/0/confirm",
method: () => "POST",
invalidate: ["warehouse"],
onSuccess: () => {
alert.success("Doklad byl potvrzen");
},
});
const saveReceipt = async (): Promise<number | null> => {
try {
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(buildPayload()),
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] });
return result.data?.id ?? Number(id);
} else {
alert.error(result.error || "Nepodařilo se uložit doklad");
return null;
}
} catch {
alert.error("Chyba připojení");
const data = await saveMutation.mutateAsync(buildPayload());
return data?.id ?? Number(id);
} catch (e) {
alert.error(
e instanceof Error ? e.message : "Nepodařilo se uložit doklad",
);
return null;
}
};
const confirmReceipt = async (receiptId: number) => {
setConfirmReceiptId(receiptId);
try {
const response = await apiFetch(
`/api/admin/warehouse/receipts/${receiptId}/confirm`,
{ method: "POST" },
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] });
alert.success("Doklad byl potvrzen");
} else {
alert.error(result.error || "Nepodařilo se potvrdit doklad");
}
} catch {
alert.error("Chyba připojení");
await confirmMutation.mutateAsync(undefined);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setConfirmReceiptId(null);
}
};
@@ -239,7 +249,7 @@ export default function WarehouseReceiptForm() {
);
}
}
queryClient.invalidateQueries({ queryKey: ["warehouse", "receipts"] });
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success("Soubory byly nahrány");
} catch {
alert.error("Chyba připojení");
@@ -359,16 +369,16 @@ export default function WarehouseReceiptForm() {
/>
</FormField>
<FormField label="Datum dodacího listu">
<input
type="date"
<AdminDatePicker
mode="date"
value={form.delivery_note_date}
onChange={(e) =>
onChange={(val) =>
setForm((prev) => ({
...prev,
delivery_note_date: e.target.value,
delivery_note_date: val,
}))
}
className="admin-form-input"
placeholder="dd.mm.yyyy"
/>
</FormField>
</div>
@@ -395,23 +405,19 @@ export default function WarehouseReceiptForm() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<h3 className="admin-card-title">Řádky dokladu</h3>
{errors.lines && (
<h3 className="admin-card-title">Položky</h3>
{errors.items && (
<div
style={{
color: "var(--danger)",
fontSize: "0.875rem",
marginBottom: "0.75rem",
}}
className="admin-form-error"
style={{ marginBottom: "0.75rem" }}
>
{errors.lines}
{errors.items}
</div>
)}
<WarehouseMovementTable
lines={lines}
onChange={setLines}
items={items}
onChange={setItems}
mode="receipt"
locations={locations as WarehouseLocation[]}
/>
</div>
</motion.div>
@@ -427,18 +433,10 @@ export default function WarehouseReceiptForm() {
<div className="admin-card-body">
<h3 className="admin-card-title">Přílohy</h3>
<div
className="admin-file-dropzone"
className="admin-warehouse-upload"
onDrop={handleDrop}
onDragOver={handleDragOver}
style={{
border: "2px dashed var(--border)",
borderRadius: "8px",
padding: "2rem",
textAlign: "center",
cursor: "pointer",
opacity: uploadingFiles ? 0.6 : 1,
transition: "opacity 0.2s",
}}
style={{ opacity: uploadingFiles ? 0.6 : 1 }}
onClick={() => {
const input = document.createElement("input");
input.type = "file";
@@ -467,25 +465,16 @@ export default function WarehouseReceiptForm() {
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{
color: "var(--text-tertiary)",
margin: "0 auto 0.5rem",
}}
className="admin-warehouse-upload-icon"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<p style={{ color: "var(--text-secondary)" }}>
<p className="admin-warehouse-upload-text">
Přetáhněte soubory sem nebo klikněte pro výběr
</p>
<p
style={{
color: "var(--text-tertiary)",
fontSize: "0.8rem",
marginTop: "0.25rem",
}}
>
<p className="admin-warehouse-upload-hint">
Dodací listy, faktury a další dokumenty
</p>
</>

View File

@@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import AdminDatePicker from "../components/AdminDatePicker";
import { motion, AnimatePresence } from "framer-motion";
import Pagination from "../components/Pagination";
import useDebounce from "../hooks/useDebounce";
@@ -173,7 +174,6 @@ export default function WarehouseReceipts() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 140 }}
>
<option value="">Všechny stavy</option>
<option value="DRAFT">Návrh</option>
@@ -190,7 +190,6 @@ export default function WarehouseReceipts() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 180 }}
>
<option value="">Všichni dodavatelé</option>
{suppliers.map((s) => (
@@ -200,26 +199,22 @@ export default function WarehouseReceipts() {
))}
</select>
)}
<input
type="date"
<AdminDatePicker
mode="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
onChange={(val) => {
setDateFrom(val);
setPage(1);
}}
className="admin-form-input"
style={{ width: 140 }}
placeholder="Od"
/>
<input
type="date"
<AdminDatePicker
mode="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
onChange={(val) => {
setDateTo(val);
setPage(1);
}}
className="admin-form-input"
style={{ width: 140 }}
placeholder="Do"
/>
{hasActiveFilters && (
@@ -282,11 +277,11 @@ export default function WarehouseReceipts() {
<thead>
<tr>
<th>Číslo dokladu</th>
<th>Dodavatel</th>
<th >Dodavatel</th>
<th>Stav</th>
<th>Dodací list</th>
<th>Vytvořeno</th>
<th className="text-right">Řádků</th>
<th>Položek</th>
</tr>
</thead>
<tbody>
@@ -299,7 +294,9 @@ export default function WarehouseReceipts() {
<td className="admin-mono fw-500">
{receipt.receipt_number || "—"}
</td>
<td>{receipt.supplier?.name || "—"}</td>
<td >
{receipt.supplier?.name || "—"}
</td>
<td>
<span
className={`admin-badge ${STATUS_BADGE[receipt.status] ?? ""}`}
@@ -313,8 +310,8 @@ export default function WarehouseReceipts() {
<td className="admin-mono">
{formatDate(receipt.created_at)}
</td>
<td className="admin-mono text-right">
{receipt.lines?.length ?? 0}
<td className="admin-mono">
{receipt._count?.items ?? 0}
</td>
</tr>
))}

View File

@@ -4,6 +4,7 @@ import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import FormField from "../components/FormField";
import AdminDatePicker from "../components/AdminDatePicker";
import { projectListOptions } from "../lib/queries/projects";
import {
warehouseStockStatusOptions,
@@ -44,6 +45,12 @@ const TABS: { id: TabId; label: string }[] = [
{ id: "below-minimum", label: "Pod minimem" },
];
const MOVEMENT_BADGE: Record<string, string> = {
receipt: "admin-badge-incoming",
issue: "admin-badge-outgoing",
inventory: "admin-badge-info",
};
export default function WarehouseReports() {
const { hasPermission } = useAuth();
const [activeTab, setActiveTab] = useState<TabId>("stock-status");
@@ -157,7 +164,6 @@ function StockStatusTab({
setCategoryId(e.target.value === "" ? "" : Number(e.target.value))
}
className="admin-form-select"
style={{ minWidth: 180 }}
>
<option value="">Všechny kategorie</option>
{categories.map((cat) => (
@@ -179,10 +185,10 @@ function StockStatusTab({
<thead>
<tr>
<th>Název</th>
<th className="text-right">Celkem</th>
<th className="text-right">K dispozici</th>
<th className="text-right">Hodnota</th>
<th className="text-right">Min. množství</th>
<th>Celkem</th>
<th>K dispozici</th>
<th>Hodnota</th>
<th>Min. množství</th>
<th>Stav</th>
</tr>
</thead>
@@ -197,20 +203,14 @@ function StockStatusTab({
{(items as WarehouseItem[] | undefined)?.map((item) => (
<tr key={item.id}>
<td className="fw-500">{item.name}</td>
<td className="admin-mono text-right">
{item.total_quantity ?? 0}
</td>
<td className="admin-mono text-right">
{item.available_quantity ?? 0}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">{item.total_quantity ?? 0}</td>
<td className="admin-mono">{item.available_quantity ?? 0}</td>
<td className="admin-mono">
{item.stock_value != null
? formatCurrency(item.stock_value, "CZK")
: "0,00 Kč"}
</td>
<td className="admin-mono text-right">
{item.min_quantity ?? "—"}
</td>
<td className="admin-mono">{item.min_quantity ?? "—"}</td>
<td>
{item.below_minimum ? (
<span className="admin-badge admin-badge-danger">
@@ -294,7 +294,6 @@ function ProjectConsumptionTab({
setProjectId(e.target.value === "" ? "" : Number(e.target.value))
}
className="admin-form-select"
style={{ minWidth: 180 }}
>
<option value="">Všechny projekty</option>
{projects.map((p) => (
@@ -304,19 +303,17 @@ function ProjectConsumptionTab({
))}
</select>
)}
<input
type="date"
<AdminDatePicker
mode="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="admin-form-input"
style={{ width: 140 }}
onChange={setDateFrom}
placeholder="Od"
/>
<input
type="date"
<AdminDatePicker
mode="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="admin-form-input"
style={{ width: 140 }}
onChange={setDateTo}
placeholder="Do"
/>
<button
type="button"
@@ -347,8 +344,8 @@ function ProjectConsumptionTab({
<tr>
<th>Položka</th>
<th>Projekt</th>
<th className="text-right">Množství</th>
<th className="text-right">Hodnota</th>
<th>Množství</th>
<th>Hodnota</th>
</tr>
</thead>
<tbody>
@@ -363,8 +360,8 @@ function ProjectConsumptionTab({
<tr key={i}>
<td className="fw-500">{row.item_name}</td>
<td>{row.project_name}</td>
<td className="admin-mono text-right">{row.total_qty}</td>
<td className="admin-mono text-right">
<td className="admin-mono">{row.total_qty}</td>
<td className="admin-mono">
{formatCurrency(row.total_value, "CZK")}
</td>
</tr>
@@ -435,25 +432,22 @@ function MovementLogTab({
value={type}
onChange={(e) => setType(e.target.value)}
className="admin-form-select"
style={{ minWidth: 140 }}
>
<option value="">Všechny typy</option>
<option value="receipt">Příjem</option>
<option value="issue">Výdej</option>
</select>
<input
type="date"
<AdminDatePicker
mode="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="admin-form-input"
style={{ width: 140 }}
onChange={setDateFrom}
placeholder="Od"
/>
<input
type="date"
<AdminDatePicker
mode="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="admin-form-input"
style={{ width: 140 }}
onChange={setDateTo}
placeholder="Do"
/>
<button
type="button"
@@ -486,8 +480,8 @@ function MovementLogTab({
<th>Typ</th>
<th>Doklad</th>
<th>Položka</th>
<th className="text-right">Množství</th>
<th className="text-right">Cena/ks</th>
<th>Množství</th>
<th>Cena/ks</th>
<th>Dodavatel / Projekt</th>
</tr>
</thead>
@@ -504,15 +498,15 @@ function MovementLogTab({
<td className="admin-mono">{row.date}</td>
<td>
<span
className={`admin-badge ${row.type === "receipt" ? "admin-badge-active" : "admin-badge-info"}`}
className={`admin-badge ${MOVEMENT_BADGE[row.type] ?? "admin-badge-info"}`}
>
{TYPE_LABEL[row.type] ?? row.type}
</span>
</td>
<td className="admin-mono">{row.document_number || "—"}</td>
<td className="fw-500">{row.item_name}</td>
<td className="admin-mono text-right">{row.quantity}</td>
<td className="admin-mono text-right">
<td className="admin-mono">{row.quantity}</td>
<td className="admin-mono">
{formatCurrency(row.unit_price, "CZK")}
</td>
<td>{row.supplier_or_project || "—"}</td>
@@ -545,9 +539,9 @@ function BelowMinimumTab() {
<thead>
<tr>
<th>Název</th>
<th className="text-right">Celkem</th>
<th className="text-right">K dispozici</th>
<th className="text-right">Min. množství</th>
<th>Celkem</th>
<th>K dispozici</th>
<th>Min. množství</th>
</tr>
</thead>
<tbody>
@@ -559,18 +553,11 @@ function BelowMinimumTab() {
</tr>
)}
{(items as WarehouseItem[] | undefined)?.map((item) => (
<tr key={item.id} style={{ background: "var(--danger-light)" }}>
<tr key={item.id} className="admin-warehouse-row-danger">
<td className="fw-500">{item.name}</td>
<td className="admin-mono text-right">
{item.total_quantity ?? 0}
</td>
<td className="admin-mono text-right">
{item.available_quantity ?? 0}
</td>
<td
className="admin-mono text-right fw-500"
style={{ color: "var(--danger)" }}
>
<td className="admin-mono">{item.total_quantity ?? 0}</td>
<td className="admin-mono">{item.available_quantity ?? 0}</td>
<td className="admin-mono fw-500 admin-warehouse-danger-value">
{item.min_quantity ?? 0}
</td>
</tr>

View File

@@ -1,4 +1,5 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
@@ -6,13 +7,14 @@ import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import Pagination from "../components/Pagination";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import FormField from "../components/FormField";
import ItemPicker from "../components/warehouse/ItemPicker";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import apiFetch from "../utils/api";
import { formatDate } from "../utils/formatters";
import { formatDate, czechPlural } from "../utils/formatters";
import {
warehouseReservationListOptions,
type WarehouseReservation,
@@ -41,6 +43,7 @@ interface ReservationForm {
}
export default function WarehouseReservations() {
const navigate = useNavigate();
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
@@ -112,9 +115,7 @@ export default function WarehouseReservations() {
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({
queryKey: ["warehouse", "reservations"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
setShowCreateModal(false);
setForm({ item_id: null, project_id: null, quantity: 0, notes: "" });
alert.success("Rezervace byla vytvořena");
@@ -138,9 +139,7 @@ export default function WarehouseReservations() {
);
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({
queryKey: ["warehouse", "reservations"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
setCancelTarget(null);
alert.success("Rezervace byla zrušena");
} else {
@@ -173,13 +172,12 @@ export default function WarehouseReservations() {
<h1 className="admin-page-title">Rezervace</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? items.length}{" "}
{pagination?.total === 1
? "rezervace"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
? "rezervace"
: "rezervací"}
{czechPlural(
pagination?.total ?? items.length,
"rezervace",
"rezervace",
"rezervací",
)}
</p>
</div>
<div className="admin-page-actions">
@@ -221,7 +219,6 @@ export default function WarehouseReservations() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 140 }}
>
<option value="">Všechny stavy</option>
<option value="ACTIVE">Aktivní</option>
@@ -238,7 +235,6 @@ export default function WarehouseReservations() {
setPage(1);
}}
className="admin-form-select"
style={{ minWidth: 180 }}
>
<option value="">Všechny projekty</option>
{projects.map((p) => (
@@ -309,12 +305,12 @@ export default function WarehouseReservations() {
<tr>
<th>Položka</th>
<th>Projekt</th>
<th className="text-right">Množství</th>
<th className="text-right">Zbývá</th>
<th>Množství</th>
<th>Zbývá</th>
<th>Stav</th>
<th>Rezervoval</th>
<th>Vytvořeno</th>
{canOperate && <th></th>}
{canOperate && <th>Akce</th>}
</tr>
</thead>
<tbody>
@@ -324,10 +320,8 @@ export default function WarehouseReservations() {
{res.item?.name || `ID: ${res.item_id}`}
</td>
<td>{res.project?.name || "—"}</td>
<td className="admin-mono text-right">
{Number(res.quantity)}
</td>
<td className="admin-mono text-right">
<td className="admin-mono">{Number(res.quantity)}</td>
<td className="admin-mono">
{Number(res.remaining_qty)}
</td>
<td>
@@ -347,29 +341,63 @@ export default function WarehouseReservations() {
</td>
{canOperate && (
<td>
{res.status === "ACTIVE" && (
<button
onClick={() => setCancelTarget(res)}
className="admin-btn-icon danger"
title="Zrušit rezervaci"
aria-label="Zrušit rezervaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<div className="admin-table-actions">
{res.status === "ACTIVE" && (
<button
onClick={() =>
navigate("/warehouse/issues/new", {
state: {
reservationId: res.id,
itemId: res.item_id,
projectId: res.project_id,
quantity: Number(res.remaining_qty),
itemName: res.item?.name,
},
})
}
className="admin-btn-icon"
title="Vytvořit výdej"
aria-label="Vytvořit výdej"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</button>
)}
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="7 13 12 18 17 13" />
<line x1="12" y1="18" x2="12" y2="6" />
</svg>
</button>
)}
{res.status === "ACTIVE" && (
<button
onClick={() => setCancelTarget(res)}
className="admin-btn-icon danger"
title="Zrušit rezervaci"
aria-label="Zrušit rezervaci"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</button>
)}
</div>
</td>
)}
</tr>
@@ -386,120 +414,68 @@ export default function WarehouseReservations() {
</motion.div>
{/* Create reservation modal */}
<AnimatePresence>
{showCreateModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowCreateModal(false)}
<FormModal
isOpen={showCreateModal}
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
title="Nová rezervace"
submitLabel="Vytvořit rezervaci"
loading={saving}
>
<div className="admin-form">
<FormField label="Položka" required>
<ItemPicker
value={form.item_id}
onChange={(id) => setForm((prev) => ({ ...prev, item_id: id }))}
/>
<motion.div
className="admin-modal"
role="dialog"
aria-modal="true"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
</FormField>
<FormField label="Projekt" required>
<select
value={form.project_id ?? ""}
onChange={(e) =>
setForm((prev) => ({
...prev,
project_id: e.target.value ? Number(e.target.value) : null,
}))
}
className="admin-form-select"
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Nová rezervace</h2>
<button
type="button"
className="admin-modal-close"
onClick={() => setShowCreateModal(false)}
aria-label="Zavřít"
>
&times;
</button>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Položka" required>
<ItemPicker
value={form.item_id}
onChange={(id) =>
setForm((prev) => ({ ...prev, item_id: id }))
}
/>
</FormField>
<FormField label="Projekt" required>
<select
value={form.project_id ?? ""}
onChange={(e) =>
setForm((prev) => ({
...prev,
project_id: e.target.value
? Number(e.target.value)
: null,
}))
}
className="admin-form-select"
>
<option value="">Vyberte projekt</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
</FormField>
<FormField label="Množství" required>
<input
type="number"
value={form.quantity || ""}
onChange={(e) =>
setForm((prev) => ({
...prev,
quantity: Number(e.target.value),
}))
}
className="admin-form-input"
min="0"
step="0.001"
/>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="admin-btn admin-btn-secondary"
disabled={saving}
>
Zrušit
</button>
<button
type="button"
onClick={handleCreate}
className="admin-btn admin-btn-primary"
disabled={saving}
>
{saving ? "Ukládání..." : "Vytvořit rezervaci"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<option value="">Vyberte projekt</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
</FormField>
<FormField label="Množství" required>
<input
type="number"
value={form.quantity || ""}
onChange={(e) =>
setForm((prev) => ({
...prev,
quantity: Number(e.target.value),
}))
}
className="admin-form-input"
min="0"
step="0.001"
/>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm((prev) => ({ ...prev, notes: e.target.value }))
}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</FormModal>
{/* Cancel confirmation modal */}
<ConfirmModal

View File

@@ -1,20 +1,20 @@
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion, AnimatePresence } from "framer-motion";
import ConfirmModal from "../components/ConfirmModal";
import FormModal from "../components/FormModal";
import Pagination from "../components/Pagination";
import useModalLock from "../hooks/useModalLock";
import useDebounce from "../hooks/useDebounce";
import { usePaginatedQuery } from "../hooks/usePaginatedQuery";
import apiFetch from "../utils/api";
import FormField from "../components/FormField";
import {
warehouseSupplierListOptions,
type WarehouseSupplier,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
const API_BASE = "/api/admin/warehouse/suppliers";
@@ -34,13 +34,17 @@ const PER_PAGE = 20;
export default function WarehouseSuppliers() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data: suppliersData, isPending } = useQuery(
const {
items: suppliers,
pagination,
isPending,
isFetching,
} = usePaginatedQuery<WarehouseSupplier>(
warehouseSupplierListOptions({
search: debouncedSearch || undefined,
page,
@@ -48,9 +52,6 @@ export default function WarehouseSuppliers() {
}),
);
const suppliers = suppliersData?.data ?? [];
const pagination = suppliersData?.pagination ?? null;
const [showModal, setShowModal] = useState(false);
const [editingSupplier, setEditingSupplier] =
useState<WarehouseSupplier | null>(null);
@@ -70,8 +71,46 @@ export default function WarehouseSuppliers() {
show: boolean;
supplier: WarehouseSupplier | null;
}>({ show: false, supplier: null });
const [saving, setSaving] = useState(false);
const [toggleActiveSupplierId, setToggleActiveSupplierId] = useState<
number | null
>(null);
useModalLock(showModal);
const submitMutation = useApiMutation<
SupplierForm,
{ id?: number; message?: string }
>({
url: () =>
editingSupplier ? `${API_BASE}/${editingSupplier.id}` : API_BASE,
method: () => (editingSupplier ? "PUT" : "POST"),
invalidate: ["warehouse"],
onSuccess: (data) => {
setShowModal(false);
alert.success(data?.message || "Dodavatel byl uložen");
},
});
const deleteMutation = useApiMutation<number, { message?: string }>({
url: (id) => `${API_BASE}/${id}`,
method: () => "DELETE",
invalidate: ["warehouse"],
onSuccess: (data) => {
setDeactivateConfirm({ show: false, supplier: null });
alert.success(data?.message || "Dodavatel byl smazán");
},
});
const toggleActiveMutation = useApiMutation<
{ is_active: boolean },
{ message?: string }
>({
url: () => {
if (!toggleActiveSupplierId) return API_BASE;
return `${API_BASE}/${toggleActiveSupplierId}`;
},
method: () => "PUT",
invalidate: ["warehouse"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;
@@ -113,32 +152,13 @@ export default function WarehouseSuppliers() {
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
setSaving(true);
try {
const url = editingSupplier
? `${API_BASE}/${editingSupplier.id}`
: API_BASE;
const method = editingSupplier ? "PUT" : "POST";
const response = await apiFetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const result = await response.json();
if (result.success) {
setShowModal(false);
queryClient.invalidateQueries({
queryKey: ["warehouse", "suppliers"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await submitMutation.mutateAsync(form);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSaving(false);
}
};
@@ -146,55 +166,27 @@ export default function WarehouseSuppliers() {
if (!deactivateConfirm.supplier) return;
try {
const response = await apiFetch(
`${API_BASE}/${deactivateConfirm.supplier.id}`,
{
method: "DELETE",
},
);
const result = await response.json();
if (result.success) {
setDeactivateConfirm({ show: false, supplier: null });
queryClient.invalidateQueries({
queryKey: ["warehouse", "suppliers"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
await deleteMutation.mutateAsync(deactivateConfirm.supplier.id);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const toggleActive = async (supplier: WarehouseSupplier) => {
setToggleActiveSupplierId(supplier.id);
try {
const response = await apiFetch(`${API_BASE}/${supplier.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ is_active: !supplier.is_active }),
await toggleActiveMutation.mutateAsync({
is_active: !supplier.is_active,
});
const result = await response.json();
if (result.success) {
queryClient.invalidateQueries({
queryKey: ["warehouse", "suppliers"],
});
queryClient.invalidateQueries({ queryKey: ["warehouse"] });
alert.success(
supplier.is_active
? "Dodavatel byl deaktivován"
: "Dodavatel byl aktivován",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
alert.success(
supplier.is_active
? "Dodavatel byl deaktivován"
: "Dodavatel byl aktivován",
);
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setToggleActiveSupplierId(null);
}
};
@@ -216,33 +208,18 @@ export default function WarehouseSuppliers() {
>
<div>
<h1 className="admin-page-title">Dodavatelé</h1>
<p className="admin-page-subtitle">
{pagination?.total ?? suppliers.length}{" "}
{pagination?.total === 1
? "dodavatel"
: pagination?.total !== undefined &&
pagination.total >= 2 &&
pagination.total <= 4
? "dodavatelé"
: "dodavatelů"}
</p>
</div>
<div className="admin-page-actions">
<div className="admin-search">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat dodavatele..."
className="admin-form-input"
/>
</div>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
@@ -268,302 +245,290 @@ export default function WarehouseSuppliers() {
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
style={{ opacity: isFetching ? 0.6 : 1, transition: "opacity 0.2s" }}
>
<div className="admin-card-body">
{suppliers.length === 0 && !search && (
<div className="admin-empty-state">
<div className="admin-empty-icon">
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<p>Zatím nejsou žádní dodavatelé.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
<div className="admin-search-bar mb-4">
<div className="admin-search">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
Přidat prvního dodavatele
</button>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
placeholder="Hledat dodavatele..."
className="admin-form-input"
/>
</div>
)}
{suppliers.length === 0 && search && (
<div className="admin-empty-state">
<p>Žádní dodavatelé pro &quot;{search}&quot;.</p>
</div>
)}
{suppliers.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>IČO</th>
<th>DIČ</th>
<th>Kontaktní osoba</th>
<th>E-mail</th>
<th>Telefon</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{suppliers.map((supplier) => (
<tr
key={supplier.id}
className={
!supplier.is_active ? "admin-table-row-inactive" : ""
}
</div>
<AnimatePresence mode="wait">
<motion.div
key={`${debouncedSearch}-${page}-${suppliers.length}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{suppliers.length === 0 && !search && (
<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"
>
<td className="fw-500">{supplier.name}</td>
<td className="admin-mono">{supplier.ico || "—"}</td>
<td className="admin-mono">{supplier.dic || "—"}</td>
<td>{supplier.contact_person || "—"}</td>
<td>{supplier.email || "—"}</td>
<td className="admin-mono">{supplier.phone || "—"}</td>
<td>
<button
onClick={() => toggleActive(supplier)}
className={`admin-badge ${supplier.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
</div>
<p>Zatím nejsou žádní dodavatelé.</p>
<button
onClick={openCreateModal}
className="admin-btn admin-btn-primary"
>
Přidat prvního dodavatele
</button>
</div>
)}
{suppliers.length === 0 && search && (
<div className="admin-empty-state">
<p>Žádní dodavatelé pro &quot;{search}&quot;.</p>
</div>
)}
{suppliers.length > 0 && (
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Název</th>
<th>IČO</th>
<th>DIČ</th>
<th>Kontaktní osoba</th>
<th>E-mail</th>
<th>Telefon</th>
<th>Stav</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{suppliers.map((supplier) => (
<tr
key={supplier.id}
className={
!supplier.is_active
? "admin-table-row-inactive"
: ""
}
>
{supplier.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(supplier)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
<td className="fw-500">{supplier.name}</td>
<td className="admin-mono">{supplier.ico || "—"}</td>
<td className="admin-mono">{supplier.dic || "—"}</td>
<td>{supplier.contact_person || "—"}</td>
<td>{supplier.email || "—"}</td>
<td className="admin-mono">
{supplier.phone || "—"}
</td>
<td>
<button
onClick={() => toggleActive(supplier)}
className={`admin-badge ${supplier.is_active ? "admin-badge-active" : "admin-badge-inactive"}`}
>
<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={() =>
setDeactivateConfirm({
show: true,
supplier,
})
}
className={`admin-btn-icon ${supplier.is_active ? "danger" : ""}`}
title={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
aria-label={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{supplier.is_active ? "Aktivní" : "Neaktivní"}
</button>
</td>
<td>
<div className="admin-table-actions">
<button
onClick={() => openEditModal(supplier)}
className="admin-btn-icon"
title="Upravit"
aria-label="Upravit"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() =>
setDeactivateConfirm({
show: true,
supplier,
})
}
className={`admin-btn-icon ${supplier.is_active ? "danger" : ""}`}
title={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
aria-label={
supplier.is_active ? "Deaktivovat" : "Smazat"
}
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</motion.div>
</AnimatePresence>
<Pagination pagination={pagination} onPageChange={setPage} />
</div>
</motion.div>
{pagination && (
<Pagination pagination={pagination} onPageChange={setPage} />
)}
{/* Add/Edit Modal */}
<AnimatePresence>
{showModal && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setShowModal(false)}
<FormModal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
loading={saving}
>
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název dodavatele"
aria-invalid={!!errors.name}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">
{editingSupplier ? "Upravit dodavatele" : "Přidat dodavatele"}
</h2>
</div>
</FormField>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Název" error={errors.name} required>
<input
type="text"
value={form.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
className="admin-form-input"
placeholder="Název dodavatele"
aria-invalid={!!errors.name}
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.ico}
onChange={(e) => setForm({ ...form, ico: e.target.value })}
className="admin-form-input"
placeholder="12345678"
/>
</FormField>
<div className="admin-form-row">
<FormField label="IČO">
<input
type="text"
value={form.ico}
onChange={(e) =>
setForm({ ...form, ico: e.target.value })
}
className="admin-form-input"
placeholder="12345678"
/>
</FormField>
<FormField label="DIČ">
<input
type="text"
value={form.dic}
onChange={(e) => setForm({ ...form, dic: e.target.value })}
className="admin-form-input"
placeholder="CZ12345678"
/>
</FormField>
</div>
<FormField label="DIČ">
<input
type="text"
value={form.dic}
onChange={(e) =>
setForm({ ...form, dic: e.target.value })
}
className="admin-form-input"
placeholder="CZ12345678"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Kontaktní osoba">
<input
type="text"
value={form.contact_person}
onChange={(e) =>
setForm({
...form,
contact_person: e.target.value,
})
}
className="admin-form-input"
placeholder="Jan Novák"
/>
</FormField>
<div className="admin-form-row">
<FormField label="Kontaktní osoba">
<input
type="text"
value={form.contact_person}
onChange={(e) =>
setForm({
...form,
contact_person: e.target.value,
})
}
className="admin-form-input"
placeholder="Jan Novák"
/>
</FormField>
<FormField label="E-mail">
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="admin-form-input"
placeholder="info@firma.cz"
/>
</FormField>
</div>
<FormField label="E-mail">
<input
type="email"
value={form.email}
onChange={(e) =>
setForm({ ...form, email: e.target.value })
}
className="admin-form-input"
placeholder="info@firma.cz"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Telefon">
<input
type="tel"
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
className="admin-form-input"
placeholder="+420 123 456 789"
/>
</FormField>
</div>
<div className="admin-form-row">
<FormField label="Telefon">
<input
type="tel"
value={form.phone}
onChange={(e) =>
setForm({ ...form, phone: e.target.value })
}
className="admin-form-input"
placeholder="+420 123 456 789"
/>
</FormField>
</div>
<FormField label="Adresa">
<textarea
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
className="admin-form-input"
rows={3}
placeholder="Ulice, město, PSČ"
/>
</FormField>
<FormField label="Adresa">
<textarea
value={form.address}
onChange={(e) =>
setForm({ ...form, address: e.target.value })
}
className="admin-form-input"
rows={3}
placeholder="Ulice, město, PSČ"
/>
</FormField>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) =>
setForm({ ...form, notes: e.target.value })
}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setShowModal(false)}
className="admin-btn admin-btn-secondary"
>
Zrušit
</button>
<button
type="button"
onClick={handleSubmit}
className="admin-btn admin-btn-primary"
>
Uložit
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<FormField label="Poznámky">
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
className="admin-form-input"
rows={3}
placeholder="Volitelné poznámky"
/>
</FormField>
</div>
</FormModal>
{/* Deactivate/Delete Confirmation */}
<ConfirmModal