feat: raw deals dnd between statuses

This commit is contained in:
2025-08-01 10:01:39 +04:00
parent 8af4a908e6
commit 5fe9ea6747
14 changed files with 507 additions and 7 deletions

View File

@ -3,12 +3,12 @@
import React from "react";
import { ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/Board/Board";
import useBoards from "@/app/deals/hooks/useBoards";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SortableDnd from "@/components/SortableDnd";
import { BoardSchema } from "@/types/BoardSchema";
const Boards = () => {
const { boards, setBoards } = useBoards();
const { boards } = useBoardsContext();
const renderBoard = (board: BoardSchema) => {
return <Board board={board} />;

View File

@ -0,0 +1,12 @@
import { Card } from "@mantine/core";
import { DealSchema } from "@/types/DealSchema";
type Props = {
deal: DealSchema;
};
const DealCard = ({ deal }: Props) => {
return <Card>{deal.name}</Card>;
};
export default DealCard;

View File

@ -0,0 +1,37 @@
import React from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
type SortableTaskItemProps = {
children: React.ReactNode;
id: string;
};
const SortableItem = ({ children, id }: SortableTaskItemProps) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
{...attributes}
{...listeners}>
{children}
</div>
);
};
export default SortableItem;

View File

@ -0,0 +1,48 @@
import React, { useEffect, useState } from "react";
import { useDroppable } from "@dnd-kit/core";
import {
SortableContext,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { Box, Text } from "@mantine/core";
import DealCard from "@/app/deals/components/DealCard/DealCard";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
import { DealSchema } from "@/types/DealSchema";
import { sortByLexorank } from "@/utils/lexorank";
type BoardSectionProps = {
id: string;
title: string;
deals: DealSchema[];
};
const StatusColumn = ({ id, title, deals }: BoardSectionProps) => {
const { setNodeRef } = useDroppable({ id });
const [sortedDeals, setSortedDeals] = useState<DealSchema[]>([]);
useEffect(() => {
setSortedDeals(sortByLexorank(deals));
}, [deals]);
return (
<Box style={{ backgroundColor: "#eee", padding: 2 }}>
<Text>{title}</Text>
<SortableContext
id={id}
items={sortedDeals}
strategy={verticalListSortingStrategy}>
<div ref={setNodeRef}>
{sortedDeals.map(deal => (
<Box key={deal.id}>
<SortableItem id={deal.id}>
<DealCard deal={deal} />
</SortableItem>
</Box>
))}
</div>
</SortableContext>
</Box>
);
};
export default StatusColumn;

View File

@ -0,0 +1,25 @@
"use client";
import StatusColumnsDnd from "@/app/deals/components/StatusColumnsDnd/StatusColumnsDnd";
const StatusColumns = () => {
const onDealDragEnd = (
dealId: number,
statusId: number,
lexorank?: string
) => {
// Send request to server
console.log(
"onDealDragEnd. dealId =",
dealId,
", statusId =",
statusId,
", lexorank =",
lexorank
);
};
return <StatusColumnsDnd onDealDragEnd={onDealDragEnd} />;
};
export default StatusColumns;

View File

@ -0,0 +1,210 @@
"use client";
import React, { FC, useState } from "react";
import {
closestCorners,
defaultDropAnimation,
DndContext,
DragOverEvent,
DragOverlay,
DragStartEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { Group } from "@mantine/core";
import DealCard from "@/app/deals/components/DealCard/DealCard";
import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { DealSchema } from "@/types/DealSchema";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
type Props = {
onDealDragEnd: (
dealId: number,
statusId: number,
lexorank?: string
) => void;
};
const StatusColumnsDnd: FC<Props> = props => {
const { statuses, deals, setDeals } = useBoardsContext();
const [activeDealId, setActiveDealId] = useState<null | number>(null);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const handleDragStart = ({ active }: DragStartEvent) => {
setActiveDealId(active.id as number);
};
const getStatusByDealId = (dealId: number) => {
const deal = deals.find(deal => deal.id === dealId);
if (!deal) return;
return statuses.find(status => status.id === deal.statusId);
};
const handleDragOver = ({ active, over }: DragOverEvent) => {
if (!over?.id) return;
const activeDealId = Number(active.id);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatusId, newLexorank } = getDropTarget(
over.id,
activeDealId,
activeStatusId
);
if (!overStatusId) return;
setDeals(deals =>
deals.map(deal =>
deal.id === activeDealId
? {
...deal,
statusId: overStatusId,
rank: newLexorank || deal.rank,
}
: deal
)
);
};
const getDropTarget = (
overId: string | number,
activeDealId: number,
activeStatusId: number
) => {
if (typeof overId === "string") {
return {
overStatusId: Number(overId.replace(/-status$/, "")),
newLexorank: undefined,
};
}
const overDealId = Number(overId);
const overStatusId = getStatusByDealId(overDealId)?.id;
if (!overStatusId || activeDealId === overDealId) {
return { overStatusId: undefined, newLexorank: undefined };
}
const statusDeals = sortByLexorank(
deals.filter(deal => deal.statusId === overStatusId)
);
const overDealIndex = statusDeals.findIndex(
deal => deal.id === overDealId
);
let newLexorank;
if (activeStatusId === overStatusId) {
newLexorank = getNewRankForSameStatus(
statusDeals,
overDealIndex,
activeDealId
);
} else {
newLexorank = getNewRankForAnotherStatus(
statusDeals,
overDealIndex
);
}
return { overStatusId, newLexorank };
};
const getNewRankForSameStatus = (
statusDeals: DealSchema[],
overDealIndex: number,
activeDealId: number
) => {
const activeDealIndex = statusDeals.findIndex(
deal => deal.id === activeDealId
);
const [leftIndex, rightIndex] =
overDealIndex < activeDealIndex
? [overDealIndex - 1, overDealIndex]
: [overDealIndex, overDealIndex + 1];
const leftLexorank =
leftIndex >= 0 ? LexoRank.parse(deals[leftIndex].rank) : null;
const rightLexorank =
rightIndex < deals.length
? LexoRank.parse(deals[rightIndex].rank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewRankForAnotherStatus = (
statusDeals: DealSchema[],
overDealIndex: number
) => {
const leftLexorank =
overDealIndex > 0
? LexoRank.parse(statusDeals[overDealIndex - 1].rank)
: null;
const rightLexorank = LexoRank.parse(statusDeals[overDealIndex].rank);
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const handleDragEnd = ({ active, over }: DragOverEvent) => {
setActiveDealId(null);
if (!over?.id) return;
const activeDealId = Number(active.id);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatusId, newLexorank } = getDropTarget(
over.id,
activeDealId,
activeStatusId
);
if (!overStatusId) return;
props.onDealDragEnd(activeDealId, overStatusId, newLexorank);
};
const deal = activeDealId
? deals.find(deal => deal.id === activeDealId)
: null;
return (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}>
<Group
gap={"xs"}
justify={"start"}>
{statuses.map(status => (
<StatusColumn
key={status.id}
id={`${status.id}-status`}
title={status.name}
deals={deals.filter(
deal => deal.statusId === status.id
)}
/>
))}
<DragOverlay dropAnimation={defaultDropAnimation}>
{deal ? <DealCard deal={deal} /> : null}
</DragOverlay>
</Group>
</DndContext>
);
};
export default StatusColumnsDnd;