diff --git a/src/modals/modals.ts b/src/modals/modals.ts index 05d450a..6595a21 100644 --- a/src/modals/modals.ts +++ b/src/modals/modals.ts @@ -9,6 +9,7 @@ import PrintBarcodeModal from "./PrintBarcodeModal/PrintBarcodeModal.tsx"; import AddBarcodeModal from "./AddBarcodeModal/AddBarcodeModal.tsx"; import BarcodeTemplateFormModal from "../pages/BarcodePage/modals/BarcodeTemplateFormModal/BarcodeTemplateFormModal.tsx"; +import ProductServiceFormModal from "../pages/LeadsPage/modals/ProductServiceFormModal.tsx"; export const modals = { enterDeadline: EnterDeadlineModal, @@ -18,6 +19,7 @@ export const modals = { productFormModal: ProductFormModal, addDealService: AddDealServiceModal, addDealProduct: AddDealProductModal, + productServiceForm: ProductServiceFormModal, printBarcode: PrintBarcodeModal, addBarcode: AddBarcodeModal, barcodeTemplateFormModal: BarcodeTemplateFormModal diff --git a/src/pages/LeadsPage/drawers/DealEditDrawer/DealEditDrawer.tsx b/src/pages/LeadsPage/drawers/DealEditDrawer/DealEditDrawer.tsx index 555cac4..80c5df1 100644 --- a/src/pages/LeadsPage/drawers/DealEditDrawer/DealEditDrawer.tsx +++ b/src/pages/LeadsPage/drawers/DealEditDrawer/DealEditDrawer.tsx @@ -7,10 +7,11 @@ import {notifications} from "../../../../shared/lib/notifications.ts"; import {modals} from "@mantine/modals"; import {BaseTableRef} from "../../../../components/BaseTable/BaseTable.tsx"; import DealProductsTable from "../../components/DealProductsTable/DealProductsTable.tsx"; -import {IconBarcode, IconBox, IconCalendarUser, IconSettings} from "@tabler/icons-react"; +import {IconBox, IconCalendarUser, IconSettings} from "@tabler/icons-react"; import DealStatusChangeTable from "../../components/DealStatusChangeTable/DealStatusChangeTable.tsx"; import DealEditDrawerGeneralTab from "./tabs/DealEditDrawerGeneralTab.tsx"; import {useQueryClient} from "@tanstack/react-query"; +import ProductAndServiceTab from "../../tabs/ProductAndServiceTab/ProductAndServiceTab.tsx"; // import styles from './DealEditDrawer.module.css'; const useDealServicesTableState = () => { @@ -338,12 +339,15 @@ const DealEditDrawer: FC = () => { }> История - }> - Услуги - - }> - Товары + }> + Товары и услуги + {/*}>*/} + {/* Услуги*/} + {/**/} + {/*}>*/} + {/* Товары*/} + {/**/} @@ -355,6 +359,11 @@ const DealEditDrawer: FC = () => { + + + + + @@ -362,18 +371,10 @@ const DealEditDrawer: FC = () => { - - {/**/} - - {/**/} ); } diff --git a/src/pages/LeadsPage/modals/AddDealProductModal.tsx b/src/pages/LeadsPage/modals/AddDealProductModal.tsx index 5816027..1ab0ee9 100644 --- a/src/pages/LeadsPage/modals/AddDealProductModal.tsx +++ b/src/pages/LeadsPage/modals/AddDealProductModal.tsx @@ -9,7 +9,8 @@ import {omit} from "lodash"; import {BaseFormInputProps} from "../../../types/utils.ts"; type RestProps = { - clientId: number + clientId: number; + productIds?: number[]; } type Props = CreateEditFormProps & RestProps; @@ -56,6 +57,7 @@ const AddDealProductModal = ({ label={"Товар"} clientId={innerProps.clientId} disabled={isEditing} + filterBy={(item) => !(innerProps.productIds || []).includes(item.id)} {...form.getInputProps('product')} /> & RestProps; + +const ProductServiceFormModal = ({ + context, + id, innerProps + }: ContextModalProps) => { + const isEditing = 'onChange' in innerProps; + const initialValues: Partial = isEditing ? innerProps.element : { + service: undefined, + price: undefined + } + const form = useForm>({ + initialValues, + validate: { + service: (service?: ServiceSchema) => isNil(service) || service.id < 0 ? 'Укажите услугу' : null, + price: (price?: number) => !isNumber(price) || price < 0 ? 'Укажите цену' : null + } + }) + const onClose = () => { + context.closeContextModal(id); + } + console.log(innerProps) + return ( + } + onClose={onClose} + closeOnSubmit + > + + <> + + + !innerProps.serviceIds.includes(item.id) || isEditing, + style: {width: "100%"} + }} + priceProps={{ + ...form.getInputProps('price'), + label: "Цена", + placeholder: "Введите цену", + style: {width: "100%"} + + }} + filterType={ServiceType.PRODUCT_SERVICE} + containerProps={{ + direction: "column", + style: {width: "100%"} + + }} + lockOnEdit={isEditing} + quantity={innerProps.quantity} + /> + + + + + + + ) +} +export default ProductServiceFormModal; \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.module.css b/src/pages/LeadsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.module.css new file mode 100644 index 0000000..6a81dee --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.module.css @@ -0,0 +1,35 @@ +.container { + display: flex; + //flex-direction: column; + gap: rem(10); + max-height: 95vh; +} + +.products-list { + width: 60%; + display: flex; + flex-direction: column; + gap: rem(10); +} + +.deal-container { + display: flex; + flex-direction: column; + height: 100%; + gap: rem(10); +} + +.deal-container-wrapper { + border: dashed var(--item-border-size) var(--mantine-color-default-border); + border-radius: var(--item-border-radius); + padding: rem(10); + +} + +.deal-container-buttons { + gap: rem(10); + display: flex; + flex-direction: column; + margin-top: auto; + width: 100%; +} \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.tsx b/src/pages/LeadsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.tsx new file mode 100644 index 0000000..309a8cf --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/ProductAndServiceTab.tsx @@ -0,0 +1,69 @@ +import {FC} from "react"; +import styles from './ProductAndServiceTab.module.css'; +import ProductView from "./components/ProductView/ProductView.tsx"; +import {Button, Flex, ScrollArea, Title} from "@mantine/core"; +import DealServicesTable from "./components/DealServicesTable/DealServicesTable.tsx"; +import useDealProductAndServiceTabState from "./hooks/useProductAndServiceTabState.tsx"; +import {modals} from "@mantine/modals"; + +const ProductAndServiceTab: FC = () => { + const {dealState, dealServicesState, dealProductsState} = useDealProductAndServiceTabState(); + + const onCreateProductClick = () => { + if (!dealProductsState.onCreate || !dealState.deal) return; + const productIds = dealState.deal.products.map(product => product.product.id); + modals.openContextModal({ + modal: "addDealProduct", + innerProps: { + onCreate: dealProductsState.onCreate, + clientId: dealState.deal.clientId, + productIds: productIds + }, + withCloseButton: false + }) + } + const getTotalPrice = () => { + if (!dealState.deal) return 0 + const productServicesPrice = dealState.deal.products.reduce((acc, row) => acc + row.services.reduce((acc2, row2) => acc2 + row2.price * row.quantity, 0), 0); + const dealServicesPrice = dealState.deal.services.reduce((acc, row) => acc + row.price * row.quantity, 0); + return dealServicesPrice + productServicesPrice; + } + return ( +
+ +
+ + + {dealState.deal?.products.map(product => ( + + ))} + + +
+
+ + +
+ +
+
+ + Общая стоимость всех услуг: {getTotalPrice()} + +
+ +
+ ) +} + +export default ProductAndServiceTab; \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/DealServicesTable/DealServicesTable.tsx b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/DealServicesTable/DealServicesTable.tsx new file mode 100644 index 0000000..7e71460 --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/DealServicesTable/DealServicesTable.tsx @@ -0,0 +1,110 @@ +import {CRUDTableProps} from "../../../../../../types/CRUDTable.tsx"; +import {DealServiceSchema} from "../../../../../../client"; +import {FC} from "react"; +import {ActionIcon, Button, Flex, NumberInput, rem, Text, Title, Tooltip} from "@mantine/core"; +import {IconTrash} from "@tabler/icons-react"; +import {modals} from "@mantine/modals"; +import {isNumber} from "lodash"; + +type Props = CRUDTableProps; +const DealServicesTable: FC = ({items, onDelete, onCreate, onChange}) => { + const onDeleteClick = (item: DealServiceSchema) => { + if (!onDelete) return; + onDelete(item); + } + const onCreateClick = () => { + if (!onCreate) return; + const serviceIds = items.map(service => service.service.id); + modals.openContextModal({ + modal: "addDealService", + innerProps: { + onCreate: onCreate, + serviceIds + }, + withCloseButton: false + }) + } + const onQuantityChange = (item: DealServiceSchema, quantity: number) => { + if (!onChange) return; + onChange({ + ...item, + quantity + }) + } + const onPriceChange = (item: DealServiceSchema, price: number) => { + if (!onChange) return; + onChange({ + ...item, + price + }) + } + return ( + + + Общие услуги + + + {items.map(service => ( + + onDeleteClick(service)} + label="Удалить услугу"> + + + + + {service.service.name} + isNumber(event) && onQuantityChange(service, event)} + value={service.quantity} + /> + isNumber(event) && onPriceChange(service, event)} + suffix={"₽"} + value={service.price} + /> + + ))} + + + Итог: {items.reduce((acc, item) => acc + (item.price * item.quantity), 0)}₽ + + + + + + ) +} +export default DealServicesTable; \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductServicesTable/ProductServicesTable.tsx b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductServicesTable/ProductServicesTable.tsx new file mode 100644 index 0000000..a644e80 --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductServicesTable/ProductServicesTable.tsx @@ -0,0 +1,97 @@ +import {CRUDTableProps} from "../../../../../../types/CRUDTable.tsx"; +import {DealProductServiceSchema} from "../../../../../../client"; +import {FC} from "react"; +import useProductServicesTableColumns from "./columns.tsx"; +import {BaseTable} from "../../../../../../components/BaseTable/BaseTable.tsx"; +import {MRT_TableOptions} from "mantine-react-table"; +import {ActionIcon, Button, Flex, rem, Tooltip} from "@mantine/core"; +import {IconEdit, IconTrash} from "@tabler/icons-react"; +import {modals} from "@mantine/modals"; + +type RestProps = { + quantity: number; +} +type Props = CRUDTableProps & RestProps; +const ProductServicesTable: FC = ({items, quantity, onCreate, onDelete, onChange}) => { + const columns = useProductServicesTableColumns({data: items, quantity}); + const serviceIds = items.map(service => service.service.id); + + const onCreateClick = () => { + if (!onCreate) return; + modals.openContextModal({ + modal: "productServiceForm", + innerProps: { + onCreate: onCreate, + serviceIds, + quantity + }, + withCloseButton: false + }) + } + + const onChangeClick = (item: DealProductServiceSchema) => { + console.log('change click') + + if (!onChange) return; + console.log('change click1') + modals.openContextModal({ + modal: "productServiceForm", + innerProps: { + element: item, + onChange, + serviceIds, + quantity + }, + withCloseButton: false + }) + } + + return ( + + + + + + + ), + renderRowActions: ({row}) => ( + + + onChangeClick(row.original)} + variant={"default"}> + + + + + { + if (onDelete) onDelete(row.original); + // console.log(row); + }} variant={"default"}> + + + + + ) + } as MRT_TableOptions} + /> + {/**/} + + + ) +} +export default ProductServicesTable; \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductServicesTable/columns.tsx b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductServicesTable/columns.tsx new file mode 100644 index 0000000..68c7be1 --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductServicesTable/columns.tsx @@ -0,0 +1,25 @@ +import {useMemo} from "react"; +import {MRT_ColumnDef} from "mantine-react-table"; +import {DealProductServiceSchema} from "../../../../../../client"; + +type Props = { + data: DealProductServiceSchema[]; + quantity: number; +} +const useProductServicesTableColumns = (props: Props) => { + const {data, quantity} = props; + const totalPrice = useMemo(() => data.reduce((acc, row) => acc + (row.price * quantity), 0), [data, quantity]); + return useMemo[]>(() => [ + { + accessorKey: "service.name", + header: "Услуга", + }, + { + accessorKey: "price", + header: "Цена", + Footer: () => <>Итог: {totalPrice}₽, + } + ], [totalPrice]); +} + +export default useProductServicesTableColumns; \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductView/ProductView.module.css b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductView/ProductView.module.css new file mode 100644 index 0000000..b9b416b --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductView/ProductView.module.css @@ -0,0 +1,33 @@ +.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); +} + +.image-container { + display: flex; + max-height: rem(250); + max-width: rem(250); +} + +.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); +} + +.attributes-container { + overflow-wrap: break-word; +} \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductView/ProductView.tsx b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductView/ProductView.tsx new file mode 100644 index 0000000..421aba3 --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/components/ProductView/ProductView.tsx @@ -0,0 +1,140 @@ +import {FC} from "react"; +import {DealProductSchema, DealProductServiceSchema, ProductSchema} from "../../../../../../client"; +import styles from './ProductView.module.css'; +import {ActionIcon, Flex, Image, NumberInput, rem, Spoiler, Text, Title, Tooltip} from '@mantine/core'; +import ProductServicesTable from "../ProductServicesTable/ProductServicesTable.tsx"; +import {isNil, isNumber} from "lodash"; +import {IconBarcode, IconTrash} from "@tabler/icons-react"; +import {modals} from "@mantine/modals"; + +type Props = { + product: DealProductSchema; + onChange?: (item: DealProductSchema) => void; + onDelete?: (item: DealProductSchema) => void +} +type ProductFieldNames = { + [K in keyof ProductSchema]: string +} +const ProductFieldNames: Partial = { + color: "Цвет", + article: "Артикул", + size: "Размер", + brand: "Бренд", + composition: "Состав", + additionalInfo: "Доп. информация", +} +const ProductView: FC = ({product, onDelete, onChange}) => { + const onDeleteClick = () => { + if (!onDelete) return; + onDelete(product); + } + + const onServiceDelete = (item: DealProductServiceSchema) => { + if (!onChange) return; + onChange({ + ...product, + services: product.services.filter(service => service.service.id !== item.service.id) + }); + + } + const onServiceCreate = (item: DealProductServiceSchema) => { + if (!onChange) return; + onChange({ + ...product, + services: [...product.services, item] + }) + } + + const onServiceChange = (item: DealProductServiceSchema) => { + if (!onChange) return; + onChange({ + ...product, + services: product.services.map(service => service.service.id === item.service.id ? item : service) + }) + } + + const onQuantityChange = (quantity: number) => { + if (!onChange) return; + onChange({ + ...product, + quantity + }) + } + const onPrintBarcodeClick = () => { + modals.openContextModal({ + modal: "printBarcode", + innerProps: { + productId: product.product.id, + defaultQuantity: product.quantity + }, + title: 'Печать штрихкода', + + }) + } + + + return ( +
+
+
+ +
+
+ {product.product.name} + + + {Object.entries(product.product).map(([key, value]) => { + const fieldName = ProductFieldNames[key as keyof ProductSchema]; + if (!fieldName || isNil(value) || value === '') return; + return ({fieldName}: {value.toString()} ) + })} + Штрихкоды: {product.product.barcodes.join(', ')} + + +
+ isNumber(event) && onQuantityChange(event)} + placeholder={"Введите количество товара"} + /> +
+ +
+ + + + + + + + + + + + + +
+
+ ) +} + +export default ProductView; \ No newline at end of file diff --git a/src/pages/LeadsPage/tabs/ProductAndServiceTab/hooks/useProductAndServiceTabState.tsx b/src/pages/LeadsPage/tabs/ProductAndServiceTab/hooks/useProductAndServiceTabState.tsx new file mode 100644 index 0000000..d3d4358 --- /dev/null +++ b/src/pages/LeadsPage/tabs/ProductAndServiceTab/hooks/useProductAndServiceTabState.tsx @@ -0,0 +1,125 @@ +import {CRUDTableProps} from "../../../../../types/CRUDTable.tsx"; +import {DealProductSchema, DealService, DealServiceSchema} from "../../../../../client"; +import {useDealPageContext} from "../../../contexts/DealPageContext.tsx"; +import {notifications} from "../../../../../shared/lib/notifications.ts"; + +const useDealState = () => { + const {selectedDeal, setSelectedDeal} = useDealPageContext(); + const refetch = async () => { + if (!selectedDeal) return + return DealService.getDealById({dealId: selectedDeal.id}).then((deal) => { + setSelectedDeal(deal); + }) + } + return { + deal: selectedDeal, + refetch + } +} + +const useDealServicesState = (): CRUDTableProps => { + const {deal, refetch} = useDealState(); + const onCreate = (item: DealServiceSchema) => { + if (!deal) return; + DealService.addDealService({ + requestBody: { + dealId: deal.id, + serviceId: item.service.id, + quantity: item.quantity, + price: item.price + } + }).then(async ({ok, message}) => { + if (!ok) notifications.guess(ok, {message}); + if (ok) await refetch(); + }) + } + const onDelete = (item: DealServiceSchema) => { + if (!deal) return; + DealService.deleteDealService({ + requestBody: + { + dealId: deal.id, + serviceId: item.service.id + } + }).then(async ({ok, message}) => { + if (!ok) notifications.guess(ok, {message}); + if (ok) await refetch(); + }) + } + const onChange = (item: DealServiceSchema) => { + if (!deal) return; + DealService.updateDealService({ + requestBody: + { + dealId: deal.id, + service: item + } + }).then(async ({ok, message}) => { + if (!ok) notifications.guess(ok, {message}); + if (ok) await refetch(); + }) + } + return { + items: deal?.services || [], + onCreate, + onDelete, + onChange + } +} + +const useDealProductsState = (): CRUDTableProps => { + const {deal, refetch} = useDealState(); + const onCreate = (item: DealProductSchema) => { + if (!deal) return; + DealService.addDealProduct({ + requestBody: { + dealId: deal.id, + product: item + } + }).then(async ({ok, message}) => { + if (!ok) notifications.guess(ok, {message}); + if (ok) await refetch(); + }) + }; + const onDelete = (item: DealProductSchema) => { + if (!deal) return; + DealService.deleteDealProduct({ + requestBody: { + dealId: deal.id, + productId: item.product.id + } + }).then(async ({ok, message}) => { + if (!ok) notifications.guess(ok, {message}); + if (ok) await refetch(); + }) + }; + const onChange = (item: DealProductSchema) => { + if (!deal) return; + DealService.updateDealProduct({ + requestBody: { + dealId: deal.id, + product: item + } + }).then(async ({ok, message}) => { + if (!ok) notifications.guess(ok, {message}); + if (ok) await refetch(); + }) + }; + return { + items: deal?.products || [], + onCreate, + onDelete, + onChange + } +} +const useDealProductAndServiceTabState = () => { + const dealState = useDealState(); + const dealProductsState = useDealProductsState(); + const dealServicesState = useDealServicesState(); + return { + dealState, + dealProductsState, + dealServicesState + } +} +export default useDealProductAndServiceTabState; \ No newline at end of file diff --git a/src/shared/lib/utils.ts b/src/shared/lib/utils.ts index a91dfe2..fe5567b 100644 --- a/src/shared/lib/utils.ts +++ b/src/shared/lib/utils.ts @@ -1,3 +1,5 @@ +import {isNil} from "lodash"; + export const dateWithoutTimezone = (date: Date) => { const tzoffset = date.getTimezoneOffset() * 60000; //offset in milliseconds const withoutTimezone = new Date(date.valueOf() - tzoffset) @@ -45,3 +47,7 @@ export const IMAGE_MIME_TYPES = [ "image/svg+xml", "image/heic" ]; + +export const isNilOrEmptyString = (value: unknown): value is null | undefined | '' => { + return isNil(value) || (typeof value === 'string' && value.trim() === ''); +} \ No newline at end of file