feat: creating and updating groups

This commit is contained in:
2025-10-17 19:47:47 +04:00
parent daa9d12983
commit 30bc7bbee4
18 changed files with 657 additions and 290 deletions

View File

@ -12,6 +12,26 @@
}
}
.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;

View File

@ -1,3 +1,6 @@
import { IconCategoryPlus } from "@tabler/icons-react";
import classNames from "classnames";
import { useContextMenu } from "mantine-contextmenu";
import { Box, Card, Group, Pill, Stack, Text } from "@mantine/core";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
@ -13,10 +16,16 @@ type Props = {
const DealCard = ({ deal, isInGroup = false }: Props) => {
const { selectedProject, modulesSet } = useProjectsContext();
const { dealsCrud, refetchDeals } = useDealsContext();
const { dealsCrud, refetchDeals, groupDealsSelection } = useDealsContext();
const { openDrawer } = useDrawersContext();
const onClick = () => {
if (groupDealsSelection.isDealsSelecting) {
if (groupDealsSelection.selectedBaseDealId !== deal.id)
groupDealsSelection.toggleDeal(deal.id);
return;
}
openDrawer({
key: "dealEditorDrawer",
props: {
@ -29,12 +38,37 @@ const DealCard = ({ deal, isInGroup = false }: Props) => {
});
};
const { showContextMenu } = useContextMenu();
const dealContextMenu = deal.group
? []
: [
{
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 (
<Card
onClick={onClick}
className={
className={classNames(
getSelectedStyles(),
isInGroup ? styles["container-in-group"] : styles.container
}>
)}
onContextMenu={showContextMenu(dealContextMenu)}>
<Group
justify={"space-between"}
wrap={"nowrap"}

View File

@ -10,3 +10,13 @@
border-color: var(--mantine-color-dark-5);
}
}
.selected-group {
border: 2px solid;
@mixin light {
border-color: dodgerblue;
}
@mixin dark {
border-color: dodgerblue;
}
}

View File

@ -1,6 +1,10 @@
import { FC } from "react";
import { Stack, Text } from "@mantine/core";
import { FC, useEffect, useState } from "react";
import classNames from "classnames";
import { Flex, Stack, TextInput } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import GroupMenu from "@/app/deals/components/shared/GroupMenu/GroupMenu";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
import styles from "./DealsGroup.module.css";
@ -9,13 +13,41 @@ type Props = {
};
const DealsGroup: FC<Props> = ({ group }) => {
const [groupName, setGroupName] = useState(group.name ?? "");
const [debouncedGroupName] = useDebouncedValue(groupName, 600);
const { groupsCrud, groupDealsSelection } = useDealsContext();
useEffect(() => {
if (debouncedGroupName === group.name) return;
groupsCrud.onUpdate(group.id, { name: debouncedGroupName });
}, [debouncedGroupName]);
return (
<Stack
className={styles["group-container"]}
className={classNames(
styles["group-container"],
groupDealsSelection.selectedGroupId === group.id &&
styles["selected-group"]
)}
gap={"xs"}
bdrs={"lg"}
p={"xs"}>
<Text mx={"xs"}>{group.name}</Text>
<Flex
mx={"xs"}
align={"center"}>
<TextInput
value={groupName}
onChange={e => setGroupName(e.target.value)}
variant={"unstyled"}
/>
<GroupMenu
group={group}
onDelete={groupsCrud.onDelete}
onStartDealsSelecting={
groupDealsSelection.startSelectingWithExistingGroup
}
/>
</Flex>
{group.items.map(deal => (
<DealCard
deal={deal}

View File

@ -0,0 +1,8 @@
.shadow {
@mixin light {
box-shadow: var(--light-shadow);
}
@mixin dark {
box-shadow: var(--dark-shadow);
}
}

View File

@ -0,0 +1,51 @@
"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: {
selectedBaseDealId,
selectedGroupId,
finishDealsSelecting,
cancelDealsSelecting,
},
} = useDealsContext();
return (
<Affix position={{ bottom: 35, right: 35 }}>
<Transition
transition="slide-up"
mounted={!!(selectedBaseDealId || selectedGroupId)}>
{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

@ -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";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type Props = {
group: GroupWithDealsSchema;
onDelete: (groupId: number) => void;
onStartDealsSelecting: (group: GroupWithDealsSchema) => void;
};
const GroupMenu: FC<Props> = ({ group, onDelete, onStartDealsSelecting }) => {
return (
<Menu>
<Menu.Target>
<Box onClick={e => e.stopPropagation()}>
<ThemeIcon size={"sm"}>
<IconDotsVertical />
</ThemeIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<DropdownMenuItem
onClick={() => onDelete(group.id)}
icon={<IconTrash />}
label={"Удалить"}
/>
<DropdownMenuItem
onClick={() => onStartDealsSelecting(group)}
icon={<IconCheckbox />}
label={"Добавить/удалить сделки"}
/>
</Menu.Dropdown>
</Menu>
);
};
export default GroupMenu;

View File

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

View File

@ -1,10 +1,13 @@
"use client";
import React from "react";
import { Dispatch, SetStateAction } from "react";
import { UseFormReturnType } from "@mantine/form";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useDealsAndGroups from "@/app/deals/hooks/useDealsAndGroups";
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 useDealsList from "@/hooks/lists/useDealsList";
@ -23,10 +26,11 @@ type DealsContextState = {
groupsCrud: GroupsCrud;
paginationInfo?: PaginationInfoSchema;
page: number;
setPage: React.Dispatch<React.SetStateAction<number>>;
setPage: Dispatch<SetStateAction<number>>;
dealsFiltersForm: UseFormReturnType<DealsFiltersForm>;
isChangedFilters: boolean;
sortingForm: UseFormReturnType<SortingForm>;
groupDealsSelection: GroupDealsSelection;
};
type Props = {
@ -56,6 +60,8 @@ const useDealsContextState = ({
const groupsCrud = useDealGroupCrud();
const groupDealsSelection = useGroupDealsSelection({ groupsCrud });
const { dealsWithoutGroup, groupsWithDeals } =
useDealsAndGroups(dealsListObjects);
@ -65,6 +71,7 @@ const useDealsContextState = ({
groupsCrud,
dealsWithoutGroup,
groupsWithDeals,
groupDealsSelection,
};
};

View File

@ -0,0 +1,92 @@
import { Dispatch, SetStateAction, useState } from "react";
import { GroupsCrud } from "@/hooks/cruds/useDealGroupCrud";
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;
selectedDealIds: Set<number>;
setSelectedDealIds: Dispatch<SetStateAction<Set<number>>>;
toggleDeal: (dealId: number) => 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 = (dealId: number) => {
if (selectedDealIds.has(dealId)) {
selectedDealIds.delete(dealId);
} else {
selectedDealIds.add(dealId);
}
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);
};
const startSelectingWithExistingGroup = (group: GroupWithDealsSchema) => {
setSelectedDealIds(new Set(group.items.map(item => item.id)));
setSelectedGroupId(group.id);
setIsDealsSelecting(true);
};
const startSelectingWithDeal = (dealId: number) => {
setSelectedDealIds(new Set([dealId]));
setSelectedBaseDealId(dealId);
setIsDealsSelecting(true);
};
return {
isDealsSelecting,
selectedBaseDealId,
startSelectingWithDeal,
selectedGroupId,
startSelectingWithExistingGroup,
selectedDealIds,
setSelectedDealIds,
toggleDeal,
finishDealsSelecting,
cancelDealsSelecting,
};
};
export default useGroupDealsSelection;

View File

@ -2,6 +2,7 @@ import "@mantine/core/styles.css";
import "mantine-datatable/styles.layer.css";
import "@mantine/notifications/styles.css";
import "@mantine/dates/styles.css";
import "mantine-contextmenu/styles.css";
import "swiper/css";
import "swiper/css/pagination";
import "swiper/css/scrollbar";
@ -14,6 +15,7 @@ import {
} from "@mantine/core";
import { theme } from "@/theme";
import "@/app/global.css";
import { ContextMenuProvider } from "mantine-contextmenu";
import { ModalsProvider } from "@mantine/modals";
import { Notifications } from "@mantine/notifications";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
@ -63,40 +65,42 @@ export default function RootLayout({ children }: Props) {
<MantineProvider
theme={theme}
defaultColorScheme={"auto"}>
<ReactQueryProvider>
<ReduxProvider>
<ModalsProvider
labels={{ confirm: "Да", cancel: "Нет" }}
modals={modals}>
<DrawersContextProvider>
<ProjectsContextProvider>
<AppShell
layout={"alt"}
withBorder={false}
navbar={{
width: 220,
breakpoint: "sm",
collapsed: {
desktop: false,
mobile: true,
},
}}>
<AppShellNavbarWrapper>
<Navbar />
</AppShellNavbarWrapper>
<AppShellMainWrapper>
{children}
</AppShellMainWrapper>
<AppShellFooterWrapper>
<Footer />
</AppShellFooterWrapper>
</AppShell>
</ProjectsContextProvider>
</DrawersContextProvider>
</ModalsProvider>
</ReduxProvider>
<Notifications position="bottom-right" />
</ReactQueryProvider>
<ContextMenuProvider>
<ReactQueryProvider>
<ReduxProvider>
<ModalsProvider
labels={{ confirm: "Да", cancel: "Нет" }}
modals={modals}>
<DrawersContextProvider>
<ProjectsContextProvider>
<AppShell
layout={"alt"}
withBorder={false}
navbar={{
width: 220,
breakpoint: "sm",
collapsed: {
desktop: false,
mobile: true,
},
}}>
<AppShellNavbarWrapper>
<Navbar />
</AppShellNavbarWrapper>
<AppShellMainWrapper>
{children}
</AppShellMainWrapper>
<AppShellFooterWrapper>
<Footer />
</AppShellFooterWrapper>
</AppShell>
</ProjectsContextProvider>
</DrawersContextProvider>
</ModalsProvider>
</ReduxProvider>
<Notifications position="bottom-right" />
</ReactQueryProvider>
</ContextMenuProvider>
</MantineProvider>
</body>
</html>