Files
app/src/admin/pages/WarehouseLocations.tsx
BOHA 39fe84ce99 feat(mui): consistent staggered page entrance across all 43 route pages
Adopt the PageEnter wrapper as every page's outermost render element and remove the ad-hoc per-page entrance motion.div wrappers. Every page now enters the same way — all top-level sections rise+fade in, staggered — so nothing appears instantly and the motion is identical app-wide. Presentation-only: no data/logic/hooks/invalidate/permissions touched. Embedded sub-tabs (CompanySettings, ReceivedInvoices), Login (auth shell) and the dev UiKit are intentionally excluded. Gates: tsc -b --noEmit=0, build=0, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 10:13:06 +02:00

408 lines
11 KiB
TypeScript

import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import Box from "@mui/material/Box";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import {
warehouseLocationListOptions,
type WarehouseLocation,
} from "../lib/queries/warehouse";
import { useApiMutation } from "../lib/queries/mutations";
import {
Button,
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
StatusChip,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const API_BASE = "/api/admin/warehouse/locations";
interface LocationForm {
code: string;
name: string;
description: string;
}
const PlusIcon = (
<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>
);
const EditIcon = (
<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>
);
const DeleteIcon = (
<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>
);
export default function WarehouseLocations() {
const alert = useAlert();
const { hasPermission } = useAuth();
const { data: locations = [], isPending } = useQuery(
warehouseLocationListOptions({ active: "all" }),
);
const [showModal, setShowModal] = useState(false);
const [editingLocation, setEditingLocation] =
useState<WarehouseLocation | null>(null);
const [form, setForm] = useState<LocationForm>({
code: "",
name: "",
description: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [deleteConfirm, setDeleteConfirm] = useState<{
show: boolean;
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"],
});
if (!hasPermission("warehouse.manage")) return <Forbidden />;
const openCreateModal = () => {
setEditingLocation(null);
setForm({
code: "",
name: "",
description: "",
});
setErrors({});
setShowModal(true);
};
const openEditModal = (location: WarehouseLocation) => {
setEditingLocation(location);
setForm({
code: location.code,
name: location.name,
description: location.description || "",
});
setErrors({});
setShowModal(true);
};
const handleSubmit = async () => {
const newErrors: Record<string, string> = {};
if (!form.code.trim()) newErrors.code = "Zadejte kód umístění";
if (!form.name.trim()) newErrors.name = "Zadejte název umístění";
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
try {
if (editingLocation) {
await updateLocationMutation.mutateAsync({
id: editingLocation.id,
code: form.code,
name: form.name,
description: form.description,
});
} else {
await createLocationMutation.mutateAsync({
code: form.code,
name: form.name,
description: form.description,
});
}
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleDelete = async () => {
if (!deleteConfirm.location) return;
try {
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 {
await toggleMutation.mutateAsync({
id: location.id,
is_active: !location.is_active,
});
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í");
}
};
if (isPending) {
return <LoadingState />;
}
const total = locations.length;
const subtitle = `${total} ${
total === 1
? "umístění"
: total >= 2 && total <= 4
? "umístění"
: "umístění"
}`;
const columns: DataColumn<WarehouseLocation>[] = [
{
key: "code",
header: "Kód",
width: "15%",
mono: true,
bold: true,
render: (l) => l.code,
},
{
key: "name",
header: "Název",
width: "25%",
render: (l) => l.name,
},
{
key: "description",
header: "Popis",
width: "35%",
render: (l) => l.description || "—",
},
{
key: "status",
header: "Stav",
width: "15%",
render: (l) => (
<StatusChip
label={l.is_active ? "Aktivní" : "Neaktivní"}
color={l.is_active ? "success" : "default"}
onClick={() => toggleActive(l)}
/>
),
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: (l) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(l)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteConfirm({ show: true, location: l })}
aria-label="Smazat"
title="Smazat"
>
{DeleteIcon}
</IconButton>
</Box>
),
},
];
return (
<PageEnter>
<PageHeader
title="Umístění skladu"
subtitle={subtitle}
actions={
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat umístění
</Button>
}
/>
<Card>
<DataTable<WarehouseLocation>
columns={columns}
rows={locations}
rowKey={(l) => l.id}
rowInactive={(l) => !l.is_active}
empty={
<EmptyState
title="Zatím nejsou žádná umístění."
action={
<Button startIcon={PlusIcon} onClick={openCreateModal}>
Přidat první umístění
</Button>
}
/>
}
/>
</Card>
{/* Add/Edit Modal */}
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
onSubmit={handleSubmit}
title={editingLocation ? "Upravit umístění" : "Přidat umístění"}
loading={
createLocationMutation.isPending || updateLocationMutation.isPending
}
>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
}}
>
<Field label="Kód" required error={errors.code}>
<TextField
value={form.code}
error={!!errors.code}
onChange={(e) => {
setForm({ ...form, code: e.target.value.toUpperCase() });
setErrors((prev) => ({ ...prev, code: "" }));
}}
placeholder="A-01"
/>
</Field>
<Field label="Název" required error={errors.name}>
<TextField
value={form.name}
error={!!errors.name}
onChange={(e) => {
setForm({ ...form, name: e.target.value });
setErrors((prev) => ({ ...prev, name: "" }));
}}
placeholder="Regál A, polička 1"
/>
</Field>
</Box>
<Field label="Popis">
<TextField
multiline
minRows={3}
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="Volitelný popis umístění"
/>
</Field>
</Modal>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, location: null })}
onConfirm={handleDelete}
title="Smazat umístění"
message={
deleteConfirm.location
? `Opravdu chcete smazat umístění "${deleteConfirm.location.code} - ${deleteConfirm.location.name}"? Pokud umístění obsahuje zásoby, nelze jej smazat.`
: ""
}
confirmText="Smazat"
confirmVariant="danger"
loading={deleteMutation.isPending}
/>
</PageEnter>
);
}