feat: modules, products, services, services kits

This commit is contained in:
2025-09-16 10:56:10 +04:00
parent f2746b8b65
commit 553e76d610
92 changed files with 8404 additions and 103 deletions

View File

@ -0,0 +1,13 @@
import FulfillmentBaseTab from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/FulfillmentBaseTab";
import { ModuleNames } from "./modules";
import ModulesType from "./types";
const connectModules = (modules: ModulesType) => {
modules[ModuleNames.FULFILLMENT_BASE].getTab = props => (
<FulfillmentBaseTab {...props} />
);
return modules;
};
export default connectModules;

View File

@ -0,0 +1,38 @@
.container {
display: flex;
//flex-direction: column;
gap: rem(10);
max-height: 95vh;
}
.container-disabled {
}
.products-list {
width: 52%;
display: flex;
flex-direction: column;
gap: rem(10);
flex: 2;
}
.card-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.card-container-wrapper {
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
padding: rem(10);
}
.card-container-buttons {
gap: rem(10);
display: flex;
flex-direction: column;
margin-top: auto;
width: 100%;
}

View File

@ -0,0 +1,18 @@
import { FC } from "react";
import { DealSchema } from "@/lib/client";
import FulfillmentBaseTabBody from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/FulfillmentBaseTabBody/FulfillmentBaseTabBody";
import { FulfillmentBaseContextProvider } from "@/modules/dealModules/dealEditorTabs/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,71 @@
import { FC } from "react";
import { Flex, rem } from "@mantine/core";
import DealServiceRow from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/DealServicesTable/components/DealServiceRow";
import DealServicesTitle from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/DealServicesTable/components/DealServicesTitle";
import DealServicesTotalLabel from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/DealServicesTable/components/DealServicesTotalLabel";
import ServicesActions from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/DealServicesTable/components/ServicesActions";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
const DealServicesTable: FC = () => {
const { dealServicesList, dealServicesCrud } = useFulfillmentBaseContext();
// const isLocked = isDealLocked(deal); // TODO bills
// const [currentService, setCurrentService] = useState<
// DealServiceSchema | undefined
// >();
// const [employeesModalVisible, setEmployeesModalVisible] = useState(false);
// const onEmployeeClick = (item: CardServiceSchema) => {
// if (!onChange) return;
// setCurrentService(item);
// setEmployeesModalVisible(true);
// };
// const onEmployeeModalClose = () => {
// setEmployeesModalVisible(false);
// setCurrentService(undefined);
// };
// const getCurrentEmployees = (): UserSchema[] => {
// if (!currentService) return [];
// const item = items.find(
// i => i.service.id === currentService.service.id
// );
// if (!item) return [];
// return item.employees;
// };
// const onEmployeesChange = (items: UserSchema[]) => {
// if (!currentService || !onChange) return;
// debouncedOnChange({
// ...currentService,
// employees: items,
// });
// };
return (
<Flex
direction={"column"}
gap={rem(10)}
h={"100%"}>
<Flex
h={"100%"}
direction={"column"}>
<DealServicesTitle />
<Flex
direction={"column"}
gap={rem(10)}>
{dealServicesList.dealServices.map(dealService => (
<DealServiceRow
key={dealService.service.id}
value={dealService}
onDelete={dealServicesCrud.onDelete}
onChange={dealServicesCrud.onUpdate}
/>
))}
</Flex>
</Flex>
<DealServicesTotalLabel />
<ServicesActions />
</Flex>
);
};
export default DealServicesTable;

View File

@ -0,0 +1,114 @@
import { FC } from "react";
import { IconTrash } from "@tabler/icons-react";
import { isNumber } from "lodash";
import {
ActionIcon,
Divider,
Group,
NumberInput,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { DealServiceSchema } from "@/lib/client";
import LockCheckbox from "@/modules/dealModules/dealEditorTabs/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
);
const onQuantityChange = (item: DealServiceSchema, quantity: number) => {
debouncedOnChange({ ...item, quantity });
};
const onPriceChange = (item: DealServiceSchema, price: number) => {
debouncedOnChange({
...item,
price,
isFixedPrice: true,
});
};
return (
<Stack
w={"100%"}
gap={"xs"}>
<Divider />
<Text>{value.service.name}</Text>
<Group>
<Tooltip
onClick={() => onDelete(value)}
label="Удалить услугу">
<ActionIcon variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
{/*<Tooltip label="Сотрудники">*/}
{/* <ActionIcon*/}
{/* onClick={() => onEmployeeClick(service)}*/}
{/* variant={"default"}>*/}
{/* <IconUsersGroup />*/}
{/* </ActionIcon>*/}
{/*</Tooltip>*/}
<NumberInput
flex={1}
suffix={" шт."}
onChange={event =>
isNumber(event) && onQuantityChange(value, event)
}
value={value.quantity}
min={1}
allowNegative={false}
/>
<NumberInput
flex={1}
onChange={event =>
isNumber(event) && onPriceChange(value, event)
}
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,12 @@
import { rem, Title } from "@mantine/core";
const DealServicesTitle = () => (
<Title
order={3}
w={"100%"}
mb={rem(10)}>
Общие услуги
</Title>
);
export default DealServicesTitle;

View File

@ -0,0 +1,26 @@
import { useMemo } from "react";
import { rem, Title } from "@mantine/core";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/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={rem(10)}
order={3}>
Итог: {total}
</Title>
);
};
export default DealServicesTotalLabel;

View File

@ -0,0 +1,58 @@
import { Button, Flex, rem } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
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 onAddKitClick = () => {
// if (!onKitAdd) return;
// modals.openContextModal({
// modal: "servicesKitSelectModal",
// innerProps: {
// onSelect: onKitAdd,
// serviceType: ServiceType.DEAL_SERVICE,
// },
// withCloseButton: false,
// });
// };
return (
<Flex
direction={"column"}
gap={rem(10)}
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,34 @@
import { Flex, ScrollArea, Stack } from "@mantine/core";
import DealServicesTable from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/DealServicesTable/DealServicesTable";
import ProductsActions from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/ProductsActions/ProductsActions";
import ProductView from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/ProductView/ProductView";
import { useFulfillmentBaseContext } from "../../contexts/FulfillmentBaseContext";
const FulfillmentBaseTabBody = () => {
const { dealProductsList } = useFulfillmentBaseContext();
return (
<Flex
p={"md"}
gap={"xs"}>
<ScrollArea
offsetScrollbars={"y"}
mah={"91vh"}>
<Stack>
{dealProductsList.dealProducts.map((dealProduct, index) => (
<ProductView
dealProduct={dealProduct}
key={index}
/>
))}
</Stack>
</ScrollArea>
<Stack>
<DealServicesTable />
<ProductsActions />
</Stack>
</Flex>
);
};
export default FulfillmentBaseTabBody;

View File

@ -0,0 +1,115 @@
import ShippingWarehouseAutocomplete
from "../../../../../../components/Selects/ShippingWarehouseAutocomplete/ShippingWarehouseAutocomplete.tsx";
import { CardService, ShippingWarehouseSchema } from "../../../../../../client";
import { useForm } from "@mantine/form";
import { useCardPageContext } from "../../../../../../pages/CardsPage/contexts/CardPageContext.tsx";
import { Button, Checkbox, Stack } from "@mantine/core";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { useEffect, useState } from "react";
import { isEqual } from "lodash";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
type GeneralDataFormType = {
shippingWarehouse?: ShippingWarehouseSchema | null | string;
isServicesProfitAccounted: boolean;
}
const GeneralDataForm = () => {
const { selectedCard: card, refetchCard } = useCardPageContext();
const { isDealsViewer } = useSelector((state: RootState) => state.auth);
if (!card) return <></>;
const [initialValues, setInitialValues] = useState<GeneralDataFormType>(card);
const form = useForm<GeneralDataFormType>({
initialValues,
});
useEffect(() => {
const data = card ?? {};
setInitialValues(data);
form.setValues(data);
}, [card]);
const isShippingWarehouse = (
value: ShippingWarehouseSchema | string | null | undefined,
): value is ShippingWarehouseSchema => {
return !!value && !["string"].includes(typeof value);
};
const onSubmit = (values: GeneralDataFormType) => {
if (!card) return;
const shippingWarehouse = isShippingWarehouse(values.shippingWarehouse)
? values.shippingWarehouse.name
: values.shippingWarehouse;
CardService.updateProductsAndServicesGeneralInfo({
requestBody: {
cardId: card.id,
data: {
...values,
shippingWarehouse,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchCard();
})
.catch(err => console.log(err));
};
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<ShippingWarehouseAutocomplete
placeholder={isDealsViewer ? "" : "Введите склад отгрузки"}
label={"Склад отгрузки"}
value={
isShippingWarehouse(
form.values.shippingWarehouse,
)
? form.values.shippingWarehouse
: undefined
}
onChange={event => {
if (isShippingWarehouse(event)) {
form.getInputProps(
"shippingWarehouse",
).onChange(event.name);
return;
}
form.getInputProps(
"shippingWarehouse",
).onChange(event);
}}
readOnly={isDealsViewer}
/>
{!isDealsViewer && (
<>
<Checkbox
label={"Учет выручки в статистике"}
{...form.getInputProps("isServicesProfitAccounted", { type: "checkbox" })}
/>
<Button
type={"submit"}
variant={"default"}
disabled={isEqual(initialValues, form.values)}
>
Сохранить
</Button>
</>
)}
</Stack>
</form>
);
};
export default GeneralDataForm;

View File

@ -0,0 +1,28 @@
import { FC } from "react";
import { IconLock, IconLockOpen } from "@tabler/icons-react";
import { ActionIcon, CheckboxProps, Tooltip } from "@mantine/core";
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 (
<Tooltip label={props.label}>
<ActionIcon
onClick={handleChange}
variant={props.variant}>
{getIcon()}
</ActionIcon>
</Tooltip>
);
};
export default LockCheckbox;

View File

@ -0,0 +1,68 @@
import { CardSchema } from "../../../../../../client";
import ButtonCopy from "../../../../../../components/ButtonCopy/ButtonCopy.tsx";
import { ButtonCopyControlled } from "../../../../../../components/ButtonCopyControlled/ButtonCopyControlled.tsx";
import { getCurrentDateTimeForFilename } from "../../../../../../shared/lib/date.ts";
import FileSaver from "file-saver";
import { Button, Popover, Stack } from "@mantine/core";
type Props = {
card: CardSchema;
}
const PaymentLinkButton = ({ card }: Props) => {
if ((!card.billRequests || card.billRequests.length === 0) && (!card?.group?.billRequests || card?.group?.billRequests.length === 0)) {
return (
<ButtonCopyControlled
onCopyClick={() => {
const date =
getCurrentDateTimeForFilename();
FileSaver.saveAs(
`${import.meta.env.VITE_API_URL}/card/billing-document/${card.id}`,
`bill_${card.id}_${date}.pdf`,
);
}}
copied={false}
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
>
Ссылка на оплату (PDF)
</ButtonCopyControlled>
);
}
const requests = (card?.group ? card?.group?.billRequests : card.billRequests) ?? [];
const urls = requests.map(request => request.pdfUrl).filter(url => url !== null);
if (urls.length === 1) {
return (
<ButtonCopy
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
value={urls[0]}
>
Ссылка на оплату
</ButtonCopy>
);
}
return (
<Popover width={380} position="bottom" withArrow shadow="md">
<Popover.Target>
<Button variant={"default"}>Ссылки на оплату</Button>
</Popover.Target>
<Popover.Dropdown>
<Stack gap={"md"}>
{urls.map((url, i) => (
<ButtonCopy
key={i}
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
value={url}
>
{`Ссылка на оплату (часть ${String(i + 1)})`}
</ButtonCopy>
))}
</Stack>
</Popover.Dropdown>
</Popover>
);
};
export default PaymentLinkButton;

View File

@ -0,0 +1,63 @@
import { ActionIcon, Group, Tooltip } from "@mantine/core";
import styles from "../../../../../../pages/CardsPage/ui/CardsPage.module.css";
import { CardSchema, CardService } from "../../../../../../client";
import { base64ToBlob } from "../../../../../../shared/lib/utils.ts";
import { notifications } from "../../../../../../shared/lib/notifications.ts";
import { IconBarcode, IconPrinter } from "@tabler/icons-react";
type Props = {
card: CardSchema;
}
const PrintDealBarcodesButton = ({ card }: Props) => {
return (
<Group wrap={"nowrap"}>
<Tooltip
className={styles["print-deals-button"]}
label={"Распечатать штрихкоды сделки"}
>
<ActionIcon
onClick={async () => {
const response =
await CardService.getCardProductsBarcodesPdf({
requestBody: {
cardId: card.id,
},
});
const pdfBlob = base64ToBlob(
response.base64String,
response.mimeType,
);
const pdfUrl = URL.createObjectURL(pdfBlob);
const pdfWindow = window.open(pdfUrl);
if (!pdfWindow) {
notifications.error({ message: "Ошибка" });
return;
}
pdfWindow.onload = () => {
pdfWindow.print();
};
}}
variant={"default"}>
<IconBarcode />
</ActionIcon>
</Tooltip>
<Tooltip label={"Распечатать сделку"}>
<ActionIcon
onClick={() => {
const pdfWindow = window.open(
`${import.meta.env.VITE_API_URL}/card/tech-spec/${card.id}`,
);
if (!pdfWindow) return;
pdfWindow.print();
}}
variant={"default"}>
<IconPrinter />
</ActionIcon>
</Tooltip>
</Group>
);
};
export default PrintDealBarcodesButton;

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/dealModules/dealEditorTabs/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/dealModules/dealEditorTabs/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,36 @@
.container {
display: flex;
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
gap: rem(20);
padding: rem(10);
margin-bottom: rem(10);
flex: 1;
}
.image-container {
display: flex;
max-height: rem(250);
max-width: rem(250);
height: 100%;
}
.services-container {
width: 100%;
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.data-container {
max-width: rem(250);
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.attributes-container {
overflow-wrap: break-word;
}

View File

@ -0,0 +1,156 @@
import { FC } from "react";
import { isNumber } from "lodash";
import {
Box,
Card,
Image,
NumberInput,
rem,
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/dealModules/dealEditorTabs/FulfillmentBaseTab/components/ProductView/components/ProductFieldsList";
import ProductViewActions from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/ProductView/components/ProductViewActions";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
import { ServiceType } from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/types/service";
import ProductServicesTable from "./components/ProductServicesTable";
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 (
<Card
p={"sm"}
style={{ display: "flex", flexDirection: "row" }}>
<Stack gap={"sm"}>
<Image
flex={1}
radius={rem(10)}
fit={"cover"}
// src={dealProduct.product.imageUrl}
/>
<Title order={3}>{dealProduct.product.name}</Title>
<ProductFieldsList product={dealProduct.product} />
{/*<Text>*/}
{/* Штрихкоды:*/}
{/*{value.product.barcodes.join(", ")}*/}
{/*</Text>*/}
<Box />
<NumberInput
mt={rem(10)}
suffix={" шт."}
value={dealProduct.quantity}
onChange={quantity =>
isNumber(quantity) && debouncedOnChange({ quantity })
}
placeholder={"Введите количество товара"}
/>
<Textarea
mih={rem(140)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
}}
my={rem(10)}
defaultValue={dealProduct.comment}
onChange={event =>
debouncedOnChange({
comment: event.currentTarget.value,
})
}
placeholder={"Комментарий"}
/>
</Stack>
<Stack>
<ProductServicesTable
dealProduct={dealProduct}
onDuplicateServices={() => onDuplicateServices(dealProduct)}
onKitAdd={onAddKitClick}
/>
<ProductViewActions dealProduct={dealProduct} />
</Stack>
</Card>
);
};
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,146 @@
import { FC } from "react";
import { IconMoodSad } from "@tabler/icons-react";
import { Button, Flex, Group, Stack, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { DealProductSchema, ProductServiceSchema } from "@/lib/client";
import useProductServicesTableColumns from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/components/ProductView/hooks/useProductServicesTableColumns";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/contexts/FulfillmentBaseContext";
type Props = {
dealProduct: DealProductSchema;
onDuplicateServices?: () => void;
onKitAdd?: () => void;
};
const ProductServicesTable: FC<Props> = ({
dealProduct,
onDuplicateServices,
onKitAdd,
}) => {
const { productServiceCrud } = useFulfillmentBaseContext();
const onChange = (item: ProductServiceSchema) => {
const excludeServiceIds = dealProduct.productServices.map(
productService => productService.service.id
);
modals.openContextModal({
modal: "productServiceEditorModal",
innerProps: {
entity: item,
onChange: values =>
productServiceCrud.onUpdate(
item.dealId,
item.productId,
item.serviceId,
values
),
excludeServiceIds,
quantity: dealProduct.quantity,
isEditing: true,
},
withCloseButton: false,
});
};
const columns = useProductServicesTableColumns({
data: dealProduct.productServices,
quantity: dealProduct.quantity,
onDelete: productServiceCrud.onDelete,
onChange,
});
// const [currentService, setCurrentService] = useState<
// ProductServiceSchema | undefined
// >();
// const [employeesModalVisible, setEmployeesModalVisible] = useState(false);
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 onEmployeeClick = (item: CardProductServiceSchema) => {
// if (!onChange) return;
// setCurrentService(item);
// setEmployeesModalVisible(true);
// };
// const onEmployeeModalClose = () => {
// setEmployeesModalVisible(false);
// setCurrentService(undefined);
// };
// const getCurrentEmployees = (): UserSchema[] => {
// if (!currentService) return [];
// const item = items.find(
// i => i.service.id === currentService.service.id
// );
// if (!item) return [];
// return item.employees;
// };
// const onEmployeesChange = (items: UserSchema[]) => {
// if (!currentService || !onChange) return;
// onChange({
// ...currentService,
// employees: items,
// });
// };
const isEmptyTable = dealProduct.productServices.length === 0;
return (
<Stack gap={0}>
<BaseTable
records={dealProduct.productServices}
columns={columns}
groups={undefined}
idAccessor={"serviceId"}
style={{
height: isEmptyTable ? "8rem" : "auto",
}}
emptyState={
<Group
gap={"xs"}
mt={isEmptyTable ? "xl" : 0}>
<Text>Нет услуг</Text>
<IconMoodSad />
</Group>
}
/>
<Flex
justify={"flex-end"}
gap={"xs"}
pt={isEmptyTable ? 0 : "xs"}>
<Button
onClick={() => onKitAdd && onKitAdd()}
variant={"default"}>
Добавить набор услуг
</Button>
<Button
onClick={() => onDuplicateServices && onDuplicateServices()}
variant={"default"}>
Продублировать услуги
</Button>
<Button
onClick={onCreateClick}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
</Stack>
);
};
export default ProductServicesTable;

View File

@ -0,0 +1,64 @@
import { FC } from "react";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { ActionIcon, Flex, rem, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { DealProductSchema } from "@/lib/client";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/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={rem(10)}>
{/*<Tooltip*/}
{/* onClick={onPrintBarcodeClick}*/}
{/* label="Печать штрихкода">*/}
{/* <ActionIcon variant={"default"}>*/}
{/* <IconBarcode />*/}
{/* </ActionIcon>*/}
{/*</Tooltip>*/}
<Tooltip
onClick={onProductEditClick}
label="Редактировать товар">
<ActionIcon variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
<Tooltip
onClick={() => dealProductsCrud.onDelete(dealProduct)}
label="Удалить товар">
<ActionIcon variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
);
};
export default ProductViewActions;

View File

@ -0,0 +1,70 @@
import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable";
import { ProductServiceSchema } from "@/lib/client";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
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">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDelete(dealProductService)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onChange(dealProductService)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
{/*<Tooltip label="Сотрудники">*/}
{/* <ActionIcon*/}
{/* onClick={() =>*/}
{/* onEmployeeClick(row.original)*/}
{/* }*/}
{/* variant={"default"}>*/}
{/* <IconUsersGroup />*/}
{/* </ActionIcon>*/}
{/*</Tooltip>*/}
</Flex>
),
},
{
accessor: "service.name",
title: "Услуга",
},
{
accessor: "price",
title: "Цена",
Footer: () => <>Итог: {totalPrice.toLocaleString("ru")}</>,
},
] as DataTableColumn<ProductServiceSchema>[],
[totalPrice]
);
};
export default useProductServicesTableColumns;

View File

@ -0,0 +1,60 @@
import { FC } from "react";
import { Button, Flex } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useFulfillmentBaseContext } from "@/modules/dealModules/dealEditorTabs/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%"}
direction={"column"}
gap={"sm"}>
<Button
variant={"default"}
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
variant={"default"}
onClick={onCreateDealProductClick}>
Добавить товар
</Button>
</Flex>
);
};
export default ProductsActions;

View File

@ -0,0 +1,51 @@
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/dealModules/dealEditorTabs/FulfillmentBaseTab/hooks/lists/useServicesList";
import { ServiceType } from "@/modules/dealModules/dealEditorTabs/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
? 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/dealModules/dealEditorTabs/FulfillmentBaseTab/hooks/cruds/useDealServiceCrud";
import useProductServiceCrud, {
DealProductServicesCrud,
} from "@/modules/dealModules/dealEditorTabs/FulfillmentBaseTab/hooks/cruds/useProductServiceCrud";
import useDealServicesList, {
DealServicesList,
} from "@/modules/dealModules/dealEditorTabs/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,114 @@
"use client";
import {
ComboboxItem,
ComboboxItemGroup,
NumberInput,
OptionsFilter,
} 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/dealModules/dealEditorTabs/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);
const serviceOptionsFilter = ({
options,
}: {
options: ComboboxItemGroup[];
}) => {
if (!innerProps.serviceIdsToExclude) return options;
const productServiceIds = innerProps.serviceIdsToExclude;
return (options as ComboboxItemGroup[]).map(({ items, group }) => {
return {
group,
items: items.filter(
item =>
!productServiceIds.includes(
Number((item as ComboboxItem).value)
)
),
};
});
};
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,
filter: serviceOptionsFilter as OptionsFilter,
}}
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/dealModules/dealEditorTabs/FulfillmentBaseTab/components/ServiceSelect/ServiceSelect";
import { ServiceType } from "@/modules/dealModules/dealEditorTabs/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/dealModules/dealEditorTabs/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,100 @@
"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/dealModules/dealEditorTabs/FulfillmentBaseTab/modals/DealServiceEditorModal/components/ServiceWithPriceInput";
import { ServiceType } from "@/modules/dealModules/dealEditorTabs/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,
// employees: [],
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/dealModules/dealEditorTabs/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/dealModules/dealEditorTabs/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,
}

28
src/modules/modules.tsx Normal file
View File

@ -0,0 +1,28 @@
import {
IconBox,
} from "@tabler/icons-react";
import ModulesType from "./types";
import connectModules from "./connectModules";
export enum ModuleNames {
FULFILLMENT_BASE = "fulfillment_base",
}
const modules: ModulesType = {
[ModuleNames.FULFILLMENT_BASE]: {
renderInfo: {
label: "Фулфиллмент",
key: "fulfillment_base",
icon: <IconBox />,
},
modelData: {
id: 1,
key: "fulfillment_base",
label: "Фулфиллмент",
iconName: "IconBox",
description: "Создание товаров и услуг, их привязка к сделкам",
},
},
};
export const MODULES = connectModules(modules);

View File

@ -0,0 +1,86 @@
import * as fs from "fs";
import * as path from "path";
import axios, { AxiosResponse } from "axios";
import * as handlebars from "handlebars";
// region types
type Module = {
id: number;
key: string;
label: string;
iconName: string;
description: string;
};
type ModulesResponse = {
items: Module[];
};
type Args = {
[key: string]: string | boolean;
};
// endregion
// region utils
const getArgs = (): Args =>
process.argv.slice(2).reduce((args: Args, arg: string) => {
if (arg.startsWith("--")) {
// Handle long arguments like --port=8000
const [key, value] = arg.slice(2).split("=");
args[key] = value !== undefined ? value : true;
} else if (arg.startsWith("-") && arg.length > 1) {
// Handle short arguments like -p=8000
const [key, value] = arg.slice(1).split("=");
args[key] = value !== undefined ? value : true;
}
return args;
}, {});
// endregion
const kwargs = getArgs();
// region constants
const HOST = kwargs.host ?? kwargs.h ?? "127.0.0.1";
const PORT = kwargs.port ?? kwargs.p ?? "8000";
const ENDPOINT = `http://${HOST}:${PORT}/api/module/built-in/`;
const TEMPLATE_PATH = path.join(
__dirname,
"templates",
"modulesFileTemplate.hbs"
);
const OUTPUT_PATH = path.join(__dirname, "..", "modules.tsx");
// endregion
const templateSource = fs.readFileSync(TEMPLATE_PATH, "utf8");
const template = handlebars.compile(templateSource);
handlebars.registerHelper("uppercase", text => {
return text.replace(/([a-z])([A-Z])/g, "$1_$2").toUpperCase();
});
const generateRows = (modules: Module[]) => {
try {
const data = {
modules,
};
const tsxContent = template(data);
fs.writeFileSync(OUTPUT_PATH, tsxContent);
console.log("File successfully generated.");
} catch (error) {
console.error(error);
}
};
const modulesFileGen = () => {
console.log("Start file generation...");
axios
.get(ENDPOINT)
.then((response: AxiosResponse<ModulesResponse>) => {
generateRows(response.data.items);
})
.catch(err => console.log(err));
};
modulesFileGen();

View File

@ -0,0 +1,34 @@
import {
{{#each modules}}
{{#if this.iconName}}{{this.iconName}},{{/if}}
{{/each}}
} from "@tabler/icons-react";
import ModulesType from "./types";
import connectModules from "./connectModules";
export enum ModuleNames {
{{#each modules}}
{{uppercase this.key}} = "{{this.key}}",
{{/each}}
}
const modules: ModulesType = {
{{#each modules}}
[ModuleNames.{{uppercase this.key}}]: {
renderInfo: {
label: "{{this.label}}",
key: "{{this.key}}",
icon: {{#if this.iconName}}<{{this.iconName}} />{{else}}None{{/if}},
},
modelData: {
id: {{this.id}},
key: "{{this.key}}",
label: "{{this.label}}",
iconName: "{{this.iconName}}",
description: "{{this.description}}",
},
},
{{/each}}
};
export const MODULES = connectModules(modules);

18
src/modules/types.tsx Normal file
View File

@ -0,0 +1,18 @@
import { ReactNode } from "react";
import { BuiltInModuleSchema } from "@/lib/client";
export type Module = {
renderInfo: {
label: string;
key: string;
icon: ReactNode;
};
modelData: BuiltInModuleSchema;
getTab?: (props: any) => ReactNode;
};
type ModulesType = {
[key: string]: Module;
};
export default ModulesType;

View File

@ -0,0 +1,8 @@
import { ProjectSchema } from "../../client";
import { ModuleNames } from "../modules.tsx";
const isModuleInProject = (module: ModuleNames, project?: ProjectSchema | null) => {
return project?.modules.findIndex(m => m.key === module.toString()) !== -1;
};
export default isModuleInProject;