fix: fixed columns draggables and styles

This commit is contained in:
2025-08-17 10:38:28 +04:00
parent 4ff663536e
commit c405c802aa
14 changed files with 188 additions and 93 deletions

View File

@ -29,7 +29,8 @@ const BoardsMobile = () => {
scrollbars={"x"} scrollbars={"x"}
scrollbarSize={0} scrollbarSize={0}
w={"100vw"} w={"100vw"}
mt={5}> mt={5}
mb={"sm"}>
<Group <Group
wrap={"nowrap"} wrap={"nowrap"}
gap={0}> gap={0}>

View File

@ -1,10 +1,16 @@
.container { .container {
border-radius: var(--mantine-spacing-md);
flex-wrap: nowrap; flex-wrap: nowrap;
border-bottom: solid dodgerblue 3px;
margin-bottom: 3px;
cursor: pointer; cursor: pointer;
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
@media (max-width: 48em) { @media (max-width: 48em) {
width: 80vw; width: 80vw;
} }

View File

@ -12,7 +12,9 @@ const CreateStatusButton = () => {
return ( return (
<Stack> <Stack>
<Box className={styles.container}> <Box
className={styles.container}
style={{ width: "fit-content", minWidth: "auto" }}>
<InPlaceInput <InPlaceInput
placeholder={"Название колонки"} placeholder={"Название колонки"}
onComplete={onCreateStatus} onComplete={onCreateStatus}
@ -32,7 +34,8 @@ const CreateStatusButton = () => {
modalTitle={"Создание колонки"} modalTitle={"Создание колонки"}
inputStyles={{ inputStyles={{
wrapper: { wrapper: {
padding: 4, paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-xs)",
}, },
}} }}
/> />

View File

@ -1,7 +1,7 @@
.container { .container {
@mixin light { @mixin light {
background-color: var(--color-light-aqua); background-color: var(--color-light-white-blue);
} }
@mixin dark { @mixin dark {
background-color: var(--mantine-color-dark-7); background-color: var(--mantine-color-dark-7);

View File

@ -3,6 +3,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 DealContainer from "@/app/deals/components/shared/DealContainer/DealContainer";
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";
import { useDealsContext } from "@/app/deals/contexts/DealsContext"; import { useDealsContext } from "@/app/deals/contexts/DealsContext";
@ -43,14 +44,21 @@ const Funnel: FC = () => {
} }
renderContainer={( renderContainer={(
status: StatusSchema, status: StatusSchema,
funnelColumnComponent: ReactNode funnelColumnComponent: ReactNode,
renderDraggable
) => ( ) => (
<StatusColumnWrapper <StatusColumnWrapper
status={status} status={status}
isDragging={activeStatus?.id === status.id}> renderHeader={renderDraggable}>
{funnelColumnComponent} {funnelColumnComponent}
</StatusColumnWrapper> </StatusColumnWrapper>
)} )}
renderContainerHeader={status => (
<StatusColumnHeader
status={status}
isDragging={activeStatus?.id === status.id}
/>
)}
renderItem={(deal: DealSchema) => ( renderItem={(deal: DealSchema) => (
<DealContainer <DealContainer
key={deal.id} key={deal.id}
@ -63,7 +71,12 @@ const Funnel: FC = () => {
renderContainerOverlay={(status: StatusSchema, children) => ( renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper <StatusColumnWrapper
status={status} status={status}
isDragging> renderHeader={() => (
<StatusColumnHeader
status={status}
isDragging={activeStatus?.id === status.id}
/>
)}>
{children} {children}
</StatusColumnWrapper> </StatusColumnWrapper>
)} )}

View File

@ -0,0 +1,4 @@
.header {
border-bottom: solid dodgerblue 3px;
}

View File

@ -0,0 +1,62 @@
import React, { FC } from "react";
import { Group, Text } from "@mantine/core";
import styles from "./StatusColumnHeader.module.css";
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";
type Props = {
status: StatusSchema;
isDragging: boolean;
};
const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
const { onUpdateStatus } = useStatusesContext();
const handleSave = (value: string) => {
const newValue = value.trim();
if (newValue && newValue !== status.name) {
onUpdateStatus(status.id, { name: newValue });
}
};
return (
<Group
justify={"space-between"}
p={"sm"}
wrap={"nowrap"}
mb={"xs"}
className={styles.header}>
<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>
);
};
export default StatusColumnHeader;

View File

@ -1,10 +1,20 @@
.container { .container {
height: calc(100vh - 150px);
@media (max-width: 48em) { @media (max-width: 48em) {
width: 80vw; width: 80vw;
} }
} }
.header { .inner-container {
border-bottom: solid dodgerblue 3px; border-radius: var(--mantine-spacing-md);
gap: 0;
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
} }

View File

@ -1,68 +1,24 @@
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { Box, Group, Text } from "@mantine/core"; import { Box, Stack } 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 { StatusSchema } from "@/lib/client";
import styles from "./StatusColumnWrapper.module.css"; import styles from "./StatusColumnWrapper.module.css";
type Props = { type Props = {
status: StatusSchema; status: StatusSchema;
isDragging?: boolean; renderHeader: () => ReactNode;
children: ReactNode; children: ReactNode;
}; };
const StatusColumnWrapper = ({ const StatusColumnWrapper = ({ renderHeader, children }: Props) => {
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 ( return (
<Box className={styles.container}> <Box className={styles.container}>
<Group <Stack
justify={"space-between"} px={"xs"}
p={"sm"} pb={"xs"}
wrap={"nowrap"} className={styles["inner-container"]}>
mb={"xs"} {renderHeader()}
className={styles.header}> {children}
<InPlaceInput </Stack>
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> </Box>
); );
}; };

View File

@ -1,13 +1,14 @@
import { RefObject, useMemo, useRef, useState } from "react"; import { RefObject, useMemo, useRef, useState } from "react";
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core"; import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core";
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 { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
import useIsMobile from "@/hooks/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client"; import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank"; import { sortByLexorank } from "@/utils/lexorank";
import { SwiperRef } from "swiper/swiper-react";
type ReturnType = { type ReturnType = {
sortedStatuses: StatusSchema[]; sortedStatuses: StatusSchema[];
@ -26,6 +27,7 @@ const useDealsAndStatusesDnd = (): ReturnType => {
const { statuses, setStatuses, updateStatus } = useStatusesContext(); const { statuses, setStatuses, updateStatus } = useStatusesContext();
const { deals, setDeals, updateDeal } = useDealsContext(); const { deals, setDeals, updateDeal } = useDealsContext();
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]); const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const isMobile = useIsMobile();
const { const {
getNewRankForSameStatus, getNewRankForSameStatus,
@ -42,32 +44,50 @@ const useDealsAndStatusesDnd = (): ReturnType => {
return statuses.find(status => status.id === deal.statusId); return statuses.find(status => status.id === deal.statusId);
}; };
const swipeSliderDuringDrag = (activeId: number, over: Over) => {
const activeStatusLexorank = getStatusByDealId(
Number(activeId)
)?.lexorank;
let overStatusLexorank: string | undefined;
if (typeof over.id === "string" && isStatusId(over.id)) {
const overStatusId = getStatusId(over.id);
overStatusLexorank = statuses.find(
s => s.id === overStatusId
)?.lexorank;
} else {
overStatusLexorank = getStatusByDealId(Number(over.id))?.lexorank;
}
if (
!activeStatusLexorank ||
!overStatusLexorank ||
!swiperRef.current?.swiper
)
return;
const activeIndex = sortedStatuses.findIndex(
s => s.lexorank === activeStatusLexorank
);
const overIndex = sortedStatuses.findIndex(
s => s.lexorank === overStatusLexorank
);
if (activeIndex > overIndex) {
swiperRef.current.swiper.slidePrev();
return;
}
if (activeIndex < overIndex) {
swiperRef.current.swiper.slideNext();
}
};
const handleDragOver = ({ active, over }: DragOverEvent) => { const handleDragOver = ({ active, over }: DragOverEvent) => {
if (!over) return; if (!over) return;
const activeId = active.id as string | number; const activeId = active.id as string | number;
// Only perform swiper navigation for deal drag (not status column drag) if (isMobile && typeof activeId !== "string") {
if (typeof activeId !== "string") { swipeSliderDuringDrag(activeId, over);
const activeStatusLexorank = getStatusByDealId(Number(activeId))?.lexorank;
let overStatusLexorank: string | undefined;
if (typeof over.id === "string" && isStatusId(over.id)) {
const overStatusId = getStatusId(over.id);
overStatusLexorank = statuses.find(s => s.id === overStatusId)?.lexorank;
} else {
overStatusLexorank = getStatusByDealId(Number(over.id))?.lexorank;
}
if (activeStatusLexorank && overStatusLexorank && swiperRef.current?.swiper) {
const activeIndex = sortedStatuses.findIndex(s => s.lexorank === activeStatusLexorank);
const overIndex = sortedStatuses.findIndex(s => s.lexorank === overStatusLexorank);
if (activeIndex > overIndex) {
swiperRef.current.swiper.slidePrev();
} else if (activeIndex < overIndex) {
swiperRef.current.swiper.slideNext();
}
}
} }
if (typeof activeId === "string" && isStatusId(activeId)) { if (typeof activeId === "string" && isStatusId(activeId)) {

View File

@ -4,6 +4,7 @@
/* Colors */ /* Colors */
--color-light-gray-blue: #f4f7fd; --color-light-gray-blue: #f4f7fd;
--color-light-aqua: #e0f0f4; --color-light-aqua: #e0f0f4;
--color-light-white-blue: #f5fbfc;
--color-light-whitesmoke: #fcfdff; --color-light-whitesmoke: #fcfdff;
--mantine-color-dark-7-5: #212121; --mantine-color-dark-7-5: #212121;
/* Shadows */ /* Shadows */

View File

@ -2,7 +2,7 @@
import React, { ReactNode, RefObject } from "react"; import React, { ReactNode, RefObject } from "react";
import { import {
closestCorners, closestCenter,
DndContext, DndContext,
DragEndEvent, DragEndEvent,
DragOverEvent, DragOverEvent,
@ -30,7 +30,12 @@ type Props<TContainer, TItem> = {
onDragOver: (event: DragOverEvent) => void; onDragOver: (event: DragOverEvent) => void;
onDragEnd: (event: DragEndEvent) => void; onDragEnd: (event: DragEndEvent) => void;
swiperRef: RefObject<SwiperRef | null>; swiperRef: RefObject<SwiperRef | null>;
renderContainer: (container: TContainer, children: ReactNode) => ReactNode; renderContainer: (
container: TContainer,
children: ReactNode,
renderDraggable: () => ReactNode
) => ReactNode;
renderContainerHeader: (container: TContainer) => ReactNode;
renderContainerOverlay: ( renderContainerOverlay: (
container: TContainer, container: TContainer,
children: ReactNode children: ReactNode
@ -56,6 +61,7 @@ const FunnelDnd = <
onDragEnd, onDragEnd,
swiperRef, swiperRef,
renderContainer, renderContainer,
renderContainerHeader,
renderContainerOverlay, renderContainerOverlay,
renderItem, renderItem,
renderItemOverlay, renderItemOverlay,
@ -81,16 +87,18 @@ const FunnelDnd = <
key={containerId} key={containerId}
id={containerId} id={containerId}
disabled={disabledColumns} disabled={disabledColumns}
renderItem={() => renderItem={renderDraggable =>
renderContainer( renderContainer(
container, container,
<FunnelColumn <FunnelColumn
id={containerId} id={containerId}
items={containerItems} items={containerItems}
renderItem={renderItem} renderItem={renderItem}
/> />,
renderDraggable!
) )
} }
renderDraggable={() => renderContainerHeader(container)}
/> />
</SwiperSlide> </SwiperSlide>
); );
@ -142,7 +150,7 @@ const FunnelDnd = <
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCorners} collisionDetection={closestCenter}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragOver={onDragOver} onDragOver={onDragOver}
onDragEnd={onDragEnd}> onDragEnd={onDragEnd}>

View File

@ -0,0 +1,9 @@
.swiper-container :global(.swiper-scrollbar-drag) {
@mixin dark {
background-color: var(--mantine-color-dark-9);
}
@mixin light {
background-color: grey;
}
}

View File

@ -18,6 +18,7 @@ import useDndSensors from "@/app/deals/hooks/useSensors";
import { SortableOverlay } from "@/components/dnd/SortableDnd/SortableOverlay"; import { SortableOverlay } from "@/components/dnd/SortableDnd/SortableOverlay";
import SortableItem from "@/components/dnd/SortableItem"; import SortableItem from "@/components/dnd/SortableItem";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
import classes from "./SortableDnd.module.css";
type BaseItem = { type BaseItem = {
id: number; id: number;
@ -112,6 +113,7 @@ const SortableDnd = <T extends BaseItem>({
enabled: true, enabled: true,
sensitivity: 0.2, sensitivity: 0.2,
}} }}
className={classes["swiper-container"]}
style={containerStyle} style={containerStyle}
direction={vertical ? "vertical" : "horizontal"} direction={vertical ? "vertical" : "horizontal"}
freeMode={{ enabled: true }} freeMode={{ enabled: true }}