feat: products and services tabs for mobile

This commit is contained in:
2025-09-23 10:41:55 +04:00
parent 41ff994ad1
commit a83328492e
18 changed files with 635 additions and 4 deletions

View File

@ -4,9 +4,31 @@
border-radius: var(--mantine-radius-lg);
}
.shadow {
@mixin light {
box-shadow: var(--light-shadow);
}
@mixin dark {
box-shadow: var(--dark-shadow);
}
}
.image-container {
display: flex;
max-height: rem(250);
max-width: rem(250);
height: 100%;
}
.products-swiper :global(.swiper-pagination) {
@mixin light {
background-color: var(--mantine-color-gray-2);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-lg);
padding: 3px;
}

View File

@ -1,5 +1,12 @@
import { FC } from "react";
import { FC, useEffect, useRef, useState } from "react";
import { isNull } from "lodash";
import type { Swiper as SwiperClass } from "swiper/types";
import { DealSchema } from "@/lib/client";
import ProductsTabSlider from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductsTabSlider/ProductsTabSlider";
import ProductsTabTable from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductsTabTable/ProductsTabTable";
import ProductsTabViewAffix, {
ProductsTabView,
} from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductsTabViewAffix/ProductsTabViewAffix";
import { FulfillmentBaseContextProvider } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
type Props = {
@ -7,9 +14,36 @@ type Props = {
};
const ProductsTab: FC<Props> = ({ value }) => {
const [view, setView] = useState<ProductsTabView>(ProductsTabView.SLIDER);
const swiperRef = useRef<SwiperClass | null>(null);
const targetSlideIndex = useRef<number>(null);
const onProductRowSelect = async (productIndex: number) => {
targetSlideIndex.current = productIndex;
setView(ProductsTabView.SLIDER);
};
useEffect(() => {
if (
view === ProductsTabView.SLIDER &&
!isNull(targetSlideIndex.current)
) {
swiperRef.current?.slideTo(targetSlideIndex.current);
targetSlideIndex.current = null;
}
}, [view]);
return (
<FulfillmentBaseContextProvider deal={value}>
<></>
{view === ProductsTabView.SLIDER ? (
<ProductsTabSlider swiperRef={swiperRef} />
) : (
<ProductsTabTable onSelect={onProductRowSelect} />
)}
<ProductsTabViewAffix
value={view}
onChange={value => setView(value as ProductsTabView)}
/>
</FulfillmentBaseContextProvider>
);
};

View File

@ -0,0 +1,43 @@
import { FC } from "react";
import { IconPlus } from "@tabler/icons-react";
import { ButtonProps, Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
type Props = ButtonProps;
const AddDealProductButton: FC<Props> = props => {
const { dealProductsList, dealProductsCrud, deal } =
useFulfillmentBaseContext();
const onCreateClick = () => {
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 (
<InlineButton
{...props}
onClick={onCreateClick}>
<IconPlus />
<Text>Добавить товар</Text>
</InlineButton>
);
};
export default AddDealProductButton;

View File

@ -0,0 +1,94 @@
import { FC } from "react";
import {
Box,
Card,
Flex,
Group,
Image,
Stack,
Text,
Title,
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { DealProductSchema } from "@/lib/client";
import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductFieldsList";
import ProductMenu from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductMenu/ProductMenu";
import ProductServicesTable from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductServicesTable/ProductServicesTable";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
import styles from "../../../FulfillmentBase.module.css";
type Props = {
dealProduct: DealProductSchema;
};
const DealProductView: FC<Props> = ({ dealProduct }) => {
const { dealProductsCrud } = useFulfillmentBaseContext();
const onChangeDealProductClick = () => {
modals.openContextModal({
modal: "dealProductEditorModal",
title: "Добавление товара",
withCloseButton: false,
innerProps: {
onChange: values =>
dealProductsCrud.onUpdate(
dealProduct.dealId,
dealProduct.productId,
values
),
entity: dealProduct,
isEditing: true,
clientId: 0, // TODO add clients
},
});
};
return (
<Box
p={"md"}
h={"100%"}
style={{ display: "flex", flexDirection: "column" }}>
<Card
style={{
display: "flex",
flexDirection: "column",
gap: "var(--mantine-spacing-sm)",
flex: 1,
minHeight: 0,
}}
className={styles.shadow}
withBorder>
<Flex
gap={"sm"}
wrap={"nowrap"}
align={"start"}>
<Image
src="https://placehold.co/400x500?text=Placeholder"
alt={dealProduct.product.name}
w={"40%"}
bdrs={"md"}
/>
<Stack
gap={0}
w={"100%"}>
<Group
wrap={"nowrap"}
justify={"space-between"}>
<Title order={3}>{dealProduct.product.name}</Title>
<ProductMenu
value={dealProduct}
onChange={onChangeDealProductClick}
onDelete={dealProductsCrud.onDelete}
/>
</Group>
<Text>Количество: {dealProduct.quantity}</Text>
<ProductFieldsList product={dealProduct.product} />
</Stack>
</Flex>
<ProductServicesTable dealProduct={dealProduct} />
</Card>
</Box>
);
};
export default DealProductView;

View File

@ -0,0 +1,35 @@
import { FC, useMemo } from "react";
import { Title, TitleProps } from "@mantine/core";
import { DealProductSchema } from "@/lib/client";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
type Props = TitleProps;
const DealProductsTotalLabel: FC<Props> = ({ order = 4, ...props }) => {
const { dealProductsList } = useFulfillmentBaseContext();
const getProductTotal = (dealProduct: DealProductSchema) =>
dealProduct.productServices.reduce(
(acc, service) => acc + dealProduct.quantity * service.price,
0
);
const total = useMemo(
() =>
dealProductsList.dealProducts.reduce(
(acc, product) => acc + getProductTotal(product),
0
),
[dealProductsList.dealProducts]
);
return (
<Title
order={order}
{...props}>
Итог: {total.toLocaleString("ru")}
</Title>
);
};
export default DealProductsTotalLabel;

View File

@ -0,0 +1,54 @@
import React, { FC } from "react";
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
import { Box, Group, Menu, Text } from "@mantine/core";
import ThemeIcon from "@/components/ui/ThemeIcon/ThemeIcon";
import { DealProductSchema } from "@/lib/client";
type Props = {
value: DealProductSchema;
onChange: (dealProduct: DealProductSchema) => void;
onDelete: (dealProduct: DealProductSchema) => void;
};
const ProductMenu: FC<Props> = ({ value, onChange, onDelete }) => {
return (
<Menu>
<Menu.Target>
<Box
style={{ cursor: "pointer" }}
onClick={e => e.stopPropagation()}>
<ThemeIcon
bd={0}
variant={"default"}
size={"sm"}>
<IconDotsVertical />
</ThemeIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={e => {
e.stopPropagation();
onChange(value);
}}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Редактировать</Text>
</Group>
</Menu.Item>
<Menu.Item
onClick={e => {
e.stopPropagation();
onDelete(value);
}}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export default ProductMenu;

View File

@ -0,0 +1,115 @@
import { FC } from "react";
import { IconMoodSad } from "@tabler/icons-react";
import { Button, Flex, Group, ScrollArea, 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/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductServicesTable/useProductServicesTableColumns";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
type Props = {
dealProduct: DealProductSchema;
};
const ProductServicesTable: FC<Props> = ({ dealProduct }) => {
const { productServiceCrud, dealProductsList } =
useFulfillmentBaseContext();
const onChange = (item: ProductServiceSchema) => {
const excludeServiceIds = dealProduct.productServices.map(
productService => productService.service.id
);
const totalQuantity = dealProductsList.dealProducts.reduce(
(sum, prod) => prod.quantity + sum,
0
);
modals.openContextModal({
modal: "productServiceEditorModal",
innerProps: {
entity: item,
onChange: values =>
productServiceCrud.onUpdate(
item.dealId,
item.productId,
item.serviceId,
values
),
excludeServiceIds,
quantity: totalQuantity,
isEditing: true,
},
withCloseButton: false,
});
};
const columns = useProductServicesTableColumns({
data: dealProduct.productServices,
quantity: dealProduct.quantity,
onDelete: productServiceCrud.onDelete,
onChange,
});
const onCreateClick = () => {
const excludeServiceIds = dealProduct.productServices.map(
productService => productService.service.id
);
modals.openContextModal({
modal: "productServiceEditorModal",
innerProps: {
onCreate: values =>
productServiceCrud.onCreate({ ...dealProduct, ...values }),
excludeServiceIds,
quantity: dealProduct.quantity,
isEditing: false,
},
withCloseButton: false,
});
};
const isEmptyTable = dealProduct.productServices.length === 0;
return (
<Flex
flex={1}
gap={"xs"}
direction={"column"}
justify={"space-between"}
style={{ minHeight: 0 }}>
<ScrollArea
flex={10}
scrollbarSize={10}
onTouchStart={e => e.stopPropagation()}
onTouchMove={e => e.stopPropagation()}>
<BaseTable
records={dealProduct.productServices}
columns={columns}
groups={undefined}
idAccessor={"serviceId"}
verticalSpacing={"md"}
withTableBorder
style={{
height: isEmptyTable ? "8rem" : "auto",
}}
emptyState={
<Group
gap={"xs"}
mt={isEmptyTable ? "xl" : 0}>
<Text>Нет услуг</Text>
<IconMoodSad />
</Group>
}
/>
</ScrollArea>
<Button
flex={1}
py={"xs"}
onClick={onCreateClick}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
);
};
export default ProductServicesTable;

View File

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

View File

@ -0,0 +1,46 @@
import React, { FC, RefObject } from "react";
import { Pagination } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react";
import type { Swiper as SwiperClass } from "swiper/types";
import { Box } from "@mantine/core";
import AddDealProductButton from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/AddDealProductButton/AddDealProductButton";
import DealProductView from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/DealProductView/DealProductView";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
import classes from "../../../FulfillmentBase.module.css";
type Props = {
swiperRef: RefObject<SwiperClass | null>;
};
const ProductsTabSlider: FC<Props> = ({ swiperRef }) => {
const { dealProductsList } = useFulfillmentBaseContext();
return (
<Swiper
onSwiper={swiper => (swiperRef.current = swiper)}
spaceBetween={15}
modules={[Pagination]}
direction={"vertical"}
style={{ maxHeight: "calc(100vh - 110px)" }}
className={classes["products-swiper"]}
pagination={{ enabled: true, clickable: true }}>
{dealProductsList.dealProducts.map(dealProduct => (
<SwiperSlide>
<DealProductView dealProduct={dealProduct} />
</SwiperSlide>
))}
<SwiperSlide>
<Box
p={"md"}
h={"100%"}>
<AddDealProductButton
fullWidth
h={"100%"}
/>
</Box>
</SwiperSlide>
</Swiper>
);
};
export default ProductsTabSlider;

View File

@ -0,0 +1,41 @@
import { FC } from "react";
import { Flex, Stack } from "@mantine/core";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import AddDealProductButton from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/AddDealProductButton/AddDealProductButton";
import DealProductsTotalLabel from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/DealProductsTotalLabel/DealProductsTotalLabel";
import useDealServicesTableColumns from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductsTabTable/hooks/useDealProductsTableColumns";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
type Props = {
onSelect: (index: number) => void;
};
const ProductsTabTable: FC<Props> = ({ onSelect }) => {
const { dealProductsList } = useFulfillmentBaseContext();
const columns = useDealServicesTableColumns();
return (
<Stack
gap={"xs"}
m={"xs"}>
<Flex
p={"sm"}
bd={"1px solid var(--mantine-color-default-border)"}
bdrs={"lg"}>
<DealProductsTotalLabel />
</Flex>
<BaseTable
records={dealProductsList.dealProducts}
columns={columns}
groups={undefined}
idAccessor={"productId"}
verticalSpacing={"md"}
withTableBorder
onRowClick={event => onSelect(event.index)}
/>
<AddDealProductButton size={"lg"} />
</Stack>
);
};
export default ProductsTabTable;

View File

@ -0,0 +1,29 @@
import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable";
import { DealProductSchema } from "@/lib/client";
const useDealServicesTableColumns = () => {
return useMemo(
() =>
[
{
accessor: "product.name",
title: "Название",
width: "60%",
},
{
accessor: "quantity",
title: "Кол-во",
width: "20%",
},
{
accessor: "product.size",
title: "Размер",
width: "20%",
},
] as DataTableColumn<DealProductSchema>[],
[]
);
};
export default useDealServicesTableColumns;

View File

@ -0,0 +1,46 @@
import { FC } from "react";
import { IconLayoutDistributeVertical, IconTable } from "@tabler/icons-react";
import {
Affix,
Center,
SegmentedControl,
SegmentedControlProps,
} from "@mantine/core";
import styles from "../../../FulfillmentBase.module.css";
export enum ProductsTabView {
TABLE = "table",
SLIDER = "slider",
}
type Props = Omit<SegmentedControlProps, "data">;
const ProductsTabViewAffix: FC<Props> = props => {
return (
<Affix
bdrs={"xl"}
bd={"1px solid var(--mantine-color-default-border"}
className={styles.shadow}
position={{ bottom: 10, right: 10 }}>
<Center>
<SegmentedControl
bdrs={"xl"}
radius={"lg"}
data={[
{
value: ProductsTabView.SLIDER,
label: <IconLayoutDistributeVertical />,
},
{
value: ProductsTabView.TABLE,
label: <IconTable />,
},
]}
{...props}
/>
</Center>
</Affix>
);
};
export default ProductsTabViewAffix;

View File

@ -12,7 +12,7 @@ import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
import ProductSelect
from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductSelect/ProductSelect";
from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/components/ProductSelect/ProductSelect";
type RestProps = {
clientId: number;

View File

@ -9,7 +9,7 @@ import {
} from "@mantine/core";
import { ObjectSelectProps } from "@/components/selects/ObjectSelect/ObjectSelect";
import { ServiceSchema } from "@/lib/client";
import ServiceSelect from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ServiceSelect/ServiceSelect";
import ServiceSelect from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/components/ServiceSelect/ServiceSelect";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service";
type ServiceProps = Omit<ObjectSelectProps<ServiceSchema>, "data">;