feat: projects and boards
This commit is contained in:
54
src/components/BoardSelect/BoardSelect.tsx
Normal file
54
src/components/BoardSelect/BoardSelect.tsx
Normal 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;
|
||||
40
src/components/DealStatusSelect/DealStatusSelect.tsx
Normal file
40
src/components/DealStatusSelect/DealStatusSelect.tsx
Normal 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;
|
||||
37
src/components/Dnd/Boards/Board/Board.module.scss
Normal file
37
src/components/Dnd/Boards/Board/Board.module.scss
Normal 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);
|
||||
}
|
||||
97
src/components/Dnd/Boards/Board/Board.tsx
Normal file
97
src/components/Dnd/Boards/Board/Board.tsx
Normal 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;
|
||||
90
src/components/Dnd/Boards/Boards/Boards.tsx
Normal file
90
src/components/Dnd/Boards/Boards/Boards.tsx
Normal 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;
|
||||
83
src/components/Dnd/Boards/Boards/hooks/useBoards.tsx
Normal file
83
src/components/Dnd/Boards/Boards/hooks/useBoards.tsx
Normal 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;
|
||||
53
src/components/Dnd/Boards/Boards/hooks/useBoardsDnd.tsx
Normal file
53
src/components/Dnd/Boards/Boards/hooks/useBoardsDnd.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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) {
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
173
src/components/Dnd/Deals/DealSummaryCard/DealSummaryCard.tsx
Normal file
173
src/components/Dnd/Deals/DealSummaryCard/DealSummaryCard.tsx
Normal 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;
|
||||
@@ -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,
|
||||
},
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
213
src/components/Dnd/Deals/DealsDndColumn/hooks/useDealsDnd.tsx
Normal file
213
src/components/Dnd/Deals/DealsDndColumn/hooks/useDealsDnd.tsx
Normal 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;
|
||||
@@ -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;
|
||||
66
src/components/Dnd/Deals/DealsDndFooter/DealsDndFooter.tsx
Normal file
66
src/components/Dnd/Deals/DealsDndFooter/DealsDndFooter.tsx
Normal 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;
|
||||
104
src/components/Dnd/Statuses/Status/Status.tsx
Normal file
104
src/components/Dnd/Statuses/Status/Status.tsx
Normal 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;
|
||||
57
src/components/Dnd/Statuses/Status/hooks/useStatus.tsx
Normal file
57
src/components/Dnd/Statuses/Status/hooks/useStatus.tsx
Normal 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;
|
||||
86
src/components/Dnd/Statuses/Statuses/Statuses.tsx
Normal file
86
src/components/Dnd/Statuses/Statuses/Statuses.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
43
src/components/ProjectSelect/ProjectSelect.tsx
Normal file
43
src/components/ProjectSelect/ProjectSelect.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user