feat: cards, attributes and modules
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
107
src/pages/CardsPage/components/DealProductsTable/columns.tsx
Normal file
107
src/pages/CardsPage/components/DealProductsTable/columns.tsx
Normal 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;
|
||||
@@ -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;
|
||||
63
src/pages/CardsPage/components/DealServicesTable/columns.tsx
Normal file
63
src/pages/CardsPage/components/DealServicesTable/columns.tsx
Normal 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}</>,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
};
|
||||
57
src/pages/CardsPage/components/DealsTable/CardsTable.tsx
Normal file
57
src/pages/CardsPage/components/DealsTable/CardsTable.tsx
Normal 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;
|
||||
56
src/pages/CardsPage/components/DealsTable/columns.tsx
Normal file
56
src/pages/CardsPage/components/DealsTable/columns.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
23
src/pages/CardsPage/components/SimpleUsersTable/columns.tsx
Normal file
23
src/pages/CardsPage/components/SimpleUsersTable/columns.tsx
Normal 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: "Должность",
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
};
|
||||
75
src/pages/CardsPage/contexts/CardPageContext.tsx
Normal file
75
src/pages/CardsPage/contexts/CardPageContext.tsx
Normal 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;
|
||||
};
|
||||
71
src/pages/CardsPage/contexts/PrefillCardContext.tsx
Normal file
71
src/pages/CardsPage/contexts/PrefillCardContext.tsx
Normal 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;
|
||||
};
|
||||
152
src/pages/CardsPage/contexts/PrefillDealsWithExcelContext.tsx
Normal file
152
src/pages/CardsPage/contexts/PrefillDealsWithExcelContext.tsx
Normal 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;
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
131
src/pages/CardsPage/drawers/CardEditDrawer/CardEditDrawer.tsx
Normal file
131
src/pages/CardsPage/drawers/CardEditDrawer/CardEditDrawer.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
};
|
||||
@@ -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)
|
||||
));
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
6
src/pages/CardsPage/enums/DisplayMode.ts
Normal file
6
src/pages/CardsPage/enums/DisplayMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
enum DisplayMode {
|
||||
BOARD,
|
||||
TABLE,
|
||||
}
|
||||
|
||||
export default DisplayMode;
|
||||
7
src/pages/CardsPage/enums/DragState.ts
Normal file
7
src/pages/CardsPage/enums/DragState.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
enum DragState {
|
||||
DRAG_ENDED,
|
||||
DRAG_DEAL,
|
||||
DRAG_STATUS,
|
||||
}
|
||||
|
||||
export default DragState;
|
||||
33
src/pages/CardsPage/hooks/useBoards.tsx
Normal file
33
src/pages/CardsPage/hooks/useBoards.tsx
Normal 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;
|
||||
22
src/pages/CardsPage/hooks/useCardSummaries.tsx
Normal file
22
src/pages/CardsPage/hooks/useCardSummaries.tsx
Normal 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,
|
||||
});
|
||||
90
src/pages/CardsPage/hooks/useCardsPageState.tsx
Normal file
90
src/pages/CardsPage/hooks/useCardsPageState.tsx
Normal 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;
|
||||
63
src/pages/CardsPage/hooks/useDnd.tsx
Normal file
63
src/pages/CardsPage/hooks/useDnd.tsx
Normal 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;
|
||||
11
src/pages/CardsPage/hooks/useManagersList.tsx
Normal file
11
src/pages/CardsPage/hooks/useManagersList.tsx
Normal 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;
|
||||
26
src/pages/CardsPage/hooks/useProjects.tsx
Normal file
26
src/pages/CardsPage/hooks/useProjects.tsx
Normal 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;
|
||||
1
src/pages/CardsPage/index.ts
Normal file
1
src/pages/CardsPage/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CardsPage } from "./ui/CardsPage.tsx";
|
||||
90
src/pages/CardsPage/modals/AddCardProductModal.tsx
Normal file
90
src/pages/CardsPage/modals/AddCardProductModal.tsx
Normal 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;
|
||||
106
src/pages/CardsPage/modals/AddCardServiceModal.tsx
Normal file
106
src/pages/CardsPage/modals/AddCardServiceModal.tsx
Normal 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;
|
||||
46
src/pages/CardsPage/modals/BoardModal/BoardModal.tsx
Normal file
46
src/pages/CardsPage/modals/BoardModal/BoardModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
78
src/pages/CardsPage/modals/CardsTableFiltersModal.tsx
Normal file
78
src/pages/CardsPage/modals/CardsTableFiltersModal.tsx
Normal 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;
|
||||
97
src/pages/CardsPage/modals/ProductServiceFormModal.tsx
Normal file
97
src/pages/CardsPage/modals/ProductServiceFormModal.tsx
Normal 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;
|
||||
85
src/pages/CardsPage/modals/ProjectsModal/ProjectsModal.tsx
Normal file
85
src/pages/CardsPage/modals/ProjectsModal/ProjectsModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
83
src/pages/CardsPage/modals/SelectCardProductsModal.tsx
Normal file
83
src/pages/CardsPage/modals/SelectCardProductsModal.tsx
Normal 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;
|
||||
46
src/pages/CardsPage/modals/StatusModal/StatusModal.tsx
Normal file
46
src/pages/CardsPage/modals/StatusModal/StatusModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
98
src/pages/CardsPage/tabs/ClientTab/ClientTab.tsx
Normal file
98
src/pages/CardsPage/tabs/ClientTab/ClientTab.tsx
Normal 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;
|
||||
14
src/pages/CardsPage/tabs/EmployeesTab/EmployeesTab.tsx
Normal file
14
src/pages/CardsPage/tabs/EmployeesTab/EmployeesTab.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { UserSchema } from "../../../../../client";
|
||||
|
||||
type AssignUserModalForm = {
|
||||
employee: UserSchema;
|
||||
}
|
||||
|
||||
export default AssignUserModalForm;
|
||||
309
src/pages/CardsPage/tabs/GeneralTab/GeneralTab.tsx
Normal file
309
src/pages/CardsPage/tabs/GeneralTab/GeneralTab.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
50
src/pages/CardsPage/tabs/ShippingTab/ShippingTab.tsx
Normal file
50
src/pages/CardsPage/tabs/ShippingTab/ShippingTab.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
151
src/pages/CardsPage/tabs/ShippingTab/components/ShippingTree.tsx
Normal file
151
src/pages/CardsPage/tabs/ShippingTab/components/ShippingTree.tsx
Normal 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;
|
||||
@@ -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;
|
||||
98
src/pages/CardsPage/tabs/ShippingTab/hooks/useShipping.tsx
Normal file
98
src/pages/CardsPage/tabs/ShippingTab/hooks/useShipping.tsx
Normal 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;
|
||||
@@ -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;
|
||||
18
src/pages/CardsPage/tabs/ShippingTab/hooks/useUpdateCard.tsx
Normal file
18
src/pages/CardsPage/tabs/ShippingTab/hooks/useUpdateCard.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
52
src/pages/CardsPage/ui/CardsPage.module.css
Normal file
52
src/pages/CardsPage/ui/CardsPage.module.css
Normal 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;
|
||||
}
|
||||
108
src/pages/CardsPage/ui/CardsPage.tsx
Normal file
108
src/pages/CardsPage/ui/CardsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
12
src/pages/CardsPage/utils/isModuleInProject.ts
Normal file
12
src/pages/CardsPage/utils/isModuleInProject.ts
Normal 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;
|
||||
Reference in New Issue
Block a user