Compare commits

..

1 Commits

Author SHA1 Message Date
36c2a3a2af feat: funnel dnd using pragmatic, not finished groups 2025-10-16 15:26:53 +04:00
96 changed files with 3892 additions and 4530 deletions

View File

@ -11,6 +11,12 @@
"generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs" "generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs"
}, },
"dependencies": { "dependencies": {
"@atlaskit/avatar": "^25.4.2",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.7",
"@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.1",
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.7",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -27,6 +33,7 @@
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@types/react-dom": "19.1.2",
"axios": "1.12.0", "axios": "1.12.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@ -38,7 +45,6 @@
"i18n-iso-countries": "^7.14.0", "i18n-iso-countries": "^7.14.0",
"lexorank": "^1.0.5", "lexorank": "^1.0.5",
"libphonenumber-js": "^1.12.10", "libphonenumber-js": "^1.12.10",
"mantine-contextmenu": "^8.2.0",
"mantine-datatable": "^8.2.0", "mantine-datatable": "^8.2.0",
"next": "15.4.7", "next": "15.4.7",
"phone": "^3.1.67", "phone": "^3.1.67",

View File

@ -1,14 +1,12 @@
"use client"; "use client";
import { RefObject, useMemo, useRef } from "react"; import { useMemo } from "react";
import { IconTag } from "@tabler/icons-react";
import { SimpleGrid, Stack } from "@mantine/core"; import { SimpleGrid, Stack } from "@mantine/core";
import Action from "@/app/actions/components/Action/Action"; import Action from "@/app/actions/components/Action/Action";
import mobileButtonsData from "@/app/actions/data/mobileButtonsData"; import mobileButtonsData from "@/app/actions/data/mobileButtonsData";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext"; import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import PageBlock from "@/components/layout/PageBlock/PageBlock"; import PageBlock from "@/components/layout/PageBlock/PageBlock";
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect"; import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
import BuiltInLinkData from "@/types/BuiltInLinkData";
const PageBody = () => { const PageBody = () => {
const { selectedProject, setSelectedProjectId, projects, modulesSet } = const { selectedProject, setSelectedProjectId, projects, modulesSet } =
@ -22,14 +20,6 @@ const PageBody = () => {
[modulesSet] [modulesSet]
); );
const commonActionsData: RefObject<BuiltInLinkData[]> = useRef([
{
icon: IconTag,
label: "Теги",
href: "/tags",
},
]);
return ( return (
<PageBlock fullScreenMobile> <PageBlock fullScreenMobile>
<Stack p={"xs"}> <Stack p={"xs"}>
@ -43,10 +33,7 @@ const PageBody = () => {
<SimpleGrid <SimpleGrid
type={"container"} type={"container"}
cols={2}> cols={2}>
{[ {filteredMobileButtonsData.map((data, index) => (
...commonActionsData.current,
...filteredMobileButtonsData,
].map((data, index) => (
<Action <Action
linkData={data} linkData={data}
key={index} key={index}

View File

@ -1,41 +0,0 @@
import React, { FC } from "react";
import { IconCheckbox, IconDotsVertical, IconTrash } from "@tabler/icons-react";
import { Box, Menu } from "@mantine/core";
import DropdownMenuItem from "@/components/ui/DropdownMenuItem/DropdownMenuItem";
import ThemeIcon from "@/components/ui/ThemeIcon/ThemeIcon";
type Props = {
onDelete: () => void;
startDealsSelecting: () => void;
};
const GroupMenu: FC<Props> = ({ onDelete, startDealsSelecting }) => {
return (
<Menu>
<Menu.Target>
<Box
px={"md"}
style={{ cursor: "pointer" }}
onClick={e => e.stopPropagation()}>
<ThemeIcon size={"sm"}>
<IconDotsVertical />
</ThemeIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<DropdownMenuItem
onClick={onDelete}
icon={<IconTrash />}
label={"Удалить группу"}
/>
<DropdownMenuItem
onClick={startDealsSelecting}
icon={<IconCheckbox />}
label={"Добавить/удалить сделки"}
/>
</Menu.Dropdown>
</Menu>
);
};
export default GroupMenu;

View File

@ -1,13 +1,11 @@
.create-button { .create-button {
cursor: pointer; cursor: pointer;
min-height: max-content; min-height: max-content;
border: 1px dashed;
@mixin light { @mixin light {
background-color: var(--color-light-white-blue); background-color: var(--color-light-white-blue);
border-color: lightblue;
} }
@mixin dark { @mixin dark {
background-color: var(--mantine-color-dark-7); background-color: var(--mantine-color-dark-7);
border-color: var(--mantine-color-dark-5);
} }
} }

View File

@ -1,47 +1,12 @@
.container { .container {
flex: 1;
padding: 0; padding: 0;
border: 1px dashed;
@mixin light { @mixin light {
background-color: var(--color-light-white-blue); background-color: var(--color-light-white-blue);
border-color: lightblue;
} }
@mixin dark { @mixin dark {
background-color: var(--mantine-color-dark-7); background-color: var(--mantine-color-dark-7);
border-color: var(--mantine-color-dark-5);
}
}
.container-selected {
border: 2px dashed !important;
@mixin light {
border-color: dodgerblue !important;
}
@mixin dark {
border-color: dodgerblue !important;
}
}
.container-mainly-selected {
border: 2px solid;
@mixin light {
border-color: dodgerblue !important;
}
@mixin dark {
border-color: dodgerblue !important;
}
}
.container-in-group {
padding: 0;
border: 1px dashed;
@mixin light {
background-color: var(--color-light-aqua);
border-color: lightblue;
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
border-color: var(--mantine-color-dark-5);
} }
} }

View File

@ -1,33 +1,21 @@
import { IconCategoryPlus } from "@tabler/icons-react"; import { Box, Card, Group, Pill, Stack, Text } from "@mantine/core";
import classNames from "classnames";
import { useContextMenu } from "mantine-contextmenu";
import { Box, Card, Group, Stack, Text } from "@mantine/core";
import { useDealsContext } from "@/app/deals/contexts/DealsContext"; import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext"; import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { useDrawersContext } from "@/drawers/DrawersContext"; import { useDrawersContext } from "@/drawers/DrawersContext";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema } from "@/lib/client"; import { DealSchema } from "@/lib/client";
import { ModuleNames } from "@/modules/modules"; import { ModuleNames } from "@/modules/modules";
import styles from "./DealCard.module.css"; import styles from "./DealCard.module.css";
import DealTags from "@/components/ui/DealTags/DealTags";
type Props = { type Props = {
deal: DealSchema; deal: DealSchema;
isInGroup?: boolean;
}; };
const DealCard = ({ deal, isInGroup = false }: Props) => { const DealCard = ({ deal }: Props) => {
const { selectedProject, modulesSet } = useProjectsContext(); const { selectedProject, modulesSet } = useProjectsContext();
const { dealsCrud, refetchDeals, groupDealsSelection } = useDealsContext(); const { dealsCrud, refetchDeals } = useDealsContext();
const { openDrawer } = useDrawersContext(); const { openDrawer } = useDrawersContext();
const isMobile = useIsMobile();
const onClick = () => { const onClick = () => {
if (groupDealsSelection.isDealsSelecting) {
groupDealsSelection.toggleDeal(deal);
return;
}
openDrawer({ openDrawer({
key: "dealEditorDrawer", key: "dealEditorDrawer",
props: { props: {
@ -40,38 +28,10 @@ const DealCard = ({ deal, isInGroup = false }: Props) => {
}); });
}; };
const { showContextMenu } = useContextMenu();
const dealContextMenu =
deal.group || isMobile
? []
: [
{
key: "startGroupForming",
onClick: () =>
groupDealsSelection.startSelectingWithDeal(deal.id),
title: "Создать группу",
icon: <IconCategoryPlus />,
},
];
const getSelectedStyles = () => {
if (groupDealsSelection.selectedBaseDealId === deal.id) {
return styles["container-mainly-selected"];
}
if (groupDealsSelection.selectedDealIds.has(deal.id)) {
return styles["container-selected"];
}
};
return ( return (
<Card <Card
onClick={onClick} onClick={onClick}
className={classNames( className={styles.container}>
getSelectedStyles(),
isInGroup ? styles["container-in-group"] : styles.container
)}
onContextMenu={showContextMenu(dealContextMenu)}>
<Group <Group
justify={"space-between"} justify={"space-between"}
wrap={"nowrap"} wrap={"nowrap"}
@ -101,7 +61,10 @@ const DealCard = ({ deal, isInGroup = false }: Props) => {
</> </>
)} )}
</Stack> </Stack>
{!deal.group && <DealTags dealId={deal.id} tags={deal.tags} />} <Group gap={"xs"}>
<Pill className={styles["first-tag"]}>Срочно</Pill>
<Pill className={styles["second-tag"]}>Бесплатно</Pill>
</Group>
</Stack> </Stack>
</Card> </Card>
); );

View File

@ -0,0 +1,89 @@
import { FC, useEffect, useState } from "react";
import { IconGripHorizontal } from "@tabler/icons-react";
import { Flex, rem, TextInput, useMantineColorScheme } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import FulfillmentGroupInfo from "@/app/deals/components/shared/DealGroupCard/components/FulfillmentGroupInfo";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { notifications } from "@/lib/notifications";
import { ModuleNames } from "@/modules/modules";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type Props = {
group: GroupWithDealsSchema;
};
const DealGroupCard: FC<Props> = ({ group }) => {
const theme = useMantineColorScheme();
const [name, setName] = useState<string>(group.name ?? "");
const [debouncedName] = useDebouncedValue(name, 200);
const { modulesSet } = useProjectsContext();
const isServicesAndProductsIncluded = modulesSet.has(
ModuleNames.FULFILLMENT_BASE
);
const updateName = () => {
if (debouncedName === group.name) return;
CardGroupService.updateCardGroup({
requestBody: {
data: {
...group,
name: debouncedName,
},
},
}).then(response => {
if (response.ok) return;
setName(group.name || "");
notifications.guess(response.ok, { message: response.message });
});
};
useEffect(() => {
updateName();
}, [debouncedName]);
return (
<Flex
style={{
border: "dashed var(--item-border-size) var(--mantine-color-default-border)",
borderRadius: "0.5rem",
}}
p={rem(5)}
py={"xs"}
bg={
theme.colorScheme === "dark"
? "var(--mantine-color-dark-5)"
: "var(--mantine-color-gray-1)"
}
gap={"xs"}
direction={"column"}>
<Flex
justify={"space-between"}
align={"center"}
gap={"xs"}
px={"xs"}>
<TextInput
value={name}
onChange={event => setName(event.currentTarget.value)}
variant={"unstyled"}
/>
<IconGripHorizontal />
</Flex>
<Flex
direction={"column"}
gap={"xs"}>
{group.deals?.map(deal => (
<DealCard
key={deal.id}
deal={deal}
/>
))}
</Flex>
{isServicesAndProductsIncluded && (
<FulfillmentGroupInfo group={group} />
)}
</Flex>
);
};
export default DealGroupCard;

View File

@ -0,0 +1,51 @@
import { Flex, Text, useMantineColorScheme } from "@mantine/core";
import { FC, useMemo } from "react";
import { DealGroupSchema } from "@/lib/client";
type Props = {
group: DealGroupSchema;
}
const FulfillmentGroupInfo: FC<Props> = ({ group }) => {
const theme = useMantineColorScheme();
const totalPrice = useMemo(
() =>
group.deals?.reduce((acc, deal) => acc + (deal.totalPrice ?? 0), 0),
[group.deals]
);
const totalProducts = useMemo(
() =>
group.deals?.reduce(
(acc, deal) => acc + (deal.productsQuantity ?? 0),
0
),
[group.deals]
);
return (
<Flex
p={"xs"}
direction={"column"}
bg={
theme.colorScheme === "dark"
? "var(--mantine-color-dark-6)"
: "var(--mantine-color-gray-2)"
}
style={{ borderRadius: "0.5rem" }}>
<Text
c={"gray.6"}
size={"xs"}>
Сумма: {totalPrice?.toLocaleString("ru-RU")} руб.
</Text>
<Text
c={"gray.6"}
size={"xs"}>
Всего товаров: {totalProducts?.toLocaleString("ru-RU")}{" "}
шт.
</Text>
</Flex>
)
}
export default FulfillmentGroupInfo;

View File

@ -1,22 +0,0 @@
.group-container {
border: 1px dashed;
@mixin light {
background-color: var(--color-light-white-blue);
border-color: lightblue;
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
border-color: var(--mantine-color-dark-5);
}
}
.selected-group {
border: 2px solid;
@mixin light {
border-color: dodgerblue;
}
@mixin dark {
border-color: dodgerblue;
}
}

View File

@ -1,101 +0,0 @@
import React, { FC, useEffect, useState } from "react";
import { IconCheckbox, IconTrash } from "@tabler/icons-react";
import classNames from "classnames";
import { useContextMenu } from "mantine-contextmenu";
import { Flex, Stack, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import GroupMenu from "@/app/deals/components/mobile/GroupMenu/GroupMenu";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import DealTags from "@/components/ui/DealTags/DealTags";
import useIsMobile from "@/hooks/utils/useIsMobile";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
import styles from "./DealsGroup.module.css";
type Props = {
group: GroupWithDealsSchema;
};
const DealsGroup: FC<Props> = ({ group }) => {
const [groupName, setGroupName] = useState(group.name ?? "");
const [debouncedGroupName] = useDebouncedValue(groupName, 600);
const {
groupsCrud,
groupDealsSelection: {
startSelectingWithExistingGroup,
selectedGroupId,
},
} = useDealsContext();
const { showContextMenu } = useContextMenu();
const isMobile = useIsMobile();
useEffect(() => {
if (debouncedGroupName === group.name) return;
groupsCrud.onUpdate(group.id, { name: debouncedGroupName });
}, [debouncedGroupName]);
const dealContextMenu = isMobile
? []
: [
{
key: "delete",
onClick: () => groupsCrud.onDelete(group.id),
title: "Удалить группу",
icon: <IconTrash />,
},
{
key: "startDealsSelecting",
onClick: () => startSelectingWithExistingGroup(group),
title: "Добавить/удалить сделки",
icon: <IconCheckbox />,
},
];
return (
<Stack
className={classNames(
styles["group-container"],
selectedGroupId === group.id && styles["selected-group"]
)}
gap={"xs"}
bdrs={"lg"}
p={"xs"}
onContextMenu={showContextMenu(dealContextMenu)}>
<Flex
mx={"xs"}
align={"center"}
w={"100%"}>
<TextInput
value={groupName}
onChange={e => setGroupName(e.target.value)}
variant={"unstyled"}
onKeyDown={e => e.stopPropagation()}
flex={1}
/>
{isMobile && (
<GroupMenu
startDealsSelecting={() =>
startSelectingWithExistingGroup(group)
}
onDelete={() => groupsCrud.onDelete(group.id)}
/>
)}
</Flex>
{group.items.map(deal => (
<DealCard
deal={deal}
isInGroup
key={deal.id}
/>
))}
{group.items.length > 0 && (
<DealTags
groupId={group.id}
tags={group.items[0].tags}
/>
)}
</Stack>
);
};
export default DealsGroup;

View File

@ -1,92 +1,67 @@
"use client"; "use client";
import React, { FC, ReactNode } from "react"; import React, { FC } from "react";
import { Box } from "@mantine/core";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard"; import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import DealsGroup from "@/app/deals/components/shared/DealsGroup/DealsGroup";
import StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader"; import StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader";
import StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useDealsContext } from "@/app/deals/contexts/DealsContext"; import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd"; import DndFunnel from "@/components/dnd-pragmatic/DndFunnel/DndFunnel";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
import { sortByLexorank } from "@/utils/lexorank/sort"; import { sortByLexorank } from "@/utils/lexorank/sort";
const Funnel: FC = () => { const Funnel: FC = () => {
const { selectedBoard } = useBoardsContext(); const { statuses, setStatuses, statusesCrud } = useStatusesContext();
const { dealsWithoutGroup, groupsWithDeals } = useDealsContext(); const { dealsWithoutGroup, groupsWithDeals, deals, setDeals, dealsCrud } =
const isMobile = useIsMobile(); useDealsContext();
const { sortedStatuses, handleDragOver, handleDragEnd, swiperRef } = const updateStatus = (statusId: number, lexorank: string) => {
useDealsAndStatusesDnd(); setStatuses(
statuses.map(status =>
status.id === statusId ? { ...status, lexorank } : status
)
);
statusesCrud.onUpdate(statusId, { lexorank });
};
const updateDeal = (dealId: number, lexorank: string, statusId: number) => {
const status = statuses.find(s => s.id === statusId);
if (!status) return;
setDeals(
deals.map(deal =>
deal.id === dealId ? { ...deal, lexorank, status } : deal
)
);
dealsCrud.onUpdate(dealId, { lexorank, statusId });
};
return ( return (
<FunnelDnd<StatusSchema, DealSchema, GroupWithDealsSchema> <DndFunnel
containers={sortedStatuses} columns={statuses}
itemsAndGroups={sortByLexorank([ updateColumn={updateStatus}
...dealsWithoutGroup, items={dealsWithoutGroup}
...groupsWithDeals, groups={groupsWithDeals}
])} updateItem={updateDeal}
onDragOver={handleDragOver} getColumnItemsGroups={statusId =>
onDragEnd={handleDragEnd}
swiperRef={swiperRef}
getItemsByContainer={(status: StatusSchema) =>
sortByLexorank([ sortByLexorank([
...dealsWithoutGroup.filter( ...dealsWithoutGroup.filter(d => d.status.id === statusId),
deal => deal.status.id === status.id
),
...groupsWithDeals.filter( ...groupsWithDeals.filter(
group => group.items[0].status.id === status.id g =>
g.items.length > 0 &&
g.items[0].status.id === statusId
), ),
]) ])
} }
renderContainer={( renderColumnHeader={status => (
status: StatusSchema, <StatusColumnHeader status={status} />
funnelColumnComponent: ReactNode,
renderDraggable,
index
) => (
<StatusColumnWrapper
status={status}
renderHeader={renderDraggable}
createFormEnabled={index === 0}>
{funnelColumnComponent}
</StatusColumnWrapper>
)} )}
renderContainerHeader={status => ( renderItem={deal => (
<StatusColumnHeader
status={status}
isDragging={false}
/>
)}
renderItem={(deal: DealSchema) => (
<DealCard <DealCard
key={deal.id} key={deal.id}
deal={deal} deal={deal}
/> />
)} )}
renderGroup={(group: GroupWithDealsSchema) => ( renderGroup={group => <Box flex={1}>{group.name}</Box>}
<DealsGroup
key={`${group.id}group`}
group={group}
/>
)}
renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper
status={status}
renderHeader={() => (
<StatusColumnHeader
status={status}
isDragging
/>
)}>
{children}
</StatusColumnWrapper>
)}
disabledColumns={isMobile}
isCreatingContainerEnabled={!!selectedBoard}
/> />
); );
}; };

View File

@ -1,9 +0,0 @@
.shadow {
@mixin light {
box-shadow: var(--light-shadow);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
box-shadow: var(--dark-shadow);
}
}

View File

@ -1,52 +0,0 @@
"use client";
import { Affix, Flex, Stack, Title, Transition } from "@mantine/core";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import styles from "./GroupDealsSelectionAffix.module.css";
const GroupDealsSelectionAffix = () => {
const {
groupDealsSelection: {
finishDealsSelecting,
cancelDealsSelecting,
isDealsSelecting,
},
} = useDealsContext();
return (
<Affix position={{ bottom: 35, right: 35 }}>
<Transition
transition="slide-up"
mounted={isDealsSelecting}>
{transitionStyles => (
<Stack
bdrs={"xl"}
bd={"1px solid var(--mantine-color-default-border"}
className={styles.shadow}
p={"md"}
gap={"md"}
style={transitionStyles}>
<Title
order={5}
ta={"center"}>
Выбор сделок для группы
</Title>
<Flex gap={"xs"}>
<InlineButton onClick={cancelDealsSelecting}>
Отмена
</InlineButton>
<InlineButton
variant={"filled"}
onClick={finishDealsSelecting}>
Сохранить
</InlineButton>
</Flex>
</Stack>
)}
</Transition>
</Affix>
);
};
export default GroupDealsSelectionAffix;

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { Box } from "@mantine/core"; import { Flex } from "@mantine/core";
import TopToolPanel from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel"; import TopToolPanel from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel";
import { import {
BoardView, BoardView,
@ -46,7 +46,11 @@ const PageBody = () => {
<PageBlock <PageBlock
fullScreenMobile fullScreenMobile
style={{ flex: 1 }}> style={{ flex: 1 }}>
<Box h={"100%"}>{getViewContent()}</Box> <Flex
direction={"column"}
h={"100%"}>
{getViewContent()}
</Flex>
</PageBlock> </PageBlock>
</DealsContextProvider> </DealsContextProvider>
); );

View File

@ -2,20 +2,17 @@ import React, { FC } from "react";
import { Group, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu"; import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput"; import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { StatusSchema } from "@/lib/client"; import { StatusSchema } from "@/lib/client";
type Props = { type Props = {
status: StatusSchema; status: StatusSchema;
isDragging: boolean;
}; };
const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => { const StatusColumnHeader: FC<Props> = ({ status }) => {
const { statusesCrud, refetchStatuses } = useStatusesContext(); const { statusesCrud, refetchStatuses } = useStatusesContext();
const { selectedBoard } = useBoardsContext(); const { selectedBoard } = useBoardsContext();
const { groupDealsSelection } = useDealsContext();
const handleSave = (value: string) => { const handleSave = (value: string) => {
const newValue = value.trim(); const newValue = value.trim();
@ -30,6 +27,7 @@ const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
p={"sm"} p={"sm"}
wrap={"nowrap"} wrap={"nowrap"}
mb={"xs"} mb={"xs"}
w={"100%"}
style={{ style={{
borderBottom: `solid ${status.color} 3px`, borderBottom: `solid ${status.color} 3px`,
}}> }}>
@ -44,14 +42,7 @@ const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
}} }}
getChildren={startEditing => ( getChildren={startEditing => (
<> <>
<Text <Text>{status.name}</Text>
style={{
cursor: "grab",
userSelect: "none",
opacity: isDragging ? 0.5 : 1,
}}>
{status.name}
</Text>
<StatusMenu <StatusMenu
board={selectedBoard} board={selectedBoard}
status={status} status={status}
@ -61,9 +52,6 @@ const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
} }
refetchStatuses={refetchStatuses} refetchStatuses={refetchStatuses}
onDeleteStatus={statusesCrud.onDelete} onDeleteStatus={statusesCrud.onDelete}
startDealsSelecting={
groupDealsSelection.startSelecting
}
/> />
</> </>
)} )}

View File

@ -1,6 +1,5 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { import {
IconCheckbox,
IconDotsVertical, IconDotsVertical,
IconEdit, IconEdit,
IconExchange, IconExchange,
@ -22,7 +21,6 @@ type Props = {
onStatusColorChange: (color: string) => void; onStatusColorChange: (color: string) => void;
board: BoardSchema | null; board: BoardSchema | null;
onDeleteStatus: (status: StatusSchema) => void; onDeleteStatus: (status: StatusSchema) => void;
startDealsSelecting?: () => void;
refetchStatuses?: () => void; refetchStatuses?: () => void;
withChangeOrderButton?: boolean; withChangeOrderButton?: boolean;
}; };
@ -33,7 +31,6 @@ const StatusMenu: FC<Props> = ({
onStatusColorChange, onStatusColorChange,
board, board,
onDeleteStatus, onDeleteStatus,
startDealsSelecting,
refetchStatuses, refetchStatuses,
withChangeOrderButton = true, withChangeOrderButton = true,
}) => { }) => {
@ -99,13 +96,6 @@ const StatusMenu: FC<Props> = ({
label={"Изменить порядок"} label={"Изменить порядок"}
/> />
)} )}
{isMobile && startDealsSelecting && (
<DropdownMenuItem
onClick={startDealsSelecting}
icon={<IconCheckbox />}
label={"Создать группу сделок"}
/>
)}
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
); );

View File

@ -1,13 +1,11 @@
import { Space } from "@mantine/core"; import { Space } from "@mantine/core";
import MainBlockHeader from "@/app/deals/components/mobile/MainBlockHeader/MainBlockHeader"; import MainBlockHeader from "@/app/deals/components/mobile/MainBlockHeader/MainBlockHeader";
import Funnel from "@/app/deals/components/shared/Funnel/Funnel"; import Funnel from "@/app/deals/components/shared/Funnel/Funnel";
import GroupDealsSelectionAffix from "@/app/deals/components/shared/GroupDealsSelectionAffix/GroupDealsSelectionAffix";
export const BoardView = () => ( export const BoardView = () => (
<> <>
<MainBlockHeader /> <MainBlockHeader />
<Space h="md" /> <Space h="md" />
<Funnel /> <Funnel />
<GroupDealsSelectionAffix />
</> </>
); );

View File

@ -1,14 +1,10 @@
"use client"; "use client";
import { Dispatch, SetStateAction } from "react"; import React from "react";
import { UseFormReturnType } from "@mantine/form"; import { UseFormReturnType } from "@mantine/form";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useDealsAndGroups from "@/app/deals/hooks/useDealsAndGroups"; import useDealsAndGroups from "@/app/deals/hooks/useDealsAndGroups";
import { DealsFiltersForm } from "@/app/deals/hooks/useDealsFilters"; import { DealsFiltersForm } from "@/app/deals/hooks/useDealsFilters";
import useGroupDealsSelection, {
GroupDealsSelection,
} from "@/app/deals/hooks/useGroupDealsSelection";
import useDealGroupCrud, { GroupsCrud } from "@/hooks/cruds/useDealGroupCrud";
import { DealsCrud, useDealsCrud } from "@/hooks/cruds/useDealsCrud"; import { DealsCrud, useDealsCrud } from "@/hooks/cruds/useDealsCrud";
import useDealsList from "@/hooks/lists/useDealsList"; import useDealsList from "@/hooks/lists/useDealsList";
import { SortingForm } from "@/hooks/utils/useSorting"; import { SortingForm } from "@/hooks/utils/useSorting";
@ -23,14 +19,12 @@ type DealsContextState = {
groupsWithDeals: GroupWithDealsSchema[]; groupsWithDeals: GroupWithDealsSchema[];
refetchDeals: () => void; refetchDeals: () => void;
dealsCrud: DealsCrud; dealsCrud: DealsCrud;
groupsCrud: GroupsCrud;
paginationInfo?: PaginationInfoSchema; paginationInfo?: PaginationInfoSchema;
page: number; page: number;
setPage: Dispatch<SetStateAction<number>>; setPage: React.Dispatch<React.SetStateAction<number>>;
dealsFiltersForm: UseFormReturnType<DealsFiltersForm>; dealsFiltersForm: UseFormReturnType<DealsFiltersForm>;
isChangedFilters: boolean; isChangedFilters: boolean;
sortingForm: UseFormReturnType<SortingForm>; sortingForm: UseFormReturnType<SortingForm>;
groupDealsSelection: GroupDealsSelection;
}; };
type Props = { type Props = {
@ -58,20 +52,14 @@ const useDealsContextState = ({
statuses, statuses,
}); });
const groupsCrud = useDealGroupCrud();
const groupDealsSelection = useGroupDealsSelection({ groupsCrud });
const { dealsWithoutGroup, groupsWithDeals } = const { dealsWithoutGroup, groupsWithDeals } =
useDealsAndGroups(dealsListObjects); useDealsAndGroups(dealsListObjects);
return { return {
...dealsListObjects, ...dealsListObjects,
dealsCrud,
groupsCrud,
dealsWithoutGroup, dealsWithoutGroup,
groupsWithDeals, groupsWithDeals,
groupDealsSelection, dealsCrud,
}; };
}; };

View File

@ -2,10 +2,10 @@
import React, { FC, useState } from "react"; import React, { FC, useState } from "react";
import { Drawer } from "@mantine/core"; import { Drawer } from "@mantine/core";
import ProjectEditorBody from "@/app/deals/drawers/ProjectEditorDrawer/components/ProjectEditorBody";
import { DrawerProps } from "@/drawers/types"; import { DrawerProps } from "@/drawers/types";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { ProjectSchema } from "@/lib/client"; import { ProjectSchema } from "@/lib/client";
import ProjectEditorBody from "@/drawers/common/ProjectEditorDrawer/components/ProjectEditorBody";
type Props = { type Props = {
value: ProjectSchema; value: ProjectSchema;

View File

@ -1,11 +1,10 @@
import { FC } from "react"; import { FC } from "react";
import { IconBlocks, IconEdit, IconTags } from "@tabler/icons-react"; import { IconBlocks, IconEdit } from "@tabler/icons-react";
import { Tabs } from "@mantine/core"; import { Tabs } from "@mantine/core";
import { import {
GeneralTab, GeneralTab,
ModulesTab, ModulesTab,
} from "@/drawers/common/ProjectEditorDrawer/tabs"; } from "@/app/deals/drawers/ProjectEditorDrawer/tabs";
import TagsTab from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/TagsTab";
import { ProjectSchema } from "@/lib/client"; import { ProjectSchema } from "@/lib/client";
import styles from "../ProjectEditorDrawer.module.css"; import styles from "../ProjectEditorDrawer.module.css";
@ -31,21 +30,13 @@ const ProjectEditorBody: FC<Props> = props => {
leftSection={<IconBlocks />}> leftSection={<IconBlocks />}>
Модули Модули
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab
value={"tags"}
leftSection={<IconTags />}>
Теги
</Tabs.Tab>
</Tabs.List> </Tabs.List>
<Tabs.Panel value={"general"}> <Tabs.Panel value="general">
<GeneralTab {...props} /> <GeneralTab {...props} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value={"modules"}> <Tabs.Panel value="modules">
<ModulesTab {...props} /> <ModulesTab {...props} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value={"tags"}>
<TagsTab {...props} />
</Tabs.Panel>
</Tabs> </Tabs>
); );
}; };

View File

@ -0,0 +1,3 @@
import ProjectEditorDrawer from "@/app/deals/drawers/ProjectEditorDrawer/ProjectEditorDrawer";
export default ProjectEditorDrawer;

View File

@ -1,8 +1,8 @@
import { FC } from "react"; import { FC } from "react";
import { Stack, TextInput } from "@mantine/core"; import { Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import Footer from "@/app/deals/drawers/ProjectEditorDrawer/tabs/GeneralTab/components/Footer";
import { ProjectSchema } from "@/lib/client"; import { ProjectSchema } from "@/lib/client";
import Footer from "./components/Footer";
type Props = { type Props = {
value: ProjectSchema; value: ProjectSchema;

View File

@ -1,7 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { Button, Stack } from "@mantine/core"; import { Button, Stack } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import resolveDependencies from "@/drawers/common/ProjectEditorDrawer/tabs/ModulesTab/utils/resolveDependencies"; import resolveDependencies from "@/app/deals/drawers/ProjectEditorDrawer/tabs/ModulesTab/utils/resolveDependencies";
import { ProjectSchema } from "@/lib/client"; import { ProjectSchema } from "@/lib/client";
import ModulesTable from "./components/ModulesTable"; import ModulesTable from "./components/ModulesTable";

View File

@ -1,7 +1,7 @@
import { FC } from "react"; import { FC } from "react";
import { Divider, Stack } from "@mantine/core"; import { Divider, Stack } from "@mantine/core";
import useModulesTableColumns from "@/app/deals/drawers/ProjectEditorDrawer/tabs/ModulesTab/hooks/useModulesTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable"; import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useModulesTableColumns from "@/drawers/common/ProjectEditorDrawer/tabs/ModulesTab/hooks/useModulesTableColumns";
import useBuiltInModulesList from "@/hooks/lists/useBuiltInModulesList"; import useBuiltInModulesList from "@/hooks/lists/useBuiltInModulesList";
import { BuiltInModuleSchemaOutput } from "@/lib/client"; import { BuiltInModuleSchemaOutput } from "@/lib/client";

View File

@ -2,7 +2,6 @@ import React, { FC } from "react";
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu"; import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesMobileContext } from "@/app/deals/drawers/StatusesMobileEditorDrawer/contexts/BoardStatusesContext"; import { useStatusesMobileContext } from "@/app/deals/drawers/StatusesMobileEditorDrawer/contexts/BoardStatusesContext";
import { BoardSchema, StatusSchema } from "@/lib/client"; import { BoardSchema, StatusSchema } from "@/lib/client";
@ -13,7 +12,6 @@ type Props = {
const StatusMobile: FC<Props> = ({ status, board }) => { const StatusMobile: FC<Props> = ({ status, board }) => {
const { statusesCrud } = useStatusesMobileContext(); const { statusesCrud } = useStatusesMobileContext();
const { groupDealsSelection } = useDealsContext();
const startEditing = () => { const startEditing = () => {
modals.openContextModal({ modals.openContextModal({
@ -42,11 +40,8 @@ const StatusMobile: FC<Props> = ({ status, board }) => {
board={board} board={board}
onDeleteStatus={statusesCrud.onDelete} onDeleteStatus={statusesCrud.onDelete}
handleEdit={startEditing} handleEdit={startEditing}
onStatusColorChange={color => onStatusColorChange={color => statusesCrud.onUpdate(status.id, { color })}
statusesCrud.onUpdate(status.id, { color })
}
withChangeOrderButton={false} withChangeOrderButton={false}
startDealsSelecting={groupDealsSelection.startSelecting}
/> />
</Group> </Group>
); );

View File

@ -25,16 +25,15 @@ const useDealsAndGroups = ({ deals }: Props) => {
groupData.items.push(deal); groupData.items.push(deal);
groupsWithDealMap.set(deal.group.id, groupData); groupsWithDealMap.set(deal.group.id, groupData);
} else { } else {
groupsWithDealMap.set(deal.group.id, { groupsWithDealMap.set(deal.group.id, { ...deal.group, items: [] });
...deal.group,
items: [deal],
});
} }
} }
return sortByLexorank(groupsWithDealMap.values().toArray()); return sortByLexorank(groupsWithDealMap.values().toArray());
}, [deals]); }, [deals]);
console.log(groupsWithDeals);
return { return {
dealsWithoutGroup, dealsWithoutGroup,
groupsWithDeals, groupsWithDeals,

View File

@ -1,441 +0,0 @@
import { RefObject, useMemo, useRef } from "react";
import { DragOverEvent, Over } from "@dnd-kit/core";
import { SwiperRef } from "swiper/swiper-react";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
import isItemGroup from "@/app/deals/utils/isItemGroup";
import {
getContainerId,
isContainerId,
} from "@/components/dnd/FunnelDnd/utils/columnId";
import {
getGroupId,
isGroupId,
} from "@/components/dnd/FunnelDnd/utils/groupId";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank/sort";
type ReturnType = {
sortedStatuses: StatusSchema[];
handleDragOver: ({ active, over }: DragOverEvent) => void;
handleDragEnd: ({ active, over }: DragOverEvent) => void;
swiperRef: RefObject<SwiperRef | null>;
};
const useDealsAndStatusesDnd = (): ReturnType => {
const swiperRef = useRef<SwiperRef>(null);
const { statuses, setStatuses, statusesCrud } = useStatusesContext();
const {
deals,
dealsWithoutGroup,
groupsWithDeals,
setDeals,
dealsCrud,
groupsCrud,
} = useDealsContext();
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const isMobile = useIsMobile();
const {
getNewRankForSameStatus,
getNewRankForAnotherStatus,
getNewStatusRank,
} = useGetNewRank();
const debouncedSetStatuses = useDebouncedCallback(setStatuses, 200);
const debouncedSetDeals = useDebouncedCallback(setDeals, 200);
const getStatusByDealId = (dealId: number) => {
const deal = dealsWithoutGroup.find(deal => deal.id === dealId);
if (!deal) return;
return statuses.find(status => status.id === deal.status.id);
};
const getStatusByGroupId = (groupId: number) => {
const group = groupsWithDeals.find(group => group.id === groupId);
if (!group || group.items.length === 0) return;
return statuses.find(status => status.id === group.items[0].status.id);
};
const getStatusById = (statusId: number) => {
return statuses.find(status => status.id === statusId);
};
const getStatusDealsAndGroups = (statusId: number) =>
sortByLexorank([
...dealsWithoutGroup.filter(d => d.status.id === statusId),
...groupsWithDeals.filter(
g => g.items.length > 0 && g.items[0].status.id === statusId
),
]);
const swipeSliderDuringDrag = (activeId: number | string, over: Over) => {
let activeStatus: StatusSchema | undefined;
if (typeof activeId === "string") {
activeStatus = getStatusByGroupId(getGroupId(activeId));
} else {
activeStatus = getStatusByDealId(Number(activeId));
}
const swiperActiveStatus =
statuses[swiperRef.current?.swiper.activeIndex ?? 0];
if (swiperActiveStatus.id !== activeStatus?.id) return;
const activeStatusLexorank = activeStatus?.lexorank;
let overStatusLexorank: string | undefined;
if (typeof over.id === "string") {
if (isContainerId(over.id)) {
const overStatusId = getContainerId(over.id);
overStatusLexorank = statuses.find(
s => s.id === overStatusId
)?.lexorank;
} else {
const overGroupId = getGroupId(over.id);
overStatusLexorank = getStatusByGroupId(overGroupId)?.lexorank;
}
} else {
overStatusLexorank = getStatusByDealId(Number(over.id))?.lexorank;
}
if (
!activeStatusLexorank ||
!overStatusLexorank ||
!swiperRef.current?.swiper
)
return;
const activeIndex = sortedStatuses.findIndex(
s => s.lexorank === activeStatusLexorank
);
const overIndex = sortedStatuses.findIndex(
s => s.lexorank === overStatusLexorank
);
if (activeIndex > overIndex) {
swiperRef.current.swiper.slidePrev();
return;
}
if (activeIndex < overIndex) {
swiperRef.current.swiper.slideNext();
}
};
const handleDragOver = ({ active, over }: DragOverEvent) => {
if (!over) return;
const activeId = active.id as string | number;
if (isMobile && (typeof activeId !== "string" || isGroupId(activeId))) {
swipeSliderDuringDrag(activeId, over);
}
if (typeof activeId !== "string") {
handleDealDragOver(activeId, over);
return;
}
if (isContainerId(activeId)) {
handleColumnDragOver(activeId, over);
return;
}
handleGroupDragOver(activeId, over);
};
const handleDealDragOver = (activeId: string | number, over: Over) => {
const activeDealId = Number(activeId);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatus, newLexorank } = getDropTarget(
over.id,
activeDealId,
undefined,
activeStatusId
);
if (!overStatus) return;
debouncedSetDeals(
deals.map(deal =>
deal.id === activeDealId
? {
...deal,
status: overStatus,
lexorank: newLexorank || deal.lexorank,
}
: deal
)
);
};
const handleGroupDragOver = (activeId: string, over: Over) => {
const activeGroupId = getGroupId(activeId);
const activeStatusId = getStatusByGroupId(activeGroupId)?.id;
if (!activeStatusId) return;
const { overStatus, newLexorank } = getDropTarget(
over.id,
undefined,
activeGroupId,
activeStatusId
);
if (!overStatus) return;
debouncedSetDeals(
deals.map(deal =>
deal.group && deal.group.id === activeGroupId
? {
...deal,
status: overStatus,
group: {
...deal.group,
lexorank: newLexorank || deal.group.lexorank,
},
}
: deal
)
);
};
const handleColumnDragOver = (activeId: string, over: Over) => {
const activeStatusId = getContainerId(activeId);
let overStatusId: number;
if (typeof over.id === "string") {
if (isContainerId(over.id)) {
overStatusId = getContainerId(over.id);
} else {
const status = getStatusByGroupId(getGroupId(over.id));
if (!status) return;
overStatusId = status.id;
}
} else {
const deal = dealsWithoutGroup.find(deal => deal.id === over.id);
if (!deal) return;
overStatusId = deal.status.id;
}
if (!overStatusId || activeStatusId === overStatusId) return;
const newRank = getNewStatusRank(activeStatusId, overStatusId);
if (!newRank) return;
debouncedSetStatuses(
statuses.map(status =>
status.id === activeStatusId
? { ...status, lexorank: newRank }
: status
)
);
};
const getDropTarget = (
overId: string | number,
activeDealId: number | undefined,
activeGroupId: number | undefined,
activeStatusId: number,
isOnDragEnd: boolean = false
): { overStatus?: StatusSchema; newLexorank?: string } => {
if (typeof overId === "string") {
if (isContainerId(overId)) {
return getStatusDropTarget(overId);
}
if (isGroupId(overId)) {
return getGroupDropTarget(
overId,
activeGroupId,
activeStatusId,
isOnDragEnd
);
}
}
return getDealDropTarget(
Number(overId),
activeDealId,
activeStatusId,
isOnDragEnd
);
};
const getStatusDropTarget = (overId: string) => ({
overStatus: getStatusById(getContainerId(overId)),
newLexorank: undefined,
});
const getDealDropTarget = (
overId: number,
activeDealId: number | undefined,
activeStatusId: number,
isOnDragEnd: boolean = false
) => {
const overDealId = Number(overId);
const overStatus = getStatusByDealId(overDealId);
if (!overStatus || (!isOnDragEnd && activeDealId === overDealId)) {
return { overStatus: undefined, newLexorank: undefined };
}
const statusItems = getStatusDealsAndGroups(overStatus.id);
const overDealIndex = statusItems.findIndex(
deal => !isItemGroup(deal) && deal.id === overDealId
);
const activeDealIndex = statusItems.findIndex(
deal => !isItemGroup(deal) && deal.id === activeDealId
);
if (activeStatusId === overStatus.id) {
const newLexorank = getNewRankForSameStatus(
statusItems,
overDealIndex,
activeDealIndex
);
return { overStatus, newLexorank };
}
const newLexorank = getNewRankForAnotherStatus(
statusItems,
overDealIndex
);
return { overStatus, newLexorank };
};
const getGroupDropTarget = (
overId: string,
activeGroupId: number | undefined,
activeStatusId: number,
isOnDragEnd: boolean = false
) => {
const overGroupId = getGroupId(overId);
const overStatus = getStatusByGroupId(overGroupId);
if (!overStatus || (!isOnDragEnd && activeGroupId === overGroupId)) {
return { overStatus: undefined, newLexorank: undefined };
}
const statusItems = getStatusDealsAndGroups(overStatus.id);
const overGroupIndex = statusItems.findIndex(
group => isItemGroup(group) && group.id === overGroupId
);
const activeGroupIndex = statusItems.findIndex(
group => isItemGroup(group) && group.id === activeGroupId
);
if (activeStatusId === overStatus.id) {
const newLexorank = getNewRankForSameStatus(
statusItems,
overGroupIndex,
activeGroupIndex
);
return { overStatus, newLexorank };
}
const newLexorank = getNewRankForAnotherStatus(
statusItems,
overGroupIndex
);
return { overStatus, newLexorank };
};
const handleDragEnd = ({ active, over }: DragOverEvent) => {
if (!over) return;
const activeId: string | number = active.id;
if (typeof activeId !== "string") {
handleDealDragEnd(activeId, over);
return;
}
if (isContainerId(activeId)) {
handleStatusColumnDragEnd(activeId, over);
return;
}
handleGroupDragEnd(activeId, over);
};
const handleStatusColumnDragEnd = (activeId: string, over: Over) => {
const activeStatusId = getContainerId(activeId);
let overStatusId: number;
if (typeof over.id === "string" && isContainerId(over.id)) {
overStatusId = getContainerId(over.id);
} else {
const deal = dealsWithoutGroup.find(
deal => deal.status.id === over.id
);
if (!deal) return;
overStatusId = deal.status.id;
}
if (!overStatusId) return;
const newRank = getNewStatusRank(activeStatusId, overStatusId);
if (!newRank) return;
onStatusDragEnd?.(activeStatusId, newRank);
};
const onStatusDragEnd = (statusId: number, lexorank: string) => {
statusesCrud.onUpdate(statusId, { lexorank });
};
const handleDealDragEnd = (activeId: number | string, over: Over) => {
const activeDealId = Number(activeId);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatus, newLexorank } = getDropTarget(
over.id,
activeDealId,
undefined,
activeStatusId,
true
);
if (!overStatus) return;
onDealDragEnd(activeDealId, overStatus.id, newLexorank);
};
const onDealDragEnd = (
dealId: number,
statusId: number,
lexorank?: string
) => {
dealsCrud.onUpdate(dealId, { statusId, lexorank, name: null });
};
const handleGroupDragEnd = (activeId: string, over: Over) => {
const activeGroupId = getGroupId(activeId);
const activeStatusId = getStatusByGroupId(activeGroupId)?.id;
if (!activeStatusId) return;
const { overStatus, newLexorank } = getDropTarget(
over.id,
undefined,
activeGroupId,
activeStatusId,
true
);
if (!overStatus) return;
onGroupDragEnd(activeGroupId, overStatus.id, newLexorank);
};
const onGroupDragEnd = (
groupId: number,
statusId: number,
lexorank?: string
) => {
groupsCrud.onUpdate(groupId, { statusId, lexorank, name: null });
};
return {
swiperRef,
sortedStatuses,
handleDragOver,
handleDragEnd,
};
};
export default useDealsAndStatusesDnd;

View File

@ -1,112 +0,0 @@
import { LexoRank } from "lexorank";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import {
BaseDraggable,
BaseGroupDraggable,
} from "@/components/dnd/types/types";
import { getNewLexorank } from "@/utils/lexorank/generation";
import { sortByLexorank } from "@/utils/lexorank/sort";
type NewRankGetters<
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
> = {
getNewRankForSameStatus: (
statusItemsAndGroups: (TItem | TGroup)[],
overItemOrGroupIndex: number,
activeItemOrGroupIndex: number
) => string;
getNewRankForAnotherStatus: (
statusItemsAndGroups: (TItem | TGroup)[],
overItemOrGroupIndex: number
) => string;
getNewStatusRank: (
activeStatusId: number,
overStatusId: number
) => string | null;
};
const useGetNewRank = <
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
>(): NewRankGetters<TItem, TGroup> => {
const { statuses } = useStatusesContext();
const getNewRankForSameStatus = (
statusItemsAndGroups: (TItem | TGroup)[],
overItemOrGroupIndex: number,
activeItemOrGroupIndex: number
): string => {
const [leftIndex, rightIndex] =
overItemOrGroupIndex < activeItemOrGroupIndex
? [overItemOrGroupIndex - 1, overItemOrGroupIndex]
: [overItemOrGroupIndex, overItemOrGroupIndex + 1];
const leftLexorank =
leftIndex >= 0
? LexoRank.parse(statusItemsAndGroups[leftIndex].lexorank)
: null;
const rightLexorank =
rightIndex < statusItemsAndGroups.length
? LexoRank.parse(statusItemsAndGroups[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewRankForAnotherStatus = (
statusItemsAndGroups: (TItem | TGroup)[],
overItemOrGroupIndex: number
): string => {
const leftLexorank =
overItemOrGroupIndex > 0
? LexoRank.parse(
statusItemsAndGroups[overItemOrGroupIndex - 1].lexorank
)
: null;
const rightLexorank = LexoRank.parse(
statusItemsAndGroups[overItemOrGroupIndex].lexorank
);
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewStatusRank = (
activeStatusId: number,
overStatusId: number
): string | null => {
const sortedStatusList = sortByLexorank(statuses);
const overIndex = sortedStatusList.findIndex(
s => s.id === overStatusId
);
const activeIndex = sortedStatusList.findIndex(
s => s.id === activeStatusId
);
if (overIndex === -1 || activeIndex === -1) return null;
const [leftIndex, rightIndex] =
overIndex < activeIndex
? [overIndex - 1, overIndex]
: [overIndex, overIndex + 1];
const leftLexorank =
leftIndex >= 0
? LexoRank.parse(statuses[leftIndex].lexorank)
: null;
const rightLexorank =
rightIndex < statuses.length
? LexoRank.parse(statuses[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
return {
getNewRankForSameStatus,
getNewRankForAnotherStatus,
getNewStatusRank,
};
};
export default useGetNewRank;

View File

@ -1,114 +0,0 @@
import { Dispatch, SetStateAction, useState } from "react";
import { GroupsCrud } from "@/hooks/cruds/useDealGroupCrud";
import { DealSchema } from "@/lib/client";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type Props = {
groupsCrud: GroupsCrud;
};
export type GroupDealsSelection = {
isDealsSelecting: boolean;
selectedBaseDealId: number | null;
startSelectingWithDeal: (dealId: number) => void;
selectedGroupId: number | null;
startSelectingWithExistingGroup: (group: GroupWithDealsSchema) => void;
startSelecting: () => void;
selectedDealIds: Set<number>;
setSelectedDealIds: Dispatch<SetStateAction<Set<number>>>;
toggleDeal: (deal: DealSchema) => void;
finishDealsSelecting: () => void;
cancelDealsSelecting: () => void;
};
const useGroupDealsSelection = ({ groupsCrud }: Props): GroupDealsSelection => {
const [selectedDealIds, setSelectedDealIds] = useState<Set<number>>(
new Set()
);
const [isDealsSelecting, setIsDealsSelecting] = useState<boolean>(false);
const [selectedBaseDealId, setSelectedBaseDealId] = useState<number | null>(
null
);
const [selectedGroupId, setSelectedGroupId] = useState<number | null>(null);
const toggleDeal = (deal: DealSchema) => {
if (selectedBaseDealId === deal.id) return;
if (selectedDealIds.has(deal.id)) {
selectedDealIds.delete(deal.id);
} else {
if (!selectedBaseDealId && !selectedGroupId) {
if (deal.group) return;
setSelectedBaseDealId(deal.id);
return;
}
selectedDealIds.add(deal.id);
}
setSelectedDealIds(new Set(selectedDealIds));
};
const finishDealsSelecting = () => {
if (selectedBaseDealId) {
groupsCrud.onCreate(
selectedBaseDealId,
selectedDealIds.values().toArray()
);
setSelectedBaseDealId(null);
} else if (selectedGroupId) {
groupsCrud.onUpdateDealsInGroup(
selectedGroupId,
selectedDealIds.values().toArray()
);
setSelectedGroupId(null);
}
setIsDealsSelecting(false);
setSelectedDealIds(new Set());
};
const cancelDealsSelecting = () => {
setSelectedDealIds(new Set());
setSelectedBaseDealId(null);
setSelectedGroupId(null);
setIsDealsSelecting(false);
};
// For editing group
const startSelectingWithExistingGroup = (group: GroupWithDealsSchema) => {
setSelectedDealIds(new Set(group.items.map(item => item.id)));
setSelectedBaseDealId(null);
setSelectedGroupId(group.id);
setIsDealsSelecting(true);
};
// For creating group on desktop
const startSelectingWithDeal = (dealId: number) => {
setSelectedDealIds(new Set([dealId]));
setSelectedBaseDealId(dealId);
setSelectedGroupId(null);
setIsDealsSelecting(true);
};
// For creating group on mobile
const startSelecting = () => {
setSelectedDealIds(new Set());
setSelectedBaseDealId(null);
setSelectedGroupId(null);
setIsDealsSelecting(true);
};
return {
isDealsSelecting,
selectedBaseDealId,
startSelectingWithDeal,
selectedGroupId,
startSelectingWithExistingGroup,
startSelecting,
selectedDealIds,
setSelectedDealIds,
toggleDeal,
finishDealsSelecting,
cancelDealsSelecting,
};
};
export default useGroupDealsSelection;

View File

@ -1,15 +0,0 @@
import {
BaseDraggable,
BaseGroupDraggable,
} from "@/components/dnd/types/types";
const isItemGroup = <
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
>(
item: TItem | TGroup
): boolean => {
return "items" in item;
};
export default isItemGroup;

View File

@ -0,0 +1,6 @@
const STATUS_POSTFIX = "-status";
export const isStatusId = (rawId: string) => rawId.endsWith(STATUS_POSTFIX);
export const getStatusId = (rawId: string) =>
Number(rawId.replace(STATUS_POSTFIX, ""));

View File

@ -2,7 +2,6 @@ import "@mantine/core/styles.css";
import "mantine-datatable/styles.layer.css"; import "mantine-datatable/styles.layer.css";
import "@mantine/notifications/styles.css"; import "@mantine/notifications/styles.css";
import "@mantine/dates/styles.css"; import "@mantine/dates/styles.css";
import "mantine-contextmenu/styles.css";
import "swiper/css"; import "swiper/css";
import "swiper/css/pagination"; import "swiper/css/pagination";
import "swiper/css/scrollbar"; import "swiper/css/scrollbar";
@ -15,7 +14,6 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { theme } from "@/theme"; import { theme } from "@/theme";
import "@/app/global.css"; import "@/app/global.css";
import { ContextMenuProvider } from "mantine-contextmenu";
import { ModalsProvider } from "@mantine/modals"; import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications"; import { Notifications } from "@mantine/notifications";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext"; import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
@ -65,7 +63,6 @@ export default function RootLayout({ children }: Props) {
<MantineProvider <MantineProvider
theme={theme} theme={theme}
defaultColorScheme={"auto"}> defaultColorScheme={"auto"}>
<ContextMenuProvider>
<ReactQueryProvider> <ReactQueryProvider>
<ReduxProvider> <ReduxProvider>
<ModalsProvider <ModalsProvider
@ -100,7 +97,6 @@ export default function RootLayout({ children }: Props) {
</ReduxProvider> </ReduxProvider>
<Notifications position="bottom-right" /> <Notifications position="bottom-right" />
</ReactQueryProvider> </ReactQueryProvider>
</ContextMenuProvider>
</MantineProvider> </MantineProvider>
</body> </body>
</html> </html>

View File

@ -1,31 +0,0 @@
"use client";
import { Center, Divider, Stack, Text } from "@mantine/core";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import TagsPageHeader from "@/app/tags/components/TagsPageHeader/TagsPageHeader";
import TagsTable from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/components/TagsTable";
import { DealTagsContextProvider } from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/contexts/DealTagsContext";
const PageBody = () => {
const { selectedProject } = useProjectsContext();
if (!selectedProject) {
return (
<Center>
<Text>Проект не найден</Text>
</Center>
);
}
return (
<DealTagsContextProvider project={selectedProject}>
<Stack gap={"md"}>
<TagsPageHeader project={selectedProject} />
<Divider />
<TagsTable />
</Stack>
</DealTagsContextProvider>
);
};
export default PageBody;

View File

@ -1,32 +0,0 @@
import { FC } from "react";
import Link from "next/link";
import { IconChevronLeft, IconPlus } from "@tabler/icons-react";
import { Box, Group, Title } from "@mantine/core";
import useDealTagActions from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/hooks/useDealTagActions";
import { ProjectSchema } from "@/lib/client";
type Props = {
project: ProjectSchema;
};
const TagsPageHeader: FC<Props> = ({ project }) => {
const { onCreateClick } = useDealTagActions();
return (
<Group
mx={"xs"}
mt={"md"}
wrap={"nowrap"}
justify={"space-between"}>
<Link href={"/actions"}>
<IconChevronLeft />
</Link>
<Title order={5}>Теги проекта "{project.name}"</Title>
<Box onClick={onCreateClick}>
<IconPlus />
</Box>
</Group>
);
};
export default TagsPageHeader;

View File

@ -1,25 +0,0 @@
import { Suspense } from "react";
import { Center, Loader } from "@mantine/core";
import PageBody from "@/app/tags/components/PageBody/PageBody";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
import PageContainer from "@/components/layout/PageContainer/PageContainer";
/*
* Page for mobiles only
*/
export default async function TagsPage() {
return (
<Suspense
fallback={
<Center h="50vh">
<Loader size="lg" />
</Center>
}>
<PageContainer>
<PageBlock fullScreenMobile>
<PageBody />
</PageBlock>
</PageContainer>
</Suspense>
);
}

View File

@ -0,0 +1,15 @@
.visible-column {
border-radius: var(--mantine-spacing-lg);
gap: 0;
@media (max-width: 48em) {
max-height: 100%;
}
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}

View File

@ -0,0 +1,39 @@
"use client";
import React from "react";
import DndBoard from "@/components/dnd-pragmatic/DndFunnel/components/DndBoard";
import { DndColumn } from "@/components/dnd-pragmatic/DndFunnel/components/DndColumn";
import { DndFunnelContextProvider } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import FunnelDndProps from "@/components/dnd-pragmatic/DndFunnel/types/FunnelDndProps";
const DndFunnel = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>(
props: FunnelDndProps<ColumnType, ItemType, GroupType>
) => {
return (
<DndFunnelContextProvider {...props}>
<DndBoard>
{props.columns.map(column => (
<DndColumn
column={column}
columnItemsAndGroups={props.getColumnItemsGroups(
column.id
)}
{...props}
key={column.id}
/>
))}
</DndBoard>
</DndFunnelContextProvider>
);
};
export default DndFunnel;

View File

@ -0,0 +1,52 @@
import React, { ForwardedRef, forwardRef, ReactNode, type Ref } from "react";
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { Flex, xcss } from "@atlaskit/primitives";
import { BaseItemType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { CardState } from "@/components/dnd-pragmatic/DndFunnel/types/DndStates";
const baseStyles = xcss({
width: "100%",
position: "relative",
});
const stateStyles: {
[Key in CardState["type"]]: ReturnType<typeof xcss> | undefined;
} = {
idle: xcss({
cursor: "grab",
}),
dragging: xcss({
opacity: 0.4,
}),
// no shadow for preview - the platform will add it's own drop shadow
preview: undefined,
};
type CardPrimitiveProps<ItemType extends BaseItemType> = {
closestEdge: Edge | null;
item: ItemType;
renderItem: (item: any) => ReactNode;
state: CardState;
actionMenuTriggerRef?: Ref<HTMLButtonElement>;
};
const CardWrapper = forwardRef(
<ItemType extends BaseItemType>(
{ closestEdge, item, renderItem, state }: CardPrimitiveProps<ItemType>,
ref: ForwardedRef<HTMLDivElement>
) => {
return (
<Flex
ref={ref}
columnGap="space.100"
alignItems="center"
xcss={[baseStyles, stateStyles[state.type]]}>
{renderItem(item)}
{closestEdge && <DropIndicator edge={closestEdge} />}
</Flex>
);
}
);
export default CardWrapper;

View File

@ -0,0 +1,42 @@
import React, { forwardRef, memo, useEffect, type ReactNode } from "react";
import { autoScrollWindowForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import classNames from "classnames";
import { Box } from "@mantine/core";
import { useDndFunnelContext } from "../contexts/DndBoardContext";
type Props = {
children: ReactNode;
};
const boardStyles = classNames({
display: "flex",
justifyContent: "center",
gap: "space.200",
flexDirection: "row",
flex: 1,
border: "1px white solid",
});
const DndBoard = memo(
forwardRef<HTMLDivElement, Props>(({ children }: Props, ref) => {
const { instanceId } = useDndFunnelContext();
useEffect(() => {
return autoScrollWindowForElements({
canScroll: ({ source }) =>
source.data.instanceId === instanceId,
});
}, [instanceId]);
return (
<Box
className={boardStyles}
flex={1}
ref={ref}>
{children}
</Box>
);
})
);
export default DndBoard;

View File

@ -0,0 +1,146 @@
import React, { memo, ReactNode, useEffect, useRef, useState } from "react";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter";
import { Box } from "@atlaskit/primitives";
import ReactDOM from "react-dom";
import invariant from "tiny-invariant";
import CardWrapper from "@/components/dnd-pragmatic/DndFunnel/components/CardWrapper";
import { useDndFunnelContext } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import { BaseItemType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { CardState } from "../types/DndStates";
const idleState: CardState = { type: "idle" };
const draggingState: CardState = { type: "dragging" };
type DndCardProps<ItemType extends BaseItemType> = {
item: ItemType;
renderItem: (item: any) => ReactNode;
};
export const DndCard = memo(
<ItemType extends BaseItemType>({
item,
renderItem,
}: DndCardProps<ItemType>) => {
const ref = useRef<HTMLDivElement | null>(null);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const [state, setState] = useState<CardState>(idleState);
const { instanceId } = useDndFunnelContext();
useEffect(() => {
const element = ref.current;
invariant(element);
return combine(
draggable({
element,
getInitialData: () => ({
type: "card",
itemId: item.id,
instanceId,
}),
onGenerateDragPreview: ({
location,
source,
nativeSetDragImage,
}) => {
const rect = source.element.getBoundingClientRect();
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: preserveOffsetOnSource({
element,
input: location.current.input,
}),
render({ container }) {
setState({ type: "preview", container, rect });
return () => setState(draggingState);
},
});
},
onDragStart: () => setState(draggingState),
onDrop: () => setState(idleState),
}),
dropTargetForExternal({
element,
}),
dropTargetForElements({
element,
canDrop: ({ source }) => {
return (
source.data.instanceId === instanceId &&
source.data.type === "card"
);
},
getIsSticky: () => true,
getData: ({ input, element }) => {
const data = { type: "card", itemId: item.id };
return attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
});
},
onDragEnter: args => {
if (args.source.data.itemId !== item.id) {
setClosestEdge(extractClosestEdge(args.self.data));
}
},
onDrag: args => {
if (args.source.data.itemId !== item.id) {
setClosestEdge(extractClosestEdge(args.self.data));
}
},
onDragLeave: () => {
setClosestEdge(null);
},
onDrop: () => {
setClosestEdge(null);
},
})
);
}, [instanceId, item.id]);
return (
<>
<CardWrapper
ref={ref}
item={item}
renderItem={renderItem}
state={state}
closestEdge={closestEdge}
/>
{state.type === "preview" &&
ReactDOM.createPortal(
<Box
style={{
width: state.rect.width,
height: state.rect.height,
}}>
<CardWrapper
item={item}
renderItem={renderItem}
state={state}
closestEdge={null}
/>
</Box>,
state.container
)}
</>
);
}
);

View File

@ -0,0 +1,306 @@
import React, { memo, ReactNode, useEffect, useRef, useState } from "react";
import { easeInOut } from "@atlaskit/motion/curves";
import { durations } from "@atlaskit/motion/durations";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { centerUnderPointer } from "@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { Box, Flex, Inline, Stack, xcss } from "@atlaskit/primitives";
import { createPortal } from "react-dom";
import invariant from "tiny-invariant";
import { Stack as MantineStack, ScrollArea } from "@mantine/core";
import DndGroup from "@/components/dnd-pragmatic/DndFunnel/components/DndGroup";
import SafariDndColumnPreview from "@/components/dnd-pragmatic/DndFunnel/components/SafariDndColumnPreview";
import { useDndFunnelContext } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { ColumnState } from "@/components/dnd-pragmatic/DndFunnel/types/DndStates";
import { DndCard } from "./DndCard";
import styles from "../DndFunnel.module.css";
const columnStyles = xcss({
width: "250px",
borderRadius: "radius.xlarge",
transition: `background ${durations.medium}ms ${easeInOut}`,
position: "relative",
// Replace height: "100%" with these:
alignSelf: "stretch", // fill parent's height
minHeight: "0", // allow it to shrink inside parent flex
border: "1px solid red",
});
const stackStyles = xcss({
minHeight: "0",
border: "1px solid red",
flexGrow: 1,
});
const scrollContainerStyles = xcss({
flex: 1,
overflowY: "auto",
});
const cardListStyles = xcss({
boxSizing: "border-box",
minHeight: "100%",
padding: "space.100",
gap: "space.100",
});
const columnHeaderStyles = xcss({
paddingInlineStart: "space.200",
paddingInlineEnd: "space.200",
paddingBlockStart: "space.100",
color: "color.text.subtlest",
userSelect: "none",
});
// preventing re-renders with stable state objects
const idle: ColumnState = { type: "idle" };
const stateStyles: {
[key in ColumnState["type"]]: ReturnType<typeof xcss> | undefined;
} = {
"idle": xcss({
cursor: "grab",
}),
"is-column-over": undefined,
"generate-column-preview": xcss({
isolation: "isolate",
}),
"generate-safari-column-preview": undefined,
};
const isDraggingStyles = xcss({
opacity: 0.4,
});
type Props<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
column: ColumnType;
columnItemsAndGroups: (ItemType | GroupType)[];
renderColumnHeader: (column: any) => ReactNode;
renderItem: (item: any) => ReactNode;
renderGroup: (group: any) => ReactNode;
};
export const DndColumn = memo(
<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
column,
columnItemsAndGroups,
renderColumnHeader,
renderItem,
renderGroup,
}: Props<ColumnType, ItemType, GroupType>) => {
const columnId = column.id;
const columnRef = useRef<HTMLDivElement | null>(null);
const columnInnerRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<ColumnState>(idle);
const [isDragging, setIsDragging] = useState<boolean>(false);
const { instanceId } = useDndFunnelContext();
useEffect(() => {
invariant(columnRef.current);
invariant(columnInnerRef.current);
invariant(headerRef.current);
invariant(scrollableRef.current);
return combine(
draggable({
element: columnRef.current,
dragHandle: headerRef.current,
getInitialData: () => ({
columnId,
type: "column",
instanceId,
}),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
const isSafari: boolean =
navigator.userAgent.includes("AppleWebKit") &&
!navigator.userAgent.includes("Chrome");
if (!isSafari) {
setState({ type: "generate-column-preview" });
return;
}
setCustomNativeDragPreview({
getOffset: centerUnderPointer,
render: ({ container }) => {
setState({
type: "generate-safari-column-preview",
container,
});
return () => setState(idle);
},
nativeSetDragImage,
});
},
onDragStart: () => {
setIsDragging(true);
},
onDrop() {
setState(idle);
setIsDragging(false);
},
}),
dropTargetForElements({
element: columnInnerRef.current,
getData: () => ({ columnId }),
canDrop: ({ source }) => {
return (
source.data.instanceId === instanceId &&
source.data.type === "card"
);
},
getIsSticky: () => true,
onDragLeave: () => setState(idle),
onDrop: () => setState(idle),
}),
dropTargetForElements({
element: columnRef.current,
canDrop: ({ source }) => {
return (
source.data.instanceId === instanceId &&
source.data.type === "column"
);
},
getIsSticky: () => true,
getData: ({ input, element }) => {
const data = {
columnId,
};
return attachClosestEdge(data, {
input,
element,
allowedEdges: ["left", "right"],
});
},
onDragEnter: args => {
setState({
type: "is-column-over",
closestEdge: extractClosestEdge(args.self.data),
});
},
onDrag: args => {
// skip react re-render if edge is not changing
setState(current => {
const closestEdge: Edge | null = extractClosestEdge(
args.self.data
);
if (
current.type === "is-column-over" &&
current.closestEdge === closestEdge
) {
return current;
}
return {
type: "is-column-over",
closestEdge,
};
});
},
onDragLeave: () => setState(idle),
onDrop: () => setState(idle),
}),
autoScrollForElements({
element: scrollableRef.current,
canScroll: ({ source }) =>
source.data.instanceId === instanceId &&
source.data.type === "card",
})
);
}, [columnId, instanceId]);
const renderItemsAndGroups = () =>
columnItemsAndGroups.map(groupOrItem =>
"items" in groupOrItem ? (
<DndGroup
renderGroup={renderGroup}
group={groupOrItem}
key={`${groupOrItem.id}group`}
/>
) : (
<DndCard
item={groupOrItem}
renderItem={renderItem}
key={groupOrItem.id}
/>
)
);
return (
<>
<Flex
ref={columnRef}
direction={"column"}
xcss={[columnStyles, stateStyles[state.type]]}>
<Stack
xcss={stackStyles}
ref={columnInnerRef}>
<Stack
xcss={[
stackStyles,
isDragging ? isDraggingStyles : undefined,
]}>
<MantineStack className={styles["visible-column"]}>
<ScrollArea scrollbars={"y"}>
<Inline
xcss={columnHeaderStyles}
ref={headerRef}
spread="space-between"
alignBlock="center">
{renderColumnHeader(column)}
</Inline>
<Box
xcss={scrollContainerStyles}
ref={scrollableRef}>
<Stack
xcss={cardListStyles}
space="space.100">
{renderItemsAndGroups()}
</Stack>
</Box>
</ScrollArea>
</MantineStack>
</Stack>
</Stack>
{state.type === "is-column-over" && state.closestEdge && (
<DropIndicator edge={state.closestEdge} />
)}
</Flex>
{state.type === "generate-safari-column-preview"
? createPortal(
<SafariDndColumnPreview
column={column}
renderColumnHeader={renderColumnHeader}
/>,
state.container
)
: null}
</>
);
}
);

View File

@ -0,0 +1,119 @@
import React, { memo, ReactNode, useEffect, useRef, useState } from "react";
import FocusRing from "@atlaskit/focus-ring";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { centerUnderPointer } from "@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import invariant from "tiny-invariant";
import { useDndFunnelContext } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import {
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { GroupState } from "@/components/dnd-pragmatic/DndFunnel/types/DndStates";
const idleState: GroupState = { type: "idle" };
const draggingState: GroupState = { type: "dragging" };
const isCardOver: GroupState = { type: "is-card-over" };
const isGroupOver: GroupState = { type: "is-group-over" };
type Props<
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
group: GroupType;
renderGroup: (group: any) => ReactNode;
};
const DndGroup = memo(
<ItemType extends BaseItemType, GroupType extends BaseGroupType<ItemType>>({
group,
renderGroup,
}: Props<ItemType, GroupType>) => {
const ref = useRef<HTMLDivElement | null>(null);
const { instanceId } = useDndFunnelContext();
const [state, setState] = useState<GroupState>(idleState);
useEffect(() => {
const element = ref.current;
invariant(element);
return combine(
draggable({
element,
getInitialData: () => ({
groupId: group.id,
type: "group",
instanceId,
}),
onGenerateDragPreview: ({ source, nativeSetDragImage }) => {
const rect = source.element.getBoundingClientRect();
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: centerUnderPointer,
render({ container }) {
setState({ type: "preview", container, rect });
return () => setState(draggingState);
},
});
},
onDragStart: () => setState(draggingState),
onDrop: () => setState(idleState),
}),
dropTargetForElements({
element,
getData: ({ input, element }) => {
const data = {
groupId,
};
return attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
});
},
canDrop: ({ source }) =>
source.data.instanceId === instanceId &&
source.data.type === "group" &&
source.data.id !== group.id,
onDragEnter: ({ element }) =>
setState(
element.type === "card" ? isCardOver : isGroupOver
),
onDragLeave: () => setState(idleState),
onDragStart: () => setState(isCardOver),
})
);
}, [group]);
return (
<div
style={{
border: state.type === "is-card-over" && "1px solid red",
}}>
<FocusRing isInset>
<div ref={ref}>
{renderGroup(group)}
{(state.type === "is-card-over" ||
state.type === "is-group-over") &&
state.closestEdge && (
<DropIndicator
edge={state.closestEdge}
gap={token("space.200", "0")}
/>
)}
</div>
</FocusRing>
</div>
);
}
);
export default DndGroup;

View File

@ -0,0 +1,36 @@
import React, { ReactNode } from "react";
import { Box, xcss } from "@atlaskit/primitives";
import { BaseColumnType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
const safariPreviewStyles = xcss({
width: "250px",
backgroundColor: "elevation.surface.sunken",
borderRadius: "radius.small",
padding: "space.200",
});
const columnHeaderStyles = xcss({
paddingInlineStart: "space.200",
paddingInlineEnd: "space.200",
paddingBlockStart: "space.100",
color: "color.text.subtlest",
userSelect: "none",
});
type Props<ColumnType extends BaseColumnType> = {
column: ColumnType;
renderColumnHeader: (column: ColumnType) => ReactNode;
};
const SafariDndColumnPreview = <ColumnType extends BaseColumnType>({
column,
renderColumnHeader,
}: Props<ColumnType>) => {
return (
<Box xcss={[columnHeaderStyles, safariPreviewStyles]}>
{renderColumnHeader(column)}
</Box>
);
};
export default SafariDndColumnPreview;

View File

@ -0,0 +1,206 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { SwiperRef } from "swiper/swiper-react";
import useResolveDrop from "@/components/dnd-pragmatic/DndFunnel/hooks/useResolveDrop";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import FunnelDndProps from "@/components/dnd-pragmatic/DndFunnel/types/FunnelDndProps";
import makeContext from "@/lib/contextFactory/contextFactory";
import { sortByLexorank } from "@/utils/lexorank/sort";
export type DndFunnelContextState<
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
instanceId: symbol;
mixedItemsAndGroups: Array<GroupType | ItemType>;
};
const useFunnelContextState = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
columns,
updateColumn,
items,
updateItem,
getColumnItemsGroups,
groups,
}: FunnelDndProps<ColumnType, ItemType, GroupType>): DndFunnelContextState<
ItemType,
GroupType
> => {
const swiperRef = useRef<SwiperRef>(null);
const sortedColumns = useMemo(() => sortByLexorank(columns), [columns]);
// const getColumnByItemId = (itemId: number) => {
// const item = items.find(item => item.id === itemId);
// if (!item) return;
// return columnes.find(column => column.id === item.column.id);
// };
//
// const swipeSliderDuringDrag = () => {
// const activeColumn = getColumnByItemId(activeId);
// const swiperActiveColumn =
// columnes[swiperRef.current?.swiper.activeIndex ?? 0];
// if (swiperActiveColumn.id !== activeColumn?.id) return;
//
// const activeColumnLexorank = activeColumn?.lexorank;
// let overColumnLexorank: string | undefined;
//
// if (typeof over.id === "string" && isColumnId(over.id)) {
// const overColumnId = getColumnId(over.id);
// overColumnLexorank = columnes.find(
// s => s.id === overColumnId
// )?.lexorank;
// } else {
// overColumnLexorank = getColumnByItemId(Number(over.id))?.lexorank;
// }
//
// if (
// !activeColumnLexorank ||
// !overColumnLexorank ||
// !swiperRef.current?.swiper
// )
// return;
//
// const activeIndex = sortedColumns.findIndex(
// s => s.lexorank === activeColumnLexorank
// );
// const overIndex = sortedColumns.findIndex(
// s => s.lexorank === overColumnLexorank
// );
//
// if (activeIndex > overIndex) {
// swiperRef.current.swiper.slidePrev();
// return;
// }
// if (activeIndex < overIndex) {
// swiperRef.current.swiper.slideNext();
// }
// };
// const lastOperation = useRef<Operation | null>(null);
//
// useEffect(() => {
// if (lastOperation.current === null) return;
// const { outcome, trigger } = lastOperation.current;
//
// if (outcome.type === "column-reorder") {
// const { startIndex, finishIndex } = outcome;
//
// const sourceColumn = sortedColumns[finishIndex];
//
// const entry = registry.getColumn(sourceColumn.id);
// triggerPostMoveFlash(entry.element);
//
// console.log(
// `You've moved ${sourceColumn.name} from position ${
// startIndex + 1
// } to position ${finishIndex + 1} of ${sortedColumns.length}.`
// );
//
// return;
// }
//
// if (outcome.type === "card-reorder") {
// const { columnId, startIndex, finishIndex } = outcome;
//
// const column = sortedColumns.find(s => s.id === columnId);
// if (!column) return;
// const columnItems = items.filter(d => d.column.id === columnId);
// const item = columnItems[finishIndex];
//
// const entry = registry.getCard(item.id);
// triggerPostMoveFlash(entry.element);
//
// if (trigger !== "keyboard") return;
//
// console.log(
// `You've moved ${item.name} from position ${
// startIndex + 1
// } to position ${finishIndex + 1} of ${columnItems.length} in the ${column.name} column.`
// );
//
// return;
// }
//
// if (outcome.type === "card-move") {
// const {
// finishColumnId,
// itemIndexInStartColumn,
// itemIndexInFinishColumn,
// } = outcome;
//
// const destColumn = sortedColumns.find(
// s => s.id === finishColumnId
// );
// if (!destColumn) return;
// const columnItems = items.filter(
// d => d.column.id === destColumn.id
// );
//
// const item = columnItems[itemIndexInFinishColumn];
//
// const finishPosition =
// typeof itemIndexInFinishColumn === "number"
// ? itemIndexInFinishColumn + 1
// : columnItems.length;
//
// const entry = registry.getCard(item.id);
// triggerPostMoveFlash(entry.element);
//
// if (trigger !== "keyboard") return;
//
// console.log(
// `You've moved ${item.name} from position ${
// itemIndexInStartColumn + 1
// } to position ${finishPosition} in the ${destColumn.name} column.`
// );
// }
// }, [lastOperation, registry]);
useEffect(() => {
return liveRegion.cleanup();
}, []);
const [instanceId] = useState(() => Symbol("instance-id"));
const { onDrop } = useResolveDrop<ColumnType, ItemType, GroupType>({
sortedColumns,
updateColumn,
updateItem,
getColumnItemsGroups,
});
const mixedItemsAndGroups: Array<ItemType | GroupType> = sortByLexorank([
...items,
...groups,
]);
useEffect(() => {
return combine(
monitorForElements({
canMonitor: ({ source }) =>
source.data.instanceId === instanceId,
onDrop,
})
);
}, [items, sortedColumns, instanceId]);
return {
instanceId,
mixedItemsAndGroups,
};
};
export const [DndFunnelContextProvider, useDndFunnelContext] = makeContext<
DndFunnelContextState<any, any>,
FunnelDndProps<any, any, any>
>(useFunnelContextState, "DndFunnel");

View File

@ -0,0 +1,93 @@
import useGetNewRankForFunnel from "@/components/dnd-pragmatic/DndFunnel/hooks/useGetNewRankForFunnel";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
type Props<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
sortedColumns: ColumnType[];
updateColumn: (id: number, lexorank: string) => void;
updateItem: (id: number, lexorank: string, columnId: number) => void;
getColumnItemsGroups: (columnId: number) => (ItemType | GroupType)[];
};
const useFunnelActions = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
sortedColumns,
updateColumn,
updateItem,
getColumnItemsGroups,
}: Props<ColumnType, ItemType, GroupType>) => {
const {
getNewRankForSameColumn,
getNewRankForAnotherColumn,
getNewColumnRank,
} = useGetNewRankForFunnel<ColumnType, ItemType, GroupType>();
const reorderItem = (
columnId: number,
startIndex: number,
finishIndex: number,
columnItems: ItemType[]
) => {
const startItemId = columnItems[startIndex].id;
const newLexorank = getNewRankForSameColumn(
columnItems,
finishIndex,
startItemId
);
updateItem(startItemId, newLexorank, columnId);
};
const moveItem = (
startColumnId: number,
itemIndexInStartColumn: number,
finishColumnId: number,
finishItemIndex?: number
) => {
const startColumnItems = getColumnItemsGroups(startColumnId);
const startItemId = startColumnItems[itemIndexInStartColumn].id;
const finishColumnItems = getColumnItemsGroups(finishColumnId);
const newLexorank = getNewRankForAnotherColumn(
finishColumnItems,
finishItemIndex
);
updateItem(startItemId, newLexorank, finishColumnId);
};
const reorderColumn = (startIndex: number, finishIndex: number) => {
const startColumnId = sortedColumns[startIndex].id;
const finishColumnId = sortedColumns[finishIndex].id;
if (startColumnId === finishColumnId) return;
const newRank = getNewColumnRank(
sortedColumns,
startColumnId,
finishColumnId
);
if (!newRank) return;
updateColumn(startColumnId, newRank);
};
return {
moveItem,
reorderColumn,
reorderItem,
};
};
export default useFunnelActions;

View File

@ -0,0 +1,118 @@
import { LexoRank } from "lexorank";
import { BaseColumnType, BaseItemType } from "@/components/dnd-pragmatic/types/base";
import { getNewLexorank } from "@/utils/lexorank/generation";
import { sortByLexorank } from "@/utils/lexorank/sort";
import { BaseGroupType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
interface NewRankGetters<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> {
getNewRankForSameColumn: (
columnItems: ItemType[],
overItemIndex: number,
activeItemId: number
) => string;
getNewRankForAnotherColumn: (
columnItems: ItemType[],
overItemIndex?: number
) => string;
getNewColumnRank: (
columns: ColumnType[],
activeColumnId: number,
overColumnId: number
) => string | null;
}
const useGetNewRankForFunnel = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>(): NewRankGetters<ColumnType, ItemType, GroupType> => {
const getNewRankForSameColumn = (
columnItems: ItemType[],
overItemIndex: number,
activeItemId: number
): string => {
const activeItemIndex = columnItems.findIndex(
item => item.id === activeItemId
);
const [leftIndex, rightIndex] =
overItemIndex < activeItemIndex
? [overItemIndex - 1, overItemIndex]
: [overItemIndex, overItemIndex + 1];
const leftLexorank =
leftIndex >= 0
? LexoRank.parse(columnItems[leftIndex].lexorank)
: null;
const rightLexorank =
rightIndex < columnItems.length
? LexoRank.parse(columnItems[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewRankForAnotherColumn = (
columnItems: ItemType[],
overItemIndex?: number
): string => {
if (columnItems.length === 0) return LexoRank.middle().toString();
if (!overItemIndex || overItemIndex >= columnItems.length) {
return getNewLexorank(
LexoRank.parse(columnItems[columnItems.length - 1].lexorank)
).toString();
}
const leftLexorank =
overItemIndex > 0
? LexoRank.parse(columnItems[overItemIndex - 1].lexorank)
: null;
const rightLexorank = LexoRank.parse(
columnItems[overItemIndex].lexorank
);
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewColumnRank = (
columns: ColumnType[],
activeColumnId: number,
overColumnId: number
): string | null => {
const sortedColumnsList = sortByLexorank(columns);
const overIndex = sortedColumnsList.findIndex(
s => s.id === overColumnId
);
const activeIndex = sortedColumnsList.findIndex(
s => s.id === activeColumnId
);
if (overIndex === -1 || activeIndex === -1) return null;
const [leftIndex, rightIndex] =
overIndex < activeIndex
? [overIndex - 1, overIndex]
: [overIndex, overIndex + 1];
const leftLexorank =
leftIndex >= 0 ? LexoRank.parse(columns[leftIndex].lexorank) : null;
const rightLexorank =
rightIndex < columns.length
? LexoRank.parse(columns[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
return {
getNewRankForSameColumn,
getNewRankForAnotherColumn,
getNewColumnRank,
};
};
export default useGetNewRankForFunnel;

View File

@ -0,0 +1,191 @@
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/types";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import {
BaseEventPayload,
ElementDragType,
} from "@atlaskit/pragmatic-drag-and-drop/types";
import useFunnelActions from "@/components/dnd-pragmatic/DndFunnel/hooks/useFunnelActions";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
type Props<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
sortedColumns: ColumnType[];
getColumnItemsGroups: (columnId: number) => (ItemType | GroupType)[];
updateColumn: (id: number, lexorank: string) => void;
updateItem: (id: number, lexorank: string, columnId: number) => void;
};
const useResolveDrop = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
sortedColumns,
getColumnItemsGroups,
...props
}: Props<ColumnType, ItemType, GroupType>) => {
const { moveItem, reorderColumn, reorderItem } = useFunnelActions<
ColumnType,
ItemType,
GroupType
>({
sortedColumns,
getColumnItemsGroups,
...props,
});
const onItemDrop = ({
location,
source,
}: BaseEventPayload<ElementDragType>) => {
const itemId: number = source.data.itemId as number;
const [, startColumnRecord] = location.initial.dropTargets;
const sourceColumnId: number = startColumnRecord.data
.columnId as number;
const sourceColumn = sortedColumns.find(s => s.id === sourceColumnId);
if (!sourceColumn) return;
const sourceColumnItems = getColumnItemsGroups(sourceColumnId);
const startItemIndex = sourceColumnItems.findIndex(
d => d.id === itemId
);
if (startItemIndex === -1) return;
if (location.current.dropTargets.length === 1) {
const [destinationColumnRecord] = location.current.dropTargets;
const destinationId: number = destinationColumnRecord.data
.columnId as number;
const destinationColumn = sortedColumns.find(
s => s.id === destinationId
);
if (!destinationColumn) return;
// reordering in same column
if (sourceColumn === destinationColumn) {
const destinationIndex = getReorderDestinationIndex({
startIndex: startItemIndex,
indexOfTarget: sourceColumnItems.length - 1,
closestEdgeOfTarget: null,
axis: "vertical",
});
reorderItem(
sourceColumn.id,
startItemIndex,
destinationIndex,
sourceColumnItems
);
return;
}
// moving to a new column
moveItem(sourceColumn.id, startItemIndex, destinationId);
return;
}
// dropping in a column (relative to a card)
if (location.current.dropTargets.length === 2) {
const [destinationCardRecord, destinationColumnRecord] =
location.current.dropTargets;
const destinationColumnId: number = destinationColumnRecord.data
.columnId as number;
const destinationColumn = sortedColumns.find(
s => s.id === destinationColumnId
);
if (!destinationColumn) return;
const destColumnItems = getColumnItemsGroups(destinationColumnId);
const indexOfTarget = destColumnItems.findIndex(
item =>
item.id === destinationCardRecord.data.itemId &&
!("items" in item)
);
const closestEdgeOfTarget: Edge | null = extractClosestEdge(
destinationCardRecord.data
);
// case 1: ordering in the same column
if (sourceColumn === destinationColumn) {
const destinationIndex = getReorderDestinationIndex({
startIndex: startItemIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: "vertical",
});
reorderItem(
sourceColumn.id,
startItemIndex,
destinationIndex,
destColumnItems
);
return;
}
// case 2: moving into a new column relative to a card
const destinationIndex =
closestEdgeOfTarget === "bottom"
? indexOfTarget + 1
: indexOfTarget;
moveItem(
sourceColumn.id,
startItemIndex,
destinationColumn.id,
destinationIndex
);
}
};
const onColumnDrop = ({
location,
source,
}: BaseEventPayload<ElementDragType>) => {
const startIndex: number = sortedColumns.findIndex(
column => column.id === source.data.columnId
);
const target = location.current.dropTargets[0];
const indexOfTarget: number = sortedColumns.findIndex(
column => column.id === target.data.columnId
);
const closestEdgeOfTarget: Edge | null = extractClosestEdge(
target.data
);
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: "horizontal",
});
reorderColumn(startIndex, finishIndex);
};
const onDrop = ({
location,
source,
}: BaseEventPayload<ElementDragType>) => {
if (!location.current.dropTargets.length) return;
if (source.data.type === "column") {
onColumnDrop({ location, source });
return;
}
if (source.data.type === "card") {
onItemDrop({ location, source });
}
};
return {
onDrop,
};
};
export default useResolveDrop;

View File

@ -0,0 +1,12 @@
type BaseFunnelType = {
id: number;
lexorank: string;
};
export type BaseColumnType = BaseFunnelType;
export type BaseItemType = BaseFunnelType;
export type BaseGroupType<ItemType extends BaseItemType> = BaseFunnelType & {
items: ItemType[];
};

View File

@ -0,0 +1,19 @@
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
export type CardState =
| { type: "idle" }
| { type: "preview"; container: HTMLElement; rect: DOMRect }
| { type: "dragging" };
export type GroupState =
| { type: "idle" }
| { type: "is-card-over"; closestEdge: Edge | null }
| { type: "is-group-over"; closestEdge: Edge | null }
| { type: "preview"; container: HTMLElement; rect: DOMRect }
| { type: "dragging" };
export type ColumnState =
| { type: "idle" }
| { type: "is-column-over"; closestEdge: Edge | null }
| { type: "generate-safari-column-preview"; container: HTMLElement }
| { type: "generate-column-preview" };

View File

@ -0,0 +1,23 @@
import { ReactNode } from "react";
import {
BaseColumnType, BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
type FunnelDndProps<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
columns: ColumnType[];
updateColumn: (id: number, lexorank: string) => void;
items: ItemType[];
updateItem: (id: number, lexorank: string, columnId: number) => void;
getColumnItemsGroups: (columnId: number) => (ItemType | GroupType)[];
renderColumnHeader: (column: ColumnType) => ReactNode;
renderItem: (item: ItemType) => ReactNode;
groups: GroupType[];
renderGroup: (group: GroupType) => ReactNode;
};
export default FunnelDndProps;

View File

@ -0,0 +1,52 @@
import type { CleanupFn } from "@atlaskit/pragmatic-drag-and-drop/types";
import invariant from "tiny-invariant";
export type Entry = {
element: HTMLElement;
};
export type RegisterCardProps = {
cardId: number;
entry: Entry;
};
export type RegisterColumnProps = {
columnId: number;
entry: Entry;
};
// TODO delete
export function createRegistry() {
const cards = new Map<number, Entry>();
const columns = new Map<number, Entry>();
function registerCard({ cardId, entry }: RegisterCardProps): CleanupFn {
cards.set(cardId, entry);
return () => cards.delete(cardId);
}
function registerColumn({
columnId,
entry,
}: RegisterColumnProps): CleanupFn {
columns.set(columnId, entry);
return function cleanup() {
cards.delete(columnId);
};
}
function getCard(cardId: number): Entry {
const entry = cards.get(cardId);
invariant(entry);
return entry;
}
function getColumn(columnId: number): Entry {
const entry = columns.get(columnId);
invariant(entry);
return entry;
}
return { registerCard, registerColumn, getCard, getColumn };
}

View File

@ -0,0 +1,3 @@
import DragHandle from './DragHandle';
export default DragHandle;

View File

@ -0,0 +1,42 @@
import React, { ReactNode } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Stack } from "@mantine/core";
import { BaseDraggable } from "@/components/dnd/types/types";
type Props<TItem> = {
id: string;
items: TItem[];
renderItem: (item: TItem) => ReactNode;
children?: ReactNode;
};
const FunnelColumn = <TItem extends BaseDraggable>({
id,
items,
renderItem,
children,
}: Props<TItem>) => {
const { setNodeRef } = useDroppable({ id });
return (
<>
{children}
<SortableContext
id={id}
items={items}
strategy={verticalListSortingStrategy}>
<Stack
gap="xs"
ref={setNodeRef}>
{items.map(renderItem)}
</Stack>
</SortableContext>
</>
);
};
export default FunnelColumn;

View File

@ -1,43 +1,21 @@
"use client"; "use client";
import React, { ReactNode, RefObject, useMemo, useState } from "react"; import React, { ReactNode, RefObject } from "react";
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
} from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { FreeMode, Pagination, Scrollbar } from "swiper/modules"; import { FreeMode, Pagination, Scrollbar } from "swiper/modules";
import { Swiper, SwiperRef, SwiperSlide } from "swiper/react"; import { Swiper, SwiperRef, SwiperSlide } from "swiper/react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton"; import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton";
import useDndSensors from "@/app/deals/hooks/useSensors"; import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn";
import FunnelColumn from "@/components/dnd/FunnelDnd/components/FunnelColumn"; import { BaseDraggable } from "@/components/dnd/types/types";
import FunnelOverlay from "@/components/dnd/FunnelDnd/components/FunnelOverlay";
import {
getContainerId,
getDndContainerId,
isContainerId,
} from "@/components/dnd/FunnelDnd/utils/columnId";
import {
getGroupId,
isGroupId,
} from "@/components/dnd/FunnelDnd/utils/groupId";
import {
BaseDraggable,
BaseGroupDraggable,
} from "@/components/dnd/types/types";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import SortableItem from "../SortableItem"; import SortableItem from "../SortableItem";
import classes from "./FunnelDnd.module.css"; import classes from "./FunnelDnd.module.css";
import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
type Props<TContainer, TItem, TGroup> = { type Props<TContainer, TItem> = {
containers: TContainer[]; containers: TContainer[];
itemsAndGroups: (TItem | TGroup)[]; items: TItem[];
onDragStart: (event: DragStartEvent) => void;
onDragOver: (event: DragOverEvent) => void; onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void; onDragEnd: (event: DragEndEvent) => void;
swiperRef: RefObject<SwiperRef | null>; swiperRef: RefObject<SwiperRef | null>;
@ -53,8 +31,11 @@ type Props<TContainer, TItem, TGroup> = {
children: ReactNode children: ReactNode
) => ReactNode; ) => ReactNode;
renderItem: (item: TItem) => ReactNode; renderItem: (item: TItem) => ReactNode;
renderGroup: (group: TGroup) => ReactNode; renderItemOverlay: (item: TItem) => ReactNode;
getItemsByContainer: (container: TContainer) => (TItem | TGroup)[]; getContainerId: (container: TContainer) => string;
getItemsByContainer: (container: TContainer, items: TItem[]) => TItem[];
activeContainer: TContainer | null;
activeItem: TItem | null;
isCreatingContainerEnabled?: boolean; isCreatingContainerEnabled?: boolean;
disabledColumns?: boolean; disabledColumns?: boolean;
}; };
@ -62,10 +43,10 @@ type Props<TContainer, TItem, TGroup> = {
const FunnelDnd = < const FunnelDnd = <
TContainer extends BaseDraggable, TContainer extends BaseDraggable,
TItem extends BaseDraggable, TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
>({ >({
containers, containers,
itemsAndGroups, items,
onDragStart,
onDragOver, onDragOver,
onDragEnd, onDragEnd,
swiperRef, swiperRef,
@ -73,25 +54,20 @@ const FunnelDnd = <
renderContainerHeader, renderContainerHeader,
renderContainerOverlay, renderContainerOverlay,
renderItem, renderItem,
renderGroup, renderItemOverlay,
getContainerId,
getItemsByContainer, getItemsByContainer,
activeContainer,
activeItem,
isCreatingContainerEnabled = true, isCreatingContainerEnabled = true,
disabledColumns = false, disabledColumns = false,
}: Props<TContainer, TItem, TGroup>) => { }: Props<TContainer, TItem>) => {
const sensors = useDndSensors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const frequency = useMemo(() => (isMobile ? 1 : undefined), [isMobile]);
const [activeItem, setActiveItem] = useState<TItem | null>(null);
const [activeContainer, setActiveContainer] = useState<TContainer | null>(
null
);
const [activeGroup, setActiveGroup] = useState<TGroup | null>(null);
const renderContainers = () => const renderContainers = () =>
containers.map((container, index) => { containers.map((container, index) => {
const containerItems = getItemsByContainer(container); const containerItems = getItemsByContainer(container, items);
const containerId = getDndContainerId(container.id); const containerId = getContainerId(container);
return ( return (
<SwiperSlide <SwiperSlide
style={{ width: 250 }} style={{ width: 250 }}
@ -105,9 +81,8 @@ const FunnelDnd = <
container, container,
<FunnelColumn <FunnelColumn
id={containerId} id={containerId}
itemsAndGroups={containerItems} items={containerItems}
renderItem={renderItem} renderItem={renderItem}
renderGroup={renderGroup}
/>, />,
renderDraggable!, renderDraggable!,
index index
@ -119,7 +94,6 @@ const FunnelDnd = <
); );
}); });
const renderBody = () => {
if (isMobile) { if (isMobile) {
return ( return (
<Box> <Box>
@ -147,6 +121,7 @@ const FunnelDnd = <
</Box> </Box>
); );
} }
return ( return (
<Swiper <Swiper
ref={swiperRef} ref={swiperRef}
@ -166,81 +141,6 @@ const FunnelDnd = <
)} )}
</Swiper> </Swiper>
); );
};
const onDragStart = ({ active }: DragStartEvent) => {
const activeId = active.id as string | number;
if (typeof activeId !== "string") {
const item = (itemsAndGroups.find(
item => !("items" in item) && item.id === activeId
) ?? null) as TItem | null;
setActiveItem(item);
return;
}
if (isContainerId(activeId)) {
const contId = getContainerId(activeId);
setActiveContainer(
containers.find(container => container.id === contId) ?? null
);
return;
}
if (isGroupId(activeId)) {
const groupId = getGroupId(activeId);
const group = (itemsAndGroups.find(
group => "items" in group && group.id === groupId
) ?? null) as TGroup | null;
setActiveGroup(group);
}
};
return (
<DndContext
sensors={sensors}
measuring={{
droppable: {
frequency,
},
}}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDragEnd={state => {
setActiveContainer(null);
setActiveItem(null);
setActiveGroup(null);
onDragEnd(state);
}}>
<SortableContext
items={containers.map(container =>
getDndContainerId(container.id)
)}
strategy={horizontalListSortingStrategy}>
{renderBody()}
<FunnelOverlay
activeContainer={activeContainer}
activeItem={activeItem}
activeGroup={activeGroup}
renderContainer={container => {
const containerItems = getItemsByContainer(container);
const containerId = getDndContainerId(container.id);
return renderContainerOverlay(
container,
<FunnelColumn
id={containerId}
itemsAndGroups={containerItems}
renderItem={renderItem}
renderGroup={renderGroup}
/>
);
}}
renderItem={renderItem}
renderGroup={renderGroup}
/>
</SortableContext>
</DndContext>
);
}; };
export default FunnelDnd; export default FunnelDnd;

View File

@ -2,32 +2,28 @@ import React, { ReactNode } from "react";
import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core"; import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core";
import styles from "@/components/dnd/FunnelDnd/FunnelDnd.module.css"; import styles from "@/components/dnd/FunnelDnd/FunnelDnd.module.css";
type Props<TContainer, TItem, TGroup> = { type Props<TContainer, TItem> = {
activeContainer: TContainer | null; activeContainer: TContainer | null;
activeItem: TItem | null; activeItem: TItem | null;
activeGroup: TGroup | null;
renderContainer: (container: TContainer) => ReactNode; renderContainer: (container: TContainer) => ReactNode;
renderItem: (item: TItem) => ReactNode; renderItem: (item: TItem) => ReactNode;
renderGroup: (group: TGroup) => ReactNode;
}; };
const FunnelOverlay = <TContainer, TItem, TGroup>({ const FunnelOverlay = <TContainer, TItem>({
activeContainer, activeContainer,
activeItem, activeItem,
activeGroup,
renderContainer, renderContainer,
renderItem, renderItem,
renderGroup, }: Props<TContainer, TItem>) => {
}: Props<TContainer, TItem, TGroup>) => {
const renderOverlay = () => {
if (activeItem) return renderItem(activeItem);
if (activeContainer) return renderContainer(activeContainer);
if (activeGroup) return renderGroup(activeGroup);
};
return ( return (
<DragOverlay dropAnimation={defaultDropAnimation}> <DragOverlay dropAnimation={defaultDropAnimation}>
<div className={styles.overlay}>{renderOverlay()}</div> <div className={styles.overlay}>
{activeItem
? renderItem(activeItem)
: activeContainer
? renderContainer(activeContainer)
: null}
</div>
</DragOverlay> </DragOverlay>
); );
}; };

View File

@ -1,76 +0,0 @@
import React, { ReactNode } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Stack } from "@mantine/core";
import { getDndGroupId } from "@/components/dnd/FunnelDnd/utils/groupId";
import SortableItem from "@/components/dnd/SortableItem";
import {
BaseDraggable,
BaseGroupDraggable,
} from "@/components/dnd/types/types";
import isItemGroup from "@/app/deals/utils/isItemGroup";
type Props<
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
> = {
id: string;
itemsAndGroups: (TItem | TGroup)[];
renderItem: (item: TItem) => ReactNode;
renderGroup: (group: TGroup) => ReactNode;
children?: ReactNode;
};
const FunnelColumn = <
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
>({
id,
itemsAndGroups,
renderItem,
renderGroup,
children,
}: Props<TItem, TGroup>) => {
const { setNodeRef } = useDroppable({ id });
return (
<>
{children}
<SortableContext
id={id}
items={itemsAndGroups.map(itemOrGroup =>
isItemGroup(itemOrGroup)
? getDndGroupId(itemOrGroup.id)
: itemOrGroup.id
)}
strategy={verticalListSortingStrategy}>
<Stack
gap="xs"
ref={setNodeRef}>
{itemsAndGroups.map(itemOrGroup =>
"items" in itemOrGroup ? (
<SortableItem
key={`${itemOrGroup.id.toString()}g`}
dragHandleStyle={{ cursor: "pointer" }}
id={getDndGroupId(itemOrGroup.id)}
renderItem={() => renderGroup(itemOrGroup)}
/>
) : (
<SortableItem
key={itemOrGroup.id}
dragHandleStyle={{ cursor: "pointer" }}
id={itemOrGroup.id}
renderItem={() => renderItem(itemOrGroup)}
/>
)
)}
</Stack>
</SortableContext>
</>
);
};
export default FunnelColumn;

View File

@ -1,9 +0,0 @@
const CONTAINER_POSTFIX = "-con";
export const isContainerId = (rawId: string) => rawId.endsWith(CONTAINER_POSTFIX);
export const getContainerId = (rawId: string) =>
Number(rawId.replace(CONTAINER_POSTFIX, ""));
export const getDndContainerId = (id: number) =>
`${id}${CONTAINER_POSTFIX}`;

View File

@ -1,8 +0,0 @@
const GROUP_POSTFIX = "-gr";
export const isGroupId = (rawId: string) => rawId.endsWith(GROUP_POSTFIX);
export const getGroupId = (rawId: string) =>
Number(rawId.replace(GROUP_POSTFIX, ""));
export const getDndGroupId = (id: number) => `${id}${GROUP_POSTFIX}`;

View File

@ -0,0 +1,77 @@
import React, { CSSProperties, ReactNode, useCallback, useMemo } from "react";
import { useDndContext } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import DragHandle from "@/components/dnd/DragHandle";
// import collisionsShouldBeCombined from "@/components/dnd/utils/collisionsShouldBeCombined";
type Props = {
id: number | string;
renderItem: (renderDraggable?: () => ReactNode) => ReactNode;
renderDraggable?: () => ReactNode; // if not passed - the whole item renders as draggable
disabled?: boolean;
dragHandleStyle?: CSSProperties;
};
const SortableCombinableItem = ({
renderItem,
dragHandleStyle,
renderDraggable,
id,
disabled = false,
}: Props) => {
const { isDragging, setNodeRef, transform, transition, over, active } =
useSortable({
id,
animateLayoutChanges: () => false,
});
const { collisions } = useDndContext();
// const isCurrentCombining = useMemo(
// () => collisionsShouldBeCombined({ collisions }) && over?.id === id,
// [over, collisions]
// );
const getBorder = useCallback(() => {
// if (isCurrentCombining) return "1px solid red";
return "";
}, [active, over, id]);
const style = {
// transform: collisionsShouldBeCombined({ collisions })
// ? "none"
// : CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
border: getBorder(),
};
const renderDragHandle = () => (
<DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderDraggable && renderDraggable()}
</DragHandle>
);
return (
<div
ref={setNodeRef}
style={style}>
{renderDraggable ? (
renderItem(renderDragHandle)
) : (
<DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderItem()}
</DragHandle>
)}
</div>
);
};
export default SortableCombinableItem;

View File

@ -0,0 +1,3 @@
import SortableCombinableItem from "./SortableCombinableItem";
export default SortableCombinableItem;

View File

@ -1,7 +1,6 @@
import React, { CSSProperties, ReactNode } from "react"; import React, { CSSProperties, ReactNode } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import DragHandle from "@/components/dnd/DragHandle";
import DragHandle from "./DragHandle";
type Props = { type Props = {
id: number | string; id: number | string;
@ -23,12 +22,6 @@ const SortableItem = ({
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
};
const renderDragHandle = () => ( const renderDragHandle = () => (
<DragHandle <DragHandle
id={id} id={id}
@ -39,9 +32,7 @@ const SortableItem = ({
); );
return ( return (
<div <div ref={setNodeRef}>
ref={setNodeRef}
style={style}>
{renderDraggable ? ( {renderDraggable ? (
renderItem(renderDragHandle) renderItem(renderDragHandle)
) : ( ) : (

View File

@ -1,8 +1,3 @@
export type BaseDraggable = { export type BaseDraggable = {
id: number; id: number;
lexorank: string;
};
export type BaseGroupDraggable<TItem extends BaseDraggable> = BaseDraggable & {
items: TItem[];
}; };

View File

@ -3,8 +3,7 @@ import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { useDrawersContext } from "@/drawers/DrawersContext"; import { useDrawersContext } from "@/drawers/DrawersContext";
const useProjectActions = () => { const useProjectActions = () => {
const { selectedProject, projectsCrud, refetchProjects } = const { selectedProject, projectsCrud } = useProjectsContext();
useProjectsContext();
const { openDrawer } = useDrawersContext(); const { openDrawer } = useDrawersContext();
const onCreateClick = () => { const onCreateClick = () => {
@ -28,7 +27,6 @@ const useProjectActions = () => {
onChange: value => projectsCrud.onUpdate(value.id, value), onChange: value => projectsCrud.onUpdate(value.id, value),
onDelete: projectsCrud.onDelete, onDelete: projectsCrud.onDelete,
}, },
onClose: refetchProjects,
}); });
}; };

View File

@ -1,34 +0,0 @@
import { lighten, Pill, useMantineColorScheme } from "@mantine/core";
import { DealTagSchema } from "@/lib/client";
type Props = {
tag: Partial<DealTagSchema>;
};
const DealTag = ({ tag }: Props) => {
const theme = useMantineColorScheme();
const isInherit = tag.tagColor!.backgroundColor === "inherit";
let color = tag.tagColor!.color;
const backgroundColor = tag.tagColor!.backgroundColor;
if (!(theme.colorScheme === "dark" || isInherit)) {
color = lighten(color, 0.95);
}
return (
<Pill
key={tag.id}
style={{
opacity: 0.7,
color,
backgroundColor,
border: "1px solid",
borderColor: color,
}}>
{tag.name}
</Pill>
);
};
export default DealTag;

View File

@ -1,30 +0,0 @@
.add-tag-button {
@mixin light {
background-color: var(--mantine-color-gray-1);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
color: gray;
border: 1px gray dashed;
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
padding: 0;
cursor: pointer;
}
.add-tag-button:hover {
@mixin light {
border-color: black;
color: black;
}
@mixin dark {
border-color: white;
color: white;
}
}
.add-tag-button-icon {
color: inherit !important;
}

View File

@ -1,93 +0,0 @@
import React, { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import classNames from "classnames";
import { Button, Center, Checkbox, Group, Menu, Stack } from "@mantine/core";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import DealTag from "@/components/ui/DealTag/DealTag";
import useDealTags from "@/components/ui/DealTags/hooks/useDealTags";
import { DealTagSchema } from "@/lib/client";
import styles from "./DealTags.module.css";
type Props = {
dealId?: number;
groupId?: number;
tags: DealTagSchema[];
};
const DealTags = ({ tags, dealId, groupId }: Props) => {
const { selectedProject } = useProjectsContext();
const { switchTag } = useDealTags();
const tagIdsSet = useMemo(() => new Set(tags.map(t => t.id)), [tags]);
if (selectedProject?.tags.length === 0) return;
const onTagClick = (tagId: number, event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
switchTag({ dealId, groupId, tagId });
};
const addTagButton = useMemo(
() => (
<Menu withArrow>
<Menu.Target>
<Button
onClick={e => e.stopPropagation()}
unstyled
className={classNames(styles["add-tag-button"])}>
<Center>
<IconPlus
size={"1.2em"}
className={classNames(
styles["add-tag-button-icon"]
)}
/>
</Center>
</Button>
</Menu.Target>
<Menu.Dropdown>
<Stack
p={"xs"}
gap={"sm"}
onClick={e => e.stopPropagation()}>
{selectedProject?.tags.map(tag => (
<Group
key={tag.id}
wrap={"nowrap"}>
<Checkbox
checked={tagIdsSet.has(tag.id)}
onChange={event =>
onTagClick(
tag.id,
event as unknown as any
)
}
label={tag.name}
/>
</Group>
))}
</Stack>
</Menu.Dropdown>
</Menu>
),
[selectedProject?.tags, tags]
);
return (
<Group gap={"xs"}>
{addTagButton}
{selectedProject?.tags.map(
tag =>
tagIdsSet.has(tag.id) && (
<DealTag
key={tag.id}
tag={tag}
/>
)
)}
</Group>
);
};
export default DealTags;

View File

@ -1,25 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { SwitchDealTagRequest } from "@/lib/client";
import { switchDealTagMutation } from "@/lib/client/@tanstack/react-query.gen";
const useDealTags = () => {
const { refetchDeals } = useDealsContext();
const switchTagMutation = useMutation({
...switchDealTagMutation(),
onSettled: refetchDeals,
});
const switchTag = (data: SwitchDealTagRequest) => {
switchTagMutation.mutate({
body: data,
});
};
return {
switchTag,
};
};
export default useDealTags;

View File

@ -1,3 +0,0 @@
import ProjectEditorDrawer from "./ProjectEditorDrawer";
export default ProjectEditorDrawer;

View File

@ -1,26 +0,0 @@
import { FC } from "react";
import { Flex } from "@mantine/core";
import TagsTabHeader from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/components/TagsTabHeader";
import TagsTable from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/components/TagsTable";
import { DealTagsContextProvider } from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/contexts/DealTagsContext";
import { ProjectSchema } from "@/lib/client";
type Props = {
value: ProjectSchema;
};
const TagsTab: FC<Props> = ({ value }) => {
return (
<Flex
h={"100%"}
direction={"column"}
gap={"xs"}>
<DealTagsContextProvider project={value}>
<TagsTabHeader />
<TagsTable />
</DealTagsContextProvider>
</Flex>
);
};
export default TagsTab;

View File

@ -1,60 +0,0 @@
"use client";
import { IconCheck } from "@tabler/icons-react";
import { Group, SelectProps } from "@mantine/core";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
import DealTag from "@/components/ui/DealTag/DealTag";
import { DealTagColorSchema } from "@/lib/client";
import useTagColorList from "../hooks/useTagColorList";
type Props = Omit<
ObjectSelectProps<DealTagColorSchema>,
"data" | "getValueFn" | "getLabelFn"
>;
const TagColorInput = (props: Props) => {
const { colors } = useTagColorList();
const colorsMap = new Map<string, DealTagColorSchema>(
colors.map(
color =>
[color.id.toString(), color] as [string, DealTagColorSchema]
)
);
const renderSelectOption: SelectProps["renderOption"] = ({
option,
checked,
}) => {
const tag = {
id: Number(option.value),
name: "Тег-пример",
tagColor: colorsMap.get(option.value),
};
return (
<Group
flex="1"
gap="md">
<DealTag tag={tag} />
{option.label}
{checked && <IconCheck style={{ marginInlineStart: "auto" }} />}
</Group>
);
};
return (
<ObjectSelect
label={"Цвет"}
renderOption={renderSelectOption}
data={colors}
getValueFn={color => color.id.toString()}
getLabelFn={color => color.label}
searchable
{...props}
/>
);
};
export default TagColorInput;

View File

@ -1,21 +0,0 @@
import { IconPlus } from "@tabler/icons-react";
import { Group } from "@mantine/core";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import useDealTagActions from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/hooks/useDealTagActions";
const TagsTabHeader = () => {
const { onCreateClick } = useDealTagActions();
return (
<Group
pt={"xs"}
px={"xs"}>
<InlineButton onClick={onCreateClick}>
<IconPlus />
Создать
</InlineButton>
</Group>
);
};
export default TagsTabHeader;

View File

@ -1,38 +0,0 @@
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { useDealTagsContext } from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/contexts/DealTagsContext";
import tagsTableColumns from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/hooks/tagsTableColumns";
import useDealTagActions from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/hooks/useDealTagActions";
const TagsTable = () => {
const { dealTags, dealTagsCrud } = useDealTagsContext();
const { onChangeClick } = useDealTagActions();
const columns = tagsTableColumns({
onDelete: dealTagsCrud.onDelete,
onChange: onChangeClick,
});
return (
<BaseTable
withTableBorder
records={dealTags}
columns={columns}
groups={undefined}
style={{
marginInline: "var(--mantine-spacing-xs)",
minHeight: 200,
}}
verticalSpacing={"xs"}
emptyState={
<Group mt={dealTags.length === 0 ? "xl" : 0}>
<Text>Нет тегов</Text>
<IconMoodSad />
</Group>
}
/>
);
};
export default TagsTable;

View File

@ -1,36 +0,0 @@
"use client";
import { DealTagsCrud, useDealTagsCrud } from "@/hooks/cruds/useDealTagsCrud";
import useDealTagsList from "@/hooks/lists/useDealTagsList";
import { DealTagSchema, ProjectSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
type DealTagsContextState = {
dealTags: DealTagSchema[];
refetchDealTags: () => void;
dealTagsCrud: DealTagsCrud;
};
type Props = {
project: ProjectSchema;
};
const useDealTagsContextState = ({ project }: Props): DealTagsContextState => {
const dealTagsList = useDealTagsList({ projectId: project.id });
const dealTagsCrud = useDealTagsCrud({
...dealTagsList,
projectId: project.id,
});
return {
dealTags: dealTagsList.dealTags,
refetchDealTags: dealTagsList.refetch,
dealTagsCrud,
};
};
export const [DealTagsContextProvider, useDealTagsContext] = makeContext<
DealTagsContextState,
Props
>(useDealTagsContextState, "DealTags");

View File

@ -1,46 +0,0 @@
import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable";
import { Center } from "@mantine/core";
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
import DealTag from "@/components/ui/DealTag/DealTag";
import { DealTagSchema } from "@/lib/client";
type Props = {
onDelete: (tag: DealTagSchema) => void;
onChange: (tag: DealTagSchema) => void;
};
const useTagsTableColumns = ({ onDelete, onChange }: Props) => {
return useMemo(
() =>
[
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: tag => (
<UpdateDeleteTableActions
onDelete={() => onDelete(tag)}
onChange={() => onChange(tag)}
/>
),
},
{
title: "Название",
accessor: "name",
},
{
title: "Цвет",
accessor: "tagColor.label",
},
{
title: "Пример",
accessor: "tagColor",
render: tag => <DealTag tag={tag} />,
},
] as DataTableColumn<DealTagSchema>[],
[]
);
};
export default useTagsTableColumns;

View File

@ -1,37 +0,0 @@
import { modals } from "@mantine/modals";
import { useDealTagsContext } from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/contexts/DealTagsContext";
import { DealTagSchema } from "@/lib/client";
const useDealTagActions = () => {
const { dealTagsCrud } = useDealTagsContext();
const onChangeClick = (tag: DealTagSchema) => {
modals.openContextModal({
modal: "dealTagModal",
innerProps: {
entity: tag,
onChange: data => dealTagsCrud.onUpdate(tag.id, data),
isEditing: true,
},
withCloseButton: false,
});
};
const onCreateClick = () => {
modals.openContextModal({
modal: "dealTagModal",
innerProps: {
onCreate: dealTagsCrud.onCreate,
isEditing: false,
},
withCloseButton: false,
});
};
return {
onChangeClick,
onCreateClick,
};
};
export default useDealTagActions;

View File

@ -1,13 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { getDealTagColorsOptions } from "@/lib/client/@tanstack/react-query.gen";
const useTagColorList = () => {
const { data, refetch } = useQuery(getDealTagColorsOptions());
return {
colors: data?.items ?? [],
refetch,
};
};
export default useTagColorList;

View File

@ -1,68 +0,0 @@
"use client";
import { Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { ContextModalProps } from "@mantine/modals";
import TagColorInput from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/components/TagColorInput";
import {
CreateDealTagSchema,
DealTagSchema,
UpdateDealTagSchema,
} from "@/lib/client";
import BaseFormModal, {
CreateEditFormProps,
} from "@/modals/base/BaseFormModal/BaseFormModal";
type Props = CreateEditFormProps<
CreateDealTagSchema,
UpdateDealTagSchema,
DealTagSchema
>;
const DealTagModal = ({
context,
id,
innerProps,
}: ContextModalProps<Props>) => {
const initialValues: Partial<DealTagSchema> = innerProps.isEditing
? innerProps.entity
: {
name: "",
tagColor: undefined,
tagColorId: undefined,
};
const form = useForm<Partial<DealTagSchema>>({
initialValues,
validate: {
name: name => !name && "Необходимо указать название тега",
tagColor: tagColor => !tagColor && "Необходимо указать цвет тега",
},
});
return (
<BaseFormModal
form={form}
closeOnSubmit
onClose={() => context.closeContextModal(id)}
{...innerProps}>
<Stack>
<TextInput
label={"Название"}
placeholder={"Введите название тега"}
{...form.getInputProps("name")}
/>
<TagColorInput
placeholder={"Укажите цвет"}
{...form.getInputProps("tagColor")}
onChange={tag => {
form.setFieldValue("tagColor", tag);
form.setFieldValue("tagColorId", tag.id);
}}
/>
</Stack>
</BaseFormModal>
);
};
export default DealTagModal;

View File

@ -1,9 +1,9 @@
import ClientMarketplaceDrawer from "@/app/clients/drawers/ClientMarketplacesDrawer"; import ClientMarketplaceDrawer from "@/app/clients/drawers/ClientMarketplacesDrawer";
import BoardsMobileEditorDrawer from "@/app/deals/drawers/BoardsMobileEditorDrawer"; import BoardsMobileEditorDrawer from "@/app/deals/drawers/BoardsMobileEditorDrawer";
import DealEditorDrawer from "@/app/deals/drawers/DealEditorDrawer"; import DealEditorDrawer from "@/app/deals/drawers/DealEditorDrawer";
import ProjectEditorDrawer from "@/app/deals/drawers/ProjectEditorDrawer";
import ProjectsMobileEditorDrawer from "@/app/deals/drawers/ProjectsMobileEditorDrawer"; import ProjectsMobileEditorDrawer from "@/app/deals/drawers/ProjectsMobileEditorDrawer";
import StatusesMobileEditorDrawer from "../app/deals/drawers/StatusesMobileEditorDrawer"; import StatusesMobileEditorDrawer from "../app/deals/drawers/StatusesMobileEditorDrawer";
import ProjectEditorDrawer from "./common/ProjectEditorDrawer";
const drawerRegistry = { const drawerRegistry = {
projectsMobileEditorDrawer: ProjectsMobileEditorDrawer, projectsMobileEditorDrawer: ProjectsMobileEditorDrawer,

View File

@ -1,115 +1,51 @@
import React from "react"; import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { UpdateDealGroupSchema } from "@/lib/client";
import { AxiosError } from "axios";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { HttpValidationError, UpdateDealGroupSchema } from "@/lib/client";
import { import {
addDealMutation,
createDealGroupMutation, createDealGroupMutation,
deleteDealGroupMutation, removeDealMutation,
updateDealGroupMutation, updateDealGroupMutation,
updateDealsInGroupMutation,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
export type GroupsCrud = { const useDealGroupCrud = () => {
onUpdate: (groupId: number, group: UpdateDealGroupSchema) => void; const updateMutation = useMutation(updateDealGroupMutation());
onCreate: (mainDealId: number, otherDealIds: number[]) => void;
onUpdateDealsInGroup: (groupId: number, dealIds: number[]) => void;
onDelete: (groupId: number) => void;
};
const useDealGroupCrud = (): GroupsCrud => { const onUpdate = (entity: UpdateDealGroupSchema) => {
const queryClient = useQueryClient();
const key = "getDeals";
const onError = (
error: AxiosError<HttpValidationError>,
_: any,
context: any
) => {
console.error(error);
notifications.error({
message: error.response?.data?.detail as string | undefined,
});
if (context?.previous) {
queryClient.setQueryData([key], context.previous);
}
};
const onSettled = () => {
queryClient.invalidateQueries({
predicate: (query: { queryKey: any }) =>
query.queryKey[0]?._id === key,
});
};
const updateMutation = useMutation({
...updateDealGroupMutation(),
onSettled,
onError,
});
const onUpdate = (groupId: number, entity: UpdateDealGroupSchema) => {
updateMutation.mutate({ updateMutation.mutate({
path: {
pk: groupId,
},
body: { body: {
entity, entity,
}, },
}); });
}; };
const createMutation = useMutation({ const createMutation = useMutation(createDealGroupMutation());
...createDealGroupMutation(),
onSettled,
onError,
});
const onCreate = (mainDealId: number, otherDealIds: number[]) => { const onCreate = (draggingDealId: number, hoveredDealId: number) => {
createMutation.mutate({ createMutation.mutate({
body: { body: {
mainDealId, draggingDealId,
otherDealIds, hoveredDealId,
}, },
}); });
}; };
const updateDealsMutation = useMutation({ const addDealToGroupMutation = useMutation(addDealMutation());
...updateDealsInGroupMutation(),
onSettled,
onError,
});
const onUpdateDealsInGroup = (groupId: number, dealIds: number[]) => { const onAddDeal = (dealId: number, groupId: number) => {
updateDealsMutation.mutate({ addDealToGroupMutation.mutate({
path: {
pk: groupId,
},
body: { body: {
dealIds, dealId,
groupId,
}, },
}); });
}; };
const deleteMutation = useMutation({ const removeDealFromGroupMutation = useMutation(removeDealMutation());
...deleteDealGroupMutation(),
onSettled,
onError,
});
const onDelete = (groupId: number) => { const onRemoveDeal = (dealId: number) => {
modals.openConfirmModal({ removeDealFromGroupMutation.mutate({
title: "Удаление группы", body: {
children: <Text>Вы уверены, что хотите удалить группу?</Text>, dealId,
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate({
path: {
pk: groupId,
},
});
}, },
}); });
}; };
@ -117,8 +53,8 @@ const useDealGroupCrud = (): GroupsCrud => {
return { return {
onUpdate, onUpdate,
onCreate, onCreate,
onUpdateDealsInGroup, onAddDeal,
onDelete, onRemoveDeal,
}; };
}; };

View File

@ -1,53 +0,0 @@
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
import {
CreateDealTagSchema,
DealTagSchema,
UpdateDealTagSchema,
} from "@/lib/client";
import {
createDealTagMutation,
deleteDealTagMutation,
updateDealTagMutation,
} from "@/lib/client/@tanstack/react-query.gen";
type UseDealTagsOperationsProps = {
queryKey: any[];
projectId: number;
};
export type DealTagsCrud = {
onCreate: (data: CreateDealTagSchema) => void;
onUpdate: (dealTagId: number, dealTag: UpdateDealTagSchema) => void;
onDelete: (dealTag: DealTagSchema) => void;
};
export const useDealTagsCrud = ({
queryKey,
projectId,
}: UseDealTagsOperationsProps): DealTagsCrud => {
return useCrudOperations<
DealTagSchema,
UpdateDealTagSchema,
CreateDealTagSchema
>({
key: "getDealTags",
queryKey,
mutations: {
create: createDealTagMutation(),
update: updateDealTagMutation(),
delete: deleteDealTagMutation(),
},
getCreateEntity: data => ({
tagColorId: data.tagColorId!,
name: data.name!,
projectId,
}),
getUpdateEntity: (old, update) => ({
...old,
name: update.name ?? old.name,
tagColor: update.tagColor ?? old.tagColor,
tagColorId: update.tagColor?.id ?? old.tagColorId,
}),
getDeleteConfirmTitle: () => "Удаление доски",
});
};

View File

@ -1,39 +0,0 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { DealTagSchema } from "@/lib/client";
import {
getDealTagsOptions,
getDealTagsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
type Props = {
projectId: number;
};
const useDealTagsList = ({ projectId }: Props) => {
const queryClient = useQueryClient();
const options = {
path: { projectId },
};
const { data, refetch } = useQuery(getDealTagsOptions(options));
const queryKey = getDealTagsQueryKey(options);
const setDealTags = (dealTags: DealTagSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: DealTagSchema[] }) => ({
...old,
items: dealTags,
})
);
};
return {
dealTags: data?.items ?? [],
setDealTags,
refetch,
queryKey,
};
};
export default useDealTagsList;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,21 @@
import { z } from "zod"; import { z } from "zod";
/**
* AddDealToGroupRequest
*/
export const zAddDealToGroupRequest = z.object({
dealId: z.int(),
groupId: z.int(),
});
/**
* AddDealToGroupResponse
*/
export const zAddDealToGroupResponse = z.object({
message: z.string(),
});
/** /**
* BarcodeTemplateAttributeSchema * BarcodeTemplateAttributeSchema
*/ */
@ -197,8 +212,8 @@ export const zCreateClientResponse = z.object({
* CreateDealGroupRequest * CreateDealGroupRequest
*/ */
export const zCreateDealGroupRequest = z.object({ export const zCreateDealGroupRequest = z.object({
mainDealId: z.int(), draggingDealId: z.int(),
otherDealIds: z.array(z.int()), hoveredDealId: z.int(),
}); });
/** /**
@ -348,27 +363,6 @@ export const zStatusSchema = z.object({
color: z.string(), color: z.string(),
}); });
/**
* DealTagColorSchema
*/
export const zDealTagColorSchema = z.object({
id: z.int(),
color: z.string(),
backgroundColor: z.string(),
label: z.string(),
});
/**
* DealTagSchema
*/
export const zDealTagSchema = z.object({
name: z.string(),
projectId: z.int(),
tagColorId: z.int(),
id: z.int(),
tagColor: zDealTagColorSchema,
});
/** /**
* DealSchema * DealSchema
*/ */
@ -382,7 +376,6 @@ export const zDealSchema = z.object({
offset: true, offset: true,
}), }),
group: z.union([zDealGroupSchema, z.null()]), group: z.union([zDealGroupSchema, z.null()]),
tags: z.array(zDealTagSchema),
productsQuantity: z.optional(z.int()).default(0), productsQuantity: z.optional(z.int()).default(0),
totalPrice: z.optional(z.number()).default(0), totalPrice: z.optional(z.number()).default(0),
client: z.optional(z.union([zClientSchema, z.null()])), client: z.optional(z.union([zClientSchema, z.null()])),
@ -433,30 +426,6 @@ export const zCreateDealServiceResponse = z.object({
entity: zDealServiceSchema, entity: zDealServiceSchema,
}); });
/**
* CreateDealTagSchema
*/
export const zCreateDealTagSchema = z.object({
name: z.string(),
projectId: z.int(),
tagColorId: z.int(),
});
/**
* CreateDealTagRequest
*/
export const zCreateDealTagRequest = z.object({
entity: zCreateDealTagSchema,
});
/**
* CreateDealTagResponse
*/
export const zCreateDealTagResponse = z.object({
message: z.string(),
entity: zDealTagSchema,
});
/** /**
* CreateMarketplaceSchema * CreateMarketplaceSchema
*/ */
@ -572,7 +541,6 @@ export const zProjectSchema = z.object({
id: z.int(), id: z.int(),
name: z.string(), name: z.string(),
builtInModules: z.array(zBuiltInModuleSchemaOutput), builtInModules: z.array(zBuiltInModuleSchemaOutput),
tags: z.array(zDealTagSchema),
}); });
/** /**
@ -726,6 +694,20 @@ export const zDealProductAddKitResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* DealRemoveFromGroupRequest
*/
export const zDealRemoveFromGroupRequest = z.object({
dealId: z.int(),
});
/**
* DealRemoveFromGroupResponse
*/
export const zDealRemoveFromGroupResponse = z.object({
message: z.string(),
});
/** /**
* DeleteBarcodeTemplateResponse * DeleteBarcodeTemplateResponse
*/ */
@ -747,13 +729,6 @@ export const zDeleteClientResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* DeleteDealGroupResponse
*/
export const zDeleteDealGroupResponse = z.object({
message: z.string(),
});
/** /**
* DeleteDealProductResponse * DeleteDealProductResponse
*/ */
@ -775,13 +750,6 @@ export const zDeleteDealServiceResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* DeleteDealTagResponse
*/
export const zDeleteDealTagResponse = z.object({
message: z.string(),
});
/** /**
* DeleteMarketplaceResponse * DeleteMarketplaceResponse
*/ */
@ -901,13 +869,6 @@ export const zGetDealServicesResponse = z.object({
items: z.array(zDealServiceSchema), items: z.array(zDealServiceSchema),
}); });
/**
* GetDealTagsResponse
*/
export const zGetDealTagsResponse = z.object({
items: z.array(zDealTagSchema),
});
/** /**
* PaginationInfoSchema * PaginationInfoSchema
*/ */
@ -957,13 +918,6 @@ export const zGetProductsResponse = z.object({
paginationInfo: zPaginationInfoSchema, paginationInfo: zPaginationInfoSchema,
}); });
/**
* GetProjectResponse
*/
export const zGetProjectResponse = z.object({
entity: zProjectSchema,
});
/** /**
* GetProjectsResponse * GetProjectsResponse
*/ */
@ -1019,13 +973,6 @@ export const zGetStatusesResponse = z.object({
items: z.array(zStatusSchema), items: z.array(zStatusSchema),
}); });
/**
* GetTagColorsResponse
*/
export const zGetTagColorsResponse = z.object({
items: z.array(zDealTagColorSchema),
});
/** /**
* ValidationError * ValidationError
*/ */
@ -1069,22 +1016,6 @@ export const zProductServicesDuplicateResponse = z.object({
export const zSortDir = z.enum(["asc", "desc"]); export const zSortDir = z.enum(["asc", "desc"]);
/**
* SwitchDealTagRequest
*/
export const zSwitchDealTagRequest = z.object({
tagId: z.int(),
dealId: z.optional(z.union([z.int(), z.null()])),
groupId: z.optional(z.union([z.int(), z.null()])),
});
/**
* SwitchDealTagResponse
*/
export const zSwitchDealTagResponse = z.object({
message: z.string(),
});
/** /**
* UpdateBarcodeTemplateSchema * UpdateBarcodeTemplateSchema
*/ */
@ -1163,7 +1094,6 @@ export const zUpdateClientResponse = z.object({
export const zUpdateDealGroupSchema = z.object({ export const zUpdateDealGroupSchema = z.object({
name: z.optional(z.union([z.string(), z.null()])), name: z.optional(z.union([z.string(), z.null()])),
lexorank: z.optional(z.union([z.string(), z.null()])), lexorank: z.optional(z.union([z.string(), z.null()])),
statusId: z.optional(z.union([z.int(), z.null()])),
}); });
/** /**
@ -1250,42 +1180,6 @@ export const zUpdateDealServiceResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* UpdateDealTagSchema
*/
export const zUpdateDealTagSchema = z.object({
name: z.optional(z.union([z.string(), z.null()])),
tagColor: z.optional(z.union([zDealTagColorSchema, z.null()])),
});
/**
* UpdateDealTagRequest
*/
export const zUpdateDealTagRequest = z.object({
entity: zUpdateDealTagSchema,
});
/**
* UpdateDealTagResponse
*/
export const zUpdateDealTagResponse = z.object({
message: z.string(),
});
/**
* UpdateDealsInGroupRequest
*/
export const zUpdateDealsInGroupRequest = z.object({
dealIds: z.array(z.int()),
});
/**
* UpdateDealsInGroupResponse
*/
export const zUpdateDealsInGroupResponse = z.object({
message: z.string(),
});
/** /**
* UpdateMarketplaceSchema * UpdateMarketplaceSchema
*/ */
@ -1594,24 +1488,9 @@ export const zUpdateDealData = z.object({
*/ */
export const zUpdateDealResponse2 = zUpdateDealResponse; export const zUpdateDealResponse2 = zUpdateDealResponse;
export const zDeleteDealGroupData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zDeleteDealGroupResponse2 = zDeleteDealGroupResponse;
export const zUpdateDealGroupData = z.object({ export const zUpdateDealGroupData = z.object({
body: zUpdateDealGroupRequest, body: zUpdateDealGroupRequest,
path: z.object({ path: z.optional(z.never()),
pk: z.int(),
}),
query: z.optional(z.never()), query: z.optional(z.never()),
}); });
@ -1631,34 +1510,8 @@ export const zCreateDealGroupData = z.object({
*/ */
export const zCreateDealGroupResponse2 = zCreateDealGroupResponse; export const zCreateDealGroupResponse2 = zCreateDealGroupResponse;
export const zUpdateDealsInGroupData = z.object({ export const zRemoveDealData = z.object({
body: zUpdateDealsInGroupRequest, body: zDealRemoveFromGroupRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateDealsInGroupResponse2 = zUpdateDealsInGroupResponse;
export const zGetDealTagsData = z.object({
body: z.optional(z.never()),
path: z.object({
projectId: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetDealTagsResponse2 = zGetDealTagsResponse;
export const zCreateDealTagData = z.object({
body: zCreateDealTagRequest,
path: z.optional(z.never()), path: z.optional(z.never()),
query: z.optional(z.never()), query: z.optional(z.never()),
}); });
@ -1666,36 +1519,10 @@ export const zCreateDealTagData = z.object({
/** /**
* Successful Response * Successful Response
*/ */
export const zCreateDealTagResponse2 = zCreateDealTagResponse; export const zRemoveDealResponse = zDealRemoveFromGroupResponse;
export const zDeleteDealTagData = z.object({ export const zAddDealData = z.object({
body: z.optional(z.never()), body: zAddDealToGroupRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zDeleteDealTagResponse2 = zDeleteDealTagResponse;
export const zUpdateDealTagData = z.object({
body: zUpdateDealTagRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateDealTagResponse2 = zUpdateDealTagResponse;
export const zSwitchDealTagData = z.object({
body: zSwitchDealTagRequest,
path: z.optional(z.never()), path: z.optional(z.never()),
query: z.optional(z.never()), query: z.optional(z.never()),
}); });
@ -1703,18 +1530,7 @@ export const zSwitchDealTagData = z.object({
/** /**
* Successful Response * Successful Response
*/ */
export const zSwitchDealTagResponse2 = zSwitchDealTagResponse; export const zAddDealResponse = zAddDealToGroupResponse;
export const zGetDealTagColorsData = z.object({
body: z.optional(z.never()),
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetDealTagColorsResponse = zGetTagColorsResponse;
export const zGetBuiltInModulesData = z.object({ export const zGetBuiltInModulesData = z.object({
body: z.optional(z.never()), body: z.optional(z.never()),
@ -1727,6 +1543,117 @@ export const zGetBuiltInModulesData = z.object({
*/ */
export const zGetBuiltInModulesResponse = zGetAllBuiltInModulesResponse; export const zGetBuiltInModulesResponse = zGetAllBuiltInModulesResponse;
export const zGetProjectsData = z.object({
body: z.optional(z.never()),
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetProjectsResponse2 = zGetProjectsResponse;
export const zCreateProjectData = z.object({
body: zCreateProjectRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateProjectResponse2 = zCreateProjectResponse;
export const zDeleteProjectData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zDeleteProjectResponse2 = zDeleteProjectResponse;
export const zUpdateProjectData = z.object({
body: zUpdateProjectRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateProjectResponse2 = zUpdateProjectResponse;
export const zGetStatusesData = z.object({
body: z.optional(z.never()),
path: z.object({
boardId: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetStatusesResponse2 = zGetStatusesResponse;
export const zCreateStatusData = z.object({
body: zCreateStatusRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateStatusResponse2 = zCreateStatusResponse;
export const zDeleteStatusData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zDeleteStatusResponse2 = zDeleteStatusResponse;
export const zUpdateStatusData = z.object({
body: zUpdateStatusRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateStatusResponse2 = zUpdateStatusResponse;
export const zGetStatusHistoryData = z.object({
body: z.optional(z.never()),
path: z.object({
dealId: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetStatusHistoryResponse2 = zGetStatusHistoryResponse;
export const zGetClientsData = z.object({ export const zGetClientsData = z.object({
body: z.optional(z.never()), body: z.optional(z.never()),
path: z.optional(z.never()), path: z.optional(z.never()),
@ -2300,127 +2227,3 @@ export const zUpdateServicesKitData = z.object({
* Successful Response * Successful Response
*/ */
export const zUpdateServicesKitResponse2 = zUpdateServicesKitResponse; export const zUpdateServicesKitResponse2 = zUpdateServicesKitResponse;
export const zGetProjectsData = z.object({
body: z.optional(z.never()),
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetProjectsResponse2 = zGetProjectsResponse;
export const zCreateProjectData = z.object({
body: zCreateProjectRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateProjectResponse2 = zCreateProjectResponse;
export const zDeleteProjectData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zDeleteProjectResponse2 = zDeleteProjectResponse;
export const zGetProjectData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetProjectResponse2 = zGetProjectResponse;
export const zUpdateProjectData = z.object({
body: zUpdateProjectRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateProjectResponse2 = zUpdateProjectResponse;
export const zGetStatusesData = z.object({
body: z.optional(z.never()),
path: z.object({
boardId: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetStatusesResponse2 = zGetStatusesResponse;
export const zCreateStatusData = z.object({
body: zCreateStatusRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateStatusResponse2 = zCreateStatusResponse;
export const zDeleteStatusData = z.object({
body: z.optional(z.never()),
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zDeleteStatusResponse2 = zDeleteStatusResponse;
export const zUpdateStatusData = z.object({
body: zUpdateStatusRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateStatusResponse2 = zUpdateStatusResponse;
export const zGetStatusHistoryData = z.object({
body: z.optional(z.never()),
path: z.object({
dealId: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zGetStatusHistoryResponse2 = zGetStatusHistoryResponse;

View File

@ -20,7 +20,6 @@ import {
ProductServiceEditorModal, ProductServiceEditorModal,
ServicesKitSelectModal, ServicesKitSelectModal,
} from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals"; } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/modals";
import DealTagModal from "@/drawers/common/ProjectEditorDrawer/tabs/TagsTab/modals/DealTagModal";
export const modals = { export const modals = {
enterNameModal: EnterNameModal, enterNameModal: EnterNameModal,
@ -41,5 +40,4 @@ export const modals = {
printBarcodeModal: PrintBarcodeModal, printBarcodeModal: PrintBarcodeModal,
statusColorPickerModal: ColorPickerModal, statusColorPickerModal: ColorPickerModal,
marketplaceEditorModal: MarketplaceEditorModal, marketplaceEditorModal: MarketplaceEditorModal,
dealTagModal: DealTagModal,
}; };

View File

@ -5,7 +5,6 @@ import {
Image, Image,
NumberInput, NumberInput,
Stack, Stack,
Text,
Textarea, Textarea,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@ -17,9 +16,9 @@ import {
duplicateProductServices, duplicateProductServices,
ServicesKitSchema, ServicesKitSchema,
} from "@/lib/client"; } from "@/lib/client";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductFieldsList"; import ProductFieldsList from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductFieldsList";
import ProductViewActions from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductViewActions"; import ProductViewActions from "@/modules/dealModularEditorTabs/FulfillmentBase/desktop/components/ProductView/components/ProductViewActions";
import { useFulfillmentBaseContext } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/contexts/FulfillmentBaseContext";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service"; import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service";
import ProductServicesTable from "./components/ProductServicesTable"; import ProductServicesTable from "./components/ProductServicesTable";
import styles from "../../../FulfillmentBase.module.css"; import styles from "../../../FulfillmentBase.module.css";
@ -117,10 +116,10 @@ const ProductView: FC<Props> = ({ dealProduct }) => {
)} )}
<Title order={3}>{dealProduct.product.name}</Title> <Title order={3}>{dealProduct.product.name}</Title>
<ProductFieldsList product={dealProduct.product} /> <ProductFieldsList product={dealProduct.product} />
<Text> {/*<Text>*/}
Штрихкоды: {/* Штрихкоды:*/}
{dealProduct.product.barcodes.join(", ")} {/*{value.product.barcodes.join(", ")}*/}
</Text> {/*</Text>*/}
<NumberInput <NumberInput
suffix={" шт."} suffix={" шт."}
value={dealProduct.quantity} value={dealProduct.quantity}

View File

@ -1,5 +1,5 @@
import { FC } from "react"; import { FC } from "react";
import { IconBarcode, IconEdit, IconTrash } from "@tabler/icons-react"; import { IconEdit, IconTrash } from "@tabler/icons-react";
import { Flex } from "@mantine/core"; import { Flex } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip"; import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
@ -33,28 +33,18 @@ const ProductViewActions: FC<Props> = ({ dealProduct }) => {
}); });
}; };
const onPrintBarcodeClick = () => {
modals.openContextModal({
modal: "printBarcodeModal",
title: "Печать штрихкода",
withCloseButton: true,
innerProps: {
product: dealProduct.product,
defaultQuantity: dealProduct.quantity,
},
});
};
return ( return (
<Flex <Flex
mt={"auto"} mt={"auto"}
ml={"auto"} ml={"auto"}
gap={"sm"}> gap={"sm"}>
<ActionIconWithTip {/*<Tooltip*/}
onClick={onPrintBarcodeClick} {/* onClick={onPrintBarcodeClick}*/}
tipLabel="Печать штрихкода"> {/* label="Печать штрихкода">*/}
<IconBarcode /> {/* <ActionIcon variant={"default"}>*/}
</ActionIconWithTip> {/* <IconBarcode />*/}
{/* </ActionIcon>*/}
{/*</Tooltip>*/}
<ActionIconWithTip <ActionIconWithTip
onClick={onProductEditClick} onClick={onProductEditClick}
tipLabel="Редактировать товар"> tipLabel="Редактировать товар">

View File

@ -1,9 +0,0 @@
import { IconPlus } from "@tabler/icons-react";
type BuiltInLinkData = {
icon: typeof IconPlus;
label: string;
href: string;
};
export default BuiltInLinkData;

812
yarn.lock

File diff suppressed because it is too large Load Diff