feat: division between mobile and desktop components, boards for mobile

This commit is contained in:
2025-08-13 09:55:27 +04:00
parent 1a98facd72
commit 838c9640a1
26 changed files with 175 additions and 89 deletions

View File

@ -0,0 +1,61 @@
import React, { FC } from "react";
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
import { Box, Group, Menu, Text } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { BoardSchema } from "@/lib/client";
type Props = {
board: BoardSchema;
startEditing: () => void;
isHovered?: boolean;
menuIconSize?: number;
};
const BoardMenu: FC<Props> = ({
board,
startEditing,
isHovered = true,
menuIconSize = 16,
}) => {
const { selectedBoard, onDeleteBoard } = useBoardsContext();
return (
<Menu>
<Menu.Target>
<Box
style={{
opacity:
isHovered || selectedBoard?.id === board.id ? 1 : 0,
cursor: "pointer",
}}
onClick={e => e.stopPropagation()}>
<IconDotsVertical size={menuIconSize} />
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={e => {
e.stopPropagation();
startEditing();
}}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item
onClick={e => {
e.stopPropagation();
onDeleteBoard(board);
}}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
export default BoardMenu;

View File

@ -0,0 +1,35 @@
import React from "react";
import { Box, Text } from "@mantine/core";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
const CreateStatusButton = () => {
const { onCreateStatus } = useStatusesContext();
return (
<Box
style={{
borderWidth: 1,
borderStyle: "dashed",
borderColor: "gray",
width: "15vw",
minWidth: 150,
cursor: "pointer",
}}>
<InPlaceInput
placeholder={"Название колонки"}
onComplete={onCreateStatus}
getChildren={startEditing => (
<Box
p={9}
onClick={() => startEditing()}>
<Text>Создать колонку</Text>
</Box>
)}
modalTitle={"Создание колонки"}
/>
</Box>
);
};
export default CreateStatusButton;

View File

@ -0,0 +1,8 @@
.container {
background-color: #e0f0f4;
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}

View File

@ -0,0 +1,13 @@
import { Card } from "@mantine/core";
import { DealSchema } from "@/lib/client";
import styles from "./DealCard.module.css";
type Props = {
deal: DealSchema;
};
const DealCard = ({ deal }: Props) => {
return <Card className={styles.container}>{deal.name}</Card>;
};
export default DealCard;

View File

@ -0,0 +1,25 @@
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,96 @@
"use client";
import React, { FC, ReactNode } from "react";
import { Group, ScrollArea } from "@mantine/core";
import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import DealContainer from "@/app/deals/components/shared/DealContainer/DealContainer";
import StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd";
import useIsMobile from "@/hooks/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
const Funnel: FC = () => {
const { deals } = useDealsContext();
const isMobile = useIsMobile();
const {
sortedStatuses,
handleDragStart,
handleDragOver,
handleDragEnd,
activeStatus,
activeDeal,
} = useDealsAndStatusesDnd();
const renderFunnelDnd = () => (
<>
<FunnelDnd
containers={sortedStatuses}
items={deals}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
getContainerId={(status: StatusSchema) => `${status.id}-status`}
getItemsByContainer={(
status: StatusSchema,
items: DealSchema[]
) =>
sortByLexorank(
items.filter(deal => deal.statusId === status.id)
)
}
renderContainer={(
status: StatusSchema,
funnelColumnComponent: ReactNode
) => (
<StatusColumnWrapper
status={status}
isDragging={activeStatus?.id === status.id}>
{funnelColumnComponent}
</StatusColumnWrapper>
)}
renderItem={(deal: DealSchema) => (
<DealContainer
key={deal.id}
deal={deal}
/>
)}
activeContainer={activeStatus}
activeItem={activeDeal}
renderItemOverlay={(deal: DealSchema) => (
<DealCard deal={deal} />
)}
renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper
status={status}
isDragging>
{children}
</StatusColumnWrapper>
)}
disabledColumns={isMobile}
/>
{!isMobile && <CreateStatusButton />}
</>
);
if (isMobile) return renderFunnelDnd();
return (
<ScrollArea
offsetScrollbars={"x"}
scrollbarSize={"0.5rem"}>
<Group
align={"start"}
wrap={"nowrap"}
gap={"sm"}>
{renderFunnelDnd()}
</Group>
</ScrollArea>
);
};
export default Funnel;

View File

@ -0,0 +1,65 @@
"use client";
import { IconChevronLeft, IconSettings } from "@tabler/icons-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import Boards from "@/app/deals/components/desktop/Boards/Boards";
import BoardsMobile from "@/app/deals/components/mobile/BoardsMobile/BoardsMobile";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
import { ColorSchemeToggle } from "@/components/ui/ColorSchemeToggle/ColorSchemeToggle";
import useIsMobile from "@/hooks/useIsMobile";
const Header = () => {
const { projects, setSelectedProject, selectedProject } =
useProjectsContext();
const { setIsEditorDrawerOpened } = useBoardsContext();
const isMobile = useIsMobile();
const getDesktopHeader = () => {
return (
<Group
w={"100%"}
justify={"space-between"}
wrap={"nowrap"}
pr={"md"}>
<Boards />
<ColorSchemeToggle />
<ProjectSelect
data={projects}
value={selectedProject}
onChange={value => value && setSelectedProject(value)}
/>
</Group>
);
};
const getMobileHeader = () => {
return (
<Stack>
<Group justify={"space-between"}>
<Box p={"md"}>
<IconChevronLeft />
</Box>
<Text>{selectedProject?.name}</Text>
<Box
p={"md"}
onClick={() => setIsEditorDrawerOpened(true)}>
<IconSettings />
</Box>
</Group>
<BoardsMobile />
</Stack>
);
};
return (
<Group
justify={"flex-end"}
w={"100%"}>
{isMobile ? getMobileHeader() : getDesktopHeader()}
</Group>
);
};
export default Header;

View File

@ -0,0 +1,9 @@
.container {
min-width: 150px;
width: 15vw;
@media (max-width: 48em) {
width: 90vw;
}
}

View File

@ -0,0 +1,69 @@
import React, { ReactNode } from "react";
import { Box, Group, Text } from "@mantine/core";
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { StatusSchema } from "@/lib/client";
import styles from "./StatusColumnWrapper.module.css";
type Props = {
status: StatusSchema;
isDragging?: boolean;
children: ReactNode;
};
const StatusColumnWrapper = ({
status,
children,
isDragging = false,
}: Props) => {
const { onUpdateStatus } = useStatusesContext();
const handleSave = (value: string) => {
const newValue = value.trim();
if (newValue && newValue !== status.name) {
onUpdateStatus(status.id, { name: newValue });
}
};
return (
<Box
mx={7}
className={styles.container}>
<Group
justify={"space-between"}
className={"flex-nowrap border-b-3 border-blue-500 mb-3 p-3"}>
<InPlaceInput
defaultValue={status.name}
onComplete={value => handleSave(value)}
inputStyles={{
input: {
height: 25,
minHeight: 25,
},
}}
getChildren={startEditing => (
<>
<Text
style={{
cursor: "grab",
userSelect: "none",
opacity: isDragging ? 0.5 : 1,
}}>
{status.name}
</Text>
<StatusMenu
status={status}
handleEdit={startEditing}
/>
</>
)}
modalTitle={"Редактирование статуса"}
/>
</Group>
{children}
</Box>
);
};
export default StatusColumnWrapper;

View File

@ -0,0 +1,72 @@
import React, { FC } from "react";
import {
IconDotsVertical,
IconEdit,
IconExchange,
IconTrash,
} from "@tabler/icons-react";
import { Box, Group, Menu, Text } from "@mantine/core";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useIsMobile from "@/hooks/useIsMobile";
import { StatusSchema } from "@/lib/client";
type Props = {
status: StatusSchema;
handleEdit: () => void;
};
const StatusMenu: FC<Props> = ({ status, handleEdit }) => {
const isMobile = useIsMobile();
const { onDeleteStatus, setIsEditorDrawerOpened } = useStatusesContext();
return (
<Menu>
<Menu.Target>
<Box
p={5}
style={{
cursor: "pointer",
}}
onClick={e => e.stopPropagation()}>
<IconDotsVertical size={16} />
</Box>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={e => {
e.stopPropagation();
handleEdit();
}}>
<Group wrap={"nowrap"}>
<IconEdit />
<Text>Переименовать</Text>
</Group>
</Menu.Item>
<Menu.Item
onClick={e => {
e.stopPropagation();
onDeleteStatus(status);
}}>
<Group wrap={"nowrap"}>
<IconTrash />
<Text>Удалить</Text>
</Group>
</Menu.Item>
{isMobile && (
<Menu.Item
onClick={e => {
e.stopPropagation();
setIsEditorDrawerOpened(true);
}}>
<Group wrap={"nowrap"}>
<IconExchange />
<Text>Изменить порядок</Text>
</Group>
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
);
};
export default StatusMenu;