feat: services table with dnd

This commit is contained in:
2025-10-03 09:07:02 +04:00
parent f3a0179467
commit 1a2895da59
29 changed files with 450 additions and 272 deletions

View File

@ -11,7 +11,8 @@ import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd"; import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client"; import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
import { sortByLexorank } from "@/utils/lexorank/sort";
const Funnel: FC = () => { const Funnel: FC = () => {
const { selectedBoard } = useBoardsContext(); const { selectedBoard } = useBoardsContext();

View File

@ -8,7 +8,8 @@ import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId"; import { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema, StatusSchema } from "@/lib/client"; import { DealSchema, StatusSchema } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
import { sortByLexorank } from "@/utils/lexorank/sort";
type ReturnType = { type ReturnType = {
sortedStatuses: StatusSchema[]; sortedStatuses: StatusSchema[];

View File

@ -1,7 +1,8 @@
import { LexoRank } from "lexorank"; import { LexoRank } from "lexorank";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext"; import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import { DealSchema } from "@/lib/client"; import { DealSchema } from "@/lib/client";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; import { sortByLexorank } from "@/utils/lexorank/sort";
import { getNewLexorank } from "@/utils/lexorank/generation";
type NewRankGetters = { type NewRankGetters = {
getNewRankForSameStatus: ( getNewRankForSameStatus: (

View File

@ -1,31 +1,24 @@
"use client"; "use client";
import { useState } from "react";
import { Stack } from "@mantine/core"; import { Stack } from "@mantine/core";
import ServicesDesktopHeader from "@/app/services/components/desktop/ServicesDesktopHeader/ServicesDesktopHeader"; import ServicesDesktopHeader from "@/app/services/components/desktop/ServicesDesktopHeader/ServicesDesktopHeader";
import ServicesMobileHeader from "@/app/services/components/mobile/ServicesMobileHeader/ServicesMobileHeader"; import ServicesMobileHeader from "@/app/services/components/mobile/ServicesMobileHeader/ServicesMobileHeader";
import ServicesKitsTable from "@/app/services/components/shared/ServicesKitTable/ServicesKitTable"; import ServicesKitsTable from "@/app/services/components/shared/ServicesKitTable/ServicesKitTable";
import ServicesTable from "@/app/services/components/shared/ServicesTable/ServicesTable"; import ServicesTable from "@/app/services/components/shared/ServicesTable/ServicesTable";
import { ServicesTab } from "@/app/services/components/shared/ServiceTabSegmentedControl/ServiceTabSegmentedControl"; import { ServicesTab } from "@/app/services/components/shared/ServiceTabSegmentedControl/ServiceTabSegmentedControl";
import { useServicesContext } from "@/app/services/contexts/ServicesContext";
import PageBlock from "@/components/layout/PageBlock/PageBlock"; import PageBlock from "@/components/layout/PageBlock/PageBlock";
import useIsMobile from "@/hooks/utils/useIsMobile"; import useIsMobile from "@/hooks/utils/useIsMobile";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service";
const PageBody = () => { const PageBody = () => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { servicesTab, setServicesTab } = useServicesContext();
const [servicesTab, setServicesTab] = useState<ServicesTab>(
ServicesTab.PRODUCT_SERVICE
);
const getPageBody = () => { const getPageBody = () => {
switch (servicesTab) { switch (servicesTab) {
case ServicesTab.PRODUCT_SERVICE: case ServicesTab.PRODUCT_SERVICE:
return (
<ServicesTable serviceType={ServiceType.PRODUCT_SERVICE} />
);
case ServicesTab.DEAL_SERVICE: case ServicesTab.DEAL_SERVICE:
return <ServicesTable serviceType={ServiceType.DEAL_SERVICE} />; return <ServicesTable />;
case ServicesTab.SERVICES_KITS: case ServicesTab.SERVICES_KITS:
return <ServicesKitsTable />; return <ServicesKitsTable />;
default: default:

View File

@ -1,28 +1,17 @@
"use client"; "use client";
import { FC, useMemo, useState } from "react"; import { FC, useState } from "react";
import { DragDropContext, Draggable } from "@hello-pangea/dnd";
import { IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { DataTableDraggableRow } from "mantine-datatable";
import { Center, TableTd } from "@mantine/core";
import InnerServicesTableDndWrapper from "@/app/services/components/shared/ServicesTable/components/InnerServicesTableDndWrapper"; import InnerServicesTableDndWrapper from "@/app/services/components/shared/ServicesTable/components/InnerServicesTableDndWrapper";
import useServicesOuterTableColumns from "@/app/services/components/shared/ServicesTable/hooks/servicesOuterTableColumns"; import useServicesOuterTableColumns from "@/app/services/components/shared/ServicesTable/hooks/servicesOuterTableColumns";
import { GroupedServices } from "@/app/services/components/shared/ServicesTable/types/GroupedServices"; import ServicesTableDndWrapper from "@/app/services/components/shared/ServicesTableDndWrapper/ServicesTableDndWrapper";
import { useServicesContext } from "@/app/services/contexts/ServicesContext"; import { useServicesContext } from "@/app/services/contexts/ServicesContext";
import { useServicesDndContext } from "@/app/services/contexts/ServicesDndContext"; import DraggableTableRow from "@/components/dnd-pangea/DraggableTableRow/DraggableTableRow";
import Droppable from "@/components/dnd-pangea/Droppable/Droppable";
import BaseTable from "@/components/ui/BaseTable/BaseTable"; import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service"; import useIsMobile from "@/hooks/utils/useIsMobile";
import classes from "./ServicesTable.module.css";
type Props = { const ServicesTable: FC = () => {
serviceType: ServiceType; const isMobile = useIsMobile();
}; const { groupedServices } = useServicesContext();
const ServicesTable: FC<Props> = ({ serviceType }) => {
const { servicesList } = useServicesContext();
const { onDragEnd, onDragStart } = useServicesDndContext();
const [expandedCategoryIds, setExpandedCategoryIds] = useState<number[]>( const [expandedCategoryIds, setExpandedCategoryIds] = useState<number[]>(
[] []
@ -33,46 +22,14 @@ const ServicesTable: FC<Props> = ({ serviceType }) => {
setExpandedCategoryIds, setExpandedCategoryIds,
}); });
const groupedServices: GroupedServices[] = useMemo(() => {
const grouped: GroupedServices[] = [];
servicesList.services.forEach(service => {
if (service.serviceType !== serviceType) return;
const existingGroup = grouped.find(
group => group.category.id === service.category.id
);
if (existingGroup) {
existingGroup.services.push(service);
} else {
grouped.push({
category: service.category,
services: [service],
});
}
});
return grouped;
}, [servicesList.services, serviceType]);
return ( return (
<DragDropContext <ServicesTableDndWrapper>
onDragEnd={onDragEnd}
onDragStart={onDragStart}>
<BaseTable <BaseTable
columns={outerColumns} columns={outerColumns}
groups={undefined} groups={undefined}
records={groupedServices} records={groupedServices}
withTableBorder withTableBorder
idAccessor={"category.id"} idAccessor={"category.id"}
tableWrapper={({ children }) => (
<Droppable
droppableId={"categories"}
// isDropDisabled={
// dragState !== ServiceDragState.DRAG_CATEGORY
// }
>
{children}
</Droppable>
)}
rowExpansion={{ rowExpansion={{
allowMultiple: true, allowMultiple: true,
expanded: { expanded: {
@ -82,47 +39,21 @@ const ServicesTable: FC<Props> = ({ serviceType }) => {
content: ({ record }) => ( content: ({ record }) => (
<InnerServicesTableDndWrapper <InnerServicesTableDndWrapper
services={record.services} services={record.services}
categoryId={record.category.id}
/> />
), ),
}} }}
styles={{ table: { tableLayout: "fixed" } }} styles={{ table: { tableLayout: "fixed" } }}
rowFactory={({ rowFactory={({ record, children, ...props }) => (
record, <DraggableTableRow
index, draggableId={`category-${record.category.id}`}
rowProps, rowElement={children}
children, {...props}
expandedElement, />
}) => (
<>
<Draggable
key={record.category.id}
draggableId={`category-${record.category.id.toString()}`}
index={index}>
{(provided, snapshot) => (
<DataTableDraggableRow
isDragging={snapshot.isDragging}
{...rowProps}
className={clsx(
rowProps.className,
classes["draggable-row"]
)}
{...provided.draggableProps}>
<TableTd>
<Center
{...provided.dragHandleProps}
ref={provided.innerRef}>
<IconGripVertical />
</Center>
</TableTd>
{children}
</DataTableDraggableRow>
)}
</Draggable>
{expandedElement}
</>
)} )}
mx={isMobile ? "xs" : 0}
/> />
</DragDropContext> </ServicesTableDndWrapper>
); );
}; };

View File

@ -1,54 +1,35 @@
"use client";
import { FC } from "react"; import { FC } from "react";
import { Draggable } from "@hello-pangea/dnd";
import { IconGripVertical } from "@tabler/icons-react";
import clsx from "clsx";
import { DataTableDraggableRow } from "mantine-datatable";
import { Center, TableTd } from "@mantine/core";
import useServicesInnerTableColumns from "@/app/services/components/shared/ServicesTable/hooks/servicesInnerTableColumns"; import useServicesInnerTableColumns from "@/app/services/components/shared/ServicesTable/hooks/servicesInnerTableColumns";
import classes from "@/app/services/components/shared/ServicesTable/ServicesTable.module.css"; import DraggableTableRow from "@/components/dnd-pangea/DraggableTableRow/DraggableTableRow";
import BaseTable from "@/components/ui/BaseTable/BaseTable"; import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { ServiceSchema } from "@/lib/client"; import { ServiceSchema } from "@/lib/client";
type Props = { type Props = {
services: ServiceSchema[]; services: ServiceSchema[];
categoryId: number;
}; };
const InnerServicesTable: FC<Props> = ({ services }) => { const InnerServicesTable: FC<Props> = ({ services, categoryId }) => {
const innerColumns = useServicesInnerTableColumns(); const innerColumns = useServicesInnerTableColumns();
return ( return (
<BaseTable <BaseTable
key={categoryId}
withTableBorder withTableBorder
columns={innerColumns} columns={innerColumns}
records={services} records={services}
verticalSpacing={"md"} verticalSpacing={"md"}
groups={undefined} groups={undefined}
styles={{ table: { width: "100%" } }} styles={{ table: { width: "100%" } }}
rowFactory={({ record, index, rowProps, children }) => ( rowFactory={({ record, children, ...props }) => (
<Draggable <DraggableTableRow
key={record.id}
draggableId={`service-${record.id}`} draggableId={`service-${record.id}`}
index={index}> rowElement={children}
{(provided, snapshot) => ( disableDndOnMobile
<DataTableDraggableRow {...props}
isDragging={snapshot.isDragging} />
{...rowProps}
className={clsx(
rowProps.className,
classes["draggable-row"]
)}
{...provided.draggableProps}>
<TableTd>
<Center
{...provided.dragHandleProps}
ref={provided.innerRef}>
<IconGripVertical />
</Center>
</TableTd>
{children}
</DataTableDraggableRow>
)}
</Draggable>
)} )}
/> />
); );

View File

@ -1,19 +1,26 @@
import { FC } from "react"; import { FC } from "react";
import InnerServicesTable from "@/app/services/components/shared/ServicesTable/components/InnerServicesTable"; import InnerServicesTable from "@/app/services/components/shared/ServicesTable/components/InnerServicesTable";
import { useServicesDndContext } from "@/app/services/contexts/ServicesDndContext";
import ServiceDragState from "@/app/services/enums/DragState";
import Droppable from "@/components/dnd-pangea/Droppable/Droppable"; import Droppable from "@/components/dnd-pangea/Droppable/Droppable";
import { ServiceSchema } from "@/lib/client"; import { ServiceSchema } from "@/lib/client";
type Props = { type Props = {
services: ServiceSchema[]; services: ServiceSchema[];
categoryId: number;
}; };
const InnerServicesTableDndWrapper: FC<Props> = ({ services }) => { const InnerServicesTableDndWrapper: FC<Props> = ({ services, categoryId }) => {
const { dragState } = useServicesDndContext();
return ( return (
<Droppable <Droppable
droppableId={"services"} droppableId={`services-${categoryId}`}
// isDropDisabled={dragState !== ServiceDragState.DRAG_SERVICE} isDropDisabled={dragState !== ServiceDragState.DRAG_SERVICE}>
> <InnerServicesTable
<InnerServicesTable services={services} /> services={services}
categoryId={categoryId}
/>
</Droppable> </Droppable>
); );
}; };

View File

@ -34,30 +34,30 @@ const useServicesInnerTableColumns = () => {
return useMemo( return useMemo(
() => () =>
[ [
{ accessor: "", hiddenContent: true, width: 0 }, { accessor: "", hiddenContent: true, width: 2 },
{ {
accessor: "name", accessor: "name",
title: "Название", title: "Название",
width: 350, width: isMobile ? 70 : 350,
}, },
{ {
accessor: "price", accessor: "price",
title: "Цена", title: "Цена",
render: service => getPriceRow(service), render: service => getPriceRow(service),
width: 200, width: isMobile ? 40 : 200,
}, },
{ {
accessor: "cost", accessor: "cost",
title: isMobile ? "Себестоим." : "Себестоимость", title: isMobile ? "Себестоим." : "Себестоимость",
render: service => `${service.cost?.toLocaleString("ru")}`, render: service => `${service.cost?.toLocaleString("ru")}`,
width: 200, width: isMobile ? 40 : 200,
}, },
{ {
accessor: "actions", accessor: "actions",
title: isMobile ? "" : "Действия", title: isMobile ? "" : "Действия",
sortable: false, sortable: false,
textAlign: "center", textAlign: "center",
width: 70, width: "0%",
render: service => ( render: service => (
<Center> <Center>
<UpdateDeleteTableActions <UpdateDeleteTableActions

View File

@ -45,7 +45,7 @@ const useServicesOuterTableColumns = ({
return useMemo( return useMemo(
() => () =>
[ [
{ accessor: "", hiddenContent: true, width: 3 }, { accessor: "", hiddenContent: true, width: isMobile ? 1 : 3 },
{ {
accessor: "category.name", accessor: "category.name",
title: ( title: (
@ -58,9 +58,10 @@ const useServicesOuterTableColumns = ({
Категория Категория
</Group> </Group>
), ),
noWrap: true,
render: ({ category: { id, name } }) => ( render: ({ category: { id, name } }) => (
<Group key={id}> <Group
key={id}
wrap={"nowrap"}>
{expandedCategoryIds.includes(id) ? ( {expandedCategoryIds.includes(id) ? (
<IconChevronUp /> <IconChevronUp />
) : ( ) : (
@ -69,13 +70,14 @@ const useServicesOuterTableColumns = ({
<Text>{name}</Text> <Text>{name}</Text>
</Group> </Group>
), ),
width: 450, width: isMobile ? 100 : 450,
}, },
{ {
accessor: "actions", accessor: "actions",
title: isMobile ? "" : "Действия", title: isMobile ? "" : "Действия",
sortable: false, sortable: false,
textAlign: "center", textAlign: "center",
width: isMobile ? 2 : 50,
render: ({ category }) => ( render: ({ category }) => (
<Center> <Center>
<UpdateDeleteTableActions <UpdateDeleteTableActions
@ -87,7 +89,6 @@ const useServicesOuterTableColumns = ({
/> />
</Center> </Center>
), ),
width: 50,
}, },
] as DataTableColumn<GroupedServices>[], ] as DataTableColumn<GroupedServices>[],
[expandedCategoryIds, categories] [expandedCategoryIds, categories]

View File

@ -0,0 +1,27 @@
import { FC, ReactNode } from "react";
import { DragDropContext } from "@hello-pangea/dnd";
import ServiceDragState from "@/app/services/enums/DragState";
import Droppable from "@/components/dnd-pangea/Droppable/Droppable";
import { useServicesDndContext } from "@/app/services/contexts/ServicesDndContext";
type Props = {
children: ReactNode;
};
const ServicesTableDndWrapper: FC<Props> = ({ children }) => {
const { onDragEnd, onDragStart, dragState } = useServicesDndContext();
return (
<DragDropContext
onDragEnd={onDragEnd}
onDragStart={onDragStart}>
<Droppable
droppableId={"categories"}
isDropDisabled={dragState !== ServiceDragState.DRAG_CATEGORY}>
{children}
</Droppable>
</DragDropContext>
);
};
export default ServicesTableDndWrapper;

View File

@ -1,5 +1,9 @@
"use client"; "use client";
import { Dispatch, SetStateAction, useState } from "react";
import { GroupedServices } from "@/app/services/components/shared/ServicesTable/types/GroupedServices";
import { ServicesTab } from "@/app/services/components/shared/ServiceTabSegmentedControl/ServiceTabSegmentedControl";
import useGroupedServices from "@/app/services/hooks/useGroupedServices";
import { ServiceCategorySchema } from "@/lib/client"; import { ServiceCategorySchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory"; import makeContext from "@/lib/contextFactory/contextFactory";
import { import {
@ -22,6 +26,9 @@ import useServicesList, {
} from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/hooks/lists/useServicesList"; } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/hooks/lists/useServicesList";
type ServicesContextState = { type ServicesContextState = {
servicesTab: ServicesTab;
setServicesTab: Dispatch<SetStateAction<ServicesTab>>;
groupedServices: GroupedServices[];
servicesList: ServicesList; servicesList: ServicesList;
servicesCrud: ServicesCrud; servicesCrud: ServicesCrud;
servicesKitList: ServicesKitsList; servicesKitList: ServicesKitsList;
@ -31,6 +38,10 @@ type ServicesContextState = {
}; };
const useServicesContextState = (): ServicesContextState => { const useServicesContextState = (): ServicesContextState => {
const [servicesTab, setServicesTab] = useState<ServicesTab>(
ServicesTab.PRODUCT_SERVICE
);
const servicesList = useServicesList(); const servicesList = useServicesList();
const servicesCrud = useServicesCrud(servicesList); const servicesCrud = useServicesCrud(servicesList);
@ -43,7 +54,15 @@ const useServicesContextState = (): ServicesContextState => {
categories, categories,
}); });
const groupedServices = useGroupedServices({
services: servicesList.services,
servicesTab,
});
return { return {
servicesTab,
setServicesTab,
groupedServices,
servicesList, servicesList,
servicesCrud, servicesCrud,
servicesKitList, servicesKitList,

View File

@ -2,8 +2,11 @@
import { Dispatch, SetStateAction, useState } from "react"; import { Dispatch, SetStateAction, useState } from "react";
import type { DragStart, DropResult } from "@hello-pangea/dnd"; import type { DragStart, DropResult } from "@hello-pangea/dnd";
import { ServicesTab } from "@/app/services/components/shared/ServiceTabSegmentedControl/ServiceTabSegmentedControl";
import { useServicesContext } from "@/app/services/contexts/ServicesContext";
import ServiceDragState from "@/app/services/enums/DragState"; import ServiceDragState from "@/app/services/enums/DragState";
import makeContext from "@/lib/contextFactory/contextFactory"; import makeContext from "@/lib/contextFactory/contextFactory";
import { getNewDndLexorank } from "@/utils/lexorank/generation";
type ServicesDndContextState = { type ServicesDndContextState = {
onDragStart: (start: DragStart) => void; onDragStart: (start: DragStart) => void;
@ -13,36 +16,107 @@ type ServicesDndContextState = {
}; };
const useServiceDndContextState = (): ServicesDndContextState => { const useServiceDndContextState = (): ServicesDndContextState => {
const {
categories,
groupedServices,
servicesTab,
categoriesCrud,
servicesCrud,
servicesList,
} = useServicesContext();
const [dragState, setDragState] = useState<ServiceDragState>( const [dragState, setDragState] = useState<ServiceDragState>(
ServiceDragState.DRAG_ENDED ServiceDragState.DRAG_ENDED
); );
const [value, setValue] = useState<boolean>(false);
const onDragStart = (start: DragStart) => { const onDragStart = (start: DragStart) => {
setValue(prev => !prev); if (start.draggableId.includes("category")) {
setDragState(ServiceDragState.DRAG_CATEGORY);
return;
}
setDragState(ServiceDragState.DRAG_SERVICE);
};
const getCategoriesFromGroupedServices = () => [
...new Set(groupedServices.map(grouped => grouped.category)),
];
const onCategoryDrag = (result: DropResult) => {
if (
!result.draggableId.startsWith("category-") ||
result.source.index === result.destination!.index
)
return;
const draggingCatId = Number(
result.draggableId.replace("category-", "")
);
const rankKey =
servicesTab === ServicesTab.DEAL_SERVICE
? "dealServiceRank"
: "productServiceRank";
const categories = getCategoriesFromGroupedServices();
const newLexorank = getNewDndLexorank(
result.source.index,
result.destination!.index,
categories,
rankKey
);
categoriesCrud.onUpdate(draggingCatId, {
[rankKey]: newLexorank,
});
};
const onServiceDrag = (result: DropResult) => {
if (!result.draggableId.startsWith("service-") || !result.destination)
return;
const sourceCatId = Number(
result.source.droppableId.replace("services-", "")
);
const targetCatId = Number(
result.destination!.droppableId.replace("services-", "")
);
const draggingServiceId = Number(
result.draggableId.replace("service-", "")
);
const services = servicesList.services.filter(
s => s.category.id === targetCatId && s.serviceType === servicesTab
);
const newLexorank = getNewDndLexorank(
result.source.index,
result.destination!.index,
services,
"lexorank"
);
const category =
sourceCatId !== targetCatId
? categories.find(c => c.id === targetCatId)
: null;
servicesCrud.onUpdate(draggingServiceId, {
lexorank: newLexorank,
category,
});
}; };
const onDragEnd = (result: DropResult) => { const onDragEnd = (result: DropResult) => {
if (!result.destination) return; if (!result.destination) return;
const sourceId = result.source.droppableId; const sourceId = result.source.droppableId;
const destinationId = result.destination.droppableId;
console.log(destinationId); if (sourceId === "categories") {
onCategoryDrag(result);
// const items = Array.from(records); return;
// const sourceIndex = result.source.index; }
// const destinationIndex = result.destination.index; onServiceDrag(result);
// const [reorderedItem] = items.splice(sourceIndex, 1);
// items.splice(destinationIndex, 0, reorderedItem);
//
// setRecords(items);
// notifications.show({
// title: 'Table reordered',
// message: `The company named "${items[sourceIndex].name}" has been moved from position ${sourceIndex + 1} to ${destinationIndex + 1}.`,
// color: 'blue',
// });
}; };
return { return {

View File

@ -0,0 +1,46 @@
import { useMemo } from "react";
import { GroupedServices } from "@/app/services/components/shared/ServicesTable/types/GroupedServices";
import { ServicesTab } from "@/app/services/components/shared/ServiceTabSegmentedControl/ServiceTabSegmentedControl";
import { ServiceCategorySchema, ServiceSchema } from "@/lib/client";
type Props = {
services: ServiceSchema[];
servicesTab: ServicesTab;
};
const useGroupedServices = ({ services, servicesTab }: Props) => {
const sortGroupedServices = (grouped: GroupedServices[]) => {
const key: keyof ServiceCategorySchema =
servicesTab === ServicesTab.DEAL_SERVICE
? "dealServiceRank"
: "productServiceRank";
return grouped.sort((a, b) => {
const aRank = a.category[key];
const bRank = b.category[key];
return aRank < bRank ? -1 : aRank > bRank ? 1 : 0;
});
};
return useMemo(() => {
const grouped: GroupedServices[] = [];
services.forEach(service => {
if (service.serviceType !== servicesTab) return;
const existingGroup = grouped.find(
group => group.category.id === service.category.id
);
if (existingGroup) {
existingGroup.services.push(service);
} else {
grouped.push({
category: service.category,
services: [service],
});
}
});
return sortGroupedServices(grouped);
}, [services, servicesTab]);
};
export default useGroupedServices;

View File

@ -0,0 +1,58 @@
import { FC, ReactNode } from "react";
import { Draggable } from "@hello-pangea/dnd";
import { IconGripVertical } from "@tabler/icons-react";
import classNames from "classnames";
import { DataTableDraggableRow } from "mantine-datatable";
import { Center, TableTd, TableTrProps } from "@mantine/core";
import classes from "@/app/services/components/shared/ServicesTable/ServicesTable.module.css";
import useIsMobile from "@/hooks/utils/useIsMobile";
type Props = {
draggableId: string;
index: number;
rowElement: ReactNode;
expandedElement?: ReactNode;
rowProps: TableTrProps;
disableDndOnMobile?: boolean;
};
const DraggableTableRow: FC<Props> = ({
draggableId,
index,
rowElement,
expandedElement,
rowProps,
}) => {
const isMobile = useIsMobile();
return (
<>
<Draggable
key={draggableId}
draggableId={draggableId}
index={index}>
{(provided, snapshot) => (
<DataTableDraggableRow
isDragging={snapshot.isDragging}
{...rowProps}
className={classNames(
rowProps.className,
classes["draggable-row"]
)}
{...provided.draggableProps}>
<TableTd maw={isMobile ? 2 : "auto"}>
<Center
{...provided.dragHandleProps}
ref={provided.innerRef}>
<IconGripVertical />
</Center>
</TableTd>
{rowElement}
</DataTableDraggableRow>
)}
</Draggable>
{expandedElement}
</>
);
};
export default DraggableTableRow;

View File

@ -13,14 +13,14 @@ import {
restrictToVerticalAxis, restrictToVerticalAxis,
} from "@dnd-kit/modifiers"; } from "@dnd-kit/modifiers";
import { SortableContext } from "@dnd-kit/sortable"; import { SortableContext } from "@dnd-kit/sortable";
import { LexoRank } from "lexorank";
import { FreeMode, Mousewheel, Scrollbar } from "swiper/modules"; import { FreeMode, Mousewheel, Scrollbar } from "swiper/modules";
import { Swiper, SwiperSlide } from "swiper/react"; import { Swiper, SwiperSlide } from "swiper/react";
import { Box, Flex } from "@mantine/core"; import { Box, Flex } from "@mantine/core";
import useDndSensors from "@/app/deals/hooks/useSensors"; import useDndSensors from "@/app/deals/hooks/useSensors";
import { SortableOverlay } from "@/components/dnd/SortableDnd/SortableOverlay"; import { SortableOverlay } from "@/components/dnd/SortableDnd/SortableOverlay";
import SortableItem from "@/components/dnd/SortableItem"; import SortableItem from "@/components/dnd/SortableItem";
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank"; import { getNewDndLexorank } from "@/utils/lexorank/generation";
import { sortByLexorank } from "@/utils/lexorank/sort";
import classes from "./SortableDnd.module.css"; import classes from "./SortableDnd.module.css";
type BaseItem = { type BaseItem = {
@ -80,24 +80,12 @@ const SortableDnd = <T extends BaseItem>({
({ id }) => id === activeItem.id ({ id }) => id === activeItem.id
); );
let leftIndex = overIndex; const newLexorank = getNewDndLexorank(
let rightIndex = overIndex + 1; activeIndex,
if (overIndex < activeIndex) { overIndex,
leftIndex = overIndex - 1; items,
rightIndex = overIndex; "lexorank"
} );
const leftLexorank: LexoRank | null =
leftIndex >= 0 ? LexoRank.parse(items[leftIndex].lexorank) : null;
const rightLexorank: LexoRank | null =
rightIndex < items.length
? LexoRank.parse(items[rightIndex].lexorank)
: null;
const newLexorank = getNewLexorank(
leftLexorank,
rightLexorank
).toString();
items[activeIndex].lexorank = newLexorank; items[activeIndex].lexorank = newLexorank;
onDragEnd(items[activeIndex].id, newLexorank); onDragEnd(items[activeIndex].id, newLexorank);

View File

@ -5,13 +5,13 @@ import { Text } from "@mantine/core";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import getCommonQueryClient from "@/hooks/cruds/baseCrud/getCommonQueryClient"; import getCommonQueryClient from "@/hooks/cruds/baseCrud/getCommonQueryClient";
import { HttpValidationError } from "@/lib/client"; import { HttpValidationError } from "@/lib/client";
import { sortByLexorank } from "@/utils/lexorank";
import { import {
BaseEntity, BaseEntity,
CreateMutationOptions, CreateMutationOptions,
DeleteMutationOptions, DeleteMutationOptions,
UpdateMutationOptions, UpdateMutationOptions,
} from "./types"; } from "./types";
import { sortByLexorank } from "@/utils/lexorank/sort";
type CrudOperations< type CrudOperations<
TEntity, TEntity,

View File

@ -10,7 +10,8 @@ import {
deleteBoardMutation, deleteBoardMutation,
updateBoardMutation, updateBoardMutation,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank"; import { getMaxByLexorank } from "@/utils/lexorank/max";
import { getNewLexorank } from "@/utils/lexorank/generation";
type UseBoardsOperationsProps = { type UseBoardsOperationsProps = {
boards: BoardSchema[]; boards: BoardSchema[];

View File

@ -11,7 +11,8 @@ import {
deleteDealMutation, deleteDealMutation,
updateDealMutation, updateDealMutation,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
import { getNewLexorank } from "@/utils/lexorank";
import { getNewLexorank } from "@/utils/lexorank/generation";
type UseDealsOperationsProps = { type UseDealsOperationsProps = {
deals: DealSchema[]; deals: DealSchema[];

View File

@ -10,7 +10,8 @@ import {
deleteStatusMutation, deleteStatusMutation,
updateStatusMutation, updateStatusMutation,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank"; import { getMaxByLexorank } from "@/utils/lexorank/max";
import { getNewLexorank } from "@/utils/lexorank/generation";
type Props = { type Props = {
statuses: StatusSchema[]; statuses: StatusSchema[];

View File

@ -451,6 +451,10 @@ export type CreateServiceSchema = {
*/ */
name: string; name: string;
category: ServiceCategorySchema; category: ServiceCategorySchema;
/**
* Categoryid
*/
categoryId?: number | null;
/** /**
* Price * Price
*/ */
@ -1116,6 +1120,10 @@ export type ServiceSchema = {
*/ */
name: string; name: string;
category: ServiceCategorySchema; category: ServiceCategorySchema;
/**
* Categoryid
*/
categoryId?: number | null;
/** /**
* Price * Price
*/ */
@ -1485,19 +1493,15 @@ export type UpdateServiceCategorySchema = {
/** /**
* Name * Name
*/ */
name: string; name?: string | null;
/** /**
* Dealservicerank * Dealservicerank
*/ */
dealServiceRank: string; dealServiceRank?: string | null;
/** /**
* Productservicerank * Productservicerank
*/ */
productServiceRank: string; productServiceRank?: string | null;
/**
* Id
*/
id: number;
}; };
/** /**
@ -1524,32 +1528,32 @@ export type UpdateServiceSchema = {
/** /**
* Name * Name
*/ */
name: string; name?: string | null;
category: ServiceCategorySchema; category?: ServiceCategorySchema | null;
/**
* Categoryid
*/
categoryId?: number | null;
/** /**
* Price * Price
*/ */
price: number; price?: number | null;
/** /**
* Servicetype * Servicetype
*/ */
serviceType: number; serviceType?: number | null;
/** /**
* Priceranges * Priceranges
*/ */
priceRanges: Array<ServicePriceRangeSchema>; priceRanges?: Array<ServicePriceRangeSchema> | null;
/** /**
* Cost * Cost
*/ */
cost: number | null; cost?: number | null;
/** /**
* Lexorank * Lexorank
*/ */
lexorank: string; lexorank?: string | null;
/**
* Id
*/
id: number;
}; };
/** /**

View File

@ -141,6 +141,7 @@ export const zServicePriceRangeSchema = z.object({
export const zServiceSchema = z.object({ export const zServiceSchema = z.object({
name: z.string(), name: z.string(),
category: zServiceCategorySchema, category: zServiceCategorySchema,
categoryId: z.optional(z.union([z.int(), z.null()])),
price: z.number(), price: z.number(),
serviceType: z.int(), serviceType: z.int(),
priceRanges: z.array(zServicePriceRangeSchema), priceRanges: z.array(zServicePriceRangeSchema),
@ -381,6 +382,7 @@ export const zCreateServiceCategoryResponse = z.object({
export const zCreateServiceSchema = z.object({ export const zCreateServiceSchema = z.object({
name: z.string(), name: z.string(),
category: zServiceCategorySchema, category: zServiceCategorySchema,
categoryId: z.optional(z.union([z.int(), z.null()])),
price: z.number(), price: z.number(),
serviceType: z.int(), serviceType: z.int(),
priceRanges: z.array(zServicePriceRangeSchema), priceRanges: z.array(zServicePriceRangeSchema),
@ -886,10 +888,9 @@ export const zUpdateProjectResponse = z.object({
* UpdateServiceCategorySchema * UpdateServiceCategorySchema
*/ */
export const zUpdateServiceCategorySchema = z.object({ export const zUpdateServiceCategorySchema = z.object({
name: z.string(), name: z.optional(z.union([z.string(), z.null()])),
dealServiceRank: z.string(), dealServiceRank: z.optional(z.union([z.string(), z.null()])),
productServiceRank: z.string(), productServiceRank: z.optional(z.union([z.string(), z.null()])),
id: z.int(),
}); });
/** /**
@ -910,14 +911,16 @@ export const zUpdateServiceCategoryResponse = z.object({
* UpdateServiceSchema * UpdateServiceSchema
*/ */
export const zUpdateServiceSchema = z.object({ export const zUpdateServiceSchema = z.object({
name: z.string(), name: z.optional(z.union([z.string(), z.null()])),
category: zServiceCategorySchema, category: z.optional(z.union([zServiceCategorySchema, z.null()])),
price: z.number(), categoryId: z.optional(z.union([z.int(), z.null()])),
serviceType: z.int(), price: z.optional(z.union([z.number(), z.null()])),
priceRanges: z.array(zServicePriceRangeSchema), serviceType: z.optional(z.union([z.int(), z.null()])),
cost: z.union([z.number(), z.null()]), priceRanges: z.optional(
lexorank: z.string(), z.union([z.array(zServicePriceRangeSchema), z.null()])
id: z.int(), ),
cost: z.optional(z.union([z.number(), z.null()])),
lexorank: z.optional(z.union([z.string(), z.null()])),
}); });
/** /**

View File

@ -11,7 +11,8 @@ import {
updateServiceCategoryMutation, updateServiceCategoryMutation,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service"; import { ServiceType } from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/service";
import { getMaxLexorankByKey, getNewLexorank } from "@/utils/lexorank"; import { getMaxLexorankByKey } from "@/utils/lexorank/max";
import { getNewLexorank } from "@/utils/lexorank/generation";
type UseServiceCategoryProps = { type UseServiceCategoryProps = {
queryKey: any[]; queryKey: any[];

View File

@ -10,7 +10,8 @@ import {
deleteServiceMutation, deleteServiceMutation,
updateServiceMutation, updateServiceMutation,
} from "@/lib/client/@tanstack/react-query.gen"; } from "@/lib/client/@tanstack/react-query.gen";
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank"; import { getMaxByLexorank } from "@/utils/lexorank/max";
import { getNewLexorank } from "@/utils/lexorank/generation";
type UseServicesProps = { type UseServicesProps = {
queryKey: any[]; queryKey: any[];

View File

@ -1,52 +0,0 @@
import { LexoRank } from "lexorank";
type LexorankSortable = {
lexorank: string;
};
export function compareByLexorank<T extends LexorankSortable>(
a: T,
b: T
): -1 | 1 | 0 {
if (a.lexorank < b.lexorank) {
return -1;
}
if (a.lexorank > b.lexorank) {
return 1;
}
return 0;
}
export function sortByLexorank<T extends LexorankSortable>(items: T[]): T[] {
return items.sort(compareByLexorank);
}
export function getMaxByLexorank<T extends LexorankSortable>(
items: T[]
): T | null {
return items.reduce(
(max, item) => (!max || item.lexorank > max.lexorank ? item : max),
null as T | null
);
}
export function getMaxLexorankByKey<T>(items: T[], key: keyof T): T | null {
return items.reduce(
(max, item) => (!max || item[key] > max[key] ? item : max),
null as T | null
);
}
export function getNewLexorank(
left?: LexoRank | null,
right?: LexoRank | null
): LexoRank {
if (right) {
if (left) return left?.between(right);
return right.between(LexoRank.min());
}
if (left) {
return left.between(LexoRank.max());
}
return LexoRank.middle();
}

View File

@ -0,0 +1,22 @@
import { LexorankSortable } from "@/utils/lexorank/types";
export function compareByLexorank<T extends LexorankSortable>(
a: T,
b: T
): -1 | 1 | 0 {
return compareByLexorankWithKey<T>(a, b, "lexorank");
}
export function compareByLexorankWithKey<T>(
a: T,
b: T,
key: keyof T
): -1 | 1 | 0 {
if (a[key] < b[key]) {
return -1;
}
if (a[key] > b[key]) {
return 1;
}
return 0;
}

View File

@ -0,0 +1,38 @@
import { LexoRank } from "lexorank";
export function getNewLexorank(
left?: LexoRank | null,
right?: LexoRank | null
): LexoRank {
if (right) {
if (left) return left?.between(right);
return right.between(LexoRank.min());
}
if (left) {
return left.between(LexoRank.max());
}
return LexoRank.middle();
}
export const getNewDndLexorank = <T>(
sourceIdx: number,
targetIdx: number,
items: T[],
key: keyof T
): string => {
let leftIndex = targetIdx;
let rightIndex = targetIdx + 1;
if (targetIdx < sourceIdx) {
leftIndex = targetIdx - 1;
rightIndex = targetIdx;
}
const leftLexorank: LexoRank | null =
leftIndex >= 0 ? LexoRank.parse(items[leftIndex][key] as string) : null;
const rightLexorank: LexoRank | null =
rightIndex < items.length
? LexoRank.parse(items[rightIndex][key] as string)
: null;
return getNewLexorank(leftLexorank, rightLexorank).toString();
};

17
src/utils/lexorank/max.ts Normal file
View File

@ -0,0 +1,17 @@
import { LexorankSortable } from "@/utils/lexorank/types";
export function getMaxByLexorank<T extends LexorankSortable>(
items: T[]
): T | null {
return items.reduce(
(max, item) => (!max || item.lexorank > max.lexorank ? item : max),
null as T | null
);
}
export function getMaxLexorankByKey<T>(items: T[], key: keyof T): T | null {
return items.reduce(
(max, item) => (!max || item[key] > max[key] ? item : max),
null as T | null
);
}

View File

@ -0,0 +1,10 @@
import { compareByLexorankWithKey } from "@/utils/lexorank/compare";
import { LexorankSortable } from "@/utils/lexorank/types";
export function sortByLexorank<T extends LexorankSortable>(items: T[]): T[] {
return sortByLexorankWithKey(items, "lexorank");
}
export function sortByLexorankWithKey<T>(items: T[], key: keyof T): T[] {
return items.sort((a, b) => compareByLexorankWithKey(a, b, key));
}

View File

@ -0,0 +1,3 @@
export type LexorankSortable = {
lexorank: string;
};