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>
408 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|