feat: cards, attributes and modules

This commit is contained in:
2025-02-19 14:46:13 +04:00
parent cc3e72bf94
commit dc9455966e
286 changed files with 2355 additions and 2168 deletions

View File

@@ -0,0 +1,132 @@
import {
ActionIcon,
Button,
ComboboxItem,
ComboboxItemGroup,
Flex,
Input,
OptionsFilter,
rem,
} from "@mantine/core";
import { BaseFormInputProps } from "../../../../types/utils.ts";
import { CardProductServiceSchema, ServiceSchema } from "../../../../client";
import { FC, useEffect, useState } from "react";
import ServiceWithPriceInput from "../../../../components/ServiceWithPriceInput/ServiceWithPriceInput.tsx";
import { isNumber } from "lodash";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { IconTrash } from "@tabler/icons-react";
import { useSelector } from "react-redux";
import { RootState } from "../../../../redux/store.ts";
type RestProps = {
quantity: number;
};
type Props = BaseFormInputProps<CardProductServiceSchema[]> & RestProps;
const DealProductServiceTable: FC<Props> = (props: Props) => {
const { value, onChange, quantity, error } = props;
const authState = useSelector((state: RootState) => state.auth);
const [innerValue, setInnerValue] = useState<
Partial<CardProductServiceSchema>[]
>(value || []);
const onServiceChange = (idx: number, value: ServiceSchema) => {
setInnerValue(oldValue =>
oldValue.map((item, i) =>
i === idx ? { ...item, service: value } : item
)
);
};
const onQuantityChange = (idx: number, value: string | number) => {
if (!isNumber(value)) return;
setInnerValue(oldValue =>
oldValue.map((item, i) =>
i === idx ? { ...item, price: value } : item
)
);
};
const onCreate = () => {
if (innerValue.length > 0 && !innerValue.at(-1)?.service) {
notifications.error({ message: "Заполните последнюю услугу" });
return;
}
setInnerValue(prevState => [
...prevState,
{ service: undefined, quantity: 1 },
]);
};
const onDelete = (idx: number) => {
setInnerValue(oldValue => oldValue.filter((_, i) => i !== idx));
};
const serviceOptionsFilter = ({
options,
}: {
options: ComboboxItemGroup[];
}) => {
const productServiceIds = innerValue.map(
service => service.service?.id
);
return (options as ComboboxItemGroup[]).map(({ items, group }) => {
return {
group,
items: items.filter(
item =>
!productServiceIds.includes(
parseInt((item as ComboboxItem).value)
)
),
};
});
};
useEffect(() => {
onChange(innerValue as CardProductServiceSchema[]);
}, [innerValue]);
return (
<Input.Wrapper error={error}>
<Flex
direction={"column"}
gap={rem(10)}>
{innerValue.map((service, idx) => (
<Flex
key={service.service?.name || idx}
direction={"row"}
gap={rem(10)}
align={"center"}
justify={"stretch"}>
<ActionIcon
onClick={() => onDelete(idx)}
variant={"default"}>
<IconTrash />
</ActionIcon>
<ServiceWithPriceInput
serviceProps={{
onChange: event => onServiceChange(idx, event),
value: service.service,
placeholder: "Выберите услугу",
style: { width: "100%" },
filter: serviceOptionsFilter as OptionsFilter,
}}
priceProps={{
onChange: event => onQuantityChange(idx, event),
value: service.price,
placeholder: "Введите стоимость",
hideControls: true,
style: { width: "100%" },
suffix: "₽",
disabled: authState.isGuest,
}}
containerProps={{ w: "100%" }}
quantity={quantity}
/>
</Flex>
))}
<Button
onClick={onCreate}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
</Input.Wrapper>
);
};
export default DealProductServiceTable;

View File

@@ -0,0 +1,168 @@
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import useDealProductsTableColumns from "./columns.tsx";
import { FC } from "react";
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { CardProductSchema, ProductService } from "../../../../client";
import { ActionIcon, Button, Flex, rem, Tooltip } from "@mantine/core";
import { MRT_TableOptions } from "mantine-react-table";
import { modals } from "@mantine/modals";
import { IconBarcode, IconEdit, IconTrash } from "@tabler/icons-react";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { CreateProductRequest } from "../../../ProductsPage/types.ts";
type RestProps = {
clientId: number;
onMultipleDelete?: (items: CardProductSchema[]) => void;
};
type Props = CRUDTableProps<CardProductSchema> & RestProps;
const DealProductsTable: FC<Props> = (props: Props) => {
const { items, clientId, onChange, onCreate, onDelete, onMultipleDelete } =
props;
const columns = useDealProductsTableColumns({
onChange: (product, quantity) => {
if (!onChange) return;
if (quantity <= 0 && onDelete) {
onDelete(product);
return;
}
onChange({ ...product, quantity });
},
data: items,
});
const onCreateClick = () => {
if (!onCreate) return;
modals.openContextModal({
modal: "addCardProduct",
title: "Добавление товара",
innerProps: {
onCreate: product => onCreate(product as CardProductSchema),
clientId,
},
size: "lg",
});
};
const onPrintBarcodeClick = (product: CardProductSchema) => {
modals.openContextModal({
modal: "printBarcode",
title: "Печать штрихкода",
withCloseButton: true,
innerProps: {
productId: product.product.id,
defaultQuantity: product.quantity,
},
});
};
const onCreateProduct = (newProduct: CreateProductRequest) => {
ProductService.createProduct({
requestBody: newProduct,
}).then(({ ok, message }) => {
notifications.guess(ok, { message: message });
});
};
const onCreateProductClick = () => {
modals.openContextModal({
modal: "createProduct",
title: "Создание товара",
withCloseButton: false,
innerProps: {
clientId: clientId,
onCreate: onCreateProduct,
},
});
};
const onEditClick = (product: CardProductSchema) => {
if (!onChange) return;
modals.openContextModal({
modal: "addCardProduct",
title: "Создание товара",
withCloseButton: false,
innerProps: {
clientId: clientId,
element: product,
onChange: onChange,
},
size: "lg",
});
};
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableBottomToolbar: true,
enableRowActions: true,
enableRowSelection: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() =>
onDelete && onDelete(row.original)
}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Печать штрихкода">
<ActionIcon
onClick={() =>
onPrintBarcodeClick(row.original)
}
variant={"default"}>
<IconBarcode />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
renderBottomToolbar: ({ table }) => (
<Flex
justify={"flex-end"}
gap={rem(10)}
p={rem(10)}>
{onMultipleDelete &&
table.getSelectedRowModel().rows.length > 0 && (
<Button
onClick={() => {
onMultipleDelete(
table
.getSelectedRowModel()
.rows.map(
row => row.original
)
);
}}
variant={"filled"}
color={"red"}>
Удалить выбранные
</Button>
)}
<Button
variant={"default"}
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
onClick={onCreateClick}
variant={"default"}>
Добавить товар в сделку
</Button>
</Flex>
),
} as MRT_TableOptions<CardProductSchema>
}
/>
);
};
export default DealProductsTable;

View File

@@ -0,0 +1,107 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardProductSchema } from "../../../../client";
import { List } from "@mantine/core";
type Props = {
onChange: (product: CardProductSchema, quantity: number) => void;
data: CardProductSchema[];
};
const useDealProductsTableColumns = (props: Props) => {
const { onChange, data } = props;
const totalQuantity = useMemo(
() => data.reduce((acc, row) => acc + row.quantity, 0),
[data]
);
const totalPrice = useMemo(
() =>
data.reduce(
(totalAcc, row) =>
totalAcc +
row.services.reduce(
(singleAcc, service) =>
singleAcc + service.price * row.quantity,
0
),
0
),
[data]
);
return useMemo<MRT_ColumnDef<CardProductSchema>[]>(
() => [
{
accessorKey: "product.article",
header: "Артикул",
enableSorting: false,
enableColumnActions: false,
},
{
accessorKey: "product.name",
header: "Название",
enableSorting: false,
enableColumnActions: false,
},
{
accessorKey: "product.barcodes",
header: "Штрихкоды",
Cell: ({ cell }) => {
return (
<List size={"sm"}>
{cell
.getValue<string[]>()
?.map(barcode => (
<List.Item key={barcode}>
{barcode}
</List.Item>
))}
</List>
);
},
enableSorting: false,
enableColumnActions: false,
},
{
accessorKey: "quantity",
header: "Количество",
enableSorting: false,
enableColumnActions: false,
Footer: <>Всего товаров: {totalQuantity} </>,
},
{
header: "Услуги",
Cell: ({ row }) => (
<List size={"sm"}>
{row.original.services
.map(
service =>
`${service.service.name} (${service.price}₽ за шт)`
)
.map(serviceText => (
<List.Item key={serviceText}>
{serviceText}
</List.Item>
))}
</List>
),
enableColumnActions: false,
},
{
header: "Итоговая стоимость услуг",
Cell: ({ row }) => (
<>
{row.original.services.reduce(
(acc, service) =>
acc + row.original.quantity * service.price,
0
)}
</>
),
enableColumnActions: false,
Footer: <>Всего стоимость услуг: {totalPrice}</>,
},
],
[onChange, data]
);
};
export default useDealProductsTableColumns;

View File

@@ -0,0 +1,129 @@
import { FC } from "react";
import { useDealServicesTableColumns } from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { CardServiceSchema } from "../../../../client";
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { MRT_TableOptions } from "mantine-react-table";
import { ActionIcon, Button, Flex, rem, Tooltip } from "@mantine/core";
import { openContextModal } from "@mantine/modals";
import { IconEdit, IconTrash } from "@tabler/icons-react";
type RestProps = {
onMultipleDelete?: (items: CardServiceSchema[]) => void;
};
type Props = CRUDTableProps<CardServiceSchema> & RestProps;
const DealServicesTable: FC<Props> = ({
items,
onChange,
onDelete,
onCreate,
onSelectionChange,
onMultipleDelete,
tableRef,
}) => {
const serviceIds = items.map(item => item.service.id);
const columns = useDealServicesTableColumns({
data: items,
});
const onCreateClick = () => {
if (!onCreate) return;
openContextModal({
title: "Добавление услуги",
modal: "addCardService",
innerProps: {
onCreate: event => onCreate(event as CardServiceSchema),
serviceIds,
},
});
};
const onEditClick = (service: CardServiceSchema) => {
if (!onChange) return;
openContextModal({
title: "Добавление услуги",
modal: "addCardService",
innerProps: {
element: service,
onChange,
serviceIds,
},
});
};
return (
<>
<BaseTable
ref={tableRef}
data={items}
columns={columns}
onSelectionChange={onSelectionChange}
restProps={
{
enableGrouping: true,
initialState: { grouping: ["service.category"] },
enableColumnActions: false,
enableSorting: false,
enableBottomToolbar: true,
enableRowActions: true,
enableRowSelection: true,
renderBottomToolbar: ({ table }) => (
<Flex
justify={"flex-end"}
gap={rem(10)}
p={rem(10)}>
{onMultipleDelete &&
table.getSelectedRowModel().rows.length >
0 && (
<Button
onClick={() => {
onMultipleDelete(
table
.getSelectedRowModel()
.rows.map(
row => row.original
)
);
}}
variant={"filled"}
color={"red"}>
Удалить выбранные
</Button>
)}
<Button
onClick={onCreateClick}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
),
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => {
if (onDelete)
onDelete(row.original);
}}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => {
onEditClick(row.original);
}}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<CardServiceSchema>
}
/>
</>
);
};
export default DealServicesTable;

View File

@@ -0,0 +1,63 @@
import { MRT_ColumnDef } from "mantine-react-table";
import { useMemo } from "react";
import { CardServiceSchema } from "../../../../client";
type Props = {
data: CardServiceSchema[];
};
export const useDealServicesTableColumns = (props: Props) => {
const { data } = props;
const totalPrice = useMemo(
() => data.reduce((acc, row) => acc + row.quantity * row.price, 0),
[data]
);
return useMemo<MRT_ColumnDef<CardServiceSchema>[]>(
() => [
{
accessorKey: "service.category",
header: "Категория",
accessorFn: row => row.service.category.name,
},
{
enableGrouping: false,
accessorKey: "service.name",
header: "Услуга",
},
{
enableGrouping: false,
accessorKey: "price",
header: "Цена",
},
{
enableGrouping: false,
accessorKey: "quantity",
header: "Количество",
},
{
enableGrouping: false,
header: "Сумма",
Cell: ({ row }) => {
return row.original.quantity * row.original.price;
},
aggregationFn: "sum",
AggregatedCell: ({ cell }) => {
return (
<>
Итоговая сумма по категории:{" "}
{cell.row.subRows?.reduce(
(acc, row) =>
acc +
row.original.quantity * row.original.price,
0
)}
</>
);
},
Footer: <>Итоговая сумма по услугам: {totalPrice}</>,
},
],
[]
);
};

View File

@@ -0,0 +1,29 @@
import { CardStatusHistorySchema } from "../../../../client";
import { useDealStatusChangeTableColumns } from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { FC } from "react";
type Props = {
items: CardStatusHistorySchema[];
};
const CardStatusChangeTable: FC<Props> = (props: Props) => {
const { items } = props;
return (
<BaseTable
data={items}
columns={useDealStatusChangeTableColumns()}
restProps={{
enableRowActions: false,
enableColumnActions: false,
enableSorting: false,
enableBottomToolbar: false,
enableColumnFilters: false,
enableColumnVisibilityToggle: false,
layoutMode: "grid",
}}
/>
);
};
export default CardStatusChangeTable;

View File

@@ -0,0 +1,52 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardStatusHistorySchema } from "../../../../client";
import { Spoiler, Text } from "@mantine/core";
export const useDealStatusChangeTableColumns = () => {
return useMemo<MRT_ColumnDef<CardStatusHistorySchema>[]>(
() => [
{
accessorKey: "changedAt",
header: "Дата",
accessorFn: row =>
new Date(row.changedAt).toLocaleString("ru-RU"),
},
{
header: "Пользователь",
accessorFn: row =>
`${row.user.firstName} ${row.user.secondName}`,
},
{
accessorKey: "fromStatus.name",
header: "Из статуса",
},
{
accessorKey: "toStatus.name",
header: "В статус",
},
{
accessorKey: "comment",
header: "Комментарий",
Cell: ({ row }) => (
<Spoiler
maxHeight={80}
showLabel={"Показать весь"}
hideLabel={"Скрыть"}>
<Text
style={{
wordWrap: "break-word",
wordBreak: "break-all",
whiteSpace: "normal",
}}
span>
{row.original.comment}
<br />
</Text>
</Spoiler>
),
},
],
[]
);
};

View File

@@ -0,0 +1,57 @@
import { CRUDTableProps } from "../../../../types/CRUDTable.tsx";
import { CardService, CardSummary } from "../../../../client";
import { FC } from "react";
import useCardsTableColumns from "./columns.tsx";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
type RestProps = {
viewOnly?: boolean;
};
type Props = CRUDTableProps<CardSummary> & RestProps;
const CardsTable: FC<Props> = ({ items, onSelectionChange, viewOnly = false }) => {
const columns = useCardsTableColumns();
const { setSelectedCard } = useCardPageContext();
const onEditClick = (cardSummary: CardSummary) => {
CardService.getCardById({ cardId: cardSummary.id }).then(card => {
setSelectedCard(card);
});
};
return (
<BaseTable
data={items}
columns={columns}
onSelectionChange={onSelectionChange}
restProps={
{
enableSorting: true,
enableColumnActions: false,
enablePagination: !viewOnly,
enableBottomToolbar: !viewOnly,
paginationDisplayMode: "pages",
enableRowActions: true,
enableRowSelection: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
disabled={viewOnly}
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<CardSummary>
}
/>
);
};
export default CardsTable;

View File

@@ -0,0 +1,56 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardSummary } from "../../../../client";
import { ActionIcon, Image } from "@mantine/core";
const useCardsTableColumns = () => {
return useMemo<MRT_ColumnDef<CardSummary>[]>(
() => [
{
accessorKey: "id",
header: "Номер",
size: 20,
},
{
header: "Маркетплейс",
size: 10,
Cell: ({ row }) => (
<ActionIcon variant={"transparent"}>
<Image
src={row.original.baseMarketplace?.iconUrl || ""}
/>
</ActionIcon>
),
},
{
header: "Дата создания",
accessorKey: "createdAt",
Cell: ({ row }) =>
new Date(row.original.createdAt).toLocaleString("ru-RU"),
enableSorting: true,
sortingFn: (rowA, rowB) =>
new Date(rowB.original.createdAt).getTime() -
new Date(rowA.original.createdAt).getTime(),
},
{
accessorKey: "name",
header: "Название",
enableSorting: false,
},
{
accessorKey: "clientName",
header: "Клиент",
enableSorting: false,
},
{
header: "Общая стоимость",
Cell: ({ row }) =>
row.original.totalPrice.toLocaleString("ru-RU") + "₽",
accessorKey: "totalPrice",
},
],
[],
);
};
export default useCardsTableColumns;

View File

@@ -0,0 +1,127 @@
import { ActionIcon, Flex, rem, Text } from "@mantine/core";
import { IconEdit, IconMenu2, IconMenuDeep } from "@tabler/icons-react";
import { motion } from "framer-motion";
import styles from "../../ui/CardsPage.module.css";
import PageBlock from "../../../../components/PageBlock/PageBlock.tsx";
import DisplayMode from "../../enums/DisplayMode.ts";
import { UseFormReturnType } from "@mantine/form";
import { CardsPageState } from "../../hooks/useCardsPageState.tsx";
import React from "react";
import { ProjectSchema } from "../../../../client";
import { modals } from "@mantine/modals";
import ObjectSelect from "../../../../components/ObjectSelect/ObjectSelect.tsx";
import CardsTableFiltersModal from "../../modals/CardsTableFiltersModal.tsx";
type Props = {
displayMode: DisplayMode;
setDisplayMode: React.Dispatch<React.SetStateAction<DisplayMode>>;
form: UseFormReturnType<CardsPageState>;
projects: ProjectSchema[];
refetchProjects: () => void;
}
const LeadsPageHeader = ({
displayMode,
setDisplayMode,
form,
projects,
refetchProjects,
}: Props) => {
const openModal = () => {
modals.openContextModal({
modal: "projectsModal",
title: "Проекты",
innerProps: {
onUpdate: refetchProjects,
},
});
};
const getHeaderInputsBoard = () => {
return (
<div className={styles["top-panel"]}
style={{
display: displayMode === DisplayMode.BOARD ? "flex" : "none",
}}>
<ActionIcon
size={"lg"}
onClick={openModal}
variant={"default"}>
<IconEdit />
</ActionIcon>
<ObjectSelect
placeholder={"Выберите проект"}
data={projects}
{...form.getInputProps("project")}
/>
</div>
);
};
const getHeaderInputsTable = () => {
return (
<div
className={styles["top-panel"]}
style={{
display: displayMode === DisplayMode.TABLE ? "flex" : "none",
}}
>
<CardsTableFiltersModal
form={form}
projects={projects}
/>
</div>
);
};
return (
<PageBlock style={{ flex: 0 }}>
<Flex
align={"center"}
justify={"space-between"}>
<Flex
gap={rem(10)}
direction={"column"}
align={"center"}>
<Text size={"xs"}>Вид</Text>
<Flex gap={rem(10)}>
<ActionIcon
onClick={() =>
setDisplayMode(DisplayMode.BOARD)
}
variant={
displayMode === DisplayMode.BOARD
? "filled"
: "default"
}>
<IconMenuDeep
style={{ rotate: "-90deg" }}
/>
</ActionIcon>
<ActionIcon
onClick={() =>
setDisplayMode(DisplayMode.TABLE)
}
variant={
displayMode === DisplayMode.TABLE
? "filled"
: "default"
}>
<IconMenu2 />
</ActionIcon>
</Flex>
</Flex>
<motion.div
key={displayMode}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
{getHeaderInputsTable()}
{getHeaderInputsBoard()}
</motion.div>
</Flex>
</PageBlock>
);
};
export default LeadsPageHeader;

View File

@@ -0,0 +1,79 @@
import { UserSchema } from "../../../../client";
import { FC } from "react";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import { useSimpleUsersTableColumns } from "./columns.tsx";
import { MRT_TableOptions } from "mantine-react-table";
import { ActionIcon, Button, Flex, rem, Tooltip } from "@mantine/core";
import { modals } from "@mantine/modals";
import { IconTrash } from "@tabler/icons-react";
import { UserRoleEnum } from "../../../../shared/enums/UserRole.ts";
export type SimpleUsersTableProps = {
items: UserSchema[];
onChange: (items: UserSchema[]) => void;
};
const SimpleUsersTable: FC<SimpleUsersTableProps> = ({ items, onChange }) => {
const columns = useSimpleUsersTableColumns();
const onAddClick = () => {
const userIds = items.map(user => user.id);
modals.openContextModal({
title: "Выберите сотрудника",
modal: "employeeSelect",
withCloseButton: false,
innerProps: {
selectProps: {
filterBy: user =>
!userIds.includes(user.id) &&
user.roleKey === UserRoleEnum.EMPLOYEE,
},
onSelect: user => onChange([...items, user]),
},
});
};
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableColumnActions: false,
enableSorting: false,
enableBottomToolbar: true,
renderBottomToolbar: (
<Flex
justify={"flex-end"}
gap={rem(10)}
p={rem(10)}>
<Button
onClick={() => onAddClick()}
variant={"default"}>
Добавить сотрудника к услуге
</Button>
</Flex>
),
enableRowActions: true,
renderRowActions: ({ row }) => (
<Tooltip label="Удалить">
<ActionIcon
onClick={() => {
onChange(
items.filter(
item => item.id !== row.original.id
)
);
}}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
),
} as MRT_TableOptions<UserSchema>
}
/>
);
};
export default SimpleUsersTable;

View File

@@ -0,0 +1,23 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { UserSchema } from "../../../../client";
export const useSimpleUsersTableColumns = () => {
return useMemo<MRT_ColumnDef<UserSchema>[]>(
() => [
{
accessorKey: "firstName",
header: "Имя",
},
{
accessorKey: "secondName",
header: "Фамилия",
},
{
accessorKey: "position.name",
header: "Должность",
},
],
[]
);
};

View File

@@ -0,0 +1,75 @@
import React, { createContext, FC, useContext, useEffect, useState } from "react";
import { CardSchema, CardService, ProjectSchema } from "../../../client";
type CardPageContextState = {
selectedCard?: CardSchema;
setSelectedCard: (card: CardSchema | undefined) => void;
refetchCards: () => Promise<void>;
refetchCard: () => void;
selectedProject?: ProjectSchema | null;
};
const CardPageContext = createContext<CardPageContextState | undefined>(
undefined,
);
type CardPageContextStateProps = {
refetchCards: () => Promise<void>;
defaultCardId?: number;
selectedProject?: ProjectSchema | null;
}
const useCardPageContextState = (props: CardPageContextStateProps) => {
const { refetchCards, defaultCardId } = props;
const [selectedCard, setSelectedCard] = useState<CardSchema | undefined>(
undefined,
);
const refetchCard = () => {
const cardId = selectedCard?.id ?? defaultCardId;
if (!cardId) return;
CardService.getCardById({ cardId }).then(card => {
setSelectedCard(card);
});
};
useEffect(() => {
refetchCard();
}, []);
return {
selectedCard,
setSelectedCard,
selectedProject: props.selectedProject,
refetchCards,
refetchCard,
};
};
type CardPageContextProviderProps = {
children: React.ReactNode;
} & CardPageContextStateProps;
export const CardPageContextProvider: FC<CardPageContextProviderProps> = ({
children,
...props
}) => {
const state = useCardPageContextState(props);
return (
<CardPageContext.Provider value={state}>
{children}
</CardPageContext.Provider>
);
};
export const useCardPageContext = () => {
const context = useContext(CardPageContext);
if (!context) {
throw new Error(
"useCardPageContext must be used within a CardPageContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,71 @@
import { createContext, Dispatch, FC, SetStateAction, useContext, useState } from "react";
import { CardSchema, CardService } from "../../../client";
import { useDisclosure } from "@mantine/hooks";
type PrefillCardContextState = {
prefillOpened: boolean;
prefillOnClose: () => void;
prefillOnOpen: () => void;
selectedPrefillCard?: CardSchema;
selectPrefillCard: (cardId: number) => void;
prefillCard?: CardSchema;
setPrefillCard: Dispatch<SetStateAction<CardSchema | undefined>>;
};
const PrefillCardContext = createContext<PrefillCardContextState | undefined>(
undefined
);
const usePrefillCardContextState = () => {
const [selectedPrefillCard, setSelectedPrefillCard] = useState<CardSchema | undefined>(
undefined,
);
const [prefillCard, setPrefillCard] = useState<CardSchema | undefined>(
undefined,
);
const [prefillOpened, { open, close }] = useDisclosure(false);
const prefillOnClose = close;
const prefillOnOpen = open;
const selectPrefillCard = (cardId: number) => {
CardService.getCardById({ cardId }).then(card => {
setSelectedPrefillCard(card);
});
}
return {
prefillOpened,
prefillOnClose,
prefillOnOpen,
selectedPrefillCard,
selectPrefillCard,
prefillCard,
setPrefillCard,
};
};
type PrefillCardContextProviderProps = {
children: React.ReactNode;
};
export const PrefillCardContextProvider: FC<PrefillCardContextProviderProps> = ({
children,
}) => {
const state = usePrefillCardContextState();
return (
<PrefillCardContext.Provider value={state}>
{children}
</PrefillCardContext.Provider>
);
};
export const usePrefillCardContext = () => {
const context = useContext(PrefillCardContext);
if (!context) {
throw new Error(
"usePrefillCardContext must be used within a PrefillCardContextProvider"
);
}
return context;
};

View File

@@ -0,0 +1,152 @@
import React, { createContext, FC, useContext, useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { CardsWithExcelForm, ProductExcelData } from "../drawers/PrefillCardWithExcelDrawer/types.tsx";
import { FileWithPath } from "@mantine/dropzone";
import { notifications } from "../../../shared/lib/notifications.ts";
import { CardService, type ProductFromExcelSchema, ProductSchema, StatusSchema } from "../../../client";
import UseExcelDropzone from "../../../types/UseExcelDropzone.tsx";
import { useForm, UseFormReturnType } from "@mantine/form";
import { useCardPageContext } from "./CardPageContext.tsx";
type PrefillCardsWithExcelContextState = {
prefillWithExcelOpened: boolean;
prefillWithExcelOnClose: () => void;
prefillWithExcelOnOpen: () => void;
barcodeProductsMap: Map<string, ProductExcelData>,
onProductSelectChange: (barcode: string, selectedProduct: ProductSchema) => void,
onDrop: (files: FileWithPath[]) => void;
excelDropzone: UseExcelDropzone;
createCards: (values: CardsWithExcelForm, status: StatusSchema) => void;
form: UseFormReturnType<CardsWithExcelForm>;
errors: string[];
};
const PrefillCardsWithExcelContext = createContext<PrefillCardsWithExcelContextState | undefined>(
undefined,
);
const usePrefillCardsWithExcelContextState = () => {
const [prefillWithExcelOpened, { open, close }] = useDisclosure(false);
const { refetchCards } = useCardPageContext();
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
const excelDropzone: UseExcelDropzone = {
isLoading,
setIsLoading,
};
const form = useForm<CardsWithExcelForm>({
validate: {
client: client => !client && "Выберите клиента",
},
});
const [barcodeProductsMap, setBarcodeProductsMap] = useState<Map<string, ProductExcelData>>(new Map());
const onDrop = (files: FileWithPath[]) => {
if (files.length > 1) {
notifications.error({ message: "Прикрепите одно изображение" });
return;
}
const file = files[0];
setIsLoading(true);
CardService.parseDealsExcel({
formData: {
upload_file: file,
},
})
.then(res => {
const barcodeProductsMap = new Map();
res.rows.forEach((row) => {
const productsData: ProductExcelData = row;
productsData.selectedProduct = productsData.products[0];
barcodeProductsMap.set(row.barcode, row);
});
setBarcodeProductsMap(barcodeProductsMap);
setErrors(res.errors);
})
.catch(error => {
notifications.error({ message: error.toString() });
})
.finally(() => setIsLoading(false));
};
const onProductSelectChange = (barcode: string, selectedProduct: ProductSchema) => {
const newBarcodeProductsMap = new Map(barcodeProductsMap);
const productsData = newBarcodeProductsMap.get(barcode);
if (!productsData) return;
productsData.selectedProduct = selectedProduct;
newBarcodeProductsMap.set(
barcode,
productsData,
);
setBarcodeProductsMap(newBarcodeProductsMap);
};
const prefillWithExcelOnClose = () => {
close();
setBarcodeProductsMap(new Map());
form.reset();
};
const createCards = (values: CardsWithExcelForm, status: StatusSchema) => {
const products: ProductFromExcelSchema[] = barcodeProductsMap.entries().map(([, productData]) => {
return {
productId: productData.selectedProduct!.id,
citiesBreakdown: productData.breakdowns,
};
}).toArray();
CardService.createDealsExcel({
requestBody: {
products,
clientId: values.client?.id ?? -1,
statusId: status.id,
},
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
if (ok) prefillWithExcelOnClose();
refetchCards();
})
.catch(err => console.log(err));
};
return {
prefillWithExcelOpened,
prefillWithExcelOnClose,
prefillWithExcelOnOpen: open,
barcodeProductsMap,
onProductSelectChange,
onDrop,
excelDropzone,
createCards,
form,
errors,
};
};
type PrefillCardsWithExcelContextProviderProps = {
children: React.ReactNode;
};
export const PrefillCardsWithExcelContextProvider: FC<PrefillCardsWithExcelContextProviderProps> = ({ children }) => {
const state = usePrefillCardsWithExcelContextState();
return (
<PrefillCardsWithExcelContext.Provider value={state}>
{children}
</PrefillCardsWithExcelContext.Provider>
);
};
export const usePrefillCardsWithExcelContext = () => {
const context = useContext(PrefillCardsWithExcelContext);
if (!context) {
throw new Error(
"usePrefillCardsWithExcelContext must be used within a PrefillCardsWithExcelContextProvider",
);
}
return context;
};

View File

@@ -0,0 +1,10 @@
.bottom-panel {
padding: rem(10);
border-radius: rem(5);
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}

View File

@@ -0,0 +1,131 @@
import { Box, Drawer, rem, Tabs } from "@mantine/core";
import { FC, ReactNode, useEffect } from "react";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
import { IconBox, IconCalendarUser, IconCubeSend, IconSettings, IconUser, IconUsersGroup } from "@tabler/icons-react";
import CardStatusChangeTable from "../../components/DealStatusChangeTable/CardStatusChangeTable.tsx";
import GeneralTab from "../../tabs/GeneralTab/GeneralTab.tsx";
import { useQueryClient } from "@tanstack/react-query";
import ProductAndServiceTab from "../../tabs/ProductAndServiceTab/ProductAndServiceTab.tsx";
import { motion } from "framer-motion";
import ShippingTab from "../../tabs/ShippingTab/ShippingTab.tsx";
import EmployeesTab from "../../tabs/EmployeesTab/EmployeesTab.tsx";
import ClientTab from "../../tabs/ClientTab/ClientTab.tsx";
const useCardStatusChangeState = () => {
const { selectedCard } = useCardPageContext();
return {
statusHistory: selectedCard?.statusHistory || [],
};
};
const CardEditDrawerStatusChangeTable = () => {
const { statusHistory } = useCardStatusChangeState();
return <CardStatusChangeTable items={statusHistory} />;
};
const useDealEditDrawerState = () => {
const { selectedCard, setSelectedCard } = useCardPageContext();
return {
isVisible: selectedCard !== undefined,
onClose: () => setSelectedCard(undefined),
};
};
const CardEditDrawer: FC = () => {
const { isVisible, onClose } = useDealEditDrawerState();
const queryClient = useQueryClient();
const { selectedCard } = useCardPageContext();
const modules = new Set<string>(selectedCard?.board.project.modules.map(module => module.key));
useEffect(() => {
if (isVisible) return;
queryClient.invalidateQueries({ queryKey: ["getCardSummaries"] });
}, [isVisible]);
const getTabPanel = (value: string, component: ReactNode) => {
return (
<Tabs.Panel value={value}>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
<Box
h={"100%"}
w={"100%"}
p={rem(10)}>
{component}
</Box>
</motion.div>
</Tabs.Panel>
);
};
const getTab = (key: string, icon: ReactNode, label: string) => {
if (!modules.has(key)) return;
return (
<Tabs.Tab
value={key}
leftSection={icon}>
{label}
</Tabs.Tab>
);
};
return (
<Drawer
size={"calc(100vw - 150px)"}
position={"right"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={isVisible}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "column",
gap: rem(10),
},
}}>
<Tabs
defaultValue={"general"}
flex={1}
variant={"outline"}
orientation={"vertical"}
keepMounted={false}>
<Tabs.List>
<Tabs.Tab
value={"general"}
leftSection={<IconSettings />}>
Общее
</Tabs.Tab>
<Tabs.Tab
value={"client"}
leftSection={<IconUser />}>
Клиент
</Tabs.Tab>
<Tabs.Tab
value={"history"}
leftSection={<IconCalendarUser />}>
История
</Tabs.Tab>
{getTab("servicesAndProducts", <IconBox />, "Товары и услуги")}
{getTab("shipment", <IconCubeSend />, "Отгрузка")}
{getTab("employees", <IconUsersGroup />, "Исполнители")}
</Tabs.List>
{getTabPanel("general", <GeneralTab />)}
{getTabPanel("client", <ClientTab />)}
{getTabPanel("history", <CardEditDrawerStatusChangeTable />)}
{getTabPanel("servicesAndProducts", <ProductAndServiceTab />)}
{getTabPanel("shipment", <ShippingTab />)}
{getTabPanel("employees", <EmployeesTab />)}
</Tabs>
</Drawer>
);
};
export default CardEditDrawer;

View File

@@ -0,0 +1,33 @@
.container {
display: flex;
flex-direction: row;
gap: rem(10);
max-height: 95vh;
}
.deal-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.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%;
}
.top-panel {
padding-bottom: rem(9);
gap: rem(10);
display: flex;
}

View File

@@ -0,0 +1,82 @@
import { FC, useEffect } from "react";
import { Button, Drawer, Flex, rem, TextInput } from "@mantine/core";
import DealsTable from "./components/tables/DealsTable/DealsTable.tsx";
import Preview from "./components/Preview/Preview.tsx";
import styles from "./CardPrefillDrawer.module.css";
import BaseMarketplaceSelect from "../../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import usePrefillDeal from "./hooks/usePrefillDeal.tsx";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { usePrefillCardContext } from "../../contexts/PrefillCardContext.tsx";
const CardPrefillDrawer: FC = () => {
const { prefillOpened, prefillOnClose, selectedPrefillCard, setPrefillCard, prefillCard } = usePrefillCardContext();
const { data, form } = usePrefillDeal();
useEffect(() => {
if (prefillOpened) return;
}, [prefillOpened]);
return (
<Drawer
size={"calc(77vw)"}
position={"right"}
onClose={prefillOnClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={prefillOpened}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "row",
gap: rem(20),
},
}}
>
<div className={styles["deal-container"]}>
<div className={styles["deal-container-wrapper"]}>
<div className={styles["top-panel"]}>
<TextInput
placeholder={"Введите название / id"}
{...form.getInputProps("idOrName")}
/>
<BaseMarketplaceSelect
onClear={() =>
form.setFieldValue("marketplace", null)
}
clearable
placeholder={"Выберите маркетплейс"}
{...form.getInputProps("marketplace")}
/>
</div>
<DealsTable items={data} />
<Flex direction={"row"} gap="sm">
<Button mt={10} w={"100%"} onClick={() => {
if (!selectedPrefillCard) {
notifications.error({ message: "Карточка не выбрана." });
return;
}
setPrefillCard(selectedPrefillCard);
prefillOnClose();
}}>
Предзаполнить
</Button>
{
prefillCard &&
<Button mt={10} w={"100%"} variant={"outline"} onClick={() => {
setPrefillCard(undefined);
notifications.success({ message: "Предзаполнение отменено." });
prefillOnClose();
}}>
Отменить предзаполнение
</Button>
}
</Flex>
</div>
</div>
<Preview />
</Drawer>
);
};
export default CardPrefillDrawer;

View File

@@ -0,0 +1,34 @@
.container {
display: flex;
gap: rem(10);
width: 50%;
}
.products-list {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 2;
}
.deal-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.deal-container-wrapper {
width: 100%;
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%;
}

View File

@@ -0,0 +1,58 @@
import { FC } from "react";
import styles from "./Preview.module.css";
import { ScrollArea, Skeleton, Title } from "@mantine/core";
import DealServicesTable from "../tables/DealServicesTable/DealServicesTable.tsx";
import ProductPreview from "../ProductPreview/ProductPreview.tsx";
import { usePrefillCardContext } from "../../../../contexts/PrefillCardContext.tsx";
const Preview: FC = () => {
const { selectedPrefillCard } = usePrefillCardContext();
const getTotalPrice = () => {
if (!selectedPrefillCard) return 0;
const productServicesPrice = selectedPrefillCard.products.reduce(
(acc, row) =>
acc +
row.services.reduce(
(acc2, row2) => acc2 + row2.price * row.quantity,
0,
),
0,
);
const cardServicesPrice = selectedPrefillCard.services.reduce(
(acc, row) => acc + row.price * row.quantity,
0,
);
return cardServicesPrice + productServicesPrice;
};
return (
<div className={styles["container"]}>
<div className={styles["deal-container-wrapper"]}>
<ScrollArea offsetScrollbars={"y"} w={"100%"}>
<div style={{ height: "93vh" }}>
<Skeleton visible={!selectedPrefillCard}>
<Title order={4} mb={18}>
Общая стоимость всех услуг:{" "}
{getTotalPrice().toLocaleString("ru")}
</Title>
<DealServicesTable items={selectedPrefillCard?.services} />
<div className={styles["products-list"]}>
{selectedPrefillCard?.products.map(product => (
<ProductPreview
key={product.product.id}
product={product}
/>
))}
</div>
</Skeleton>
</div>
</ScrollArea>
</div>
</div>
);
};
export default Preview;

View File

@@ -0,0 +1,33 @@
.container {
display: flex;
gap: rem(20);
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,55 @@
import { FC } from "react";
import { CardProductSchema, ProductSchema } from "../../../../../../client";
import { Image, rem, Text, Title } from "@mantine/core";
import { isNil } from "lodash";
import { ProductFieldNames } from "../../../../tabs/ProductAndServiceTab/components/ProductView/ProductView.tsx";
import ProductServicesTable from "../tables/ProductServicesTable/ProductServicesTable.tsx";
import styles from "./ProductPreview.module.css";
type Props = {
product: CardProductSchema;
};
const ProductPreview: FC<Props> = ({ product }) => {
return (
<div className={styles["container"]}>
<div className={styles["data-container"]}>
<div className={styles["image-container"]}>
<Image
flex={1}
radius={rem(10)}
fit={"cover"}
src={product.product.imageUrl}
/>
</div>
<div className={styles["attributes-container"]}>
<Title order={3}>{product.product.name}</Title>
{Object.entries(product.product).map(([key, value]) => {
const fieldName =
ProductFieldNames[key as keyof ProductSchema];
if (!fieldName || isNil(value) || value === "") return;
return (
<Text key={fieldName}>
{fieldName}: {value.toString()}{" "}
</Text>
);
})}
<Text>
Штрихкоды: {product.product.barcodes.join(", ")}
</Text>
<Text>Количество товара: {product.quantity}</Text>
</div>
</div>
<div className={styles["services-container"]}>
<ProductServicesTable
items={product.services}
quantity={product.quantity}
/>
</div>
</div>
);
};
export default ProductPreview;

View File

@@ -0,0 +1,58 @@
import { FC } from "react";
import { Flex, rem, Title } from "@mantine/core";
import { CardServiceSchema, CardSummary } from "../../../../../../../client";
import useDealServicesTableColumns from "./columns.tsx";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import { MRT_TableOptions } from "mantine-react-table";
type Props = {
items?: CardServiceSchema[];
};
const DealServicesTable: FC<Props> = ({ items }) => {
const columns = useDealServicesTableColumns();
return (
<Flex
direction={"column"}
gap={rem(10)}
mb={10}>
<Flex
h={"100%"}
direction={"column"}>
{
items && items.length > 0 &&
<>
<BaseTable
data={items}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enablePagination: false,
enableBottomToolbar: false,
} as MRT_TableOptions<CardSummary>
}
/>
<Title
style={{ textAlign: "end" }}
mt={rem(10)}
mb={rem(6)}
order={4}>
Итог:{" "}
{items.reduce(
(acc, item) => acc + item.price * item.quantity,
0,
)}
</Title>
</>
}
</Flex>
</Flex>
);
};
export default DealServicesTable;

View File

@@ -0,0 +1,30 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardServiceSchema } from "../../../../../../../client";
const useDealServicesTableColumns = () => {
return useMemo<MRT_ColumnDef<CardServiceSchema>[]>(
() => [
{
header: "Название",
accessorKey: "service.name",
size: 450,
},
{
header: "Количество",
accessorKey: "quantity",
size: 50,
Cell: ({ cell }) => cell.getValue() + " шт.",
},
{
accessorKey: "price",
header: "Цена",
size: 50,
Cell: ({ cell }) => cell.getValue() + " ₽",
},
],
[]
);
};
export default useDealServicesTableColumns;

View File

@@ -0,0 +1,48 @@
import { FC, useEffect } from "react";
import useDealsTableColumns from "./columns.tsx";
import { CardSummary } from "../../../../../../../client";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import { usePrefillCardContext } from "../../../../../contexts/PrefillCardContext.tsx";
type Props = {
items: CardSummary[];
};
const DealsTable: FC<Props> = ({ items }) => {
const { selectPrefillCard } = usePrefillCardContext();
const columns = useDealsTableColumns();
const defaultSorting = [{ id: "createdAt", desc: false }];
useEffect(() => {
if (items.length < 1) return;
selectPrefillCard(items[0].id);
}, []);
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableSorting: true,
enableColumnActions: false,
enablePagination: true,
enableBottomToolbar: true,
paginationDisplayMode: "pages",
initialState: {
sorting: defaultSorting,
},
mantinePaginationProps: {
showRowsPerPage: false,
},
enableStickyHeader: true,
enableStickyFooter: true,
mantineTableContainerProps: { style: { maxHeight: "81vh", height: "81vh" } },
}
}
/>
);
};
export default DealsTable;

View File

@@ -0,0 +1,72 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ActionIcon, Image, Radio } from "@mantine/core";
import { CardSummary } from "../../../../../../../client";
import { usePrefillCardContext } from "../../../../../contexts/PrefillCardContext.tsx";
const useDealsTableColumns = () => {
return useMemo<MRT_ColumnDef<CardSummary>[]>(
() => [
{
accessorKey: "select",
header: "",
size: 5,
enableSorting: false,
Cell: ({ row }) => {
const { selectPrefillCard, selectedPrefillCard } = usePrefillCardContext();
const checked = row.original.id === selectedPrefillCard?.id;
return (
<Radio
checked={checked}
onChange={() => {
selectPrefillCard(row.original.id);
}}
/>
);
},
},
{
accessorKey: "id",
header: "ID",
size: 20,
},
{
accessorKey: "clientName",
header: "Клиент",
size: 60,
enableSorting: false,
},
{
accessorKey: "name",
header: "Название",
enableSorting: false,
size: 60,
},
{
header: "Дата создания",
accessorKey: "createdAt",
size: 10,
Cell: ({ row }) =>
new Date(row.original.createdAt).toLocaleString("ru-RU").substring(0, 17),
enableSorting: true,
sortingFn: (rowA, rowB) =>
new Date(rowB.original.createdAt).getTime() -
new Date(rowA.original.createdAt).getTime(),
},
{
header: "МП",
size: 5,
Cell: ({ row }) => (
<ActionIcon variant={"transparent"}>
<Image
src={row.original.baseMarketplace?.iconUrl || ""}
/>
</ActionIcon>
),
},
],
[],
);
};
export default useDealsTableColumns;

View File

@@ -0,0 +1,31 @@
import { FC } from "react";
import { MRT_TableOptions } from "mantine-react-table";
import { CardProductServiceSchema } from "../../../../../../../client";
import { BaseTable } from "../../../../../../../components/BaseTable/BaseTable.tsx";
import useProductServicesTableColumns from "./columns.tsx";
type Props = {
items: CardProductServiceSchema[];
quantity: number;
};
const ProductServicesTable: FC<Props> = ({ items, quantity }) => {
const columns = useProductServicesTableColumns({ data: items, quantity });
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableColumnActions: false,
enableSorting: false,
enableRowActions: false,
enableBottomToolbar: false,
} as MRT_TableOptions<CardProductServiceSchema>
}
/>
);
};
export default ProductServicesTable;

View File

@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardProductServiceSchema } from "../../../../../../../client";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../../redux/store.ts";
type Props = {
data: CardProductServiceSchema[];
quantity: number;
};
const useProductServicesTableColumns = (props: Props) => {
const { data, quantity } = props;
const authState = useSelector((state: RootState) => state.auth);
const totalPrice = useMemo(
() => data.reduce((acc, row) => acc + row.price * quantity, 0),
[data, quantity]
);
const hideGuestColumns = ["service.cost"];
return useMemo<MRT_ColumnDef<CardProductServiceSchema>[]>(
() => [
{
accessorKey: "service.name",
header: "Услуга",
},
{
accessorKey: "price",
header: "Цена",
size: 5,
Cell: ({ cell }) => cell.getValue() + " ₽",
Footer: () => <>Итог: {totalPrice.toLocaleString("ru")}</>,
},
],
[totalPrice]
).filter(
columnDef =>
!(
hideGuestColumns.includes(columnDef.accessorKey || "") &&
authState.isGuest
)
);
};
export default useProductServicesTableColumns;

View File

@@ -0,0 +1,52 @@
import { useForm } from "@mantine/form";
import { useEffect, useState } from "react";
import { BaseMarketplaceSchema } from "../../../../../client";
import { useCardSummariesFull } from "../../../hooks/useCardSummaries.tsx";
type State = {
idOrName: string | null;
marketplace: BaseMarketplaceSchema | null;
};
const usePrefillDeal = () => {
const { objects } = useCardSummariesFull();
const form = useForm<State>({
initialValues: {
idOrName: null,
marketplace: null,
},
});
const [data, setData] = useState(objects);
const applyFilters = () => {
let result = objects;
if (form.values.idOrName) {
if (isNaN(parseInt(form.values.idOrName))) {
const name: string = form.values.idOrName.toLowerCase();
result = result.filter(
obj => obj.name.toLowerCase().search(name) !== -1,
);
}
else {
const id = parseInt(form.values.idOrName);
result = result.filter(
obj => obj.id === id,
);
}
}
if (form.values.marketplace) {
result = result.filter(
obj => obj.baseMarketplace?.key === form.values.marketplace?.key,
);
}
setData(result);
};
useEffect(() => {
applyFilters();
}, [form.values, objects]);
return { data, form };
};
export default usePrefillDeal;

View File

@@ -0,0 +1,33 @@
.container {
display: flex;
flex-direction: row;
gap: rem(10);
max-height: 95vh;
}
.deal-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.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%;
}
.top-panel {
padding-bottom: rem(9);
gap: rem(10);
display: flex;
}

View File

@@ -0,0 +1,54 @@
import { Drawer, rem } from "@mantine/core";
import ExcelDropzone from "../../../../components/ExcelDropzone/ExcelDropzone.tsx";
import styles from "./PrefillCardsWithExcelDrawer.module.css";
import { usePrefillCardsWithExcelContext } from "../../contexts/PrefillDealsWithExcelContext.tsx";
import ProductsPreview from "./components/ProductsPreview.tsx";
import { BoardSchema } from "../../../../client";
type Props = {
board: BoardSchema | null;
}
const PrefillCardsWithExcelDrawer = ({ board }: Props) => {
const {
prefillWithExcelOpened,
prefillWithExcelOnClose,
barcodeProductsMap,
onDrop,
excelDropzone,
} = usePrefillCardsWithExcelContext();
const getBody = () => {
if (!board || board.statuses.length === 0) return;
if (barcodeProductsMap?.size === 0) {
return <ExcelDropzone dropzone={excelDropzone} onDrop={onDrop} />;
}
return <ProductsPreview status={board.statuses[0]}/>;
};
return (
<Drawer
size={"calc(77vw)"}
position={"right"}
onClose={prefillWithExcelOnClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton={false}
opened={prefillWithExcelOpened}
styles={{
body: {
height: "100%",
display: "flex",
flexDirection: "row",
gap: rem(20),
},
}}
>
<div className={styles["deal-container"]}>
{getBody()}
</div>
</Drawer>
);
};
export default PrefillCardsWithExcelDrawer;

View File

@@ -0,0 +1,28 @@
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import { ParsedCityBreakdownSchema } from "../../../../../client";
import { useBreakdownByCityTableColumns } from "../hooks/useBreakdownByCityTableColumns.tsx";
type Props = {
breakdowns: ParsedCityBreakdownSchema[];
}
const BreakdownByCityTable = ({ breakdowns }: Props) => {
const columns = useBreakdownByCityTableColumns();
return (
<BaseTable
data={breakdowns}
columns={columns}
w={"30%"}
restProps={{
enableSorting: false,
enableRowActions: false,
enableTopToolbar: false,
enableColumnActions: false,
}}
/>
);
};
export default BreakdownByCityTable;

View File

@@ -0,0 +1,30 @@
import { usePrefillCardsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { Text, Tooltip } from "@mantine/core";
import { IconAlertCircle, IconCircleCheck } from "@tabler/icons-react";
const ParsingResultsTooltip = () => {
const { errors } = usePrefillCardsWithExcelContext();
const isError = errors.length !== 0;
const errorLines = errors.map((error, i) => <Text key={i}>{error}</Text>);
const tooltipData = isError ? errorLines : "Ошибок при обработке нет";
const color = isError ? "red" : "grey";
return (
<Tooltip
label={tooltipData}
multiline
w={350}
withArrow
color={color}
>
{isError ? (
<IconAlertCircle color={"red"}/>
) : (
<IconCircleCheck color={"green"}/>
)}
</Tooltip>
);
};
export default ParsingResultsTooltip;

View File

@@ -0,0 +1,73 @@
import styles from "../PrefillCardsWithExcelDrawer.module.css";
import ProductsTable from "./ProductsTable.tsx";
import { usePrefillCardsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { Box, Button, Flex, Group, Stack, Title } from "@mantine/core";
import { ProductExcelData } from "../types.tsx";
import BreakdownByCityTable from "./BreakdownByCityTable.tsx";
import ClientSelect from "../../../../../components/Selects/ClientSelect/ClientSelect.tsx";
import ParsingResultsTooltip from "./ParsingResultsTooltip.tsx";
import { StatusSchema } from "../../../../../client";
type Props = {
status: StatusSchema;
}
const ProductsPreview = ({ status }: Props) => {
const { barcodeProductsMap, createCards, form } = usePrefillCardsWithExcelContext();
const getTitle = (barcode: string, productsData: ProductExcelData) => {
if (productsData.products.length === 1) {
return `Товар со штрихкодом ${barcode}`;
}
return `Товары со штрихкодом ${barcode}`;
};
const getProductsData = () => {
return barcodeProductsMap.entries().map(([barcode, productsData]) => (
<div key={barcode} className={styles["deal-container-wrapper"]}>
<Stack>
<Title order={5}>
{getTitle(barcode, productsData)}
</Title>
<Flex direction={"row"} gap={"md"} flex={10}>
<Box flex={7}>
<ProductsTable barcode={barcode} productsData={productsData} />
</Box>
<Box flex={3}>
<BreakdownByCityTable breakdowns={productsData.breakdowns} />
</Box>
</Flex>
</Stack>
</div>
),
).toArray();
};
return (
<Stack gap={"md"}>
<Title order={3}>Предпросмотр</Title>
<form onSubmit={form.onSubmit((values) => createCards(values, status))}>
<ClientSelect
{...form.getInputProps("client")}
inputContainer={(children) => (
<Group align={"flex-start"}>
{children}
<Group>
<Button
variant="outline"
type="submit"
>
Создать сделки
</Button>
<ParsingResultsTooltip />
</Group>
</Group>
)}
/>
</form>
{getProductsData()}
</Stack>
);
};
export default ProductsPreview;

View File

@@ -0,0 +1,29 @@
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import { useProductsTableColumns } from "../hooks/useProductsTableColumns.tsx";
import { ProductExcelData } from "../types.tsx";
type Props = {
barcode: string;
productsData: ProductExcelData;
}
const ProductsTable = ({ barcode, productsData }: Props) => {
const columns = useProductsTableColumns({ barcode });
return (
<BaseTable
data={productsData.products}
columns={columns}
w={"100%"}
restProps={{
enableSorting: false,
enableRowActions: false,
enableTopToolbar: false,
enableColumnActions: false,
}}
/>
);
};
export default ProductsTable;

View File

@@ -0,0 +1,34 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ParsedCityBreakdownSchema } from "../../../../../client";
import { ActionIcon, Image } from "@mantine/core";
export const useBreakdownByCityTableColumns = () => {
return useMemo<MRT_ColumnDef<ParsedCityBreakdownSchema>[]>(
() => [
{
accessorKey: "baseMarketplace.iconUrl",
header: "Маркетплейс",
Cell: ({ cell }) => (
<ActionIcon
radius={"md"}
variant={"transparent"}>
<Image src={cell.getValue()} />
</ActionIcon>
),
size: 10,
},
{
accessorKey: "shippingWarehouse.name",
header: "Склад отгрузки",
size: 10,
},
{
accessorKey: "quantity",
header: "Количество",
size: 10,
},
],
[],
);
};

View File

@@ -0,0 +1,63 @@
import { useEffect, useMemo, useState } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { ProductSchema } from "../../../../../client";
import { Radio } from "@mantine/core";
import { usePrefillCardsWithExcelContext } from "../../../contexts/PrefillDealsWithExcelContext.tsx";
import { ProductExcelData } from "../types.tsx";
type Props = {
barcode: string;
}
export const useProductsTableColumns = ({ barcode }: Props) => {
const { onProductSelectChange, barcodeProductsMap } = usePrefillCardsWithExcelContext();
const [productData, setProductData] = useState<ProductExcelData>();
useEffect(() => {
setProductData(barcodeProductsMap.get(barcode));
}, [barcodeProductsMap]);
return useMemo<MRT_ColumnDef<ProductSchema>[]>(
() => [
{
header: "Выбор",
size: 10,
Cell: ({ row }) => {
return (
<Radio
checked={productData?.selectedProduct?.id === row.original.id}
onChange={() => onProductSelectChange(barcode, row.original)}
/>
);
},
},
{
accessorKey: "article",
header: "Артикул",
size: 20,
},
{
accessorKey: "name",
header: "Название",
},
{
accessorKey: "brand",
header: "Бренд",
size: 30,
},
{
accessorKey: "color",
header: "Цвет",
size: 30,
},
{
accessorKey: "size",
header: "Размер",
size: 10,
},
],
[productData],
).filter(columnDef => (
!(columnDef.header === "Выбор" && (productData?.products.length ?? 0) === 1)
));
};

View File

@@ -0,0 +1,11 @@
import { ClientSchema, type ParsedCityBreakdownSchema, ProductSchema } from "../../../../client";
export type ProductExcelData = {
products: ProductSchema[];
breakdowns: ParsedCityBreakdownSchema[];
selectedProduct?: ProductSchema;
}
export type CardsWithExcelForm = {
client?: ClientSchema;
}

View File

@@ -0,0 +1,6 @@
enum DisplayMode {
BOARD,
TABLE,
}
export default DisplayMode;

View File

@@ -0,0 +1,7 @@
enum DragState {
DRAG_ENDED,
DRAG_DEAL,
DRAG_STATUS,
}
export default DragState;

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { BoardSchema, BoardService } from "../../../client";
type Props = {
projectId?: number;
}
const useBoards = ({ projectId }: Props) => {
const [boards, setBoards] = useState<BoardSchema[]>([]);
const refetchBoards = () => {
if (!projectId) return;
BoardService.getBoards({
projectId,
})
.then(data => {
setBoards(data.boards);
})
.catch(e => console.log(e));
};
useEffect(() => {
refetchBoards();
}, [projectId]);
return {
boards,
refetchBoards,
};
};
export default useBoards;

View File

@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { CardService } from "../../../client";
import ObjectList from "../../../hooks/objectList.tsx";
export const useCardSummaries = () => {
const { data: summariesRaw = [], refetch } = useQuery({
queryKey: ["getCardSummaries"],
queryFn: () => CardService.getCardSummaries({ full: false }),
select: data => data.summaries || [], // Трансформируем полученные данные
});
// Теперь summaries будет содержать либо трансформированные данные, либо пустой массив по умолчанию
// isLoading и isError могут быть использованы для отображения индикаторов загрузки или ошибки
return { summariesRaw, refetch };
};
export const useCardSummariesFull = () =>
ObjectList({
queryFn: () => CardService.getCardSummaries({ full: true }),
queryKey: "getCardSummariesFull",
getObjectsFn: response => response.summaries,
});

View File

@@ -0,0 +1,90 @@
import { useCardSummariesFull } from "./useCardSummaries.tsx";
import { useForm } from "@mantine/form";
import { useEffect, useState } from "react";
import { BaseMarketplaceSchema, BoardSchema, ClientSchema, ProjectSchema, StatusSchema } from "../../../client";
type Props = {
projects: ProjectSchema[];
}
export type CardsPageState = {
id: number | null;
marketplace: BaseMarketplaceSchema | null;
client: ClientSchema | null;
project: ProjectSchema | null;
projectForTable: ProjectSchema | null;
board: BoardSchema | null;
status: StatusSchema | null;
};
const useCardsPageState = ({ projects }: Props) => {
const { objects } = useCardSummariesFull();
const form = useForm<CardsPageState>({
initialValues: {
project: null,
id: null,
marketplace: null,
client: null,
projectForTable: null,
board: null,
status: null,
},
});
const [data, setData] = useState(objects);
const applyFilters = () => {
let result = objects;
if (form.values.id) {
result = result.filter(
obj => obj.id === form.values.id,
);
}
if (form.values.marketplace) {
result = result.filter(
obj => obj.baseMarketplace?.key === form.values.marketplace?.key,
);
}
if (form.values.projectForTable) {
result = result.filter(
obj => obj.board.projectId === form.values.project?.id,
);
if (form.values.board) {
result = result.filter(
obj => obj.board.id === form.values.board?.id,
);
if (form.values.status) {
result = result.filter(
obj => obj.status.id === form.values.status?.id,
);
}
}
}
if (form.values.client) {
result = result.filter(
obj => obj.clientName === form.values.client?.name,
);
}
setData(result);
};
useEffect(() => {
applyFilters();
}, [form.values, objects]);
useEffect(() => {
if (projects.length > 0 && form.values.project === null) {
form.setFieldValue("project", projects[0]);
}
}, [projects]);
return { data, form };
};
export default useCardsPageState;

View File

@@ -0,0 +1,63 @@
import { BoardSchema, CardSummary } from "../../../client";
import { DragStart, DropResult } from "@hello-pangea/dnd";
import { useState } from "react";
import DragState from "../enums/DragState.ts";
import useCardsDnd from "../../../components/Dnd/Cards/CardsDndColumn/hooks/useCardsDnd.tsx";
import useStatusesDnd from "../../../components/Dnd/Statuses/Statuses/hooks/useStatusesDnd.tsx";
type Props = {
selectedBoard: BoardSchema | null;
summariesRaw: CardSummary[];
refetchSummaries: () => void;
refetchBoards: () => void;
}
const useDnd = ({
selectedBoard,
summariesRaw,
refetchSummaries,
refetchBoards,
}: Props) => {
const [dragState, setDragState] = useState<DragState>(DragState.DRAG_ENDED);
const {
summaries,
onDealDragEnd,
} = useCardsDnd({
summariesRaw,
refetchSummaries,
})
const {
onStatusDragEnd,
} = useStatusesDnd({
board: selectedBoard,
refetch: refetchBoards,
});
const onDragEnd = async (result: DropResult) => {
setDragState(DragState.DRAG_ENDED);
if (result.draggableId.includes("status")) {
return onStatusDragEnd(result);
}
return onDealDragEnd(result);
}
const onDragStart = (start: DragStart) => {
if (start.source.droppableId.includes("status")) {
setDragState(DragState.DRAG_STATUS);
} else {
setDragState(DragState.DRAG_DEAL);
}
}
return {
summaries,
dragState,
onDragStart,
onDragEnd,
};
};
export default useDnd;

View File

@@ -0,0 +1,11 @@
import ObjectList from "../../../hooks/objectList.tsx";
import { UserService } from "../../../client";
const useManagersList = () =>
ObjectList({
queryFn: UserService.getManagers,
getObjectsFn: response => response.managers,
queryKey: "getManagers",
});
export default useManagersList;

View File

@@ -0,0 +1,26 @@
import { useEffect, useState } from "react";
import { type FullProjectSchema, ProjectService } from "../../../client";
const useProjects = () => {
const [projects, setProjects] = useState<FullProjectSchema[]>([]);
const refetchProjects = () => {
ProjectService.getProjects()
.then(data => {
setProjects(data.projects);
})
.catch(e => console.log(e));
};
useEffect(() => {
refetchProjects();
}, []);
return {
projects,
refetchProjects,
};
};
export default useProjects;

View File

@@ -0,0 +1 @@
export { CardsPage } from "./ui/CardsPage.tsx";

View File

@@ -0,0 +1,90 @@
import { ContextModalProps } from "@mantine/modals";
import BaseFormModal, {
CreateEditFormProps,
} from "../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import { CardProductSchema, CardProductServiceSchema } from "../../../client";
import { useForm } from "@mantine/form";
import { NumberInput } from "@mantine/core";
import ProductSelect from "../../../components/ProductSelect/ProductSelect.tsx";
import { omit } from "lodash";
type RestProps = {
clientId: number;
productIds?: number[];
};
type Props = CreateEditFormProps<CardProductSchema> & RestProps;
const AddCardProductModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const isEditing = "element" in innerProps;
const restProps = omit(innerProps, ["clientId"]);
const validateServices = (services?: CardProductServiceSchema[]) => {
if (!services || services.length == 0) return null;
return services.find(service => service.service === undefined)
? "Удалите пустые услуги"
: null;
};
const form = useForm<Partial<CardProductSchema>>({
initialValues: isEditing
? innerProps.element
: {
product: undefined,
services: [],
quantity: 1,
},
validate: {
product: (product?: CardProductSchema["product"]) =>
product !== undefined ? null : "Необходимо выбрать товар",
quantity: (quantity?: number) =>
quantity && quantity > 0
? null
: "Количество должно быть больше 0",
services: validateServices,
},
});
const onClose = () => {
context.closeContextModal(id);
};
return (
<BaseFormModal
{...(restProps as CreateEditFormProps<CardProductSchema>)}
form={form}
closeOnSubmit
onClose={onClose}>
<BaseFormModal.Body>
<>
<ProductSelect
placeholder={"Выберите товар"}
label={"Товар"}
clientId={innerProps.clientId}
disabled={isEditing}
filterBy={item =>
!(innerProps.productIds || []).includes(item.id)
}
{...form.getInputProps("product")}
/>
<NumberInput
placeholder={"Введите количество"}
label={"Количество"}
min={1}
{...form.getInputProps("quantity")}
/>
{/*<Fieldset legend={'Услуги'}>*/}
{/* <DealProductServiceTable*/}
{/* quantity={form.values.quantity || 1}*/}
{/* {...form.getInputProps('services') as*/}
{/* BaseFormInputProps<CardProductServiceSchema[]>}*/}
{/* />*/}
{/*</Fieldset>*/}
</>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default AddCardProductModal;

View File

@@ -0,0 +1,106 @@
import { ContextModalProps } from "@mantine/modals";
import BaseFormModal, { CreateEditFormProps } from "../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import { CardServiceSchema } from "../../../client";
import { useForm } from "@mantine/form";
import { ComboboxItem, ComboboxItemGroup, NumberInput, OptionsFilter } from "@mantine/core";
import ServiceWithPriceInput from "../../../components/ServiceWithPriceInput/ServiceWithPriceInput.tsx";
import { ServiceType } from "../../../shared/enums/ServiceType.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../redux/store.ts";
type RestProps = {
serviceIds?: number[];
};
type Props = CreateEditFormProps<Partial<CardServiceSchema>> & RestProps;
const AddCardServiceModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const authState = useSelector((state: RootState) => state.auth);
const isEditing = "element" in innerProps;
const form = useForm<Partial<CardServiceSchema>>({
initialValues: isEditing
? innerProps.element
: {
service: undefined,
quantity: 1,
employees: [],
},
validate: {
service: (service?: CardServiceSchema["service"]) =>
service !== undefined ? null : "Необходимо выбрать услугу",
quantity: (quantity?: number) =>
quantity && quantity > 0
? null
: "Количество должно быть больше 0",
},
});
const onClose = () => {
context.closeContextModal(id);
};
const serviceOptionsFilter = ({
options,
}: {
options: ComboboxItemGroup[];
}) => {
if (!innerProps.serviceIds) return options;
const productServiceIds = innerProps.serviceIds;
return (options as ComboboxItemGroup[]).map(({ items, group }) => {
return {
group,
items: items.filter(
item =>
!productServiceIds.includes(
parseInt((item as ComboboxItem).value)
)
),
};
});
};
return (
<BaseFormModal
{...innerProps}
form={form}
closeOnSubmit
onClose={onClose}>
<BaseFormModal.Body>
<>
<ServiceWithPriceInput
serviceProps={{
...form.getInputProps("service"),
label: "Услуга",
placeholder: "Выберите услугу",
style: { width: "100%" },
disabled: isEditing,
filter: serviceOptionsFilter as OptionsFilter,
}}
priceProps={{
...form.getInputProps("price"),
label: "Цена",
placeholder: "Введите цену",
style: { width: "100%" },
disabled: authState.isGuest,
}}
quantity={form.values.quantity || 1}
containerProps={{
direction: "column",
style: { width: "100%" },
}}
filterType={ServiceType.DEAL_SERVICE}
lockOnEdit={isEditing}
/>
<NumberInput
placeholder={"Введите количество"}
label={"Количество"}
min={1}
{...form.getInputProps("quantity")}
/>
</>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default AddCardServiceModal;

View File

@@ -0,0 +1,46 @@
import { ContextModalProps } from "@mantine/modals";
import { Button, Stack, TextInput } from "@mantine/core";
import { BoardSchema } from "../../../../client";
import useBoardModal from "./hooks/useBoardModal.tsx";
type Props = {
projectId: number;
board?: BoardSchema;
refetchBoards: () => void;
};
const BoardModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const closeModal = () => context.closeContextModal(id);
const {
form,
onSubmit,
} = useBoardModal({
...innerProps,
closeModal,
});
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<TextInput
{...form.getInputProps("name")}
label={"Название"}
placeholder={"Введите название"}
/>
<Button
type={"submit"}
variant={"default"}
>
Сохранить
</Button>
</Stack>
</form>
);
};
export default BoardModal;

View File

@@ -0,0 +1,87 @@
import { useForm } from "@mantine/form";
import { BoardSchema, BoardService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type BoardForm = {
name: string;
}
type Props = {
projectId: number;
board?: BoardSchema;
refetchBoards: () => void;
closeModal: () => void;
};
const useBoardModal = ({
projectId,
board,
refetchBoards,
closeModal,
}: Props) => {
const form = useForm<BoardForm>({
initialValues: {
name: board ? board.name : "",
},
validate: {
name: name => !name && "Необходимо ввести название доски",
},
});
const createBoard = (values: BoardForm) => {
BoardService.createBoard({
requestBody: {
board: {
projectId: projectId,
name: values.name,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchBoards();
closeModal();
})
.catch(err => console.log(err));
};
const updateBoard = (values: BoardForm) => {
if (!board) return;
BoardService.updateBoard({
requestBody: {
board: {
...board,
name: values.name,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchBoards();
closeModal();
})
.catch(err => console.log(err));
};
const onSubmit = (values: BoardForm) => {
if (board) {
updateBoard(values);
return;
}
createBoard(values);
};
return {
form,
onSubmit,
};
};
export default useBoardModal;

View File

@@ -0,0 +1,78 @@
import { ProjectSchema } from "../../../client";
import { Flex, Modal, NumberInput, rem } from "@mantine/core";
import { UseFormReturnType } from "@mantine/form";
import { CardsPageState } from "../hooks/useCardsPageState.tsx";
import ObjectSelect from "../../../components/ObjectSelect/ObjectSelect.tsx";
import DealStatusSelect from "../../../components/DealStatusSelect/DealStatusSelect.tsx";
import BaseMarketplaceSelect from "../../../components/Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import ClientSelectNew from "../../../components/Selects/ClientSelectNew/ClientSelectNew.tsx";
import { useDisclosure } from "@mantine/hooks";
import InlineButton from "../../../components/InlineButton/InlineButton.tsx";
import { IconFilter } from "@tabler/icons-react";
import BoardSelect from "../../../components/BoardSelect/BoardSelect.tsx";
type Props = {
form: UseFormReturnType<CardsPageState>;
projects: ProjectSchema[];
};
const CardsTableFiltersModal = ({ form, projects }: Props) => {
const [opened, { open, close }] = useDisclosure();
return (
<>
<InlineButton onClick={open}>
<IconFilter />
Фильтры
</InlineButton>
<Modal title={"Фильтры для сделок"} opened={opened} onClose={close}>
<Flex
direction={"column"}
gap={rem(10)}
>
<NumberInput
min={1}
placeholder={"Введите номер"}
{...form.getInputProps("id")}
hideControls
/>
<ObjectSelect
placeholder={"Выберите проект"}
data={projects}
clearable
searchable
{...form.getInputProps("projectForTable")}
onClear={() => form.setFieldValue("projectForTable", null)}
/>
<BoardSelect
project={form.values.projectForTable}
{...form.getInputProps("board")}
clearable
/>
<DealStatusSelect
board={form.values.board}
{...form.getInputProps("status")}
clearable
/>
<BaseMarketplaceSelect
onClear={() => form.setFieldValue("marketplace", null)}
clearable
placeholder={"Выберите маркетплейс"}
{...form.getInputProps("marketplace")}
/>
<ClientSelectNew
onClear={() =>
form.setFieldValue("client", null)
}
clearable
searchable
placeholder={"Выберите клиента"}
{...form.getInputProps("client")}
/>
</Flex>
</Modal>
</>
);
};
export default CardsTableFiltersModal;

View File

@@ -0,0 +1,97 @@
import BaseFormModal, {
CreateEditFormProps,
} from "../../ClientsPage/modals/BaseFormModal/BaseFormModal.tsx";
import {
CardProductServiceSchema,
ServiceSchema,
} from "../../../client";
import { ContextModalProps } from "@mantine/modals";
import { useForm, UseFormReturnType } from "@mantine/form";
import { isNil, isNumber } from "lodash";
import ServiceWithPriceInput from "../../../components/ServiceWithPriceInput/ServiceWithPriceInput.tsx";
import { Checkbox, Flex, rem } from "@mantine/core";
import { ServiceType } from "../../../shared/enums/ServiceType.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../redux/store.ts";
type RestProps = {
quantity: number;
serviceIds: number[];
};
type Props = CreateEditFormProps<CardProductServiceSchema> & RestProps;
const ProductServiceFormModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const authState = useSelector((state: RootState) => state.auth);
const isEditing = "onChange" in innerProps;
const initialValues: Partial<CardProductServiceSchema> = isEditing
? innerProps.element
: {
service: undefined,
price: undefined,
employees: [],
isFixedPrice: false,
};
const form = useForm<Partial<CardProductServiceSchema>>({
initialValues,
validate: {
service: (service?: ServiceSchema) =>
isNil(service) || service.id < 0 ? "Укажите услугу" : null,
price: (price?: number) =>
!isNumber(price) || price < 0 ? "Укажите цену" : null,
},
});
const onClose = () => {
context.closeContextModal(id);
};
return (
<BaseFormModal
{...innerProps}
form={form as UseFormReturnType<CardProductServiceSchema>}
onClose={onClose}
closeOnSubmit>
<BaseFormModal.Body>
<>
<Flex w={"100%"} direction={"column"} gap={rem(10)}>
<ServiceWithPriceInput
serviceProps={{
...form.getInputProps("service"),
label: "Услуга",
placeholder: "Выберите услугу",
disabled: isEditing,
filterBy: item =>
!innerProps.serviceIds.includes(item.id) ||
isEditing,
style: { width: "100%" },
}}
priceProps={{
...form.getInputProps("price"),
label: "Цена",
placeholder: "Введите цену",
style: { width: "100%" },
disabled: authState.isGuest,
}}
filterType={ServiceType.PRODUCT_SERVICE}
containerProps={{
direction: "column",
style: { width: "100%" },
}}
lockOnEdit={isEditing}
quantity={innerProps.quantity}
/>
<Checkbox
{...form.getInputProps("isFixedPrice", { type: "checkbox" })}
label={"Зафиксировать цену"}
placeholder={"Зафиксировать цену"}
/>
</Flex>
</>
</BaseFormModal.Body>
</BaseFormModal>
);
};
export default ProductServiceFormModal;

View File

@@ -0,0 +1,85 @@
import { ContextModalProps } from "@mantine/modals";
import { ActionIcon, Flex, rem, Stack, TextInput, Tooltip } from "@mantine/core";
import { BaseTable } from "../../../../components/BaseTable/BaseTable.tsx";
import useProjectsTableColumns from "./hooks/projectsTableColumns.tsx";
import { IconCheck, IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import InlineButton from "../../../../components/InlineButton/InlineButton.tsx";
import useProjectModal from "./hooks/useProjectModal.tsx";
import { FullProjectSchema } from "../../../../client";
type Props = {
onUpdate: () => void;
};
const ProjectsModal = ({ innerProps }: ContextModalProps<Props>) => {
const {
projects,
name,
setName,
editingProjects,
handleEditClick,
handleDeleteClick,
handleCreateClick,
} = useProjectModal(innerProps);
const columns = useProjectsTableColumns({ editingProjects });
return (
<Stack gap={rem(10)}>
<TextInput
label={"Добавить проект"}
variant={"default"}
value={name}
onChange={e => setName(e.target.value)}
/>
<InlineButton
variant={"default"}
onClick={handleCreateClick}>
<IconPlus />
Добавить
</InlineButton>
<BaseTable
data={projects}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => handleEditClick(row.original)}
variant={"default"}>
{
editingProjects.has(row.original.id) ? (
<IconCheck />
) : (
<IconEdit />
)
}
</ActionIcon>
</Tooltip>
<Tooltip label={"Удалить"}>
<ActionIcon
onClick={() => handleDeleteClick(row.original)}
disabled={row.original.boardsCount > 0}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<FullProjectSchema>
}
/>
</Stack>
);
};
export default ProjectsModal;

View File

@@ -0,0 +1,46 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { BaseProjectSchema, ProjectSchema } from "../../../../../client";
import { TextInput } from "@mantine/core";
type Props = {
editingProjects: Map<number, ProjectSchema | BaseProjectSchema>;
}
const useProjectsTableColumns = ({ editingProjects }: Props) => {
return useMemo<MRT_ColumnDef<ProjectSchema>[]>(
() => [
{
header: "Название",
accessorKey: "name",
Cell: ({ row }) => {
if (editingProjects.has(row.original.id)) {
return (
<TextInput
variant={"default"}
value={editingProjects.get(row.original.id)?.name}
onChange={e => {
const project = editingProjects.get(row.original.id);
if (!project) return;
project.name = e.target.value;
editingProjects.set(row.original.id, project);
}}
/>
);
}
return row.original.name;
},
size: 25,
},
{
header: "Кол-во досок",
accessorKey: "boardsCount",
size: 10,
},
],
[],
);
};
export default useProjectsTableColumns;

View File

@@ -0,0 +1,100 @@
import { BaseProjectSchema, ProjectSchema, ProjectService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useMap } from "@mantine/hooks";
import { useState } from "react";
import useProjects from "../../../hooks/useProjects.tsx";
type Props = {
onUpdate: () => void;
}
const useProjectModal = ({ onUpdate }: Props) => {
const editingProjects = useMap<number, ProjectSchema>();
const { projects, refetchProjects } = useProjects();
const [name, setName] = useState("");
const updateProject = (project: ProjectSchema) => {
ProjectService.updateProject({
requestBody: {
project,
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
editingProjects.delete(project.id);
refetchProjects();
onUpdate();
})
.catch(err => console.log(err));
};
const handleEditClick = (project: ProjectSchema) => {
const editedProject = editingProjects.get(project.id);
if (!editedProject) {
editingProjects.set(project.id, project);
return;
}
if (editedProject.name.length === 0) {
notifications.error({ message: "Имя проекта не может быть пустым" });
return;
}
updateProject(project);
};
const handleDeleteClick = (project: ProjectSchema) => {
ProjectService.deleteProject({
projectId: project.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchProjects();
onUpdate();
})
.catch(err => console.log(err));
};
const createProject = (project: BaseProjectSchema) => {
ProjectService.createProject({
requestBody: { project },
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
setName("");
refetchProjects();
onUpdate();
})
.catch(err => console.log(err));
};
const handleCreateClick = () => {
if (name.length === 0) {
notifications.error({ message: "Имя проекта не может быть пустым" });
return;
}
createProject({ name });
};
return {
projects,
name,
setName,
editingProjects,
handleEditClick,
handleDeleteClick,
handleCreateClick,
};
};
export default useProjectModal;

View File

@@ -0,0 +1,83 @@
import { CardProductSchema } from "../../../client";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, rem } from "@mantine/core";
import { useState } from "react";
import ObjectMultiSelect from "../../../components/ObjectMultiSelect/ObjectMultiSelect.tsx";
import { notifications } from "../../../shared/lib/notifications.ts";
type Props = {
cardProducts: CardProductSchema[];
cardProduct: CardProductSchema;
onSelect: (
sourceProduct: CardProductSchema,
destinationProducts: CardProductSchema[]
) => void;
};
const SelectCardProductsModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const [cardProducts, setCardProducts] = useState<CardProductSchema[]>([]);
const onSelectClick = () => {
if (!cardProducts) {
notifications.error({
message:
"Выберите товары на которые необходимо продублировать услуги",
});
return;
}
innerProps.onSelect(innerProps.cardProduct, cardProducts);
context.closeContextModal(id);
};
const onDuplicateAllClick = () => {
innerProps.onSelect(
innerProps.cardProduct,
innerProps.cardProducts.filter(
item => item !== innerProps.cardProduct
)
);
context.closeContextModal(id);
};
return (
<Flex
direction={"column"}
gap={rem(10)}>
<Flex>
<ObjectMultiSelect<CardProductSchema>
w={"100%"}
label={"Товары"}
placeholder={
"Выберите товары на которые нужно продублировать услуги"
}
onChange={setCardProducts}
value={cardProducts}
data={innerProps.cardProducts}
getLabelFn={item => item.product.name}
getValueFn={item => item.product.id.toString()}
filterBy={item => item !== innerProps.cardProduct}
/>
</Flex>
<Flex
gap={rem(10)}
justify={"flex-end"}>
<Button
variant={"subtle"}
onClick={() => context.closeContextModal(id)}>
Отменить
</Button>
<Button onClick={onDuplicateAllClick}>
Продублировать на все товары
</Button>
<Button
onClick={onSelectClick}
variant={"default"}>
Продублировать
</Button>
</Flex>
</Flex>
);
};
export default SelectCardProductsModal;

View File

@@ -0,0 +1,46 @@
import { ContextModalProps } from "@mantine/modals";
import { Button, Stack, TextInput } from "@mantine/core";
import { StatusSchema } from "../../../../client";
import useStatusModal from "./hooks/useStatusModal.tsx";
type Props = {
boardId?: number;
status?: StatusSchema;
refetch: () => void;
};
const StatusModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const closeModal = () => context.closeContextModal(id);
const {
form,
onSubmit,
} = useStatusModal({
...innerProps,
closeModal,
});
return (
<form onSubmit={form.onSubmit(values => onSubmit(values))}>
<Stack>
<TextInput
{...form.getInputProps("name")}
label={"Название"}
placeholder={"Введите название"}
/>
<Button
type={"submit"}
variant={"default"}
>
Сохранить
</Button>
</Stack>
</form>
);
};
export default StatusModal;

View File

@@ -0,0 +1,88 @@
import { useForm } from "@mantine/form";
import { StatusSchema, StatusService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type StatusForm = {
name: string;
}
type Props = {
boardId?: number;
status?: StatusSchema;
refetch: () => void;
closeModal: () => void;
};
const useStatusModal = ({
boardId,
status,
refetch,
closeModal,
}: Props) => {
const form = useForm<StatusForm>({
initialValues: {
name: status ? status.name : "",
},
validate: {
name: name => !name && "Необходимо ввести название статуса",
},
});
const createBoard = (values: StatusForm) => {
if (!boardId) return;
StatusService.createStatus({
requestBody: {
status: {
boardId,
name: values.name,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetch();
closeModal();
})
.catch(err => console.log(err));
};
const updateBoard = (values: StatusForm) => {
if (!status) return;
StatusService.updateStatus({
requestBody: {
status: {
...status,
name: values.name,
},
},
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetch();
closeModal();
})
.catch(err => console.log(err));
};
const onSubmit = (values: StatusForm) => {
if (status) {
updateBoard(values);
return;
}
createBoard(values);
};
return {
form,
onSubmit,
};
};
export default useStatusModal;

View File

@@ -0,0 +1,98 @@
import { Button, Fieldset, Flex, rem, Textarea, TextInput } from "@mantine/core";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
import { useForm } from "@mantine/form";
import { CardGeneralFormType } from "../GeneralTab/GeneralTab.tsx";
import { ClientService, CardSchema, CardService } from "../../../../client";
import { isEqual } from "lodash";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useQueryClient } from "@tanstack/react-query";
const ClientTab = () => {
const { selectedCard: card, setSelectedCard } = useCardPageContext();
const initialValues: CardGeneralFormType = card as CardSchema;
const queryClient = useQueryClient();
const form = useForm<CardGeneralFormType>(
{
initialValues: initialValues,
validate: {
name: (value: string) => value.length > 0 ? null : "Название сделки не может быть пустым",
},
},
);
const hasChanges = !isEqual(form.values, initialValues);
const updateClientInfo = async (values: CardGeneralFormType) => {
return ClientService.updateClient({
requestBody: {
data: values.client,
},
}).then(({ ok, message }) => notifications.guess(ok, { message }));
};
const update = async () => {
return CardService.getCardById({ cardId: form.values.id }).then(data => {
setSelectedCard(data);
form.setInitialValues(data);
queryClient.invalidateQueries({
queryKey: ["getCardSummaries"],
});
});
};
const handleSave = () => {
updateClientInfo(form.values).then(async () => {
await update();
});
};
const handleCancel = () => {
form.setInitialValues(initialValues);
};
return (
<Flex direction={"column"} flex={1} gap={rem(10)}>
<Flex flex={1}>
<Fieldset legend={"Клиент"} flex={1}>
<TextInput
disabled
placeholder={"Название"}
label={"Название"}
value={card?.client.name}
/>
<TextInput
placeholder={"Введите телефон"}
label={"Телефон клиента"}
{...form.getInputProps("client.details.phoneNumber")}
/>
<TextInput
placeholder={"Введите email"}
label={"Email"}
{...form.getInputProps("client.details.email")}
/>
<TextInput
placeholder={"Введите телеграм"}
label={"Телеграм"}
{...form.getInputProps("client.details.telegram")}
/>
<TextInput
placeholder={"Введите ИНН"}
label={"ИНН"}
{...form.getInputProps("client.details.inn")}
/>
<Textarea
placeholder={"Введите комментарий"}
label={"Комментарий"}
{...form.getInputProps("client.comment")}
/>
</Fieldset>
</Flex>
<Flex
gap={rem(10)}
justify={"flex-end"}
display={!hasChanges ? "none" : "flex"}
>
<Button onClick={handleCancel} variant={"default"}>Отмена</Button>
<Button onClick={handleSave} variant={"default"}>Сохранить</Button>
</Flex>
</Flex>
);
};
export default ClientTab;

View File

@@ -0,0 +1,14 @@
import { Stack } from "@mantine/core";
import EmployeeInput from "./components/EmployeeInput.tsx";
import EmployeesTable from "./components/EmployeesTable.tsx";
const EmployeesTab = () => {
return (
<Stack>
<EmployeeInput />
<EmployeesTable />
</Stack>
);
};
export default EmployeesTab;

View File

@@ -0,0 +1,27 @@
import { FC } from "react";
import ObjectSelect, { ObjectSelectProps } from "../../../../../components/ObjectSelect/ObjectSelect.tsx";
import { UserSchema } from "../../../../../client";
import useAvailableEmployeesList from "../hooks/useAvailableEmployeesList.tsx";
type DealData = {
cardId: number;
}
type Props = DealData & Omit<
ObjectSelectProps<UserSchema | null>,
"data" | "getValueFn" | "getLabelFn"
>;
const UserForDepartmentSelect: FC<Props> = ({ cardId, ...selectProps }) => {
const { objects: employees } = useAvailableEmployeesList({ cardId });
return (
<ObjectSelect
data={employees}
getLabelFn={(user: UserSchema) => `${user.firstName} ${user.secondName}`}
getValueFn={(user: UserSchema) => user.id.toString()}
{...selectProps}
/>
);
};
export default UserForDepartmentSelect;

View File

@@ -0,0 +1,29 @@
import { Button, Group } from "@mantine/core";
import { IconQrcode, IconTablePlus } from "@tabler/icons-react";
import useEmployeesTab from "../hooks/useEmployeesTab.tsx";
const EmployeeInput = () => {
const {
onAssignEmployeeByQrClick,
onAssignEmployeeManuallyClick,
} = useEmployeesTab();
return (
<Group>
<Button variant={"default"} onClick={onAssignEmployeeByQrClick}>
<Group gap={"md"}>
<IconQrcode />
Добавить исполнителя по QR коду
</Group>
</Button>
<Button variant={"default"} onClick={onAssignEmployeeManuallyClick}>
<Group gap={"md"}>
<IconTablePlus />
Добавить исполнителя вручную
</Group>
</Button>
</Group>
);
};
export default EmployeeInput;

View File

@@ -0,0 +1,42 @@
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import useEmployeeTableColumns from "../hooks/useEmployeesTableColumns.tsx";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { CardEmployeesSchema } from "../../../../../client";
import useEmployeesTab from "../hooks/useEmployeesTab.tsx";
const EmployeesTable = () => {
const { selectedCard: card } = useCardPageContext();
const columns = useEmployeeTableColumns();
const { onUnassignEmployeeClick } = useEmployeesTab();
return (
<BaseTable
data={card?.employees}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onUnassignEmployeeClick(row.original)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<CardEmployeesSchema>
}
/>
);
};
export default EmployeesTable;

View File

@@ -0,0 +1,16 @@
import { CardService } from "../../../../../client";
import ObjectList from "../../../../../hooks/objectList.tsx";
type Props = {
cardId: number;
}
const useAvailableEmployeesList = ({ cardId }: Props) =>
ObjectList({
queryFn: () => CardService.getAvailableEmployeesToAssign({ cardId }),
getObjectsFn: response => response.employees,
queryKey: "getAvailableEmployeesToAssign",
});
export default useAvailableEmployeesList;

View File

@@ -0,0 +1,73 @@
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { modals } from "@mantine/modals";
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
import { CardEmployeesSchema, CardService } from "../../../../../client";
const useEmployeesTab = () => {
const { selectedCard: card, refetchCard } = useCardPageContext();
const manageEmployee = (cardId: number, userId: number, isAssign: boolean) => {
CardService.manageEmployee({
requestBody: {
cardId,
userId,
isAssign,
},
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
refetchCard();
})
.catch((err) => console.log(err));
};
const onInputFinish = (userIdInput: string) => {
const userId = parseInt(userIdInput);
if (isNaN(userId)) {
notifications.error({ message: "Ошибка, некорректные данные в QR-коде" });
return;
}
if (!card) return;
manageEmployee(card.id, userId, true);
};
const onAssignEmployeeByQrClick = () => {
modals.openContextModal({
modal: "scanningModal",
innerProps: {
label: "Отсканируйте QR-код",
onScan: onInputFinish,
closeOnScan: true,
},
withCloseButton: false,
});
};
const onUnassignEmployeeClick = (assignment: CardEmployeesSchema) => {
if (!card) return;
manageEmployee(card.id, assignment.user.id, false);
};
const onAssignEmployeeManuallyClick = () => {
if (!card) return;
modals.openContextModal({
modal: "assignUserModal",
title: `Назначение исполнителя`,
withCloseButton: false,
innerProps: {
card,
manageEmployee,
},
});
};
return {
onAssignEmployeeByQrClick,
onAssignEmployeeManuallyClick,
onUnassignEmployeeClick,
};
};
export default useEmployeesTab;

View File

@@ -0,0 +1,36 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardEmployeesSchema } from "../../../../../client";
const useEmployeeTableColumns = () => {
return useMemo<MRT_ColumnDef<CardEmployeesSchema>[]>(
() => [
{
accessorKey: "createdAt",
header: "Дата назначения",
Cell: ({ cell }) => new Date(cell.getValue() as string).toLocaleString("ru"),
},
{
header: "ФИО",
Cell: ({ row }) =>
`${row.original.user.secondName} ${row.original.user.firstName} ${row.original.user.patronymic}`,
},
{
accessorKey: "user.role.name",
header: "Роль",
},
{
accessorKey: "user.position.name",
header: "Должность",
},
{
accessorKey: "user.comment",
header: "Дополнительная информация",
},
],
[],
);
};
export default useEmployeeTableColumns;

View File

@@ -0,0 +1,58 @@
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, rem } from "@mantine/core";
import { CardSchema } from "../../../../../client";
import AssignUserModalForm from "../types/AssignUserModalForm.tsx";
import AvailableEmployeesSelect from "../components/AvailableEmployeesSelect.tsx";
type Props = {
card: CardSchema;
manageEmployee: (cardId: number, userId: number, isAssign: boolean) => void;
}
const AssignEmployeeModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const {
card,
manageEmployee,
} = innerProps;
const form = useForm<Partial<AssignUserModalForm>>({
validate: {
employee: employee => !employee && "Необходимо выбрать работника",
},
});
const onSubmit = () => {
if (!form.values.employee) return;
manageEmployee(card.id, form.values.employee.id, true);
context.closeContextModal(id);
};
return (
<form onSubmit={form.onSubmit(() => onSubmit())}>
<Flex
direction={"column"}
gap={rem(10)}
>
<AvailableEmployeesSelect
label={"Работник"}
placeholder={"Выберите работника"}
{...form.getInputProps("employee")}
cardId={card.id}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Flex>
</form>
);
};
export default AssignEmployeeModal;

View File

@@ -0,0 +1,7 @@
import { UserSchema } from "../../../../../client";
type AssignUserModalForm = {
employee: UserSchema;
}
export default AssignUserModalForm;

View File

@@ -0,0 +1,309 @@
import { FC, useState } from "react";
import { useCardPageContext } from "../../contexts/CardPageContext.tsx";
import { Button, Checkbox, Divider, Fieldset, Flex, Group, rem, Textarea, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import {
CardSchema,
CardService,
ClientService,
ProjectSchema,
ShippingWarehouseSchema,
StatusSchema,
} from "../../../../client";
import { isEqual } from "lodash";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useQueryClient } from "@tanstack/react-query";
import ShippingWarehouseAutocomplete
from "../../../../components/Selects/ShippingWarehouseAutocomplete/ShippingWarehouseAutocomplete.tsx";
import { ButtonCopyControlled } from "../../../../components/ButtonCopyControlled/ButtonCopyControlled.tsx";
import { useClipboard } from "@mantine/hooks";
import ManagerSelect from "../../../../components/ManagerSelect/ManagerSelect.tsx";
import ProjectSelect from "../../../../components/ProjectSelect/ProjectSelect.tsx";
import BoardSelect from "../../../../components/BoardSelect/BoardSelect.tsx";
import DealStatusSelect from "../../../../components/DealStatusSelect/DealStatusSelect.tsx";
import CardAttributeFields from "../../../../components/CardAttributeFields/CardAttributeFields.tsx";
import getAttributesFromCard from "../../../../components/CardAttributeFields/utils/getAttributesFromCard.ts";
import isModuleInProject, { Modules } from "../../utils/isModuleInProject.ts";
import PaymentLinkButton from "./components/PaymentLinkButton.tsx";
import PrintDealBarcodesButton from "./components/PrintDealBarcodesButton.tsx";
type Props = {
card: CardSchema;
};
type Attributes = {
[key: string]: number | boolean | string;
};
export type CardGeneralFormType = Omit<CardSchema, "statusHistory" | "services" | "products">;
const Content: FC<Props> = ({ card }) => {
const { setSelectedCard } = useCardPageContext();
const clipboard = useClipboard();
const queryClient = useQueryClient();
const [project, setProject] = useState<ProjectSchema | null>(card.board.project);
const isServicesAndProductsIncluded = isModuleInProject(Modules.SERVICES_AND_PRODUCTS, card.board.project);
const getInitialValues = (card: CardSchema): CardGeneralFormType => {
return {
...card,
...getAttributesFromCard(card),
};
};
let initialValues = getInitialValues(card);
const form = useForm<CardGeneralFormType>({
initialValues,
validate: {
name: (value: string) =>
value.length > 0
? null
: "Название не может быть пустым",
status: (value: StatusSchema) =>
!value && "Статус не выбран",
},
});
const updateCardInfo = async (values: CardGeneralFormType) => {
console.log("Updated attributes:");
console.log(values);
const formCardAttrs = values as unknown as Attributes;
const attributes = project?.attributes.reduce((attrs, projectAttr) => {
return {
...attrs,
[projectAttr.name]: formCardAttrs[projectAttr.name],
};
}, {});
return CardService.updateCardGeneralInfo({
requestBody: {
cardId: card.id,
data: {
...values,
statusId: values.status.id,
boardId: values.board.id,
shippingWarehouse: values.shippingWarehouse?.toString(),
attributes,
},
},
}).then(({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
CardService.getCardById({ cardId: card.id }).then(data => {
console.log(data);
setSelectedCard(data);
initialValues = getInitialValues(data);
form.setValues(initialValues);
queryClient.invalidateQueries({
queryKey: ["getCardSummaries"],
});
});
});
};
const updateClientInfo = async (values: CardGeneralFormType) => {
return ClientService.updateClient({
requestBody: {
data: values.client,
},
}).then(({ ok, message }) => notifications.guess(ok, { message }));
};
const handleSubmit = async (values: CardGeneralFormType) => {
// Updating client info if there changes
if (!isEqual(values.client, card.client)) {
await updateClientInfo(values);
}
const shippingWarehouse = isShippingWarehouse(values.shippingWarehouse) ? values.shippingWarehouse.name : values.shippingWarehouse;
await updateCardInfo(
{
...values,
shippingWarehouse,
},
);
};
const isShippingWarehouse = (
value: ShippingWarehouseSchema | string | null | undefined,
): value is ShippingWarehouseSchema => {
return !["string", "null", "undefined"].includes(typeof value);
};
const onCopyGuestUrlClick = () => {
CardService.createDealGuestUrl({
requestBody: {
cardId: card.id,
},
}).then(({ ok, message, url }) => {
if (!ok) notifications.guess(ok, { message });
clipboard.copy(`${window.location.origin}/${url}`);
});
};
return (
<form onSubmit={form.onSubmit(values => handleSubmit(values))}>
<Flex
direction={"column"}
justify={"space-between"}
h={"100%"}>
<Fieldset legend={`Общие параметры [ID: ${card.id}]`}>
<Flex
direction={"column"}
gap={rem(10)}
>
<TextInput
placeholder={"Название сделки"}
label={"Название сделки"}
{...form.getInputProps("name")}
/>
<TextInput
disabled
placeholder={"Дата создания"}
label={"Дата создания"}
value={new Date(card.createdAt).toLocaleString(
"ru-RU",
)}
/>
<ProjectSelect
value={project}
onChange={setProject}
label={"Проект"}
disabled
/>
<BoardSelect
project={project}
{...form.getInputProps("board")}
label={"Доска"}
/>
<DealStatusSelect
board={form.values.board}
{...form.getInputProps("status")}
label={"Статус"}
/>
<Textarea
h={rem(150)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
}}
label={"Коментарий"}
placeholder={"Введите коментарий"}
{...form.getInputProps("comment")}
/>
<ShippingWarehouseAutocomplete
placeholder={"Введите склад отгрузки"}
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);
}}
/>
<ManagerSelect
placeholder={"Укажите менеджера"}
label={"Менеджер"}
{...form.getInputProps("manager")}
/>
{project && (
<CardAttributeFields
project={project}
form={form}
/>
)}
</Flex>
</Fieldset>
<Flex
mt={"md"}
gap={rem(10)}
align={"center"}
justify={"flex-end"}>
<Flex
align={"center"}
gap={rem(10)}
justify={"center"}>
<Flex
gap={rem(10)}
align={"center"}
justify={"space-between"}>
{isServicesAndProductsIncluded && (
<PrintDealBarcodesButton card={card}/>
)}
<Flex gap={rem(10)}>
{isServicesAndProductsIncluded && (
<PaymentLinkButton card={card} />
)}
<ButtonCopyControlled
onCopyClick={onCopyGuestUrlClick}
onCopiedLabel={
"Ссылка скопирована в буфер обмена"
}
copied={clipboard.copied}
>
Ссылка на редактирование
</ButtonCopyControlled>
</Flex>
</Flex>
<Flex gap={rem(10)}>
{isServicesAndProductsIncluded && (
<Checkbox
label={"Оплачен"}
checked={card.billRequest?.paid || card.group?.billRequest?.paid || false}
disabled
/>
)}
<Checkbox
label={"Завершена"}
{...form.getInputProps("isCompleted", { type: "checkbox" })}
/>
<Checkbox
label={"Удалена"}
{...form.getInputProps("isDeleted", { type: "checkbox" })}
/>
</Flex>
</Flex>
<Divider orientation={"vertical"} />
<Group
align={"center"}
justify={"center"}>
<Button
color={"red"}
type={"reset"}
disabled={isEqual(initialValues, form.values)}
onClick={() => form.reset()}>
Отменить изменения
</Button>
<Button
variant={"default"}
type={"submit"}
disabled={isEqual(initialValues, form.values)}>
Сохранить изменения
</Button>
</Group>
</Flex>
</Flex>
</form>
);
};
const GeneralTab: FC = () => {
const { selectedCard } = useCardPageContext();
if (!selectedCard) return <>No card selected</>;
return <Content card={selectedCard} />;
};
export default GeneralTab;

View File

@@ -0,0 +1,43 @@
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";
type Props = {
card: CardSchema;
}
const PaymentLinkButton = ({ card }: Props) => {
const billRequestPdfUrl = card?.billRequest?.pdfUrl || card?.group?.billRequest?.pdfUrl;
if (billRequestPdfUrl) {
return (
<ButtonCopy
onCopiedLabel={"Ссылка скопирована в буфер обмена"}
value={billRequestPdfUrl}
>
Ссылка на оплату
</ButtonCopy>
);
}
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>
);
}
export default PaymentLinkButton;

View File

@@ -0,0 +1,63 @@
import { ActionIcon, Tooltip } from "@mantine/core";
import styles from "../../../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 (
<>
<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>
</>
);
};
export default PrintDealBarcodesButton;

View File

@@ -0,0 +1,38 @@
.container {
display: flex;
//flex-direction: column;
gap: rem(10);
max-height: 95vh;
}
.container-disabled {
}
.products-list {
width: 60%;
display: flex;
flex-direction: column;
gap: rem(10);
flex: 2;
}
.deal-container {
display: flex;
flex-direction: column;
gap: rem(10);
flex: 1;
}
.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%;
}

View File

@@ -0,0 +1,297 @@
import { FC } from "react";
import styles from "./ProductAndServiceTab.module.css";
import ProductView from "./components/ProductView/ProductView.tsx";
import {
Button,
Divider,
Flex,
rem,
ScrollArea,
Text,
Title,
} from "@mantine/core";
import CardServicesTable from "./components/DealServicesTable/CardServicesTable.tsx";
import useCardProductAndServiceTabState from "./hooks/useProductAndServiceTabState.tsx";
import { modals } from "@mantine/modals";
import {
BillingService,
CardProductSchema,
CardService,
GetServiceKitSchema,
ProductSchema,
ProductService,
} from "../../../../client";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { CreateProductRequest } from "../../../ProductsPage/types.ts";
import classNames from "classnames";
const ProductAndServiceTab: FC = () => {
const { cardState, cardServicesState, cardProductsState } =
useCardProductAndServiceTabState();
const isLocked = Boolean(cardState.card?.billRequest || cardState.card?.group?.billRequest);
const onAddProductClick = () => {
if (!cardProductsState.onCreate || !cardState.card) return;
const productIds = cardState.card.products.map(
product => product.product.id
);
modals.openContextModal({
modal: "addCardProduct",
innerProps: {
onCreate: cardProductsState.onCreate,
clientId: cardState.card.clientId,
productIds: productIds,
},
withCloseButton: false,
});
};
const getTotalPrice = () => {
if (!cardState.card) return 0;
const productServicesPrice = cardState.card.products.reduce(
(acc, row) =>
acc +
row.services.reduce(
(acc2, row2) => acc2 + row2.price * row.quantity,
0
),
0
);
const cardServicesPrice = cardState.card.services.reduce(
(acc, row) => acc + row.price * row.quantity,
0
);
return cardServicesPrice + productServicesPrice;
};
const onCopyServices = (
sourceProduct: CardProductSchema,
destinationProducts: CardProductSchema[]
) => {
if (!cardState.card) return;
CardService.copyProductServices({
requestBody: {
cardId: cardState.card.id,
destinationProductIds: destinationProducts.map(
product => product.product.id
),
sourceProductId: sourceProduct.product.id,
},
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
await cardState.refetch();
});
};
const onCopyServicesClick = (product: CardProductSchema) => {
modals.openContextModal({
modal: "selectCardProductsModal",
title: "Дублирование услуг",
size: "lg",
innerProps: {
cardProducts: cardState.card?.products || [],
cardProduct: product,
onSelect: onCopyServices,
},
withCloseButton: false,
});
};
const onKitAdd = (item: CardProductSchema, kit: GetServiceKitSchema) => {
if (!cardState.card) return;
CardService.addKitToCardProduct({
requestBody: {
cardId: cardState.card.id,
kitId: kit.id,
productId: item.product.id,
},
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
await cardState.refetch();
});
};
const onDealKitAdd = (kit: GetServiceKitSchema) => {
if (!cardState.card) return;
CardService.addKitToCard({
requestBody: {
cardId: cardState.card.id,
kitId: kit.id,
},
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
await cardState.refetch();
});
};
const onCreateProduct = (newProduct: CreateProductRequest) => {
ProductService.createProduct({
requestBody: newProduct,
}).then(({ ok, message }) => {
notifications.guess(ok, { message: message });
});
};
const onCreateProductClick = () => {
if (!cardState.card) return;
modals.openContextModal({
modal: "createProduct",
title: "Создание товара",
withCloseButton: false,
innerProps: {
clientId: cardState.card.clientId,
onCreate: onCreateProduct,
},
});
};
const onProductEdit = (product: ProductSchema) => {
ProductService.updateProduct({ requestBody: { product } }).then(
async ({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
await cardState.refetch();
}
);
};
const onCreateBillClick = () => {
if (!cardState.card) return;
const cardId = cardState.card.id;
modals.openConfirmModal({
withCloseButton: false,
size: "xl",
children: (
<Text style={{ textAlign: "justify" }}>
Создание заявки на выставление счета, подтвержденное
нажатием кнопки "Выставить", заблокирует возможность
редактирования товаров и услуг сделки. Пожалуйста, проверьте
всю информацию на точность и полноту перед подтверждением.
</Text>
),
onConfirm: () => {
BillingService.createDealBill({
requestBody: {
cardId,
},
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
if (ok)
notifications.success({
message:
"Ссылка на оплату доступна во вкладе общее",
});
await cardState.refetch();
});
},
labels: {
confirm: "Выставить",
cancel: "Отмена",
},
});
};
const onCancelBillClick = () => {
if (!cardState.card) return;
const cardId = cardState.card.id;
modals.openConfirmModal({
withCloseButton: false,
children: (
<Text style={{ textAlign: "justify" }}>
Вы уверены что хотите отозвать заявку на оплату?
</Text>
),
onConfirm: () => {
BillingService.cancelDealBill({
requestBody: {
cardId,
},
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
await cardState.refetch();
});
},
labels: {
confirm: "Отозвать",
cancel: "Отмена",
},
});
};
return (
<div
className={classNames(
styles["container"],
cardState.card?.billRequest && styles["container-disabled"]
)}>
<div className={styles["products-list"]}>
<ScrollArea offsetScrollbars>
{cardState.card?.products.map(product => (
<ProductView
onProductEdit={onProductEdit}
onKitAdd={onKitAdd}
onCopyServices={onCopyServicesClick}
key={product.product.id}
product={product}
onChange={cardProductsState.onChange}
onDelete={cardProductsState.onDelete}
/>
))}
</ScrollArea>
</div>
<div className={styles["deal-container"]}>
<ScrollArea offsetScrollbars>
<Flex
direction={"column"}
className={styles["deal-container-wrapper"]}>
<CardServicesTable
onKitAdd={onDealKitAdd}
{...cardServicesState}
/>
<Divider my={rem(15)} />
<div className={styles["deal-container-buttons"]}>
<Button
disabled={isLocked}
variant={"default"}
fullWidth
onClick={onCreateProductClick}>
Создать товар
</Button>
<Button
disabled={isLocked}
onClick={onAddProductClick}
variant={"default"}
fullWidth>
Добавить товар
</Button>
</div>
<Divider my={rem(15)} />
<div className={styles["deal-container-buttons"]}>
{isLocked ? (
<Button
onClick={onCancelBillClick}
color={"red"}>
Отозвать счет
</Button>
) : (
<Button
disabled={isLocked}
onClick={onCreateBillClick}
variant={"default"}
fullWidth>
Выставить счет
</Button>
)}
</div>
</Flex>
<Flex
direction={"column"}
className={styles["deal-container-wrapper"]}>
<Title order={3}>
Общая стоимость всех услуг:{" "}
{getTotalPrice().toLocaleString("ru")}
</Title>
</Flex>
</ScrollArea>
</div>
</div>
);
};
export default ProductAndServiceTab;

View File

@@ -0,0 +1,251 @@
import { CRUDTableProps } from "../../../../../../types/CRUDTable.tsx";
import { CardServiceSchema, GetServiceKitSchema, UserSchema } from "../../../../../../client";
import { FC, useState } from "react";
import { ActionIcon, Button, Flex, Modal, NumberInput, rem, Text, Title, Tooltip } from "@mantine/core";
import { IconTrash, IconUsersGroup } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { isNumber } from "lodash";
import SimpleUsersTable from "../../../../components/SimpleUsersTable/SimpleUsersTable.tsx";
import { ServiceType } from "../../../../../../shared/enums/ServiceType.ts";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
import useCardProductAndServiceTabState from "../../hooks/useProductAndServiceTabState.tsx";
import LockCheckbox from "../../../../../../components/LockCheckbox/LockCheckbox.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
type RestProps = {
onKitAdd?: (kit: GetServiceKitSchema) => void;
};
type Props = CRUDTableProps<CardServiceSchema> & RestProps;
const CardServicesTable: FC<Props> = ({
items,
onDelete,
onCreate,
onChange,
onKitAdd,
}) => {
const debouncedOnChange = useDebouncedCallback(async (item: CardServiceSchema) => {
if (!onChange) return;
onChange(item);
}, 200);
const authState = useSelector((state: RootState) => state.auth);
const { cardState } = useCardProductAndServiceTabState();
const isLocked = Boolean(cardState.card?.billRequest);
const [currentService, setCurrentService] = useState<
CardServiceSchema | undefined
>();
const [employeesModalVisible, setEmployeesModalVisible] = useState(false);
const onDeleteClick = (item: CardServiceSchema) => {
if (!onDelete) return;
onDelete(item);
};
const onCreateClick = () => {
if (!onCreate) return;
const serviceIds = items.map(service => service.service.id);
modals.openContextModal({
modal: "addCardService",
innerProps: {
onCreate: onCreate,
serviceIds,
},
withCloseButton: false,
});
};
const onQuantityChange = (item: CardServiceSchema, quantity: number) => {
if (!onChange) return;
debouncedOnChange({
...item,
quantity,
});
};
const onPriceChange = (item: CardServiceSchema, price: number) => {
if (!onChange) return;
debouncedOnChange({
...item,
price,
isFixedPrice: true,
});
};
const onLockChange = (item: CardServiceSchema, isLocked: boolean) => {
if (!onChange) return;
debouncedOnChange({
...item,
isFixedPrice: isLocked,
});
};
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,
});
};
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)}
h={"100%"}>
<Flex
h={"100%"}
direction={"column"}>
<Title
order={3}
w={"100%"}
style={{ textAlign: "center" }}
mb={rem(10)}>
Общие услуги
</Title>
<Flex
direction={"column"}
gap={rem(10)}>
{items.map(service => (
<Flex
key={service.service.id}
w={"100%"}
gap={rem(10)}
align={"center"}>
<Tooltip
onClick={() => onDeleteClick(service)}
label="Удалить услугу">
<ActionIcon
disabled={isLocked}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
{!authState.isGuest && (
<Tooltip label="Сотрудники">
<ActionIcon
onClick={() =>
onEmployeeClick(service)
}
variant={"default"}>
<IconUsersGroup />
</ActionIcon>
</Tooltip>
)}
<Text flex={1}>{service.service.name}</Text>
<NumberInput
disabled={isLocked}
flex={1}
suffix={" шт."}
onChange={event =>
isNumber(event) &&
onQuantityChange(service, event)
}
value={service.quantity}
/>
<NumberInput
flex={1}
onChange={event =>
isNumber(event) &&
onPriceChange(service, event)
}
suffix={"₽"}
value={service.price}
disabled={authState.isGuest || isLocked || service.isFixedPrice}
rightSectionProps={{
style: {
display: "flex",
cursor: "pointer",
pointerEvents: "auto",
},
}}
rightSection={
<LockCheckbox
label={"Зафиксировать цену"}
variant={"default"}
value={service.isFixedPrice}
onChange={value => onLockChange(service, value)}
/>
}
/>
</Flex>
))}
</Flex>
<Title
style={{ textAlign: "end" }}
mt={rem(10)}
order={3}>
Итог:{" "}
{items.reduce(
(acc, item) => acc + item.price * item.quantity,
0,
)}
</Title>
</Flex>
<Flex
direction={"column"}
gap={rem(10)}
mt={"auto"}>
<Button
disabled={isLocked}
onClick={onCreateClick}
fullWidth
variant={"default"}>
Добавить услугу
</Button>
<Button
disabled={isLocked}
onClick={onAddKitClick}
fullWidth
variant={"default"}>
Добавить набор услуг
</Button>
</Flex>
</Flex>
<Modal
title={"Добавление сотрудника к услуге"}
opened={employeesModalVisible}
onClose={onEmployeeModalClose}
size={"xl"}>
<Flex
direction={"column"}
gap={rem(10)}>
<SimpleUsersTable
items={getCurrentEmployees()}
onChange={onEmployeesChange}
/>
</Flex>
</Modal>
</>
);
};
export default CardServicesTable;

View File

@@ -0,0 +1,192 @@
import { CRUDTableProps } from "../../../../../../types/CRUDTable.tsx";
import { CardProductServiceSchema, UserSchema } from "../../../../../../client";
import { FC, useState } 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, Modal, rem, Tooltip } from "@mantine/core";
import { IconEdit, IconTrash, IconUsersGroup } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import SimpleUsersTable from "../../../../components/SimpleUsersTable/SimpleUsersTable.tsx";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
import useCardProductAndServiceTabState from "../../hooks/useProductAndServiceTabState.tsx";
type RestProps = {
quantity: number;
onCopyServices?: () => void;
onKitAdd?: () => void;
};
type Props = CRUDTableProps<CardProductServiceSchema> & RestProps;
const ProductServicesTable: FC<Props> = ({
items,
quantity,
onCreate,
onDelete,
onChange,
onCopyServices,
onKitAdd,
}) => {
const { cardState } = useCardProductAndServiceTabState();
const isLocked = Boolean(cardState.card?.billRequest);
const authState = useSelector((state: RootState) => state.auth);
const columns = useProductServicesTableColumns({ data: items, quantity });
const serviceIds = items.map(service => service.service.id);
const [currentService, setCurrentService] = useState<
CardProductServiceSchema | undefined
>();
const [employeesModalVisible, setEmployeesModalVisible] = useState(false);
const onCreateClick = () => {
if (!onCreate) return;
modals.openContextModal({
modal: "productServiceForm",
innerProps: {
onCreate: onCreate,
serviceIds,
quantity,
},
withCloseButton: false,
});
};
const onChangeClick = (item: CardProductServiceSchema) => {
if (!onChange) return;
modals.openContextModal({
modal: "productServiceForm",
innerProps: {
element: item,
onChange,
serviceIds,
quantity,
},
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,
});
};
return (
<>
<Flex
direction={"column"}
gap={rem(10)}>
<BaseTable
data={items}
columns={columns}
restProps={
{
enableColumnActions: false,
enableSorting: false,
enableRowActions: true,
enableBottomToolbar: true,
renderBottomToolbar: (
<Flex
justify={"flex-end"}
gap={rem(10)}
p={rem(10)}>
<Button
disabled={isLocked}
onClick={() => onKitAdd && onKitAdd()}
variant={"default"}>
Добавить набор услуг
</Button>
<Button
disabled={isLocked}
onClick={() =>
onCopyServices && onCopyServices()
}
variant={"default"}>
Продублировать услуги
</Button>
<Button
disabled={isLocked}
onClick={onCreateClick}
variant={"default"}>
Добавить услугу
</Button>
</Flex>
),
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => {
if (onDelete)
onDelete(row.original);
}}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() =>
onChangeClick(row.original)
}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
{!authState.isGuest && (
<Tooltip label="Сотрудники">
<ActionIcon
onClick={() =>
onEmployeeClick(
row.original
)
}
variant={"default"}>
<IconUsersGroup />
</ActionIcon>
</Tooltip>
)}
</Flex>
),
} as MRT_TableOptions<CardProductServiceSchema>
}
/>
</Flex>
<Modal
title={"Добавление сотрудника к услуге"}
opened={employeesModalVisible}
onClose={onEmployeeModalClose}
size={"xl"}>
<Flex
direction={"column"}
gap={rem(10)}>
<SimpleUsersTable
items={getCurrentEmployees()}
onChange={onEmployeesChange}
/>
</Flex>
</Modal>
</>
);
};
export default ProductServicesTable;

View File

@@ -0,0 +1,42 @@
import { useMemo } from "react";
import { MRT_ColumnDef } from "mantine-react-table";
import { CardProductServiceSchema } from "../../../../../../client";
import { useSelector } from "react-redux";
import { RootState } from "../../../../../../redux/store.ts";
type Props = {
data: CardProductServiceSchema[];
quantity: number;
};
const useProductServicesTableColumns = (props: Props) => {
const { data, quantity } = props;
const authState = useSelector((state: RootState) => state.auth);
const totalPrice = useMemo(
() => data.reduce((acc, row) => acc + row.price * quantity, 0),
[data, quantity]
);
const hideGuestColumns = ["service.cost"];
return useMemo<MRT_ColumnDef<CardProductServiceSchema>[]>(
() => [
{
accessorKey: "service.name",
header: "Услуга",
},
{
accessorKey: "price",
header: "Цена",
Footer: () => <>Итог: {totalPrice.toLocaleString("ru")}</>,
},
],
[totalPrice]
).filter(
columnDef =>
!(
hideGuestColumns.includes(columnDef.accessorKey || "") &&
authState.isGuest
)
);
};
export default useProductServicesTableColumns;

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,230 @@
import { FC } from "react";
import {
CardProductSchema,
CardProductServiceSchema,
GetServiceKitSchema,
ProductSchema,
} from "../../../../../../client";
import styles from "./ProductView.module.css";
import { ActionIcon, Box, Flex, Image, NumberInput, rem, Text, Textarea, Title, Tooltip } from "@mantine/core";
import ProductServicesTable from "../ProductServicesTable/ProductServicesTable.tsx";
import { isNil, isNumber } from "lodash";
import { IconBarcode, IconEdit, IconTrash } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { ServiceType } from "../../../../../../shared/enums/ServiceType.ts";
import useCardProductAndServiceTabState from "../../hooks/useProductAndServiceTabState.tsx";
import { useDebouncedCallback } from "@mantine/hooks";
type Props = {
product: CardProductSchema;
onChange?: (item: CardProductSchema) => void;
onDelete?: (item: CardProductSchema) => void;
onCopyServices?: (item: CardProductSchema) => void;
onKitAdd?: (item: CardProductSchema, kit: GetServiceKitSchema) => void;
onProductEdit: (product: ProductSchema) => void;
};
type ProductFieldNames = {
[K in keyof ProductSchema]: string;
};
export const ProductFieldNames: Partial<ProductFieldNames> = {
color: "Цвет",
article: "Артикул",
size: "Размер",
brand: "Бренд",
composition: "Состав",
additionalInfo: "Доп. информация",
};
const ProductView: FC<Props> = ({
product,
onDelete,
onChange,
onCopyServices,
onKitAdd,
onProductEdit,
}) => {
const { cardState } = useCardProductAndServiceTabState();
const debouncedOnChange = useDebouncedCallback(async (item: CardProductSchema) => {
if (!onChange) return;
onChange(item);
}, 200);
const isLocked = Boolean(cardState.card?.billRequest);
const onDeleteClick = () => {
if (!onDelete) return;
onDelete(product);
};
const onServiceDelete = (item: CardProductServiceSchema) => {
if (!onChange) return;
onChange({
...product,
services: product.services.filter(
service => service.service.id !== item.service.id,
),
});
};
const onServiceCreate = (item: CardProductServiceSchema) => {
if (!onChange) return;
onChange({
...product,
services: [...product.services, item],
});
};
const onServiceChange = (item: CardProductServiceSchema) => {
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;
debouncedOnChange({
...product,
quantity,
});
};
const onPrintBarcodeClick = () => {
modals.openContextModal({
modal: "printBarcode",
innerProps: {
productId: product.product.id,
defaultQuantity: product.quantity,
},
title: "Печать штрихкода",
});
};
const onKitAddClick = () => {
if (!onKitAdd) return;
modals.openContextModal({
modal: "servicesKitSelectModal",
innerProps: {
onSelect: kit => onKitAdd(product, kit),
serviceType: ServiceType.PRODUCT_SERVICE,
},
withCloseButton: false,
});
};
const onProductEditClick = () => {
modals.openContextModal({
modal: "createProduct",
title: "Редактирование товара",
withCloseButton: false,
innerProps: {
onChange: newProduct => onProductEdit(newProduct),
product: product.product,
},
});
};
return (
<div className={styles["container"]}>
<div className={styles["data-container"]}>
<div className={styles["image-container"]}>
<Image
flex={1}
radius={rem(10)}
fit={"cover"}
src={product.product.imageUrl}
/>
</div>
<div className={styles["attributes-container"]}>
<Title order={3}>{product.product.name}</Title>
{/*<Spoiler maxHeight={0} showLabel={"Показать характеристики"} hideLabel={"Скрыть"}>*/}
{Object.entries(product.product).map(([key, value]) => {
const fieldName =
ProductFieldNames[key as keyof ProductSchema];
if (!fieldName || isNil(value) || value === "") return;
return (
<Text key={fieldName}>
{fieldName}: {value.toString()}{" "}
</Text>
);
})}
<Text>
Штрихкоды: {product.product.barcodes.join(", ")}
</Text>
{/*</Spoiler>*/}
</div>
<Box />
<NumberInput
mt={rem(10)}
disabled={isLocked}
suffix={" шт."}
value={product.quantity}
onChange={event =>
isNumber(event) && onQuantityChange(event)
}
placeholder={"Введите количество товара"}
/>
<Textarea
mih={rem(140)}
styles={{
wrapper: { height: "90%" },
input: { height: "90%" },
}}
my={rem(10)}
disabled={isLocked}
defaultValue={product.comment}
onChange={event => {
if (!onChange) return;
debouncedOnChange({
...product,
comment: event.currentTarget.value,
});
}}
placeholder={"Введите комментарий для товара"}
/>
</div>
<div className={styles["services-container"]}>
<ProductServicesTable
onKitAdd={onKitAddClick}
onCopyServices={() =>
onCopyServices && onCopyServices(product)
}
items={product.services}
quantity={product.quantity}
onCreate={onServiceCreate}
onDelete={onServiceDelete}
onChange={onServiceChange}
/>
<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={onDeleteClick}
label="Удалить товар">
<ActionIcon
disabled={isLocked}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
</Flex>
</div>
</div>
);
};
export default ProductView;

View File

@@ -0,0 +1,148 @@
import { CRUDTableProps } from "../../../../../types/CRUDTable.tsx";
import { CardService, CardServiceSchema, CardProductSchema } from "../../../../../client";
import { useCardPageContext } from "../../../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,50 @@
import ShippingTree from "./components/ShippingTree.tsx";
import { Group, ScrollArea, Stack } from "@mantine/core";
import useShipping from "./hooks/useShipping.tsx";
import useShippingQrs from "./hooks/useShippingQrs.tsx";
import { IconPrinter, IconQrcode } from "@tabler/icons-react";
import InlineButton from "../../../../components/InlineButton/InlineButton.tsx";
const ShippingTab = () => {
const {
onCreateBoxInCardClick,
onCreatePalletClick,
} = useShipping();
const {
onGetDealQrPdfClick,
onGetPalletsPdfClick,
onGetBoxesPdfClick,
} = useShippingQrs();
return (
<Stack h={"94vh"}>
<Group>
<InlineButton onClick={() => onCreatePalletClick()}>
Добавить паллет
</InlineButton>
<InlineButton onClick={() => onCreateBoxInCardClick()}>
Добавить короб
</InlineButton>
<InlineButton onClick={() => onGetDealQrPdfClick()}>
<IconQrcode />
Сделка
</InlineButton>
<InlineButton onClick={() => onGetPalletsPdfClick()}>
<IconPrinter />
Паллеты
</InlineButton>
<InlineButton onClick={() => onGetBoxesPdfClick()}>
<IconPrinter />
Короба
</InlineButton>
</Group>
<ScrollArea>
<ShippingTree />
</ScrollArea>
</Stack>
);
};
export default ShippingTab;

View File

@@ -0,0 +1,86 @@
import useShippingTableColumns from "../hooks/shippingTableColumns.tsx";
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import { BoxSchema, ShippingService } from "../../../../../client";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { modals } from "@mantine/modals";
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
import useUpdateCard from "../hooks/useUpdateCard.tsx";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type Props = {
items: BoxSchema[];
}
const BoxesTable = ({ items }: Props) => {
const columns = useShippingTableColumns<BoxSchema>({ isBox: true });
const { update } = useUpdateCard();
const { selectedCard: card } = useCardPageContext();
const onDeleteClick = (box: BoxSchema) => {
ShippingService.deleteBox({
boxId: box.id,
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
if (ok) update();
})
.catch(err => console.log(err));
};
const onEditClick = (box: BoxSchema) => {
if (!card) return;
modals.openContextModal({
modal: "shippingProductModal",
title: "Редактирование короба",
withCloseButton: false,
innerProps: {
card,
updateOnSubmit: update,
isBox: true,
shippingData: {
boxId: box.id,
productId: box.product?.id,
quantity: box.quantity,
},
},
});
};
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
positionActionsColumn: "last",
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDeleteClick(row.original)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<BoxSchema>
}
/>
);
};
export default BoxesTable;

View File

@@ -0,0 +1,40 @@
import { ProductSchema } from "../../../../../client";
import { FC, useState } from "react";
import ObjectSelect, { ObjectSelectProps } from "../../../../../components/ObjectSelect/ObjectSelect.tsx";
import getRenderOptions from "../../../../../components/ProductSelect/utils/getRenderOptions.tsx";
type Props = Omit<ObjectSelectProps<ProductSchema>, "searchValue" | "onSearchChange">;
const ShippingProductSelect: FC<Props> = (props: Props) => {
const [search, setSearch] = useState("");
const getFilteredData = () => {
const searchValue: string = search.toLowerCase();
const data: ProductSchema[] = props.data.filter(product => {
return (
product.name.toLowerCase().includes(searchValue) ||
product.article?.toLowerCase().includes(searchValue) ||
product.barcodes && product.barcodes[0].toLowerCase().includes(searchValue)
);
});
return data;
};
return (
<ObjectSelect
label={"Товар"}
placeholder={"Выберите товар"}
searchable
{...props}
data={getFilteredData()}
searchValue={search}
onSearchChange={setSearch}
renderOption={getRenderOptions(props.data)}
filter={({ options }) => options}
/>
);
};
export default ShippingProductSelect;

View File

@@ -0,0 +1,87 @@
import useShippingTableColumns from "../hooks/shippingTableColumns.tsx";
import { BaseTable } from "../../../../../components/BaseTable/BaseTable.tsx";
import { ShippingProductSchema, ShippingService } from "../../../../../client";
import { ActionIcon, Flex, Tooltip } from "@mantine/core";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import { MRT_TableOptions } from "mantine-react-table";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import useUpdateCard from "../hooks/useUpdateCard.tsx";
import { modals } from "@mantine/modals";
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
type Props = {
items: ShippingProductSchema[];
}
const ShippingProductsTable = ({ items }: Props) => {
const columns = useShippingTableColumns<ShippingProductSchema>({ isBox: false });
const { update } = useUpdateCard();
const { selectedCard: card } = useCardPageContext();
const onDeleteClick = (shippingProduct: ShippingProductSchema) => {
ShippingService.deleteShippingProduct({
shippingProductId: shippingProduct.id,
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
update();
})
.catch(err => console.log(err));
};
const onEditClick = (shippingProduct: ShippingProductSchema) => {
if (!card) return;
modals.openContextModal({
modal: "shippingProductModal",
title: "Редактирование товара на паллете",
withCloseButton: false,
innerProps: {
card,
updateOnSubmit: update,
isBox: false,
shippingData: {
shippingProductId: shippingProduct.id,
productId: shippingProduct.product.id,
quantity: shippingProduct.quantity,
},
},
});
};
return (
<BaseTable
data={items}
columns={columns}
restProps={
{
enableSorting: false,
enableColumnActions: false,
enableRowActions: true,
enableRowNumbers: true,
positionActionsColumn: "last",
renderRowActions: ({ row }) => (
<Flex gap="md">
<Tooltip label="Удалить">
<ActionIcon
onClick={() => onDeleteClick(row.original)}
variant={"default"}>
<IconTrash />
</ActionIcon>
</Tooltip>
<Tooltip label="Редактировать">
<ActionIcon
onClick={() => onEditClick(row.original)}
variant={"default"}>
<IconEdit />
</ActionIcon>
</Tooltip>
</Flex>
),
} as MRT_TableOptions<ShippingProductSchema>
}
/>
);
};
export default ShippingProductsTable;

View File

@@ -0,0 +1,151 @@
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
import { Accordion, ActionIcon, Button, Center, Group, rem, Stack, Title, Tooltip } from "@mantine/core";
import { BoxSchema, PalletSchema, ShippingProductSchema } from "../../../../../client";
import ShippingProductsTable from "./ShippingProductsTable.tsx";
import BoxesTable from "./BoxesTable.tsx";
import { IconBox, IconPlus, IconSpace, IconTrash } from "@tabler/icons-react";
import useShipping from "../hooks/useShipping.tsx";
const ShippingTree = () => {
const { selectedCard: card } = useCardPageContext();
const {
onCreateBoxInPallet,
onCreateShippingProduct,
onDeletePalletClick,
palletIds,
} = useShipping();
const sortById = (data?: PalletSchema[] | BoxSchema[] | ShippingProductSchema[]) => {
return data?.sort((a, b) => a.id - b.id);
};
const getPallets = () => {
const sortedPallets = sortById(card?.pallets) as PalletSchema[];
const pallets = sortedPallets?.map((pallet => {
palletIds.push(pallet.id.toString());
return (
<Accordion.Item key={pallet.id} value={pallet.id.toString()}>
<Center>
<Accordion.Control icon={<IconSpace />}>
Паллет - П{pallet.id}
</Accordion.Control>
{removePalletButton(pallet.id)}
</Center>
<Accordion.Panel>
{getPalletContent(pallet)}
</Accordion.Panel>
</Accordion.Item>
);
})) ?? [];
if (card?.boxes && card?.boxes.length > 0) {
const boxes = card?.boxes.sort((b1, b2) => (b1.id - b2.id));
const itemValue = "noPallets";
const boxesWithoutPallet = (
<Accordion.Item key={-1} value={itemValue}>
<Accordion.Control icon={<IconBox />}>
Короба без паллетов
</Accordion.Control>
<Accordion.Panel>
<BoxesTable items={boxes} />
</Accordion.Panel>
</Accordion.Item>
);
pallets.unshift(boxesWithoutPallet);
palletIds.push(itemValue);
}
return pallets;
};
const removePalletButton = (palletId: number) => {
return (
<Tooltip label="Удалить паллет">
<ActionIcon
variant={"default"}
onClick={() => onDeletePalletClick(palletId)}
mx={"md"}
>
<IconTrash />
</ActionIcon>
</Tooltip>
);
};
const createBoxOrShippingProductButton = (palletId: number, isBox: boolean) => {
const createButtonLabel = isBox ? "Добавить короб" : "Добавить товар";
return (
<Button
variant={"default"}
onClick={() => {
if (isBox) {
onCreateBoxInPallet(palletId);
} else {
onCreateShippingProduct(palletId);
}
}}
>
<Group gap={rem(5)}>
<IconPlus />
{createButtonLabel}
</Group>
</Button>
);
};
const getPalletContent = (pallet: PalletSchema) => {
const isEmpty = pallet.boxes.length === 0 && pallet.shippingProducts.length === 0;
const isBox = pallet.boxes.length > 0;
const title = isEmpty ? "Пустой" : isBox ? "Короба" : "Товары";
let palletButtons;
if (isEmpty) {
palletButtons = [
createBoxOrShippingProductButton(pallet.id, true),
createBoxOrShippingProductButton(pallet.id, false),
];
} else {
palletButtons = [
createBoxOrShippingProductButton(pallet.id, isBox),
];
}
const boxes = sortById(pallet.boxes) as BoxSchema[];
const shippingProducts = sortById(pallet.shippingProducts) as ShippingProductSchema[];
let table;
if (!isEmpty) {
if (isBox) {
table = (<BoxesTable items={boxes} />);
} else {
table = (<ShippingProductsTable items={shippingProducts} />);
}
}
return (
<Stack gap={rem(5)}>
<Group justify={"space-between"}>
<Title order={6}>{title}</Title>
<Group gap={rem(10)}>
{...palletButtons}
</Group>
</Group>
{table}
</Stack>
);
};
return (
<Accordion
multiple={true}
defaultValue={palletIds}
bd={"solid 1px gray"}
>
{getPallets()}
</Accordion>
);
};
export default ShippingTree;

View File

@@ -0,0 +1,44 @@
import { useMemo } from "react";
import { MRT_ColumnDef, MRT_RowData } from "mantine-react-table";
type Props = {
isBox: boolean;
}
const useShippingTableColumns = <T extends MRT_RowData>({ isBox }: Props) => {
const hideBoxColumns = ["id"];
return useMemo<MRT_ColumnDef<T>[]>(
() => [
{
header: "ID",
accessorKey: "id",
Cell: ({ row }) => `K${row.original.id}`,
},
{
header: "Название",
accessorKey: "product.name",
Cell: ({ row }) => row.original.product?.name ?? "-",
},
{
header: "Артикул",
accessorKey: "product.article",
Cell: ({ row }) => row.original.product?.article ?? "-",
},
{
header: "Размер",
accessorKey: "product.size",
Cell: ({ row }) => row.original.product?.size ?? "-",
},
{
header: "Количество",
accessorKey: "quantity",
},
],
[],
).filter(
columnDef => isBox || !hideBoxColumns.includes(columnDef.accessorKey || ""),
);
};
export default useShippingTableColumns;

View File

@@ -0,0 +1,98 @@
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
import { CreateBoxInCardSchema, CreateBoxInPalletSchema, ShippingService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import useUpdateCard from "./useUpdateCard.tsx";
const useShipping = () => {
const { selectedCard: card } = useCardPageContext();
const { update } = useUpdateCard();
const palletIds: string[] = [];
const onCreatePalletClick = () => {
if (!card) return;
ShippingService.createPallet({
cardId: card.id,
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
update();
})
.catch(err => console.log(err));
};
const onDeletePallet = (palletId: number) => {
ShippingService.deletePallet({
palletId: palletId,
})
.then(({ ok, message }) => {
if (!ok) notifications.guess(ok, { message });
update();
})
.catch(err => console.log(err));
};
const onDeletePalletClick = (palletId: number) => {
if (!card) return;
modals.openConfirmModal({
title: "Удаление паллета",
children: <Text size="sm">Вы уверены что хотите удалить паллет?</Text>,
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => onDeletePallet(palletId),
});
};
const onCreateBox = (data: CreateBoxInPalletSchema | CreateBoxInCardSchema) => {
ShippingService.updateBox({
requestBody: {
data,
},
})
.then(({ ok, message }) => {
notifications.guess(ok, { message });
update();
})
.catch(err => console.log(err));
};
const onCreateBoxInCardClick = () => {
onCreateBox({ cardId: card?.id ?? -1 });
};
const onCreateBoxInPallet = (palletId: number) => {
onCreateBox({ palletId });
};
const onCreateShippingProduct = (palletId: number) => {
if (!card) return;
modals.openContextModal({
modal: "shippingProductModal",
title: "Добавление товара на паллет",
withCloseButton: false,
innerProps: {
card,
updateOnSubmit: update,
isBox: false,
shippingData: {
palletId,
productId: null,
quantity: null,
},
},
});
};
return {
onCreateBoxInCardClick,
onCreateBoxInPallet,
onCreateShippingProduct,
onCreatePalletClick,
onDeletePalletClick,
palletIds,
};
};
export default useShipping;

View File

@@ -0,0 +1,35 @@
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
const useShippingQrs = () => {
const { selectedCard: card } = useCardPageContext();
const basePdfUrl = `${import.meta.env.VITE_API_URL}/shipping/pdf`;
const getPdf = (url: string) => {
if (!card) return;
const pdfWindow = window.open(url);
if (!pdfWindow) return;
pdfWindow.print();
};
const onGetDealQrPdfClick = () => {
getPdf(`${basePdfUrl}/deal/${card?.id}`);
};
const onGetPalletsPdfClick = () => {
getPdf(`${basePdfUrl}/pallets/${card?.id}`);
};
const onGetBoxesPdfClick = () => {
getPdf(`${basePdfUrl}/boxes/${card?.id}`);
};
return {
onGetDealQrPdfClick,
onGetPalletsPdfClick,
onGetBoxesPdfClick,
};
};
export default useShippingQrs;

View File

@@ -0,0 +1,18 @@
import { useCardPageContext } from "../../../contexts/CardPageContext.tsx";
import { CardService } from "../../../../../client";
const useUpdateCard = () => {
const { selectedCard, setSelectedCard } = useCardPageContext();
const update = () => {
if (!selectedCard) return;
CardService.getCardById({ cardId: selectedCard.id })
.then(data => {
setSelectedCard(data);
});
};
return { update };
};
export default useUpdateCard;

View File

@@ -0,0 +1,161 @@
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import { Button, Flex, NumberInput, rem, Text } from "@mantine/core";
import getRestProducts from "../utils/getRestProducts.tsx";
import {
CreateBoxInCardSchema,
CreateBoxInPalletSchema,
CreateShippingProductSchema,
CardProductSchema,
CardSchema,
ProductSchema,
ShippingService,
UpdateBoxSchema,
UpdateShippingProductSchema,
} from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { ShippingData, ShippingModalForm } from "../types/ShippingProductData.tsx";
import { useEffect, useState } from "react";
import ShippingProductSelect from "../components/ShippingProductSelect.tsx";
type Props = {
updateOnSubmit: () => void;
card: CardSchema;
isBox: boolean;
shippingData: Partial<ShippingData>;
}
const ShippingProductModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const [restProducts, setRestProducts] = useState<Map<number, CardProductSchema>>(new Map());
const [restProductsSelectData, setRestProductSelectData] = useState<ProductSchema[]>([]);
const getRestProductQuantity = () => {
if (form.values.product) {
const restProduct = restProducts.get(form.values.product.id);
if (restProduct) {
return restProduct.quantity;
}
}
return 10000;
};
const findProductById = (productId?: number | null) => {
const cardProduct = innerProps.card.products.find(p => p.product.id === productId);
return cardProduct ? cardProduct.product : null;
};
const initialValues: ShippingModalForm = {
quantity: innerProps.shippingData.quantity ?? 0,
product: findProductById(innerProps.shippingData.productId),
};
const form = useForm<ShippingModalForm>({
initialValues,
validate: {
product: product => !product && "Необходимо выбрать товар",
quantity: quantity => quantity > getRestProductQuantity() ? "Слишком много товара" :
quantity === 0 && "Слишком мало товара",
},
});
useEffect(() => {
const data = getRestProducts({
card: innerProps.card,
unaccountedValues: innerProps.shippingData as UpdateShippingProductSchema | UpdateBoxSchema,
});
setRestProducts(data.restProducts);
setRestProductSelectData(data.restProductsSelectData);
}, [innerProps.card]);
const updateBox = () => {
const data = {
...innerProps.shippingData,
...form.values,
productId: form.values.product?.id,
} as CreateBoxInPalletSchema | CreateBoxInCardSchema | UpdateBoxSchema;
ShippingService.updateBox({
requestBody: { data },
})
.then(({ ok, message }) => {
notifications.guess(ok, { message: message });
innerProps.updateOnSubmit();
if (ok) context.closeContextModal(id);
})
.catch(err => console.log(err));
};
const updateShippingProduct = () => {
const data = {
...innerProps.shippingData,
...form.values,
productId: form.values.product?.id,
} as CreateShippingProductSchema | UpdateShippingProductSchema;
ShippingService.updateShippingProduct({
requestBody: { data },
})
.then(({ ok, message }) => {
notifications.guess(ok, { message: message });
innerProps.updateOnSubmit();
if (ok) context.closeContextModal(id);
})
.catch(err => console.log(err));
};
const onSubmit = () => {
if (innerProps.isBox) {
updateBox();
} else {
updateShippingProduct();
}
};
const getRestQuantityText = () => {
if (!form.values.product) return;
const restQuantityInDeal = getRestProductQuantity();
const restQuantity = restQuantityInDeal - form.values.quantity;
if (restQuantity >= 0) {
return <Text>Осталось распределить {restQuantity} шт.</Text>;
}
return <Text>Введено слишком большое количество.<br />Доступно {restQuantityInDeal} шт.</Text>;
};
return (
<form onSubmit={form.onSubmit(() => onSubmit())}>
<Flex
direction={"column"}
gap={rem(10)}
>
<ShippingProductSelect
{...form.getInputProps("product")}
data={restProductsSelectData}
/>
{getRestQuantityText()}
<NumberInput
label={"Количество"}
hideControls
{...form.getInputProps("quantity")}
min={0}
/>
<Button
variant={"default"}
type={"submit"}
>
Сохранить
</Button>
</Flex>
</form>
);
};
export default ShippingProductModal;

View File

@@ -0,0 +1,16 @@
import {
CreateShippingProductSchema,
ProductSchema,
UpdateBoxSchema,
UpdateShippingProductSchema,
} from "../../../../../client";
export type ShippingModalForm = {
quantity: number;
product?: ProductSchema | null;
}
export type ShippingData =
UpdateBoxSchema
| CreateShippingProductSchema
| UpdateShippingProductSchema;

View File

@@ -0,0 +1,77 @@
import { CardProductSchema, CardSchema, ProductSchema } from "../../../../../client";
type UnaccountedValues = {
boxId?: number | null;
shippingProductId?: number | null;
}
type Props = {
card?: CardSchema;
unaccountedValues: UnaccountedValues;
}
const getRestProducts = ({
card,
unaccountedValues,
}: Props) => {
const totalProducts = new Map(
card?.products.map(product => product && [product.product.id, product]),
);
const distributedProducts = new Map();
const accountProduct = (product: ProductSchema, quantity: number) => {
const productId = product.id;
if (distributedProducts.has(productId)) {
const prodData = distributedProducts.get(productId);
distributedProducts.set(productId, { product, quantity: quantity + prodData.quantity });
} else {
distributedProducts.set(productId, { product, quantity });
}
};
card?.boxes?.forEach((box) => {
if (!box.product || box.id === unaccountedValues.boxId) return;
accountProduct(box.product, box.quantity);
});
card?.pallets?.forEach(pallet => {
pallet.shippingProducts.forEach(shippingProduct => {
if (shippingProduct.id === unaccountedValues.shippingProductId) return;
accountProduct(shippingProduct.product, shippingProduct.quantity);
});
pallet.boxes.forEach((box) => {
if (!box.product || box.id === unaccountedValues.boxId) return;
accountProduct(box.product, box.quantity);
});
});
const restProducts = new Map<number, CardProductSchema>();
totalProducts.entries().forEach(([key, product]) => {
const distributedProduct = distributedProducts.get(key);
if (distributedProduct) {
if (product.quantity > distributedProduct.quantity) {
const restQuantity = product.quantity - distributedProduct.quantity;
restProducts.set(key, { ...product, quantity: restQuantity });
}
} else {
restProducts.set(key, product);
}
});
const restProductsSelectData: ProductSchema[] = [];
restProducts.forEach(
(restProduct) => {
restProductsSelectData.push(restProduct.product);
},
);
return {
restProducts,
restProductsSelectData,
};
};
export default getRestProducts;

View File

@@ -0,0 +1,52 @@
.container {
display: flex;
flex: 1;
flex-direction: column;
height: 100%;
}
.statuses {
margin-top: 1rem;
flex: 1;
display: flex;
gap: 0.5rem;
justify-content: left;
/*background-color: rebeccapurple;*/
padding-right: 3%;
padding-left: 3%;
}
.header-statuses {
display: flex;
align-items: stretch;
text-align: center;
flex-direction: column;
}
.delete {
@mixin light {
border-color: var(--mantine-color-gray-1);
}
@mixin dark {
border-color: var(--mantine-color-dark-5);
}
border: dashed var(--item-border-size) var(--mantine-color-default-border);
border-radius: var(--item-border-radius);
padding: rem(30);
text-align: center;
}
.delete-hidden {
border: none;
}
.top-panel {
padding: rem(5);
gap: rem(10);
display: flex;
align-items: center;
}
.print-deals-button {
align-self: center;
}

View File

@@ -0,0 +1,108 @@
import { FC, useState } from "react";
import { useCardSummaries } from "../hooks/useCardSummaries.tsx";
import PageBlock from "../../../components/PageBlock/PageBlock.tsx";
import CardEditDrawer from "../drawers/CardEditDrawer/CardEditDrawer.tsx";
import { CardPageContextProvider } from "../contexts/CardPageContext.tsx";
import { rem } from "@mantine/core";
import useCardsPageState from "../hooks/useCardsPageState.tsx";
import CardsTable from "../components/DealsTable/CardsTable.tsx";
import { motion } from "framer-motion";
import CardPrefillDrawer from "../drawers/CardPrefillDrawer/CardPrefillDrawer.tsx";
import { PrefillCardContextProvider } from "../contexts/PrefillCardContext.tsx";
import { useParams } from "@tanstack/react-router";
import { PrefillCardsWithExcelContextProvider } from "../contexts/PrefillDealsWithExcelContext.tsx";
import DisplayMode from "../enums/DisplayMode.ts";
import LeadsPageHeader from "../components/LeadsPageHeader/LeadsPageHeader.tsx";
import useProjects from "../hooks/useProjects.tsx";
import Boards from "../../../components/Dnd/Boards/Boards/Boards.tsx";
import useBoards from "../hooks/useBoards.tsx";
export const CardsPage: FC = () => {
const { projects, refetchProjects } = useProjects();
const { data, form } = useCardsPageState({ projects });
const { boards, refetchBoards } = useBoards({ projectId: form.values.project?.id });
const { dealId } = useParams({ strict: false });
const { summariesRaw, refetch: refetchSummaries } = useCardSummaries();
const [displayMode, setDisplayMode] = useState<DisplayMode>(
DisplayMode.BOARD,
);
const getTableBody = () => {
return (
<CardsTable items={data} />
);
};
const getBoardsBody = () => {
return (
<Boards
summariesRaw={summariesRaw}
refetchSummaries={refetchSummaries}
boards={boards}
refetchBoards={refetchBoards}
/>
);
};
const getBody = () => {
return (
<motion.div
key={displayMode}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}>
{displayMode === DisplayMode.TABLE
? getTableBody()
: getBoardsBody()
}
</motion.div>
);
};
return (
<PageBlock
fullHeight
style={{
gap: rem(10),
display: "flex",
flexDirection: "column",
backgroundColor: "transparent",
boxShadow: "none",
padding: 0,
}}
>
<CardPageContextProvider
defaultCardId={(dealId && parseInt(dealId)) || undefined}
refetchCards={async () => {
await refetchSummaries();
}}
selectedProject={form.values.project}
>
<PrefillCardContextProvider>
<PrefillCardsWithExcelContextProvider>
<LeadsPageHeader
form={form}
displayMode={displayMode}
setDisplayMode={setDisplayMode}
projects={projects}
refetchProjects={refetchProjects}
/>
<PageBlock
style={{
display: "flex",
flexDirection: "column",
flex: 1,
height: "100%",
}}
>
{getBody()}
</PageBlock>
<CardEditDrawer />
<CardPrefillDrawer />
</PrefillCardsWithExcelContextProvider>
</PrefillCardContextProvider>
</CardPageContextProvider>
</PageBlock>
);
};

View File

@@ -0,0 +1,12 @@
import { ProjectSchema } from "../../../client";
export enum Modules {
SERVICES_AND_PRODUCTS = "servicesAndProducts",
}
const isModuleInProject = (module: Modules, project?: ProjectSchema | null) => {
console.log(module.toString());
return project?.modules.findIndex(m => m.key === module.toString()) !== -1;
};
export default isModuleInProject;