feat: added tags for cards, aligned status headers

This commit is contained in:
2025-03-09 19:32:45 +04:00
parent 56135ae10c
commit ea80e92c18
48 changed files with 876 additions and 85 deletions

View File

@@ -7,6 +7,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import isModuleInProject, { Modules } from "../../../../modules/utils/isModuleInProject.ts";
import CardTags from "../CardTags/CardTags.tsx";
type Props = {
cards: CardSummary[];
@@ -75,6 +76,7 @@ export const CardGroupView: FC<Props> = ({ cards, group }) => {
/>
))}
</Flex>
<CardTags groupId={group.id} tags={cards[0].tags}/>
{isServicesAndProductsIncluded && (
<Flex
p={rem(10)}

View File

@@ -11,6 +11,7 @@ import { useContextMenu } from "mantine-contextmenu";
import useCardSummaryState from "./useCardSummaryState.tsx";
import isModuleInProject, { Modules } from "../../../../modules/utils/isModuleInProject.ts";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import CardTags from "../CardTags/CardTags.tsx";
type Props = {
cardSummary: CardSummary;
@@ -26,7 +27,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
const isServicesAndProductsIncluded = isModuleInProject(Modules.SERVICES_AND_PRODUCTS, selectedProject);
const isClientIncluded = isModuleInProject(Modules.CLIENTS, selectedProject);
const onDealSummaryClick = () => {
const onCardSummaryClick = () => {
CardService.getCardById({ cardId: cardSummary.id }).then(card => {
setSelectedCard(card);
});
@@ -59,7 +60,7 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
icon: <IconTrash />,
},
])}
onClick={() => onDealSummaryClick()}
onClick={() => onCardSummaryClick()}
className={styles["container"]}
style={{ backgroundColor: color }}
>
@@ -95,6 +96,9 @@ const CardSummaryItem: FC<Props> = ({ cardSummary, color }) => {
</Text>
</Flex>
)}
{!cardSummary.group?.id && (
<CardTags cardId={cardSummary.id} tags={cardSummary.tags}/>
)}
<Flex align={"center"} justify={"space-between"}>
<Flex align={"center"} gap={rem(5)}>
<CopyButton value={cardSummary.id.toString()}>

View File

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

View File

@@ -0,0 +1,98 @@
import { Center, Checkbox, Group, Menu, Pill, rem, Stack, Text } from "@mantine/core";
import { CardTagSchema, CardTagService } from "../../../../client";
import { IconPlus } from "@tabler/icons-react";
import styles from "./CardTags.module.css";
import classNames from "classnames";
import React from "react";
import { useProjectsContext } from "../../../../contexts/ProjectsContext.tsx";
import { notifications } from "../../../../shared/lib/notifications.ts";
import { useCardPageContext } from "../../../../pages/CardsPage/contexts/CardPageContext.tsx";
type Props = {
cardId?: number;
groupId?: number;
tags: CardTagSchema[];
}
const CardTags = ({ tags, cardId, groupId }: Props) => {
const { selectedProject } = useProjectsContext();
const { refetchCards } = useCardPageContext();
if (selectedProject?.tags.length === 0) return;
const onTagClick = (tag: CardTagSchema, event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
event.stopPropagation();
CardTagService.switchTag({
requestBody: {
cardId: cardId ?? null,
groupId: groupId ?? null,
tagId: tag.id,
},
})
.then(({ ok, message }) => {
if (!ok) notifications.error({ message });
refetchCards();
})
.catch(err => console.log(err));
};
const addTagButton = (
<Menu withArrow>
<Menu.Target>
<button
onClick={e => e.stopPropagation()}
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={!!tags.find(cardTag => cardTag.id === tag.id)}
onClick={(event) => onTagClick(tag, event)}
/>
<Text>{tag.name}</Text>
</Group>
))}
</Stack>
</Menu.Dropdown>
</Menu>
);
const pills = tags.map(tag => {
return (
<Pill
key={tag.id}
style={{
color: "gray",
backgroundColor: "var(--mantine-color-dark-6)",
border: "1px solid gray",
}}
>
{tag.name}
</Pill>
);
});
return (
<Group gap={rem(4)}>
{addTagButton}
{pills}
</Group>
);
};
export default CardTags;

View File

@@ -1,5 +1,5 @@
import styles from "../../../../pages/CardsPage/ui/CardsPage.module.css";
import { Divider, Text, Title } from "@mantine/core";
import { Divider, Stack, Text, Title } from "@mantine/core";
import getColumnColor from "../../Cards/CardsDndColumn/utils/getColumnColor.ts";
import { BoardSchema, CardSummary, StatusSchema } from "../../../../client";
import { getPluralForm } from "../../../../shared/lib/utils.ts";
@@ -10,6 +10,7 @@ import { useContextMenu } from "mantine-contextmenu";
import { IconEdit, IconTrash } from "@tabler/icons-react";
import useStatus from "./hooks/useStatus.tsx";
import isModuleInProject, { Modules } from "../../../../modules/utils/isModuleInProject.ts";
import { useEqualHeightsContext } from "./contexts/EqualHeightContext.tsx";
type Props = {
@@ -25,6 +26,10 @@ const Status = ({ summaries, status, board, dragState, index, refetch }: Props)
const isDropDisabled = dragState === DragState.DRAG_CARD;
const isServicesAndProductsIncluded = isModuleInProject(Modules.SERVICES_AND_PRODUCTS, board?.project);
const {
divRefs,
} = useEqualHeightsContext();
const {
onEditStatusClick,
onDeleteStatusClick,
@@ -60,50 +65,63 @@ const Status = ({ summaries, status, board, dragState, index, refetch }: Props)
},
]);
return (
<Droppable
isDropDisabled={isDropDisabled}
droppableId={"status-" + status.id.toString()}
direction={"horizontal"}
>
{provided => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
>
<Draggable
draggableId={"status-" + status.id.toString()}
index={status.ordinalNumber}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
const header = (
<Stack h={"100%"} gap={0} justify={"space-between"}>
<Stack gap={0}>
<Title
style={{ textAlign: "center" }}
size={"h4"}>
{status.name}
</Title>
<Text style={{ textAlign: "center", textWrap: "nowrap" }}>
{getDealsText()}
</Text>
</Stack>
<Divider
size={"xl"}
color={getColumnColor(index)}
/>
</Stack>
);
className={styles["header-statuses"]}
onContextMenu={contextMenu()}
>
<Title
style={{ textAlign: "center" }}
size={"h4"}>
{status.name}
</Title>
<Text style={{ textAlign: "center", textWrap: "nowrap" }}>
{getDealsText()}
</Text>
<Divider
size={"xl"}
my={10}
color={getColumnColor(index)}
/>
</div>
)}
</Draggable>
{provided.placeholder}
</div>
)}
</Droppable>
return (
<div
key={index}
ref={el => (divRefs.current[index] = el)}
>
<Droppable
isDropDisabled={isDropDisabled}
droppableId={"status-" + status.id.toString()}
direction={"horizontal"}
>
{provided => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
style={{ height: "100%" }}
>
<Draggable
draggableId={"status-" + status.id.toString()}
index={status.ordinalNumber}
>
{(provided) => (
<div
{...provided.draggableProps}
{...provided.dragHandleProps}
ref={provided.innerRef}
className={styles["header-statuses"]}
onContextMenu={contextMenu()}
>
{header}
</div>
)}
</Draggable>
{provided.placeholder}
</div>
)}
</Droppable>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import React, { createContext, FC, useContext, useEffect, useRef, useState } from "react";
import { StatusSchema } from "../../../../../client";
type EqualHeightsContextState = {
divRefs: React.MutableRefObject<(HTMLDivElement | null)[]>;
};
const EqualHeightsContext = createContext<EqualHeightsContextState | undefined>(
undefined,
);
type EqualHeightsContextProps = {
statuses: StatusSchema[];
}
const useEqualHeightsContextState = ({ statuses }: EqualHeightsContextProps) => {
const divRefs = useRef<(HTMLDivElement | null)[]>([]);
const updateHeights = () => {
divRefs.current.forEach(div => {
if (div) {
div.style.minHeight = "auto";
div.style.height = "auto";
}
});
const heights = divRefs.current.map(div => div?.offsetHeight || 0);
const newMaxHeight = Math.max(...heights);
divRefs.current.forEach(div => {
if (div) {
div.style.minHeight = newMaxHeight + "px";
div.style.height = newMaxHeight + "px";
}
});
};
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWindowWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
useEffect(() => {
updateHeights();
}, [windowWidth, divRefs.current.length, statuses]);
return {
divRefs,
};
};
type EqualHeightsContextProviderProps = {
children: React.ReactNode;
} & EqualHeightsContextProps;
export const EqualHeightsContextProvider: FC<EqualHeightsContextProviderProps> = ({ children, statuses }) => {
const state = useEqualHeightsContextState({ statuses });
return (
<EqualHeightsContext.Provider value={state}>
{children}
</EqualHeightsContext.Provider>
);
};
export const useEqualHeightsContext = () => {
const context = useContext(EqualHeightsContext);
if (!context) {
throw new Error(
"useEqualHeightsContext must be used within a EqualHeightsContextProvider",
);
}
return context;
};

View File

@@ -6,6 +6,7 @@ import { Flex, rem, Stack } from "@mantine/core";
import { DragDropContext } from "@hello-pangea/dnd";
import useDnd from "../../../../pages/CardsPage/hooks/useDnd.tsx";
import Status from "../Status/Status.tsx";
import { EqualHeightsContextProvider } from "../Status/contexts/EqualHeightContext.tsx";
type Props = {
@@ -67,19 +68,21 @@ const Statuses = ({
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<Flex
justify={"space-between"}
direction={"column"}
>
<Flex className={styles["statuses"]}>
{selectedBoard &&
statuses.map(((status: StatusSchema, index: number) => {
return statusDndColumn(status, index);
}))
}
<EqualHeightsContextProvider statuses={statuses}>
<Flex
justify={"space-between"}
direction={"column"}
>
<Flex className={styles["statuses"]}>
{selectedBoard &&
statuses.map(((status: StatusSchema, index: number) => {
return statusDndColumn(status, index);
}))
}
</Flex>
<CardsDndFooter dragState={dragState} />
</Flex>
<CardsDndFooter dragState={dragState} />
</Flex>
</EqualHeightsContextProvider>
</DragDropContext>
);
};

View File

@@ -0,0 +1,26 @@
import { FC } from "react";
import { CardTagSchema, ProjectSchema } from "../../../client";
import ObjectSelect, { ObjectSelectProps } from "../../ObjectSelect/ObjectSelect.tsx";
type OtherProps = {
project: ProjectSchema | null;
}
type SelectProps = Omit<ObjectSelectProps<CardTagSchema | null>, "data" | "getLabelFn" | "getValueFn">;
type Props = OtherProps & SelectProps;
const CardTagSelect: FC<Props> = ({ project, ...props }) => {
const onClear = () => props.onChange(null);
return (
<ObjectSelect
data={project?.tags ?? []}
searchable
placeholder={"Выберите тег"}
onClear={onClear}
{...props}
/>
);
};
export default CardTagSelect;