refactor: removed extra folder from modules

This commit is contained in:
2025-09-19 20:13:45 +04:00
parent de82e639b2
commit 30e0de5c5e
46 changed files with 65 additions and 65 deletions

View File

@ -0,0 +1,12 @@
.container {
padding: var(--mantine-spacing-sm);
border: dashed 1px var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
}
.image-container {
display: flex;
max-height: rem(250);
max-width: rem(250);
height: 100%;
}

View File

@ -0,0 +1,18 @@
import { FC } from "react";
import { DealSchema } from "@/lib/client";
import FulfillmentBaseTabBody from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/FulfillmentBaseTabBody/FulfillmentBaseTabBody";
import { FulfillmentBaseContextProvider } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
type Props = {
value: DealSchema;
};
const FulfillmentBaseTab: FC<Props> = ({ value }) => {
return (
<FulfillmentBaseContextProvider deal={value}>
<FulfillmentBaseTabBody />
</FulfillmentBaseContextProvider>
);
};
export default FulfillmentBaseTab;

View File

@ -0,0 +1,22 @@
import { Flex, Stack } from "@mantine/core";
import DealServicesTable from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/components/DealServicesTable/DealServicesTable";
import ProductsActions from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/components/ProductsActions/ProductsActions";
import TotalPriceLabel from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/components/TotalPriceLabel/TotalPriceLabel";
import styles from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/FulfillmentBaseTab.module.css";
const DealInfoView = () => (
<Stack
flex={2}
gap={"sm"}>
<Flex
gap={"sm"}
direction={"column"}
className={styles.container}>
<DealServicesTable />
<ProductsActions />
</Flex>
<TotalPriceLabel />
</Stack>
);
export default DealInfoView;

View File

@ -0,0 +1,42 @@
import { FC } from "react";
import { Flex, ScrollArea } from "@mantine/core";
import DealServicesTitle from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/components/DealServicesTable/components/DealServicesTitle";
import DealServicesTotalLabel from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/components/DealServicesTable/components/DealServicesTotalLabel";
import ServicesActions from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/components/DealServicesTable/components/ServicesActions";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
import DealServiceRow from "./components/DealServiceRow";
const DealServicesTable: FC = () => {
const { dealServicesList, dealServicesCrud } = useFulfillmentBaseContext();
// const isLocked = isDealLocked(deal); // TODO bills
return (
<Flex
direction={"column"}
gap={"sm"}
h={"78vh"}>
<DealServicesTitle />
<ScrollArea
flex={1}
scrollbars={"y"}
offsetScrollbars={"y"}>
<Flex
direction={"column"}
gap={"sm"}>
{dealServicesList.dealServices.map(dealService => (
<DealServiceRow
key={dealService.service.id}
value={dealService}
onDelete={dealServicesCrud.onDelete}
onChange={dealServicesCrud.onUpdate}
/>
))}
</Flex>
</ScrollArea>
<DealServicesTotalLabel />
<ServicesActions />
</Flex>
);
};
export default DealServicesTable;

View File

@ -0,0 +1,88 @@
import { FC } from "react";
import { IconTrash } from "@tabler/icons-react";
import { isNumber } from "lodash";
import { Divider, Group, NumberInput, Stack, Text } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { DealServiceSchema } from "@/lib/client";
import LockCheckbox from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/LockCheckbox/LockCheckbox";
type Props = {
value: DealServiceSchema;
onChange: (
dealId: number,
serviceId: number,
value: DealServiceSchema
) => void;
onDelete: (value: DealServiceSchema) => void;
};
const DealServiceRow: FC<Props> = ({ value, onChange, onDelete }) => {
const debouncedOnChange = useDebouncedCallback(
async (item: DealServiceSchema) => {
onChange(item.dealId, item.serviceId, item);
},
200
);
return (
<Stack
w={"100%"}
gap={"xs"}>
<Divider />
<Text>{value.service.name}</Text>
<Group>
<ActionIconWithTip
onClick={() => onDelete(value)}
tipLabel={"Удалить услугу"}>
<IconTrash />
</ActionIconWithTip>
<NumberInput
flex={1}
suffix={" шт."}
onChange={quantity =>
isNumber(quantity) &&
debouncedOnChange({ ...value, quantity })
}
value={value.quantity}
min={1}
allowNegative={false}
/>
<NumberInput
flex={1}
onChange={price =>
isNumber(price) &&
debouncedOnChange({ ...value, price })
}
suffix={"₽"}
value={value.price}
disabled={value.isFixedPrice}
min={1}
allowNegative={false}
rightSectionProps={{
style: {
display: "flex",
cursor: "pointer",
pointerEvents: "auto",
},
}}
rightSection={
<LockCheckbox
label={"Зафиксировать цену"}
variant={"default"}
value={value.isFixedPrice}
onChange={isFixedPrice =>
debouncedOnChange({
...value,
isFixedPrice,
})
}
/>
}
/>
</Group>
</Stack>
);
};
export default DealServiceRow;

View File

@ -0,0 +1,11 @@
import { Title } from "@mantine/core";
const DealServicesTitle = () => (
<Title
order={3}
w={"100%"}>
Общие услуги
</Title>
);
export default DealServicesTitle;

View File

@ -0,0 +1,26 @@
import { useMemo } from "react";
import { Title } from "@mantine/core";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
const DealServicesTotalLabel = () => {
const { dealServicesList } = useFulfillmentBaseContext();
const total = useMemo(
() =>
dealServicesList.dealServices.reduce(
(acc, item) => acc + item.price * item.quantity,
0
),
[dealServicesList.dealServices]
);
return (
<Title
style={{ textAlign: "end" }}
mt={"sm"}
order={3}>
Итог: {total.toFixed(2)}
</Title>
);
};
export default DealServicesTotalLabel;

View File

@ -0,0 +1,69 @@
import { Button, Flex } from "@mantine/core";
import { modals } from "@mantine/modals";
import { addKitToDeal, ServicesKitSchema } from "@/lib/client";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/types/service";
const ServicesActions = () => {
const { dealServicesList, dealServicesCrud, deal } =
useFulfillmentBaseContext();
const onCreateClick = () => {
const serviceIdsToExclude = dealServicesList.dealServices.map(
service => service.service.id
);
modals.openContextModal({
modal: "dealServiceEditorModal",
innerProps: {
onCreate: values =>
dealServicesCrud.onCreate({ ...values, dealId: deal.id }),
serviceIdsToExclude,
isEditing: false,
},
withCloseButton: false,
});
};
const onServicesKitAdd = (servicesKit: ServicesKitSchema) => {
addKitToDeal({
body: {
dealId: deal.id,
kitId: servicesKit.id,
},
})
.then(() => dealServicesList.refetch())
.catch(err => console.error(err));
};
const onAddKitClick = () => {
modals.openContextModal({
modal: "servicesKitSelectModal",
innerProps: {
onSelect: onServicesKitAdd,
serviceType: ServiceType.DEAL_SERVICE,
},
withCloseButton: false,
});
};
return (
<Flex
gap={"sm"}
mt={"auto"}>
<Button
onClick={onCreateClick}
fullWidth
variant={"default"}>
Добавить услугу
</Button>
<Button
onClick={onAddKitClick}
fullWidth
variant={"default"}>
Добавить набор услуг
</Button>
</Flex>
);
};
export default ServicesActions;

View File

@ -0,0 +1,61 @@
import { FC } from "react";
import { Button, Flex } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
const ProductsActions: FC = () => {
const { deal, dealProductsList, productsCrud, dealProductsCrud } =
useFulfillmentBaseContext();
const onCreateProductClick = () => {
modals.openContextModal({
modal: "productEditorModal",
title: "Создание товара",
withCloseButton: false,
innerProps: {
onCreate: productsCrud.onCreate,
isEditing: false,
},
});
};
const onCreateDealProductClick = () => {
const productIdsToExclude = dealProductsList.dealProducts.map(
product => product.product.id
);
modals.openContextModal({
modal: "dealProductEditorModal",
title: "Добавление товара",
withCloseButton: false,
innerProps: {
onCreate: values =>
dealProductsCrud.onCreate({ ...values, dealId: deal.id }),
productIdsToExclude,
isEditing: false,
clientId: 0, // TODO add clients
},
});
};
return (
<Flex
w={"100%"}
gap={"sm"}>
<Button
variant={"default"}
fullWidth
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
variant={"default"}
fullWidth
onClick={onCreateDealProductClick}>
Добавить товар
</Button>
</Flex>
);
};
export default ProductsActions;

View File

@ -0,0 +1,40 @@
import { useMemo } from "react";
import { Flex, Title } from "@mantine/core";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
import styles from "../../../../FulfillmentBaseTab.module.css";
const TotalPriceLabel = () => {
const {
dealServicesList: { dealServices },
dealProductsList: { dealProducts },
} = useFulfillmentBaseContext();
const totalPrice = useMemo(() => {
const productServicesPrice = dealProducts.reduce(
(acc, row) =>
acc +
row.productServices.reduce(
(acc2, row2) => acc2 + row2.price * row.quantity,
0
),
0
);
const cardServicesPrice = dealServices.reduce(
(acc, row) => acc + row.price * row.quantity,
0
);
return cardServicesPrice + productServicesPrice;
}, [dealServices, dealProducts]);
return (
<Flex
direction={"column"}
className={styles.container}>
<Title order={3}>
Общая стоимость всех услуг: {totalPrice.toLocaleString("ru")}
</Title>
</Flex>
);
};
export default TotalPriceLabel;

View File

@ -0,0 +1,31 @@
import { Flex, ScrollArea, Stack } from "@mantine/core";
import DealInfoView from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/DealInfoView/DealInfoView";
import ProductView from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ProductView/ProductView";
import { useFulfillmentBaseContext } from "../../contexts/FulfillmentBaseContext";
const FulfillmentBaseTabBody = () => {
const { dealProductsList } = useFulfillmentBaseContext();
return (
<Flex
mah={"96vh"}
mx={"md"}
gap={"xs"}>
<ScrollArea
offsetScrollbars={"y"}
flex={4}>
<Stack>
{dealProductsList.dealProducts.map((dealProduct, index) => (
<ProductView
dealProduct={dealProduct}
key={index}
/>
))}
</Stack>
</ScrollArea>
<DealInfoView />
</Flex>
);
};
export default FulfillmentBaseTabBody;

View File

@ -0,0 +1,27 @@
import { FC } from "react";
import { IconLock, IconLockOpen } from "@tabler/icons-react";
import { CheckboxProps } from "@mantine/core";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
type RestProps = {
value: boolean;
onChange: (value: boolean) => void;
};
type Props = Omit<CheckboxProps, "value" | "onChange"> & RestProps;
const LockCheckbox: FC<Props> = props => {
const getIcon = () => (props.value ? <IconLock /> : <IconLockOpen />);
const handleChange = () => props.onChange(!props.value);
return (
<ActionIconWithTip
onClick={handleChange}
variant={props.variant}>
{getIcon()}
</ActionIconWithTip>
);
};
export default LockCheckbox;

View File

@ -0,0 +1,68 @@
import { FC } from "react";
import { DropzoneProps, FileWithPath } from "@mantine/dropzone";
import ImageDropzone from "@/components/ui/ImageDropzone/ImageDropzone";
import { notifications } from "@/lib/notifications";
import BaseFormInputProps from "@/utils/baseFormInputProps";
import useImageDropzone from "./useImageDropzone";
interface RestProps {
imageUrlInputProps?: BaseFormInputProps<string>;
productId?: number;
}
type Props = Omit<DropzoneProps, "onDrop"> & RestProps;
const ProductImageDropzone: FC<Props> = ({
imageUrlInputProps,
productId,
}: Props) => {
const imageDropzoneProps = useImageDropzone({
imageUrlInputProps,
});
const onDrop = (files: FileWithPath[]) => {
if (!productId || !imageUrlInputProps) return;
if (files.length > 1) {
notifications.error({ message: "Прикрепите одно изображение" });
return;
}
const { setIsLoading, setShowDropzone } = imageDropzoneProps;
const file = files[0];
setIsLoading(true);
// TODO SEND REQUEST
// ProductService.uploadProductImage({
// productId,
// formData: {
// upload_file: file,
// },
// })
// .then(({ ok, message, imageUrl }) => {
// notifications.guess(ok, { message });
// setIsLoading(false);
//
// if (!ok || !imageUrl) {
// setShowDropzone(true);
// return;
// }
// imageUrlInputProps?.onChange(imageUrl);
// setShowDropzone(false);
// })
// .catch(error => {
// notifications.error({ message: error.toString() });
// setShowDropzone(true);
// setIsLoading(false);
// });
};
return (
<ImageDropzone
onDrop={onDrop}
imageDropzone={imageDropzoneProps}
/>
);
};
export default ProductImageDropzone;

View File

@ -0,0 +1,27 @@
import { useState } from "react";
import BaseFormInputProps from "@/utils/baseFormInputProps";
type Props = {
imageUrlInputProps?: BaseFormInputProps<string>;
};
const useImageDropzone = ({ imageUrlInputProps }: Props) => {
const [showDropzone, setShowDropzone] = useState(
!(
typeof imageUrlInputProps?.value === "string" &&
imageUrlInputProps.value.trim() !== ""
)
);
const [isLoading, setIsLoading] = useState(false);
return {
showDropzone,
setShowDropzone,
isLoading,
setIsLoading,
imageUrlInputProps,
};
};
export default useImageDropzone;

View File

@ -0,0 +1,55 @@
import { FC, useState } from "react";
import { omit } from "lodash";
import { Loader, OptionsFilter } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import { ProductSchema } from "@/lib/client";
import useProductsList from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/hooks/lists/useProductsList";
import renderProductOption from "./utils/renderProductOption";
type RestProps = {
clientId: number;
};
const MAX_PRODUCTS = 200;
type Props = Omit<ObjectSelectProps<ProductSchema>, "data"> & RestProps;
const ProductSelect: FC<Props> = (props: Props) => {
const [searchValue, setSearchValue] = useState("");
const [debounced] = useDebouncedValue(searchValue, 500);
const { products, isLoading } = useProductsList({
// clientId: props.clientId,
searchInput: debounced,
page: 0,
itemsPerPage: MAX_PRODUCTS,
});
const restProps = omit(props, ["clientId"]);
const optionsFilter: OptionsFilter = ({ options }) => options;
const setSearchValueImpl = (value: string) => {
const names = products.map(product => product.name);
if (names.includes(value)) return;
setSearchValue(value);
};
return (
<ObjectSelect
rightSection={
isLoading || searchValue !== debounced ? (
<Loader size={"sm"} />
) : null
}
onSearchChange={setSearchValueImpl}
renderOption={renderProductOption(products)}
searchable
{...restProps}
data={products}
filter={optionsFilter}
/>
);
};
export default ProductSelect;

View File

@ -0,0 +1,51 @@
import {
ComboboxItem,
ComboboxLikeRenderOptionInput,
SelectProps,
Tooltip,
} from "@mantine/core";
import { ProductSchema } from "@/lib/client";
import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ProductView/components/ProductFieldsList";
const renderProductOption = (
products: ProductSchema[]
): SelectProps["renderOption"] => {
return (item: ComboboxLikeRenderOptionInput<ComboboxItem>) => {
const product = products.find(
product => product.id === Number(item.option.value)
);
if (!product) return item.option.label;
// const imageUrl =
// product.images && product.images[0]
// ? product.images[0].imageUrl
// : undefined;
return (
<Tooltip
style={{ whiteSpace: "pre-line" }}
multiline
label={
<>
<ProductFieldsList product={product} />
{/*{imageUrl && (*/}
{/* <Image*/}
{/* src={imageUrl}*/}
{/* alt={product.name}*/}
{/* maw={rem(250)}*/}
{/* />*/}
{/*)}*/}
</>
}>
<div>
{product.name}
<br />
{/*{product.barcodes && (*/}
{/* <Text size={"xs"}>{product.barcodes[0]}</Text>*/}
{/*)}*/}
</div>
</Tooltip>
);
};
};
export default renderProductOption;

View File

@ -0,0 +1,156 @@
import { FC } from "react";
import { isNumber } from "lodash";
import {
Flex,
Image,
NumberInput,
Stack,
Textarea,
Title,
} from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { modals } from "@mantine/modals";
import {
addKitToDealProduct,
DealProductSchema,
duplicateProductServices,
ServicesKitSchema,
} from "@/lib/client";
import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ProductView/components/ProductFieldsList";
import ProductViewActions from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ProductView/components/ProductViewActions";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/types/service";
import ProductServicesTable from "./components/ProductServicesTable";
import styles from "../../FulfillmentBaseTab.module.css";
type Props = {
dealProduct: DealProductSchema;
};
const ProductView: FC<Props> = ({ dealProduct }) => {
const { dealProductsCrud, deal, dealProductsList } =
useFulfillmentBaseContext();
const debouncedOnChange = useDebouncedCallback(
(newValues: Partial<DealProductSchema>) => {
dealProductsCrud.onUpdate(
dealProduct.dealId,
dealProduct.productId,
{
...dealProduct,
...newValues,
}
);
},
200
);
const duplicateServices = (
sourceDealProduct: DealProductSchema,
targetDealProducts: DealProductSchema[]
) => {
duplicateProductServices({
body: {
dealId: deal.id,
sourceDealProductId: sourceDealProduct.productId,
targetDealProductIds: targetDealProducts.map(p => p.productId),
},
})
.then(() => dealProductsList.refetch())
.catch(err => console.error(err));
};
const onDuplicateServices = (sourceDealProduct: DealProductSchema) => {
modals.openContextModal({
modal: "duplicateServicesModal",
title: "Дублирование услуг",
size: "lg",
innerProps: {
dealProducts: dealProductsList.dealProducts,
sourceDealProduct,
duplicateServices,
},
withCloseButton: false,
});
};
const onServicesKitAdd = (servicesKit: ServicesKitSchema) => {
addKitToDealProduct({
body: {
dealId: dealProduct.dealId,
productId: dealProduct.productId,
kitId: servicesKit.id,
},
})
.then(() => dealProductsList.refetch())
.catch(err => console.error(err));
};
const onAddKitClick = () => {
modals.openContextModal({
modal: "servicesKitSelectModal",
innerProps: {
onSelect: onServicesKitAdd,
serviceType: ServiceType.PRODUCT_SERVICE,
},
withCloseButton: false,
});
};
return (
<Flex
align={"start"}
direction={"row"}
className={styles.container}
gap={"sm"}>
<Stack
flex={2}
gap={"sm"}>
{!dealProduct.product && (
<Image
flex={1}
radius={"md"}
fit={"cover"}
// src={dealProduct.product.imageUrl}
/>
)}
<Title order={3}>{dealProduct.product.name}</Title>
<ProductFieldsList product={dealProduct.product} />
{/*<Text>*/}
{/* Штрихкоды:*/}
{/*{value.product.barcodes.join(", ")}*/}
{/*</Text>*/}
<NumberInput
suffix={" шт."}
value={dealProduct.quantity}
onChange={quantity =>
isNumber(quantity) && debouncedOnChange({ quantity })
}
placeholder={"Введите количество товара"}
min={1}
allowNegative={false}
/>
<Textarea
defaultValue={dealProduct.comment}
onChange={event =>
debouncedOnChange({
comment: event.currentTarget.value,
})
}
rows={4}
placeholder={"Комментарий"}
/>
</Stack>
<Stack flex={5}>
<ProductServicesTable
dealProduct={dealProduct}
onDuplicateServices={() => onDuplicateServices(dealProduct)}
onKitAdd={onAddKitClick}
/>
<ProductViewActions dealProduct={dealProduct} />
</Stack>
</Flex>
);
};
export default ProductView;

View File

@ -0,0 +1,37 @@
import { FC } from "react";
import { isNil } from "lodash";
import { Text } from "@mantine/core";
import { ProductSchema } from "@/lib/client";
type ProductFieldNames = {
[K in keyof ProductSchema]: string;
};
export const ProductFieldNames: Partial<ProductFieldNames> = {
color: "Цвет",
article: "Артикул",
size: "Размер",
brand: "Бренд",
composition: "Состав",
additionalInfo: "Доп. информация",
};
type Props = {
product: ProductSchema;
};
const ProductFieldsList: FC<Props> = ({ product }) => {
const fieldList = Object.entries(product).map(([key, value]) => {
const fieldName = ProductFieldNames[key as keyof ProductSchema];
if (!fieldName || isNil(value) || value === "") return null;
return (
<Text key={fieldName}>
{fieldName}: {value.toString()}{" "}
</Text>
);
});
return <>{fieldList}</>;
};
export default ProductFieldsList;

View File

@ -0,0 +1,127 @@
import { FC } from "react";
import { IconCopy, IconMoodSad } from "@tabler/icons-react";
import { Button, Flex, Group, Stack, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { DealProductSchema, ProductServiceSchema } from "@/lib/client";
import useProductServicesTableColumns from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ProductView/hooks/useProductServicesTableColumns";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
type Props = {
dealProduct: DealProductSchema;
onDuplicateServices?: () => void;
onKitAdd?: () => void;
};
const ProductServicesTable: FC<Props> = ({
dealProduct,
onDuplicateServices,
onKitAdd,
}) => {
const { productServiceCrud, dealProductsList } =
useFulfillmentBaseContext();
const onChange = (item: ProductServiceSchema) => {
const excludeServiceIds = dealProduct.productServices.map(
productService => productService.service.id
);
const totalQuantity = dealProductsList.dealProducts.reduce(
(sum, prod) => prod.quantity + sum,
0
);
modals.openContextModal({
modal: "productServiceEditorModal",
innerProps: {
entity: item,
onChange: values =>
productServiceCrud.onUpdate(
item.dealId,
item.productId,
item.serviceId,
values
),
excludeServiceIds,
quantity: totalQuantity,
isEditing: true,
},
withCloseButton: false,
});
};
const columns = useProductServicesTableColumns({
data: dealProduct.productServices,
quantity: dealProduct.quantity,
onDelete: productServiceCrud.onDelete,
onChange,
});
const onCreateClick = () => {
const excludeServiceIds = dealProduct.productServices.map(
productService => productService.service.id
);
modals.openContextModal({
modal: "productServiceEditorModal",
innerProps: {
onCreate: values =>
productServiceCrud.onCreate({ ...dealProduct, ...values }),
excludeServiceIds,
quantity: dealProduct.quantity,
isEditing: false,
},
withCloseButton: false,
});
};
const isEmptyTable = dealProduct.productServices.length === 0;
return (
<Stack gap={0}>
<BaseTable
records={dealProduct.productServices}
columns={columns}
groups={undefined}
idAccessor={"serviceId"}
withTableBorder
style={{
height: isEmptyTable ? "8rem" : "auto",
}}
emptyState={
<Group
gap={"xs"}
mt={isEmptyTable ? "xl" : 0}>
<Text>Нет услуг</Text>
<IconMoodSad />
</Group>
}
/>
<Flex
justify={"flex-end"}
gap={"xs"}
pt={"xs"}>
{onDuplicateServices && (
<ActionIconWithTip
tipLabel={"Продублировать услуги"}
onClick={onDuplicateServices}>
<IconCopy />
</ActionIconWithTip>
)}
{onKitAdd && (
<Button
onClick={onKitAdd}
variant={"default"}>
Добавить набор услуг
</Button>
)}
<Button
onClick={onCreateClick}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
</Stack>
);
};
export default ProductServicesTable;

View File

@ -0,0 +1,61 @@
import { FC } from "react";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { Flex } from "@mantine/core";
import { modals } from "@mantine/modals";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { DealProductSchema } from "@/lib/client";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
type Props = {
dealProduct: DealProductSchema;
};
const ProductViewActions: FC<Props> = ({ dealProduct }) => {
const { dealProductsCrud, dealProductsList, productsCrud } =
useFulfillmentBaseContext();
const onProductEditClick = () => {
modals.openContextModal({
modal: "productEditorModal",
title: "Редактирование товара",
withCloseButton: false,
innerProps: {
onChange: values =>
productsCrud.onUpdate(
dealProduct.productId,
values,
dealProductsList.refetch
),
entity: dealProduct.product,
isEditing: true,
},
});
};
return (
<Flex
mt={"auto"}
ml={"auto"}
gap={"sm"}>
{/*<Tooltip*/}
{/* onClick={onPrintBarcodeClick}*/}
{/* label="Печать штрихкода">*/}
{/* <ActionIcon variant={"default"}>*/}
{/* <IconBarcode />*/}
{/* </ActionIcon>*/}
{/*</Tooltip>*/}
<ActionIconWithTip
onClick={onProductEditClick}
tipLabel="Редактировать товар">
<IconEdit />
</ActionIconWithTip>
<ActionIconWithTip
onClick={() => dealProductsCrud.onDelete(dealProduct)}
tipLabel="Удалить товар">
<IconTrash />
</ActionIconWithTip>
</Flex>
);
};
export default ProductViewActions;

View File

@ -0,0 +1,72 @@
import { useMemo } from "react";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Box, Flex, Text } from "@mantine/core";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { ProductServiceSchema } from "@/lib/client";
type Props = {
data: ProductServiceSchema[];
quantity: number;
onChange: (dealProductService: ProductServiceSchema) => void;
onDelete: (dealProductService: ProductServiceSchema) => void;
};
const useProductServicesTableColumns = ({
data,
quantity,
onChange,
onDelete,
}: Props) => {
const totalPrice = useMemo(
() => data.reduce((acc, row) => acc + row.price * quantity, 0),
[data, quantity]
);
return useMemo(
() =>
[
{
accessor: "actions",
title: "Действия",
textAlign: "center",
width: "0%",
render: dealProductService => (
<Flex
gap="md"
px={"sm"}
my={"sm"}>
<ActionIconWithTip
tipLabel={"Удалить услугу"}
onClick={() => onDelete(dealProductService)}>
<IconTrash />
</ActionIconWithTip>
<ActionIconWithTip
tipLabel="Редактировать"
onClick={() => onChange(dealProductService)}>
<IconEdit />
</ActionIconWithTip>
</Flex>
),
},
{
accessor: "service.name",
title: "Услуга",
},
{
accessor: "price",
title: "Цена",
footer: data.length > 0 && (
<Box my={"sm"}>
<Text fw={700}>
Итог: {totalPrice.toLocaleString("ru")}
</Text>
</Box>
),
},
] as DataTableColumn<ProductServiceSchema>[],
[totalPrice]
);
};
export default useProductServicesTableColumns;

View File

@ -0,0 +1,54 @@
import { FC } from "react";
import { omit } from "lodash";
import {
ComboboxItem,
ComboboxParsedItemGroup,
OptionsFilter,
} from "@mantine/core";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import { ServiceSchema } from "@/lib/client";
import useServicesList from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/hooks/lists/useServicesList";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/types/service";
type RestProps = {
filterType?: ServiceType;
};
type Props = Omit<ObjectSelectProps<ServiceSchema>, "data"> & RestProps;
const ServiceSelect: FC<Props> = props => {
const { services } = useServicesList();
const data =
props.filterType !== undefined
? services.filter(
service => service.serviceType === props.filterType
)
: services;
const restProps = omit(props, ["filterType"]);
const optionsFilter: OptionsFilter = ({ options, search }) => {
return (options as ComboboxParsedItemGroup[]).map(option => {
return {
...option,
items: option.items.filter((item: ComboboxItem) =>
item.label.toLowerCase().includes(search.toLowerCase())
),
};
});
};
return (
<ObjectSelect
{...restProps}
data={data}
searchable
groupBy={service => service.category.name}
filter={optionsFilter}
/>
);
};
export default ServiceSelect;

View File

@ -0,0 +1,68 @@
"use client";
import { DealSchema } from "@/lib/client";
import { getProductsQueryKey } from "@/lib/client/@tanstack/react-query.gen";
import makeContext from "@/lib/contextFactory/contextFactory";
import useDealServicesCrud, {
DealServicesCrud,
} from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/hooks/cruds/useDealServiceCrud";
import useProductServiceCrud, {
DealProductServicesCrud,
} from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/hooks/cruds/useProductServiceCrud";
import useDealServicesList, {
DealServicesList,
} from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/hooks/lists/useDealServicesList";
import useDealProductCrud, {
DealProductsCrud,
} from "../hooks/cruds/useDealProductCrud";
import { ProductsCrud, useProductsCrud } from "../hooks/cruds/useProductsCrud";
import useDealProductsList, {
DealProductsList,
} from "../hooks/lists/useDealProductsList";
type FulfillmentBaseContextState = {
deal: DealSchema;
productsCrud: ProductsCrud;
dealProductsList: DealProductsList;
dealProductsCrud: DealProductsCrud;
dealServicesList: DealServicesList;
dealServicesCrud: DealServicesCrud;
productServiceCrud: DealProductServicesCrud;
};
type Props = {
deal: DealSchema;
};
const useFulfillmentBaseContextState = ({
deal,
}: Props): FulfillmentBaseContextState => {
const productQueryKey = getProductsQueryKey();
const productsCrud = useProductsCrud({ queryKey: productQueryKey });
const dealProductsList = useDealProductsList({ dealId: deal.id });
const dealProductsCrud = useDealProductCrud({ dealId: deal.id });
const dealServicesList = useDealServicesList({ dealId: deal.id });
const dealServicesCrud = useDealServicesCrud({ dealId: deal.id });
const productServiceCrud = useProductServiceCrud({
refetchDealProducts: dealProductsList.refetch,
});
return {
deal,
productsCrud,
dealProductsList,
dealProductsCrud,
dealServicesList,
dealServicesCrud,
productServiceCrud,
};
};
export const [FulfillmentBaseContextProvider, useFulfillmentBaseContext] =
makeContext<FulfillmentBaseContextState, Props>(
useFulfillmentBaseContextState,
"FulfillmentBase"
);

View File

@ -0,0 +1,153 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import getCommonQueryClient from "@/hooks/cruds/baseCrud/getCommonQueryClient";
import {
CreateDealProductSchema,
DealProductSchema,
UpdateDealProductSchema,
} from "@/lib/client";
import {
createDealProductMutation,
deleteDealProductMutation,
getDealProductsQueryKey,
updateDealProductMutation,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
dealId: number;
};
export type DealProductsCrud = {
onCreate: (data: CreateDealProductSchema) => void;
onUpdate: (
dealId: number,
serviceId: number,
data: UpdateDealProductSchema
) => void;
onDelete: (data: DealProductSchema, onSuccess?: () => void) => void;
};
const useDealProductCrud = ({ dealId }: Props): DealProductsCrud => {
const queryKey = getDealProductsQueryKey({ path: { dealId } });
const key = "getDealProducts";
const { queryClient, onError, onSettled } = getCommonQueryClient({
queryKey,
key,
});
const createMutation = useMutation({
...createDealProductMutation(),
onError,
onSettled,
});
const updateMutation = useMutation({
...updateDealProductMutation(),
onError,
onSettled,
onMutate: async ({
body: { entity: update },
path: { dealId, productId },
}) => {
await queryClient.cancelQueries({ queryKey: [key] });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(
queryKey,
(old: { items: DealProductSchema[] }) => {
const updated: DealProductSchema[] = old.items.map(
(entity: DealProductSchema) =>
entity.dealId === dealId &&
entity.productId === productId
? { ...entity, ...update }
: entity
);
return {
...old,
items: updated,
};
}
);
return { previous };
},
});
const deleteMutation = useMutation({
...deleteDealProductMutation(),
onError,
onSettled,
onMutate: async ({ path: { dealId, productId } }) => {
await queryClient.cancelQueries({ queryKey: [key] });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(
queryKey,
(old: { items: DealProductSchema[] }) => {
const filtered = old.items.filter(
e => e.dealId !== dealId && e.productId !== productId
);
return {
...old,
items: filtered,
};
}
);
return { previous };
},
});
const onCreate = (entity: CreateDealProductSchema) => {
createMutation.mutate({
body: {
entity,
},
});
};
const onUpdate = (
dealId: number,
productId: number,
entity: UpdateDealProductSchema
) => {
updateMutation.mutate({
body: {
entity,
},
path: {
dealId,
productId,
},
});
};
const onDelete = (entity: DealProductSchema, onSuccess?: () => void) => {
modals.openConfirmModal({
title: "Удаление товара из сделки",
children: (
<Text>
Вы уверены, что хотите удалить "{entity.product.name}" из
сделки?
</Text>
),
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate({ path: entity }, { onSuccess });
},
});
};
return {
onCreate,
onUpdate,
onDelete,
};
};
export default useDealProductCrud;

View File

@ -0,0 +1,153 @@
import React from "react";
import { useMutation } from "@tanstack/react-query";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import getCommonQueryClient from "@/hooks/cruds/baseCrud/getCommonQueryClient";
import {
CreateDealServiceSchema,
DealServiceSchema,
UpdateDealServiceSchema,
} from "@/lib/client";
import {
createDealServiceMutation,
deleteDealServiceMutation,
getDealServicesQueryKey,
updateDealServiceMutation,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
dealId: number;
};
export type DealServicesCrud = {
onCreate: (data: CreateDealServiceSchema) => void;
onUpdate: (
dealId: number,
serviceId: number,
data: UpdateDealServiceSchema
) => void;
onDelete: (data: DealServiceSchema, onSuccess?: () => void) => void;
};
const useDealServiceCrud = ({ dealId }: Props): DealServicesCrud => {
const queryKey = getDealServicesQueryKey({ path: { dealId } });
const key = "getDealServices";
const { queryClient, onError, onSettled } = getCommonQueryClient({
queryKey,
key,
});
const createMutation = useMutation({
...createDealServiceMutation(),
onError,
onSettled,
});
const updateMutation = useMutation({
...updateDealServiceMutation(),
onError,
onSettled,
onMutate: async ({
body: { entity: update },
path: { dealId, serviceId },
}) => {
await queryClient.cancelQueries({ queryKey: [key] });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(
queryKey,
(old: { items: DealServiceSchema[] }) => {
const updated: DealServiceSchema[] = old.items.map(
(entity: DealServiceSchema) =>
entity.dealId === dealId &&
entity.serviceId === serviceId
? { ...entity, ...update }
: entity
);
return {
...old,
items: updated,
};
}
);
return { previous };
},
});
const deleteMutation = useMutation({
...deleteDealServiceMutation(),
onError,
onSettled,
onMutate: async ({ path: { dealId, serviceId } }) => {
await queryClient.cancelQueries({ queryKey: [key] });
const previous = queryClient.getQueryData(queryKey);
queryClient.setQueryData(
queryKey,
(old: { items: DealServiceSchema[] }) => {
const filtered = old.items.filter(
e => e.dealId !== dealId && e.serviceId !== serviceId
);
return {
...old,
items: filtered,
};
}
);
return { previous };
},
});
const onCreate = (entity: CreateDealServiceSchema) => {
createMutation.mutate({
body: {
entity,
},
});
};
const onUpdate = (
dealId: number,
serviceId: number,
entity: UpdateDealServiceSchema
) => {
updateMutation.mutate({
body: {
entity,
},
path: {
dealId,
serviceId,
},
});
};
const onDelete = (entity: DealServiceSchema, onSuccess?: () => void) => {
modals.openConfirmModal({
title: "Удаление услуги из сделки",
children: (
<Text>
Вы уверены, что хотите удалить "{entity.service.name}" из
сделки?
</Text>
),
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate({ path: entity }, { onSuccess });
},
});
};
return {
onCreate,
onUpdate,
onDelete,
};
};
export default useDealServiceCrud;

View File

@ -0,0 +1,102 @@
import { useMutation } from "@tanstack/react-query";
import { AxiosError } from "axios";
import {
CreateProductServiceSchema,
ProductServiceSchema,
HttpValidationError,
UpdateProductServiceSchema,
} from "@/lib/client";
import {
createDealProductServiceMutation,
deleteDealProductServiceMutation,
updateDealProductServiceMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
export type DealProductServicesCrud = {
onCreate: (data: CreateProductServiceSchema) => void;
onUpdate: (
dealId: number,
productId: number,
serviceId: number,
data: UpdateProductServiceSchema
) => void;
onDelete: (data: ProductServiceSchema) => void;
};
type Props = {
refetchDealProducts: () => void;
};
const useProductServiceCrud = ({
refetchDealProducts,
}: Props): DealProductServicesCrud => {
const onError = (error: AxiosError<HttpValidationError>, _: any) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
};
const createMutation = useMutation({
...createDealProductServiceMutation(),
onError,
});
const updateMutation = useMutation({
...updateDealProductServiceMutation(),
onError,
});
const deleteMutation = useMutation({
...deleteDealProductServiceMutation(),
onError,
});
const onCreate = (entity: CreateProductServiceSchema) => {
createMutation.mutate(
{
body: {
entity,
},
},
{ onSuccess: refetchDealProducts }
);
};
const onUpdate = (
dealId: number,
productId: number,
serviceId: number,
entity: UpdateProductServiceSchema
) => {
updateMutation.mutate(
{
body: {
entity,
},
path: {
dealId,
productId,
serviceId,
},
},
{ onSuccess: refetchDealProducts }
);
};
const onDelete = (entity: ProductServiceSchema) => {
deleteMutation.mutate(
{ path: entity },
{ onSuccess: refetchDealProducts }
);
};
return {
onCreate,
onUpdate,
onDelete,
};
};
export default useProductServiceCrud;

View File

@ -0,0 +1,49 @@
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
import {
CreateProductSchema,
ProductSchema,
UpdateProductSchema,
} from "@/lib/client";
import {
createProductMutation,
deleteProductMutation,
updateProductMutation,
} from "@/lib/client/@tanstack/react-query.gen";
type UseProductsProps = {
queryKey: any[];
};
export type ProductsCrud = {
onCreate: (product: CreateProductSchema) => void;
onUpdate: (
productId: number,
product: UpdateProductSchema,
onSuccess?: () => void
) => void;
onDelete: (product: ProductSchema) => void;
};
export const useProductsCrud = ({
queryKey,
}: UseProductsProps): ProductsCrud => {
return useCrudOperations<
ProductSchema,
UpdateProductSchema,
CreateProductSchema
>({
key: "getProducts",
queryKey,
mutations: {
create: createProductMutation(),
update: updateProductMutation(),
delete: deleteProductMutation(),
},
getUpdateEntity: (old, update) =>
({
...old,
...update,
}) as ProductSchema,
getDeleteConfirmTitle: () => "Удаление товара",
});
};

View File

@ -0,0 +1,49 @@
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
import {
CreateServiceSchema,
ServiceSchema,
UpdateServiceSchema,
} from "@/lib/client";
import {
createServiceMutation,
deleteServiceMutation,
updateServiceMutation,
} from "@/lib/client/@tanstack/react-query.gen";
type UseServicesProps = {
queryKey: any[];
};
export type ServicesCrud = {
onCreate: (service: CreateServiceSchema) => void;
onUpdate: (
serviceId: number,
service: UpdateServiceSchema,
onSuccess?: () => void
) => void;
onDelete: (service: ServiceSchema) => void;
};
export const useServicesCrud = ({
queryKey,
}: UseServicesProps): ServicesCrud => {
return useCrudOperations<
ServiceSchema,
UpdateServiceSchema,
CreateServiceSchema
>({
key: "getServices",
queryKey,
mutations: {
create: createServiceMutation(),
update: updateServiceMutation(),
delete: deleteServiceMutation(),
},
getUpdateEntity: (old, update) =>
({
...old,
...update,
}) as ServiceSchema,
getDeleteConfirmTitle: () => "Удаление услуги",
});
};

View File

@ -0,0 +1,49 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DealProductSchema } from "@/lib/client";
import {
getDealProductsOptions,
getDealProductsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
dealId: number;
};
export type DealProductsList = {
dealProducts: DealProductSchema[];
setDealProducts: (dealProducts: DealProductSchema[]) => void;
refetch: () => void;
queryKey: any[];
};
const useDealProductsList = ({ dealId }: Props): DealProductsList => {
const queryClient = useQueryClient();
const options = {
path: { dealId },
};
const { data, refetch } = useQuery({
...getDealProductsOptions(options),
enabled: !!dealId,
});
const queryKey = getDealProductsQueryKey(options);
const setDealProducts = (dealProducts: DealProductSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: DealProductSchema[] }) => ({
...old,
items: dealProducts,
})
);
};
return {
dealProducts: data?.items ?? [],
setDealProducts,
refetch,
queryKey,
};
};
export default useDealProductsList;

View File

@ -0,0 +1,49 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DealServiceSchema } from "@/lib/client";
import {
getDealServicesOptions,
getDealServicesQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
dealId: number;
};
export type DealServicesList = {
dealServices: DealServiceSchema[];
setDealServices: (dealServices: DealServiceSchema[]) => void;
refetch: () => void;
queryKey: any[];
};
const useDealServicesList = ({ dealId }: Props): DealServicesList => {
const queryClient = useQueryClient();
const options = {
path: { dealId },
};
const { data, refetch } = useQuery({
...getDealServicesOptions(options),
enabled: !!dealId,
});
const queryKey = getDealServicesQueryKey(options);
const setDealServices = (dealServices: DealServiceSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: DealServiceSchema[] }) => ({
...old,
items: dealServices,
})
);
};
return {
dealServices: data?.items ?? [],
setDealServices,
refetch,
queryKey,
};
};
export default useDealServicesList;

View File

@ -0,0 +1,42 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ProductSchema } from "@/lib/client";
import {
getProductsOptions,
getProductsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
searchInput: string;
page?: number;
itemsPerPage?: number;
};
const useProductsList = ({ searchInput, page, itemsPerPage }: Props) => {
const queryClient = useQueryClient();
const options = {
query: { searchInput, page, itemsPerPage },
};
const { data, refetch, isLoading } = useQuery(getProductsOptions(options));
const queryKey = getProductsQueryKey(options);
const setProducts = (products: ProductSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: ProductSchema[] }) => ({
...old,
items: products,
})
);
};
return {
products: data?.items ?? [],
setProducts,
refetch,
queryKey,
isLoading,
};
};
export default useProductsList;

View File

@ -0,0 +1,33 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ServicesKitSchema } from "@/lib/client";
import {
getServicesKitsOptions,
getServicesKitsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
const useServicesKitsList = () => {
const queryClient = useQueryClient();
const { data, refetch, isLoading } = useQuery(getServicesKitsOptions());
const queryKey = getServicesKitsQueryKey();
const setServicesKits = (servicesKits: ServicesKitSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: ServicesKitSchema[] }) => ({
...old,
items: servicesKits,
})
);
};
return {
servicesKits: data?.items ?? [],
setServicesKits,
refetch,
queryKey,
isLoading,
};
};
export default useServicesKitsList;

View File

@ -0,0 +1,34 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ServiceSchema } from "@/lib/client";
import {
getServicesOptions,
getServicesQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
const useServicesList = () => {
const queryClient = useQueryClient();
const { data, refetch, isLoading } = useQuery(getServicesOptions());
const queryKey = getServicesQueryKey();
const setServices = (services: ServiceSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: ServiceSchema[] }) => ({
...old,
items: services,
})
);
};
return {
services: data?.items ?? [],
setServices,
refetch,
queryKey,
isLoading,
};
};
export default useServicesList;

View File

@ -0,0 +1,148 @@
import { CRUDTableProps } from "../../../../../types/CRUDTable.tsx";
import { CardService, CardServiceSchema, CardProductSchema } from "../../../../../client";
import { useCardPageContext } from "../../../../../pages/CardsPage/contexts/CardPageContext.tsx";
import { notifications } from "../../../../../shared/lib/notifications.ts";
const useCardState = () => {
const { selectedCard, setSelectedCard } = useCardPageContext();
const recalculate = async () => {
return CardService.recalculateCardPrice({
requestBody: {
cardId: selectedCard?.id || -1,
},
});
};
const refetchCard = async () => {
if (!selectedCard) return;
return CardService.getCardById({ cardId: selectedCard.id }).then(
async card => {
setSelectedCard(card);
},
);
};
const refetch = async () => {
if (!selectedCard) return;
await refetchCard();
const { ok, message } = await recalculate();
if (!ok) notifications.guess(ok, { message });
await refetchCard();
};
return {
card: selectedCard,
refetch,
};
};
const useCardServicesState = (): CRUDTableProps<CardServiceSchema> => {
const { card, refetch } = useCardState();
const refetchAndRecalculate = async () => {
await refetch();
};
const onCreate = (item: CardServiceSchema) => {
if (!card) return;
CardService.addCardService({
requestBody: {
cardId: card.id,
serviceId: item.service.id,
quantity: item.quantity,
price: item.price,
},
}).then(async ({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
if (ok) await refetchAndRecalculate();
});
};
const onDelete = (item: CardServiceSchema) => {
if (!card) return;
CardService.deleteCardService({
requestBody: {
cardId: card.id,
serviceId: item.service.id,
},
}).then(async ({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
if (ok) await refetchAndRecalculate();
});
};
const onChange = (item: CardServiceSchema) => {
if (!card) return;
CardService.updateCardService({
requestBody: {
cardId: card.id,
service: item,
},
}).then(async ({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
if (ok) await refetchAndRecalculate();
});
};
return {
items: card?.services || [],
onCreate,
onDelete,
onChange,
};
};
const useDealProductsState = (): CRUDTableProps<CardProductSchema> => {
const { card, refetch } = useCardState();
const refetchAndRecalculate = async () => {
await refetch();
};
const onCreate = (item: CardProductSchema) => {
if (!card) return;
CardService.addCardProduct({
requestBody: {
cardId: card.id,
product: item,
},
}).then(async ({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
if (ok) await refetchAndRecalculate();
});
};
const onDelete = (item: CardProductSchema) => {
if (!card) return;
CardService.deleteCardProduct({
requestBody: {
cardId: card.id,
productId: item.product.id,
},
}).then(async ({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
if (ok) await refetchAndRecalculate();
});
};
const onChange = (item: CardProductSchema) => {
if (!card) return;
CardService.updateCardProduct({
requestBody: {
cardId: card.id,
product: item,
},
}).then(async ({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
if (ok) await refetchAndRecalculate();
});
};
return {
items: card?.products || [],
onCreate,
onDelete,
onChange,
};
};
const useCardProductAndServiceTabState = () => {
const cardState = useCardState();
const cardProductsState = useDealProductsState();
const cardServicesState = useCardServicesState();
return {
cardState,
cardProductsState,
cardServicesState,
};
};
export default useCardProductAndServiceTabState;

View File

@ -0,0 +1,87 @@
"use client";
import { NumberInput, Textarea } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import {
CreateDealProductSchema,
DealProductSchema,
UpdateDealProductSchema,
} from "@/lib/client";
import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
import ProductSelect from "../../components/ProductSelect/ProductSelect";
type RestProps = {
clientId: number;
productIdsToExclude?: number[];
};
type Props = CreateEditFormProps<
CreateDealProductSchema,
UpdateDealProductSchema,
DealProductSchema
> &
RestProps;
const DealProductEditorModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const form = useForm<Partial<DealProductSchema>>({
initialValues: innerProps.isEditing
? innerProps.entity
: {
quantity: 1,
comment: "",
},
validate: {
product: product => !product && "Необходимо выбрать товар",
quantity: quantity =>
(!quantity || quantity === 0) &&
"Количество должно быть больше 0",
},
});
const onClose = () => {
context.closeContextModal(id);
};
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
onClose={onClose}>
<ProductSelect
placeholder={"Выберите товар"}
label={"Товар"}
clientId={innerProps.clientId}
disabled={innerProps.isEditing}
filterBy={item =>
!(innerProps.productIdsToExclude || []).includes(item.id)
}
{...form.getInputProps("product")}
onChange={product => {
form.setFieldValue("product", product);
form.setFieldValue("productId", product.id);
}}
/>
<NumberInput
placeholder={"Введите количество"}
label={"Количество"}
min={1}
{...form.getInputProps("quantity")}
/>
<Textarea
placeholder={"Введите комментарий"}
label={"Комментарий"}
{...form.getInputProps("comment")}
/>
</BaseFormModal>
);
};
export default DealProductEditorModal;

View File

@ -0,0 +1,91 @@
"use client";
import { NumberInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import {
CreateDealServiceSchema,
DealServiceSchema,
UpdateDealServiceSchema,
} from "@/lib/client";
import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/types/service";
import ServiceWithPriceInput from "./components/ServiceWithPriceInput";
type RestProps = {
serviceIdsToExclude?: number[];
};
type Props = CreateEditFormProps<
CreateDealServiceSchema,
UpdateDealServiceSchema,
DealServiceSchema
> &
RestProps;
const DealServiceEditorModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const form = useForm<Partial<DealServiceSchema>>({
initialValues: innerProps.isEditing
? innerProps.entity
: {
service: undefined,
serviceId: undefined,
quantity: 1,
isFixedPrice: false,
},
validate: {
service: service => !service && "Необходимо выбрать услугу",
quantity: quantity =>
(!quantity || quantity === 0) &&
"Количество должно быть больше 0",
},
});
const onClose = () => context.closeContextModal(id);
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
onClose={onClose}>
<ServiceWithPriceInput
serviceProps={{
...form.getInputProps("service"),
onChange: service => {
form.setFieldValue("service", service);
form.setFieldValue("serviceId", service.id);
},
label: "Услуга",
placeholder: "Выберите услугу",
disabled: innerProps.isEditing,
filterBy: item =>
!innerProps.serviceIdsToExclude?.includes(item.id) ||
innerProps.isEditing,
}}
priceProps={{
...form.getInputProps("price"),
label: "Цена",
placeholder: "Введите цену",
}}
quantity={form.values.quantity || 1}
filterType={ServiceType.DEAL_SERVICE}
lockOnEdit={innerProps.isEditing}
/>
<NumberInput
placeholder={"Введите количество"}
label={"Количество"}
min={1}
{...form.getInputProps("quantity")}
/>
</BaseFormModal>
);
};
export default DealServiceEditorModal;

View File

@ -0,0 +1,115 @@
"use client";
import { FC, useEffect, useRef, useState } from "react";
import {
NumberInput,
NumberInputProps,
Stack,
StackProps,
} from "@mantine/core";
import { ObjectSelectProps } from "@/components/selects/ObjectSelect/ObjectSelect";
import { ServiceSchema } from "@/lib/client";
import ServiceSelect from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ServiceSelect/ServiceSelect";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/types/service";
type ServiceProps = Omit<ObjectSelectProps<ServiceSchema>, "data">;
type PriceProps = NumberInputProps;
type Props = {
serviceProps: ServiceProps;
priceProps: PriceProps;
quantity: number;
containerProps?: StackProps;
filterType?: ServiceType;
lockOnEdit?: boolean;
};
const ServiceWithPriceInput: FC<Props> = ({
serviceProps,
priceProps,
quantity,
containerProps,
filterType = ServiceType.PRODUCT_SERVICE,
lockOnEdit = true,
}) => {
const [price, setPrice] = useState<number | undefined>(
typeof priceProps.value === "number" ? priceProps.value : undefined
);
const [service, setService] = useState<ServiceSchema | undefined>(
serviceProps.value
);
const isFirstRender = useRef(true);
const getPriceBasedOnQuantity = (): number | null => {
if (!service || !service.priceRanges.length) return null;
const range =
service.priceRanges.find(
priceRange =>
quantity >= priceRange.fromQuantity &&
quantity <= priceRange.toQuantity
) || service.priceRanges[0];
return range.price;
};
const setPriceBasedOnService = () => {
if (!service) return;
const rangePrice = getPriceBasedOnQuantity();
setPrice(rangePrice || service.price);
};
const onPriceManualChange = (value: number | string) => {
if (typeof value !== "number") return;
setPrice(value);
};
useEffect(() => {
if (isFirstRender.current && lockOnEdit) return;
const price = getPriceBasedOnQuantity();
if (price) setPrice(price);
}, [quantity]);
useEffect(() => {
if (isFirstRender.current && lockOnEdit) return;
if (!priceProps.onChange || price === undefined) return;
priceProps.onChange(price);
}, [price]);
useEffect(() => {
if (
!serviceProps.onChange ||
!service ||
(price && isFirstRender.current && lockOnEdit)
)
return;
setPriceBasedOnService();
serviceProps.onChange(service);
}, [service]);
useEffect(() => {
isFirstRender.current = false;
}, []);
return (
<Stack
gap={"xs"}
{...containerProps}>
<ServiceSelect
{...serviceProps}
value={service}
onChange={setService}
filterType={filterType}
disabled={lockOnEdit}
/>
<NumberInput
{...priceProps}
onChange={onPriceManualChange}
defaultValue={priceProps.value}
min={1}
allowNegative={false}
/>
</Stack>
);
};
export default ServiceWithPriceInput;

View File

@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { Button, Flex, Text } from "@mantine/core";
import { ContextModalProps } from "@mantine/modals";
import ObjectMultiSelect from "@/components/selects/ObjectMultiSelect/ObjectMultiSelect";
import { DealProductSchema } from "@/lib/client";
import { notifications } from "@/lib/notifications";
type Props = {
dealProducts: DealProductSchema[];
sourceDealProduct: DealProductSchema;
duplicateServices: (
sourceDealProduct: DealProductSchema,
targetDealProducts: DealProductSchema[]
) => void;
};
const DuplicateServicesModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const [selectedDealProducts, setSelectedDealProducts] = useState<
DealProductSchema[]
>([]);
const onDealProductSelect = () => {
if (!selectedDealProducts) {
notifications.error({
message:
"Выберите товары на которые необходимо продублировать услуги",
});
return;
}
innerProps.duplicateServices(
innerProps.sourceDealProduct,
selectedDealProducts
);
context.closeContextModal(id);
};
const onDuplicateAllClick = () => {
innerProps.duplicateServices(
innerProps.sourceDealProduct,
innerProps.dealProducts.filter(
item => item !== innerProps.sourceDealProduct
)
);
context.closeContextModal(id);
};
return (
<Flex
direction={"column"}
gap={"xs"}>
<ObjectMultiSelect
w={"100%"}
label={"Товары"}
placeholder={
"Выберите товары на которые нужно продублировать услуги"
}
onChange={setSelectedDealProducts}
value={selectedDealProducts}
data={innerProps.dealProducts}
getLabelFn={item => item.product.name}
getValueFn={item => item.product.id.toString()}
filterBy={item => item !== innerProps.sourceDealProduct}
/>
<Flex
gap={"xs"}
justify={"flex-end"}>
<Button
variant={"subtle"}
onClick={() => context.closeContextModal(id)}>
<Text>Отменить</Text>
</Button>
<Button
variant={"default"}
onClick={onDuplicateAllClick}>
<Text>Продублировать на все товары</Text>
</Button>
<Button
onClick={onDealProductSelect}
variant={"default"}>
<Text>Продублировать</Text>
</Button>
</Flex>
</Flex>
);
};
export default DuplicateServicesModal;

View File

@ -0,0 +1,122 @@
"use client";
import { Fieldset, Flex, rem, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import {
CreateProductSchema,
ProductSchema,
UpdateProductSchema,
} from "@/lib/client";
import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
import ProductImageDropzone from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/components/ProductImageDropzone/ProductImageDropzone";
import BaseFormInputProps from "@/utils/baseFormInputProps";
type Props = CreateEditFormProps<
CreateProductSchema,
UpdateProductSchema,
ProductSchema
>;
const ProductEditorModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const isEditing = "entity" in innerProps;
const initialValues: Partial<ProductSchema> = isEditing
? innerProps.entity!
: {
name: "",
article: "",
factoryArticle: "",
brand: "",
composition: "",
color: "",
size: "",
additionalInfo: "",
};
const form = useForm<Partial<ProductSchema>>({
initialValues,
validate: {
name: name =>
!name || name.trim() !== ""
? null
: "Необходимо ввести название товара",
},
});
const onClose = () => context.closeContextModal(id);
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
onClose={onClose}>
<Flex
gap={rem(10)}
direction={"column"}>
<Fieldset legend={"Основные характеристики"}>
<TextInput
placeholder={"Введите название товара"}
label={"Название товара"}
{...form.getInputProps("name")}
/>
<TextInput
placeholder={"Введите артикул"}
label={"Артикул"}
{...form.getInputProps("article")}
/>
<TextInput
placeholder={"Введите складской артикул"}
label={"Складской артикул"}
{...form.getInputProps("factoryArticle")}
/>
</Fieldset>
<Fieldset legend={"Дополнительные характеристики"}>
<TextInput
placeholder={"Введите бренд"}
label={"Бренд"}
{...form.getInputProps("brand")}
/>
<TextInput
placeholder={"Введите состав"}
label={"Состав"}
{...form.getInputProps("composition")}
/>
<TextInput
placeholder={"Введите цвет"}
label={"Цвет"}
{...form.getInputProps("color")}
/>
<TextInput
placeholder={"Введите размер"}
label={"Размер"}
{...form.getInputProps("size")}
/>
<TextInput
placeholder={"Введите доп. информацию"}
label={"Доп. информация"}
{...form.getInputProps("additionalInfo")}
/>
</Fieldset>
{isEditing && (
<ProductImageDropzone
imageUrlInputProps={
form.getInputProps(
"imageUrl"
) as BaseFormInputProps<string>
}
productId={innerProps.entity.id}
/>
)}
</Flex>
</BaseFormModal>
);
};
export default ProductEditorModal;

View File

@ -0,0 +1,99 @@
"use client";
import { isNil, isNumber } from "lodash";
import { Checkbox, Flex } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import {
CreateProductServiceSchema,
ProductServiceSchema,
UpdateProductServiceSchema,
} from "@/lib/client/index.js";
import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
import ServiceWithPriceInput from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/modals/DealServiceEditorModal/components/ServiceWithPriceInput";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/types/service";
type RestProps = {
quantity: number;
excludeServiceIds: number[];
};
type Props = CreateEditFormProps<
CreateProductServiceSchema,
UpdateProductServiceSchema,
ProductServiceSchema
> &
RestProps;
const ProductServiceEditorModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const initialValues: Partial<ProductServiceSchema> = innerProps.isEditing
? innerProps.entity
: {
service: undefined,
serviceId: undefined,
price: undefined,
isFixedPrice: false,
};
const form = useForm<Partial<ProductServiceSchema>>({
initialValues,
validate: {
service: service =>
(isNil(service) || service.id < 0) && "Укажите услугу",
price: price => (!isNumber(price) || price < 0) && "Укажите цену",
},
});
const onClose = () => context.closeContextModal(id);
return (
<BaseFormModal
{...innerProps}
form={form}
onClose={onClose}
closeOnSubmit>
<Flex
w={"100%"}
direction={"column"}
gap={"xs"}>
<ServiceWithPriceInput
serviceProps={{
...form.getInputProps("service"),
onChange: value => {
form.setFieldValue("service", value);
form.setFieldValue("serviceId", value.id);
},
label: "Услуга",
placeholder: "Выберите услугу",
disabled: innerProps.isEditing,
filterBy: item =>
!innerProps.excludeServiceIds.includes(item.id) ||
innerProps.isEditing,
}}
priceProps={{
...form.getInputProps("price"),
label: "Цена",
placeholder: "Введите цену",
}}
filterType={ServiceType.PRODUCT_SERVICE}
lockOnEdit={innerProps.isEditing}
quantity={innerProps.quantity}
/>
<Checkbox
{...form.getInputProps("isFixedPrice", {
type: "checkbox",
})}
label={"Зафиксировать цену"}
placeholder={"Зафиксировать цену"}
/>
</Flex>
</BaseFormModal>
);
};
export default ProductServiceEditorModal;

View File

@ -0,0 +1,57 @@
"use client";
import { Flex } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import { ServicesKitSchema } from "@/lib/client";
import BaseFormModalActions from "@/modals/base/BaseFormModal/BaseFormModalActions";
import ServicesKitSelect from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/modals/ServicesKitSelectModal/components/ServicesKitSelect";
type Props = {
onSelect: (kit: ServicesKitSchema) => void;
serviceType: number;
};
type ServicesKitForm = {
servicesKit: ServicesKitSchema;
};
const ServicesKitSelectModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const form = useForm<ServicesKitForm>({
validate: {
servicesKit: servicesKit => !servicesKit && "Выберите сервис",
},
});
const onClose = () => context.closeContextModal(id);
const onSubmit = (values: ServicesKitForm) => {
innerProps.onSelect(values.servicesKit);
onClose();
};
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<Flex
gap={"xs"}
direction={"column"}>
<Flex>
<ServicesKitSelect
w={"100%"}
{...form.getInputProps("servicesKit")}
filterBy={item =>
item.serviceType === innerProps.serviceType
}
/>
</Flex>
<BaseFormModalActions onClose={onClose} />
</Flex>
</form>
);
};
export default ServicesKitSelectModal;

View File

@ -0,0 +1,23 @@
import { FC } from "react";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import { ServicesKitSchema } from "@/lib/client";
import useServicesKitsList from "@/modules/dealModularEditorTabs/FulfillmentBaseTab/hooks/lists/useServicesKitsList";
type Props = Omit<ObjectSelectProps<ServicesKitSchema>, "data">;
const ServicesKitSelect: FC<Props> = props => {
const { servicesKits } = useServicesKitsList();
return (
<ObjectSelect
label={"Набор услуг"}
placeholder={"Выберите набор услуг"}
data={servicesKits}
{...props}
/>
);
};
export default ServicesKitSelect;

View File

@ -0,0 +1,4 @@
export enum ServiceType {
DEAL_SERVICE,
PRODUCT_SERVICE,
}