Files
app/src/admin/components/dashboard/DashQuickActions.tsx
BOHA 5459d8c325 feat(mui): migrate Dashboard (Přehled) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 01:19:49 +02:00

484 lines
13 KiB
TypeScript

import { useState } from "react";
import { Link as RouterLink } from "react-router-dom";
import { motion } from "framer-motion";
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;
}
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 [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 = await response.json();
if (result.success) {
setTripVehicles(
Array.isArray(result.data)
? result.data
: result.data?.vehicles || [],
);
}
} catch {
// vozidla se nenacetla
}
};
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 = await response.json();
if (result.success) {
setTripForm((prev) => ({ ...prev, start_km: result.data.last_km }));
}
} catch {
// last_km se nenacetlo
}
};
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) <= parseInt(tripForm.start_km)
) {
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 = await response.json();
if (result.success) {
setShowTripModal(false);
alert.success(result.message);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setTripSubmitting(false);
}
};
const tripDistance = (): number => {
const s = parseInt(tripForm.start_km) || 0;
const e = parseInt(tripForm.end_km) || 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={{ opacity: 0, y: 12 }}
animate={{ 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>
</>
);
}