Compare commits

...

1 Commits

Author SHA1 Message Date
36c2a3a2af feat: funnel dnd using pragmatic, not finished groups 2025-10-16 15:26:53 +04:00
41 changed files with 3337 additions and 629 deletions

View File

@ -11,6 +11,12 @@
"generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs" "generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs"
}, },
"dependencies": { "dependencies": {
"@atlaskit/avatar": "^25.4.2",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.7",
"@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.1",
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.7",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
@ -27,6 +33,7 @@
"@tabler/icons-react": "^3.34.0", "@tabler/icons-react": "^3.34.0",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.83.0", "@tanstack/react-query": "^5.83.0",
"@types/react-dom": "19.1.2",
"axios": "1.12.0", "axios": "1.12.0",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

View File

@ -1,5 +1,6 @@
.container { .container {
flex: 1;
padding: 0; padding: 0;
@mixin light { @mixin light {
background-color: var(--color-light-white-blue); background-color: var(--color-light-white-blue);

View File

@ -1,25 +0,0 @@
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,89 @@
import { FC, useEffect, useState } from "react";
import { IconGripHorizontal } from "@tabler/icons-react";
import { Flex, rem, TextInput, useMantineColorScheme } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import FulfillmentGroupInfo from "@/app/deals/components/shared/DealGroupCard/components/FulfillmentGroupInfo";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { notifications } from "@/lib/notifications";
import { ModuleNames } from "@/modules/modules";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type Props = {
group: GroupWithDealsSchema;
};
const DealGroupCard: FC<Props> = ({ group }) => {
const theme = useMantineColorScheme();
const [name, setName] = useState<string>(group.name ?? "");
const [debouncedName] = useDebouncedValue(name, 200);
const { modulesSet } = useProjectsContext();
const isServicesAndProductsIncluded = modulesSet.has(
ModuleNames.FULFILLMENT_BASE
);
const updateName = () => {
if (debouncedName === group.name) return;
CardGroupService.updateCardGroup({
requestBody: {
data: {
...group,
name: debouncedName,
},
},
}).then(response => {
if (response.ok) return;
setName(group.name || "");
notifications.guess(response.ok, { message: response.message });
});
};
useEffect(() => {
updateName();
}, [debouncedName]);
return (
<Flex
style={{
border: "dashed var(--item-border-size) var(--mantine-color-default-border)",
borderRadius: "0.5rem",
}}
p={rem(5)}
py={"xs"}
bg={
theme.colorScheme === "dark"
? "var(--mantine-color-dark-5)"
: "var(--mantine-color-gray-1)"
}
gap={"xs"}
direction={"column"}>
<Flex
justify={"space-between"}
align={"center"}
gap={"xs"}
px={"xs"}>
<TextInput
value={name}
onChange={event => setName(event.currentTarget.value)}
variant={"unstyled"}
/>
<IconGripHorizontal />
</Flex>
<Flex
direction={"column"}
gap={"xs"}>
{group.deals?.map(deal => (
<DealCard
key={deal.id}
deal={deal}
/>
))}
</Flex>
{isServicesAndProductsIncluded && (
<FulfillmentGroupInfo group={group} />
)}
</Flex>
);
};
export default DealGroupCard;

View File

@ -0,0 +1,51 @@
import { Flex, Text, useMantineColorScheme } from "@mantine/core";
import { FC, useMemo } from "react";
import { DealGroupSchema } from "@/lib/client";
type Props = {
group: DealGroupSchema;
}
const FulfillmentGroupInfo: FC<Props> = ({ group }) => {
const theme = useMantineColorScheme();
const totalPrice = useMemo(
() =>
group.deals?.reduce((acc, deal) => acc + (deal.totalPrice ?? 0), 0),
[group.deals]
);
const totalProducts = useMemo(
() =>
group.deals?.reduce(
(acc, deal) => acc + (deal.productsQuantity ?? 0),
0
),
[group.deals]
);
return (
<Flex
p={"xs"}
direction={"column"}
bg={
theme.colorScheme === "dark"
? "var(--mantine-color-dark-6)"
: "var(--mantine-color-gray-2)"
}
style={{ borderRadius: "0.5rem" }}>
<Text
c={"gray.6"}
size={"xs"}>
Сумма: {totalPrice?.toLocaleString("ru-RU")} руб.
</Text>
<Text
c={"gray.6"}
size={"xs"}>
Всего товаров: {totalProducts?.toLocaleString("ru-RU")}{" "}
шт.
</Text>
</Flex>
)
}
export default FulfillmentGroupInfo;

View File

@ -1,90 +1,67 @@
"use client"; "use client";
import React, { FC, ReactNode } from "react"; import React, { FC } from "react";
import { Box } from "@mantine/core";
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 StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader"; import StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader";
import StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useDealsContext } from "@/app/deals/contexts/DealsContext"; import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd"; import DndFunnel from "@/components/dnd-pragmatic/DndFunnel/DndFunnel";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank/sort"; import { sortByLexorank } from "@/utils/lexorank/sort";
const Funnel: FC = () => { const Funnel: FC = () => {
const { selectedBoard } = useBoardsContext(); const { statuses, setStatuses, statusesCrud } = useStatusesContext();
const { deals } = useDealsContext(); const { dealsWithoutGroup, groupsWithDeals, deals, setDeals, dealsCrud } =
const isMobile = useIsMobile(); useDealsContext();
const { const updateStatus = (statusId: number, lexorank: string) => {
sortedStatuses, setStatuses(
handleDragStart, statuses.map(status =>
handleDragOver, status.id === statusId ? { ...status, lexorank } : status
handleDragEnd, )
activeStatus, );
activeDeal,
swiperRef, statusesCrud.onUpdate(statusId, { lexorank });
} = useDealsAndStatusesDnd(); };
const updateDeal = (dealId: number, lexorank: string, statusId: number) => {
const status = statuses.find(s => s.id === statusId);
if (!status) return;
setDeals(
deals.map(deal =>
deal.id === dealId ? { ...deal, lexorank, status } : deal
)
);
dealsCrud.onUpdate(dealId, { lexorank, statusId });
};
return ( return (
<FunnelDnd <DndFunnel
containers={sortedStatuses} columns={statuses}
items={deals} updateColumn={updateStatus}
onDragStart={handleDragStart} items={dealsWithoutGroup}
onDragOver={handleDragOver} groups={groupsWithDeals}
onDragEnd={handleDragEnd} updateItem={updateDeal}
swiperRef={swiperRef} getColumnItemsGroups={statusId =>
getContainerId={(status: StatusSchema) => `${status.id}-status`} sortByLexorank([
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) => ...dealsWithoutGroup.filter(d => d.status.id === statusId),
sortByLexorank( ...groupsWithDeals.filter(
items.filter(deal => deal.status.id === status.id) g =>
) g.items.length > 0 &&
g.items[0].status.id === statusId
),
])
} }
renderContainer={( renderColumnHeader={status => (
status: StatusSchema, <StatusColumnHeader status={status} />
funnelColumnComponent: ReactNode,
renderDraggable,
index
) => (
<StatusColumnWrapper
status={status}
renderHeader={renderDraggable}
createFormEnabled={index === 0}>
{funnelColumnComponent}
</StatusColumnWrapper>
)} )}
renderContainerHeader={status => ( renderItem={deal => (
<StatusColumnHeader <DealCard
status={status}
isDragging={activeStatus?.id === status.id}
/>
)}
renderItem={(deal: DealSchema) => (
<DealContainer
key={deal.id} key={deal.id}
deal={deal} deal={deal}
/> />
)} )}
activeContainer={activeStatus} renderGroup={group => <Box flex={1}>{group.name}</Box>}
activeItem={activeDeal}
renderItemOverlay={(deal: DealSchema) => <DealCard deal={deal} />}
renderContainerOverlay={(status: StatusSchema, children) => (
<StatusColumnWrapper
status={status}
renderHeader={() => (
<StatusColumnHeader
status={status}
isDragging={activeStatus?.id === status.id}
/>
)}>
{children}
</StatusColumnWrapper>
)}
disabledColumns={isMobile}
isCreatingContainerEnabled={!!selectedBoard}
/> />
); );
}; };

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { Box } from "@mantine/core"; import { Flex } from "@mantine/core";
import TopToolPanel from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel"; import TopToolPanel from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel";
import { import {
BoardView, BoardView,
@ -46,7 +46,11 @@ const PageBody = () => {
<PageBlock <PageBlock
fullScreenMobile fullScreenMobile
style={{ flex: 1 }}> style={{ flex: 1 }}>
<Box h={"100%"}>{getViewContent()}</Box> <Flex
direction={"column"}
h={"100%"}>
{getViewContent()}
</Flex>
</PageBlock> </PageBlock>
</DealsContextProvider> </DealsContextProvider>
); );

View File

@ -8,10 +8,9 @@ import { StatusSchema } from "@/lib/client";
type Props = { type Props = {
status: StatusSchema; status: StatusSchema;
isDragging: boolean;
}; };
const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => { const StatusColumnHeader: FC<Props> = ({ status }) => {
const { statusesCrud, refetchStatuses } = useStatusesContext(); const { statusesCrud, refetchStatuses } = useStatusesContext();
const { selectedBoard } = useBoardsContext(); const { selectedBoard } = useBoardsContext();
@ -28,6 +27,7 @@ const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
p={"sm"} p={"sm"}
wrap={"nowrap"} wrap={"nowrap"}
mb={"xs"} mb={"xs"}
w={"100%"}
style={{ style={{
borderBottom: `solid ${status.color} 3px`, borderBottom: `solid ${status.color} 3px`,
}}> }}>
@ -42,14 +42,7 @@ const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
}} }}
getChildren={startEditing => ( getChildren={startEditing => (
<> <>
<Text <Text>{status.name}</Text>
style={{
cursor: "grab",
userSelect: "none",
opacity: isDragging ? 0.5 : 1,
}}>
{status.name}
</Text>
<StatusMenu <StatusMenu
board={selectedBoard} board={selectedBoard}
status={status} status={status}

View File

@ -3,16 +3,20 @@
import React from "react"; import React from "react";
import { UseFormReturnType } from "@mantine/form"; import { UseFormReturnType } from "@mantine/form";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useDealsAndGroups from "@/app/deals/hooks/useDealsAndGroups";
import { DealsFiltersForm } from "@/app/deals/hooks/useDealsFilters"; import { DealsFiltersForm } from "@/app/deals/hooks/useDealsFilters";
import { DealsCrud, useDealsCrud } from "@/hooks/cruds/useDealsCrud"; import { DealsCrud, useDealsCrud } from "@/hooks/cruds/useDealsCrud";
import useDealsList from "@/hooks/lists/useDealsList"; import useDealsList from "@/hooks/lists/useDealsList";
import { SortingForm } from "@/hooks/utils/useSorting"; import { SortingForm } from "@/hooks/utils/useSorting";
import { DealSchema, PaginationInfoSchema } from "@/lib/client"; import { DealSchema, PaginationInfoSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory"; import makeContext from "@/lib/contextFactory/contextFactory";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type DealsContextState = { type DealsContextState = {
deals: DealSchema[]; deals: DealSchema[];
setDeals: (deals: DealSchema[]) => void; setDeals: (deals: DealSchema[]) => void;
dealsWithoutGroup: DealSchema[];
groupsWithDeals: GroupWithDealsSchema[];
refetchDeals: () => void; refetchDeals: () => void;
dealsCrud: DealsCrud; dealsCrud: DealsCrud;
paginationInfo?: PaginationInfoSchema; paginationInfo?: PaginationInfoSchema;
@ -48,8 +52,13 @@ const useDealsContextState = ({
statuses, statuses,
}); });
const { dealsWithoutGroup, groupsWithDeals } =
useDealsAndGroups(dealsListObjects);
return { return {
...dealsListObjects, ...dealsListObjects,
dealsWithoutGroup,
groupsWithDeals,
dealsCrud, dealsCrud,
}; };
}; };

View File

@ -0,0 +1,43 @@
import { useMemo } from "react";
import { isNull } from "lodash";
import { DealSchema } from "@/lib/client";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
import { sortByLexorank } from "@/utils/lexorank/sort";
type Props = {
deals: DealSchema[];
};
const useDealsAndGroups = ({ deals }: Props) => {
const dealsWithoutGroup: DealSchema[] = useMemo(
() => deals.filter(d => isNull(d.group)),
[deals]
);
const groupsWithDeals: GroupWithDealsSchema[] = useMemo(() => {
const groupsWithDealMap = new Map<number, GroupWithDealsSchema>();
for (const deal of deals) {
if (isNull(deal.group)) continue;
const groupData = groupsWithDealMap.get(deal.group.id);
if (groupData) {
groupData.items.push(deal);
groupsWithDealMap.set(deal.group.id, groupData);
} else {
groupsWithDealMap.set(deal.group.id, { ...deal.group, items: [] });
}
}
return sortByLexorank(groupsWithDealMap.values().toArray());
}, [deals]);
console.log(groupsWithDeals);
return {
dealsWithoutGroup,
groupsWithDeals,
};
};
export default useDealsAndGroups;

View File

@ -1,291 +0,0 @@
import { RefObject, useMemo, useRef, useState } from "react";
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core";
import { SwiperRef } from "swiper/swiper-react";
import { useDebouncedCallback } from "@mantine/hooks";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank/sort";
type ReturnType = {
sortedStatuses: StatusSchema[];
handleDragStart: ({ active }: DragStartEvent) => void;
handleDragOver: ({ active, over }: DragOverEvent) => void;
handleDragEnd: ({ active, over }: DragOverEvent) => void;
activeStatus: StatusSchema | null;
activeDeal: DealSchema | null;
swiperRef: RefObject<SwiperRef | null>;
};
const useDealsAndStatusesDnd = (): ReturnType => {
const swiperRef = useRef<SwiperRef>(null);
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
const { statuses, setStatuses, statusesCrud } = useStatusesContext();
const { deals, setDeals, dealsCrud } = useDealsContext();
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
const isMobile = useIsMobile();
const {
getNewRankForSameStatus,
getNewRankForAnotherStatus,
getNewStatusRank,
} = useGetNewRank();
const debouncedSetStatuses = useDebouncedCallback(setStatuses, 200);
const debouncedSetDeals = useDebouncedCallback(setDeals, 200);
const getStatusByDealId = (dealId: number) => {
const deal = deals.find(deal => deal.id === dealId);
if (!deal) return;
return statuses.find(status => status.id === deal.status.id);
};
const getStatusById = (statusId: number) => {
return statuses.find(status => status.id === statusId);
};
const swipeSliderDuringDrag = (activeId: number, over: Over) => {
const activeStatus = getStatusByDealId(activeId);
const swiperActiveStatus =
statuses[swiperRef.current?.swiper.activeIndex ?? 0];
if (swiperActiveStatus.id !== activeStatus?.id) return;
const activeStatusLexorank = activeStatus?.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) => {
if (!over) return;
const activeId = active.id as string | number;
if (isMobile && typeof activeId !== "string") {
swipeSliderDuringDrag(activeId, over);
}
if (typeof activeId === "string" && isStatusId(activeId)) {
handleColumnDragOver(activeId, over);
return;
}
handleDealDragOver(activeId, over);
};
const handleDealDragOver = (activeId: string | number, over: Over) => {
const activeDealId = Number(activeId);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatus, newLexorank } = getDropTarget(
over.id,
activeDealId,
activeStatusId
);
if (!overStatus) return;
debouncedSetDeals(
deals.map(deal =>
deal.id === activeDealId
? {
...deal,
status: overStatus,
lexorank: newLexorank || deal.lexorank,
}
: deal
)
);
};
const handleColumnDragOver = (activeId: string, over: Over) => {
const activeStatusId = getStatusId(activeId);
let overStatusId: number;
if (typeof over.id === "string" && isStatusId(over.id)) {
overStatusId = getStatusId(over.id);
} else {
const deal = deals.find(deal => deal.id === over.id);
if (!deal) return;
overStatusId = deal.status.id;
}
if (!overStatusId || activeStatusId === overStatusId) return;
const newRank = getNewStatusRank(activeStatusId, overStatusId);
if (!newRank) return;
debouncedSetStatuses(
statuses.map(status =>
status.id === activeStatusId
? { ...status, lexorank: newRank }
: status
)
);
};
const getDropTarget = (
overId: string | number,
activeDealId: number,
activeStatusId: number,
isOnDragEnd: boolean = false
): { overStatus?: StatusSchema; newLexorank?: string } => {
if (typeof overId === "string") {
return {
overStatus: getStatusById(getStatusId(overId)),
newLexorank: undefined,
};
}
const overDealId = Number(overId);
const overStatus = getStatusByDealId(overDealId);
if (!overStatus || (!isOnDragEnd && activeDealId === overDealId)) {
return { overStatus: undefined, newLexorank: undefined };
}
const statusDeals = sortByLexorank(
deals.filter(deal => deal.status.id === overStatus.id)
);
const overDealIndex = statusDeals.findIndex(
deal => deal.id === overDealId
);
if (activeStatusId === overStatus.id) {
const newLexorank = getNewRankForSameStatus(
statusDeals,
overDealIndex,
activeDealId
);
return { overStatus, newLexorank };
}
const newLexorank = getNewRankForAnotherStatus(
statusDeals,
overDealIndex
);
return { overStatus, newLexorank };
};
const handleDragEnd = ({ active, over }: DragOverEvent) => {
setActiveDeal(null);
setActiveStatus(null);
if (!over) return;
const activeId: string | number = active.id;
if (typeof activeId === "string" && isStatusId(activeId)) {
handleStatusColumnDragEnd(activeId, over);
return;
}
handleDealDragEnd(activeId, over);
};
const handleStatusColumnDragEnd = (activeId: string, over: Over) => {
const activeStatusId = getStatusId(activeId);
let overStatusId: number;
if (typeof over.id === "string" && isStatusId(over.id)) {
overStatusId = getStatusId(over.id);
} else {
const deal = deals.find(deal => deal.status.id === over.id);
if (!deal) return;
overStatusId = deal.status.id;
}
if (!overStatusId) return;
const newRank = getNewStatusRank(activeStatusId, overStatusId);
if (!newRank) return;
onStatusDragEnd?.(activeStatusId, newRank);
};
const onStatusDragEnd = (statusId: number, lexorank: string) => {
statusesCrud.onUpdate(statusId, { lexorank });
};
const handleDealDragEnd = (activeId: number | string, over: Over) => {
const activeDealId = Number(activeId);
const activeStatusId = getStatusByDealId(activeDealId)?.id;
if (!activeStatusId) return;
const { overStatus, newLexorank } = getDropTarget(
over.id,
activeDealId,
activeStatusId,
true
);
if (!overStatus) return;
onDealDragEnd(activeDealId, overStatus.id, newLexorank);
};
const onDealDragEnd = (
dealId: number,
statusId: number,
lexorank?: string
) => {
dealsCrud.onUpdate(dealId, { statusId, lexorank, name: null });
};
const handleDragStart = ({ active }: DragStartEvent) => {
const activeId = active.id as string | number;
if (typeof activeId === "string" && isStatusId(activeId)) {
const statusId = getStatusId(activeId);
setActiveStatus(
statuses.find(status => status.id === statusId) ?? null
);
return;
}
setActiveDeal(
deals.find(deal => deal.id === (activeId as number)) ?? null
);
};
return {
swiperRef,
sortedStatuses,
handleDragStart,
handleDragOver,
handleDragEnd,
activeStatus,
activeDeal,
};
};
export default useDealsAndStatusesDnd;

View File

@ -1,104 +0,0 @@
import { LexoRank } from "lexorank";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import { DealSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank/sort";
import { getNewLexorank } from "@/utils/lexorank/generation";
type NewRankGetters = {
getNewRankForSameStatus: (
statusDeals: DealSchema[],
overDealIndex: number,
activeDealId: number
) => string;
getNewRankForAnotherStatus: (
statusDeals: DealSchema[],
overDealIndex: number
) => string;
getNewStatusRank: (
activeStatusId: number,
overStatusId: number
) => string | null;
};
const useGetNewRank = (): NewRankGetters => {
const { statuses } = useStatusesContext();
const getNewRankForSameStatus = (
statusDeals: DealSchema[],
overDealIndex: number,
activeDealId: number
): string => {
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(statusDeals[leftIndex].lexorank)
: null;
const rightLexorank =
rightIndex < statusDeals.length
? LexoRank.parse(statusDeals[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewRankForAnotherStatus = (
statusDeals: DealSchema[],
overDealIndex: number
): string => {
const leftLexorank =
overDealIndex > 0
? LexoRank.parse(statusDeals[overDealIndex - 1].lexorank)
: null;
const rightLexorank = LexoRank.parse(
statusDeals[overDealIndex].lexorank
);
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewStatusRank = (
activeStatusId: number,
overStatusId: number
): string | null => {
const sortedStatusList = sortByLexorank(statuses);
const overIndex = sortedStatusList.findIndex(
s => s.id === overStatusId
);
const activeIndex = sortedStatusList.findIndex(
s => s.id === activeStatusId
);
if (overIndex === -1 || activeIndex === -1) return null;
const [leftIndex, rightIndex] =
overIndex < activeIndex
? [overIndex - 1, overIndex]
: [overIndex, overIndex + 1];
const leftLexorank =
leftIndex >= 0
? LexoRank.parse(statuses[leftIndex].lexorank)
: null;
const rightLexorank =
rightIndex < statuses.length
? LexoRank.parse(statuses[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
return {
getNewRankForSameStatus,
getNewRankForAnotherStatus,
getNewStatusRank,
};
};
export default useGetNewRank;

View File

@ -0,0 +1,15 @@
.visible-column {
border-radius: var(--mantine-spacing-lg);
gap: 0;
@media (max-width: 48em) {
max-height: 100%;
}
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}

View File

@ -0,0 +1,39 @@
"use client";
import React from "react";
import DndBoard from "@/components/dnd-pragmatic/DndFunnel/components/DndBoard";
import { DndColumn } from "@/components/dnd-pragmatic/DndFunnel/components/DndColumn";
import { DndFunnelContextProvider } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import FunnelDndProps from "@/components/dnd-pragmatic/DndFunnel/types/FunnelDndProps";
const DndFunnel = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>(
props: FunnelDndProps<ColumnType, ItemType, GroupType>
) => {
return (
<DndFunnelContextProvider {...props}>
<DndBoard>
{props.columns.map(column => (
<DndColumn
column={column}
columnItemsAndGroups={props.getColumnItemsGroups(
column.id
)}
{...props}
key={column.id}
/>
))}
</DndBoard>
</DndFunnelContextProvider>
);
};
export default DndFunnel;

View File

@ -0,0 +1,52 @@
import React, { ForwardedRef, forwardRef, ReactNode, type Ref } from "react";
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { Flex, xcss } from "@atlaskit/primitives";
import { BaseItemType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { CardState } from "@/components/dnd-pragmatic/DndFunnel/types/DndStates";
const baseStyles = xcss({
width: "100%",
position: "relative",
});
const stateStyles: {
[Key in CardState["type"]]: ReturnType<typeof xcss> | undefined;
} = {
idle: xcss({
cursor: "grab",
}),
dragging: xcss({
opacity: 0.4,
}),
// no shadow for preview - the platform will add it's own drop shadow
preview: undefined,
};
type CardPrimitiveProps<ItemType extends BaseItemType> = {
closestEdge: Edge | null;
item: ItemType;
renderItem: (item: any) => ReactNode;
state: CardState;
actionMenuTriggerRef?: Ref<HTMLButtonElement>;
};
const CardWrapper = forwardRef(
<ItemType extends BaseItemType>(
{ closestEdge, item, renderItem, state }: CardPrimitiveProps<ItemType>,
ref: ForwardedRef<HTMLDivElement>
) => {
return (
<Flex
ref={ref}
columnGap="space.100"
alignItems="center"
xcss={[baseStyles, stateStyles[state.type]]}>
{renderItem(item)}
{closestEdge && <DropIndicator edge={closestEdge} />}
</Flex>
);
}
);
export default CardWrapper;

View File

@ -0,0 +1,42 @@
import React, { forwardRef, memo, useEffect, type ReactNode } from "react";
import { autoScrollWindowForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import classNames from "classnames";
import { Box } from "@mantine/core";
import { useDndFunnelContext } from "../contexts/DndBoardContext";
type Props = {
children: ReactNode;
};
const boardStyles = classNames({
display: "flex",
justifyContent: "center",
gap: "space.200",
flexDirection: "row",
flex: 1,
border: "1px white solid",
});
const DndBoard = memo(
forwardRef<HTMLDivElement, Props>(({ children }: Props, ref) => {
const { instanceId } = useDndFunnelContext();
useEffect(() => {
return autoScrollWindowForElements({
canScroll: ({ source }) =>
source.data.instanceId === instanceId,
});
}, [instanceId]);
return (
<Box
className={boardStyles}
flex={1}
ref={ref}>
{children}
</Box>
);
})
);
export default DndBoard;

View File

@ -0,0 +1,146 @@
import React, { memo, ReactNode, useEffect, useRef, useState } from "react";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { preserveOffsetOnSource } from "@atlaskit/pragmatic-drag-and-drop/element/preserve-offset-on-source";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { dropTargetForExternal } from "@atlaskit/pragmatic-drag-and-drop/external/adapter";
import { Box } from "@atlaskit/primitives";
import ReactDOM from "react-dom";
import invariant from "tiny-invariant";
import CardWrapper from "@/components/dnd-pragmatic/DndFunnel/components/CardWrapper";
import { useDndFunnelContext } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import { BaseItemType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { CardState } from "../types/DndStates";
const idleState: CardState = { type: "idle" };
const draggingState: CardState = { type: "dragging" };
type DndCardProps<ItemType extends BaseItemType> = {
item: ItemType;
renderItem: (item: any) => ReactNode;
};
export const DndCard = memo(
<ItemType extends BaseItemType>({
item,
renderItem,
}: DndCardProps<ItemType>) => {
const ref = useRef<HTMLDivElement | null>(null);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const [state, setState] = useState<CardState>(idleState);
const { instanceId } = useDndFunnelContext();
useEffect(() => {
const element = ref.current;
invariant(element);
return combine(
draggable({
element,
getInitialData: () => ({
type: "card",
itemId: item.id,
instanceId,
}),
onGenerateDragPreview: ({
location,
source,
nativeSetDragImage,
}) => {
const rect = source.element.getBoundingClientRect();
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: preserveOffsetOnSource({
element,
input: location.current.input,
}),
render({ container }) {
setState({ type: "preview", container, rect });
return () => setState(draggingState);
},
});
},
onDragStart: () => setState(draggingState),
onDrop: () => setState(idleState),
}),
dropTargetForExternal({
element,
}),
dropTargetForElements({
element,
canDrop: ({ source }) => {
return (
source.data.instanceId === instanceId &&
source.data.type === "card"
);
},
getIsSticky: () => true,
getData: ({ input, element }) => {
const data = { type: "card", itemId: item.id };
return attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
});
},
onDragEnter: args => {
if (args.source.data.itemId !== item.id) {
setClosestEdge(extractClosestEdge(args.self.data));
}
},
onDrag: args => {
if (args.source.data.itemId !== item.id) {
setClosestEdge(extractClosestEdge(args.self.data));
}
},
onDragLeave: () => {
setClosestEdge(null);
},
onDrop: () => {
setClosestEdge(null);
},
})
);
}, [instanceId, item.id]);
return (
<>
<CardWrapper
ref={ref}
item={item}
renderItem={renderItem}
state={state}
closestEdge={closestEdge}
/>
{state.type === "preview" &&
ReactDOM.createPortal(
<Box
style={{
width: state.rect.width,
height: state.rect.height,
}}>
<CardWrapper
item={item}
renderItem={renderItem}
state={state}
closestEdge={null}
/>
</Box>,
state.container
)}
</>
);
}
);

View File

@ -0,0 +1,306 @@
import React, { memo, ReactNode, useEffect, useRef, useState } from "react";
import { easeInOut } from "@atlaskit/motion/curves";
import { durations } from "@atlaskit/motion/durations";
import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import {
attachClosestEdge,
extractClosestEdge,
type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { centerUnderPointer } from "@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { Box, Flex, Inline, Stack, xcss } from "@atlaskit/primitives";
import { createPortal } from "react-dom";
import invariant from "tiny-invariant";
import { Stack as MantineStack, ScrollArea } from "@mantine/core";
import DndGroup from "@/components/dnd-pragmatic/DndFunnel/components/DndGroup";
import SafariDndColumnPreview from "@/components/dnd-pragmatic/DndFunnel/components/SafariDndColumnPreview";
import { useDndFunnelContext } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { ColumnState } from "@/components/dnd-pragmatic/DndFunnel/types/DndStates";
import { DndCard } from "./DndCard";
import styles from "../DndFunnel.module.css";
const columnStyles = xcss({
width: "250px",
borderRadius: "radius.xlarge",
transition: `background ${durations.medium}ms ${easeInOut}`,
position: "relative",
// Replace height: "100%" with these:
alignSelf: "stretch", // fill parent's height
minHeight: "0", // allow it to shrink inside parent flex
border: "1px solid red",
});
const stackStyles = xcss({
minHeight: "0",
border: "1px solid red",
flexGrow: 1,
});
const scrollContainerStyles = xcss({
flex: 1,
overflowY: "auto",
});
const cardListStyles = xcss({
boxSizing: "border-box",
minHeight: "100%",
padding: "space.100",
gap: "space.100",
});
const columnHeaderStyles = xcss({
paddingInlineStart: "space.200",
paddingInlineEnd: "space.200",
paddingBlockStart: "space.100",
color: "color.text.subtlest",
userSelect: "none",
});
// preventing re-renders with stable state objects
const idle: ColumnState = { type: "idle" };
const stateStyles: {
[key in ColumnState["type"]]: ReturnType<typeof xcss> | undefined;
} = {
"idle": xcss({
cursor: "grab",
}),
"is-column-over": undefined,
"generate-column-preview": xcss({
isolation: "isolate",
}),
"generate-safari-column-preview": undefined,
};
const isDraggingStyles = xcss({
opacity: 0.4,
});
type Props<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
column: ColumnType;
columnItemsAndGroups: (ItemType | GroupType)[];
renderColumnHeader: (column: any) => ReactNode;
renderItem: (item: any) => ReactNode;
renderGroup: (group: any) => ReactNode;
};
export const DndColumn = memo(
<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
column,
columnItemsAndGroups,
renderColumnHeader,
renderItem,
renderGroup,
}: Props<ColumnType, ItemType, GroupType>) => {
const columnId = column.id;
const columnRef = useRef<HTMLDivElement | null>(null);
const columnInnerRef = useRef<HTMLDivElement | null>(null);
const headerRef = useRef<HTMLDivElement | null>(null);
const scrollableRef = useRef<HTMLDivElement | null>(null);
const [state, setState] = useState<ColumnState>(idle);
const [isDragging, setIsDragging] = useState<boolean>(false);
const { instanceId } = useDndFunnelContext();
useEffect(() => {
invariant(columnRef.current);
invariant(columnInnerRef.current);
invariant(headerRef.current);
invariant(scrollableRef.current);
return combine(
draggable({
element: columnRef.current,
dragHandle: headerRef.current,
getInitialData: () => ({
columnId,
type: "column",
instanceId,
}),
onGenerateDragPreview: ({ nativeSetDragImage }) => {
const isSafari: boolean =
navigator.userAgent.includes("AppleWebKit") &&
!navigator.userAgent.includes("Chrome");
if (!isSafari) {
setState({ type: "generate-column-preview" });
return;
}
setCustomNativeDragPreview({
getOffset: centerUnderPointer,
render: ({ container }) => {
setState({
type: "generate-safari-column-preview",
container,
});
return () => setState(idle);
},
nativeSetDragImage,
});
},
onDragStart: () => {
setIsDragging(true);
},
onDrop() {
setState(idle);
setIsDragging(false);
},
}),
dropTargetForElements({
element: columnInnerRef.current,
getData: () => ({ columnId }),
canDrop: ({ source }) => {
return (
source.data.instanceId === instanceId &&
source.data.type === "card"
);
},
getIsSticky: () => true,
onDragLeave: () => setState(idle),
onDrop: () => setState(idle),
}),
dropTargetForElements({
element: columnRef.current,
canDrop: ({ source }) => {
return (
source.data.instanceId === instanceId &&
source.data.type === "column"
);
},
getIsSticky: () => true,
getData: ({ input, element }) => {
const data = {
columnId,
};
return attachClosestEdge(data, {
input,
element,
allowedEdges: ["left", "right"],
});
},
onDragEnter: args => {
setState({
type: "is-column-over",
closestEdge: extractClosestEdge(args.self.data),
});
},
onDrag: args => {
// skip react re-render if edge is not changing
setState(current => {
const closestEdge: Edge | null = extractClosestEdge(
args.self.data
);
if (
current.type === "is-column-over" &&
current.closestEdge === closestEdge
) {
return current;
}
return {
type: "is-column-over",
closestEdge,
};
});
},
onDragLeave: () => setState(idle),
onDrop: () => setState(idle),
}),
autoScrollForElements({
element: scrollableRef.current,
canScroll: ({ source }) =>
source.data.instanceId === instanceId &&
source.data.type === "card",
})
);
}, [columnId, instanceId]);
const renderItemsAndGroups = () =>
columnItemsAndGroups.map(groupOrItem =>
"items" in groupOrItem ? (
<DndGroup
renderGroup={renderGroup}
group={groupOrItem}
key={`${groupOrItem.id}group`}
/>
) : (
<DndCard
item={groupOrItem}
renderItem={renderItem}
key={groupOrItem.id}
/>
)
);
return (
<>
<Flex
ref={columnRef}
direction={"column"}
xcss={[columnStyles, stateStyles[state.type]]}>
<Stack
xcss={stackStyles}
ref={columnInnerRef}>
<Stack
xcss={[
stackStyles,
isDragging ? isDraggingStyles : undefined,
]}>
<MantineStack className={styles["visible-column"]}>
<ScrollArea scrollbars={"y"}>
<Inline
xcss={columnHeaderStyles}
ref={headerRef}
spread="space-between"
alignBlock="center">
{renderColumnHeader(column)}
</Inline>
<Box
xcss={scrollContainerStyles}
ref={scrollableRef}>
<Stack
xcss={cardListStyles}
space="space.100">
{renderItemsAndGroups()}
</Stack>
</Box>
</ScrollArea>
</MantineStack>
</Stack>
</Stack>
{state.type === "is-column-over" && state.closestEdge && (
<DropIndicator edge={state.closestEdge} />
)}
</Flex>
{state.type === "generate-safari-column-preview"
? createPortal(
<SafariDndColumnPreview
column={column}
renderColumnHeader={renderColumnHeader}
/>,
state.container
)
: null}
</>
);
}
);

View File

@ -0,0 +1,119 @@
import React, { memo, ReactNode, useEffect, useRef, useState } from "react";
import FocusRing from "@atlaskit/focus-ring";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { DropIndicator } from "@atlaskit/pragmatic-drag-and-drop-react-drop-indicator/box";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
draggable,
dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { centerUnderPointer } from "@atlaskit/pragmatic-drag-and-drop/element/center-under-pointer";
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import invariant from "tiny-invariant";
import { useDndFunnelContext } from "@/components/dnd-pragmatic/DndFunnel/contexts/DndBoardContext";
import {
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import { GroupState } from "@/components/dnd-pragmatic/DndFunnel/types/DndStates";
const idleState: GroupState = { type: "idle" };
const draggingState: GroupState = { type: "dragging" };
const isCardOver: GroupState = { type: "is-card-over" };
const isGroupOver: GroupState = { type: "is-group-over" };
type Props<
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
group: GroupType;
renderGroup: (group: any) => ReactNode;
};
const DndGroup = memo(
<ItemType extends BaseItemType, GroupType extends BaseGroupType<ItemType>>({
group,
renderGroup,
}: Props<ItemType, GroupType>) => {
const ref = useRef<HTMLDivElement | null>(null);
const { instanceId } = useDndFunnelContext();
const [state, setState] = useState<GroupState>(idleState);
useEffect(() => {
const element = ref.current;
invariant(element);
return combine(
draggable({
element,
getInitialData: () => ({
groupId: group.id,
type: "group",
instanceId,
}),
onGenerateDragPreview: ({ source, nativeSetDragImage }) => {
const rect = source.element.getBoundingClientRect();
setCustomNativeDragPreview({
nativeSetDragImage,
getOffset: centerUnderPointer,
render({ container }) {
setState({ type: "preview", container, rect });
return () => setState(draggingState);
},
});
},
onDragStart: () => setState(draggingState),
onDrop: () => setState(idleState),
}),
dropTargetForElements({
element,
getData: ({ input, element }) => {
const data = {
groupId,
};
return attachClosestEdge(data, {
input,
element,
allowedEdges: ["top", "bottom"],
});
},
canDrop: ({ source }) =>
source.data.instanceId === instanceId &&
source.data.type === "group" &&
source.data.id !== group.id,
onDragEnter: ({ element }) =>
setState(
element.type === "card" ? isCardOver : isGroupOver
),
onDragLeave: () => setState(idleState),
onDragStart: () => setState(isCardOver),
})
);
}, [group]);
return (
<div
style={{
border: state.type === "is-card-over" && "1px solid red",
}}>
<FocusRing isInset>
<div ref={ref}>
{renderGroup(group)}
{(state.type === "is-card-over" ||
state.type === "is-group-over") &&
state.closestEdge && (
<DropIndicator
edge={state.closestEdge}
gap={token("space.200", "0")}
/>
)}
</div>
</FocusRing>
</div>
);
}
);
export default DndGroup;

View File

@ -0,0 +1,36 @@
import React, { ReactNode } from "react";
import { Box, xcss } from "@atlaskit/primitives";
import { BaseColumnType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
const safariPreviewStyles = xcss({
width: "250px",
backgroundColor: "elevation.surface.sunken",
borderRadius: "radius.small",
padding: "space.200",
});
const columnHeaderStyles = xcss({
paddingInlineStart: "space.200",
paddingInlineEnd: "space.200",
paddingBlockStart: "space.100",
color: "color.text.subtlest",
userSelect: "none",
});
type Props<ColumnType extends BaseColumnType> = {
column: ColumnType;
renderColumnHeader: (column: ColumnType) => ReactNode;
};
const SafariDndColumnPreview = <ColumnType extends BaseColumnType>({
column,
renderColumnHeader,
}: Props<ColumnType>) => {
return (
<Box xcss={[columnHeaderStyles, safariPreviewStyles]}>
{renderColumnHeader(column)}
</Box>
);
};
export default SafariDndColumnPreview;

View File

@ -0,0 +1,206 @@
import { useEffect, useMemo, useRef, useState } from "react";
import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { SwiperRef } from "swiper/swiper-react";
import useResolveDrop from "@/components/dnd-pragmatic/DndFunnel/hooks/useResolveDrop";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
import FunnelDndProps from "@/components/dnd-pragmatic/DndFunnel/types/FunnelDndProps";
import makeContext from "@/lib/contextFactory/contextFactory";
import { sortByLexorank } from "@/utils/lexorank/sort";
export type DndFunnelContextState<
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
instanceId: symbol;
mixedItemsAndGroups: Array<GroupType | ItemType>;
};
const useFunnelContextState = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
columns,
updateColumn,
items,
updateItem,
getColumnItemsGroups,
groups,
}: FunnelDndProps<ColumnType, ItemType, GroupType>): DndFunnelContextState<
ItemType,
GroupType
> => {
const swiperRef = useRef<SwiperRef>(null);
const sortedColumns = useMemo(() => sortByLexorank(columns), [columns]);
// const getColumnByItemId = (itemId: number) => {
// const item = items.find(item => item.id === itemId);
// if (!item) return;
// return columnes.find(column => column.id === item.column.id);
// };
//
// const swipeSliderDuringDrag = () => {
// const activeColumn = getColumnByItemId(activeId);
// const swiperActiveColumn =
// columnes[swiperRef.current?.swiper.activeIndex ?? 0];
// if (swiperActiveColumn.id !== activeColumn?.id) return;
//
// const activeColumnLexorank = activeColumn?.lexorank;
// let overColumnLexorank: string | undefined;
//
// if (typeof over.id === "string" && isColumnId(over.id)) {
// const overColumnId = getColumnId(over.id);
// overColumnLexorank = columnes.find(
// s => s.id === overColumnId
// )?.lexorank;
// } else {
// overColumnLexorank = getColumnByItemId(Number(over.id))?.lexorank;
// }
//
// if (
// !activeColumnLexorank ||
// !overColumnLexorank ||
// !swiperRef.current?.swiper
// )
// return;
//
// const activeIndex = sortedColumns.findIndex(
// s => s.lexorank === activeColumnLexorank
// );
// const overIndex = sortedColumns.findIndex(
// s => s.lexorank === overColumnLexorank
// );
//
// if (activeIndex > overIndex) {
// swiperRef.current.swiper.slidePrev();
// return;
// }
// if (activeIndex < overIndex) {
// swiperRef.current.swiper.slideNext();
// }
// };
// const lastOperation = useRef<Operation | null>(null);
//
// useEffect(() => {
// if (lastOperation.current === null) return;
// const { outcome, trigger } = lastOperation.current;
//
// if (outcome.type === "column-reorder") {
// const { startIndex, finishIndex } = outcome;
//
// const sourceColumn = sortedColumns[finishIndex];
//
// const entry = registry.getColumn(sourceColumn.id);
// triggerPostMoveFlash(entry.element);
//
// console.log(
// `You've moved ${sourceColumn.name} from position ${
// startIndex + 1
// } to position ${finishIndex + 1} of ${sortedColumns.length}.`
// );
//
// return;
// }
//
// if (outcome.type === "card-reorder") {
// const { columnId, startIndex, finishIndex } = outcome;
//
// const column = sortedColumns.find(s => s.id === columnId);
// if (!column) return;
// const columnItems = items.filter(d => d.column.id === columnId);
// const item = columnItems[finishIndex];
//
// const entry = registry.getCard(item.id);
// triggerPostMoveFlash(entry.element);
//
// if (trigger !== "keyboard") return;
//
// console.log(
// `You've moved ${item.name} from position ${
// startIndex + 1
// } to position ${finishIndex + 1} of ${columnItems.length} in the ${column.name} column.`
// );
//
// return;
// }
//
// if (outcome.type === "card-move") {
// const {
// finishColumnId,
// itemIndexInStartColumn,
// itemIndexInFinishColumn,
// } = outcome;
//
// const destColumn = sortedColumns.find(
// s => s.id === finishColumnId
// );
// if (!destColumn) return;
// const columnItems = items.filter(
// d => d.column.id === destColumn.id
// );
//
// const item = columnItems[itemIndexInFinishColumn];
//
// const finishPosition =
// typeof itemIndexInFinishColumn === "number"
// ? itemIndexInFinishColumn + 1
// : columnItems.length;
//
// const entry = registry.getCard(item.id);
// triggerPostMoveFlash(entry.element);
//
// if (trigger !== "keyboard") return;
//
// console.log(
// `You've moved ${item.name} from position ${
// itemIndexInStartColumn + 1
// } to position ${finishPosition} in the ${destColumn.name} column.`
// );
// }
// }, [lastOperation, registry]);
useEffect(() => {
return liveRegion.cleanup();
}, []);
const [instanceId] = useState(() => Symbol("instance-id"));
const { onDrop } = useResolveDrop<ColumnType, ItemType, GroupType>({
sortedColumns,
updateColumn,
updateItem,
getColumnItemsGroups,
});
const mixedItemsAndGroups: Array<ItemType | GroupType> = sortByLexorank([
...items,
...groups,
]);
useEffect(() => {
return combine(
monitorForElements({
canMonitor: ({ source }) =>
source.data.instanceId === instanceId,
onDrop,
})
);
}, [items, sortedColumns, instanceId]);
return {
instanceId,
mixedItemsAndGroups,
};
};
export const [DndFunnelContextProvider, useDndFunnelContext] = makeContext<
DndFunnelContextState<any, any>,
FunnelDndProps<any, any, any>
>(useFunnelContextState, "DndFunnel");

View File

@ -0,0 +1,93 @@
import useGetNewRankForFunnel from "@/components/dnd-pragmatic/DndFunnel/hooks/useGetNewRankForFunnel";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
type Props<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
sortedColumns: ColumnType[];
updateColumn: (id: number, lexorank: string) => void;
updateItem: (id: number, lexorank: string, columnId: number) => void;
getColumnItemsGroups: (columnId: number) => (ItemType | GroupType)[];
};
const useFunnelActions = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
sortedColumns,
updateColumn,
updateItem,
getColumnItemsGroups,
}: Props<ColumnType, ItemType, GroupType>) => {
const {
getNewRankForSameColumn,
getNewRankForAnotherColumn,
getNewColumnRank,
} = useGetNewRankForFunnel<ColumnType, ItemType, GroupType>();
const reorderItem = (
columnId: number,
startIndex: number,
finishIndex: number,
columnItems: ItemType[]
) => {
const startItemId = columnItems[startIndex].id;
const newLexorank = getNewRankForSameColumn(
columnItems,
finishIndex,
startItemId
);
updateItem(startItemId, newLexorank, columnId);
};
const moveItem = (
startColumnId: number,
itemIndexInStartColumn: number,
finishColumnId: number,
finishItemIndex?: number
) => {
const startColumnItems = getColumnItemsGroups(startColumnId);
const startItemId = startColumnItems[itemIndexInStartColumn].id;
const finishColumnItems = getColumnItemsGroups(finishColumnId);
const newLexorank = getNewRankForAnotherColumn(
finishColumnItems,
finishItemIndex
);
updateItem(startItemId, newLexorank, finishColumnId);
};
const reorderColumn = (startIndex: number, finishIndex: number) => {
const startColumnId = sortedColumns[startIndex].id;
const finishColumnId = sortedColumns[finishIndex].id;
if (startColumnId === finishColumnId) return;
const newRank = getNewColumnRank(
sortedColumns,
startColumnId,
finishColumnId
);
if (!newRank) return;
updateColumn(startColumnId, newRank);
};
return {
moveItem,
reorderColumn,
reorderItem,
};
};
export default useFunnelActions;

View File

@ -0,0 +1,118 @@
import { LexoRank } from "lexorank";
import { BaseColumnType, BaseItemType } from "@/components/dnd-pragmatic/types/base";
import { getNewLexorank } from "@/utils/lexorank/generation";
import { sortByLexorank } from "@/utils/lexorank/sort";
import { BaseGroupType } from "@/components/dnd-pragmatic/DndFunnel/types/Base";
interface NewRankGetters<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> {
getNewRankForSameColumn: (
columnItems: ItemType[],
overItemIndex: number,
activeItemId: number
) => string;
getNewRankForAnotherColumn: (
columnItems: ItemType[],
overItemIndex?: number
) => string;
getNewColumnRank: (
columns: ColumnType[],
activeColumnId: number,
overColumnId: number
) => string | null;
}
const useGetNewRankForFunnel = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>(): NewRankGetters<ColumnType, ItemType, GroupType> => {
const getNewRankForSameColumn = (
columnItems: ItemType[],
overItemIndex: number,
activeItemId: number
): string => {
const activeItemIndex = columnItems.findIndex(
item => item.id === activeItemId
);
const [leftIndex, rightIndex] =
overItemIndex < activeItemIndex
? [overItemIndex - 1, overItemIndex]
: [overItemIndex, overItemIndex + 1];
const leftLexorank =
leftIndex >= 0
? LexoRank.parse(columnItems[leftIndex].lexorank)
: null;
const rightLexorank =
rightIndex < columnItems.length
? LexoRank.parse(columnItems[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewRankForAnotherColumn = (
columnItems: ItemType[],
overItemIndex?: number
): string => {
if (columnItems.length === 0) return LexoRank.middle().toString();
if (!overItemIndex || overItemIndex >= columnItems.length) {
return getNewLexorank(
LexoRank.parse(columnItems[columnItems.length - 1].lexorank)
).toString();
}
const leftLexorank =
overItemIndex > 0
? LexoRank.parse(columnItems[overItemIndex - 1].lexorank)
: null;
const rightLexorank = LexoRank.parse(
columnItems[overItemIndex].lexorank
);
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
const getNewColumnRank = (
columns: ColumnType[],
activeColumnId: number,
overColumnId: number
): string | null => {
const sortedColumnsList = sortByLexorank(columns);
const overIndex = sortedColumnsList.findIndex(
s => s.id === overColumnId
);
const activeIndex = sortedColumnsList.findIndex(
s => s.id === activeColumnId
);
if (overIndex === -1 || activeIndex === -1) return null;
const [leftIndex, rightIndex] =
overIndex < activeIndex
? [overIndex - 1, overIndex]
: [overIndex, overIndex + 1];
const leftLexorank =
leftIndex >= 0 ? LexoRank.parse(columns[leftIndex].lexorank) : null;
const rightLexorank =
rightIndex < columns.length
? LexoRank.parse(columns[rightIndex].lexorank)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};
return {
getNewRankForSameColumn,
getNewRankForAnotherColumn,
getNewColumnRank,
};
};
export default useGetNewRankForFunnel;

View File

@ -0,0 +1,191 @@
import { extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/types";
import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index";
import {
BaseEventPayload,
ElementDragType,
} from "@atlaskit/pragmatic-drag-and-drop/types";
import useFunnelActions from "@/components/dnd-pragmatic/DndFunnel/hooks/useFunnelActions";
import {
BaseColumnType,
BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
type Props<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
sortedColumns: ColumnType[];
getColumnItemsGroups: (columnId: number) => (ItemType | GroupType)[];
updateColumn: (id: number, lexorank: string) => void;
updateItem: (id: number, lexorank: string, columnId: number) => void;
};
const useResolveDrop = <
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
>({
sortedColumns,
getColumnItemsGroups,
...props
}: Props<ColumnType, ItemType, GroupType>) => {
const { moveItem, reorderColumn, reorderItem } = useFunnelActions<
ColumnType,
ItemType,
GroupType
>({
sortedColumns,
getColumnItemsGroups,
...props,
});
const onItemDrop = ({
location,
source,
}: BaseEventPayload<ElementDragType>) => {
const itemId: number = source.data.itemId as number;
const [, startColumnRecord] = location.initial.dropTargets;
const sourceColumnId: number = startColumnRecord.data
.columnId as number;
const sourceColumn = sortedColumns.find(s => s.id === sourceColumnId);
if (!sourceColumn) return;
const sourceColumnItems = getColumnItemsGroups(sourceColumnId);
const startItemIndex = sourceColumnItems.findIndex(
d => d.id === itemId
);
if (startItemIndex === -1) return;
if (location.current.dropTargets.length === 1) {
const [destinationColumnRecord] = location.current.dropTargets;
const destinationId: number = destinationColumnRecord.data
.columnId as number;
const destinationColumn = sortedColumns.find(
s => s.id === destinationId
);
if (!destinationColumn) return;
// reordering in same column
if (sourceColumn === destinationColumn) {
const destinationIndex = getReorderDestinationIndex({
startIndex: startItemIndex,
indexOfTarget: sourceColumnItems.length - 1,
closestEdgeOfTarget: null,
axis: "vertical",
});
reorderItem(
sourceColumn.id,
startItemIndex,
destinationIndex,
sourceColumnItems
);
return;
}
// moving to a new column
moveItem(sourceColumn.id, startItemIndex, destinationId);
return;
}
// dropping in a column (relative to a card)
if (location.current.dropTargets.length === 2) {
const [destinationCardRecord, destinationColumnRecord] =
location.current.dropTargets;
const destinationColumnId: number = destinationColumnRecord.data
.columnId as number;
const destinationColumn = sortedColumns.find(
s => s.id === destinationColumnId
);
if (!destinationColumn) return;
const destColumnItems = getColumnItemsGroups(destinationColumnId);
const indexOfTarget = destColumnItems.findIndex(
item =>
item.id === destinationCardRecord.data.itemId &&
!("items" in item)
);
const closestEdgeOfTarget: Edge | null = extractClosestEdge(
destinationCardRecord.data
);
// case 1: ordering in the same column
if (sourceColumn === destinationColumn) {
const destinationIndex = getReorderDestinationIndex({
startIndex: startItemIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: "vertical",
});
reorderItem(
sourceColumn.id,
startItemIndex,
destinationIndex,
destColumnItems
);
return;
}
// case 2: moving into a new column relative to a card
const destinationIndex =
closestEdgeOfTarget === "bottom"
? indexOfTarget + 1
: indexOfTarget;
moveItem(
sourceColumn.id,
startItemIndex,
destinationColumn.id,
destinationIndex
);
}
};
const onColumnDrop = ({
location,
source,
}: BaseEventPayload<ElementDragType>) => {
const startIndex: number = sortedColumns.findIndex(
column => column.id === source.data.columnId
);
const target = location.current.dropTargets[0];
const indexOfTarget: number = sortedColumns.findIndex(
column => column.id === target.data.columnId
);
const closestEdgeOfTarget: Edge | null = extractClosestEdge(
target.data
);
const finishIndex = getReorderDestinationIndex({
startIndex,
indexOfTarget,
closestEdgeOfTarget,
axis: "horizontal",
});
reorderColumn(startIndex, finishIndex);
};
const onDrop = ({
location,
source,
}: BaseEventPayload<ElementDragType>) => {
if (!location.current.dropTargets.length) return;
if (source.data.type === "column") {
onColumnDrop({ location, source });
return;
}
if (source.data.type === "card") {
onItemDrop({ location, source });
}
};
return {
onDrop,
};
};
export default useResolveDrop;

View File

@ -0,0 +1,12 @@
type BaseFunnelType = {
id: number;
lexorank: string;
};
export type BaseColumnType = BaseFunnelType;
export type BaseItemType = BaseFunnelType;
export type BaseGroupType<ItemType extends BaseItemType> = BaseFunnelType & {
items: ItemType[];
};

View File

@ -0,0 +1,19 @@
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
export type CardState =
| { type: "idle" }
| { type: "preview"; container: HTMLElement; rect: DOMRect }
| { type: "dragging" };
export type GroupState =
| { type: "idle" }
| { type: "is-card-over"; closestEdge: Edge | null }
| { type: "is-group-over"; closestEdge: Edge | null }
| { type: "preview"; container: HTMLElement; rect: DOMRect }
| { type: "dragging" };
export type ColumnState =
| { type: "idle" }
| { type: "is-column-over"; closestEdge: Edge | null }
| { type: "generate-safari-column-preview"; container: HTMLElement }
| { type: "generate-column-preview" };

View File

@ -0,0 +1,23 @@
import { ReactNode } from "react";
import {
BaseColumnType, BaseGroupType,
BaseItemType,
} from "@/components/dnd-pragmatic/DndFunnel/types/Base";
type FunnelDndProps<
ColumnType extends BaseColumnType,
ItemType extends BaseItemType,
GroupType extends BaseGroupType<ItemType>,
> = {
columns: ColumnType[];
updateColumn: (id: number, lexorank: string) => void;
items: ItemType[];
updateItem: (id: number, lexorank: string, columnId: number) => void;
getColumnItemsGroups: (columnId: number) => (ItemType | GroupType)[];
renderColumnHeader: (column: ColumnType) => ReactNode;
renderItem: (item: ItemType) => ReactNode;
groups: GroupType[];
renderGroup: (group: GroupType) => ReactNode;
};
export default FunnelDndProps;

View File

@ -0,0 +1,52 @@
import type { CleanupFn } from "@atlaskit/pragmatic-drag-and-drop/types";
import invariant from "tiny-invariant";
export type Entry = {
element: HTMLElement;
};
export type RegisterCardProps = {
cardId: number;
entry: Entry;
};
export type RegisterColumnProps = {
columnId: number;
entry: Entry;
};
// TODO delete
export function createRegistry() {
const cards = new Map<number, Entry>();
const columns = new Map<number, Entry>();
function registerCard({ cardId, entry }: RegisterCardProps): CleanupFn {
cards.set(cardId, entry);
return () => cards.delete(cardId);
}
function registerColumn({
columnId,
entry,
}: RegisterColumnProps): CleanupFn {
columns.set(columnId, entry);
return function cleanup() {
cards.delete(columnId);
};
}
function getCard(cardId: number): Entry {
const entry = cards.get(cardId);
invariant(entry);
return entry;
}
function getColumn(columnId: number): Entry {
const entry = columns.get(columnId);
invariant(entry);
return entry;
}
return { registerCard, registerColumn, getCard, getColumn };
}

View File

@ -0,0 +1,3 @@
import DragHandle from './DragHandle';
export default DragHandle;

View File

@ -1,27 +1,16 @@
"use client"; "use client";
import React, { ReactNode, RefObject, useMemo } from "react"; import React, { ReactNode, RefObject } from "react";
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
} from "@dnd-kit/core";
import {
horizontalListSortingStrategy,
SortableContext,
} from "@dnd-kit/sortable";
import { FreeMode, Pagination, Scrollbar } from "swiper/modules"; import { FreeMode, Pagination, Scrollbar } from "swiper/modules";
import { Swiper, SwiperRef, SwiperSlide } from "swiper/react"; import { Swiper, SwiperRef, SwiperSlide } from "swiper/react";
import { Box } from "@mantine/core"; import { Box } from "@mantine/core";
import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton"; import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton";
import useDndSensors from "@/app/deals/hooks/useSensors";
import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn"; import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn";
import FunnelOverlay from "@/components/dnd/FunnelDnd/FunnelOverlay";
import { BaseDraggable } from "@/components/dnd/types/types"; import { BaseDraggable } from "@/components/dnd/types/types";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import SortableItem from "../SortableItem"; import SortableItem from "../SortableItem";
import classes from "./FunnelDnd.module.css"; import classes from "./FunnelDnd.module.css";
import { DragEndEvent, DragOverEvent, DragStartEvent } from "@dnd-kit/core";
type Props<TContainer, TItem> = { type Props<TContainer, TItem> = {
containers: TContainer[]; containers: TContainer[];
@ -73,9 +62,7 @@ const FunnelDnd = <
isCreatingContainerEnabled = true, isCreatingContainerEnabled = true,
disabledColumns = false, disabledColumns = false,
}: Props<TContainer, TItem>) => { }: Props<TContainer, TItem>) => {
const sensors = useDndSensors();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const frequency = useMemo(() => (isMobile ? 1 : undefined), [isMobile]);
const renderContainers = () => const renderContainers = () =>
containers.map((container, index) => { containers.map((container, index) => {
@ -107,92 +94,52 @@ const FunnelDnd = <
); );
}); });
const renderBody = () => { if (isMobile) {
if (isMobile) {
return (
<Box>
<Swiper
ref={swiperRef}
onTouchStart={swiper => {
swiper.allowTouchMove = !activeItem;
}}
onTouchMove={swiper => {
swiper.allowTouchMove = !activeItem;
}}
className={classes["swiper-container"]}
spaceBetween={15}
style={{ paddingInline: "10vw" }}
modules={[Pagination]}
freeMode={{ enabled: false }}
pagination={{ enabled: true, clickable: true }}>
{renderContainers()}
{isCreatingContainerEnabled && (
<SwiperSlide style={{ width: 250 }}>
<CreateStatusButton />
</SwiperSlide>
)}
</Swiper>
</Box>
);
}
return ( return (
<Swiper <Box>
ref={swiperRef} <Swiper
className={classes["swiper-container"]} ref={swiperRef}
modules={[Scrollbar, FreeMode]} onTouchStart={swiper => {
spaceBetween={15} swiper.allowTouchMove = !activeItem;
slidesPerView={"auto"} }}
scrollbar={{ hide: false }} onTouchMove={swiper => {
freeMode={{ enabled: true }} swiper.allowTouchMove = !activeItem;
touchStartPreventDefault={false} }}
grabCursor> className={classes["swiper-container"]}
{renderContainers()} spaceBetween={15}
{isCreatingContainerEnabled && ( style={{ paddingInline: "10vw" }}
<SwiperSlide style={{ width: 50 }}> modules={[Pagination]}
<CreateStatusButton /> freeMode={{ enabled: false }}
</SwiperSlide> pagination={{ enabled: true, clickable: true }}>
)} {renderContainers()}
</Swiper> {isCreatingContainerEnabled && (
<SwiperSlide style={{ width: 250 }}>
<CreateStatusButton />
</SwiperSlide>
)}
</Swiper>
</Box>
); );
}; }
return ( return (
<DndContext <Swiper
sensors={sensors} ref={swiperRef}
measuring={{ className={classes["swiper-container"]}
droppable: { modules={[Scrollbar, FreeMode]}
frequency, spaceBetween={15}
}, slidesPerView={"auto"}
}} scrollbar={{ hide: false }}
onDragStart={onDragStart} freeMode={{ enabled: true }}
onDragOver={onDragOver} touchStartPreventDefault={false}
onDragEnd={onDragEnd}> grabCursor>
<SortableContext {renderContainers()}
items={containers.map(getContainerId)} {isCreatingContainerEnabled && (
strategy={horizontalListSortingStrategy}> <SwiperSlide style={{ width: 50 }}>
{renderBody()} <CreateStatusButton />
<FunnelOverlay </SwiperSlide>
activeContainer={activeContainer} )}
activeItem={activeItem} </Swiper>
renderContainer={container => {
const containerItems = getItemsByContainer(
container,
items
);
const containerId = getContainerId(container);
return renderContainerOverlay(
container,
<FunnelColumn
id={containerId}
items={containerItems}
renderItem={renderItem}
/>
);
}}
renderItem={renderItemOverlay}
/>
</SortableContext>
</DndContext>
); );
}; };

View File

@ -0,0 +1,77 @@
import React, { CSSProperties, ReactNode, useCallback, useMemo } from "react";
import { useDndContext } from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import DragHandle from "@/components/dnd/DragHandle";
// import collisionsShouldBeCombined from "@/components/dnd/utils/collisionsShouldBeCombined";
type Props = {
id: number | string;
renderItem: (renderDraggable?: () => ReactNode) => ReactNode;
renderDraggable?: () => ReactNode; // if not passed - the whole item renders as draggable
disabled?: boolean;
dragHandleStyle?: CSSProperties;
};
const SortableCombinableItem = ({
renderItem,
dragHandleStyle,
renderDraggable,
id,
disabled = false,
}: Props) => {
const { isDragging, setNodeRef, transform, transition, over, active } =
useSortable({
id,
animateLayoutChanges: () => false,
});
const { collisions } = useDndContext();
// const isCurrentCombining = useMemo(
// () => collisionsShouldBeCombined({ collisions }) && over?.id === id,
// [over, collisions]
// );
const getBorder = useCallback(() => {
// if (isCurrentCombining) return "1px solid red";
return "";
}, [active, over, id]);
const style = {
// transform: collisionsShouldBeCombined({ collisions })
// ? "none"
// : CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.4 : 1,
border: getBorder(),
};
const renderDragHandle = () => (
<DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderDraggable && renderDraggable()}
</DragHandle>
);
return (
<div
ref={setNodeRef}
style={style}>
{renderDraggable ? (
renderItem(renderDragHandle)
) : (
<DragHandle
id={id}
style={dragHandleStyle}
disabled={disabled}>
{renderItem()}
</DragHandle>
)}
</div>
);
};
export default SortableCombinableItem;

View File

@ -0,0 +1,3 @@
import SortableCombinableItem from "./SortableCombinableItem";
export default SortableCombinableItem;

View File

@ -1,7 +1,6 @@
import React, { CSSProperties, ReactNode } from "react"; import React, { CSSProperties, ReactNode } from "react";
import { useSortable } from "@dnd-kit/sortable"; import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import DragHandle from "@/components/dnd/DragHandle";
import DragHandle from "./DragHandle";
type Props = { type Props = {
id: number | string; id: number | string;
@ -23,12 +22,6 @@ const SortableItem = ({
animateLayoutChanges: () => false, animateLayoutChanges: () => false,
}); });
const style: CSSProperties = {
opacity: isDragging ? 0.4 : undefined,
transform: CSS.Translate.toString(transform),
transition,
};
const renderDragHandle = () => ( const renderDragHandle = () => (
<DragHandle <DragHandle
id={id} id={id}
@ -39,9 +32,7 @@ const SortableItem = ({
); );
return ( return (
<div <div ref={setNodeRef}>
ref={setNodeRef}
style={style}>
{renderDraggable ? ( {renderDraggable ? (
renderItem(renderDragHandle) renderItem(renderDragHandle)
) : ( ) : (

View File

@ -0,0 +1,61 @@
import { useMutation } from "@tanstack/react-query";
import { UpdateDealGroupSchema } from "@/lib/client";
import {
addDealMutation,
createDealGroupMutation,
removeDealMutation,
updateDealGroupMutation,
} from "@/lib/client/@tanstack/react-query.gen";
const useDealGroupCrud = () => {
const updateMutation = useMutation(updateDealGroupMutation());
const onUpdate = (entity: UpdateDealGroupSchema) => {
updateMutation.mutate({
body: {
entity,
},
});
};
const createMutation = useMutation(createDealGroupMutation());
const onCreate = (draggingDealId: number, hoveredDealId: number) => {
createMutation.mutate({
body: {
draggingDealId,
hoveredDealId,
},
});
};
const addDealToGroupMutation = useMutation(addDealMutation());
const onAddDeal = (dealId: number, groupId: number) => {
addDealToGroupMutation.mutate({
body: {
dealId,
groupId,
},
});
};
const removeDealFromGroupMutation = useMutation(removeDealMutation());
const onRemoveDeal = (dealId: number) => {
removeDealFromGroupMutation.mutate({
body: {
dealId,
},
});
};
return {
onUpdate,
onCreate,
onAddDeal,
onRemoveDeal,
};
};
export default useDealGroupCrud;

View File

@ -9,12 +9,14 @@ import {
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { client as _heyApiClient } from "../client.gen"; import { client as _heyApiClient } from "../client.gen";
import { import {
addDeal,
addKitToDeal, addKitToDeal,
addKitToDealProduct, addKitToDealProduct,
createBarcodeTemplate, createBarcodeTemplate,
createBoard, createBoard,
createClient, createClient,
createDeal, createDeal,
createDealGroup,
createDealProduct, createDealProduct,
createDealProductService, createDealProductService,
createDealService, createDealService,
@ -59,10 +61,12 @@ import {
getServicesKits, getServicesKits,
getStatuses, getStatuses,
getStatusHistory, getStatusHistory,
removeDeal,
updateBarcodeTemplate, updateBarcodeTemplate,
updateBoard, updateBoard,
updateClient, updateClient,
updateDeal, updateDeal,
updateDealGroup,
updateDealProduct, updateDealProduct,
updateDealProductService, updateDealProductService,
updateDealService, updateDealService,
@ -76,6 +80,9 @@ import {
type Options, type Options,
} from "../sdk.gen"; } from "../sdk.gen";
import type { import type {
AddDealData,
AddDealError,
AddDealResponse,
AddKitToDealData, AddKitToDealData,
AddKitToDealError, AddKitToDealError,
AddKitToDealProductData, AddKitToDealProductData,
@ -93,6 +100,9 @@ import type {
CreateClientResponse2, CreateClientResponse2,
CreateDealData, CreateDealData,
CreateDealError, CreateDealError,
CreateDealGroupData,
CreateDealGroupError,
CreateDealGroupResponse2,
CreateDealProductData, CreateDealProductData,
CreateDealProductError, CreateDealProductError,
CreateDealProductResponse2, CreateDealProductResponse2,
@ -194,6 +204,9 @@ import type {
GetServicesKitsData, GetServicesKitsData,
GetStatusesData, GetStatusesData,
GetStatusHistoryData, GetStatusHistoryData,
RemoveDealData,
RemoveDealError,
RemoveDealResponse,
UpdateBarcodeTemplateData, UpdateBarcodeTemplateData,
UpdateBarcodeTemplateError, UpdateBarcodeTemplateError,
UpdateBarcodeTemplateResponse2, UpdateBarcodeTemplateResponse2,
@ -205,6 +218,9 @@ import type {
UpdateClientResponse2, UpdateClientResponse2,
UpdateDealData, UpdateDealData,
UpdateDealError, UpdateDealError,
UpdateDealGroupData,
UpdateDealGroupError,
UpdateDealGroupResponse2,
UpdateDealProductData, UpdateDealProductData,
UpdateDealProductError, UpdateDealProductError,
UpdateDealProductResponse2, UpdateDealProductResponse2,
@ -605,6 +621,159 @@ export const updateDealMutation = (
return mutationOptions; return mutationOptions;
}; };
/**
* Update Group
*/
export const updateDealGroupMutation = (
options?: Partial<Options<UpdateDealGroupData>>
): UseMutationOptions<
UpdateDealGroupResponse2,
AxiosError<UpdateDealGroupError>,
Options<UpdateDealGroupData>
> => {
const mutationOptions: UseMutationOptions<
UpdateDealGroupResponse2,
AxiosError<UpdateDealGroupError>,
Options<UpdateDealGroupData>
> = {
mutationFn: async localOptions => {
const { data } = await updateDealGroup({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const createDealGroupQueryKey = (
options: Options<CreateDealGroupData>
) => createQueryKey("createDealGroup", options);
/**
* Create Group
*/
export const createDealGroupOptions = (
options: Options<CreateDealGroupData>
) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await createDealGroup({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: createDealGroupQueryKey(options),
});
};
/**
* Create Group
*/
export const createDealGroupMutation = (
options?: Partial<Options<CreateDealGroupData>>
): UseMutationOptions<
CreateDealGroupResponse2,
AxiosError<CreateDealGroupError>,
Options<CreateDealGroupData>
> => {
const mutationOptions: UseMutationOptions<
CreateDealGroupResponse2,
AxiosError<CreateDealGroupError>,
Options<CreateDealGroupData>
> = {
mutationFn: async localOptions => {
const { data } = await createDealGroup({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
/**
* Remove Deal
*/
export const removeDealMutation = (
options?: Partial<Options<RemoveDealData>>
): UseMutationOptions<
RemoveDealResponse,
AxiosError<RemoveDealError>,
Options<RemoveDealData>
> => {
const mutationOptions: UseMutationOptions<
RemoveDealResponse,
AxiosError<RemoveDealError>,
Options<RemoveDealData>
> = {
mutationFn: async localOptions => {
const { data } = await removeDeal({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const addDealQueryKey = (options: Options<AddDealData>) =>
createQueryKey("addDeal", options);
/**
* Add Deal
*/
export const addDealOptions = (options: Options<AddDealData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await addDeal({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: addDealQueryKey(options),
});
};
/**
* Add Deal
*/
export const addDealMutation = (
options?: Partial<Options<AddDealData>>
): UseMutationOptions<
AddDealResponse,
AxiosError<AddDealError>,
Options<AddDealData>
> => {
const mutationOptions: UseMutationOptions<
AddDealResponse,
AxiosError<AddDealError>,
Options<AddDealData>
> = {
mutationFn: async localOptions => {
const { data } = await addDeal({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const getBuiltInModulesQueryKey = ( export const getBuiltInModulesQueryKey = (
options?: Options<GetBuiltInModulesData> options?: Options<GetBuiltInModulesData>
) => createQueryKey("getBuiltInModules", options); ) => createQueryKey("getBuiltInModules", options);

View File

@ -3,6 +3,9 @@
import type { Client, Options as ClientOptions, TDataShape } from "./client"; import type { Client, Options as ClientOptions, TDataShape } from "./client";
import { client as _heyApiClient } from "./client.gen"; import { client as _heyApiClient } from "./client.gen";
import type { import type {
AddDealData,
AddDealErrors,
AddDealResponses,
AddKitToDealData, AddKitToDealData,
AddKitToDealErrors, AddKitToDealErrors,
AddKitToDealProductData, AddKitToDealProductData,
@ -20,6 +23,9 @@ import type {
CreateClientResponses, CreateClientResponses,
CreateDealData, CreateDealData,
CreateDealErrors, CreateDealErrors,
CreateDealGroupData,
CreateDealGroupErrors,
CreateDealGroupResponses,
CreateDealProductData, CreateDealProductData,
CreateDealProductErrors, CreateDealProductErrors,
CreateDealProductResponses, CreateDealProductResponses,
@ -144,6 +150,9 @@ import type {
GetStatusHistoryData, GetStatusHistoryData,
GetStatusHistoryErrors, GetStatusHistoryErrors,
GetStatusHistoryResponses, GetStatusHistoryResponses,
RemoveDealData,
RemoveDealErrors,
RemoveDealResponses,
UpdateBarcodeTemplateData, UpdateBarcodeTemplateData,
UpdateBarcodeTemplateErrors, UpdateBarcodeTemplateErrors,
UpdateBarcodeTemplateResponses, UpdateBarcodeTemplateResponses,
@ -155,6 +164,9 @@ import type {
UpdateClientResponses, UpdateClientResponses,
UpdateDealData, UpdateDealData,
UpdateDealErrors, UpdateDealErrors,
UpdateDealGroupData,
UpdateDealGroupErrors,
UpdateDealGroupResponses,
UpdateDealProductData, UpdateDealProductData,
UpdateDealProductErrors, UpdateDealProductErrors,
UpdateDealProductResponses, UpdateDealProductResponses,
@ -188,6 +200,8 @@ import type {
UpdateStatusResponses, UpdateStatusResponses,
} from "./types.gen"; } from "./types.gen";
import { import {
zAddDealData,
zAddDealResponse,
zAddKitToDealData, zAddKitToDealData,
zAddKitToDealProductData, zAddKitToDealProductData,
zAddKitToDealProductResponse, zAddKitToDealProductResponse,
@ -199,6 +213,8 @@ import {
zCreateClientData, zCreateClientData,
zCreateClientResponse2, zCreateClientResponse2,
zCreateDealData, zCreateDealData,
zCreateDealGroupData,
zCreateDealGroupResponse2,
zCreateDealProductData, zCreateDealProductData,
zCreateDealProductResponse2, zCreateDealProductResponse2,
zCreateDealProductServiceData, zCreateDealProductServiceData,
@ -288,6 +304,8 @@ import {
zGetStatusesResponse2, zGetStatusesResponse2,
zGetStatusHistoryData, zGetStatusHistoryData,
zGetStatusHistoryResponse2, zGetStatusHistoryResponse2,
zRemoveDealData,
zRemoveDealResponse,
zUpdateBarcodeTemplateData, zUpdateBarcodeTemplateData,
zUpdateBarcodeTemplateResponse2, zUpdateBarcodeTemplateResponse2,
zUpdateBoardData, zUpdateBoardData,
@ -295,6 +313,8 @@ import {
zUpdateClientData, zUpdateClientData,
zUpdateClientResponse2, zUpdateClientResponse2,
zUpdateDealData, zUpdateDealData,
zUpdateDealGroupData,
zUpdateDealGroupResponse2,
zUpdateDealProductData, zUpdateDealProductData,
zUpdateDealProductResponse2, zUpdateDealProductResponse2,
zUpdateDealProductServiceData, zUpdateDealProductServiceData,
@ -535,6 +555,114 @@ export const updateDeal = <ThrowOnError extends boolean = false>(
}); });
}; };
/**
* Update Group
*/
export const updateDealGroup = <ThrowOnError extends boolean = false>(
options: Options<UpdateDealGroupData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).patch<
UpdateDealGroupResponses,
UpdateDealGroupErrors,
ThrowOnError
>({
requestValidator: async data => {
return await zUpdateDealGroupData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zUpdateDealGroupResponse2.parseAsync(data);
},
url: "/deal-group/",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Create Group
*/
export const createDealGroup = <ThrowOnError extends boolean = false>(
options: Options<CreateDealGroupData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).post<
CreateDealGroupResponses,
CreateDealGroupErrors,
ThrowOnError
>({
requestValidator: async data => {
return await zCreateDealGroupData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zCreateDealGroupResponse2.parseAsync(data);
},
url: "/deal-group/",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Remove Deal
*/
export const removeDeal = <ThrowOnError extends boolean = false>(
options: Options<RemoveDealData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).delete<
RemoveDealResponses,
RemoveDealErrors,
ThrowOnError
>({
requestValidator: async data => {
return await zRemoveDealData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zRemoveDealResponse.parseAsync(data);
},
url: "/deal-group/deal",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/**
* Add Deal
*/
export const addDeal = <ThrowOnError extends boolean = false>(
options: Options<AddDealData, ThrowOnError>
) => {
return (options.client ?? _heyApiClient).post<
AddDealResponses,
AddDealErrors,
ThrowOnError
>({
requestValidator: async data => {
return await zAddDealData.parseAsync(data);
},
responseType: "json",
responseValidator: async data => {
return await zAddDealResponse.parseAsync(data);
},
url: "/deal-group/deal",
...options,
headers: {
"Content-Type": "application/json",
...options.headers,
},
});
};
/** /**
* Get Built In Modules * Get Built In Modules
*/ */

View File

@ -1,5 +1,29 @@
// This file is auto-generated by @hey-api/openapi-ts // This file is auto-generated by @hey-api/openapi-ts
/**
* AddDealToGroupRequest
*/
export type AddDealToGroupRequest = {
/**
* Dealid
*/
dealId: number;
/**
* Groupid
*/
groupId: number;
};
/**
* AddDealToGroupResponse
*/
export type AddDealToGroupResponse = {
/**
* Message
*/
message: string;
};
/** /**
* BarcodeTemplateAttributeSchema * BarcodeTemplateAttributeSchema
*/ */
@ -347,6 +371,27 @@ export type CreateClientSchema = {
details: ClientDetailsSchema; details: ClientDetailsSchema;
}; };
/**
* CreateDealGroupRequest
*/
export type CreateDealGroupRequest = {
/**
* Draggingdealid
*/
draggingDealId: number;
/**
* Hovereddealid
*/
hoveredDealId: number;
};
/**
* CreateDealGroupResponse
*/
export type CreateDealGroupResponse = {
entity: DealGroupSchema;
};
/** /**
* CreateDealProductRequest * CreateDealProductRequest
*/ */
@ -832,6 +877,24 @@ export type DealAddKitResponse = {
message: string; message: string;
}; };
/**
* DealGroupSchema
*/
export type DealGroupSchema = {
/**
* Id
*/
id: number;
/**
* Name
*/
name?: string | null;
/**
* Lexorank
*/
lexorank: string;
};
/** /**
* DealProductAddKitRequest * DealProductAddKitRequest
*/ */
@ -887,6 +950,26 @@ export type DealProductSchema = {
productServices: Array<ProductServiceSchema>; productServices: Array<ProductServiceSchema>;
}; };
/**
* DealRemoveFromGroupRequest
*/
export type DealRemoveFromGroupRequest = {
/**
* Dealid
*/
dealId: number;
};
/**
* DealRemoveFromGroupResponse
*/
export type DealRemoveFromGroupResponse = {
/**
* Message
*/
message: string;
};
/** /**
* DealSchema * DealSchema
*/ */
@ -909,6 +992,7 @@ export type DealSchema = {
* Createdat * Createdat
*/ */
createdAt: string; createdAt: string;
group: DealGroupSchema | null;
/** /**
* Productsquantity * Productsquantity
*/ */
@ -1753,6 +1837,37 @@ export type UpdateClientSchema = {
details?: ClientDetailsSchema | null; details?: ClientDetailsSchema | null;
}; };
/**
* UpdateDealGroupRequest
*/
export type UpdateDealGroupRequest = {
entity: UpdateDealGroupSchema;
};
/**
* UpdateDealGroupResponse
*/
export type UpdateDealGroupResponse = {
/**
* Message
*/
message: string;
};
/**
* UpdateDealGroupSchema
*/
export type UpdateDealGroupSchema = {
/**
* Name
*/
name?: string | null;
/**
* Lexorank
*/
lexorank?: string | null;
};
/** /**
* UpdateDealProductRequest * UpdateDealProductRequest
*/ */
@ -2466,6 +2581,110 @@ export type UpdateDealResponses = {
export type UpdateDealResponse2 = export type UpdateDealResponse2 =
UpdateDealResponses[keyof UpdateDealResponses]; UpdateDealResponses[keyof UpdateDealResponses];
export type UpdateDealGroupData = {
body: UpdateDealGroupRequest;
path?: never;
query?: never;
url: "/deal-group/";
};
export type UpdateDealGroupErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateDealGroupError =
UpdateDealGroupErrors[keyof UpdateDealGroupErrors];
export type UpdateDealGroupResponses = {
/**
* Successful Response
*/
200: UpdateDealGroupResponse;
};
export type UpdateDealGroupResponse2 =
UpdateDealGroupResponses[keyof UpdateDealGroupResponses];
export type CreateDealGroupData = {
body: CreateDealGroupRequest;
path?: never;
query?: never;
url: "/deal-group/";
};
export type CreateDealGroupErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type CreateDealGroupError =
CreateDealGroupErrors[keyof CreateDealGroupErrors];
export type CreateDealGroupResponses = {
/**
* Successful Response
*/
200: CreateDealGroupResponse;
};
export type CreateDealGroupResponse2 =
CreateDealGroupResponses[keyof CreateDealGroupResponses];
export type RemoveDealData = {
body: DealRemoveFromGroupRequest;
path?: never;
query?: never;
url: "/deal-group/deal";
};
export type RemoveDealErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RemoveDealError = RemoveDealErrors[keyof RemoveDealErrors];
export type RemoveDealResponses = {
/**
* Successful Response
*/
200: DealRemoveFromGroupResponse;
};
export type RemoveDealResponse = RemoveDealResponses[keyof RemoveDealResponses];
export type AddDealData = {
body: AddDealToGroupRequest;
path?: never;
query?: never;
url: "/deal-group/deal";
};
export type AddDealErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type AddDealError = AddDealErrors[keyof AddDealErrors];
export type AddDealResponses = {
/**
* Successful Response
*/
200: AddDealToGroupResponse;
};
export type AddDealResponse = AddDealResponses[keyof AddDealResponses];
export type GetBuiltInModulesData = { export type GetBuiltInModulesData = {
body?: never; body?: never;
path?: never; path?: never;

View File

@ -2,6 +2,21 @@
import { z } from "zod"; import { z } from "zod";
/**
* AddDealToGroupRequest
*/
export const zAddDealToGroupRequest = z.object({
dealId: z.int(),
groupId: z.int(),
});
/**
* AddDealToGroupResponse
*/
export const zAddDealToGroupResponse = z.object({
message: z.string(),
});
/** /**
* BarcodeTemplateAttributeSchema * BarcodeTemplateAttributeSchema
*/ */
@ -193,6 +208,30 @@ export const zCreateClientResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* CreateDealGroupRequest
*/
export const zCreateDealGroupRequest = z.object({
draggingDealId: z.int(),
hoveredDealId: z.int(),
});
/**
* DealGroupSchema
*/
export const zDealGroupSchema = z.object({
id: z.int(),
name: z.optional(z.union([z.string(), z.null()])),
lexorank: z.string(),
});
/**
* CreateDealGroupResponse
*/
export const zCreateDealGroupResponse = z.object({
entity: zDealGroupSchema,
});
/** /**
* CreateDealProductSchema * CreateDealProductSchema
*/ */
@ -336,6 +375,7 @@ export const zDealSchema = z.object({
createdAt: z.iso.datetime({ createdAt: z.iso.datetime({
offset: true, offset: true,
}), }),
group: z.union([zDealGroupSchema, z.null()]),
productsQuantity: z.optional(z.int()).default(0), productsQuantity: z.optional(z.int()).default(0),
totalPrice: z.optional(z.number()).default(0), totalPrice: z.optional(z.number()).default(0),
client: z.optional(z.union([zClientSchema, z.null()])), client: z.optional(z.union([zClientSchema, z.null()])),
@ -654,6 +694,20 @@ export const zDealProductAddKitResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* DealRemoveFromGroupRequest
*/
export const zDealRemoveFromGroupRequest = z.object({
dealId: z.int(),
});
/**
* DealRemoveFromGroupResponse
*/
export const zDealRemoveFromGroupResponse = z.object({
message: z.string(),
});
/** /**
* DeleteBarcodeTemplateResponse * DeleteBarcodeTemplateResponse
*/ */
@ -1034,6 +1088,28 @@ export const zUpdateClientResponse = z.object({
message: z.string(), message: z.string(),
}); });
/**
* UpdateDealGroupSchema
*/
export const zUpdateDealGroupSchema = z.object({
name: z.optional(z.union([z.string(), z.null()])),
lexorank: z.optional(z.union([z.string(), z.null()])),
});
/**
* UpdateDealGroupRequest
*/
export const zUpdateDealGroupRequest = z.object({
entity: zUpdateDealGroupSchema,
});
/**
* UpdateDealGroupResponse
*/
export const zUpdateDealGroupResponse = z.object({
message: z.string(),
});
/** /**
* UpdateDealProductSchema * UpdateDealProductSchema
*/ */
@ -1412,6 +1488,50 @@ export const zUpdateDealData = z.object({
*/ */
export const zUpdateDealResponse2 = zUpdateDealResponse; export const zUpdateDealResponse2 = zUpdateDealResponse;
export const zUpdateDealGroupData = z.object({
body: zUpdateDealGroupRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zUpdateDealGroupResponse2 = zUpdateDealGroupResponse;
export const zCreateDealGroupData = z.object({
body: zCreateDealGroupRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zCreateDealGroupResponse2 = zCreateDealGroupResponse;
export const zRemoveDealData = z.object({
body: zDealRemoveFromGroupRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zRemoveDealResponse = zDealRemoveFromGroupResponse;
export const zAddDealData = z.object({
body: zAddDealToGroupRequest,
path: z.optional(z.never()),
query: z.optional(z.never()),
});
/**
* Successful Response
*/
export const zAddDealResponse = zAddDealToGroupResponse;
export const zGetBuiltInModulesData = z.object({ export const zGetBuiltInModulesData = z.object({
body: z.optional(z.never()), body: z.optional(z.never()),
path: z.optional(z.never()), path: z.optional(z.never()),

View File

@ -0,0 +1,7 @@
import { DealGroupSchema, DealSchema } from "@/lib/client";
type GroupWithDealsSchema = DealGroupSchema & {
items: DealSchema[];
};
export default GroupWithDealsSchema;

798
yarn.lock

File diff suppressed because it is too large Load Diff