feat: residues accounting

This commit is contained in:
2025-01-14 21:35:06 +04:00
parent fec6b13972
commit c45d2ac20a
74 changed files with 2994 additions and 28 deletions

View File

@@ -0,0 +1,83 @@
import { ResidualBoxSchema } from "../../../../client";
import { Accordion, ActionIcon, Center, Checkbox, Text, Tooltip } from "@mantine/core";
import { IconBox, IconTrash } from "@tabler/icons-react";
import ResidualProductsTable from "../ResidualProductsTable/ResidualProductsTable.tsx";
import { useResiduesContext } from "../../contexts/ResiduesContext.tsx";
type Props = {
boxes?: ResidualBoxSchema[];
}
const ResidualBoxes = ({ boxes }: Props) => {
const { onDeleteBoxClick, boxIdsToPrint } = useResiduesContext();
if (!boxes || boxes.length == 0) return;
const boxIds = boxes.map((box) => box.id.toString());
const removeBoxButton = (box: ResidualBoxSchema) => {
return (
<Tooltip label="Удалить короб">
<ActionIcon
variant={"default"}
onClick={() => onDeleteBoxClick(box)}
mx={"md"}
>
<IconTrash />
</ActionIcon>
</Tooltip>
);
};
const checkboxToPrint = (box: ResidualBoxSchema) => {
return (
<Checkbox
ml="sm"
checked={boxIdsToPrint.has(box.id)}
onChange={() => {
if (boxIdsToPrint.has(box.id)) {
boxIdsToPrint.delete(box.id);
} else {
boxIdsToPrint.add(box.id);
}
}}
/>
);
};
const renderBox = (box: ResidualBoxSchema) => {
return (
<Accordion.Item key={box.id} value={box.id.toString()}>
<Center>
<Accordion.Control icon={<IconBox />}>
Короб {box.id}
</Accordion.Control>
{checkboxToPrint(box)}
{removeBoxButton(box)}
</Center>
<Accordion.Panel>
{
box.residualProducts.length > 0 ? (
<ResidualProductsTable
items={box.residualProducts.sort((a, b) => a.id - b.id)}
/>
) : (
<Text>Пустой</Text>
)
}
</Accordion.Panel>
</Accordion.Item>
);
};
return (
<Accordion
multiple={true}
defaultValue={boxIds}
bd={"solid 1px gray"}
>
{boxes.sort((a, b) => a.id - b.id).map(box => renderBox(box))}
</Accordion>
);
};
export default ResidualBoxes;

View File

@@ -0,0 +1,84 @@
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { ResidualProductSchema, ResiduesService } from "../../../../client";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { modals } from "@mantine/modals";
import { useResiduesContext } from "../../contexts/ResiduesContext.tsx";
import useResiduesTableColumns from "../../hooks/residuesTableColumns.tsx";
type Props = {
items: ResidualProductSchema[];
}
const ResidualProductsTable = ({ items }: Props) => {
const columns = useResiduesTableColumns<ResidualProductSchema>();
const { selectedClient, refetchClient } = useResiduesContext();
const onDeleteClick = (residualProduct: ResidualProductSchema) => {
ResiduesService.deleteResidualProduct({
residualProductId: residualProduct.id,
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
refetchClient();
})
.catch(err => console.log(err));
};
const onEditClick = (residualProduct: ResidualProductSchema) => {
if (!selectedClient) return;
modals.openContextModal({
modal: "residualProductModal",
title: "Редактирование товара на паллете",
withCloseButton: false,
innerProps: {
client: selectedClient,
updateOnSubmit: refetchClient,
residuesData: {
residualProductId: residualProduct.id,
product: residualProduct.product,
quantity: residualProduct.quantity,
},
},
});
};
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
enableRowNumbers: true,
positionActionsColumn: "last",
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDeleteClick(row.original)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<ResidualProductSchema>
}
/>
);
};
export default ResidualProductsTable;

View File

@@ -0,0 +1,34 @@
import { Group } from "@mantine/core";
import { useResiduesContext } from "../../contexts/ResiduesContext.tsx";
import useResiduesPdf from "../../hooks/useResiduesPdf.tsx";
import { IconQrcode } from "@tabler/icons-react";
import InlineButton from "../../../../components/InlineButton/InlineButton.tsx";
const ResiduesHeader = () => {
const {
onCreatePalletClick,
onCreateBoxClick,
} = useResiduesContext();
const {
onGetPalletsPdfClick,
} = useResiduesPdf();
return (
<Group>
<InlineButton onClick={() => onCreatePalletClick()}>
Добавить паллет
</InlineButton>
<InlineButton onClick={() => onCreateBoxClick()}>
Добавить короб
</InlineButton>
<InlineButton onClick={() => onGetPalletsPdfClick()}>
<IconQrcode />
Печать
</InlineButton>
</Group>
);
};
export default ResiduesHeader;

View File

@@ -0,0 +1,21 @@
import { Group, Stack } from "@mantine/core";
import ClientSelect from "../../../../components/Selects/ClientSelect/ClientSelect.tsx";
import ResiduesHeader from "../ResiduesHeader/ResiduesHeader.tsx";
import ResiduesTree from "../ResiduesTree/ResiduesTree.tsx";
import { useResiduesContext } from "../../contexts/ResiduesContext.tsx";
const ResiduesPageContent = () => {
const { selectedClient, selectClient } = useResiduesContext();
return (
<Stack h="92vh">
<Group>
<ClientSelect onChange={selectClient} />
{selectedClient && <ResiduesHeader />}
</Group>
{selectedClient && <ResiduesTree />}
</Stack>
);
};
export default ResiduesPageContent;

View File

@@ -0,0 +1,135 @@
import {
Accordion,
ActionIcon,
Button,
Center, Checkbox,
Group,
rem,
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { IconPlus, IconSpace, IconTrash } from "@tabler/icons-react";
import { useResiduesContext } from "../../contexts/ResiduesContext.tsx";
import { ResidualBoxSchema, ResidualPalletSchema, ResidualProductSchema } from "../../../../client";
import ResidualProductsTable from "../ResidualProductsTable/ResidualProductsTable.tsx";
import ResidualBoxes from "../ResidualBoxes/ResidualBoxes.tsx";
import residualBoxes from "../ResidualBoxes/ResidualBoxes.tsx";
const ResiduesTree = () => {
const {
selectedClient,
onDeletePalletClick,
onCreateBoxClick,
palletIdsToPrint,
} = useResiduesContext();
const palletIds: string[] = [];
const sortById = (data?: ResidualPalletSchema[] | ResidualBoxSchema[] | ResidualProductSchema[]) => {
return data?.sort((a, b) => a.id - b.id);
};
const checkboxToPrint = (pallet: ResidualPalletSchema) => {
return (
<Checkbox
ml="sm"
checked={palletIdsToPrint.has(pallet.id)}
onChange={() => {
if (palletIdsToPrint.has(pallet.id)) {
palletIdsToPrint.delete(pallet.id);
} else {
palletIdsToPrint.add(pallet.id);
}
}}
/>
);
};
const getPallets = () => {
const sortedPallets = sortById(selectedClient?.pallets) as ResidualPalletSchema[];
return sortedPallets?.map((pallet => {
palletIds.push(pallet.id.toString());
return (
<Accordion.Item key={pallet.id} value={pallet.id.toString()}>
<Center>
<Accordion.Control icon={<IconSpace />}>
Паллет - П{pallet.id}
</Accordion.Control>
{checkboxToPrint(pallet)}
{removePalletButton(pallet)}
</Center>
<Accordion.Panel>
{getPalletContent(pallet)}
</Accordion.Panel>
</Accordion.Item>
);
})) ?? [];
};
const removePalletButton = (pallet: ResidualPalletSchema) => {
return (
<Tooltip label="Удалить паллет">
<ActionIcon
variant={"default"}
onClick={() => onDeletePalletClick(pallet)}
mx={"md"}
>
<IconTrash />
</ActionIcon>
</Tooltip>
);
};
const createBoxButton = (palletId: number) => {
return (
<Button
variant={"default"}
onClick={() => onCreateBoxClick(palletId)}
>
<Group gap={"md"}>
<IconPlus />
Добавить короб
</Group>
</Button>
);
};
const getPalletContent = (pallet: ResidualPalletSchema) => {
const isEmpty = pallet.boxes.length === 0 && pallet.residualProducts.length === 0;
const isBox = pallet.residualProducts.length === 0;
const title = isEmpty ? "Пустой" : isBox ? "Короба" : "Товары";
const residualProducts = sortById(pallet.residualProducts) as ResidualProductSchema[];
return (
<Stack gap={rem(5)}>
<Group justify={"space-between"}>
<Stack gap={"xs"}>
<Text>Дата добавления: {new Date(pallet.createdAt).toLocaleString("ru-RU")}</Text>
<Title order={6}>{title}</Title>
</Stack>
{isBox && createBoxButton(pallet.id)}
</Group>
{residualProducts.length > 0 && <ResidualProductsTable items={residualProducts} />}
{residualBoxes.length > 0 && <ResidualBoxes boxes={pallet.boxes} />}
</Stack>
);
};
return (
<ScrollArea>
<Accordion
multiple={true}
defaultValue={palletIds}
bd={"solid 1px gray"}
>
<ResidualBoxes boxes={selectedClient?.boxes} />
{getPallets()}
</Accordion>
</ScrollArea>
);
};
export default ResiduesTree;

View File

@@ -0,0 +1,176 @@
import { createContext, FC, useContext, useState } from "react";
import {
ClientDetailedSchema,
ClientSchema,
ClientService,
ResidualBoxSchema,
ResidualPalletSchema,
ResiduesService,
} from "../../../client";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { notifications } from "../../../shared/lib/notifications.ts";
import { useSet } from "@mantine/hooks";
type ResiduesContextState = {
selectedClient?: ClientDetailedSchema;
selectClient: (client?: ClientSchema) => void;
refetchClient: () => void;
onDeletePalletClick: (pallet: ResidualPalletSchema) => void;
onCreatePalletClick: () => void;
onDeleteBoxClick: (box: ResidualBoxSchema) => void;
onCreateBoxClick: (pallet?: number) => void;
boxIdsToPrint: Set<number>;
palletIdsToPrint: Set<number>;
};
const ResiduesContext = createContext<ResiduesContextState | undefined>(
undefined,
);
const useResiduesContextState = () => {
const [selectedClient, setSelectedClient] = useState<ClientDetailedSchema>();
const boxIdsToPrint = useSet<number>();
const palletIdsToPrint = useSet<number>();
const fetchClient = (clientId?: number) => {
if (!clientId) {
setSelectedClient(undefined);
return;
}
ClientService.getClient({
clientId,
}).then(res => {
setSelectedClient(res.client);
}).catch(err => console.log(err));
};
const selectClient = (client?: ClientSchema) => {
fetchClient(client?.id);
};
const refetchClient = () => {
fetchClient(selectedClient?.id);
};
const onDeletePallet = (palletId: number) => {
ResiduesService.deleteResidualPallet({ palletId })
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
palletIdsToPrint.delete(palletId);
refetchClient();
})
.catch(err => console.log(err));
};
const onDeletePalletClick = (pallet: ResidualPalletSchema) => {
if (!selectedClient) return;
if (pallet.boxes.length === 0 && pallet.residualProducts.length === 0) {
onDeletePallet(pallet.id);
return;
}
modals.openConfirmModal({
title: "Удаление паллета",
children: <Text size="sm">Вы уверены что хотите удалить паллет?</Text>,
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => onDeletePallet(pallet.id),
});
};
const onDeleteBox = (boxId: number) => {
ResiduesService.deleteResidualBox({ boxId })
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
boxIdsToPrint.delete(boxId);
refetchClient();
})
.catch(err => console.log(err));
};
const onDeleteBoxClick = (box: ResidualBoxSchema) => {
if (!selectedClient) return;
if (box.residualProducts.length === 0) {
onDeleteBox(box.id);
return;
}
modals.openConfirmModal({
title: "Удаление короба",
children: <Text size="sm">Вы уверены что хотите удалить короб?</Text>,
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => onDeleteBox(box.id),
});
};
const onCreatePalletClick = () => {
if (!selectedClient) return;
ResiduesService.createResidualPallet({
requestBody: { clientId: selectedClient.id },
})
.then(({ ok, message }) => {
if (!ok) notifications.error({ message });
refetchClient();
})
.catch(err => console.log(err));
};
const onCreateBoxClick = (palletId?: number) => {
if (!selectedClient) return;
ResiduesService.createResidualBox({
requestBody: {
palletId: palletId ?? null,
clientId: palletId ? null : selectedClient.id,
},
})
.then(({ ok, message }) => {
if (!ok) notifications.error({ message });
refetchClient();
})
.catch(err => console.log(err));
};
return {
selectedClient,
selectClient,
refetchClient,
onDeletePalletClick,
onCreatePalletClick,
onDeleteBoxClick,
onCreateBoxClick,
boxIdsToPrint,
palletIdsToPrint,
};
};
type ResiduesContextProviderProps = {
children: React.ReactNode;
};
export const ResiduesContextProvider: FC<ResiduesContextProviderProps> = ({ children }) => {
const state = useResiduesContextState();
return (
<ResiduesContext.Provider value={state}>
{children}
</ResiduesContext.Provider>
);
};
export const useResiduesContext = () => {
const context = useContext(ResiduesContext);
if (!context) {
throw new Error(
"useResiduesContext must be used within a ResiduesContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,32 @@
import { useMemo } from "react";
import { MRT_ColumnDef, MRT_RowData } from "mantine-react-table";
const useResiduesTableColumns = <T extends MRT_RowData>() => {
return useMemo<MRT_ColumnDef<T>[]>(
() => [
{
header: "Название",
accessorKey: "product.name",
Cell: ({ row }) => row.original.product?.name ?? "-",
},
{
header: "Артикул",
accessorKey: "product.article",
Cell: ({ row }) => row.original.product?.article ?? "-",
},
{
header: "Размер",
accessorKey: "product.size",
Cell: ({ row }) => row.original.product?.size ?? "-",
},
{
header: "Количество",
accessorKey: "quantity",
},
],
[],
);
};
export default useResiduesTableColumns;

View File

@@ -0,0 +1,31 @@
import { useResiduesContext } from "../contexts/ResiduesContext.tsx";
import { notifications } from "../../../shared/lib/notifications.ts";
const useResiduesPdf = () => {
const { palletIdsToPrint, boxIdsToPrint } = useResiduesContext();
const basePdfUrl = `${import.meta.env.VITE_API_URL}/residues/pdf`;
const getPdf = (url: string) => {
const pdfWindow = window.open(url);
if (!pdfWindow) return;
pdfWindow.print();
};
const onGetPalletsPdfClick = () => {
if (palletIdsToPrint.size === 0 && boxIdsToPrint.size === 0) {
notifications.show({ message: "Не выбран ни один элемент для печати "});
return;
}
const palletIdsStr = palletIdsToPrint.values().toArray().join(",")
const boxIdsStr = boxIdsToPrint.values().toArray().join(",")
getPdf(`${basePdfUrl}/?pallet_ids=${palletIdsStr}&box_ids=${boxIdsStr}`);
};
return {
onGetPalletsPdfClick,
};
};
export default useResiduesPdf;

View File

@@ -0,0 +1,83 @@
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, NumberInput, rem } from "@mantine/core";
import { ClientDetailedSchema, ProductSchema, ResiduesService, UpdateResidualProductSchema } from "../../../../client";
import { ResidualModalForm, UpdateResidualProductData } from "../../types/ResidualProductData.tsx";
import { notifications } from "../../../../shared/lib/notifications.ts";
import ProductSelect from "../../../../components/ProductSelect/ProductSelect.tsx";
type Props = {
updateOnSubmit: () => void;
client: ClientDetailedSchema;
residuesData: UpdateResidualProductData;
}
const ResidualProductModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const initialValues: ResidualModalForm = {
quantity: innerProps.residuesData.quantity ?? 0,
product: innerProps.residuesData.product,
};
const form = useForm<ResidualModalForm>({
initialValues,
validate: {
product: product => !product && "Необходимо выбрать товар",
quantity: quantity => quantity === 0 && "Слишком мало товара",
},
});
const updateResidualProduct = () => {
const data = {
...form.values,
productId: form.values.product!.id,
} as UpdateResidualProductSchema;
ResiduesService.updateResidualProduct({
requestBody: { data },
residualProductId: innerProps.residuesData.residualProductId,
})
.then(({ ok, message }) => {
notifications.guess(ok, { message: message });
innerProps.updateOnSubmit();
if (ok) context.closeContextModal(id);
})
.catch(err => console.log(err));
};
return (
<form onSubmit={form.onSubmit(() => updateResidualProduct())}>
<Flex
direction={"column"}
gap={rem(10)}
>
<ProductSelect
label={"Товар"}
placeholder={"Выберите товар"}
{...form.getInputProps("product")}
defaultValue={innerProps.residuesData.product as (ProductSchema & string)}
clientId={innerProps.client.id}
/>
<NumberInput
label={"Количество"}
hideControls
{...form.getInputProps("quantity")}
min={0}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Flex>
</form>
);
};
export default ResidualProductModal;

View File

@@ -0,0 +1,12 @@
import { ProductSchema } from "../../../client";
export type ResidualModalForm = {
quantity: number;
product: ProductSchema | null;
}
export type UpdateResidualProductData = {
product: ProductSchema | null;
quantity: number;
residualProductId: number;
};

View File

@@ -0,0 +1,19 @@
import styles from "../../ProductsPage/ui/ProductsPage.module.css";
import PageBlock from "../../../components/PageBlock/PageBlock.tsx";
import { ResiduesContextProvider } from "../contexts/ResiduesContext.tsx";
import ResiduesPageContent from "../components/ResiduesPageContent/ResiduesPageContent.tsx";
const ResiduesPage = () => {
return (
<div className={styles["container"]}>
<PageBlock fullHeight>
<ResiduesContextProvider>
<ResiduesPageContent />
</ResiduesContextProvider>
</PageBlock>
</div>
);
};
export default ResiduesPage;