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:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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" : ""}`}
|
||||
>
|
||||
← 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
{deleteConfirm.order?.order_number}"? Bude smazán i
|
||||
přidružený projekt. Tato akce je nevratná.
|
||||
<label
|
||||
className="admin-form-checkbox"
|
||||
style={{ marginTop: "1rem", display: "flex" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
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 "
|
||||
{deleteConfirm.order?.order_number}"? Bude smazán i přidružený
|
||||
projekt. Tato akce je nevratná.
|
||||
<label
|
||||
className="admin-form-checkbox"
|
||||
style={{ marginTop: "1rem", display: "flex" }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={deleteFiles}
|
||||
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
@@ -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
@@ -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 má 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 má 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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 účtů a oprávnění
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="admin-btn admin-btn-primary"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<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 účtů 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 →
|
||||
</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 →
|
||||
</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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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č"}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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
|
||||
|
||||
@@ -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 "{search}".</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 "{search}".</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
|
||||
|
||||
Reference in New Issue
Block a user