feat: projects and boards

This commit is contained in:
2025-02-07 20:07:10 +04:00
parent 58d397ed0b
commit 580552bd47
185 changed files with 3352 additions and 1284 deletions

View File

@@ -0,0 +1,54 @@
import ObjectSelect, { ObjectSelectProps } from "../ObjectSelect/ObjectSelect.tsx";
import { FC, useEffect, useState } from "react";
import { BoardSchema, BoardService, ProjectSchema } from "../../client";
type OtherProps = {
project: ProjectSchema | null;
}
type SelectProps = Omit<ObjectSelectProps<BoardSchema | null>, "data" | "getLabelFn" | "getValueFn">;
type Props = OtherProps & SelectProps;
const BoardSelect: FC<Props> = ({ project, ...props }) => {
const [boards, setBoards] = useState<BoardSchema[]>([]);
const [isInitial, setIsInitial] = useState<boolean>(true);
const onClear = () => props.onChange(null);
const fetchBoards = () => {
if (!project?.id) {
setBoards([]);
return;
}
BoardService.getBoards({
projectId: project?.id,
})
.then(({ boards }) => {
setBoards(boards);
})
.catch(err => console.log(err));
};
useEffect(() => {
fetchBoards();
if (isInitial) {
setIsInitial(false);
} else {
onClear();
}
}, [project?.id]);
return (
<ObjectSelect
data={boards}
searchable
placeholder={"Выберите доску"}
onClear={onClear}
{...props}
/>
);
};
export default BoardSelect;

View File

@@ -0,0 +1,40 @@
import ObjectSelect, { ObjectSelectProps } from "../ObjectSelect/ObjectSelect.tsx";
import { FC, useEffect, useState } from "react";
import { BoardSchema, StatusSchema } from "../../client";
type OtherProps = {
board: BoardSchema | null;
}
type SelectProps = Omit<ObjectSelectProps<StatusSchema | null>, "data" | "getLabelFn" | "getValueFn">;
type Props = OtherProps & SelectProps;
const DealStatusSelect: FC<Props> = ({ board, ...props}) => {
const [isInitial, setIsInitial] = useState<boolean>(true);
const filteredData = board?.dealStatuses.filter(
status => !status.isDeleted,
) ?? [];
const onClear = () => props.onChange(null);
useEffect(() => {
if (isInitial) {
setIsInitial(false);
} else {
onClear();
}
}, [board?.id]);
return (
<ObjectSelect
data={filteredData}
searchable
placeholder={"Выберите статус"}
onClear={onClear}
{...props}
/>
);
};
export default DealStatusSelect;

View File

@@ -0,0 +1,37 @@
.boards-select-element {
border-bottom: solid gray 1px;
}
.board {
padding: 0.5em 1em 0.3em 1em;
text-wrap: nowrap;
cursor: pointer !important;
}
.selected-board {
padding: 0.5em 1em 0.3em 1em;
text-wrap: nowrap;
border-radius: var(--item-border-radius);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border: solid gray 1px;
border-bottom: none;
}
.board-during-dnd {
background-color: var(--color-gray-10);
}
.board::after {
content: "";
}
.board-drag-over {
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
border-radius: var(--item-border-radius);
}

View File

@@ -0,0 +1,97 @@
import { Draggable, Droppable } from "@hello-pangea/dnd";
import { BoardSchema } from "../../../../client";
import { useContextMenu } from "mantine-contextmenu";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import classNames from "classnames";
import styles from "./Board.module.scss";
import useStatus from "../../Statuses/Status/hooks/useStatus.tsx";
type Props = {
board: BoardSchema;
selectedBoard: BoardSchema | null;
setSelectedBoard: (board: BoardSchema) => void;
onEditBoardClick: (board: BoardSchema) => void;
onDeleteBoardClick: (board: BoardSchema) => void;
isBoardDragEnded: boolean;
refetch: () => void;
}
const Board = ({
board,
selectedBoard,
setSelectedBoard,
onEditBoardClick,
onDeleteBoardClick,
isBoardDragEnded,
refetch,
}: Props) => {
const { showContextMenu } = useContextMenu();
const { onCreateStatusClick } = useStatus({ refetch });
const contextMenu = (board: BoardSchema) => showContextMenu([
{
key: "complete",
onClick: () => onEditBoardClick(board),
title: "Переименовать",
icon: <IconEdit />,
},
{
key: "add-status",
onClick: () => onCreateStatusClick(board),
title: "Новый статус",
icon: <IconPlus />,
},
{
key: "delete",
onClick: () => onDeleteBoardClick(board),
title: "Удалить",
icon: <IconTrash />,
},
]);
return (
<Droppable
droppableId={board.id.toString()}
direction={"horizontal"}
>
{provided => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
>
<Draggable
draggableId={board.id.toString()}
index={board.ordinalNumber}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
className={classNames(
styles["boards-select-element"],
board.id === selectedBoard?.id ?
styles["selected-board"] :
styles["board"],
!isBoardDragEnded && styles["board-during-dnd"],
)}
onClick={() => setSelectedBoard(board)}
onContextMenu={contextMenu(board)}
>
{board.name}
</div>
)}
</Draggable>
{provided.placeholder}
</div>
)}
</Droppable>
);
};
export default Board;

View File

@@ -0,0 +1,90 @@
import { Box, Center, Group, Stack } from "@mantine/core";
import { DragDropContext } from "@hello-pangea/dnd";
import { BoardSchema, DealSummary, ProjectSchema } from "../../../../client";
import { IconPlus } from "@tabler/icons-react";
import useBoards from "./hooks/useBoards.tsx";
import Statuses from "../../Statuses/Statuses/Statuses.tsx";
import Board from "../Board/Board.tsx";
import useBoardsDnd from "./hooks/useBoardsDnd.tsx";
import PrefillDealsWithExcelDrawer
from "../../../../pages/DealsPage/drawers/PrefillDealWithExcelDrawer/PrefillDealsWithExcelDrawer.tsx";
type Props = {
project: ProjectSchema | null;
summariesRaw: DealSummary[];
refetchSummaries: () => void;
boards: BoardSchema[];
refetchBoards: () => void;
}
const Boards = (props: Props) => {
const {
selectedBoard,
setSelectedBoard,
onCreateBoardClick,
onEditBoardClick,
onDeleteBoardClick,
} = useBoards(props);
const {
onBoardDragEnd,
isBoardDragEnded,
setIsBoardDragEnded,
} = useBoardsDnd(props);
const getBoardsTabs = () => {
return (
<DragDropContext
onDragStart={() => {
setIsBoardDragEnded(false);
}}
onDragEnd={onBoardDragEnd}
>
<Group
mx={"3%"}
mb={"md"}
gap={0}
align={"end"}
wrap={"nowrap"}
>
{props.boards.map((board: BoardSchema) => (
<Board
key={board.id}
board={board}
selectedBoard={selectedBoard}
setSelectedBoard={setSelectedBoard}
onEditBoardClick={onEditBoardClick}
onDeleteBoardClick={onDeleteBoardClick}
isBoardDragEnded={isBoardDragEnded}
refetch={props.refetchBoards}
/>
))}
<Center
px={"md"}
py={"xs"}
style={{ cursor: "pointer", borderBottom: "solid gray 1px" }}
onClick={onCreateBoardClick}
>
<IconPlus />
</Center>
<Box w={"100%"} style={{ borderBottom: "solid gray 1px" }}></Box>
</Group>
</DragDropContext>
);
};
return (
<Stack>
{getBoardsTabs()}
<Statuses
selectedBoard={selectedBoard}
{...props}
/>
<PrefillDealsWithExcelDrawer board={selectedBoard}/>
</Stack>
);
};
export default Boards;

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from "react";
import { BoardSchema, BoardService, ProjectSchema } from "../../../../../client";
import { modals } from "@mantine/modals";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type Props = {
boards: BoardSchema[];
refetchBoards: () => void;
project: ProjectSchema | null;
}
const useBoards = ({ boards, refetchBoards, project }: Props) => {
const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>(null);
useEffect(() => {
if (boards.length > 0 && selectedBoard === null) {
setSelectedBoard(boards[0]);
return;
}
if (selectedBoard) {
// Update selected board after changing all boards
let newBoard = boards.find(board => board.id === selectedBoard.id);
if (!newBoard && boards.length > 0) {
newBoard = boards[0]
}
setSelectedBoard(newBoard ?? null);
}
}, [boards]);
const onEditBoardClick = (board: BoardSchema) => {
if (!project) return;
modals.openContextModal({
modal: "boardModal",
title: "Редактирование доски",
withCloseButton: false,
innerProps: {
projectId: project.id,
board,
refetchBoards,
},
});
};
const onCreateBoardClick = () => {
if (!project) return;
modals.openContextModal({
modal: "boardModal",
title: "Добавление доски",
withCloseButton: false,
innerProps: {
projectId: project.id,
refetchBoards,
},
});
};
const onDeleteBoardClick = (board: BoardSchema) => {
if (!board) return;
BoardService.deleteBoard({
boardId: board.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetchBoards();
})
.catch(err => console.log(err));
};
return {
selectedBoard,
setSelectedBoard,
onCreateBoardClick,
onEditBoardClick,
onDeleteBoardClick,
};
};
export default useBoards;

View File

@@ -0,0 +1,53 @@
import { DropResult } from "@hello-pangea/dnd";
import { BoardSchema, BoardService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { useState } from "react";
type Props = {
boards: BoardSchema[];
refetchBoards: () => void;
}
const useBoardsDnd = ({ boards, refetchBoards }: Props) => {
const [isBoardDragEnded, setIsBoardDragEnded] = useState(true);
const updateBoardOrder = (board: BoardSchema, newOrdinalNumber: number) => {
BoardService.updateBoardOrder({
requestBody: {
projectId: board.projectId,
boardId: board.id,
newOrdinalNumber,
}
})
.then(({ ok, message }) => {
if (!ok) notifications.error({ message });
refetchBoards();
})
};
const onBoardDragEnd = async (result: DropResult) => {
setIsBoardDragEnded(true);
// If there is no changes
if (!result.destination || result.destination == result.source) return;
// Checking for valid dealId
const boardId = parseInt(result.draggableId);
if (isNaN(boardId)) return;
// Checking for valid deal
const board = boards.find(board => board.id == boardId);
if (!board) return;
updateBoardOrder(board, result.destination.index);
};
return {
onBoardDragEnd,
isBoardDragEnded,
setIsBoardDragEnded,
};
};
export default useBoardsDnd;

View File

@@ -1,226 +0,0 @@
import { FC } from "react";
import { DealService, DealSummary } from "../../../client";
import styles from "./DealSummaryCard.module.css";
import {
ActionIcon,
Badge,
CopyButton,
Flex,
Image,
Indicator,
IndicatorProps,
Popover,
rem,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useDealPageContext } from "../../../pages/LeadsPage/contexts/DealPageContext.tsx";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { DealStatus } from "../../../shared/enums/DealStatus.ts";
import { IconCheck, IconLayoutGridRemove, IconTrash } from "@tabler/icons-react";
import { useContextMenu } from "mantine-contextmenu";
import useDealSummaryState from "./useDealSummaryState.tsx";
type Props = {
dealSummary: DealSummary;
color?: string
};
const DealSummaryCard: FC<Props> = ({ dealSummary, color }) => {
const { showContextMenu } = useContextMenu();
const { setSelectedDeal } = useDealPageContext();
const { onDelete, onComplete, onDeleteFromGroup } = useDealSummaryState();
const onDealSummaryClick = () => {
DealService.getDealById({ dealId: dealSummary.id }).then(deal => {
setSelectedDeal(deal);
});
};
const getIndicatorProps = (): IndicatorProps => {
if (!dealSummary.deliveryDate)
return { disabled: true };
const deliveryDate = new Date(dealSummary.deliveryDate);
const currentDate = new Date();
const diff = deliveryDate.getTime() - currentDate.getTime();
const diffDays = Math.ceil(diff / (1000 * 3600 * 24));
if (dealSummary.status < DealStatus.IN_DELIVERY) {
if (diffDays <= 2 && diffDays > 1) {
return {
color: "yellow",
};
}
if (diffDays <= 1) {
return {
color: "red",
};
}
return {
disabled: false,
};
}
return { disabled: true };
};
const isPaid = () => {
return dealSummary.billRequest?.paid || dealSummary.group?.billRequest?.paid;
}
const isLockedInsideGroup = () => {
return dealSummary.group && !dealSummary.group.billRequest;
}
return (
<Indicator
position={"top-end"}
withBorder
size={15}
processing
{...getIndicatorProps()}
>
<div
onContextMenu={showContextMenu([
...isLockedInsideGroup() ? [{
key: "removeFromGroup",
onClick: () => onDeleteFromGroup(dealSummary),
title: "Убрать из группы",
icon: <IconLayoutGridRemove />,
}] : [],
{
key: "complete",
onClick: () => onComplete(dealSummary),
title: "Завершить",
icon: <IconCheck />,
},
{
key: "delete",
onClick: () => onDelete(dealSummary),
title: "Удалить",
icon: <IconTrash />,
},
])}
onClick={() => onDealSummaryClick()}
className={styles["container"]}
style={{ backgroundColor: color }}
>
<Flex direction={"column"} flex={1} gap={rem(3)}>
<Flex justify={"space-between"}>
<Text
c={"gray.6"}
size={"xs"}
>
{dealSummary.clientName}
</Text>
</Flex>
<Text
c={"blue.5"}
size={"sm"}>
{dealSummary.name}
</Text>
<Flex
// align={"center"}
direction={"column"}
justify={"space-between"}
>
<Text
size={"sm"}
c={"gray.6"}>
{dealSummary.shipmentWarehouseName || "Склад не указан"}
</Text>
<Text
c={"gray.6"}
size={"sm"}
>
{dealSummary.totalPrice.toLocaleString("ru-RU")} руб,{" "}
</Text>
<Text
c={"gray.6"}
size={"sm"}>
{dealSummary.totalProducts.toLocaleString("ru-RU")} тов.
</Text>
</Flex>
<Flex direction={"column"}>
{dealSummary.deliveryDate && (
<Text
c={"blue.5"}
size={"sm"}>
Доставка: {(new Date(dealSummary.deliveryDate)).toLocaleDateString("ru-RU")}
</Text>
)}
{dealSummary.receivingSlotDate && (
<Text
c={"blue.5"}
size={"sm"}>
Слот: {(new Date(dealSummary.receivingSlotDate)).toLocaleDateString("ru-RU")}
</Text>
)}
</Flex>
<Flex align={"center"} justify={"space-between"}>
<Flex align={"center"} gap={rem(5)}>
<CopyButton value={dealSummary.id.toString()}>
{({ copy, copied }) => (
<Popover
opened={copied}
withArrow>
<Popover.Target>
<div
onClick={e => {
e.stopPropagation();
copy();
}}
className={styles["flex-item"]}>
<Badge
variant={"light"}
radius={"sm"}>
ID: {dealSummary.id}
</Badge>
</div>
</Popover.Target>
<Popover.Dropdown>
<Flex
justify={"center"}
align={"center"}
gap={rem(5)}>
<FontAwesomeIcon
bounce
style={{ animationIterationCount: 1 }}
icon={faCheck}
/>
<Text size={"xs"}>
ID сделки скопирован
</Text>
</Flex>
</Popover.Dropdown>
</Popover>
)}
</CopyButton>
{isPaid() && (
<Tooltip label={"Оплачен"}>
<ThemeIcon variant={"transparent"}>
<IconCheck />
</ThemeIcon>
</Tooltip>
)}
</Flex>
<ActionIcon variant={"transparent"}>
<Image
src={dealSummary.baseMarketplace?.iconUrl || ""}
/>
</ActionIcon>
</Flex>
</Flex>
</div>
</Indicator>
);
};
export default DealSummaryCard;

View File

@@ -3,12 +3,16 @@ import { useState } from "react";
import styles from "./CreateDealButton.module.css";
import { Text, Transition } from "@mantine/core";
import CreateDealFrom from "../CreateDealForm/CreateDealFrom.tsx";
import { DealService } from "../../../client";
import { DealService, StatusSchema } from "../../../../client";
import { useQueryClient } from "@tanstack/react-query";
import { dateWithoutTimezone } from "../../../shared/lib/date.ts";
import { usePrefillDealContext } from "../../../pages/LeadsPage/contexts/PrefillDealContext.tsx";
import { dateWithoutTimezone } from "../../../../shared/lib/date.ts";
import { usePrefillDealContext } from "../../../../pages/DealsPage/contexts/PrefillDealContext.tsx";
const CreateDealButton = () => {
type Props = {
status: StatusSchema;
}
const CreateDealButton = ({ status }: Props) => {
const [isCreating, setIsCreating] = useState(false);
const [isTransitionEnded, setIsTransitionEnded] = useState(true);
const queryClient = useQueryClient();
@@ -43,6 +47,7 @@ const CreateDealButton = () => {
acceptanceDate: dateWithoutTimezone(
quickDeal.acceptanceDate,
),
statusId: status.id,
},
}).then(async (result) => {
if (prefillDeal) {

View File

@@ -1,15 +1,15 @@
import { Button, rem, Textarea, TextInput } from "@mantine/core";
import { QuickDeal } from "../../../types/QuickDeal.ts";
import { QuickDeal } from "../../../../types/QuickDeal.ts";
import { FC } from "react";
import { useForm } from "@mantine/form";
import styles from "./CreateDealForm.module.css";
import ClientAutocomplete from "../../Selects/ClientAutocomplete/ClientAutocomplete.tsx";
import ClientAutocomplete from "../../../Selects/ClientAutocomplete/ClientAutocomplete.tsx";
import { DateTimePicker } from "@mantine/dates";
import ShippingWarehouseAutocomplete
from "../../Selects/ShippingWarehouseAutocomplete/ShippingWarehouseAutocomplete.tsx";
import BaseMarketplaceSelect from "../../Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import ServicePriceCategorySelect from "../../Selects/ServicePriceCategorySelect/ServicePriceCategorySelect.tsx";
import { usePrefillDealContext } from "../../../pages/LeadsPage/contexts/PrefillDealContext.tsx";
from "../../../Selects/ShippingWarehouseAutocomplete/ShippingWarehouseAutocomplete.tsx";
import BaseMarketplaceSelect from "../../../Selects/BaseMarketplaceSelect/BaseMarketplaceSelect.tsx";
import ServicePriceCategorySelect from "../../../Selects/ServicePriceCategorySelect/ServicePriceCategorySelect.tsx";
import { usePrefillDealContext } from "../../../../pages/DealsPage/contexts/PrefillDealContext.tsx";
type Props = {
onSubmit: (quickDeal: QuickDeal) => void;

View File

@@ -1,6 +1,6 @@
import styles from "./CreateDealsFromFileButton.module.css";
import { Text } from "@mantine/core";
import { usePrefillDealsWithExcelContext } from "../../../pages/LeadsPage/contexts/PrefillDealsWithExcelContext.tsx";
import { usePrefillDealsWithExcelContext } from "../../../../pages/DealsPage/contexts/PrefillDealsWithExcelContext.tsx";
const CreateDealsFromFileButton = () => {
const { prefillWithExcelOnOpen } = usePrefillDealsWithExcelContext();

View File

@@ -1,10 +1,10 @@
import { DealGroupSchema, DealService, DealSummary } from "../../../client";
import { DealGroupSchema, DealGroupService, DealSummary } from "../../../../client";
import { FC, useEffect, useMemo, useState } from "react";
import DealSummaryCard from "../DealSummaryCard/DealSummaryCard.tsx";
import { Flex, rem, Text, TextInput, useMantineColorScheme } from "@mantine/core";
import { IconGripHorizontal } from "@tabler/icons-react";
import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "../../../shared/lib/notifications.ts";
import { notifications } from "../../../../shared/lib/notifications.ts";
type Props = {
deals: DealSummary[];
@@ -19,7 +19,7 @@ export const DealGroupView: FC<Props> = ({ deals, group }) => {
const totalProducts = useMemo(() => deals.reduce((acc, deal) => acc + deal.totalProducts, 0), [deals]);
const updateName = () => {
if (debouncedName === group.name) return;
DealService.updateDealGroup({
DealGroupService.updateDealGroup({
requestBody: {
data: {
...group,

View File

@@ -0,0 +1,173 @@
import { FC } from "react";
import { DealService, DealSummary } from "../../../../client";
import styles from "./DealSummaryCard.module.css";
import { ActionIcon, Badge, CopyButton, Flex, Image, Popover, rem, Text, ThemeIcon, Tooltip } from "@mantine/core";
import { useDealPageContext } from "../../../../pages/DealsPage/contexts/DealPageContext.tsx";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCheck } from "@fortawesome/free-solid-svg-icons";
import { IconCheck, IconLayoutGridRemove, IconTrash } from "@tabler/icons-react";
import { useContextMenu } from "mantine-contextmenu";
import useDealSummaryState from "./useDealSummaryState.tsx";
type Props = {
dealSummary: DealSummary;
color?: string
};
const DealSummaryCard: FC<Props> = ({ dealSummary, color }) => {
const { showContextMenu } = useContextMenu();
const { setSelectedDeal } = useDealPageContext();
const { onDelete, onComplete, onDeleteFromGroup } = useDealSummaryState();
const onDealSummaryClick = () => {
DealService.getDealById({ dealId: dealSummary.id }).then(deal => {
setSelectedDeal(deal);
});
};
const isPaid = () => {
return dealSummary.billRequest?.paid || dealSummary.group?.billRequest?.paid;
};
const isLockedInsideGroup = () => {
return dealSummary.group && !dealSummary.group.billRequest;
};
return (
<div
onContextMenu={showContextMenu([
...isLockedInsideGroup() ? [{
key: "removeFromGroup",
onClick: () => onDeleteFromGroup(dealSummary),
title: "Убрать из группы",
icon: <IconLayoutGridRemove />,
}] : [],
{
key: "complete",
onClick: () => onComplete(dealSummary),
title: "Завершить",
icon: <IconCheck />,
},
{
key: "delete",
onClick: () => onDelete(dealSummary),
title: "Удалить",
icon: <IconTrash />,
},
])}
onClick={() => onDealSummaryClick()}
className={styles["container"]}
style={{ backgroundColor: color }}
>
<Flex direction={"column"} flex={1} gap={rem(3)}>
<Flex justify={"space-between"}>
<Text
c={"gray.6"}
size={"xs"}
>
{dealSummary.clientName}
</Text>
</Flex>
<Text
c={"blue.5"}
size={"sm"}>
{dealSummary.name}
</Text>
<Flex
// align={"center"}
direction={"column"}
justify={"space-between"}
>
<Text
size={"sm"}
c={"gray.6"}>
{dealSummary.shipmentWarehouseName || "Склад не указан"}
</Text>
<Text
c={"gray.6"}
size={"sm"}
>
{dealSummary.totalPrice.toLocaleString("ru-RU")} руб,{" "}
</Text>
<Text
c={"gray.6"}
size={"sm"}>
{dealSummary.totalProducts.toLocaleString("ru-RU")} тов.
</Text>
</Flex>
<Flex direction={"column"}>
{dealSummary.deliveryDate && (
<Text
c={"blue.5"}
size={"sm"}>
Доставка: {(new Date(dealSummary.deliveryDate)).toLocaleDateString("ru-RU")}
</Text>
)}
{dealSummary.receivingSlotDate && (
<Text
c={"blue.5"}
size={"sm"}>
Слот: {(new Date(dealSummary.receivingSlotDate)).toLocaleDateString("ru-RU")}
</Text>
)}
</Flex>
<Flex align={"center"} justify={"space-between"}>
<Flex align={"center"} gap={rem(5)}>
<CopyButton value={dealSummary.id.toString()}>
{({ copy, copied }) => (
<Popover
opened={copied}
withArrow>
<Popover.Target>
<div
onClick={e => {
e.stopPropagation();
copy();
}}
className={styles["flex-item"]}>
<Badge
variant={"light"}
radius={"sm"}>
ID: {dealSummary.id}
</Badge>
</div>
</Popover.Target>
<Popover.Dropdown>
<Flex
justify={"center"}
align={"center"}
gap={rem(5)}>
<FontAwesomeIcon
bounce
style={{ animationIterationCount: 1 }}
icon={faCheck}
/>
<Text size={"xs"}>
ID сделки скопирован
</Text>
</Flex>
</Popover.Dropdown>
</Popover>
)}
</CopyButton>
{isPaid() && (
<Tooltip label={"Оплачен"}>
<ThemeIcon variant={"transparent"}>
<IconCheck />
</ThemeIcon>
</Tooltip>
)}
</Flex>
<ActionIcon variant={"transparent"}>
<Image
src={dealSummary.baseMarketplace?.iconUrl || ""}
/>
</ActionIcon>
</Flex>
</Flex>
</div>
);
};
export default DealSummaryCard;

View File

@@ -1,8 +1,8 @@
import { DealService, DealSummary } from "../../../client";
import { useDealPageContext } from "../../../pages/LeadsPage/contexts/DealPageContext.tsx";
import { DealGroupService, DealService, DealSummary } from "../../../../client";
import { useDealPageContext } from "../../../../pages/DealsPage/contexts/DealPageContext.tsx";
import { modals } from "@mantine/modals";
import { Text } from "@mantine/core";
import { notifications } from "../../../shared/lib/notifications.ts";
import { notifications } from "../../../../shared/lib/notifications.ts";
const useDealSummaryState = () => {
const { refetchDeals } = useDealPageContext();
@@ -76,7 +76,7 @@ const useDealSummaryState = () => {
labels: { confirm: "Да", cancel: "Нет" },
confirmProps: { color: "red" },
onConfirm: () => {
DealService.removeDealFromGroup({
DealGroupService.removeDeal({
requestBody: {
dealId: summary.id,
},

View File

@@ -5,15 +5,6 @@
flex: 1;
}
.header {
display: flex;
align-items: stretch;
text-align: center;
flex-direction: column;
/*border: solid var(--item-border-size) var(--mantine-color-default-border);*/
/*border-radius: var(--item-border-radius);*/
}
.items-list {
gap: 0.5rem;
display: flex;

View File

@@ -1,46 +1,37 @@
import { FC } from "react";
import styles from "./Board.module.css";
import { Divider, Text, Title } from "@mantine/core";
import styles from "./DealsDndColumn.module.css";
import { Draggable, Droppable } from "@hello-pangea/dnd";
import CreateDealButton from "../CreateDealButton/CreateDealButton.tsx";
import { DealGroupSchema, DealSummary } from "../../../client";
import { DealGroupSchema, DealSummary, StatusSchema } from "../../../../client";
import DealSummaryCard from "../DealSummaryCard/DealSummaryCard.tsx";
import classNames from "classnames";
import { getPluralForm } from "../../../shared/lib/utils.ts";
import { groupBy, has, sum, uniq } from "lodash";
import { groupBy, has, uniq } from "lodash";
import { DealGroupView } from "../DealGroupView/DealGroupView.tsx";
import CreateDealsFromFileButton from "../CreateDealsFromFileButton/CreateDealsFromFileButton.tsx";
import DragState from "../../../../pages/DealsPage/enums/DragState.ts";
type Props = {
droppableId: string;
title: string;
status: StatusSchema;
withCreateButton?: boolean;
summaries: DealSummary[];
color: string;
dragState: DragState;
};
type GroupWithDeals = { group: DealGroupSchema, deals: DealSummary[] }
export const Board: FC<Props> = ({
droppableId,
title,
summaries,
color,
withCreateButton = false,
}) => {
const getDealsText = () => {
const pluralForm = getPluralForm(
summaries.length,
"сделка",
"сделки",
"сделок",
);
return `${summaries.length} ${pluralForm}: ${sum(summaries.map(summary => summary.totalPrice)).toLocaleString("ru-RU")}`;
};
export const DealsDndColumn: FC<Props> = ({
status,
summaries,
dragState,
withCreateButton = false,
}) => {
const isDropDisabled = dragState === DragState.DRAG_STATUS;
const droppableId = status.id.toString();
const isGroup = (obj: GroupWithDeals | DealSummary): obj is GroupWithDeals => {
return has(obj, "deals");
};
const getDealGroups = (): GroupWithDeals[] => {
const groups = uniq<DealGroupSchema>(summaries.filter(s => s.group).map(summary => summary.group) as DealGroupSchema[]);
if (groups.length === 0) return [];
@@ -110,96 +101,89 @@ export const Board: FC<Props> = ({
};
const renderDeal = (deal: DealSummary) => {
return (<Draggable
draggableId={deal.id.toString()}
index={deal.rank}
key={deal.id}>
{(provided, snapshot) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}>
return (
<Draggable
draggableId={deal.id.toString()}
index={deal.rank}
key={deal.id}>
{(provided, snapshot) => (
<div
style={{
transition: "transform 0.1s ease-in-out",
transform: snapshot.combineWith ? "scale(0.85)" : "none",
color: snapshot.combineWith ? "red" : "black",
}}
>
<DealSummaryCard
dealSummary={deal}
/>
</div>
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}>
<div
style={{
transition: "transform 0.1s ease-in-out",
transform: snapshot.combineWith ? "scale(0.85)" : "none",
color: snapshot.combineWith ? "red" : "black",
}}
>
<DealSummaryCard
dealSummary={deal}
/>
</div>
</div>
)}
</Draggable>);
</div>
)}
</Draggable>
);
};
const renderGroup = (obj: GroupWithDeals) => {
const { deals, group } = obj;
return (<Draggable
draggableId={"group-" + group.id}
index={deals[0].rank}
key={"group-" + group.id}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
>
<DealGroupView
deals={deals}
group={group}
/>
</div>
)}
</Draggable>);
};
return (
<div className={styles["container"]}>
<div className={styles["header"]}>
<Title size={"h4"}>{title}</Title>
<Text>{getDealsText()}</Text>
<Divider
size={"xl"}
my={10}
color={color}
/>
</div>
<Droppable
isCombineEnabled
droppableId={droppableId}>
{(provided, snapshot) => (
return (
<Draggable
draggableId={"group-" + group.id}
index={deals[0].rank}
key={"group-" + group.id}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
className={classNames(
styles["items-list"],
snapshot.isDraggingOver &&
!snapshot.draggingFromThisWith &&
styles["items-list-drag-over"],
)}
{...provided.droppableProps}>
{withCreateButton && (
<>
<CreateDealButton />
<CreateDealsFromFileButton />
</>
)}
{getDealsAndGroups().map(obj => {
if (isGroup(obj)) {
return renderGroup(obj);
}
return renderDeal(obj);
})}
{provided.placeholder}
>
<DealGroupView
deals={deals}
group={group}
/>
</div>
)}
</Droppable>
</div>
</Draggable>
);
};
return (
<Droppable
isDropDisabled={isDropDisabled}
isCombineEnabled
droppableId={droppableId}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
className={classNames(
styles["items-list"],
snapshot.isDraggingOver &&
!snapshot.draggingFromThisWith &&
styles["items-list-drag-over"],
)}
{...provided.droppableProps}>
{withCreateButton && (
<>
<CreateDealButton status={status}/>
<CreateDealsFromFileButton />
</>
)}
{getDealsAndGroups().map(obj => {
if (isGroup(obj)) {
return renderGroup(obj);
}
return renderDeal(obj);
})}
{provided.placeholder}
</div>
)}
</Droppable>
);
};
export default Board;
export default DealsDndColumn;

View File

@@ -0,0 +1,213 @@
import { modals } from "@mantine/modals";
import { Flex } from "@mantine/core";
import { DropResult } from "@hello-pangea/dnd";
import { useEffect, useState } from "react";
import { DealGroupService, DealService, DealSummary, DealSummaryReorderRequest } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
import { dateWithoutTimezone } from "../../../../../shared/lib/date.ts";
type Props = {
summariesRaw: DealSummary[];
refetchSummaries: () => void;
}
const useDealsDnd = ({
summariesRaw,
refetchSummaries,
}: Props) => {
const [summaries, setSummaries] = useState(summariesRaw);
useEffect(() => {
setSummaries(summariesRaw);
}, [summariesRaw]);
const recalculate = async (dealId: number) => {
return DealService.recalculateDealPrice({
requestBody: {
dealId: dealId,
},
}).then(({ ok, message }) => {
notifications.guess(ok, { message });
});
};
const onDelete = (dealId: number) => {
const summary = summaries.find(summary => summary.id == dealId);
if (!summary) return;
modals.openConfirmModal({
title: "Удаление сделки",
children: (
<Flex>
Вы действительно хотите удалить сделку {summary.name}?
</Flex>
),
onConfirm: () => {
DealService.deleteDeal({
requestBody: { dealId: dealId },
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
await refetchSummaries();
});
},
labels: {
confirm: "Удалить",
cancel: "Отмена",
},
});
};
const onSuccess = (dealId: number) => {
const summary = summaries.find(summary => summary.id == dealId);
if (!summary) return;
modals.openConfirmModal({
title: "Завершение сделки",
children: (
<Flex>
Вы действительно хотите завершить сделку {summary.name}?
</Flex>
),
onConfirm: () => {
DealService.completeDeal({
requestBody: { dealId: dealId },
}).then(async ({ ok, message }) => {
notifications.guess(ok, { message });
if (!ok) return;
await refetchSummaries();
});
},
labels: {
confirm: "Завершить",
cancel: "Отмена",
},
});
};
const onCombine = async (result: DropResult) => {
if (!result.combine) return;
const destination = result.combine.draggableId;
const source = result.draggableId;
if (!destination || !source) return;
const sourceId = parseInt(source);
if (destination.includes("group")) {
const groupId = parseInt(destination.split("-")[1]);
DealGroupService.addDeal({
requestBody: {
dealId: sourceId,
groupId: groupId,
},
}).then(async response => {
if (!response.ok) {
notifications.error({ message: response.message });
return;
}
await refetchSummaries();
await recalculate(sourceId);
await refetchSummaries();
});
} else {
const destinationId = parseInt(destination);
// creating new group
DealGroupService.createDealGroup({
requestBody: {
draggingDealId: sourceId,
hoveredDealId: destinationId,
},
}).then(async response => {
if (!response.ok) {
notifications.error({ message: response.message });
return;
}
await refetchSummaries();
await recalculate(sourceId);
await refetchSummaries();
});
return;
}
};
const moveGroup = async (result: DropResult) => {
const groupId = parseInt(result.draggableId.split("-")[1]);
const destination = result.destination?.droppableId;
if (!destination) return;
const statusId = parseInt(destination);
DealGroupService.changeStatus({
requestBody: {
groupId: groupId,
newStatus: statusId,
},
}).then(async response => {
if (!response.ok) {
notifications.error({ message: response.message });
return;
}
await refetchSummaries();
});
};
const onDealDragEnd = async (result: DropResult) => {
if (result.combine) {
return onCombine(result);
}
// If there is no changes
if (!result.destination || result.destination == result.source) return;
// Checking for valid dealId
if (result.draggableId.includes("group")) {
return moveGroup(result);
}
const dealId = parseInt(result.draggableId);
if (isNaN(dealId)) return;
// Checking for valid deal
const summary = summaries.find(summary => summary.id == dealId);
if (!summary) return;
// Checking if it is custom actions
const droppableId = result.destination.droppableId;
if (droppableId === "DELETE") {
onDelete(dealId);
return;
}
if (droppableId === "SUCCESS") {
onSuccess(dealId);
return;
}
const statusId = Number.parseInt(droppableId);
const request: Partial<DealSummaryReorderRequest> = {
dealId,
index: result.destination.index,
statusId,
};
if (statusId == summary.status.id) {
DealService.reorderDealSummaries({
requestBody: request as DealSummaryReorderRequest,
}).then(async response => {
setSummaries(response.summaries);
await refetchSummaries();
});
return;
}
DealService.reorderDealSummaries({
requestBody: {
dealId,
statusId,
index: result.destination.index,
comment: "",
deadline: dateWithoutTimezone(new Date()),
},
}).then(async response => {
setSummaries(response.summaries);
await refetchSummaries();
});
};
return {
summaries,
onDealDragEnd,
};
};
export default useDealsDnd;

View File

@@ -0,0 +1,20 @@
const colors = [
"#4A90E2",
"#D3D3D3",
"#F5A623",
"#7ED321",
"#6A0DAD",
"#D0021B",
"#417505",
];
const getColumnColor = (colIndex: number) => {
if (colIndex < 0) return "";
if (colIndex >= colors.length) {
colIndex %= colors.length;
}
return colors[colIndex];
}
export default getColumnColor;

View File

@@ -0,0 +1,66 @@
import { Flex, rem } from "@mantine/core";
import classNames from "classnames";
import styles from "../../../../pages/DealsPage/ui/DealsPage.module.css";
import { Droppable } from "@hello-pangea/dnd";
import DragState from "../../../../pages/DealsPage/enums/DragState.ts";
type Props = {
dragState: DragState;
}
const DealsDndFooter = ({ dragState }: Props) => {
const isDealDragEnded = dragState === DragState.DRAG_ENDED;
return (
<Flex
justify={"space-between"}
gap={rem(10)}>
<div
className={classNames(
styles["delete"],
isDealDragEnded && styles["delete-hidden"],
)}>
<Droppable droppableId={"DELETE"}>
{(provided, snapshot) => (
<>
<div
{...provided.droppableProps}
ref={provided.innerRef}>
{!isDealDragEnded &&
!snapshot.isDraggingOver && (
<span>Удалить</span>
)}
</div>
{provided.placeholder}
</>
)}
</Droppable>
</div>
<div
className={classNames(
styles["delete"],
isDealDragEnded && styles["delete-hidden"],
)}>
<Droppable droppableId={"SUCCESS"}>
{(provided, snapshot) => (
<>
<div
{...provided.droppableProps}
ref={provided.innerRef}>
{!isDealDragEnded &&
!snapshot.isDraggingOver && (
<span>
Успешно завершена
</span>
)}
</div>
{provided.placeholder}
</>
)}
</Droppable>
</div>
</Flex>
);
};
export default DealsDndFooter;

View File

@@ -0,0 +1,104 @@
import styles from "../../../../pages/DealsPage/ui/DealsPage.module.css";
import { Divider, Text, Title } from "@mantine/core";
import getColumnColor from "../../Deals/DealsDndColumn/utils/getColumnColor.ts";
import { DealSummary, StatusSchema } from "../../../../client";
import { getPluralForm } from "../../../../shared/lib/utils.ts";
import { sum } from "lodash";
import { Draggable, Droppable } from "@hello-pangea/dnd";
import DragState from "../../../../pages/DealsPage/enums/DragState.ts";
import { useContextMenu } from "mantine-contextmenu";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import useStatus from "./hooks/useStatus.tsx";
type Props = {
status: StatusSchema;
index: number;
summaries: DealSummary[];
dragState: DragState;
refetch: () => void;
}
const Status = ({ summaries, status, dragState, index, refetch }: Props) => {
const isDropDisabled = dragState === DragState.DRAG_DEAL;
const {
onEditStatusClick,
onDeleteStatusClick,
} = useStatus({ refetch });
const getDealsText = () => {
const pluralForm = getPluralForm(
summaries.length,
"сделка",
"сделки",
"сделок",
);
return `${summaries.length} ${pluralForm}: ${sum(summaries.map(summary => summary.totalPrice)).toLocaleString("ru-RU")}`;
};
const { showContextMenu } = useContextMenu();
const contextMenu = () => showContextMenu([
{
key: "complete",
onClick: () => onEditStatusClick(status),
title: "Переименовать",
icon: <IconEdit />,
},
{
key: "delete",
onClick: () => onDeleteStatusClick(status),
title: "Удалить",
icon: <IconTrash />,
},
]);
return (
<Droppable
isDropDisabled={isDropDisabled}
droppableId={"status-" + status.id.toString()}
direction={"horizontal"}
>
{provided => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
>
<Draggable
draggableId={"status-" + status.id.toString()}
index={status.ordinalNumber}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
className={styles["header-statuses"]}
onContextMenu={contextMenu()}
>
<Title
style={{ textAlign: "center" }}
size={"h4"}>
{status.name}
</Title>
<Text style={{ textAlign: "center", textWrap: "nowrap" }}>
{getDealsText()}
</Text>
<Divider
size={"xl"}
my={10}
color={getColumnColor(index)}
/>
</div>
)}
</Draggable>
{provided.placeholder}
</div>
)}
</Droppable>
);
};
export default Status;

View File

@@ -0,0 +1,57 @@
import { BoardSchema, StatusSchema, StatusService } from "../../../../../client";
import { modals } from "@mantine/modals";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type Props = {
refetch: () => void;
}
const useStatus = ({ refetch }: Props) => {
const onEditStatusClick = (status: StatusSchema) => {
modals.openContextModal({
modal: "statusModal",
title: "Редактирование статуса",
withCloseButton: false,
innerProps: {
status,
refetch,
},
});
};
const onCreateStatusClick = (board: BoardSchema) => {
modals.openContextModal({
modal: "statusModal",
title: "Добавление статуса",
withCloseButton: false,
innerProps: {
boardId: board.id,
refetch,
},
});
};
const onDeleteStatusClick = (status: StatusSchema) => {
if (!status) return;
StatusService.deleteStatus({
statusId: status.id,
})
.then(({ ok, message }) => {
if (!ok) {
notifications.error({ message });
return;
}
refetch();
})
.catch(err => console.log(err));
};
return {
onEditStatusClick,
onCreateStatusClick,
onDeleteStatusClick,
};
};
export default useStatus;

View File

@@ -0,0 +1,86 @@
import { BoardSchema, DealSummary, StatusSchema } from "../../../../client";
import DealsDndColumn from "../../Deals/DealsDndColumn/DealsDndColumn.tsx";
import styles from "../../../../pages/DealsPage/ui/DealsPage.module.css";
import DealsDndFooter from "../../Deals/DealsDndFooter/DealsDndFooter.tsx";
import { Flex, rem, Stack } from "@mantine/core";
import { DragDropContext } from "@hello-pangea/dnd";
import useDnd from "../../../../pages/DealsPage/hooks/useDnd.tsx";
import Status from "../Status/Status.tsx";
type Props = {
selectedBoard: BoardSchema | null;
summariesRaw: DealSummary[];
refetchSummaries: () => void;
refetchBoards: () => void;
}
const Statuses = ({
selectedBoard,
summariesRaw,
refetchSummaries,
refetchBoards,
}: Props) => {
const {
summaries,
dragState,
onDragStart,
onDragEnd,
} = useDnd({
selectedBoard,
summariesRaw,
refetchSummaries,
refetchBoards,
});
const statusDndColumn = (status: StatusSchema, index: number) => {
const filteredSummaries = summaries.filter(
summary => summary.status.id == status.id,
);
return (
<Stack key={status.id} gap={0} flex={1} maw={rem(250)}>
<Status
summaries={filteredSummaries}
index={index}
status={status}
dragState={dragState}
refetch={refetchBoards}
/>
<DealsDndColumn
withCreateButton={index === 0}
summaries={filteredSummaries}
status={status}
dragState={dragState}
/>
</Stack>
);
};
const statuses = selectedBoard?.dealStatuses
.filter(status => !status.isDeleted)
.sort((a, b) => a.ordinalNumber - b.ordinalNumber) ?? [];
return (
<DragDropContext
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<Flex
justify={"space-between"}
direction={"column"}
>
<Flex className={styles["statuses"]}>
{selectedBoard &&
statuses.map(((status: StatusSchema, index: number) => {
return statusDndColumn(status, index);
}))
}
</Flex>
<DealsDndFooter dragState={dragState} />
</Flex>
</DragDropContext>
);
};
export default Statuses;

View File

@@ -0,0 +1,50 @@
import { DropResult } from "@hello-pangea/dnd";
import { BoardSchema, StatusSchema, StatusService } from "../../../../../client";
import { notifications } from "../../../../../shared/lib/notifications.ts";
type Props = {
board: BoardSchema | null;
refetch: () => void;
}
const useStatusesDnd = ({ refetch, board }: Props) => {
const updateStatusOrder = (status: StatusSchema, newOrdinalNumber: number) => {
if (!board) return;
StatusService.updateStatusOrder({
requestBody: {
boardId: board.id,
statusId: status.id,
newOrdinalNumber,
},
})
.then(({ ok, message }) => {
if (!ok) notifications.error({ message });
refetch();
});
};
const onStatusDragEnd = async (result: DropResult) => {
if (!board) return;
// If there is no changes
if (!result.destination || result.destination == result.source) return;
// Checking for valid dealId
const statusIdStr = result.draggableId.replace("status-", "");
const statusId = parseInt(statusIdStr);
if (isNaN(statusId)) return;
// Checking for valid deal
const status = board.dealStatuses.find(board => board.id == statusId);
if (!status) return;
updateStatusOrder(status, result.destination.index);
};
return {
onStatusDragEnd,
};
};
export default useStatusesDnd;

View File

@@ -1,7 +1,7 @@
import { FC } from "react";
import ObjectSelect, { ObjectSelectProps } from "../ObjectSelect/ObjectSelect.tsx";
import { UserSchema } from "../../client";
import useManagersList from "../../pages/LeadsPage/hooks/useManagersList.tsx";
import useManagersList from "../../pages/DealsPage/hooks/useManagersList.tsx";
type Props = Omit<
ObjectSelectProps<UserSchema | null>,
@@ -15,6 +15,7 @@ const UserSelect: FC<Props> = props => {
getLabelFn={(manager: UserSchema) => `${manager.firstName} ${manager.secondName}`}
getValueFn={(manager: UserSchema) => manager.id.toString()}
clearable
searchable
{...props}
onClear={() => props.onChange(null)}
/>

View File

@@ -0,0 +1,43 @@
import ObjectSelect, { ObjectSelectProps } from "../ObjectSelect/ObjectSelect.tsx";
import { FC, useEffect, useState } from "react";
import { ProjectSchema, ProjectService } from "../../client";
type OtherProps = {
data?: ProjectSchema[];
}
type SelectProps = Omit<ObjectSelectProps<ProjectSchema | null>, "data" | "getLabelFn" | "getValueFn">;
type Props = OtherProps & SelectProps;
const ProjectSelect: FC<Props> = ({ data, ...props }) => {
const [projects, setProjects] = useState<ProjectSchema[]>(data ?? []);
const onClear = () => props.onChange(null);
const fetchProjects = () => {
ProjectService.getProjects()
.then(({ projects }) => {
setProjects(projects);
})
.catch(err => console.log(err));
};
useEffect(() => {
if (!data) {
fetchProjects();
}
}, []);
return (
<ObjectSelect
data={projects}
searchable
placeholder={"Выберите проект"}
onClear={onClear}
{...props}
/>
);
};
export default ProjectSelect;