Compare commits

...

11 Commits

74 changed files with 4617 additions and 1271 deletions

View File

@ -38,6 +38,7 @@
"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,12 +1,14 @@
"use client"; "use client";
import { useMemo } from "react"; import { RefObject, useMemo, useRef } 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 } =
@ -20,6 +22,14 @@ 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"}>
@ -33,7 +43,10 @@ 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

@ -0,0 +1,41 @@
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,11 +1,13 @@
.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,11 +1,47 @@
.container { .container {
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,21 +1,33 @@
import { Box, Card, Group, Pill, Stack, Text } from "@mantine/core"; import { IconCategoryPlus } from "@tabler/icons-react";
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 }: Props) => { const DealCard = ({ deal, isInGroup = false }: Props) => {
const { selectedProject, modulesSet } = useProjectsContext(); const { selectedProject, modulesSet } = useProjectsContext();
const { dealsCrud, refetchDeals } = useDealsContext(); const { dealsCrud, refetchDeals, groupDealsSelection } = 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: {
@ -28,10 +40,38 @@ const DealCard = ({ deal }: 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={styles.container}> className={classNames(
getSelectedStyles(),
isInGroup ? styles["container-in-group"] : styles.container
)}
onContextMenu={showContextMenu(dealContextMenu)}>
<Group <Group
justify={"space-between"} justify={"space-between"}
wrap={"nowrap"} wrap={"nowrap"}
@ -61,10 +101,7 @@ const DealCard = ({ deal }: Props) => {
</> </>
)} )}
</Stack> </Stack>
<Group gap={"xs"}> {!deal.group && <DealTags dealId={deal.id} tags={deal.tags} />}
<Pill className={styles["first-tag"]}>Срочно</Pill>
<Pill className={styles["second-tag"]}>Бесплатно</Pill>
</Group>
</Stack> </Stack>
</Card> </Card>
); );

View File

@ -1,25 +0,0 @@
import React, { FC, useMemo } from "react";
import { Box } from "@mantine/core";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import SortableItem from "@/components/dnd/SortableItem";
import { DealSchema } from "@/lib/client";
type Props = {
deal: DealSchema;
};
const DealContainer: FC<Props> = ({ deal }) => {
const dealBody = useMemo(() => <DealCard deal={deal} />, [deal]);
return (
<Box>
<SortableItem
dragHandleStyle={{ cursor: "pointer" }}
id={deal.id}
renderItem={() => dealBody}
/>
</Box>
);
};
export default DealContainer;

View File

@ -0,0 +1,22 @@
.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

@ -0,0 +1,101 @@
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

@ -2,7 +2,7 @@
import React, { FC, ReactNode } from "react"; import React, { FC, ReactNode } from "react";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard"; import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import DealContainer from "@/app/deals/components/shared/DealContainer/DealContainer"; 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 StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext"; import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
@ -11,37 +11,36 @@ import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd"; import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client"; 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 { selectedBoard } = useBoardsContext();
const { deals } = useDealsContext(); const { dealsWithoutGroup, groupsWithDeals } = useDealsContext();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { const { sortedStatuses, handleDragOver, handleDragEnd, swiperRef } =
sortedStatuses, useDealsAndStatusesDnd();
handleDragStart,
handleDragOver,
handleDragEnd,
activeStatus,
activeDeal,
swiperRef,
} = useDealsAndStatusesDnd();
return ( return (
<FunnelDnd <FunnelDnd<StatusSchema, DealSchema, GroupWithDealsSchema>
containers={sortedStatuses} containers={sortedStatuses}
items={deals} itemsAndGroups={sortByLexorank([
onDragStart={handleDragStart} ...dealsWithoutGroup,
...groupsWithDeals,
])}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
swiperRef={swiperRef} swiperRef={swiperRef}
getContainerId={(status: StatusSchema) => `${status.id}-status`} getItemsByContainer={(status: StatusSchema) =>
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) => sortByLexorank([
sortByLexorank( ...dealsWithoutGroup.filter(
items.filter(deal => deal.status.id === status.id) deal => deal.status.id === status.id
) ),
...groupsWithDeals.filter(
group => group.items[0].status.id === status.id
),
])
} }
renderContainer={( renderContainer={(
status: StatusSchema, status: StatusSchema,
@ -59,25 +58,28 @@ const Funnel: FC = () => {
renderContainerHeader={status => ( renderContainerHeader={status => (
<StatusColumnHeader <StatusColumnHeader
status={status} status={status}
isDragging={activeStatus?.id === status.id} isDragging={false}
/> />
)} )}
renderItem={(deal: DealSchema) => ( renderItem={(deal: DealSchema) => (
<DealContainer <DealCard
key={deal.id} key={deal.id}
deal={deal} deal={deal}
/> />
)} )}
activeContainer={activeStatus} renderGroup={(group: GroupWithDealsSchema) => (
activeItem={activeDeal} <DealsGroup
renderItemOverlay={(deal: DealSchema) => <DealCard deal={deal} />} key={`${group.id}group`}
group={group}
/>
)}
renderContainerOverlay={(status: StatusSchema, children) => ( renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper <StatusColumnWrapper
status={status} status={status}
renderHeader={() => ( renderHeader={() => (
<StatusColumnHeader <StatusColumnHeader
status={status} status={status}
isDragging={activeStatus?.id === status.id} isDragging
/> />
)}> )}>
{children} {children}

View File

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

View File

@ -0,0 +1,52 @@
"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

@ -2,6 +2,7 @@ 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";
@ -14,6 +15,7 @@ type Props = {
const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => { const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
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();
@ -59,6 +61,9 @@ const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
} }
refetchStatuses={refetchStatuses} refetchStatuses={refetchStatuses}
onDeleteStatus={statusesCrud.onDelete} onDeleteStatus={statusesCrud.onDelete}
startDealsSelecting={
groupDealsSelection.startSelecting
}
/> />
</> </>
)} )}

View File

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

View File

@ -1,11 +1,13 @@
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,26 +1,36 @@
"use client"; "use client";
import React from "react"; import { Dispatch, SetStateAction } 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 { 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";
import { DealSchema, PaginationInfoSchema } from "@/lib/client"; import { DealSchema, PaginationInfoSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory"; import makeContext from "@/lib/contextFactory/contextFactory";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type DealsContextState = { type DealsContextState = {
deals: DealSchema[]; deals: DealSchema[];
setDeals: (deals: DealSchema[]) => void; setDeals: (deals: DealSchema[]) => void;
dealsWithoutGroup: DealSchema[];
groupsWithDeals: GroupWithDealsSchema[];
refetchDeals: () => void; refetchDeals: () => void;
dealsCrud: DealsCrud; dealsCrud: DealsCrud;
groupsCrud: GroupsCrud;
paginationInfo?: PaginationInfoSchema; paginationInfo?: PaginationInfoSchema;
page: number; page: number;
setPage: React.Dispatch<React.SetStateAction<number>>; setPage: Dispatch<SetStateAction<number>>;
dealsFiltersForm: UseFormReturnType<DealsFiltersForm>; dealsFiltersForm: UseFormReturnType<DealsFiltersForm>;
isChangedFilters: boolean; isChangedFilters: boolean;
sortingForm: UseFormReturnType<SortingForm>; sortingForm: UseFormReturnType<SortingForm>;
groupDealsSelection: GroupDealsSelection;
}; };
type Props = { type Props = {
@ -48,9 +58,20 @@ const useDealsContextState = ({
statuses, statuses,
}); });
const groupsCrud = useDealGroupCrud();
const groupDealsSelection = useGroupDealsSelection({ groupsCrud });
const { dealsWithoutGroup, groupsWithDeals } =
useDealsAndGroups(dealsListObjects);
return { return {
...dealsListObjects, ...dealsListObjects,
dealsCrud, dealsCrud,
groupsCrud,
dealsWithoutGroup,
groupsWithDeals,
groupDealsSelection,
}; };
}; };

View File

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

View File

@ -2,6 +2,7 @@ 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";
@ -12,6 +13,7 @@ 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({
@ -40,8 +42,11 @@ const StatusMobile: FC<Props> = ({ status, board }) => {
board={board} board={board}
onDeleteStatus={statusesCrud.onDelete} onDeleteStatus={statusesCrud.onDelete}
handleEdit={startEditing} handleEdit={startEditing}
onStatusColorChange={color => statusesCrud.onUpdate(status.id, { color })} onStatusColorChange={color =>
statusesCrud.onUpdate(status.id, { color })
}
withChangeOrderButton={false} withChangeOrderButton={false}
startDealsSelecting={groupDealsSelection.startSelecting}
/> />
</Group> </Group>
); );

View File

@ -0,0 +1,44 @@
import { useMemo } from "react";
import { isNull } from "lodash";
import { DealSchema } from "@/lib/client";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
import { sortByLexorank } from "@/utils/lexorank/sort";
type Props = {
deals: DealSchema[];
};
const useDealsAndGroups = ({ deals }: Props) => {
const dealsWithoutGroup: DealSchema[] = useMemo(
() => deals.filter(d => isNull(d.group)),
[deals]
);
const groupsWithDeals: GroupWithDealsSchema[] = useMemo(() => {
const groupsWithDealMap = new Map<number, GroupWithDealsSchema>();
for (const deal of deals) {
if (isNull(deal.group)) continue;
const groupData = groupsWithDealMap.get(deal.group.id);
if (groupData) {
groupData.items.push(deal);
groupsWithDealMap.set(deal.group.id, groupData);
} else {
groupsWithDealMap.set(deal.group.id, {
...deal.group,
items: [deal],
});
}
}
return sortByLexorank(groupsWithDealMap.values().toArray());
}, [deals]);
return {
dealsWithoutGroup,
groupsWithDeals,
};
};
export default useDealsAndGroups;

View File

@ -1,32 +1,41 @@
import { RefObject, useMemo, useRef, useState } from "react"; import { RefObject, useMemo, useRef } from "react";
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core"; import { DragOverEvent, Over } from "@dnd-kit/core";
import { SwiperRef } from "swiper/swiper-react"; import { SwiperRef } from "swiper/swiper-react";
import { useDebouncedCallback } from "@mantine/hooks"; import { useDebouncedCallback } from "@mantine/hooks";
import { useDealsContext } from "@/app/deals/contexts/DealsContext"; import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useGetNewRank from "@/app/deals/hooks/useGetNewRank"; import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId"; 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 useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client"; import { StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank/sort"; import { sortByLexorank } from "@/utils/lexorank/sort";
type ReturnType = { type ReturnType = {
sortedStatuses: StatusSchema[]; sortedStatuses: StatusSchema[];
handleDragStart: ({ active }: DragStartEvent) => void;
handleDragOver: ({ active, over }: DragOverEvent) => void; handleDragOver: ({ active, over }: DragOverEvent) => void;
handleDragEnd: ({ active, over }: DragOverEvent) => void; handleDragEnd: ({ active, over }: DragOverEvent) => void;
activeStatus: StatusSchema | null;
activeDeal: DealSchema | null;
swiperRef: RefObject<SwiperRef | null>; swiperRef: RefObject<SwiperRef | null>;
}; };
const useDealsAndStatusesDnd = (): ReturnType => { const useDealsAndStatusesDnd = (): ReturnType => {
const swiperRef = useRef<SwiperRef>(null); const swiperRef = useRef<SwiperRef>(null);
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
const { statuses, setStatuses, statusesCrud } = useStatusesContext(); const { statuses, setStatuses, statusesCrud } = useStatusesContext();
const { deals, setDeals, dealsCrud } = useDealsContext(); const {
deals,
dealsWithoutGroup,
groupsWithDeals,
setDeals,
dealsCrud,
groupsCrud,
} = useDealsContext();
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]); const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -40,17 +49,36 @@ const useDealsAndStatusesDnd = (): ReturnType => {
const debouncedSetDeals = useDebouncedCallback(setDeals, 200); const debouncedSetDeals = useDebouncedCallback(setDeals, 200);
const getStatusByDealId = (dealId: number) => { const getStatusByDealId = (dealId: number) => {
const deal = deals.find(deal => deal.id === dealId); const deal = dealsWithoutGroup.find(deal => deal.id === dealId);
if (!deal) return; if (!deal) return;
return statuses.find(status => status.id === deal.status.id); 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) => { const getStatusById = (statusId: number) => {
return statuses.find(status => status.id === statusId); return statuses.find(status => status.id === statusId);
}; };
const swipeSliderDuringDrag = (activeId: number, over: Over) => { const getStatusDealsAndGroups = (statusId: number) =>
const activeStatus = getStatusByDealId(activeId); 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 = const swiperActiveStatus =
statuses[swiperRef.current?.swiper.activeIndex ?? 0]; statuses[swiperRef.current?.swiper.activeIndex ?? 0];
if (swiperActiveStatus.id !== activeStatus?.id) return; if (swiperActiveStatus.id !== activeStatus?.id) return;
@ -58,11 +86,16 @@ const useDealsAndStatusesDnd = (): ReturnType => {
const activeStatusLexorank = activeStatus?.lexorank; const activeStatusLexorank = activeStatus?.lexorank;
let overStatusLexorank: string | undefined; let overStatusLexorank: string | undefined;
if (typeof over.id === "string" && isStatusId(over.id)) { if (typeof over.id === "string") {
const overStatusId = getStatusId(over.id); if (isContainerId(over.id)) {
overStatusLexorank = statuses.find( const overStatusId = getContainerId(over.id);
s => s.id === overStatusId overStatusLexorank = statuses.find(
)?.lexorank; s => s.id === overStatusId
)?.lexorank;
} else {
const overGroupId = getGroupId(over.id);
overStatusLexorank = getStatusByGroupId(overGroupId)?.lexorank;
}
} else { } else {
overStatusLexorank = getStatusByDealId(Number(over.id))?.lexorank; overStatusLexorank = getStatusByDealId(Number(over.id))?.lexorank;
} }
@ -94,15 +127,20 @@ const useDealsAndStatusesDnd = (): ReturnType => {
if (!over) return; if (!over) return;
const activeId = active.id as string | number; const activeId = active.id as string | number;
if (isMobile && typeof activeId !== "string") { if (isMobile && (typeof activeId !== "string" || isGroupId(activeId))) {
swipeSliderDuringDrag(activeId, over); swipeSliderDuringDrag(activeId, over);
} }
if (typeof activeId === "string" && isStatusId(activeId)) { if (typeof activeId !== "string") {
handleDealDragOver(activeId, over);
return;
}
if (isContainerId(activeId)) {
handleColumnDragOver(activeId, over); handleColumnDragOver(activeId, over);
return; return;
} }
handleDealDragOver(activeId, over); handleGroupDragOver(activeId, over);
}; };
const handleDealDragOver = (activeId: string | number, over: Over) => { const handleDealDragOver = (activeId: string | number, over: Over) => {
@ -113,6 +151,7 @@ const useDealsAndStatusesDnd = (): ReturnType => {
const { overStatus, newLexorank } = getDropTarget( const { overStatus, newLexorank } = getDropTarget(
over.id, over.id,
activeDealId, activeDealId,
undefined,
activeStatusId activeStatusId
); );
if (!overStatus) return; if (!overStatus) return;
@ -130,14 +169,49 @@ const useDealsAndStatusesDnd = (): ReturnType => {
); );
}; };
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 handleColumnDragOver = (activeId: string, over: Over) => {
const activeStatusId = getStatusId(activeId); const activeStatusId = getContainerId(activeId);
let overStatusId: number; let overStatusId: number;
if (typeof over.id === "string" && isStatusId(over.id)) { if (typeof over.id === "string") {
overStatusId = getStatusId(over.id); if (isContainerId(over.id)) {
overStatusId = getContainerId(over.id);
} else {
const status = getStatusByGroupId(getGroupId(over.id));
if (!status) return;
overStatusId = status.id;
}
} else { } else {
const deal = deals.find(deal => deal.id === over.id); const deal = dealsWithoutGroup.find(deal => deal.id === over.id);
if (!deal) return; if (!deal) return;
overStatusId = deal.status.id; overStatusId = deal.status.id;
} }
@ -158,17 +232,44 @@ const useDealsAndStatusesDnd = (): ReturnType => {
const getDropTarget = ( const getDropTarget = (
overId: string | number, overId: string | number,
activeDealId: number, activeDealId: number | undefined,
activeGroupId: number | undefined,
activeStatusId: number, activeStatusId: number,
isOnDragEnd: boolean = false isOnDragEnd: boolean = false
): { overStatus?: StatusSchema; newLexorank?: string } => { ): { overStatus?: StatusSchema; newLexorank?: string } => {
if (typeof overId === "string") { if (typeof overId === "string") {
return { if (isContainerId(overId)) {
overStatus: getStatusById(getStatusId(overId)), return getStatusDropTarget(overId);
newLexorank: undefined, }
}; 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 overDealId = Number(overId);
const overStatus = getStatusByDealId(overDealId); const overStatus = getStatusByDealId(overDealId);
@ -176,51 +277,93 @@ const useDealsAndStatusesDnd = (): ReturnType => {
return { overStatus: undefined, newLexorank: undefined }; return { overStatus: undefined, newLexorank: undefined };
} }
const statusDeals = sortByLexorank( const statusItems = getStatusDealsAndGroups(overStatus.id);
deals.filter(deal => deal.status.id === overStatus.id) const overDealIndex = statusItems.findIndex(
deal => !isItemGroup(deal) && deal.id === overDealId
); );
const overDealIndex = statusDeals.findIndex( const activeDealIndex = statusItems.findIndex(
deal => deal.id === overDealId deal => !isItemGroup(deal) && deal.id === activeDealId
); );
if (activeStatusId === overStatus.id) { if (activeStatusId === overStatus.id) {
const newLexorank = getNewRankForSameStatus( const newLexorank = getNewRankForSameStatus(
statusDeals, statusItems,
overDealIndex, overDealIndex,
activeDealId activeDealIndex
); );
return { overStatus, newLexorank }; return { overStatus, newLexorank };
} }
const newLexorank = getNewRankForAnotherStatus( const newLexorank = getNewRankForAnotherStatus(
statusDeals, statusItems,
overDealIndex overDealIndex
); );
return { overStatus, newLexorank }; 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) => { const handleDragEnd = ({ active, over }: DragOverEvent) => {
setActiveDeal(null);
setActiveStatus(null);
if (!over) return; if (!over) return;
const activeId: string | number = active.id; const activeId: string | number = active.id;
if (typeof activeId === "string" && isStatusId(activeId)) { if (typeof activeId !== "string") {
handleDealDragEnd(activeId, over);
return;
}
if (isContainerId(activeId)) {
handleStatusColumnDragEnd(activeId, over); handleStatusColumnDragEnd(activeId, over);
return; return;
} }
handleDealDragEnd(activeId, over); handleGroupDragEnd(activeId, over);
}; };
const handleStatusColumnDragEnd = (activeId: string, over: Over) => { const handleStatusColumnDragEnd = (activeId: string, over: Over) => {
const activeStatusId = getStatusId(activeId); const activeStatusId = getContainerId(activeId);
let overStatusId: number; let overStatusId: number;
if (typeof over.id === "string" && isStatusId(over.id)) { if (typeof over.id === "string" && isContainerId(over.id)) {
overStatusId = getStatusId(over.id); overStatusId = getContainerId(over.id);
} else { } else {
const deal = deals.find(deal => deal.status.id === over.id); const deal = dealsWithoutGroup.find(
deal => deal.status.id === over.id
);
if (!deal) return; if (!deal) return;
overStatusId = deal.status.id; overStatusId = deal.status.id;
} }
@ -245,6 +388,7 @@ const useDealsAndStatusesDnd = (): ReturnType => {
const { overStatus, newLexorank } = getDropTarget( const { overStatus, newLexorank } = getDropTarget(
over.id, over.id,
activeDealId, activeDealId,
undefined,
activeStatusId, activeStatusId,
true true
); );
@ -261,30 +405,36 @@ const useDealsAndStatusesDnd = (): ReturnType => {
dealsCrud.onUpdate(dealId, { statusId, lexorank, name: null }); dealsCrud.onUpdate(dealId, { statusId, lexorank, name: null });
}; };
const handleDragStart = ({ active }: DragStartEvent) => { const handleGroupDragEnd = (activeId: string, over: Over) => {
const activeId = active.id as string | number; const activeGroupId = getGroupId(activeId);
const activeStatusId = getStatusByGroupId(activeGroupId)?.id;
if (!activeStatusId) return;
if (typeof activeId === "string" && isStatusId(activeId)) { const { overStatus, newLexorank } = getDropTarget(
const statusId = getStatusId(activeId); over.id,
setActiveStatus( undefined,
statuses.find(status => status.id === statusId) ?? null activeGroupId,
); activeStatusId,
return; true
}
setActiveDeal(
deals.find(deal => deal.id === (activeId as number)) ?? null
); );
if (!overStatus) return;
onGroupDragEnd(activeGroupId, overStatus.id, newLexorank);
};
const onGroupDragEnd = (
groupId: number,
statusId: number,
lexorank?: string
) => {
groupsCrud.onUpdate(groupId, { statusId, lexorank, name: null });
}; };
return { return {
swiperRef, swiperRef,
sortedStatuses, sortedStatuses,
handleDragStart,
handleDragOver, handleDragOver,
handleDragEnd, handleDragEnd,
activeStatus,
activeDeal,
}; };
}; };

View File

@ -1,18 +1,24 @@
import { LexoRank } from "lexorank"; import { LexoRank } from "lexorank";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import { DealSchema } from "@/lib/client"; import {
import { sortByLexorank } from "@/utils/lexorank/sort"; BaseDraggable,
BaseGroupDraggable,
} from "@/components/dnd/types/types";
import { getNewLexorank } from "@/utils/lexorank/generation"; import { getNewLexorank } from "@/utils/lexorank/generation";
import { sortByLexorank } from "@/utils/lexorank/sort";
type NewRankGetters = { type NewRankGetters<
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
> = {
getNewRankForSameStatus: ( getNewRankForSameStatus: (
statusDeals: DealSchema[], statusItemsAndGroups: (TItem | TGroup)[],
overDealIndex: number, overItemOrGroupIndex: number,
activeDealId: number activeItemOrGroupIndex: number
) => string; ) => string;
getNewRankForAnotherStatus: ( getNewRankForAnotherStatus: (
statusDeals: DealSchema[], statusItemsAndGroups: (TItem | TGroup)[],
overDealIndex: number overItemOrGroupIndex: number
) => string; ) => string;
getNewStatusRank: ( getNewStatusRank: (
activeStatusId: number, activeStatusId: number,
@ -20,44 +26,46 @@ type NewRankGetters = {
) => string | null; ) => string | null;
}; };
const useGetNewRank = (): NewRankGetters => { const useGetNewRank = <
TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
>(): NewRankGetters<TItem, TGroup> => {
const { statuses } = useStatusesContext(); const { statuses } = useStatusesContext();
const getNewRankForSameStatus = ( const getNewRankForSameStatus = (
statusDeals: DealSchema[], statusItemsAndGroups: (TItem | TGroup)[],
overDealIndex: number, overItemOrGroupIndex: number,
activeDealId: number activeItemOrGroupIndex: number
): string => { ): string => {
const activeDealIndex = statusDeals.findIndex(
deal => deal.id === activeDealId
);
const [leftIndex, rightIndex] = const [leftIndex, rightIndex] =
overDealIndex < activeDealIndex overItemOrGroupIndex < activeItemOrGroupIndex
? [overDealIndex - 1, overDealIndex] ? [overItemOrGroupIndex - 1, overItemOrGroupIndex]
: [overDealIndex, overDealIndex + 1]; : [overItemOrGroupIndex, overItemOrGroupIndex + 1];
const leftLexorank = const leftLexorank =
leftIndex >= 0 leftIndex >= 0
? LexoRank.parse(statusDeals[leftIndex].lexorank) ? LexoRank.parse(statusItemsAndGroups[leftIndex].lexorank)
: null; : null;
const rightLexorank = const rightLexorank =
rightIndex < statusDeals.length rightIndex < statusItemsAndGroups.length
? LexoRank.parse(statusDeals[rightIndex].lexorank) ? LexoRank.parse(statusItemsAndGroups[rightIndex].lexorank)
: null; : null;
return getNewLexorank(leftLexorank, rightLexorank).toString(); return getNewLexorank(leftLexorank, rightLexorank).toString();
}; };
const getNewRankForAnotherStatus = ( const getNewRankForAnotherStatus = (
statusDeals: DealSchema[], statusItemsAndGroups: (TItem | TGroup)[],
overDealIndex: number overItemOrGroupIndex: number
): string => { ): string => {
const leftLexorank = const leftLexorank =
overDealIndex > 0 overItemOrGroupIndex > 0
? LexoRank.parse(statusDeals[overDealIndex - 1].lexorank) ? LexoRank.parse(
statusItemsAndGroups[overItemOrGroupIndex - 1].lexorank
)
: null; : null;
const rightLexorank = LexoRank.parse( const rightLexorank = LexoRank.parse(
statusDeals[overDealIndex].lexorank statusItemsAndGroups[overItemOrGroupIndex].lexorank
); );
return getNewLexorank(leftLexorank, rightLexorank).toString(); return getNewLexorank(leftLexorank, rightLexorank).toString();

View File

@ -0,0 +1,114 @@
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

@ -0,0 +1,15 @@
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

@ -1,6 +0,0 @@
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,6 +2,7 @@ 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";
@ -14,6 +15,7 @@ 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";
@ -63,40 +65,42 @@ export default function RootLayout({ children }: Props) {
<MantineProvider <MantineProvider
theme={theme} theme={theme}
defaultColorScheme={"auto"}> defaultColorScheme={"auto"}>
<ReactQueryProvider> <ContextMenuProvider>
<ReduxProvider> <ReactQueryProvider>
<ModalsProvider <ReduxProvider>
labels={{ confirm: "Да", cancel: "Нет" }} <ModalsProvider
modals={modals}> labels={{ confirm: "Да", cancel: "Нет" }}
<DrawersContextProvider> modals={modals}>
<ProjectsContextProvider> <DrawersContextProvider>
<AppShell <ProjectsContextProvider>
layout={"alt"} <AppShell
withBorder={false} layout={"alt"}
navbar={{ withBorder={false}
width: 220, navbar={{
breakpoint: "sm", width: 220,
collapsed: { breakpoint: "sm",
desktop: false, collapsed: {
mobile: true, desktop: false,
}, mobile: true,
}}> },
<AppShellNavbarWrapper> }}>
<Navbar /> <AppShellNavbarWrapper>
</AppShellNavbarWrapper> <Navbar />
<AppShellMainWrapper> </AppShellNavbarWrapper>
{children} <AppShellMainWrapper>
</AppShellMainWrapper> {children}
<AppShellFooterWrapper> </AppShellMainWrapper>
<Footer /> <AppShellFooterWrapper>
</AppShellFooterWrapper> <Footer />
</AppShell> </AppShellFooterWrapper>
</ProjectsContextProvider> </AppShell>
</DrawersContextProvider> </ProjectsContextProvider>
</ModalsProvider> </DrawersContextProvider>
</ReduxProvider> </ModalsProvider>
<Notifications position="bottom-right" /> </ReduxProvider>
</ReactQueryProvider> <Notifications position="bottom-right" />
</ReactQueryProvider>
</ContextMenuProvider>
</MantineProvider> </MantineProvider>
</body> </body>
</html> </html>

View File

@ -0,0 +1,31 @@
"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

@ -0,0 +1,32 @@
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;

25
src/app/tags/page.tsx Normal file
View File

@ -0,0 +1,25 @@
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

@ -1,42 +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 { 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,6 +1,6 @@
"use client"; "use client";
import React, { ReactNode, RefObject, useMemo } from "react"; import React, { ReactNode, RefObject, useMemo, useState } from "react";
import { import {
DndContext, DndContext,
DragEndEvent, DragEndEvent,
@ -16,17 +16,28 @@ 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 useDndSensors from "@/app/deals/hooks/useSensors";
import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn"; import FunnelColumn from "@/components/dnd/FunnelDnd/components/FunnelColumn";
import FunnelOverlay from "@/components/dnd/FunnelDnd/FunnelOverlay"; import FunnelOverlay from "@/components/dnd/FunnelDnd/components/FunnelOverlay";
import { BaseDraggable } from "@/components/dnd/types/types"; 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";
type Props<TContainer, TItem> = { type Props<TContainer, TItem, TGroup> = {
containers: TContainer[]; containers: TContainer[];
items: TItem[]; itemsAndGroups: (TItem | TGroup)[];
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>;
@ -42,11 +53,8 @@ type Props<TContainer, TItem> = {
children: ReactNode children: ReactNode
) => ReactNode; ) => ReactNode;
renderItem: (item: TItem) => ReactNode; renderItem: (item: TItem) => ReactNode;
renderItemOverlay: (item: TItem) => ReactNode; renderGroup: (group: TGroup) => ReactNode;
getContainerId: (container: TContainer) => string; getItemsByContainer: (container: TContainer) => (TItem | TGroup)[];
getItemsByContainer: (container: TContainer, items: TItem[]) => TItem[];
activeContainer: TContainer | null;
activeItem: TItem | null;
isCreatingContainerEnabled?: boolean; isCreatingContainerEnabled?: boolean;
disabledColumns?: boolean; disabledColumns?: boolean;
}; };
@ -54,10 +62,10 @@ type Props<TContainer, TItem> = {
const FunnelDnd = < const FunnelDnd = <
TContainer extends BaseDraggable, TContainer extends BaseDraggable,
TItem extends BaseDraggable, TItem extends BaseDraggable,
TGroup extends BaseGroupDraggable<TItem>,
>({ >({
containers, containers,
items, itemsAndGroups,
onDragStart,
onDragOver, onDragOver,
onDragEnd, onDragEnd,
swiperRef, swiperRef,
@ -65,22 +73,25 @@ const FunnelDnd = <
renderContainerHeader, renderContainerHeader,
renderContainerOverlay, renderContainerOverlay,
renderItem, renderItem,
renderItemOverlay, renderGroup,
getContainerId,
getItemsByContainer, getItemsByContainer,
activeContainer,
activeItem,
isCreatingContainerEnabled = true, isCreatingContainerEnabled = true,
disabledColumns = false, disabledColumns = false,
}: Props<TContainer, TItem>) => { }: Props<TContainer, TItem, TGroup>) => {
const sensors = useDndSensors(); const sensors = useDndSensors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const frequency = useMemo(() => (isMobile ? 1 : undefined), [isMobile]); 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, items); const containerItems = getItemsByContainer(container);
const containerId = getContainerId(container); const containerId = getDndContainerId(container.id);
return ( return (
<SwiperSlide <SwiperSlide
style={{ width: 250 }} style={{ width: 250 }}
@ -94,8 +105,9 @@ const FunnelDnd = <
container, container,
<FunnelColumn <FunnelColumn
id={containerId} id={containerId}
items={containerItems} itemsAndGroups={containerItems}
renderItem={renderItem} renderItem={renderItem}
renderGroup={renderGroup}
/>, />,
renderDraggable!, renderDraggable!,
index index
@ -156,6 +168,34 @@ const FunnelDnd = <
); );
}; };
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 ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@ -166,30 +206,37 @@ const FunnelDnd = <
}} }}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragOver={onDragOver} onDragOver={onDragOver}
onDragEnd={onDragEnd}> onDragEnd={state => {
setActiveContainer(null);
setActiveItem(null);
setActiveGroup(null);
onDragEnd(state);
}}>
<SortableContext <SortableContext
items={containers.map(getContainerId)} items={containers.map(container =>
getDndContainerId(container.id)
)}
strategy={horizontalListSortingStrategy}> strategy={horizontalListSortingStrategy}>
{renderBody()} {renderBody()}
<FunnelOverlay <FunnelOverlay
activeContainer={activeContainer} activeContainer={activeContainer}
activeItem={activeItem} activeItem={activeItem}
activeGroup={activeGroup}
renderContainer={container => { renderContainer={container => {
const containerItems = getItemsByContainer( const containerItems = getItemsByContainer(container);
container, const containerId = getDndContainerId(container.id);
items
);
const containerId = getContainerId(container);
return renderContainerOverlay( return renderContainerOverlay(
container, container,
<FunnelColumn <FunnelColumn
id={containerId} id={containerId}
items={containerItems} itemsAndGroups={containerItems}
renderItem={renderItem} renderItem={renderItem}
renderGroup={renderGroup}
/> />
); );
}} }}
renderItem={renderItemOverlay} renderItem={renderItem}
renderGroup={renderGroup}
/> />
</SortableContext> </SortableContext>
</DndContext> </DndContext>

View File

@ -0,0 +1,76 @@
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

@ -2,28 +2,32 @@ 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> = { type Props<TContainer, TItem, TGroup> = {
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>({ const FunnelOverlay = <TContainer, TItem, TGroup>({
activeContainer, activeContainer,
activeItem, activeItem,
activeGroup,
renderContainer, renderContainer,
renderItem, renderItem,
}: Props<TContainer, TItem>) => { renderGroup,
}: 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}> <div className={styles.overlay}>{renderOverlay()}</div>
{activeItem
? renderItem(activeItem)
: activeContainer
? renderContainer(activeContainer)
: null}
</div>
</DragOverlay> </DragOverlay>
); );
}; };

View File

@ -0,0 +1,9 @@
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

@ -0,0 +1,8 @@
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

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

View File

@ -3,7 +3,8 @@ import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { useDrawersContext } from "@/drawers/DrawersContext"; import { useDrawersContext } from "@/drawers/DrawersContext";
const useProjectActions = () => { const useProjectActions = () => {
const { selectedProject, projectsCrud } = useProjectsContext(); const { selectedProject, projectsCrud, refetchProjects } =
useProjectsContext();
const { openDrawer } = useDrawersContext(); const { openDrawer } = useDrawersContext();
const onCreateClick = () => { const onCreateClick = () => {
@ -27,6 +28,7 @@ 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

@ -0,0 +1,34 @@
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

@ -0,0 +1,30 @@
.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

@ -0,0 +1,93 @@
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

@ -0,0 +1,25 @@
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

@ -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,10 +1,11 @@
import { FC } from "react"; import { FC } from "react";
import { IconBlocks, IconEdit } from "@tabler/icons-react"; import { IconBlocks, IconEdit, IconTags } from "@tabler/icons-react";
import { Tabs } from "@mantine/core"; import { Tabs } from "@mantine/core";
import { import {
GeneralTab, GeneralTab,
ModulesTab, ModulesTab,
} from "@/app/deals/drawers/ProjectEditorDrawer/tabs"; } from "@/drawers/common/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";
@ -30,13 +31,21 @@ 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 "./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 "@/app/deals/drawers/ProjectEditorDrawer/tabs/ModulesTab/utils/resolveDependencies"; import resolveDependencies from "@/drawers/common/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

@ -0,0 +1,26 @@
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

@ -0,0 +1,60 @@
"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

@ -0,0 +1,21 @@
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

@ -0,0 +1,38 @@
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

@ -0,0 +1,36 @@
"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

@ -0,0 +1,46 @@
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

@ -0,0 +1,37 @@
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

@ -0,0 +1,13 @@
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

@ -0,0 +1,68 @@
"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

@ -0,0 +1,125 @@
import React from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { Text } from "@mantine/core";
import { modals } from "@mantine/modals";
import { HttpValidationError, UpdateDealGroupSchema } from "@/lib/client";
import {
createDealGroupMutation,
deleteDealGroupMutation,
updateDealGroupMutation,
updateDealsInGroupMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { notifications } from "@/lib/notifications";
export type GroupsCrud = {
onUpdate: (groupId: number, group: UpdateDealGroupSchema) => void;
onCreate: (mainDealId: number, otherDealIds: number[]) => void;
onUpdateDealsInGroup: (groupId: number, dealIds: number[]) => void;
onDelete: (groupId: number) => void;
};
const useDealGroupCrud = (): GroupsCrud => {
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({
path: {
pk: groupId,
},
body: {
entity,
},
});
};
const createMutation = useMutation({
...createDealGroupMutation(),
onSettled,
onError,
});
const onCreate = (mainDealId: number, otherDealIds: number[]) => {
createMutation.mutate({
body: {
mainDealId,
otherDealIds,
},
});
};
const updateDealsMutation = useMutation({
...updateDealsInGroupMutation(),
onSettled,
onError,
});
const onUpdateDealsInGroup = (groupId: number, dealIds: number[]) => {
updateDealsMutation.mutate({
path: {
pk: groupId,
},
body: {
dealIds,
},
});
};
const deleteMutation = useMutation({
...deleteDealGroupMutation(),
onSettled,
onError,
});
const onDelete = (groupId: number) => {
modals.openConfirmModal({
title: "Удаление группы",
children: <Text>Вы уверены, что хотите удалить группу?</Text>,
confirmProps: { color: "red" },
onConfirm: () => {
deleteMutation.mutate({
path: {
pk: groupId,
},
});
},
});
};
return {
onUpdate,
onCreate,
onUpdateDealsInGroup,
onDelete,
};
};
export default useDealGroupCrud;

View File

@ -0,0 +1,53 @@
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

@ -0,0 +1,39 @@
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

@ -193,6 +193,30 @@ export const zCreateClientResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* CreateDealGroupRequest
*/
export const zCreateDealGroupRequest = z.object({
mainDealId: z.int(),
otherDealIds: z.array(z.int()),
});
/**
* DealGroupSchema
*/
export const zDealGroupSchema = z.object({
id: z.int(),
name: z.optional(z.union([z.string(), z.null()])),
lexorank: z.string(),
});
/**
* CreateDealGroupResponse
*/
export const zCreateDealGroupResponse = z.object({
entity: zDealGroupSchema,
});
/** /**
* CreateDealProductSchema * CreateDealProductSchema
*/ */
@ -324,6 +348,27 @@ 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
*/ */
@ -336,6 +381,8 @@ export const zDealSchema = z.object({
createdAt: z.iso.datetime({ createdAt: z.iso.datetime({
offset: true, offset: true,
}), }),
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()])),
@ -386,6 +433,30 @@ 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
*/ */
@ -501,6 +572,7 @@ 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),
}); });
/** /**
@ -675,6 +747,13 @@ export const zDeleteClientResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* DeleteDealGroupResponse
*/
export const zDeleteDealGroupResponse = z.object({
message: z.string(),
});
/** /**
* DeleteDealProductResponse * DeleteDealProductResponse
*/ */
@ -696,6 +775,13 @@ export const zDeleteDealServiceResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* DeleteDealTagResponse
*/
export const zDeleteDealTagResponse = z.object({
message: z.string(),
});
/** /**
* DeleteMarketplaceResponse * DeleteMarketplaceResponse
*/ */
@ -815,6 +901,13 @@ 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
*/ */
@ -864,6 +957,13 @@ export const zGetProductsResponse = z.object({
paginationInfo: zPaginationInfoSchema, paginationInfo: zPaginationInfoSchema,
}); });
/**
* GetProjectResponse
*/
export const zGetProjectResponse = z.object({
entity: zProjectSchema,
});
/** /**
* GetProjectsResponse * GetProjectsResponse
*/ */
@ -919,6 +1019,13 @@ 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
*/ */
@ -962,6 +1069,22 @@ 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
*/ */
@ -1034,6 +1157,29 @@ export const zUpdateClientResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* UpdateDealGroupSchema
*/
export const zUpdateDealGroupSchema = z.object({
name: 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()])),
});
/**
* UpdateDealGroupRequest
*/
export const zUpdateDealGroupRequest = z.object({
entity: zUpdateDealGroupSchema,
});
/**
* UpdateDealGroupResponse
*/
export const zUpdateDealGroupResponse = z.object({
message: z.string(),
});
/** /**
* UpdateDealProductSchema * UpdateDealProductSchema
*/ */
@ -1104,6 +1250,42 @@ 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
*/ */
@ -1412,6 +1594,128 @@ 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({
body: zUpdateDealGroupRequest,
path: z.object({
pk: z.int(),
}),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateDealGroupResponse2 = zUpdateDealGroupResponse;
export const zCreateDealGroupData = z.object({
body: zCreateDealGroupRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateDealGroupResponse2 = zCreateDealGroupResponse;
export const zUpdateDealsInGroupData = z.object({
body: zUpdateDealsInGroupRequest,
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()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateDealTagResponse2 = zCreateDealTagResponse;
export const zDeleteDealTagData = z.object({
body: z.optional(z.never()),
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()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zSwitchDealTagResponse2 = zSwitchDealTagResponse;
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()),
path: z.optional(z.never()), path: z.optional(z.never()),
@ -1423,117 +1727,6 @@ 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()),
@ -2107,3 +2300,127 @@ 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,6 +20,7 @@ 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,
@ -40,4 +41,5 @@ export const modals = {
printBarcodeModal: PrintBarcodeModal, printBarcodeModal: PrintBarcodeModal,
statusColorPickerModal: ColorPickerModal, statusColorPickerModal: ColorPickerModal,
marketplaceEditorModal: MarketplaceEditorModal, marketplaceEditorModal: MarketplaceEditorModal,
dealTagModal: DealTagModal,
}; };

View File

@ -5,6 +5,7 @@ import {
Image, Image,
NumberInput, NumberInput,
Stack, Stack,
Text,
Textarea, Textarea,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@ -16,9 +17,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";
@ -116,10 +117,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>
{/* Штрихкоды:*/} Штрихкоды:
{/*{value.product.barcodes.join(", ")}*/} {dealProduct.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 { IconEdit, IconTrash } from "@tabler/icons-react"; import { IconBarcode, 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,18 +33,28 @@ 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"}>
{/*<Tooltip*/} <ActionIconWithTip
{/* onClick={onPrintBarcodeClick}*/} onClick={onPrintBarcodeClick}
{/* label="Печать штрихкода">*/} tipLabel="Печать штрихкода">
{/* <ActionIcon variant={"default"}>*/} <IconBarcode />
{/* <IconBarcode />*/} </ActionIconWithTip>
{/* </ActionIcon>*/}
{/*</Tooltip>*/}
<ActionIconWithTip <ActionIconWithTip
onClick={onProductEditClick} onClick={onProductEditClick}
tipLabel="Редактировать товар"> tipLabel="Редактировать товар">

View File

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

View File

@ -0,0 +1,7 @@
import { DealGroupSchema, DealSchema } from "@/lib/client";
type GroupWithDealsSchema = DealGroupSchema & {
items: DealSchema[];
};
export default GroupWithDealsSchema;

View File

@ -6198,6 +6198,7 @@ __metadata:
jest-environment-jsdom: "npm:^30.0.0" jest-environment-jsdom: "npm:^30.0.0"
lexorank: "npm:^1.0.5" lexorank: "npm:^1.0.5"
libphonenumber-js: "npm:^1.12.10" libphonenumber-js: "npm:^1.12.10"
mantine-contextmenu: "npm:^8.2.0"
mantine-datatable: "npm:^8.2.0" mantine-datatable: "npm:^8.2.0"
next: "npm:15.4.7" next: "npm:15.4.7"
phone: "npm:^3.1.67" phone: "npm:^3.1.67"
@ -10181,6 +10182,19 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mantine-contextmenu@npm:^8.2.0":
version: 8.2.0
resolution: "mantine-contextmenu@npm:8.2.0"
peerDependencies:
"@mantine/core": ">=8.2"
"@mantine/hooks": ">=8.2"
clsx: ">=2"
react: ">=19"
react-dom: ">=19"
checksum: 10c0/fea66f890eb9baea7e1ebcdcf3732d8522e2322938d7d0de168acffa9abe6ab0efae8838d7af2ae4399d044bca27879775efd0a07d7f6193cf0ebfe250d4b786
languageName: node
linkType: hard
"mantine-datatable@npm:^8.2.0": "mantine-datatable@npm:^8.2.0":
version: 8.2.0 version: 8.2.0
resolution: "mantine-datatable@npm:8.2.0" resolution: "mantine-datatable@npm:8.2.0"