Critical (data integrity):
- warehouse inventory confirm: throw (not return) inside $transaction so a
failed deficit line rolls back the surplus corrective receipt — retries
no longer accumulate phantom stock
- warehouse issue confirm: validate batches against the COMBINED quantity
of all lines (duplicate FIFO-resolved lines drove batches negative)
- attendance delete: restore vacation_used/sick_used for the deleted day
(in-transaction, clamped at 0)
High:
- auth refresh: terminated sessions (replaced_at only) get a plain 401 —
the theft branch (family revocation) now fires only on replaced_by_hash
- POST /users strips role_id for non-admin callers (mirrors PUT guard)
- issued-order transition flushes unsaved edits via the full save payload
when dirty; server contract (items+status in one PUT) pinned
- received-invoices list: usePaginatedQuery + pager (rows 26+ unreachable)
- received-invoice dates: nullableIsoDateString + NaN guard before NAS save
(Czech-format dates corrupted month/year, orphaned NAS files)
- leave approval skips Czech public holidays and books each calendar year's
hours against its own balance (mirrors createLeave)
Medium/Low (classes):
- 52 Zod caps aligned to DB column widths across 7 schemas (over-cap input
500ed at Prisma instead of a Czech 400)
- FK pre-validation: projects update + warehouse receipts/issues return
Czech 400s instead of P2003 500s
- invoice PDF degrades gracefully when the CNB rate is unavailable
(recap omitted instead of 500 + lost NAS archival)
- date boundaries: local-day filters (warehouse lists/reports, audit-log),
@db.Date coercion on invoice dates
- plan updateEntry re-checks the per-cell cap (self-excluding)
- {id} tiebreaks on customers/received-invoices/warehouse-items sorts;
/items honors the client sort param
- htmlToPdf relaunches once when the shared browser died mid-render
- offer number release parses the year from the document number (cross-year
finalize+delete left permanent sequence gaps)
- trips/vehicles km fields integer-coerced; AI budget regated to
settings.company|settings.system; Settings System tab no longer clobbers
Firma numbering patterns; draft invoices hide the dead PDF button;
dashboard quick-trip invalidates ["vehicles"]; TOTP secret cap 64;
audit-log + invoice month buckets day-shift fixes
Docs: corrected the stale "Chromium has no CSS margin-box footers" claim
(html-to-pdf.ts + CLAUDE.md — margin boxes render since Chrome 131); audit
report M3 withdrawn accordingly.
~65 new pinning tests; every finding reproduced RED against the real test
DB before its fix. Suite: 58 files / 634 tests green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
import { useState } from "react";
|
|
import { Link as RouterLink } from "react-router-dom";
|
|
import { motion, useReducedMotion } from "framer-motion";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import Box from "@mui/material/Box";
|
|
import { useAuth } from "../../context/AuthContext";
|
|
import { useAlert } from "../../context/AlertContext";
|
|
import { formatKm, todayLocalStr } from "../../utils/formatters";
|
|
import { Button, Modal, Field, Select, DateField, TextField } from "../../ui";
|
|
import apiFetch from "../../utils/api";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
interface Vehicle {
|
|
id: number | string;
|
|
spz: string;
|
|
name: string;
|
|
}
|
|
|
|
// Standard { success, data, error, message } envelope returned by the API.
|
|
interface ApiResult<T> {
|
|
success: boolean;
|
|
data?: T;
|
|
error?: string;
|
|
message?: string;
|
|
}
|
|
|
|
interface TripForm {
|
|
vehicle_id: string;
|
|
trip_date: string;
|
|
start_km: string;
|
|
end_km: string;
|
|
route_from: string;
|
|
route_to: string;
|
|
is_business: number;
|
|
notes: string;
|
|
}
|
|
|
|
interface TripErrors {
|
|
vehicle_id?: string;
|
|
trip_date?: string;
|
|
start_km?: string;
|
|
end_km?: string;
|
|
route_from?: string;
|
|
route_to?: string;
|
|
}
|
|
|
|
interface DashQuickActionsProps {
|
|
dashData: {
|
|
my_shift?: {
|
|
has_ongoing: boolean;
|
|
};
|
|
} | null;
|
|
punching: boolean;
|
|
onPunch: () => void;
|
|
}
|
|
|
|
// Maps the legacy quick-action color tokens to the MUI Button palette color.
|
|
const ACTION_COLOR: Record<string, "success" | "error" | "info" | "warning"> = {
|
|
success: "success",
|
|
danger: "error",
|
|
info: "info",
|
|
warning: "warning",
|
|
};
|
|
|
|
export default function DashQuickActions({
|
|
dashData,
|
|
punching,
|
|
onPunch,
|
|
}: DashQuickActionsProps) {
|
|
const { hasPermission } = useAuth();
|
|
const alert = useAlert();
|
|
const queryClient = useQueryClient();
|
|
const reduce = useReducedMotion();
|
|
|
|
const [showTripModal, setShowTripModal] = useState(false);
|
|
const [tripSubmitting, setTripSubmitting] = useState(false);
|
|
const [tripVehicles, setTripVehicles] = useState<Vehicle[]>([]);
|
|
const [tripForm, setTripForm] = useState<TripForm>({
|
|
vehicle_id: "",
|
|
trip_date: "",
|
|
start_km: "",
|
|
end_km: "",
|
|
route_from: "",
|
|
route_to: "",
|
|
is_business: 1,
|
|
notes: "",
|
|
});
|
|
const [tripErrors, setTripErrors] = useState<TripErrors>({});
|
|
|
|
const openTripModal = async () => {
|
|
setTripForm({
|
|
vehicle_id: "",
|
|
trip_date: todayLocalStr(),
|
|
start_km: "",
|
|
end_km: "",
|
|
route_from: "",
|
|
route_to: "",
|
|
is_business: 1,
|
|
notes: "",
|
|
});
|
|
setTripErrors({});
|
|
setShowTripModal(true);
|
|
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/vehicles`);
|
|
const result: ApiResult<Vehicle[] | { vehicles?: Vehicle[] }> =
|
|
await response.json();
|
|
if (result.success) {
|
|
setTripVehicles(
|
|
Array.isArray(result.data)
|
|
? result.data
|
|
: (result.data?.vehicles ?? []),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error("DashQuickActions: nepodařilo se načíst vozidla", e);
|
|
}
|
|
};
|
|
|
|
const handleTripVehicleChange = async (vehicleId: string) => {
|
|
setTripForm((prev) => ({ ...prev, vehicle_id: vehicleId }));
|
|
if (!vehicleId) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/trips/last-km/${vehicleId}`);
|
|
const result: ApiResult<{ last_km: number | string }> =
|
|
await response.json();
|
|
if (result.success && result.data) {
|
|
setTripForm((prev) => ({
|
|
...prev,
|
|
start_km: String(result.data!.last_km ?? ""),
|
|
}));
|
|
}
|
|
} catch (e) {
|
|
console.error("DashQuickActions: nepodařilo se načíst poslední km", e);
|
|
}
|
|
};
|
|
|
|
const handleTripSubmit = async () => {
|
|
const errs: TripErrors = {};
|
|
if (!tripForm.vehicle_id) {
|
|
errs.vehicle_id = "Vyberte vozidlo";
|
|
}
|
|
if (!tripForm.trip_date) {
|
|
errs.trip_date = "Zadejte datum";
|
|
}
|
|
if (!tripForm.start_km) {
|
|
errs.start_km = "Zadejte počáteční km";
|
|
}
|
|
if (!tripForm.end_km) {
|
|
errs.end_km = "Zadejte konečný km";
|
|
}
|
|
if (
|
|
tripForm.start_km &&
|
|
tripForm.end_km &&
|
|
parseInt(tripForm.end_km, 10) <= parseInt(tripForm.start_km, 10)
|
|
) {
|
|
errs.end_km = "Musí být větší než počáteční";
|
|
}
|
|
if (!tripForm.route_from) {
|
|
errs.route_from = "Zadejte místo odjezdu";
|
|
}
|
|
if (!tripForm.route_to) {
|
|
errs.route_to = "Zadejte místo příjezdu";
|
|
}
|
|
setTripErrors(errs);
|
|
if (Object.keys(errs).length > 0) {
|
|
return;
|
|
}
|
|
|
|
setTripSubmitting(true);
|
|
try {
|
|
const response = await apiFetch(`${API_BASE}/trips`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(tripForm),
|
|
});
|
|
const result: ApiResult<unknown> = await response.json();
|
|
if (result.success) {
|
|
// A new trip changes the trip list, the dashboard vehicle widgets AND
|
|
// the vehicles domain (actual_km moves) — invalidate all three broad
|
|
// domains (prefix-matching covers sub-queries).
|
|
queryClient.invalidateQueries({ queryKey: ["trips"] });
|
|
queryClient.invalidateQueries({ queryKey: ["dashboard"] });
|
|
queryClient.invalidateQueries({ queryKey: ["vehicles"] });
|
|
setShowTripModal(false);
|
|
alert.success(result.message ?? "Jízda uložena");
|
|
} else {
|
|
alert.error(result.error ?? "Uložení jízdy selhalo");
|
|
}
|
|
} catch (e) {
|
|
console.error("DashQuickActions: uložení jízdy selhalo", e);
|
|
alert.error("Chyba připojení");
|
|
} finally {
|
|
setTripSubmitting(false);
|
|
}
|
|
};
|
|
|
|
const tripDistance = (): number => {
|
|
const s = parseInt(tripForm.start_km, 10) || 0;
|
|
const e = parseInt(tripForm.end_km, 10) || 0;
|
|
return e > s ? e - s : 0;
|
|
};
|
|
|
|
const hasOngoingShift = dashData?.my_shift?.has_ongoing;
|
|
const punchLabel = hasOngoingShift
|
|
? "Zaznamenat odchod"
|
|
: "Zaznamenat příchod";
|
|
const quickActions: Array<{
|
|
label: string;
|
|
color: string;
|
|
icon: React.ReactNode;
|
|
onClick?: () => void;
|
|
path?: string;
|
|
disabled?: boolean;
|
|
}> = [];
|
|
|
|
if (hasPermission("attendance.record")) {
|
|
quickActions.push({
|
|
label: punching ? "Odesílám..." : punchLabel,
|
|
color: hasOngoingShift ? "danger" : "success",
|
|
icon: hasOngoingShift ? (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
|
<polyline points="16 17 21 12 16 7" />
|
|
<line x1="21" y1="12" x2="9" y2="12" />
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M9 12l2 2 4-4" />
|
|
<circle cx="12" cy="12" r="10" />
|
|
</svg>
|
|
),
|
|
onClick: onPunch,
|
|
disabled: punching,
|
|
});
|
|
}
|
|
if (hasPermission("offers.create")) {
|
|
quickActions.push({
|
|
label: "Nová nabídka",
|
|
path: "/offers/new",
|
|
color: "info",
|
|
icon: (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
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" />
|
|
</svg>
|
|
),
|
|
});
|
|
}
|
|
if (hasPermission("trips.record")) {
|
|
quickActions.push({
|
|
label: "Přidat jízdu",
|
|
color: "warning",
|
|
icon: (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<rect x="1" y="3" width="15" height="13" rx="2" />
|
|
<circle cx="8.5" cy="16" r="2.5" />
|
|
<circle cx="18.5" cy="16" r="2.5" />
|
|
<path d="M16 8h4l3 5v3h-7" />
|
|
</svg>
|
|
),
|
|
onClick: openTripModal,
|
|
});
|
|
}
|
|
if (hasPermission("invoices.create")) {
|
|
quickActions.push({
|
|
label: "Vystavit fakturu",
|
|
path: "/invoices/new",
|
|
color: "danger",
|
|
icon: (
|
|
<svg
|
|
width="16"
|
|
height="16"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
|
</svg>
|
|
),
|
|
});
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Box
|
|
component={motion.div}
|
|
initial={reduce ? false : { opacity: 0, y: 12 }}
|
|
animate={reduce ? undefined : { opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.08 }}
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: {
|
|
xs: "1fr",
|
|
sm: "repeat(2, 1fr)",
|
|
md: "repeat(4, 1fr)",
|
|
},
|
|
gap: 2,
|
|
mb: 3,
|
|
}}
|
|
>
|
|
{quickActions.map((action) => {
|
|
const color = ACTION_COLOR[action.color] ?? "primary";
|
|
const common = {
|
|
variant: "contained" as const,
|
|
color,
|
|
startIcon: action.icon,
|
|
sx: { py: 1.5, justifyContent: "flex-start" },
|
|
};
|
|
return action.onClick ? (
|
|
<Button
|
|
key={action.label}
|
|
onClick={action.onClick}
|
|
disabled={action.disabled}
|
|
{...common}
|
|
>
|
|
{action.label}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
key={action.label}
|
|
component={RouterLink}
|
|
to={action.path!}
|
|
{...common}
|
|
>
|
|
{action.label}
|
|
</Button>
|
|
);
|
|
})}
|
|
</Box>
|
|
|
|
<Modal
|
|
isOpen={showTripModal}
|
|
onClose={() => setShowTripModal(false)}
|
|
title="Přidat jízdu"
|
|
maxWidth="md"
|
|
onSubmit={handleTripSubmit}
|
|
submitText="Uložit"
|
|
loading={tripSubmitting}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<Field label="Vozidlo" required error={tripErrors.vehicle_id}>
|
|
<Select
|
|
value={tripForm.vehicle_id}
|
|
onChange={(val) => {
|
|
handleTripVehicleChange(val);
|
|
setTripErrors((prev) => ({ ...prev, vehicle_id: undefined }));
|
|
}}
|
|
options={[
|
|
{ value: "", label: "Vyberte vozidlo" },
|
|
...tripVehicles.map((v) => ({
|
|
value: String(v.id),
|
|
label: `${v.spz} - ${v.name}`,
|
|
})),
|
|
]}
|
|
/>
|
|
</Field>
|
|
<Field label="Datum jízdy" required error={tripErrors.trip_date}>
|
|
<DateField
|
|
value={tripForm.trip_date}
|
|
onChange={(val) => {
|
|
setTripForm((prev) => ({ ...prev, trip_date: val }));
|
|
setTripErrors((prev) => ({ ...prev, trip_date: undefined }));
|
|
}}
|
|
/>
|
|
</Field>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", sm: "repeat(3, 1fr)" },
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<Field label="Počáteční stav km" required error={tripErrors.start_km}>
|
|
<TextField
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={tripForm.start_km}
|
|
onChange={(e) => {
|
|
setTripForm((prev) => ({ ...prev, start_km: e.target.value }));
|
|
setTripErrors((prev) => ({ ...prev, start_km: undefined }));
|
|
}}
|
|
slotProps={{ htmlInput: { min: 0 } }}
|
|
/>
|
|
</Field>
|
|
<Field label="Konečný stav km" required error={tripErrors.end_km}>
|
|
<TextField
|
|
type="number"
|
|
inputMode="numeric"
|
|
value={tripForm.end_km}
|
|
onChange={(e) => {
|
|
setTripForm((prev) => ({ ...prev, end_km: e.target.value }));
|
|
setTripErrors((prev) => ({ ...prev, end_km: undefined }));
|
|
}}
|
|
slotProps={{ htmlInput: { min: 0 } }}
|
|
/>
|
|
</Field>
|
|
<Field label="Vzdálenost">
|
|
<TextField
|
|
value={`${formatKm(tripDistance())} km`}
|
|
InputProps={{ readOnly: true }}
|
|
disabled
|
|
/>
|
|
</Field>
|
|
</Box>
|
|
|
|
<Box
|
|
sx={{
|
|
display: "grid",
|
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
|
gap: 2,
|
|
}}
|
|
>
|
|
<Field label="Místo odjezdu" required error={tripErrors.route_from}>
|
|
<TextField
|
|
value={tripForm.route_from}
|
|
onChange={(e) => {
|
|
setTripForm((prev) => ({
|
|
...prev,
|
|
route_from: e.target.value,
|
|
}));
|
|
setTripErrors((prev) => ({ ...prev, route_from: undefined }));
|
|
}}
|
|
placeholder="Např. Praha"
|
|
/>
|
|
</Field>
|
|
<Field label="Místo příjezdu" required error={tripErrors.route_to}>
|
|
<TextField
|
|
value={tripForm.route_to}
|
|
onChange={(e) => {
|
|
setTripForm((prev) => ({ ...prev, route_to: e.target.value }));
|
|
setTripErrors((prev) => ({ ...prev, route_to: undefined }));
|
|
}}
|
|
placeholder="Např. Brno"
|
|
/>
|
|
</Field>
|
|
</Box>
|
|
|
|
<Field label="Typ jízdy">
|
|
<Select
|
|
value={String(tripForm.is_business)}
|
|
onChange={(val) =>
|
|
setTripForm((prev) => ({ ...prev, is_business: parseInt(val) }))
|
|
}
|
|
options={[
|
|
{ value: "1", label: "Služební" },
|
|
{ value: "0", label: "Soukromá" },
|
|
]}
|
|
/>
|
|
</Field>
|
|
|
|
<Field label="Poznámky">
|
|
<TextField
|
|
multiline
|
|
minRows={2}
|
|
value={tripForm.notes}
|
|
onChange={(e) =>
|
|
setTripForm((prev) => ({ ...prev, notes: e.target.value }))
|
|
}
|
|
placeholder="Volitelné poznámky..."
|
|
/>
|
|
</Field>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|