feat: warehouse places accounting

This commit is contained in:
2025-05-07 09:53:02 +04:00
parent d347c09199
commit e4f8e90ae6
41 changed files with 1305 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import { WarehouseManagementTabContextProvider } from "./placeType/contexts/WarehouseManagementTabContext.tsx";
import { WmsPage, WmsSegmentedControl } from "./placeType/components/WmsSegmentedControl.tsx";
import { useState } from "react";
import PlaceTypesEditor from "./placeType/components/PlaceTypesEditor.tsx";
import PlacesEditor from "./place/components/PlacesEditor.tsx";
const WarehouseManagementTab = () => {
const [page, setPage] = useState(WmsPage.PLACE);
return (
<WarehouseManagementTabContextProvider>
<WmsSegmentedControl
w={"100%"}
value={page.toString()}
onChange={event => {
setPage(parseInt(event));
}}
/>
{page === WmsPage.PLACE_TYPE ? (
<PlaceTypesEditor />
) : (
<PlacesEditor />
)}
</WarehouseManagementTabContextProvider>
);
};
export default WarehouseManagementTab;

View File

@@ -0,0 +1,57 @@
import { DataTable } from "mantine-datatable";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { useState } from "react";
import { PlaceSchema } from "../../../../../../client";
import PlaceActions from "./PlaceActions.tsx";
type Props = {
place: PlaceSchema;
}
const Place = ({ place }: Props) => {
const [placeTypeIds, setPlaceTypeIds] = useState<number[]>([]);
return (
<DataTable
noHeader
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, number, placeType }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeTypeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{placeType.name} {number}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (place) => (
<PlaceActions place={place} />
),
},
]}
records={place.children?.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeTypeIds, onRecordIdsChange: setPlaceTypeIds },
content: ({ record }) => (
<Place place={record} />
),
}}
/>
);
};
export default Place;

View File

@@ -0,0 +1,69 @@
import { PlaceSchema } from "../../../../../../client";
import { IconBoxAlignBottom, IconPlaylistAdd, IconQrcode, IconTrash } from "@tabler/icons-react";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import React, { ReactNode } from "react";
import { useWarehouseManagementTabContext } from "../../placeType/contexts/WarehouseManagementTabContext.tsx";
type Props = {
place: PlaceSchema;
}
const PlaceActions = ({ place }: Props) => {
const { placeCrud, onCreatePlace, generateQrCode } = useWarehouseManagementTabContext();
const getAction = (
label: string,
func: () => void,
icon: ReactNode,
disabled: boolean = false,
) => {
return (
<Tooltip label={label} key={label}>
<ActionIcon
variant={"default"}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
func();
}}
disabled={disabled}
>
{icon}
</ActionIcon>
</Tooltip>
);
};
const actions = [
getAction(
"Распечатать QR-код",
() => generateQrCode(place, false),
<IconQrcode />,
),
getAction(
"Распечатать QR-код ",
() => generateQrCode(place, true),
<IconBoxAlignBottom />,
),
getAction(
"Добавить",
() => onCreatePlace(place),
<IconPlaylistAdd />,
place.placeType.childCount === 0,
),
getAction(
"Удалить",
() => placeCrud.onDelete && placeCrud.onDelete(place),
<IconTrash />,
place.children?.length !== 0,
),
];
return (
<Flex gap={"md"} mx={"md"} direction={"row"}>
{...actions}
</Flex>
);
};
export default PlaceActions;

View File

@@ -0,0 +1,23 @@
import { FC } from "react";
import { FlatPlaceTypeSchema } from "../../../../../../client";
import ObjectSelect, { ObjectSelectProps } from "../../../../../../components/ObjectSelect/ObjectSelect.tsx";
type Props = Omit<
ObjectSelectProps<FlatPlaceTypeSchema | null>,
"getValueFn" | "getLabelFn"
>;
const PlaceTypeSelect: FC<Props> = (props) => {
return (
<ObjectSelect
getLabelFn={(placeType: FlatPlaceTypeSchema) => placeType.name}
getValueFn={(placeType: FlatPlaceTypeSchema) => placeType.id.toString()}
clearable
searchable
{...props}
onClear={() => props.onChange(null)}
/>
);
};
export default PlaceTypeSelect;

View File

@@ -0,0 +1,67 @@
import { Button, Group, Stack } from "@mantine/core";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { DataTable } from "mantine-datatable";
import { useState } from "react";
import { useWarehouseManagementTabContext } from "../../placeType/contexts/WarehouseManagementTabContext.tsx";
import PlaceActions from "./PlaceActions.tsx";
import Place from "./Place.tsx";
const PlacesEditor = () => {
const { places, onCreatePlace } = useWarehouseManagementTabContext();
const [placeIds, setPlaceIds] = useState<number[]>([]);
return (
<Stack w={"100%"} mt={"md"}>
<Group>
<Button
variant={"default"}
onClick={() => onCreatePlace()}
>
Добавить
</Button>
</Group>
<DataTable
noHeader
withTableBorder
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, number, placeType }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{placeType.name} {number}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (place) => (
<PlaceActions place={place} />
),
},
]}
records={places.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeIds, onRecordIdsChange: setPlaceIds },
content: ({ record }) => (
<Place place={record} />
),
}}
/>
</Stack>
);
};
export default PlacesEditor;

View File

@@ -0,0 +1,56 @@
import { useCRUD } from "../../../../../../hooks/useCRUD.tsx";
import { BasePlaceSchema, PlaceSchema, WmsService } from "../../../../../../client";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
export type PlaceCrud = {
onCreate: (element: BasePlaceSchema) => void,
onDelete: (element: PlaceSchema) => void,
onChange: (element: PlaceSchema) => void
}
type Props = {
fetchPlaces: () => void;
}
const usePlacesCrud = ({ fetchPlaces }: Props): PlaceCrud => {
return useCRUD<PlaceSchema, BasePlaceSchema>({
onChange: (place: PlaceSchema) => {
WmsService.editPlace({
requestBody: { place },
})
.then(({ ok, message }) => {
fetchPlaces();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onDelete: (place: PlaceSchema) => {
WmsService.deletePlace({
placeId: place.id,
})
.then(({ ok, message }) => {
fetchPlaces();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onCreate: (place: BasePlaceSchema) => {
WmsService.createPlace({
requestBody: {
place,
},
})
.then(({ ok, message }) => {
fetchPlaces();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
});
};
export default usePlacesCrud;

View File

@@ -0,0 +1,11 @@
import { WmsService } from "../../../../../../client";
import ObjectList from "../../../../../../hooks/objectList.tsx";
const usePlacesList = () =>
ObjectList({
queryFn: WmsService.getPlaces,
getObjectsFn: response => response.places,
queryKey: "getAllPlaces",
});
export default usePlacesList;

View File

@@ -0,0 +1,65 @@
import { ContextModalProps } from "@mantine/modals";
import { FlatPlaceTypeSchema, PlaceSchema } from "../../../../../../client";
import { Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form";
import { PlaceCrud } from "../hooks/usePlacesCrud.tsx";
import PlaceTypeSelect from "../components/PlaceTypeSelect.tsx";
type Props = {
placeCrud: PlaceCrud;
parent?: PlaceSchema;
placeTypes: FlatPlaceTypeSchema[];
}
type PlaceModalForm = {
placeType: PlaceSchema | null;
}
const PlaceModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const { parent, placeCrud, placeTypes } = innerProps;
const closeModal = () => {
context.closeContextModal(id);
};
const initialValues: PlaceModalForm = {
placeType: null,
};
const form = useForm<PlaceModalForm>({
initialValues,
validate: {
placeType: placeType => !placeType && "Необходимо указать тип",
},
});
const onSubmit = (values: PlaceModalForm) => {
if (!values.placeType) return;
placeCrud.onCreate({
placeTypeId: values.placeType.id,
parentId: parent?.id || null,
});
closeModal();
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<PlaceTypeSelect
label={"Тип места на складе"}
{...form.getInputProps("placeType")}
data={placeTypes}
/>
<Button variant={"default"} type={"submit"}>
Сохранить
</Button>
</Stack>
</form>
);
};
export default PlaceModal;

View File

@@ -0,0 +1,57 @@
import { PlaceTypeSchema } from "../../../../../../client";
import { DataTable } from "mantine-datatable";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { useState } from "react";
import PlaceTypeActions from "./PlaceTypeActions.tsx";
type Props = {
placeType: PlaceTypeSchema;
}
const PlaceType = ({ placeType }: Props) => {
const [placeTypeIds, setPlaceTypeIds] = useState<number[]>([]);
return (
<DataTable
noHeader
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, name }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeTypeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{name}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (placeType) => (
<PlaceTypeActions placeType={placeType} />
),
},
]}
records={placeType.children?.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeTypeIds, onRecordIdsChange: setPlaceTypeIds },
content: ({ record }) => (
<PlaceType placeType={record} />
),
}}
/>
);
};
export default PlaceType;

View File

@@ -0,0 +1,84 @@
import { PlaceTypeSchema } from "../../../../../../client";
import { IconEdit, IconPlaylistAdd, IconTrash } from "@tabler/icons-react";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import React, { ReactNode } from "react";
import { useWarehouseManagementTabContext } from "../contexts/WarehouseManagementTabContext.tsx";
import { modals } from "@mantine/modals";
type Props = {
placeType: PlaceTypeSchema;
}
const PlaceTypeActions = ({ placeType }: Props) => {
const { placeTypeCrud } = useWarehouseManagementTabContext();
const getAction = (
label: string,
func: () => void,
icon: ReactNode,
disabled: boolean = false,
) => {
return (
<Tooltip label={label} key={label}>
<ActionIcon
disabled={disabled}
variant={"default"}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
func();
}}
>
{icon}
</ActionIcon>
</Tooltip>
);
};
const actions = [
getAction(
"Добавить",
() => {
modals.openContextModal({
modal: "placeTypeModal",
title: "Создание типа места на складе",
withCloseButton: false,
innerProps: {
placeTypeCrud,
parent: placeType,
},
});
},
<IconPlaylistAdd />,
),
getAction(
"Редактировать",
() => {
modals.openContextModal({
modal: "placeTypeModal",
title: "Редактирование типа места на складе",
withCloseButton: false,
innerProps: {
placeTypeCrud,
element: placeType,
},
});
},
<IconEdit />,
),
getAction(
"Удалить",
() => placeTypeCrud.onDelete && placeTypeCrud.onDelete(placeType),
<IconTrash />,
placeType.placesCount !== 0 || placeType.children?.length !== 0,
),
];
return (
<Flex gap={"md"} mx={"md"} direction={"row"}>
{...actions}
</Flex>
);
};
export default PlaceTypeActions;

View File

@@ -0,0 +1,77 @@
import { Button, Group, Stack } from "@mantine/core";
import { useWarehouseManagementTabContext } from "../contexts/WarehouseManagementTabContext.tsx";
import { IconChevronRight, IconSpace } from "@tabler/icons-react";
import clsx from "clsx";
import classes from "../../../OrganizationalStructureTab/components/DepartmentsTree/DepartmentsTree.module.css";
import { DataTable } from "mantine-datatable";
import { useState } from "react";
import PlaceType from "./PlaceType.tsx";
import PlaceTypeActions from "./PlaceTypeActions.tsx";
import { modals } from "@mantine/modals";
const PlaceTypesEditor = () => {
const { placeTypes, placeTypeCrud } = useWarehouseManagementTabContext();
const [placeTypeIds, setPlaceTypeIds] = useState<number[]>([]);
return (
<Stack w={"100%"} mt={"md"}>
<Group>
<Button
variant={"default"}
onClick={() => {
modals.openContextModal({
modal: "placeTypeModal",
title: "Создание типа места на складе",
withCloseButton: false,
innerProps: {
placeTypeCrud,
},
});
}}
>
Добавить
</Button>
</Group>
<DataTable
noHeader
withTableBorder
columns={[
{
accessor: "name",
title: "Место",
noWrap: true,
render: ({ id, name }) => (
<>
<IconChevronRight
className={clsx(classes.icon, classes.expandIcon, {
[classes.expandIconRotated]: placeTypeIds?.includes(id),
})}
/>
<IconSpace className={classes.icon} />
<span>{name}</span>
</>
),
},
{
accessor: "actions",
title: "",
width: "0%",
render: (placeType) => (
<PlaceTypeActions placeType={placeType} />
),
},
]}
records={placeTypes.sort((a, b) => a.id - b.id)}
rowExpansion={{
allowMultiple: true,
expanded: { recordIds: placeTypeIds, onRecordIdsChange: setPlaceTypeIds },
content: ({ record }) => (
<PlaceType placeType={record} />
),
}}
/>
</Stack>
);
};
export default PlaceTypesEditor;

View File

@@ -0,0 +1,28 @@
import { SegmentedControl, SegmentedControlProps } from "@mantine/core";
import { FC } from "react";
export enum WmsPage {
PLACE,
PLACE_TYPE,
}
type Props = Omit<SegmentedControlProps, "data">;
const data = [
{
label: "Места на складе",
value: WmsPage.PLACE.toString(),
},
{
label: "Типы мест на складе",
value: WmsPage.PLACE_TYPE.toString(),
},
];
export const WmsSegmentedControl: FC<Props> = props => {
return (
<SegmentedControl
data={data}
{...props}
/>
);
};

View File

@@ -0,0 +1,104 @@
import React, { createContext, FC, useContext } from "react";
import { PlaceSchema, PlaceTypeSchema, WmsService } from "../../../../../../client";
import usePlaceTypesList from "../hooks/usePlaceTypesList.tsx";
import usePlaceTypesCrud, { PlaceTypeCrud } from "../hooks/usePlaceTypesCrud.tsx";
import usePlacesList from "../../place/hooks/usePlacesList.tsx";
import usePlacesCrud, { PlaceCrud } from "../../place/hooks/usePlacesCrud.tsx";
import { modals } from "@mantine/modals";
type WarehouseManagementTabContextState = {
refetchPlaceTypes: () => void;
placeTypeCrud: PlaceTypeCrud;
placeTypes: PlaceTypeSchema[];
refetchPlaces: () => void;
placeCrud: PlaceCrud;
places: PlaceSchema[];
onCreatePlace: (place?: PlaceSchema) => void;
generateQrCode: (place: PlaceSchema, isShort: boolean) => void;
};
const WarehouseManagementTabContext = createContext<WarehouseManagementTabContextState | undefined>(
undefined,
);
const useWarehouseManagementTabContextState = () => {
const { objects: placeTypes, refetch: refetchPlaceTypes } = usePlaceTypesList();
const { objects: places, refetch: refetchPlaces } = usePlacesList();
const placeTypeCrud: PlaceTypeCrud = usePlaceTypesCrud({
fetchPlaceTypes: refetchPlaceTypes,
});
const placeCrud: PlaceCrud = usePlacesCrud({
fetchPlaces: refetchPlaces,
});
const onCreatePlace = (place?: PlaceSchema) => {
WmsService.getFlatPlaceTypes({
parentPlaceTypeId: place?.placeTypeId || -1,
})
.then(({ placeTypes }) => {
if (placeTypes.length === 1) {
placeCrud.onCreate({
parentId: place?.id || null,
placeTypeId: placeTypes[0].id,
});
return;
}
modals.openContextModal({
modal: "placeModal",
title: "Создание места на складе",
withCloseButton: false,
innerProps: {
placeCrud,
parent: place,
placeTypes,
},
});
})
.catch(err => console.log(err));
};
const generateQrCode = (place: PlaceSchema, isShort: boolean) => {
const pdfWindow = window.open(
`${import.meta.env.VITE_API_URL}/wms/place/pdf/${place.id}/${isShort}`,
);
if (!pdfWindow) return;
pdfWindow.print();
};
return {
placeTypes,
placeTypeCrud,
refetchPlaceTypes,
places,
placeCrud,
refetchPlaces,
onCreatePlace,
generateQrCode,
};
};
type WarehouseManagementTabContextProviderProps = {
children: React.ReactNode;
};
export const WarehouseManagementTabContextProvider: FC<WarehouseManagementTabContextProviderProps> = ({ children }) => {
const state = useWarehouseManagementTabContextState();
return (
<WarehouseManagementTabContext.Provider value={state}>
{children}
</WarehouseManagementTabContext.Provider>
);
};
export const useWarehouseManagementTabContext = () => {
const context = useContext(WarehouseManagementTabContext);
if (!context) {
throw new Error(
"useWarehouseManagementTabContext must be used within a WarehouseManagementTabContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,11 @@
import { WmsService } from "../../../../../../client";
import ObjectList from "../../../../../../hooks/objectList.tsx";
const useFlatPlaceTypesList = (parentPlaceTypeId: number = -1) =>
ObjectList({
queryFn: () => WmsService.getFlatPlaceTypes({ parentPlaceTypeId }),
getObjectsFn: response => response.placeTypes,
queryKey: "getFlatPlaceTypes",
});
export default useFlatPlaceTypesList;

View File

@@ -0,0 +1,56 @@
import { useCRUD } from "../../../../../../hooks/useCRUD.tsx";
import { BasePlaceTypeSchema, FlatPlaceTypeSchema, WmsService } from "../../../../../../client";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
export type PlaceTypeCrud = {
onCreate: (element: BasePlaceTypeSchema) => void,
onDelete: (element: FlatPlaceTypeSchema) => void,
onChange: (element: FlatPlaceTypeSchema) => void
}
type Props = {
fetchPlaceTypes: () => void;
}
const usePlaceTypesCrud = ({ fetchPlaceTypes }: Props): PlaceTypeCrud => {
return useCRUD<FlatPlaceTypeSchema, BasePlaceTypeSchema>({
onChange: (placeType: FlatPlaceTypeSchema) => {
WmsService.editPlaceType({
requestBody: { placeType },
})
.then(({ ok, message }) => {
fetchPlaceTypes();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onDelete: (placeType: FlatPlaceTypeSchema) => {
WmsService.deletePlaceType({
placeTypeId: placeType.id,
})
.then(({ ok, message }) => {
fetchPlaceTypes();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
onCreate: (placeType: BasePlaceTypeSchema) => {
WmsService.createPlaceType({
requestBody: {
placeType,
},
})
.then(({ ok, message }) => {
fetchPlaceTypes();
if (ok) return;
notifications.error({ message });
})
.catch(err => console.log(err));
},
});
};
export default usePlaceTypesCrud;

View File

@@ -0,0 +1,11 @@
import { WmsService } from "../../../../../../client";
import ObjectList from "../../../../../../hooks/objectList.tsx";
const usePlaceTypesList = () =>
ObjectList({
queryFn: WmsService.getPlaceTypes,
getObjectsFn: response => response.placeTypes,
queryKey: "getAllPlaceTypes",
});
export default usePlaceTypesList;

View File

@@ -0,0 +1,76 @@
import { ContextModalProps } from "@mantine/modals";
import { BasePlaceTypeSchema, PlaceTypeSchema } from "../../../../../../client";
import { PlaceTypeCrud } from "../hooks/usePlaceTypesCrud.tsx";
import { Button, Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
type Props = {
placeTypeCrud: PlaceTypeCrud;
element?: PlaceTypeSchema & BasePlaceTypeSchema;
parent?: PlaceTypeSchema;
}
type PlaceTypeModalForm = {
name: string;
}
const PlaceTypeModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const { element, parent, placeTypeCrud } = innerProps;
const closeModal = () => {
context.closeContextModal(id);
};
const initialValues: PlaceTypeModalForm = {
name: innerProps.element?.name ?? "",
};
const form = useForm<PlaceTypeModalForm>({
initialValues,
validate: {
name: name => !name && "Необходимо указать название",
},
});
const isChanged = (): boolean => {
return initialValues.name !== form.values.name;
};
const onSubmit = (values: PlaceTypeModalForm) => {
if (element) {
if (isChanged()) {
placeTypeCrud.onChange({
name: values.name ?? "",
id: element?.id,
parentId: element?.parentId ?? null,
});
}
} else {
placeTypeCrud.onCreate({
name: values.name ?? "",
parentId: parent?.id ?? null,
});
}
closeModal();
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<TextInput
label={"Название"}
{...form.getInputProps("name")}
/>
<Button variant={"default"} type={"submit"}>
Сохранить
</Button>
</Stack>
</form>
);
};
export default PlaceTypeModal;