feat: products page

This commit is contained in:
2025-10-08 22:32:16 +04:00
parent 820d9b4d33
commit 8af4fcce2f
25 changed files with 664 additions and 58 deletions

View File

@ -1,4 +1,9 @@
import { IconColumns, IconFileBarcode, IconUsers } from "@tabler/icons-react"; import {
IconBox,
IconColumns,
IconFileBarcode,
IconUsers,
} from "@tabler/icons-react";
import { ModuleNames } from "@/modules/modules"; import { ModuleNames } from "@/modules/modules";
import LinkData from "@/types/LinkData"; import LinkData from "@/types/LinkData";
@ -15,6 +20,12 @@ const mobileButtonsData: LinkData[] = [
href: "/services", href: "/services",
moduleName: ModuleNames.FULFILLMENT_BASE, moduleName: ModuleNames.FULFILLMENT_BASE,
}, },
{
icon: IconBox,
label: "Товары",
href: "/products",
moduleName: ModuleNames.FULFILLMENT_BASE,
},
{ {
icon: IconFileBarcode, icon: IconFileBarcode,
label: "Шаблоны штрихкодов", label: "Шаблоны штрихкодов",

View File

@ -1,7 +1,7 @@
import { FC, useCallback } from "react"; import { FC, useCallback } from "react";
import { IconMoodSad } from "@tabler/icons-react"; import { IconMoodSad } from "@tabler/icons-react";
import { Group, Pagination, Stack, Text } from "@mantine/core"; import { Group, Pagination, Stack, Text } from "@mantine/core";
import useDealsTableColumns from "@/app/deals/components/desktop/DealsTable/useDealsTableColumns"; import useDealsTableColumns from "@/app/deals/components/shared/DealsTable/useDealsTableColumns";
import { useDealsContext } from "@/app/deals/contexts/DealsContext"; import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext"; import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import BaseTable from "@/components/ui/BaseTable/BaseTable"; import BaseTable from "@/components/ui/BaseTable/BaseTable";

View File

@ -1,3 +1,3 @@
import DealsTable from "@/app/deals/components/desktop/DealsTable/DealsTable"; import DealsTable from "../DealsTable/DealsTable";
export const TableView = () => <DealsTable />; export const TableView = () => <DealsTable />;

View File

@ -0,0 +1,35 @@
"use client";
import { Flex, Group, TextInput } from "@mantine/core";
import { useProductsContext } from "@/app/products/contexts/ProductsContext";
import useProductsActions from "@/app/products/hooks/useProductsActions";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import ClientSelect from "@/modules/dealModularEditorTabs/Clients/components/ClientSelect";
const ProductsDesktopHeader = () => {
const { productsFiltersForm } = useProductsContext();
const { onCreateClick } = useProductsActions();
return (
<Group justify={"space-between"}>
<Flex gap={"xs"}>
<ClientSelect
{...productsFiltersForm.getInputProps("client")}
/>
<InlineButton
onClick={onCreateClick}
disabled={!productsFiltersForm.values.client}>
Создать
</InlineButton>
</Flex>
<Flex>
<TextInput
placeholder={"Артикул, название, шк"}
{...productsFiltersForm.getInputProps("searchInput")}
/>
</Flex>
</Group>
);
};
export default ProductsDesktopHeader;

View File

@ -0,0 +1,41 @@
"use client";
import { Flex, Stack, TextInput } from "@mantine/core";
import { useProductsContext } from "@/app/products/contexts/ProductsContext";
import useProductsActions from "@/app/products/hooks/useProductsActions";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import ClientSelect from "@/modules/dealModularEditorTabs/Clients/components/ClientSelect";
const ProductsMobileHeader = () => {
const { productsFiltersForm } = useProductsContext();
const { onCreateClick } = useProductsActions();
return (
<Stack
gap={"xs"}
justify={"space-between"}
mt={"xs"}
mx={"xs"}>
<Flex
gap={"xs"}
flex={2}>
<ClientSelect
{...productsFiltersForm.getInputProps("client")}
flex={1}
/>
<TextInput
placeholder={"Артикул, название, шк"}
{...productsFiltersForm.getInputProps("searchInput")}
flex={1}
/>
</Flex>
<InlineButton
onClick={onCreateClick}
disabled={!productsFiltersForm.values.client}>
Создать
</InlineButton>
</Stack>
);
};
export default ProductsMobileHeader;

View File

@ -0,0 +1,23 @@
"use client";
import { FC } from "react";
import useBarcodeTemplatesList from "@/app/barcode-templates/hooks/useBarcodeTemplatesList";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import { BarcodeTemplateSchema } from "@/lib/client";
type Props = Omit<ObjectSelectProps<BarcodeTemplateSchema>, "data">;
const BarcodeTemplateSelect: FC<Props> = props => {
const { barcodeTemplates } = useBarcodeTemplatesList();
return (
<ObjectSelect
data={barcodeTemplates}
{...props}
/>
);
};
export default BarcodeTemplateSelect;

View File

@ -0,0 +1,36 @@
"use client";
import { Stack } from "@mantine/core";
import ProductsDesktopHeader from "@/app/products/components/desktop/ProductsDesktopHeader/ProductsDesktopHeader";
import ProductsMobileHeader from "@/app/products/components/mobile/ProductsMobileHeader/ProductsMobileHeader";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
import useIsMobile from "@/hooks/utils/useIsMobile";
import ProductsTable from "@/app/products/components/shared/ProductsTable/ProductsTable";
const PageBody = () => {
const isMobile = useIsMobile();
return (
<Stack h={"100%"}>
{!isMobile && (
<PageBlock>
<ProductsDesktopHeader />
</PageBlock>
)}
<PageBlock
style={{ flex: 1, minHeight: 0 }}
fullScreenMobile>
<Stack
gap={"xs"}
h={"100%"}>
{isMobile && <ProductsMobileHeader />}
<div style={{ flex: 1, overflow: "auto" }}>
<ProductsTable />
</div>
</Stack>
</PageBlock>
</Stack>
);
};
export default PageBody;

View File

@ -0,0 +1,60 @@
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Pagination, Stack, Text } from "@mantine/core";
import { useProductsTableColumns } from "@/app/products/components/shared/ProductsTable/columns";
import { useProductsContext } from "@/app/products/contexts/ProductsContext";
import useProductsActions from "@/app/products/hooks/useProductsActions";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const ProductsTable = () => {
const isMobile = useIsMobile();
const { productsCrud, products, productsFiltersForm, paginationInfo } =
useProductsContext();
const { onChangeClick } = useProductsActions();
const columns = useProductsTableColumns({
onChange: onChangeClick,
onDelete: productsCrud.onDelete,
});
return (
<Stack
h={"100%"}
pb={"xs"}
px={isMobile ? "xs" : ""}>
<BaseTable
withTableBorder
records={products}
columns={columns}
groups={undefined}
style={{
height: "100%",
}}
emptyState={
productsFiltersForm.values.client ? (
<Group
align={"center"}
gap={"xs"}>
<Text>Нет товаров</Text>
<IconMoodSad />
</Group>
) : (
<Text>Выберите клиента для вывода товаров</Text>
)
}
verticalSpacing={"md"}
/>
{paginationInfo && paginationInfo.totalPages > 1 && (
<Group justify={"end"}>
<Pagination
withEdges
total={paginationInfo?.totalPages}
{...productsFiltersForm.getInputProps("page")}
/>
</Group>
)}
</Stack>
);
};
export default ProductsTable;

View File

@ -0,0 +1,106 @@
import { useMemo } from "react";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Center, Flex, List, Spoiler, useMantineTheme } from "@mantine/core";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { ProductSchema } from "@/lib/client";
type Props = {
onChange: (product: ProductSchema) => void;
onDelete: (product: ProductSchema) => void;
};
export const useProductsTableColumns = ({ onChange, onDelete }: Props) => {
const theme = useMantineTheme();
const isMobile = useIsMobile();
return useMemo(
() =>
[
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: product => (
<Flex gap={isMobile ? "sm" : "md"}>
{/*<ActionIconWithTip*/}
{/* tipLabel={"Печать штрихкода"}*/}
{/* onClick={() => onPrintBarcodeClick(product)}>*/}
{/* <IconBarcode />*/}
{/*</ActionIconWithTip>*/}
<ActionIconWithTip
tipLabel={"Редактировать"}
onClick={() => onChange(product)}>
<IconEdit />
</ActionIconWithTip>
<ActionIconWithTip
tipLabel={"Удалить"}
onClick={() => onDelete(product)}>
<IconTrash />
</ActionIconWithTip>
</Flex>
),
},
{
accessor: "article",
title: "Артикул",
},
{
accessor: "factoryArticle",
title: "Складской артикул",
},
{
accessor: "name",
title: "Название",
},
{
accessor: "barcodes",
title: "Штрихкоды",
render: product => {
return (
<List size={"sm"}>
<Spoiler
maxHeight={
parseFloat(theme.lineHeights.sm) * 30
}
showLabel={"Показать все"}
hideLabel={"Скрыть"}>
{product.barcodes.map(barcode => (
<List.Item key={barcode}>
{barcode}
</List.Item>
))}
</Spoiler>
</List>
);
},
},
{
accessor: "barcodeTemplate.name",
title: "Шаблон ШК",
},
{
accessor: "brand",
title: "Бренд",
},
{
accessor: "composition",
title: "Состав",
},
{
accessor: "color",
title: "Цвет",
},
{
accessor: "size",
title: "Размер",
},
{
accessor: "additionalInfo",
title: "Доп. информация",
},
] as DataTableColumn<ProductSchema>[],
[]
);
};

View File

@ -0,0 +1,57 @@
"use client";
import { useForm, UseFormReturnType } from "@mantine/form";
import { useDebouncedValue } from "@mantine/hooks";
import { ClientSchema, PaginationInfoSchema, ProductSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
import {
ProductsCrud,
useProductsCrud,
} from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/hooks/cruds/useProductsCrud";
import useProductsList from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/hooks/lists/useProductsList";
export type ProductsFiltersForm = {
searchInput: string;
client: ClientSchema | null;
page: number;
};
type ProductsContextState = {
productsFiltersForm: UseFormReturnType<ProductsFiltersForm>;
products: ProductSchema[];
productsCrud: ProductsCrud;
paginationInfo?: PaginationInfoSchema;
};
const useProductsContextState = (): ProductsContextState => {
const productsFiltersForm = useForm<ProductsFiltersForm>({
initialValues: {
searchInput: "",
client: null,
page: 1,
},
});
const [debouncedSearchInput] = useDebouncedValue(
productsFiltersForm.values.searchInput,
500
);
const { products, paginationInfo, queryKey } = useProductsList({
clientId: productsFiltersForm.values.client?.id,
searchInput: debouncedSearchInput,
page: productsFiltersForm.values.page,
itemsPerPage: 10,
});
const productsCrud = useProductsCrud({ queryKey });
return {
productsFiltersForm,
products,
productsCrud,
paginationInfo,
};
};
export const [ProductsContextProvider, useProductsContext] =
makeContext<ProductsContextState>(useProductsContextState, "Products");

View File

@ -0,0 +1,59 @@
import { modals } from "@mantine/modals";
import { useProductsContext } from "@/app/products/contexts/ProductsContext";
import { ProductSchema } from "@/lib/client";
import { notifications } from "@/lib/notifications";
const useProductsActions = () => {
const { productsCrud, productsFiltersForm } = useProductsContext();
const onCreateClick = () => {
if (!productsFiltersForm.values.client) {
notifications.error({ message: "Выберите клиента" });
return;
}
modals.openContextModal({
modal: "productEditorModal",
title: "Создание товара",
withCloseButton: false,
innerProps: {
onCreate: productsCrud.onCreate,
clientId: productsFiltersForm.values.client.id,
isEditing: false,
},
});
};
const onChangeClick = (product: ProductSchema) => {
modals.openContextModal({
modal: "productEditorModal",
title: "Редактирование товара",
withCloseButton: false,
innerProps: {
onChange: updated => productsCrud.onUpdate(product.id, updated),
clientId: product.clientId,
entity: product,
isEditing: true,
},
});
};
const onPrintBarcodeClick = (product: ProductSchema) => {
modals.openContextModal({
modal: "printBarcode",
title: "Печать штрихкода", // TODO
withCloseButton: true,
innerProps: {
productId: product.id,
},
});
};
return {
onCreateClick,
onChangeClick,
onPrintBarcodeClick,
};
};
export default useProductsActions;

View File

@ -0,0 +1,20 @@
import { useQuery } from "@tanstack/react-query";
import { ProductService } from "../../../client";
type Props = {
clientId: number;
page?: number;
itemsPerPage?: number;
searchInput: string;
};
const useProductsList = (props: Props) => {
const { clientId, page, itemsPerPage, searchInput } = props;
const { data, refetch, isLoading } = useQuery({
queryKey: ["getAllServices", clientId, page, itemsPerPage, searchInput],
queryFn: () => ProductService.getProductsByClientId(props),
});
const products = !data ? [] : data.products;
const paginationInfo = data?.paginationInfo;
return { products, paginationInfo, refetch, isLoading };
};
export default useProductsList;

22
src/app/products/page.tsx Normal file
View File

@ -0,0 +1,22 @@
import { Suspense } from "react";
import { Center, Loader } from "@mantine/core";
import PageBody from "@/app/products/components/shared/PageBody/PageBody";
import { ProductsContextProvider } from "@/app/products/contexts/ProductsContext";
import PageContainer from "@/components/layout/PageContainer/PageContainer";
export default async function ProductsPage() {
return (
<Suspense
fallback={
<Center h="50vh">
<Loader size="lg" />
</Center>
}>
<PageContainer>
<ProductsContextProvider>
<PageBody />
</ProductsContextProvider>
</PageContainer>
</Suspense>
);
}

View File

@ -1,4 +1,5 @@
import { import {
IconBox,
IconColumns, IconColumns,
IconFileBarcode, IconFileBarcode,
IconLayoutKanban, IconLayoutKanban,
@ -26,6 +27,12 @@ const linksData: LinkData[] = [
href: "/services", href: "/services",
moduleName: ModuleNames.FULFILLMENT_BASE, moduleName: ModuleNames.FULFILLMENT_BASE,
}, },
{
icon: IconBox,
label: "Товары",
href: "/products",
moduleName: ModuleNames.FULFILLMENT_BASE,
},
{ {
icon: IconFileBarcode, icon: IconFileBarcode,
label: "Шаблоны штрихкодов", label: "Шаблоны штрихкодов",

View File

@ -487,6 +487,14 @@ export type CreateProductSchema = {
* Factoryarticle * Factoryarticle
*/ */
factoryArticle: string; factoryArticle: string;
/**
* Clientid
*/
clientId: number;
/**
* Barcodetemplateid
*/
barcodeTemplateId: number;
/** /**
* Brand * Brand
*/ */
@ -507,6 +515,10 @@ export type CreateProductSchema = {
* Additionalinfo * Additionalinfo
*/ */
additionalInfo: string | null; additionalInfo: string | null;
/**
* Barcodes
*/
barcodes: Array<string>;
}; };
/** /**
@ -1098,6 +1110,7 @@ export type GetProductsResponse = {
* Items * Items
*/ */
items: Array<ProductSchema>; items: Array<ProductSchema>;
paginationInfo: PaginationInfoSchema;
}; };
/** /**
@ -1218,6 +1231,14 @@ export type ProductSchema = {
* Factoryarticle * Factoryarticle
*/ */
factoryArticle: string; factoryArticle: string;
/**
* Clientid
*/
clientId: number;
/**
* Barcodetemplateid
*/
barcodeTemplateId: number;
/** /**
* Brand * Brand
*/ */
@ -1238,10 +1259,15 @@ export type ProductSchema = {
* Additionalinfo * Additionalinfo
*/ */
additionalInfo: string | null; additionalInfo: string | null;
/**
* Barcodes
*/
barcodes: Array<string>;
/** /**
* Id * Id
*/ */
id: number; id: number;
barcodeTemplate: BarcodeTemplateSchema;
}; };
/** /**
@ -1704,6 +1730,10 @@ export type UpdateProductSchema = {
* Factoryarticle * Factoryarticle
*/ */
factoryArticle?: string | null; factoryArticle?: string | null;
/**
* Barcodetemplateid
*/
barcodeTemplateId?: number | null;
/** /**
* Brand * Brand
*/ */
@ -1724,6 +1754,10 @@ export type UpdateProductSchema = {
* Additionalinfo * Additionalinfo
*/ */
additionalInfo?: string | null; additionalInfo?: string | null;
/**
* Barcodes
*/
barcodes?: Array<string> | null;
/** /**
* Images * Images
*/ */
@ -3218,6 +3252,10 @@ export type GetProductsData = {
body?: never; body?: never;
path?: never; path?: never;
query?: { query?: {
/**
* Clientid
*/
clientId?: number | null;
/** /**
* Searchinput * Searchinput
*/ */

View File

@ -208,12 +208,16 @@ export const zProductSchema = z.object({
name: z.string(), name: z.string(),
article: z.string(), article: z.string(),
factoryArticle: z.string(), factoryArticle: z.string(),
clientId: z.int(),
barcodeTemplateId: z.int(),
brand: z.union([z.string(), z.null()]), brand: z.union([z.string(), z.null()]),
color: z.union([z.string(), z.null()]), color: z.union([z.string(), z.null()]),
composition: z.union([z.string(), z.null()]), composition: z.union([z.string(), z.null()]),
size: z.union([z.string(), z.null()]), size: z.union([z.string(), z.null()]),
additionalInfo: z.union([z.string(), z.null()]), additionalInfo: z.union([z.string(), z.null()]),
barcodes: z.array(z.string()),
id: z.int(), id: z.int(),
barcodeTemplate: zBarcodeTemplateSchema,
}); });
/** /**
@ -377,11 +381,14 @@ export const zCreateProductSchema = z.object({
name: z.string(), name: z.string(),
article: z.string(), article: z.string(),
factoryArticle: z.string(), factoryArticle: z.string(),
clientId: z.int(),
barcodeTemplateId: z.int(),
brand: z.union([z.string(), z.null()]), brand: z.union([z.string(), z.null()]),
color: z.union([z.string(), z.null()]), color: z.union([z.string(), z.null()]),
composition: z.union([z.string(), z.null()]), composition: z.union([z.string(), z.null()]),
size: z.union([z.string(), z.null()]), size: z.union([z.string(), z.null()]),
additionalInfo: z.union([z.string(), z.null()]), additionalInfo: z.union([z.string(), z.null()]),
barcodes: z.array(z.string()),
}); });
/** /**
@ -765,6 +772,7 @@ export const zGetDealsResponse = z.object({
*/ */
export const zGetProductsResponse = z.object({ export const zGetProductsResponse = z.object({
items: z.array(zProductSchema), items: z.array(zProductSchema),
paginationInfo: zPaginationInfoSchema,
}); });
/** /**
@ -1014,11 +1022,13 @@ export const zUpdateProductSchema = z.object({
name: z.optional(z.union([z.string(), z.null()])), name: z.optional(z.union([z.string(), z.null()])),
article: z.optional(z.union([z.string(), z.null()])), article: z.optional(z.union([z.string(), z.null()])),
factoryArticle: z.optional(z.union([z.string(), z.null()])), factoryArticle: z.optional(z.union([z.string(), z.null()])),
barcodeTemplateId: z.optional(z.union([z.int(), z.null()])),
brand: z.optional(z.union([z.string(), z.null()])), brand: z.optional(z.union([z.string(), z.null()])),
color: z.optional(z.union([z.string(), z.null()])), color: z.optional(z.union([z.string(), z.null()])),
composition: z.optional(z.union([z.string(), z.null()])), composition: z.optional(z.union([z.string(), z.null()])),
size: z.optional(z.union([z.string(), z.null()])), size: z.optional(z.union([z.string(), z.null()])),
additionalInfo: z.optional(z.union([z.string(), z.null()])), additionalInfo: z.optional(z.union([z.string(), z.null()])),
barcodes: z.optional(z.union([z.array(z.string()), z.null()])),
images: z.optional(z.union([z.array(zProductImageSchema), z.null()])), images: z.optional(z.union([z.array(zProductImageSchema), z.null()])),
}); });
@ -1718,6 +1728,7 @@ export const zGetProductsData = z.object({
path: z.optional(z.never()), path: z.optional(z.never()),
query: z.optional( query: z.optional(
z.object({ z.object({
clientId: z.optional(z.union([z.int(), z.null()])),
searchInput: z.optional(z.union([z.string(), z.null()])), searchInput: z.optional(z.union([z.string(), z.null()])),
page: z.optional(z.union([z.int(), z.null()])), page: z.optional(z.union([z.int(), z.null()])),
itemsPerPage: z.optional(z.union([z.int(), z.null()])), itemsPerPage: z.optional(z.union([z.int(), z.null()])),

View File

@ -2,6 +2,7 @@ import { FC } from "react";
import { Button, Flex } from "@mantine/core"; import { Button, Flex } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext"; import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
import { notifications } from "@/lib/notifications";
const ProductsActions: FC = () => { const ProductsActions: FC = () => {
const { deal, dealProductsList, productsCrud, dealProductsCrud } = const { deal, dealProductsList, productsCrud, dealProductsCrud } =
@ -20,6 +21,11 @@ const ProductsActions: FC = () => {
}; };
const onCreateDealProductClick = () => { const onCreateDealProductClick = () => {
if (!deal.client) {
notifications.error({ message: "Выберите клиента для сделки" });
return;
}
const productIdsToExclude = dealProductsList.dealProducts.map( const productIdsToExclude = dealProductsList.dealProducts.map(
product => product.product.id product => product.product.id
); );
@ -33,7 +39,7 @@ const ProductsActions: FC = () => {
dealProductsCrud.onCreate({ ...values, dealId: deal.id }), dealProductsCrud.onCreate({ ...values, dealId: deal.id }),
productIdsToExclude, productIdsToExclude,
isEditing: false, isEditing: false,
clientId: 0, // TODO add clients clientId: deal.client.id,
}, },
}); });
}; };

View File

@ -3,6 +3,7 @@ import { IconPlus } from "@tabler/icons-react";
import { ButtonProps, Text } from "@mantine/core"; import { ButtonProps, Text } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import InlineButton from "@/components/ui/InlineButton/InlineButton"; import InlineButton from "@/components/ui/InlineButton/InlineButton";
import { notifications } from "@/lib/notifications";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext"; import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
type Props = ButtonProps; type Props = ButtonProps;
@ -12,6 +13,11 @@ const AddDealProductButton: FC<Props> = props => {
useFulfillmentBaseContext(); useFulfillmentBaseContext();
const onCreateClick = () => { const onCreateClick = () => {
if (!deal.client) {
notifications.error({ message: "Выберите клиента для сделки" });
return;
}
const productIdsToExclude = dealProductsList.dealProducts.map( const productIdsToExclude = dealProductsList.dealProducts.map(
product => product.product.id product => product.product.id
); );
@ -25,7 +31,7 @@ const AddDealProductButton: FC<Props> = props => {
dealProductsCrud.onCreate({ ...values, dealId: deal.id }), dealProductsCrud.onCreate({ ...values, dealId: deal.id }),
productIdsToExclude, productIdsToExclude,
isEditing: false, isEditing: false,
clientId: 0, // TODO add clients clientId: deal.client.id,
}, },
}); });
}; };

View File

@ -11,6 +11,7 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { DealProductSchema } from "@/lib/client"; import { DealProductSchema } from "@/lib/client";
import { notifications } from "@/lib/notifications";
import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductFieldsList"; import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductFieldsList";
import ProductMenu from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductMenu/ProductMenu"; import ProductMenu from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductMenu/ProductMenu";
import ProductServicesTable from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductServicesTable/ProductServicesTable"; import ProductServicesTable from "@/modules/dealModularEditorTabs/FulfillmentBase/mobile/components/ProductServicesTable/ProductServicesTable";
@ -22,9 +23,14 @@ type Props = {
}; };
const DealProductView: FC<Props> = ({ dealProduct }) => { const DealProductView: FC<Props> = ({ dealProduct }) => {
const { dealProductsCrud } = useFulfillmentBaseContext(); const { dealProductsCrud, deal } = useFulfillmentBaseContext();
const onChangeDealProductClick = () => { const onChangeDealProductClick = () => {
if (!deal.client) {
notifications.error({ message: "Выберите клиента для сделки" });
return;
}
modals.openContextModal({ modals.openContextModal({
modal: "dealProductEditorModal", modal: "dealProductEditorModal",
title: "Добавление товара", title: "Добавление товара",
@ -38,7 +44,7 @@ const DealProductView: FC<Props> = ({ dealProduct }) => {
), ),
entity: dealProduct, entity: dealProduct,
isEditing: true, isEditing: true,
clientId: 0, // TODO add clients clientId: deal.client.id,
}, },
}); });
}; };

View File

@ -21,7 +21,7 @@ const ProductSelect: FC<Props> = (props: Props) => {
const [searchValue, setSearchValue] = useState(""); const [searchValue, setSearchValue] = useState("");
const [debounced] = useDebouncedValue(searchValue, 500); const [debounced] = useDebouncedValue(searchValue, 500);
const { products, isLoading } = useProductsList({ const { products, isLoading } = useProductsList({
// clientId: props.clientId, clientId: props.clientId,
searchInput: debounced, searchInput: debounced,
page: 0, page: 0,
itemsPerPage: MAX_PRODUCTS, itemsPerPage: MAX_PRODUCTS,

View File

@ -40,7 +40,9 @@ type Props = {
const useFulfillmentBaseContextState = ({ const useFulfillmentBaseContextState = ({
deal, deal,
}: Props): FulfillmentBaseContextState => { }: Props): FulfillmentBaseContextState => {
const productQueryKey = getProductsQueryKey(); const productQueryKey = getProductsQueryKey({
query: { clientId: deal.client?.id },
});
const productsCrud = useProductsCrud({ queryKey: productQueryKey }); const productsCrud = useProductsCrud({ queryKey: productQueryKey });
const dealProductsList = useDealProductsList({ dealId: deal.id }); const dealProductsList = useDealProductsList({ dealId: deal.id });

View File

@ -1,22 +1,40 @@
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ProductSchema } from "@/lib/client"; import { PaginationInfoSchema, ProductSchema } from "@/lib/client";
import { import {
getProductsOptions, getProductsOptions,
getProductsQueryKey, getProductsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
type Props = { type Props = {
clientId?: number;
searchInput: string; searchInput: string;
page?: number; page?: number;
itemsPerPage?: number; itemsPerPage?: number;
}; };
const useProductsList = ({ searchInput, page, itemsPerPage }: Props) => { type ReturnType = {
products: ProductSchema[];
paginationInfo?: PaginationInfoSchema;
setProducts: (products: ProductSchema[]) => void;
refetch: () => void;
queryKey: any[];
isLoading: boolean;
};
const useProductsList = ({
clientId,
searchInput,
page,
itemsPerPage,
}: Props): ReturnType => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const options = { const options = {
query: { searchInput, page, itemsPerPage }, query: { clientId, searchInput, page, itemsPerPage },
}; };
const { data, refetch, isLoading } = useQuery(getProductsOptions(options)); const { data, refetch, isLoading } = useQuery({
...getProductsOptions(options),
enabled: !!clientId,
});
const queryKey = getProductsQueryKey(options); const queryKey = getProductsQueryKey(options);
@ -32,6 +50,7 @@ const useProductsList = ({ searchInput, page, itemsPerPage }: Props) => {
return { return {
products: data?.items ?? [], products: data?.items ?? [],
paginationInfo: data?.paginationInfo,
setProducts, setProducts,
refetch, refetch,
queryKey, queryKey,

View File

@ -1,9 +1,11 @@
"use client"; "use client";
import { Fieldset, Flex, TextInput } from "@mantine/core"; import { Fieldset, Flex, Stack, TagsInput, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals"; import { ContextModalProps } from "@mantine/modals";
import BarcodeTemplateSelect from "@/app/products/components/shared/BarcodeTemplateSelect/BarcodeTemplateSelect";
import { import {
BarcodeTemplateSchema,
CreateProductSchema, CreateProductSchema,
ProductSchema, ProductSchema,
UpdateProductSchema, UpdateProductSchema,
@ -18,6 +20,14 @@ type Props = CreateEditFormProps<
CreateProductSchema, CreateProductSchema,
UpdateProductSchema, UpdateProductSchema,
ProductSchema ProductSchema
> & {
clientId: number;
};
type ProductForm = Partial<
ProductSchema & {
barcodeTemplate: BarcodeTemplateSchema;
}
>; >;
const ProductEditorModal = ({ const ProductEditorModal = ({
@ -27,7 +37,7 @@ const ProductEditorModal = ({
}: ContextModalProps<Props>) => { }: ContextModalProps<Props>) => {
const isEditing = "entity" in innerProps; const isEditing = "entity" in innerProps;
const initialValues: Partial<ProductSchema> = isEditing const initialValues: ProductForm = isEditing
? innerProps.entity! ? innerProps.entity!
: { : {
name: "", name: "",
@ -38,6 +48,9 @@ const ProductEditorModal = ({
color: "", color: "",
size: "", size: "",
additionalInfo: "", additionalInfo: "",
clientId: innerProps.clientId,
barcodeTemplate: undefined,
barcodeTemplateId: undefined,
}; };
const form = useForm<Partial<ProductSchema>>({ const form = useForm<Partial<ProductSchema>>({
@ -50,18 +63,17 @@ const ProductEditorModal = ({
}, },
}); });
const onClose = () => context.closeContextModal(id);
return ( return (
<BaseFormModal <BaseFormModal
{...innerProps} {...innerProps}
form={form} form={form}
closeOnSubmit closeOnSubmit
onClose={onClose}> onClose={() => context.closeContextModal(id)}>
<Flex <Flex
gap={"xs"} gap={"xs"}
direction={"column"}> direction={"column"}>
<Fieldset legend={"Основные характеристики"}> <Fieldset legend={"Основные характеристики"}>
<Stack gap={"xs"}>
<TextInput <TextInput
placeholder={"Введите название товара"} placeholder={"Введите название товара"}
label={"Название товара"} label={"Название товара"}
@ -77,8 +89,31 @@ const ProductEditorModal = ({
label={"Складской артикул"} label={"Складской артикул"}
{...form.getInputProps("factoryArticle")} {...form.getInputProps("factoryArticle")}
/> />
<BarcodeTemplateSelect
placeholder={"Выберите шаблон штрихкода"}
label={"Шаблон штрихкода"}
{...form.getInputProps("barcodeTemplate")}
onChange={template => {
form.setFieldValue("barcodeTemplate", template);
form.setFieldValue(
"barcodeTemplateId",
template?.id
);
}}
/>
<TagsInput
placeholder={
!form.values.barcodes?.length
? "Добавьте штрихкоды к товару"
: ""
}
label={"Штрихкоды"}
{...form.getInputProps("barcodes")}
/>
</Stack>
</Fieldset> </Fieldset>
<Fieldset legend={"Дополнительные характеристики"}> <Fieldset legend={"Дополнительные характеристики"}>
<Stack gap={"xs"}>
<TextInput <TextInput
placeholder={"Введите бренд"} placeholder={"Введите бренд"}
label={"Бренд"} label={"Бренд"}
@ -104,6 +139,7 @@ const ProductEditorModal = ({
label={"Доп. информация"} label={"Доп. информация"}
{...form.getInputProps("additionalInfo")} {...form.getInputProps("additionalInfo")}
/> />
</Stack>
</Fieldset> </Fieldset>
{isEditing && ( {isEditing && (
<ProductImageDropzone <ProductImageDropzone

View File

@ -77,5 +77,10 @@ export const theme = createTheme({
radius, radius,
}, },
}, },
TagsInput: {
defaultProps: {
radius,
},
},
}, },
}); });