Compare commits
53 Commits
detached
...
49b1a235be
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b1a235be | |||
| 19a386319c | |||
| 3ccebeb123 | |||
| e5e87f775d | |||
| 85ed974f5e | |||
| 92efe3fb66 | |||
| c405c802aa | |||
| 4ff663536e | |||
| 2e9ed02722 | |||
| a4bcd62189 | |||
| 0a13070d9e | |||
| 219689b947 | |||
| 3ece4677fb | |||
| 3d213cb0d9 | |||
| 6d0c48be23 | |||
| a169600908 | |||
| 43355b6ce3 | |||
| 28004dc2a0 | |||
| c3b0da1e0d | |||
| 8fb4121ed1 | |||
| 95e49eafc1 | |||
| 255a39e2bb | |||
| b6cec9a308 | |||
| 20ade53d52 | |||
| 7932f3f5c8 | |||
| 0836e4f0ca | |||
| 90582b329e | |||
| f2bba7e469 | |||
| 838c9640a1 | |||
| 1a98facd72 | |||
| 5144c83e93 | |||
| 6715e4bd38 | |||
| 7815f99fa4 | |||
| 54cf883a3c | |||
| 45dc8901fd | |||
| 067094c78a | |||
| 301821a682 | |||
| 9fb9e794db | |||
| e3137de46d | |||
| 5ecdd3d887 | |||
| d3febcdfb0 | |||
| afad1b4605 | |||
| f52fde0097 | |||
| e29664ecc5 | |||
| 7e2dd9763b | |||
| 41f8d19d49 | |||
| 335fbfe81c | |||
| 4b843d8e5d | |||
| 96c53380e0 | |||
| 9a780e99ae | |||
| 1047a0b5fe | |||
| 573f50acc1 | |||
| 24edefa242 |
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
.storybook
|
||||||
|
tests
|
||||||
|
__tests__
|
||||||
|
*.spec.ts
|
||||||
|
*.test.ts
|
||||||
|
node_modules
|
||||||
|
.yarn/cache
|
||||||
|
.eslint
|
||||||
|
.prettier
|
||||||
|
.stylelint
|
||||||
|
.env
|
||||||
|
.idea
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
README.md
|
||||||
|
*.log
|
||||||
|
test
|
||||||
|
docs
|
||||||
|
coverage
|
||||||
|
*.map
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://your.api/api
|
||||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
FROM node:lts-alpine AS base
|
||||||
|
|
||||||
|
# Install dependencies only when needed
|
||||||
|
FROM base AS deps
|
||||||
|
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
COPY .yarn ./.yarn
|
||||||
|
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .yarnrc.yml ./
|
||||||
|
|
||||||
|
RUN yarn && rm -rf .yarn/cache .yarn/unplugged .yarn/build-state.yml
|
||||||
|
|
||||||
|
|
||||||
|
# Rebuild the source code only when needed
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
# Production image, copy all the files and run next
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@ -7,10 +7,11 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"generate-client": "openapi-ts && prettier --write ./src/client/**/*.ts && git add ./src/client"
|
"generate-client": "openapi-ts && prettier --write ./src/lib/client/**/*.ts && git add ./src/lib/client"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/modifiers": "^9.0.0",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@mantine/core": "8.1.2",
|
"@mantine/core": "8.1.2",
|
||||||
"@mantine/form": "^8.1.3",
|
"@mantine/form": "^8.1.3",
|
||||||
@ -35,6 +36,7 @@
|
|||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"redux-persist": "^6.0.0",
|
"redux-persist": "^6.0.0",
|
||||||
"sharp": "^0.34.3",
|
"sharp": "^0.34.3",
|
||||||
|
"swiper": "^11.2.10",
|
||||||
"zod": "^4.0.14"
|
"zod": "^4.0.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -56,7 +58,9 @@
|
|||||||
"@types/node": "^22.13.11",
|
"@types/node": "^22.13.11",
|
||||||
"@types/react": "19.1.8",
|
"@types/react": "19.1.8",
|
||||||
"@types/react-redux": "^7.1.34",
|
"@types/react-redux": "^7.1.34",
|
||||||
|
"@types/react-slick": "^0",
|
||||||
"@types/redux-persist": "^4.3.1",
|
"@types/redux-persist": "^4.3.1",
|
||||||
|
"@types/slick-carousel": "^1",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"babel-loader": "^10.0.0",
|
"babel-loader": "^10.0.0",
|
||||||
"eslint": "^9.29.0",
|
"eslint": "^9.29.0",
|
||||||
|
|||||||
@ -1,13 +0,0 @@
|
|||||||
import React, { FC } from "react";
|
|
||||||
import { Box } from "@mantine/core";
|
|
||||||
import { BoardSchema } from "@/lib/client";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
board: BoardSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Board: FC<Props> = ({ board }) => {
|
|
||||||
return <Box miw={100} style={{ borderWidth: 1, margin: 0 }}>{board.name}</Box>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Board;
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { ScrollArea } from "@mantine/core";
|
|
||||||
import Board from "@/app/deals/components/Board/Board";
|
|
||||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
|
||||||
import { BoardSchema } from "@/lib/client";
|
|
||||||
import { updateBoardMutation } from "@/lib/client/@tanstack/react-query.gen";
|
|
||||||
import SortableDnd from "@/components/SortableDnd";
|
|
||||||
import { notifications } from "@/lib/notifications";
|
|
||||||
|
|
||||||
const Boards = () => {
|
|
||||||
const { boards, setSelectedBoard, refetchBoards } = useBoardsContext();
|
|
||||||
|
|
||||||
const updateBoard = useMutation({
|
|
||||||
...updateBoardMutation(),
|
|
||||||
onError: error => {
|
|
||||||
console.error(error);
|
|
||||||
notifications.error({
|
|
||||||
message: error.response?.data?.detail as string | undefined,
|
|
||||||
});
|
|
||||||
refetchBoards();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderBoard = (board: BoardSchema) => {
|
|
||||||
return <Board board={board} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDragEnd = (itemId: number, newLexorank: string) => {
|
|
||||||
updateBoard.mutate({
|
|
||||||
path: {
|
|
||||||
boardId: itemId,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
board: {
|
|
||||||
lexorank: newLexorank,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectBoard = (board: BoardSchema) => {
|
|
||||||
setSelectedBoard(board);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea
|
|
||||||
offsetScrollbars={"x"}
|
|
||||||
scrollbars={"x"}
|
|
||||||
scrollbarSize={0}
|
|
||||||
w={"100%"}>
|
|
||||||
<SortableDnd
|
|
||||||
initialItems={boards}
|
|
||||||
renderItem={renderBoard}
|
|
||||||
onDragEnd={onDragEnd}
|
|
||||||
onItemClick={selectBoard}
|
|
||||||
rowStyle={{ flexWrap: "nowrap" }}
|
|
||||||
/>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Boards;
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { Card } from "@mantine/core";
|
|
||||||
import { DealSchema } from "@/lib/client";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
deal: DealSchema;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DealCard = ({ deal }: Props) => {
|
|
||||||
return <Card>{deal.name}</Card>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DealCard;
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core";
|
|
||||||
import DealCard from "@/app/deals/components/DealCard/DealCard";
|
|
||||||
import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
|
|
||||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
|
||||||
import { DealSchema, StatusSchema } from "@/lib/client";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
activeDeal: DealSchema | null;
|
|
||||||
activeStatus: StatusSchema | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DndOverlay = ({ activeStatus, activeDeal }: Props) => {
|
|
||||||
const { deals } = useStatusesContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DragOverlay dropAnimation={defaultDropAnimation}>
|
|
||||||
<div style={{ cursor: "grabbing" }}>
|
|
||||||
{activeDeal ? (
|
|
||||||
<DealCard deal={activeDeal} />
|
|
||||||
) : activeStatus ? (
|
|
||||||
<StatusColumn
|
|
||||||
id={`${activeStatus.id}-status`}
|
|
||||||
status={activeStatus}
|
|
||||||
deals={deals.filter(
|
|
||||||
deal => deal.statusId === activeStatus.id
|
|
||||||
)}
|
|
||||||
isDragging
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DragOverlay>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DndOverlay;
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Group } from "@mantine/core";
|
|
||||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
|
||||||
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
|
|
||||||
|
|
||||||
const Header = () => {
|
|
||||||
const { projects, setSelectedProject, selectedProject } =
|
|
||||||
useProjectsContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
justify={"flex-end"}
|
|
||||||
w={"100%"}>
|
|
||||||
<ProjectSelect
|
|
||||||
data={projects}
|
|
||||||
value={selectedProject}
|
|
||||||
onChange={value => value && setSelectedProject(value)}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: React.ReactNode;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SortableItem = ({ children, id }: Props) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
isDragging,
|
|
||||||
} = useSortable({ id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
transition,
|
|
||||||
opacity: isDragging ? 0 : 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SortableItem;
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import React, { useMemo } from "react";
|
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { Box, Stack, Text } from "@mantine/core";
|
|
||||||
import DealContainer from "@/app/deals/components/DealContainer/DealContainer";
|
|
||||||
import { DealSchema, StatusSchema } from "@/lib/client";
|
|
||||||
import { sortByLexorank } from "@/utils/lexorank";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
id: string;
|
|
||||||
status: StatusSchema;
|
|
||||||
deals: DealSchema[];
|
|
||||||
isDragging?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusColumn = ({ id, status, deals, isDragging }: Props) => {
|
|
||||||
const { setNodeRef } = useDroppable({ id });
|
|
||||||
const sortedDeals = useMemo(
|
|
||||||
() => sortByLexorank(deals.filter(deal => deal.statusId === status.id)),
|
|
||||||
[deals]
|
|
||||||
);
|
|
||||||
|
|
||||||
const columnBody = useMemo(() => {
|
|
||||||
return (
|
|
||||||
<SortableContext
|
|
||||||
id={id}
|
|
||||||
items={sortedDeals}
|
|
||||||
strategy={verticalListSortingStrategy}>
|
|
||||||
<Stack
|
|
||||||
gap={"xs"}
|
|
||||||
ref={setNodeRef}>
|
|
||||||
{sortedDeals.map(deal => (
|
|
||||||
<DealContainer
|
|
||||||
key={deal.id}
|
|
||||||
deal={deal}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</SortableContext>
|
|
||||||
);
|
|
||||||
}, [sortedDeals]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#eee",
|
|
||||||
padding: 2,
|
|
||||||
width: "15vw",
|
|
||||||
minWidth: 150,
|
|
||||||
}}>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
cursor: "grab",
|
|
||||||
userSelect: "none",
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
}}>
|
|
||||||
{status.name}
|
|
||||||
</Text>
|
|
||||||
{columnBody}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatusColumn;
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import StatusColumnsDnd from "@/app/deals/components/StatusColumnsDnd/StatusColumnsDnd";
|
|
||||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
|
||||||
import {
|
|
||||||
updateDealMutation,
|
|
||||||
updateStatusMutation,
|
|
||||||
} from "@/lib/client/@tanstack/react-query.gen";
|
|
||||||
import { notifications } from "@/lib/notifications";
|
|
||||||
|
|
||||||
const StatusColumns = () => {
|
|
||||||
const { refetchStatuses, refetchDeals } = useStatusesContext();
|
|
||||||
|
|
||||||
const updateStatus = useMutation({
|
|
||||||
...updateStatusMutation(),
|
|
||||||
onError: error => {
|
|
||||||
console.error(error);
|
|
||||||
notifications.error({
|
|
||||||
message: error.response?.data?.detail as string | undefined,
|
|
||||||
});
|
|
||||||
refetchStatuses();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateDeals = useMutation({
|
|
||||||
...updateDealMutation(),
|
|
||||||
onError: error => {
|
|
||||||
console.error(error);
|
|
||||||
notifications.error({
|
|
||||||
message: error.response?.data?.detail as string | undefined,
|
|
||||||
});
|
|
||||||
refetchDeals();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const onDealDragEnd = (
|
|
||||||
dealId: number,
|
|
||||||
statusId: number,
|
|
||||||
lexorank?: string
|
|
||||||
) => {
|
|
||||||
updateDeals.mutate({
|
|
||||||
path: {
|
|
||||||
dealId,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
deal: {
|
|
||||||
statusId,
|
|
||||||
lexorank,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const onStatusDragEnd = (statusId: number, lexorank: string) => {
|
|
||||||
updateStatus.mutate({
|
|
||||||
path: {
|
|
||||||
statusId,
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
status: {
|
|
||||||
lexorank,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatusColumnsDnd
|
|
||||||
onDealDragEnd={onDealDragEnd}
|
|
||||||
onStatusDragEnd={onStatusDragEnd}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatusColumns;
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, { FC } from "react";
|
|
||||||
import { closestCorners, DndContext } from "@dnd-kit/core";
|
|
||||||
import {
|
|
||||||
horizontalListSortingStrategy,
|
|
||||||
SortableContext,
|
|
||||||
} from "@dnd-kit/sortable";
|
|
||||||
import { Group, ScrollArea } from "@mantine/core";
|
|
||||||
import DndOverlay from "@/app/deals/components/DndOverlay/DndOverlay";
|
|
||||||
import StatusColumn from "@/app/deals/components/StatusColumn/StatusColumn";
|
|
||||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
|
||||||
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
|
|
||||||
import { SortableItem } from "@/components/SortableDnd/SortableItem";
|
|
||||||
import useDndSensors from "../../hooks/useSensors";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
onDealDragEnd: (
|
|
||||||
dealId: number,
|
|
||||||
statusId: number,
|
|
||||||
lexorank?: string
|
|
||||||
) => void;
|
|
||||||
onStatusDragEnd: (statusId: number, lexorank: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const StatusColumnsDnd: FC<Props> = props => {
|
|
||||||
const { deals } = useStatusesContext();
|
|
||||||
|
|
||||||
const {
|
|
||||||
sortedStatuses,
|
|
||||||
handleDragStart,
|
|
||||||
handleDragOver,
|
|
||||||
handleDragEnd,
|
|
||||||
activeStatus,
|
|
||||||
activeDeal,
|
|
||||||
} = useDealsAndStatusesDnd(props);
|
|
||||||
|
|
||||||
const sensors = useDndSensors();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea
|
|
||||||
offsetScrollbars={"x"}
|
|
||||||
scrollbarSize={"0.5rem"}>
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCorners}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}>
|
|
||||||
<SortableContext
|
|
||||||
items={sortedStatuses.map(status => `${status.id}-status`)}
|
|
||||||
strategy={horizontalListSortingStrategy}>
|
|
||||||
<Group
|
|
||||||
gap={"xs"}
|
|
||||||
wrap={"nowrap"}
|
|
||||||
align={"start"}>
|
|
||||||
{sortedStatuses.map(status => (
|
|
||||||
<SortableItem
|
|
||||||
key={status.id}
|
|
||||||
id={`${status.id}-status`}>
|
|
||||||
<StatusColumn
|
|
||||||
id={`${status.id}-status`}
|
|
||||||
status={status}
|
|
||||||
deals={deals}
|
|
||||||
isDragging={activeStatus?.id === status.id}
|
|
||||||
/>
|
|
||||||
</SortableItem>
|
|
||||||
))}
|
|
||||||
<DndOverlay
|
|
||||||
activeStatus={activeStatus}
|
|
||||||
activeDeal={activeDeal}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default StatusColumnsDnd;
|
|
||||||
7
src/app/deals/components/desktop/Board/Board.module.css
Normal file
7
src/app/deals/components/desktop/Board/Board.module.css
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
|
||||||
|
.board {
|
||||||
|
min-width: 130px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 3px;
|
||||||
|
|
||||||
|
}
|
||||||
67
src/app/deals/components/desktop/Board/Board.tsx
Normal file
67
src/app/deals/components/desktop/Board/Board.tsx
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import React, { FC, useState } from "react";
|
||||||
|
import { Box, Flex, Group, Text } from "@mantine/core";
|
||||||
|
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
|
||||||
|
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
import styles from "./Board.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
board: BoardSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Board: FC<Props> = ({ board }) => {
|
||||||
|
const { selectedBoard } = useBoardsContext();
|
||||||
|
const isSelected = selectedBoard?.id === board.id;
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const { onUpdateBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex>
|
||||||
|
<SmallPageBlock
|
||||||
|
style={{
|
||||||
|
borderBottomColor: "dodgerblue",
|
||||||
|
borderBottomWidth: isSelected ? "3px" : undefined,
|
||||||
|
}}
|
||||||
|
active={isSelected}>
|
||||||
|
<Group
|
||||||
|
px={"md"}
|
||||||
|
py={"xs"}
|
||||||
|
bdrs={"lg"}
|
||||||
|
justify={"space-between"}
|
||||||
|
className={styles.board}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}>
|
||||||
|
<InPlaceInput
|
||||||
|
defaultValue={board.name}
|
||||||
|
onComplete={value =>
|
||||||
|
onUpdateBoard(board.id, { name: value })
|
||||||
|
}
|
||||||
|
inputStyles={{
|
||||||
|
input: {
|
||||||
|
height: 25,
|
||||||
|
minHeight: 25,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
getChildren={startEditing => (
|
||||||
|
<>
|
||||||
|
<Box>
|
||||||
|
<Text>{board.name}</Text>
|
||||||
|
</Box>
|
||||||
|
<BoardMenu
|
||||||
|
isHovered={isHovered}
|
||||||
|
board={board}
|
||||||
|
startEditing={startEditing}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
modalTitle={"Редактирование доски"}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</SmallPageBlock>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Board;
|
||||||
52
src/app/deals/components/desktop/Boards/Boards.tsx
Normal file
52
src/app/deals/components/desktop/Boards/Boards.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Group } from "@mantine/core";
|
||||||
|
import Board from "@/app/deals/components/desktop/Board/Board";
|
||||||
|
import CreateBoardButton from "@/app/deals/components/desktop/CreateBoardButton/CreateBoardButton";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import SortableDnd from "@/components/dnd/SortableDnd";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
const Boards = () => {
|
||||||
|
const { boards, setSelectedBoard, onUpdateBoard } = useBoardsContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const renderBoard = (board: BoardSchema) => <Board board={board} />;
|
||||||
|
|
||||||
|
const onDragEnd = (itemId: number, newLexorank: string) => {
|
||||||
|
onUpdateBoard(itemId, { lexorank: newLexorank });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectBoard = (board: BoardSchema) => {
|
||||||
|
setSelectedBoard(board);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
wrap={"nowrap"}
|
||||||
|
gap={"md"}
|
||||||
|
pr={"sm"}
|
||||||
|
style={{ maxWidth: "100%", overflow: "hidden" }}>
|
||||||
|
<SortableDnd
|
||||||
|
initialItems={boards}
|
||||||
|
renderItem={renderBoard}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onItemClick={selectBoard}
|
||||||
|
containerStyle={{
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
gap: "var(--mantine-spacing-md)",
|
||||||
|
paddingBlock: "var(--mantine-spacing-md)",
|
||||||
|
paddingLeft: "var(--mantine-spacing-md)",
|
||||||
|
}}
|
||||||
|
dragHandleStyle={{ cursor: "pointer" }}
|
||||||
|
disabled={isMobile}
|
||||||
|
swiperEnabled
|
||||||
|
/>
|
||||||
|
<CreateBoardButton />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Boards;
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
|
||||||
|
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||||
|
|
||||||
|
const CreateBoardButton = () => {
|
||||||
|
const { onCreateBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SmallPageBlock style={{ cursor: "pointer" }}>
|
||||||
|
<InPlaceInput
|
||||||
|
placeholder={"Название доски"}
|
||||||
|
onComplete={onCreateBoard}
|
||||||
|
getChildren={startEditing => (
|
||||||
|
<Box
|
||||||
|
p={"xs"}
|
||||||
|
onClick={startEditing}>
|
||||||
|
<IconPlus />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
modalTitle={"Создание доски"}
|
||||||
|
inputStyles={{
|
||||||
|
wrapper: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 15,
|
||||||
|
marginRight: 15,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SmallPageBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateBoardButton;
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
.board-mobile {
|
||||||
|
min-width: 50px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 3px;
|
||||||
|
border-top-left-radius: 15px;
|
||||||
|
border-top-right-radius: 15px;
|
||||||
|
border-bottom: 2px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.board-mobile-selected {
|
||||||
|
border: 2px solid gray;
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
29
src/app/deals/components/mobile/BoardMobile/BoardMobile.tsx
Normal file
29
src/app/deals/components/mobile/BoardMobile/BoardMobile.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Box, Text } from "@mantine/core";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
import styles from "./BoardMobile.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
board: BoardSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BoardMobile: FC<Props> = ({ board }) => {
|
||||||
|
const { selectedBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
px={"md"}
|
||||||
|
py={"xs"}
|
||||||
|
className={classNames(
|
||||||
|
styles["board-mobile"],
|
||||||
|
selectedBoard?.id === board.id &&
|
||||||
|
styles["board-mobile-selected"]
|
||||||
|
)}>
|
||||||
|
<Text style={{ textWrap: "nowrap" }}>{board.name}</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoardMobile;
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { Group, ScrollArea } from "@mantine/core";
|
||||||
|
import BoardMobile from "@/app/deals/components/mobile/BoardMobile/BoardMobile";
|
||||||
|
import CreateBoardButtonMobile from "@/app/deals/components/mobile/CreateBoardButtonMobile/CreateBoardButtonMobile";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import SortableDnd from "@/components/dnd/SortableDnd";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
const BoardsMobile = () => {
|
||||||
|
const { boards, setSelectedBoard, onUpdateBoard } = useBoardsContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const renderBoard = (board: BoardSchema) => <BoardMobile board={board} />;
|
||||||
|
|
||||||
|
const onDragEnd = (itemId: number, newLexorank: string) => {
|
||||||
|
onUpdateBoard(itemId, { lexorank: newLexorank });
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectBoard = (board: BoardSchema) => {
|
||||||
|
setSelectedBoard(board);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollArea
|
||||||
|
offsetScrollbars={"x"}
|
||||||
|
scrollbars={"x"}
|
||||||
|
scrollbarSize={0}
|
||||||
|
w={"100vw"}
|
||||||
|
mt={5}
|
||||||
|
mb={"sm"}>
|
||||||
|
<Group
|
||||||
|
wrap={"nowrap"}
|
||||||
|
gap={0}>
|
||||||
|
<SortableDnd
|
||||||
|
initialItems={boards}
|
||||||
|
renderItem={renderBoard}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onItemClick={selectBoard}
|
||||||
|
containerStyle={{ flexWrap: "nowrap" }}
|
||||||
|
dragHandleStyle={{ cursor: "pointer" }}
|
||||||
|
disabled={isMobile}
|
||||||
|
/>
|
||||||
|
<CreateBoardButtonMobile />
|
||||||
|
</Group>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoardsMobile;
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
|
||||||
|
.create-button {
|
||||||
|
padding: 8px 10px 9px;
|
||||||
|
border-bottom: 2px solid gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spacer {
|
||||||
|
height: 43px;
|
||||||
|
border-bottom: 2px solid gray;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import { Box, Space } from "@mantine/core";
|
||||||
|
import styles from "@/app/deals/components/mobile/CreateBoardButtonMobile/CreateBoardButtonMobile.module.css";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||||
|
|
||||||
|
const CreateBoardButtonMobile = () => {
|
||||||
|
const { onCreateBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<InPlaceInput
|
||||||
|
placeholder={"Название доски"}
|
||||||
|
onComplete={onCreateBoard}
|
||||||
|
getChildren={startEditing => (
|
||||||
|
<Box
|
||||||
|
onClick={startEditing}
|
||||||
|
className={styles["create-button"]}>
|
||||||
|
<IconPlus />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
modalTitle={"Создание доски"}
|
||||||
|
inputStyles={{
|
||||||
|
wrapper: {
|
||||||
|
marginLeft: 15,
|
||||||
|
marginRight: 15,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Space className={styles.spacer} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateBoardButtonMobile;
|
||||||
55
src/app/deals/components/shared/BoardMenu/BoardMenu.tsx
Normal file
55
src/app/deals/components/shared/BoardMenu/BoardMenu.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Menu, Text } from "@mantine/core";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
board: BoardSchema;
|
||||||
|
startEditing: () => void;
|
||||||
|
isHovered?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BoardMenu: FC<Props> = ({ board, startEditing, isHovered = true }) => {
|
||||||
|
const { selectedBoard, onDeleteBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Target>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
opacity:
|
||||||
|
isHovered || selectedBoard?.id === board.id ? 1 : 0,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<IconDotsVertical />
|
||||||
|
</Box>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startEditing();
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconEdit />
|
||||||
|
<Text>Переименовать</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteBoard(board);
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconTrash />
|
||||||
|
<Text>Удалить</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoardMenu;
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
|
||||||
|
.container {
|
||||||
|
cursor: pointer;
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
width: 80vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-container {
|
||||||
|
border-radius: var(--mantine-spacing-md);
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--color-light-aqua);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import { Box, Center, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
|
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import styles from "./CreateStatusButton.module.css";
|
||||||
|
|
||||||
|
const CreateStatusButton = () => {
|
||||||
|
const { onCreateStatus } = useStatusesContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className={styles.container}>
|
||||||
|
<Box className={styles["inner-container"]}>
|
||||||
|
<InPlaceInput
|
||||||
|
placeholder={"Название колонки"}
|
||||||
|
onComplete={onCreateStatus}
|
||||||
|
getChildren={startEditing => (
|
||||||
|
<Center
|
||||||
|
p={"sm"}
|
||||||
|
onClick={() => startEditing()}>
|
||||||
|
<Group
|
||||||
|
gap={"xs"}
|
||||||
|
wrap={"nowrap"}
|
||||||
|
align={"start"}>
|
||||||
|
<IconPlus />
|
||||||
|
{isMobile && <Text>Добавить</Text>}
|
||||||
|
</Group>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
modalTitle={"Создание колонки"}
|
||||||
|
inputStyles={{
|
||||||
|
wrapper: {
|
||||||
|
paddingInline: "var(--mantine-spacing-md)",
|
||||||
|
paddingBlock: "var(--mantine-spacing-xs)",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateStatusButton;
|
||||||
30
src/app/deals/components/shared/DealCard/DealCard.module.css
Normal file
30
src/app/deals/components/shared/DealCard/DealCard.module.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
|
||||||
|
.container {
|
||||||
|
padding: var(--mantine-spacing-xs);
|
||||||
|
gap: var(--mantine-spacing-xs);
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--color-light-white-blue);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.first-tag {
|
||||||
|
@mixin light {
|
||||||
|
background-color: lightblue;
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: darkslateblue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.second-tag {
|
||||||
|
@mixin light {
|
||||||
|
background-color: lightgray;
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-4);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/deals/components/shared/DealCard/DealCard.tsx
Normal file
26
src/app/deals/components/shared/DealCard/DealCard.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { Card, Group, Pill, Stack, Text } from "@mantine/core";
|
||||||
|
import { DealSchema } from "@/lib/client";
|
||||||
|
import styles from "./DealCard.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
deal: DealSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DealCard = ({ deal }: Props) => {
|
||||||
|
return (
|
||||||
|
<Card className={styles.container}>
|
||||||
|
<Text c={"dodgerblue"}>{deal.name}</Text>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text>Wb электросталь</Text>
|
||||||
|
<Text>19 000 руб.</Text>
|
||||||
|
<Text>130 тов.</Text>
|
||||||
|
</Stack>
|
||||||
|
<Group gap={"xs"}>
|
||||||
|
<Pill className={styles["first-tag"]}>Срочно</Pill>
|
||||||
|
<Pill className={styles["second-tag"]}>Бесплатно</Pill>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DealCard;
|
||||||
@ -1,8 +1,8 @@
|
|||||||
import React, { FC, useMemo } from "react";
|
import React, { FC, useMemo } from "react";
|
||||||
import { Box } from "@mantine/core";
|
import { Box } from "@mantine/core";
|
||||||
import DealCard from "@/app/deals/components/DealCard/DealCard";
|
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
|
||||||
|
import SortableItem from "@/components/dnd/SortableItem";
|
||||||
import { DealSchema } from "@/lib/client";
|
import { DealSchema } from "@/lib/client";
|
||||||
import { SortableItem } from "@/components/SortableDnd/SortableItem";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
deal: DealSchema;
|
deal: DealSchema;
|
||||||
@ -15,9 +15,9 @@ const DealContainer: FC<Props> = ({ deal }) => {
|
|||||||
<Box>
|
<Box>
|
||||||
<SortableItem
|
<SortableItem
|
||||||
dragHandleStyle={{ cursor: "pointer" }}
|
dragHandleStyle={{ cursor: "pointer" }}
|
||||||
id={deal.id}>
|
id={deal.id}
|
||||||
{dealBody}
|
renderItem={() => dealBody}
|
||||||
</SortableItem>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
89
src/app/deals/components/shared/Funnel/Funnel.tsx
Normal file
89
src/app/deals/components/shared/Funnel/Funnel.tsx
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FC, ReactNode } from "react";
|
||||||
|
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 StatusColumnWrapper from "@/app/deals/components/shared/StatusColumnWrapper/StatusColumnWrapper";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||||
|
import useDealsAndStatusesDnd from "@/app/deals/hooks/useDealsAndStatusesDnd";
|
||||||
|
import FunnelDnd from "@/components/dnd/FunnelDnd/FunnelDnd";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import { DealSchema, StatusSchema } from "@/lib/client";
|
||||||
|
import { sortByLexorank } from "@/utils/lexorank";
|
||||||
|
|
||||||
|
const Funnel: FC = () => {
|
||||||
|
const { selectedBoard } = useBoardsContext();
|
||||||
|
const { deals } = useDealsContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const {
|
||||||
|
sortedStatuses,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
activeStatus,
|
||||||
|
activeDeal,
|
||||||
|
swiperRef,
|
||||||
|
} = useDealsAndStatusesDnd();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FunnelDnd
|
||||||
|
containers={sortedStatuses}
|
||||||
|
items={deals}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
swiperRef={swiperRef}
|
||||||
|
getContainerId={(status: StatusSchema) => `${status.id}-status`}
|
||||||
|
getItemsByContainer={(status: StatusSchema, items: DealSchema[]) =>
|
||||||
|
sortByLexorank(
|
||||||
|
items.filter(deal => deal.statusId === status.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderContainer={(
|
||||||
|
status: StatusSchema,
|
||||||
|
funnelColumnComponent: ReactNode,
|
||||||
|
renderDraggable
|
||||||
|
) => (
|
||||||
|
<StatusColumnWrapper
|
||||||
|
status={status}
|
||||||
|
renderHeader={renderDraggable}>
|
||||||
|
{funnelColumnComponent}
|
||||||
|
</StatusColumnWrapper>
|
||||||
|
)}
|
||||||
|
renderContainerHeader={status => (
|
||||||
|
<StatusColumnHeader
|
||||||
|
status={status}
|
||||||
|
isDragging={activeStatus?.id === status.id}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
renderItem={(deal: DealSchema) => (
|
||||||
|
<DealContainer
|
||||||
|
key={deal.id}
|
||||||
|
deal={deal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
activeContainer={activeStatus}
|
||||||
|
activeItem={activeDeal}
|
||||||
|
renderItemOverlay={(deal: DealSchema) => <DealCard deal={deal} />}
|
||||||
|
renderContainerOverlay={(status: StatusSchema, children) => (
|
||||||
|
<StatusColumnWrapper
|
||||||
|
status={status}
|
||||||
|
renderHeader={() => (
|
||||||
|
<StatusColumnHeader
|
||||||
|
status={status}
|
||||||
|
isDragging={activeStatus?.id === status.id}
|
||||||
|
/>
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</StatusColumnWrapper>
|
||||||
|
)}
|
||||||
|
disabledColumns={isMobile}
|
||||||
|
isCreatingContainerEnabled={!!selectedBoard}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Funnel;
|
||||||
73
src/app/deals/components/shared/Header/Header.tsx
Normal file
73
src/app/deals/components/shared/Header/Header.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconChevronLeft, IconSettings } from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import Boards from "@/app/deals/components/desktop/Boards/Boards";
|
||||||
|
import BoardsMobile from "@/app/deals/components/mobile/BoardsMobile/BoardsMobile";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
|
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
|
||||||
|
import { ColorSchemeToggle } from "@/components/ui/ColorSchemeToggle/ColorSchemeToggle";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
|
||||||
|
const Header = () => {
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
setSelectedProject,
|
||||||
|
selectedProject,
|
||||||
|
setIsEditorDrawerOpened: setIsProjectsDrawerOpened,
|
||||||
|
} = useProjectsContext();
|
||||||
|
const { setIsEditorDrawerOpened } = useBoardsContext();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const getDesktopHeader = () => {
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
w={"100%"}
|
||||||
|
justify={"space-between"}
|
||||||
|
wrap={"nowrap"}
|
||||||
|
pr={"md"}>
|
||||||
|
<Boards />
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<ColorSchemeToggle />
|
||||||
|
<ProjectSelect
|
||||||
|
data={projects}
|
||||||
|
value={selectedProject}
|
||||||
|
onChange={value => value && setSelectedProject(value)}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMobileHeader = () => {
|
||||||
|
return (
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Group justify={"space-between"}>
|
||||||
|
<Box
|
||||||
|
p={"md"}
|
||||||
|
onClick={() => setIsProjectsDrawerOpened(true)}>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</Box>
|
||||||
|
<Text>{selectedProject?.name}</Text>
|
||||||
|
<Box
|
||||||
|
p={"md"}
|
||||||
|
onClick={() => setIsEditorDrawerOpened(true)}>
|
||||||
|
<IconSettings />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<BoardsMobile />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify={"flex-end"}
|
||||||
|
w={"100%"}>
|
||||||
|
{isMobile ? getMobileHeader() : getDesktopHeader()}
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Header;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
.header {
|
||||||
|
border-bottom: solid dodgerblue 3px;
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import { Group, Text } from "@mantine/core";
|
||||||
|
import styles from "./StatusColumnHeader.module.css";
|
||||||
|
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
|
||||||
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
|
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||||
|
import { StatusSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: StatusSchema;
|
||||||
|
isDragging: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusColumnHeader: FC<Props> = ({ status, isDragging }) => {
|
||||||
|
const { onUpdateStatus } = useStatusesContext();
|
||||||
|
|
||||||
|
const handleSave = (value: string) => {
|
||||||
|
const newValue = value.trim();
|
||||||
|
if (newValue && newValue !== status.name) {
|
||||||
|
onUpdateStatus(status.id, { name: newValue });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify={"space-between"}
|
||||||
|
p={"sm"}
|
||||||
|
wrap={"nowrap"}
|
||||||
|
mb={"xs"}
|
||||||
|
className={styles.header}>
|
||||||
|
<InPlaceInput
|
||||||
|
defaultValue={status.name}
|
||||||
|
onComplete={value => handleSave(value)}
|
||||||
|
inputStyles={{
|
||||||
|
input: {
|
||||||
|
height: 25,
|
||||||
|
minHeight: 25,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
getChildren={startEditing => (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
cursor: "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
}}>
|
||||||
|
{status.name}
|
||||||
|
</Text>
|
||||||
|
<StatusMenu
|
||||||
|
status={status}
|
||||||
|
handleEdit={startEditing}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
modalTitle={"Редактирование статуса"}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusColumnHeader;
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
.container {
|
||||||
|
height: calc(100vh - 150px);
|
||||||
|
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
width: 80vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner-container {
|
||||||
|
border-radius: var(--mantine-spacing-md);
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--color-light-aqua);
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { Box, ScrollArea, Stack } from "@mantine/core";
|
||||||
|
import { StatusSchema } from "@/lib/client";
|
||||||
|
import styles from "./StatusColumnWrapper.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: StatusSchema;
|
||||||
|
renderHeader: () => ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusColumnWrapper = ({ renderHeader, children }: Props) => {
|
||||||
|
return (
|
||||||
|
<Box className={styles.container}>
|
||||||
|
<Stack
|
||||||
|
px={"xs"}
|
||||||
|
pb={"xs"}
|
||||||
|
className={styles["inner-container"]}>
|
||||||
|
{renderHeader()}
|
||||||
|
<ScrollArea
|
||||||
|
offsetScrollbars={"y"}
|
||||||
|
scrollbars={"y"}>
|
||||||
|
<Stack mah={"calc(100vh - 220px)"}>{children}</Stack>
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusColumnWrapper;
|
||||||
71
src/app/deals/components/shared/StatusMenu/StatusMenu.tsx
Normal file
71
src/app/deals/components/shared/StatusMenu/StatusMenu.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import {
|
||||||
|
IconDotsVertical,
|
||||||
|
IconEdit,
|
||||||
|
IconExchange,
|
||||||
|
IconTrash,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Menu, Text } from "@mantine/core";
|
||||||
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import { StatusSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: StatusSchema;
|
||||||
|
handleEdit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusMenu: FC<Props> = ({ status, handleEdit }) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const { onDeleteStatus, setIsEditorDrawerOpened } = useStatusesContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Target>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<IconDotsVertical />
|
||||||
|
</Box>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleEdit();
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconEdit />
|
||||||
|
<Text>Переименовать</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteStatus(status);
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconTrash />
|
||||||
|
<Text>Удалить</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
{isMobile && (
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsEditorDrawerOpened(true);
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconExchange />
|
||||||
|
<Text>Изменить порядок</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusMenu;
|
||||||
@ -8,8 +8,9 @@ import React, {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
import { BoardSchema } from "@/lib/client";
|
|
||||||
import useBoardsList from "@/hooks/useBoardsList";
|
import useBoardsList from "@/hooks/useBoardsList";
|
||||||
|
import { useBoardsOperations } from "@/hooks/useBoardsOperations";
|
||||||
|
import { BoardSchema, UpdateBoardSchema } from "@/lib/client";
|
||||||
|
|
||||||
type BoardsContextState = {
|
type BoardsContextState = {
|
||||||
boards: BoardSchema[];
|
boards: BoardSchema[];
|
||||||
@ -17,16 +18,27 @@ type BoardsContextState = {
|
|||||||
selectedBoard: BoardSchema | null;
|
selectedBoard: BoardSchema | null;
|
||||||
setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>;
|
setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>;
|
||||||
refetchBoards: () => void;
|
refetchBoards: () => void;
|
||||||
|
onCreateBoard: (name: string) => void;
|
||||||
|
onUpdateBoard: (boardId: number, board: UpdateBoardSchema) => void;
|
||||||
|
onDeleteBoard: (board: BoardSchema) => void;
|
||||||
|
isEditorDrawerOpened: boolean;
|
||||||
|
setIsEditorDrawerOpened: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const BoardsContext = createContext<BoardsContextState | undefined>(undefined);
|
const BoardsContext = createContext<BoardsContextState | undefined>(undefined);
|
||||||
|
|
||||||
const useBoardsContextState = () => {
|
const useBoardsContextState = () => {
|
||||||
const { selectedProject: project } = useProjectsContext();
|
const { selectedProject: project } = useProjectsContext();
|
||||||
const { boards, setBoards, refetch: refetchBoards } = useBoardsList({ projectId: project?.id });
|
const {
|
||||||
|
boards,
|
||||||
|
setBoards,
|
||||||
|
refetch: refetchBoards,
|
||||||
|
} = useBoardsList({ projectId: project?.id });
|
||||||
const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>(
|
const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boards.length > 0 && selectedBoard === null) {
|
if (boards.length > 0 && selectedBoard === null) {
|
||||||
@ -34,22 +46,36 @@ const useBoardsContextState = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedBoard) {
|
if (!selectedBoard) return;
|
||||||
let newBoard = boards.find(board => board.id === selectedBoard.id);
|
|
||||||
|
|
||||||
if (!newBoard && boards.length > 0) {
|
let newBoard = boards.find(board => board.id === selectedBoard.id);
|
||||||
newBoard = boards[0];
|
|
||||||
}
|
if (!newBoard && boards.length > 0) {
|
||||||
setSelectedBoard(newBoard ?? null);
|
newBoard = boards[0];
|
||||||
}
|
}
|
||||||
|
setSelectedBoard(newBoard ?? null);
|
||||||
}, [boards]);
|
}, [boards]);
|
||||||
|
|
||||||
|
const { onCreateBoard, onUpdateBoard, onDeleteBoard } = useBoardsOperations(
|
||||||
|
{
|
||||||
|
boards,
|
||||||
|
setBoards,
|
||||||
|
refetchBoards,
|
||||||
|
projectId: project?.id,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
boards,
|
boards,
|
||||||
setBoards,
|
setBoards,
|
||||||
selectedBoard,
|
selectedBoard,
|
||||||
setSelectedBoard,
|
setSelectedBoard,
|
||||||
refetchBoards,
|
refetchBoards,
|
||||||
|
onCreateBoard,
|
||||||
|
onUpdateBoard,
|
||||||
|
onDeleteBoard,
|
||||||
|
isEditorDrawerOpened,
|
||||||
|
setIsEditorDrawerOpened,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
80
src/app/deals/contexts/DealsContext.tsx
Normal file
80
src/app/deals/contexts/DealsContext.tsx
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, FC, useContext } from "react";
|
||||||
|
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import useDealsList from "@/hooks/useDealsList";
|
||||||
|
import {
|
||||||
|
DealSchema,
|
||||||
|
HttpValidationError,
|
||||||
|
Options,
|
||||||
|
UpdateDealData,
|
||||||
|
UpdateDealResponse,
|
||||||
|
} from "@/lib/client";
|
||||||
|
import { updateDealMutation } from "@/lib/client/@tanstack/react-query.gen";
|
||||||
|
import { notifications } from "@/lib/notifications";
|
||||||
|
|
||||||
|
type DealsContextState = {
|
||||||
|
deals: DealSchema[];
|
||||||
|
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>;
|
||||||
|
updateDeal: UseMutationResult<
|
||||||
|
UpdateDealResponse,
|
||||||
|
AxiosError<HttpValidationError>,
|
||||||
|
Options<UpdateDealData>
|
||||||
|
>;
|
||||||
|
refetchDeals: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DealsContext = createContext<DealsContextState | undefined>(undefined);
|
||||||
|
|
||||||
|
const useDealsContextState = () => {
|
||||||
|
const { selectedBoard } = useBoardsContext();
|
||||||
|
const {
|
||||||
|
deals,
|
||||||
|
setDeals,
|
||||||
|
refetch: refetchDeals,
|
||||||
|
} = useDealsList({ boardId: selectedBoard?.id });
|
||||||
|
|
||||||
|
const updateDeal = useMutation({
|
||||||
|
...updateDealMutation(),
|
||||||
|
onError: error => {
|
||||||
|
console.error(error);
|
||||||
|
notifications.error({
|
||||||
|
message: error.response?.data?.detail as string | undefined,
|
||||||
|
});
|
||||||
|
refetchDeals();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
deals,
|
||||||
|
setDeals,
|
||||||
|
updateDeal,
|
||||||
|
refetchDeals,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type DealsContextProviderProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DealsContextProvider: FC<DealsContextProviderProps> = ({
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
const state = useDealsContextState();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DealsContext.Provider value={state}>{children}</DealsContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDealsContext = () => {
|
||||||
|
const context = useContext(DealsContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error(
|
||||||
|
"useDealsContext must be used within a DealsContextProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
@ -7,8 +7,9 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { ProjectSchema } from "@/lib/client";
|
|
||||||
import useProjectsList from "@/hooks/useProjectsList";
|
import useProjectsList from "@/hooks/useProjectsList";
|
||||||
|
import { useProjectsOperations } from "@/hooks/useProjectsOperations";
|
||||||
|
import { ProjectSchema, UpdateProjectSchema } from "@/lib/client";
|
||||||
|
|
||||||
type ProjectsContextState = {
|
type ProjectsContextState = {
|
||||||
selectedProject: ProjectSchema | null;
|
selectedProject: ProjectSchema | null;
|
||||||
@ -16,6 +17,11 @@ type ProjectsContextState = {
|
|||||||
React.SetStateAction<ProjectSchema | null>
|
React.SetStateAction<ProjectSchema | null>
|
||||||
>;
|
>;
|
||||||
projects: ProjectSchema[];
|
projects: ProjectSchema[];
|
||||||
|
onCreateProject: (name: string) => void;
|
||||||
|
onUpdateProject: (projectId: number, project: UpdateProjectSchema) => void;
|
||||||
|
onDeleteProject: (project: ProjectSchema) => void;
|
||||||
|
isEditorDrawerOpened: boolean;
|
||||||
|
setIsEditorDrawerOpened: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ProjectsContext = createContext<ProjectsContextState | undefined>(
|
const ProjectsContext = createContext<ProjectsContextState | undefined>(
|
||||||
@ -23,7 +29,13 @@ const ProjectsContext = createContext<ProjectsContextState | undefined>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const useProjectsContextState = () => {
|
const useProjectsContextState = () => {
|
||||||
const { projects } = useProjectsList();
|
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const {
|
||||||
|
projects,
|
||||||
|
setProjects,
|
||||||
|
refetch: refetchProjects,
|
||||||
|
} = useProjectsList();
|
||||||
const [selectedProject, setSelectedProject] =
|
const [selectedProject, setSelectedProject] =
|
||||||
useState<ProjectSchema | null>(null);
|
useState<ProjectSchema | null>(null);
|
||||||
|
|
||||||
@ -43,10 +55,22 @@ const useProjectsContextState = () => {
|
|||||||
setSelectedProject(null);
|
setSelectedProject(null);
|
||||||
}, [projects]);
|
}, [projects]);
|
||||||
|
|
||||||
|
const { onCreateProject, onUpdateProject, onDeleteProject } =
|
||||||
|
useProjectsOperations({
|
||||||
|
projects,
|
||||||
|
setProjects,
|
||||||
|
refetchProjects,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects,
|
projects,
|
||||||
selectedProject,
|
selectedProject,
|
||||||
setSelectedProject,
|
setSelectedProject,
|
||||||
|
onCreateProject,
|
||||||
|
onUpdateProject,
|
||||||
|
onDeleteProject,
|
||||||
|
isEditorDrawerOpened,
|
||||||
|
setIsEditorDrawerOpened,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,18 +1,42 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, FC, useContext, useEffect } from "react";
|
import React, {
|
||||||
|
createContext,
|
||||||
|
FC,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useMutation, UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
import { DealSchema, StatusSchema } from "@/lib/client";
|
|
||||||
import useDealsList from "@/hooks/useDealsList";
|
|
||||||
import useStatusesList from "@/hooks/useStatusesList";
|
import useStatusesList from "@/hooks/useStatusesList";
|
||||||
|
import { useStatusesOperations } from "@/hooks/useStatusesOperations";
|
||||||
|
import {
|
||||||
|
HttpValidationError,
|
||||||
|
Options,
|
||||||
|
StatusSchema,
|
||||||
|
UpdateStatusData,
|
||||||
|
UpdateStatusResponse,
|
||||||
|
UpdateStatusSchema,
|
||||||
|
} from "@/lib/client";
|
||||||
|
import { updateStatusMutation } from "@/lib/client/@tanstack/react-query.gen";
|
||||||
|
import { notifications } from "@/lib/notifications";
|
||||||
|
|
||||||
type StatusesContextState = {
|
type StatusesContextState = {
|
||||||
statuses: StatusSchema[];
|
statuses: StatusSchema[];
|
||||||
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
|
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
|
||||||
deals: DealSchema[];
|
updateStatus: UseMutationResult<
|
||||||
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>;
|
UpdateStatusResponse,
|
||||||
|
AxiosError<HttpValidationError>,
|
||||||
|
Options<UpdateStatusData>
|
||||||
|
>;
|
||||||
refetchStatuses: () => void;
|
refetchStatuses: () => void;
|
||||||
refetchDeals: () => void;
|
onCreateStatus: (name: string) => void;
|
||||||
|
onUpdateStatus: (statusId: number, status: UpdateStatusSchema) => void;
|
||||||
|
onDeleteStatus: (status: StatusSchema) => void;
|
||||||
|
isEditorDrawerOpened: boolean;
|
||||||
|
setIsEditorDrawerOpened: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const StatusesContext = createContext<StatusesContextState | undefined>(
|
const StatusesContext = createContext<StatusesContextState | undefined>(
|
||||||
@ -28,24 +52,42 @@ const useStatusesContextState = () => {
|
|||||||
} = useStatusesList({
|
} = useStatusesList({
|
||||||
boardId: selectedBoard?.id,
|
boardId: selectedBoard?.id,
|
||||||
});
|
});
|
||||||
|
const [isEditorDrawerOpened, setIsEditorDrawerOpened] =
|
||||||
const {
|
useState<boolean>(false);
|
||||||
deals,
|
|
||||||
setDeals,
|
|
||||||
refetch: refetchDeals,
|
|
||||||
} = useDealsList({ boardId: selectedBoard?.id });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refetchStatuses();
|
refetchStatuses();
|
||||||
}, [selectedBoard]);
|
}, [selectedBoard]);
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
...updateStatusMutation(),
|
||||||
|
onError: error => {
|
||||||
|
console.error(error);
|
||||||
|
notifications.error({
|
||||||
|
message: error.response?.data?.detail as string | undefined,
|
||||||
|
});
|
||||||
|
refetchStatuses();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { onCreateStatus, onUpdateStatus, onDeleteStatus } =
|
||||||
|
useStatusesOperations({
|
||||||
|
statuses,
|
||||||
|
setStatuses,
|
||||||
|
refetchStatuses,
|
||||||
|
boardId: selectedBoard?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statuses,
|
statuses,
|
||||||
setStatuses,
|
setStatuses,
|
||||||
deals,
|
updateStatus,
|
||||||
setDeals,
|
|
||||||
refetchStatuses,
|
refetchStatuses,
|
||||||
refetchDeals,
|
onCreateStatus,
|
||||||
|
onUpdateStatus,
|
||||||
|
onDeleteStatus,
|
||||||
|
isEditorDrawerOpened,
|
||||||
|
setIsEditorDrawerOpened,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FC, ReactNode } from "react";
|
||||||
|
import { IconChevronLeft, IconGripVertical } from "@tabler/icons-react";
|
||||||
|
import { Box, Center, Divider, Drawer, Group, rem, Text } from "@mantine/core";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
|
import CreateStatusButton from "@/app/deals/drawers/BoardStatusesEditorDrawer/components/CreateStatusButton";
|
||||||
|
import StatusMobile from "@/app/deals/drawers/BoardStatusesEditorDrawer/components/StatusMobile";
|
||||||
|
import SortableDnd from "@/components/dnd/SortableDnd";
|
||||||
|
import { StatusSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
const BoardStatusesEditorDrawer: FC = () => {
|
||||||
|
const {
|
||||||
|
statuses,
|
||||||
|
onUpdateStatus,
|
||||||
|
isEditorDrawerOpened,
|
||||||
|
setIsEditorDrawerOpened,
|
||||||
|
} = useStatusesContext();
|
||||||
|
const { selectedBoard } = useBoardsContext();
|
||||||
|
const onClose = () => setIsEditorDrawerOpened(false);
|
||||||
|
|
||||||
|
const renderDraggable = () => (
|
||||||
|
<Box p={"xs"}>
|
||||||
|
<IconGripVertical />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderStatus = (
|
||||||
|
status: StatusSchema,
|
||||||
|
renderDraggable?: (item: StatusSchema) => ReactNode
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
{renderDraggable && renderDraggable(status)}
|
||||||
|
<StatusMobile status={status} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (itemId: number, newLexorank: string) => {
|
||||||
|
onUpdateStatus(itemId, { lexorank: newLexorank });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
size={"100%"}
|
||||||
|
position={"right"}
|
||||||
|
onClose={onClose}
|
||||||
|
removeScrollProps={{ allowPinchZoom: true }}
|
||||||
|
withCloseButton={false}
|
||||||
|
opened={isEditorDrawerOpened}
|
||||||
|
trapFocus={false}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: rem(10),
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Group justify={"space-between"}>
|
||||||
|
<Box
|
||||||
|
onClick={onClose}
|
||||||
|
p={"xs"}>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</Box>
|
||||||
|
<Center>
|
||||||
|
<Text>{selectedBoard?.name}</Text>
|
||||||
|
</Center>
|
||||||
|
<Box p={"lg"} />
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<SortableDnd
|
||||||
|
initialItems={statuses}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
renderItem={renderStatus}
|
||||||
|
renderDraggable={renderDraggable}
|
||||||
|
dragHandleStyle={{ width: "auto" }}
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
<CreateStatusButton />
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoardStatusesEditorDrawer;
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
|
|
||||||
|
const CreateStatusButton = () => {
|
||||||
|
const { onCreateStatus } = useStatusesContext();
|
||||||
|
|
||||||
|
const onStartCreating = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: "Создание колонки",
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete: onCreateStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
ml={"xs"}
|
||||||
|
onClick={onStartCreating}>
|
||||||
|
<IconPlus />
|
||||||
|
<Box mt={5}>
|
||||||
|
<Text>Создать колонку</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateStatusButton;
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
|
||||||
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
|
import { StatusSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: StatusSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StatusMobile: FC<Props> = ({ status }) => {
|
||||||
|
const { onUpdateStatus } = useStatusesContext();
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: "Редактирование статуса",
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete: name => onUpdateStatus(status.id, { name }),
|
||||||
|
defaultValue: status.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
w={"100%"}
|
||||||
|
pr={"md"}
|
||||||
|
py={"xs"}
|
||||||
|
justify={"space-between"}
|
||||||
|
wrap={"nowrap"}>
|
||||||
|
<Box>
|
||||||
|
<Text>{status.name}</Text>
|
||||||
|
</Box>
|
||||||
|
<BoardMenu
|
||||||
|
board={status}
|
||||||
|
startEditing={startEditing}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StatusMobile;
|
||||||
3
src/app/deals/drawers/BoardStatusesEditorDrawer/index.ts
Normal file
3
src/app/deals/drawers/BoardStatusesEditorDrawer/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import BoardStatusesEditorDrawer from "@/app/deals/drawers/BoardStatusesEditorDrawer/BoardStatusesEditorDrawer";
|
||||||
|
|
||||||
|
export default BoardStatusesEditorDrawer;
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FC, ReactNode } from "react";
|
||||||
|
import { IconChevronLeft, IconGripVertical } from "@tabler/icons-react";
|
||||||
|
import { Box, Center, Divider, Drawer, Group, rem, Text } from "@mantine/core";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
|
import BoardMobile from "@/app/deals/drawers/ProjectBoardsEditorDrawer/components/BoardMobile";
|
||||||
|
import CreateBoardButton from "@/app/deals/drawers/ProjectBoardsEditorDrawer/components/CreateBoardButton";
|
||||||
|
import SortableDnd from "@/components/dnd/SortableDnd";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
const ProjectBoardsEditorDrawer: FC = () => {
|
||||||
|
const {
|
||||||
|
boards,
|
||||||
|
onUpdateBoard,
|
||||||
|
isEditorDrawerOpened,
|
||||||
|
setIsEditorDrawerOpened,
|
||||||
|
} = useBoardsContext();
|
||||||
|
const { selectedProject } = useProjectsContext();
|
||||||
|
const onClose = () => setIsEditorDrawerOpened(false);
|
||||||
|
|
||||||
|
const renderDraggable = () => (
|
||||||
|
<Box p={"xs"}>
|
||||||
|
<IconGripVertical />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderBoard = (
|
||||||
|
board: BoardSchema,
|
||||||
|
renderDraggable?: (item: BoardSchema) => ReactNode
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
{renderDraggable && renderDraggable(board)}
|
||||||
|
<BoardMobile board={board} />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragEnd = (itemId: number, newLexorank: string) => {
|
||||||
|
onUpdateBoard(itemId, { lexorank: newLexorank });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
size={"100%"}
|
||||||
|
position={"right"}
|
||||||
|
onClose={onClose}
|
||||||
|
removeScrollProps={{ allowPinchZoom: true }}
|
||||||
|
withCloseButton={false}
|
||||||
|
opened={isEditorDrawerOpened}
|
||||||
|
trapFocus={false}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: rem(10),
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Group justify={"space-between"}>
|
||||||
|
<Box
|
||||||
|
onClick={onClose}
|
||||||
|
p={"xs"}>
|
||||||
|
<IconChevronLeft />
|
||||||
|
</Box>
|
||||||
|
<Center>
|
||||||
|
<Text>{selectedProject?.name}</Text>
|
||||||
|
</Center>
|
||||||
|
<Box p={"lg"} />
|
||||||
|
</Group>
|
||||||
|
<Divider />
|
||||||
|
<SortableDnd
|
||||||
|
initialItems={boards}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
renderItem={renderBoard}
|
||||||
|
renderDraggable={renderDraggable}
|
||||||
|
dragHandleStyle={{ width: "auto" }}
|
||||||
|
vertical
|
||||||
|
/>
|
||||||
|
<CreateBoardButton />
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectBoardsEditorDrawer;
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
import { BoardSchema } from "@/lib/client";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
board: BoardSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BoardMobile: FC<Props> = ({ board }) => {
|
||||||
|
const { onUpdateBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: "Редактирование доски",
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete: name => onUpdateBoard(board.id, { name }),
|
||||||
|
defaultValue: board.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
w={"100%"}
|
||||||
|
pr={"md"}
|
||||||
|
py={"xs"}
|
||||||
|
justify={"space-between"}
|
||||||
|
wrap={"nowrap"}>
|
||||||
|
<Box>
|
||||||
|
<Text>{board.name}</Text>
|
||||||
|
</Box>
|
||||||
|
<BoardMenu
|
||||||
|
board={board}
|
||||||
|
startEditing={startEditing}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoardMobile;
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||||
|
|
||||||
|
const CreateBoardButton = () => {
|
||||||
|
const { onCreateBoard } = useBoardsContext();
|
||||||
|
|
||||||
|
const onStartCreating = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: "Создание доски",
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete: onCreateBoard,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group ml={"xs"} onClick={onStartCreating}>
|
||||||
|
<IconPlus />
|
||||||
|
<Box mt={5}>
|
||||||
|
<Text>Создать доску</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateBoardButton;
|
||||||
3
src/app/deals/drawers/ProjectBoardsEditorDrawer/index.ts
Normal file
3
src/app/deals/drawers/ProjectBoardsEditorDrawer/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import ProjectBoardsEditorDrawer from "@/app/deals/drawers/ProjectBoardsEditorDrawer/ProjectBoardsEditorDrawer";
|
||||||
|
|
||||||
|
export default ProjectBoardsEditorDrawer;
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
.project:hover {
|
||||||
|
@mixin light {
|
||||||
|
background-color: whitesmoke;
|
||||||
|
}
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-button {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project:hover:has(.menu-button:hover) {
|
||||||
|
background: none; /* or revert to normal */
|
||||||
|
}
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { FC } from "react";
|
||||||
|
import { Center, Divider, Drawer, rem, Stack, Text } from "@mantine/core";
|
||||||
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
|
import CreateProjectButton from "@/app/deals/drawers/ProjectsEditorDrawer/components/CreateProjectButton";
|
||||||
|
import ProjectMobile from "@/app/deals/drawers/ProjectsEditorDrawer/components/ProjectMobile";
|
||||||
|
|
||||||
|
const ProjectsEditorDrawer: FC = () => {
|
||||||
|
const { projects, isEditorDrawerOpened, setIsEditorDrawerOpened } =
|
||||||
|
useProjectsContext();
|
||||||
|
const onClose = () => setIsEditorDrawerOpened(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
size={"100%"}
|
||||||
|
position={"left"}
|
||||||
|
onClose={onClose}
|
||||||
|
removeScrollProps={{ allowPinchZoom: true }}
|
||||||
|
withCloseButton={false}
|
||||||
|
opened={isEditorDrawerOpened}
|
||||||
|
trapFocus={false}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
height: "100%",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: rem(10),
|
||||||
|
},
|
||||||
|
}}>
|
||||||
|
<Center>
|
||||||
|
<Text>Проекты</Text>
|
||||||
|
</Center>
|
||||||
|
<Divider />
|
||||||
|
<Stack gap={0}>
|
||||||
|
{projects.map((project, index) => (
|
||||||
|
<ProjectMobile
|
||||||
|
key={index}
|
||||||
|
project={project}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<CreateProjectButton />
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectsEditorDrawer;
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
|
|
||||||
|
const CreateProjectButton = () => {
|
||||||
|
const { onCreateProject } = useProjectsContext();
|
||||||
|
|
||||||
|
const onStartCreating = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: "Создание проекта",
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete: onCreateProject,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
ml={"xs"}
|
||||||
|
onClick={onStartCreating}>
|
||||||
|
<IconPlus />
|
||||||
|
<Box mt={5}>
|
||||||
|
<Text>Создать проект</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateProjectButton;
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { Box, Group, Menu, Text } from "@mantine/core";
|
||||||
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
|
import { ProjectSchema } from "@/lib/client";
|
||||||
|
import styles from "./../ProjectsEditorDrawer.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
project: ProjectSchema;
|
||||||
|
startEditing: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectMenu: FC<Props> = ({ project, startEditing }) => {
|
||||||
|
const { onDeleteProject } = useProjectsContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Target>
|
||||||
|
<Box
|
||||||
|
className={styles["menu-button"]}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<IconDotsVertical />
|
||||||
|
</Box>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startEditing();
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconEdit />
|
||||||
|
<Text>Переименовать</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteProject(project);
|
||||||
|
}}>
|
||||||
|
<Group wrap={"nowrap"}>
|
||||||
|
<IconTrash />
|
||||||
|
<Text>Удалить</Text>
|
||||||
|
</Group>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectMenu;
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
import React, { FC } from "react";
|
||||||
|
import { Box, Group, Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||||
|
import ProjectMenu from "@/app/deals/drawers/ProjectsEditorDrawer/components/ProjectMenu";
|
||||||
|
import { ProjectSchema } from "@/lib/client";
|
||||||
|
import styles from "./../ProjectsEditorDrawer.module.css";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
project: ProjectSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ProjectMobile: FC<Props> = ({ project }) => {
|
||||||
|
const { onUpdateProject, setSelectedProject, setIsEditorDrawerOpened } =
|
||||||
|
useProjectsContext();
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: "Редактирование проекта",
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete: name => onUpdateProject(project.id, { name }),
|
||||||
|
defaultValue: project.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
setSelectedProject(project);
|
||||||
|
setIsEditorDrawerOpened(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
w={"100%"}
|
||||||
|
pl={"xs"}
|
||||||
|
py={"xs"}
|
||||||
|
justify={"space-between"}
|
||||||
|
wrap={"nowrap"}
|
||||||
|
className={styles.project}
|
||||||
|
onClick={onClick}>
|
||||||
|
<Box>
|
||||||
|
<Text>{project.name}</Text>
|
||||||
|
</Box>
|
||||||
|
<ProjectMenu
|
||||||
|
project={project}
|
||||||
|
startEditing={startEditing}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProjectMobile;
|
||||||
3
src/app/deals/drawers/ProjectsEditorDrawer/index.ts
Normal file
3
src/app/deals/drawers/ProjectsEditorDrawer/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import ProjectsEditorDrawer from "@/app/deals/drawers/ProjectsEditorDrawer/ProjectsEditorDrawer";
|
||||||
|
|
||||||
|
export default ProjectsEditorDrawer;
|
||||||
@ -1,26 +1,33 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { RefObject, useMemo, useRef, useState } from "react";
|
||||||
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core";
|
import { DragOverEvent, DragStartEvent, Over } from "@dnd-kit/core";
|
||||||
|
import { SwiperRef } from "swiper/swiper-react";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
|
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||||
import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
|
import useGetNewRank from "@/app/deals/hooks/useGetNewRank";
|
||||||
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
|
import { getStatusId, isStatusId } from "@/app/deals/utils/statusId";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
import { DealSchema, StatusSchema } from "@/lib/client";
|
import { DealSchema, StatusSchema } from "@/lib/client";
|
||||||
import { sortByLexorank } from "@/utils/lexorank";
|
import { sortByLexorank } from "@/utils/lexorank";
|
||||||
|
|
||||||
type Props = {
|
type ReturnType = {
|
||||||
onDealDragEnd: (
|
sortedStatuses: StatusSchema[];
|
||||||
dealId: number,
|
handleDragStart: ({ active }: DragStartEvent) => void;
|
||||||
statusId: number,
|
handleDragOver: ({ active, over }: DragOverEvent) => void;
|
||||||
lexorank?: string
|
handleDragEnd: ({ active, over }: DragOverEvent) => void;
|
||||||
) => void;
|
activeStatus: StatusSchema | null;
|
||||||
onStatusDragEnd: (statusId: number, lexorank: string) => void;
|
activeDeal: DealSchema | null;
|
||||||
|
swiperRef: RefObject<SwiperRef | null>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useDealsAndStatusesDnd = (props: Props) => {
|
const useDealsAndStatusesDnd = (): ReturnType => {
|
||||||
|
const swiperRef = useRef<SwiperRef>(null);
|
||||||
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
|
const [activeDeal, setActiveDeal] = useState<DealSchema | null>(null);
|
||||||
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
|
const [activeStatus, setActiveStatus] = useState<StatusSchema | null>(null);
|
||||||
const { statuses, deals, setDeals, setStatuses } = useStatusesContext();
|
const { statuses, setStatuses, updateStatus } = useStatusesContext();
|
||||||
|
const { deals, setDeals, updateDeal } = useDealsContext();
|
||||||
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
|
const sortedStatuses = useMemo(() => sortByLexorank(statuses), [statuses]);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
getNewRankForSameStatus,
|
getNewRankForSameStatus,
|
||||||
@ -37,10 +44,55 @@ const useDealsAndStatusesDnd = (props: Props) => {
|
|||||||
return statuses.find(status => status.id === deal.statusId);
|
return statuses.find(status => status.id === deal.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) => {
|
const handleDragOver = ({ active, over }: DragOverEvent) => {
|
||||||
if (!over) return;
|
if (!over) return;
|
||||||
const activeId = active.id as string | number;
|
const activeId = active.id as string | number;
|
||||||
|
|
||||||
|
if (isMobile && typeof activeId !== "string") {
|
||||||
|
swipeSliderDuringDrag(activeId, over);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof activeId === "string" && isStatusId(activeId)) {
|
if (typeof activeId === "string" && isStatusId(activeId)) {
|
||||||
handleColumnDragOver(activeId, over);
|
handleColumnDragOver(activeId, over);
|
||||||
return;
|
return;
|
||||||
@ -173,7 +225,20 @@ const useDealsAndStatusesDnd = (props: Props) => {
|
|||||||
const newRank = getNewStatusRank(activeStatusId, overStatusId);
|
const newRank = getNewStatusRank(activeStatusId, overStatusId);
|
||||||
if (!newRank) return;
|
if (!newRank) return;
|
||||||
|
|
||||||
props.onStatusDragEnd?.(activeStatusId, newRank);
|
onStatusDragEnd?.(activeStatusId, newRank);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStatusDragEnd = (statusId: number, lexorank: string) => {
|
||||||
|
updateStatus.mutate({
|
||||||
|
path: {
|
||||||
|
statusId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
status: {
|
||||||
|
lexorank,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDealDragEnd = (activeId: number | string, over: Over) => {
|
const handleDealDragEnd = (activeId: number | string, over: Over) => {
|
||||||
@ -189,7 +254,25 @@ const useDealsAndStatusesDnd = (props: Props) => {
|
|||||||
);
|
);
|
||||||
if (!overStatusId) return;
|
if (!overStatusId) return;
|
||||||
|
|
||||||
props.onDealDragEnd(activeDealId, overStatusId, newLexorank);
|
onDealDragEnd(activeDealId, overStatusId, newLexorank);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDealDragEnd = (
|
||||||
|
dealId: number,
|
||||||
|
statusId: number,
|
||||||
|
lexorank?: string
|
||||||
|
) => {
|
||||||
|
updateDeal.mutate({
|
||||||
|
path: {
|
||||||
|
dealId,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
deal: {
|
||||||
|
statusId,
|
||||||
|
lexorank,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = ({ active }: DragStartEvent) => {
|
const handleDragStart = ({ active }: DragStartEvent) => {
|
||||||
@ -209,6 +292,7 @@ const useDealsAndStatusesDnd = (props: Props) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
swiperRef,
|
||||||
sortedStatuses,
|
sortedStatuses,
|
||||||
handleDragStart,
|
handleDragStart,
|
||||||
handleDragOver,
|
handleDragOver,
|
||||||
|
|||||||
@ -3,14 +3,30 @@ import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
|||||||
import { DealSchema } from "@/lib/client";
|
import { DealSchema } from "@/lib/client";
|
||||||
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
|
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
|
||||||
|
|
||||||
const useGetNewRank = () => {
|
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 { statuses } = useStatusesContext();
|
||||||
|
|
||||||
const getNewRankForSameStatus = (
|
const getNewRankForSameStatus = (
|
||||||
statusDeals: DealSchema[],
|
statusDeals: DealSchema[],
|
||||||
overDealIndex: number,
|
overDealIndex: number,
|
||||||
activeDealId: number
|
activeDealId: number
|
||||||
) => {
|
): string => {
|
||||||
const activeDealIndex = statusDeals.findIndex(
|
const activeDealIndex = statusDeals.findIndex(
|
||||||
deal => deal.id === activeDealId
|
deal => deal.id === activeDealId
|
||||||
);
|
);
|
||||||
@ -34,7 +50,7 @@ const useGetNewRank = () => {
|
|||||||
const getNewRankForAnotherStatus = (
|
const getNewRankForAnotherStatus = (
|
||||||
statusDeals: DealSchema[],
|
statusDeals: DealSchema[],
|
||||||
overDealIndex: number
|
overDealIndex: number
|
||||||
) => {
|
): string => {
|
||||||
const leftLexorank =
|
const leftLexorank =
|
||||||
overDealIndex > 0
|
overDealIndex > 0
|
||||||
? LexoRank.parse(statusDeals[overDealIndex - 1].lexorank)
|
? LexoRank.parse(statusDeals[overDealIndex - 1].lexorank)
|
||||||
@ -46,7 +62,10 @@ const useGetNewRank = () => {
|
|||||||
return getNewLexorank(leftLexorank, rightLexorank).toString();
|
return getNewLexorank(leftLexorank, rightLexorank).toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNewStatusRank = (activeStatusId: number, overStatusId: number) => {
|
const getNewStatusRank = (
|
||||||
|
activeStatusId: number,
|
||||||
|
overStatusId: number
|
||||||
|
): string | null => {
|
||||||
const sortedStatusList = sortByLexorank(statuses);
|
const sortedStatusList = sortByLexorank(statuses);
|
||||||
const overIndex = sortedStatusList.findIndex(
|
const overIndex = sortedStatusList.findIndex(
|
||||||
s => s.id === overStatusId
|
s => s.id === overStatusId
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
@ -8,18 +8,21 @@ import {
|
|||||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||||
|
|
||||||
const useDndSensors = () => {
|
const useDndSensors = () => {
|
||||||
const sensorOptions = {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 5,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return useSensors(
|
return useSensors(
|
||||||
useSensor(PointerSensor, sensorOptions),
|
useSensor(MouseSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
}),
|
}),
|
||||||
useSensor(TouchSensor, sensorOptions)
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 300,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,28 +1,37 @@
|
|||||||
import { Divider } from "@mantine/core";
|
import Funnel from "@/app/deals/components/shared/Funnel/Funnel";
|
||||||
import Boards from "@/app/deals/components/Boards/Boards";
|
import Header from "@/app/deals/components/shared/Header/Header";
|
||||||
import Header from "@/app/deals/components/Header/Header";
|
|
||||||
import StatusColumns from "@/app/deals/components/StatusColumns/StatusColumns";
|
|
||||||
import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext";
|
import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext";
|
||||||
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
|
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
|
||||||
import { StatusesContextProvider } from "@/app/deals/contexts/StatusesContext";
|
import { StatusesContextProvider } from "@/app/deals/contexts/StatusesContext";
|
||||||
import PageBlock from "@/components/PageBlock/PageBlock";
|
import BoardStatusesEditorDrawer from "@/app/deals/drawers/BoardStatusesEditorDrawer";
|
||||||
import PageContainer from "@/components/PageContainer/PageContainer";
|
import ProjectBoardsEditorDrawer from "@/app/deals/drawers/ProjectBoardsEditorDrawer";
|
||||||
|
import ProjectsEditorDrawer from "@/app/deals/drawers/ProjectsEditorDrawer";
|
||||||
|
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||||
|
import PageContainer from "@/components/layout/PageContainer/PageContainer";
|
||||||
|
import { DealsContextProvider } from "./contexts/DealsContext";
|
||||||
|
|
||||||
export default function DealsPage() {
|
export default function DealsPage() {
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<ProjectsContextProvider>
|
||||||
<PageBlock>
|
<BoardsContextProvider>
|
||||||
<ProjectsContextProvider>
|
<PageContainer>
|
||||||
<BoardsContextProvider>
|
<PageBlock
|
||||||
|
transparent
|
||||||
|
style={{ padding: 0 }}>
|
||||||
<Header />
|
<Header />
|
||||||
<Boards />
|
</PageBlock>
|
||||||
<Divider my={"xl"} />
|
<PageBlock className={"mobile-padding-height"}>
|
||||||
<StatusesContextProvider>
|
<StatusesContextProvider>
|
||||||
<StatusColumns />
|
<DealsContextProvider>
|
||||||
|
<Funnel />
|
||||||
|
</DealsContextProvider>
|
||||||
|
<BoardStatusesEditorDrawer />
|
||||||
</StatusesContextProvider>
|
</StatusesContextProvider>
|
||||||
</BoardsContextProvider>
|
<ProjectBoardsEditorDrawer />
|
||||||
</ProjectsContextProvider>
|
<ProjectsEditorDrawer />
|
||||||
</PageBlock>
|
</PageBlock>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
|
</BoardsContextProvider>
|
||||||
|
</ProjectsContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,21 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Colors */
|
||||||
|
--color-light-gray-blue: #f4f7fd;
|
||||||
|
--color-light-aqua: #e0f0f4;
|
||||||
|
--color-light-white-blue: #f5fbfc;
|
||||||
|
--color-light-whitesmoke: #fcfdff;
|
||||||
|
--mantine-color-dark-7-5: #212121;
|
||||||
|
/* Shadows */
|
||||||
|
--light-shadow: 2px 2px 5px darkgray;
|
||||||
|
--light-thick-shadow: 4px 4px 10px darkgray;
|
||||||
|
--dark-shadow: 1px 1px 10px 1px var(--mantine-color-dark-6);
|
||||||
|
--dark-thick-shadow: 5px 5px 10px 1px var(--mantine-color-dark-6);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@mixin light {
|
@mixin light {
|
||||||
background-color: whitesmoke;
|
background-color: var(--color-light-gray-blue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
|
import "swiper/css";
|
||||||
|
import "swiper/css/pagination";
|
||||||
|
import "swiper/css/scrollbar";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
ColorSchemeScript,
|
ColorSchemeScript,
|
||||||
@ -40,10 +43,6 @@ export default function RootLayout({ children }: Props) {
|
|||||||
rel="shortcut icon"
|
rel="shortcut icon"
|
||||||
href="/favicon.svg"
|
href="/favicon.svg"
|
||||||
/>
|
/>
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="global.css"
|
|
||||||
/>
|
|
||||||
<meta
|
<meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
|
content="minimum-scale=1, initial-scale=1, width=device-width, user-scalable=no"
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button, Group, useMantineColorScheme } from "@mantine/core";
|
|
||||||
import { modals } from "@mantine/modals";
|
|
||||||
|
|
||||||
export function ColorSchemeToggle() {
|
|
||||||
const { setColorScheme } = useMantineColorScheme();
|
|
||||||
|
|
||||||
const openTestModal = () => {
|
|
||||||
modals.openContextModal({
|
|
||||||
modal: "testModal",
|
|
||||||
title: "Тест",
|
|
||||||
withCloseButton: false,
|
|
||||||
innerProps: {},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group
|
|
||||||
justify="center"
|
|
||||||
mt="xl">
|
|
||||||
<Button onClick={() => setColorScheme("light")}>Light</Button>
|
|
||||||
<Button onClick={() => setColorScheme("dark")}>Dark</Button>
|
|
||||||
<Button onClick={() => setColorScheme("auto")}>Auto</Button>
|
|
||||||
<Button onClick={() => openTestModal()}>Modal</Button>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
import React, { FC, ReactNode } from "react";
|
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
const Draggable: FC<Props> = props => {
|
|
||||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
|
||||||
id: "draggable",
|
|
||||||
});
|
|
||||||
|
|
||||||
const style = transform
|
|
||||||
? {
|
|
||||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}
|
|
||||||
{...listeners}
|
|
||||||
{...attributes}>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Draggable;
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
import React, { FC, ReactNode } from "react";
|
|
||||||
import { useDroppable } from "@dnd-kit/core";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Droppable: FC<Props> = ({ children }) => {
|
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
|
||||||
id: "droppable",
|
|
||||||
});
|
|
||||||
const style = {
|
|
||||||
color: isOver ? "green" : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Droppable;
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
.container {
|
|
||||||
border-radius: rem(40);
|
|
||||||
background-color: white;
|
|
||||||
@mixin dark {
|
|
||||||
background-color: var(--mantine-color-dark-8);
|
|
||||||
box-shadow: 5px 5px 30px 1px var(--mantine-color-dark-6);
|
|
||||||
}
|
|
||||||
@mixin light {
|
|
||||||
box-shadow: 5px 5px 24px rgba(0, 0, 0, 0.16);
|
|
||||||
}
|
|
||||||
padding: rem(35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-full-height {
|
|
||||||
min-height: calc(100vh - (rem(20) * 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-full-height-fixed {
|
|
||||||
height: calc(100vh - (rem(20) * 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-no-border-radius {
|
|
||||||
border-radius: 0 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container-full-screen-mobile {
|
|
||||||
@media (max-width: 48em) {
|
|
||||||
min-height: 100vh;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
padding: rem(40) rem(20) rem(20);
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 100;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
import React, { CSSProperties, ReactNode, useContext } from "react";
|
|
||||||
import SortableItemContext from "@/components/SortableDnd/SortableItemContext";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
children: ReactNode;
|
|
||||||
style?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DragHandle = ({ children, style }: Props) => {
|
|
||||||
const { attributes, listeners, ref } = useContext(SortableItemContext);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
{...attributes}
|
|
||||||
{...listeners}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
cursor: "grab",
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
ref={ref}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DragHandle;
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React, {
|
|
||||||
CSSProperties,
|
|
||||||
ReactNode,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Active, DndContext, DragEndEvent } from "@dnd-kit/core";
|
|
||||||
import { SortableContext } from "@dnd-kit/sortable";
|
|
||||||
import { LexoRank } from "lexorank";
|
|
||||||
import { Box, Group } from "@mantine/core";
|
|
||||||
import useDndSensors from "@/app/deals/hooks/useSensors";
|
|
||||||
import { SortableItem } from "@/components/SortableDnd/SortableItem";
|
|
||||||
import { SortableOverlay } from "@/components/SortableDnd/SortableOverlay";
|
|
||||||
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
|
|
||||||
|
|
||||||
type BaseItem = {
|
|
||||||
id: number;
|
|
||||||
lexorank: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props<T extends BaseItem> = {
|
|
||||||
initialItems: T[];
|
|
||||||
renderItem: (item: T) => ReactNode;
|
|
||||||
onDragEnd: (itemId: number, newLexorank: string) => void;
|
|
||||||
onItemClick: (item: T) => void;
|
|
||||||
rowStyle?: CSSProperties;
|
|
||||||
itemStyle?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
const SortableDnd = <T extends BaseItem>({
|
|
||||||
initialItems,
|
|
||||||
renderItem,
|
|
||||||
onDragEnd,
|
|
||||||
onItemClick,
|
|
||||||
rowStyle,
|
|
||||||
itemStyle,
|
|
||||||
}: Props<T>) => {
|
|
||||||
const [active, setActive] = useState<Active | null>(null);
|
|
||||||
const [items, setItems] = useState<T[]>([]);
|
|
||||||
const activeItem = useMemo(
|
|
||||||
() => initialItems.find(item => item.id === active?.id),
|
|
||||||
[active, items]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setItems(sortByLexorank(initialItems));
|
|
||||||
}, [initialItems]);
|
|
||||||
|
|
||||||
const sensors = useDndSensors();
|
|
||||||
|
|
||||||
const onDragEndLocal = ({ active, over }: DragEndEvent) => {
|
|
||||||
if (over && active.id !== over?.id && activeItem) {
|
|
||||||
const overIndex: number = items.findIndex(
|
|
||||||
({ id }) => id === over.id
|
|
||||||
);
|
|
||||||
const activeIndex: number = items.findIndex(
|
|
||||||
({ id }) => id === activeItem.id
|
|
||||||
);
|
|
||||||
|
|
||||||
let leftIndex = overIndex;
|
|
||||||
let rightIndex = overIndex + 1;
|
|
||||||
if (overIndex < activeIndex) {
|
|
||||||
leftIndex = overIndex - 1;
|
|
||||||
rightIndex = overIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
onDragEnd(items[activeIndex].id, newLexorank);
|
|
||||||
const sortedItems = sortByLexorank(items);
|
|
||||||
setItems([...sortedItems]);
|
|
||||||
}
|
|
||||||
setActive(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
onDragStart={({ active }) => setActive(active)}
|
|
||||||
onDragEnd={onDragEndLocal}
|
|
||||||
onDragCancel={() => setActive(null)}>
|
|
||||||
<SortableContext items={items}>
|
|
||||||
<Group
|
|
||||||
gap={0}
|
|
||||||
style={rowStyle}
|
|
||||||
role="application">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Box
|
|
||||||
key={index}
|
|
||||||
onClick={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
onItemClick(item);
|
|
||||||
}}>
|
|
||||||
<SortableItem
|
|
||||||
dragHandleStyle={{ cursor: "pointer" }}
|
|
||||||
itemStyle={itemStyle}
|
|
||||||
id={item.id}>
|
|
||||||
{renderItem(item)}
|
|
||||||
</SortableItem>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</SortableContext>
|
|
||||||
<SortableOverlay>
|
|
||||||
<div style={{ cursor: "grabbing" }}>
|
|
||||||
{activeItem ? renderItem(activeItem) : null}
|
|
||||||
</div>
|
|
||||||
</SortableOverlay>
|
|
||||||
</DndContext>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SortableDnd;
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
import React, { CSSProperties, PropsWithChildren, useMemo } from "react";
|
|
||||||
import { useSortable } from "@dnd-kit/sortable";
|
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
|
||||||
import DragHandle from "@/components/SortableDnd/DragHandle";
|
|
||||||
import SortableItemContext from "./SortableItemContext";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
id: number | string;
|
|
||||||
itemStyle?: CSSProperties;
|
|
||||||
dragHandleStyle?: CSSProperties;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SortableItem = ({
|
|
||||||
children,
|
|
||||||
itemStyle,
|
|
||||||
id,
|
|
||||||
dragHandleStyle,
|
|
||||||
}: PropsWithChildren<Props>) => {
|
|
||||||
const {
|
|
||||||
attributes,
|
|
||||||
isDragging,
|
|
||||||
listeners,
|
|
||||||
setNodeRef,
|
|
||||||
setActivatorNodeRef,
|
|
||||||
transform,
|
|
||||||
transition,
|
|
||||||
} = useSortable({ id });
|
|
||||||
|
|
||||||
const context = useMemo(
|
|
||||||
() => ({
|
|
||||||
attributes,
|
|
||||||
listeners,
|
|
||||||
ref: setActivatorNodeRef,
|
|
||||||
}),
|
|
||||||
[attributes, listeners, setActivatorNodeRef]
|
|
||||||
);
|
|
||||||
|
|
||||||
const style: CSSProperties = {
|
|
||||||
opacity: isDragging ? 0.4 : undefined,
|
|
||||||
transform: CSS.Translate.toString(transform),
|
|
||||||
transition,
|
|
||||||
...itemStyle,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableItemContext.Provider value={context}>
|
|
||||||
<div
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={style}>
|
|
||||||
<DragHandle style={dragHandleStyle}>{children}</DragHandle>
|
|
||||||
</div>
|
|
||||||
</SortableItemContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
import type { DraggableSyntheticListeners } from "@dnd-kit/core";
|
|
||||||
import { createContext } from "react";
|
|
||||||
|
|
||||||
interface Context {
|
|
||||||
attributes: Record<string, any>;
|
|
||||||
listeners: DraggableSyntheticListeners;
|
|
||||||
ref: (node: HTMLElement | null) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SortableItemContext = createContext<Context>({
|
|
||||||
attributes: {},
|
|
||||||
listeners: undefined,
|
|
||||||
ref() {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default SortableItemContext;
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
import SortableDnd from "@/components/SortableDnd/SortableDnd";
|
|
||||||
|
|
||||||
export default SortableDnd;
|
|
||||||
42
src/components/dnd/FunnelDnd/FunnelColumn.tsx
Normal file
42
src/components/dnd/FunnelDnd/FunnelColumn.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { useDroppable } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { Stack } from "@mantine/core";
|
||||||
|
import { BaseDraggable } from "@/components/dnd/types/types";
|
||||||
|
|
||||||
|
type Props<TItem> = {
|
||||||
|
id: string;
|
||||||
|
items: TItem[];
|
||||||
|
renderItem: (item: TItem) => ReactNode;
|
||||||
|
children?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FunnelColumn = <TItem extends BaseDraggable>({
|
||||||
|
id,
|
||||||
|
items,
|
||||||
|
renderItem,
|
||||||
|
children,
|
||||||
|
}: Props<TItem>) => {
|
||||||
|
const { setNodeRef } = useDroppable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{children}
|
||||||
|
<SortableContext
|
||||||
|
id={id}
|
||||||
|
items={items}
|
||||||
|
strategy={verticalListSortingStrategy}>
|
||||||
|
<Stack
|
||||||
|
gap="xs"
|
||||||
|
ref={setNodeRef}>
|
||||||
|
{items.map(renderItem)}
|
||||||
|
</Stack>
|
||||||
|
</SortableContext>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FunnelColumn;
|
||||||
27
src/components/dnd/FunnelDnd/FunnelDnd.module.css
Normal file
27
src/components/dnd/FunnelDnd/FunnelDnd.module.css
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
.overlay {
|
||||||
|
cursor: grabbing;
|
||||||
|
border-radius: 10px;
|
||||||
|
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
@mixin dark {
|
||||||
|
box-shadow: var(--dark-shadow);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
box-shadow: var(--light-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swiper-container :global(.swiper-scrollbar-drag) {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-9);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.swiper-container :global(.swiper-slide) {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
186
src/components/dnd/FunnelDnd/FunnelDnd.tsx
Normal file
186
src/components/dnd/FunnelDnd/FunnelDnd.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { ReactNode, RefObject } from "react";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverEvent,
|
||||||
|
DragStartEvent,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
horizontalListSortingStrategy,
|
||||||
|
SortableContext,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { FreeMode, Mousewheel, Pagination, Scrollbar } from "swiper/modules";
|
||||||
|
import { Swiper, SwiperRef, SwiperSlide } from "swiper/react";
|
||||||
|
import CreateStatusButton from "@/app/deals/components/shared/CreateStatusButton/CreateStatusButton";
|
||||||
|
import useDndSensors from "@/app/deals/hooks/useSensors";
|
||||||
|
import FunnelColumn from "@/components/dnd/FunnelDnd/FunnelColumn";
|
||||||
|
import FunnelOverlay from "@/components/dnd/FunnelDnd/FunnelOverlay";
|
||||||
|
import { BaseDraggable } from "@/components/dnd/types/types";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import SortableItem from "../SortableItem";
|
||||||
|
import classes from "./FunnelDnd.module.css";
|
||||||
|
|
||||||
|
type Props<TContainer, TItem> = {
|
||||||
|
containers: TContainer[];
|
||||||
|
items: TItem[];
|
||||||
|
onDragStart: (event: DragStartEvent) => void;
|
||||||
|
onDragOver: (event: DragOverEvent) => void;
|
||||||
|
onDragEnd: (event: DragEndEvent) => void;
|
||||||
|
swiperRef: RefObject<SwiperRef | null>;
|
||||||
|
renderContainer: (
|
||||||
|
container: TContainer,
|
||||||
|
children: ReactNode,
|
||||||
|
renderDraggable: () => ReactNode
|
||||||
|
) => ReactNode;
|
||||||
|
renderContainerHeader: (container: TContainer) => ReactNode;
|
||||||
|
renderContainerOverlay: (
|
||||||
|
container: TContainer,
|
||||||
|
children: ReactNode
|
||||||
|
) => ReactNode;
|
||||||
|
renderItem: (item: TItem) => ReactNode;
|
||||||
|
renderItemOverlay: (item: TItem) => ReactNode;
|
||||||
|
getContainerId: (container: TContainer) => string;
|
||||||
|
getItemsByContainer: (container: TContainer, items: TItem[]) => TItem[];
|
||||||
|
activeContainer: TContainer | null;
|
||||||
|
activeItem: TItem | null;
|
||||||
|
isCreatingContainerEnabled?: boolean;
|
||||||
|
disabledColumns?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FunnelDnd = <
|
||||||
|
TContainer extends BaseDraggable,
|
||||||
|
TItem extends BaseDraggable,
|
||||||
|
>({
|
||||||
|
containers,
|
||||||
|
items,
|
||||||
|
onDragStart,
|
||||||
|
onDragOver,
|
||||||
|
onDragEnd,
|
||||||
|
swiperRef,
|
||||||
|
renderContainer,
|
||||||
|
renderContainerHeader,
|
||||||
|
renderContainerOverlay,
|
||||||
|
renderItem,
|
||||||
|
renderItemOverlay,
|
||||||
|
getContainerId,
|
||||||
|
getItemsByContainer,
|
||||||
|
activeContainer,
|
||||||
|
activeItem,
|
||||||
|
isCreatingContainerEnabled = true,
|
||||||
|
disabledColumns = false,
|
||||||
|
}: Props<TContainer, TItem>) => {
|
||||||
|
const sensors = useDndSensors();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const renderContainers = () =>
|
||||||
|
containers.map(container => {
|
||||||
|
const containerItems = getItemsByContainer(container, items);
|
||||||
|
const containerId = getContainerId(container);
|
||||||
|
return (
|
||||||
|
<SwiperSlide
|
||||||
|
style={{ width: 250 }}
|
||||||
|
key={containerId}>
|
||||||
|
<SortableItem
|
||||||
|
key={containerId}
|
||||||
|
id={containerId}
|
||||||
|
disabled={disabledColumns}
|
||||||
|
renderItem={renderDraggable =>
|
||||||
|
renderContainer(
|
||||||
|
container,
|
||||||
|
<FunnelColumn
|
||||||
|
id={containerId}
|
||||||
|
items={containerItems}
|
||||||
|
renderItem={renderItem}
|
||||||
|
/>,
|
||||||
|
renderDraggable!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderDraggable={() => renderContainerHeader(container)}
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderBody = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<Swiper
|
||||||
|
ref={swiperRef}
|
||||||
|
className={classes["swiper-container"]}
|
||||||
|
slidesPerView={1.1}
|
||||||
|
style={{ paddingLeft: "10vw", paddingRight: "2vw" }}
|
||||||
|
modules={[Pagination]}
|
||||||
|
freeMode={{ enabled: false }}
|
||||||
|
pagination={{ enabled: true, clickable: true }}>
|
||||||
|
{renderContainers()}
|
||||||
|
{isCreatingContainerEnabled && (
|
||||||
|
<SwiperSlide>
|
||||||
|
<CreateStatusButton />
|
||||||
|
</SwiperSlide>
|
||||||
|
)}
|
||||||
|
</Swiper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Swiper
|
||||||
|
ref={swiperRef}
|
||||||
|
className={classes["swiper-container"]}
|
||||||
|
modules={[Scrollbar, Mousewheel, FreeMode]}
|
||||||
|
spaceBetween={15}
|
||||||
|
slidesPerView={"auto"}
|
||||||
|
scrollbar={{ hide: false }}
|
||||||
|
mousewheel={{
|
||||||
|
enabled: true,
|
||||||
|
sensitivity: 0.2,
|
||||||
|
releaseOnEdges: true,
|
||||||
|
}}
|
||||||
|
freeMode={{ enabled: true }}
|
||||||
|
grabCursor>
|
||||||
|
{renderContainers()}
|
||||||
|
{isCreatingContainerEnabled && (
|
||||||
|
<SwiperSlide style={{ width: 50 }}>
|
||||||
|
<CreateStatusButton />
|
||||||
|
</SwiperSlide>
|
||||||
|
)}
|
||||||
|
</Swiper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={onDragStart}
|
||||||
|
onDragOver={onDragOver}
|
||||||
|
onDragEnd={onDragEnd}>
|
||||||
|
<SortableContext
|
||||||
|
items={containers.map(getContainerId)}
|
||||||
|
strategy={horizontalListSortingStrategy}>
|
||||||
|
{renderBody()}
|
||||||
|
<FunnelOverlay
|
||||||
|
activeContainer={activeContainer}
|
||||||
|
activeItem={activeItem}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FunnelDnd;
|
||||||
31
src/components/dnd/FunnelDnd/FunnelOverlay.tsx
Normal file
31
src/components/dnd/FunnelDnd/FunnelOverlay.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import React, { ReactNode } from "react";
|
||||||
|
import { defaultDropAnimation, DragOverlay } from "@dnd-kit/core";
|
||||||
|
import styles from "@/components/dnd/FunnelDnd/FunnelDnd.module.css";
|
||||||
|
|
||||||
|
type Props<TContainer, TItem> = {
|
||||||
|
activeContainer: TContainer | null;
|
||||||
|
activeItem: TItem | null;
|
||||||
|
renderContainer: (container: TContainer) => ReactNode;
|
||||||
|
renderItem: (item: TItem) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FunnelOverlay = <TContainer, TItem>({
|
||||||
|
activeContainer,
|
||||||
|
activeItem,
|
||||||
|
renderContainer,
|
||||||
|
renderItem,
|
||||||
|
}: Props<TContainer, TItem>) => {
|
||||||
|
return (
|
||||||
|
<DragOverlay dropAnimation={defaultDropAnimation}>
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
{activeItem
|
||||||
|
? renderItem(activeItem)
|
||||||
|
: activeContainer
|
||||||
|
? renderContainer(activeContainer)
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
</DragOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FunnelOverlay;
|
||||||
3
src/components/dnd/FunnelDnd/index.ts
Normal file
3
src/components/dnd/FunnelDnd/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import FunnelDnd from "./FunnelDnd";
|
||||||
|
|
||||||
|
export default FunnelDnd;
|
||||||
9
src/components/dnd/SortableDnd/SortableDnd.module.css
Normal file
9
src/components/dnd/SortableDnd/SortableDnd.module.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.swiper-container :global(.swiper-scrollbar-drag) {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-9);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
201
src/components/dnd/SortableDnd/SortableDnd.tsx
Normal file
201
src/components/dnd/SortableDnd/SortableDnd.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
CSSProperties,
|
||||||
|
ReactNode,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Active, DndContext, DragEndEvent } from "@dnd-kit/core";
|
||||||
|
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
|
||||||
|
import { SortableContext } from "@dnd-kit/sortable";
|
||||||
|
import { LexoRank } from "lexorank";
|
||||||
|
import { FreeMode, Mousewheel, Scrollbar } from "swiper/modules";
|
||||||
|
import { Swiper, SwiperSlide } from "swiper/react";
|
||||||
|
import { Box, Flex } from "@mantine/core";
|
||||||
|
import useDndSensors from "@/app/deals/hooks/useSensors";
|
||||||
|
import { SortableOverlay } from "@/components/dnd/SortableDnd/SortableOverlay";
|
||||||
|
import SortableItem from "@/components/dnd/SortableItem";
|
||||||
|
import { getNewLexorank, sortByLexorank } from "@/utils/lexorank";
|
||||||
|
import classes from "./SortableDnd.module.css";
|
||||||
|
|
||||||
|
type BaseItem = {
|
||||||
|
id: number;
|
||||||
|
lexorank: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props<T extends BaseItem> = {
|
||||||
|
initialItems: T[];
|
||||||
|
renderItem: (
|
||||||
|
item: T,
|
||||||
|
renderDraggable?: (item: T) => ReactNode
|
||||||
|
) => ReactNode;
|
||||||
|
renderDraggable?: (item: T) => ReactNode; // if not passed - the whole item renders as draggable
|
||||||
|
dragHandleStyle?: CSSProperties;
|
||||||
|
onDragEnd: (itemId: number, newLexorank: string) => void;
|
||||||
|
onItemClick?: (item: T) => void;
|
||||||
|
containerStyle?: CSSProperties;
|
||||||
|
vertical?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
swiperEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SortableDnd = <T extends BaseItem>({
|
||||||
|
initialItems,
|
||||||
|
renderItem,
|
||||||
|
renderDraggable,
|
||||||
|
dragHandleStyle,
|
||||||
|
onDragEnd,
|
||||||
|
onItemClick,
|
||||||
|
containerStyle,
|
||||||
|
vertical = false,
|
||||||
|
disabled = false,
|
||||||
|
swiperEnabled = false,
|
||||||
|
}: Props<T>) => {
|
||||||
|
const [active, setActive] = useState<Active | null>(null);
|
||||||
|
const [items, setItems] = useState<T[]>([]);
|
||||||
|
const activeItem = useMemo(
|
||||||
|
() => initialItems.find(item => item.id === active?.id),
|
||||||
|
[active, items]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setItems(sortByLexorank(initialItems));
|
||||||
|
}, [initialItems]);
|
||||||
|
|
||||||
|
const sensors = useDndSensors();
|
||||||
|
|
||||||
|
const onDragEndLocal = ({ active, over }: DragEndEvent) => {
|
||||||
|
if (!over || active.id === over?.id || !activeItem) {
|
||||||
|
setActive(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const overIndex: number = items.findIndex(({ id }) => id === over.id);
|
||||||
|
const activeIndex: number = items.findIndex(
|
||||||
|
({ id }) => id === activeItem.id
|
||||||
|
);
|
||||||
|
|
||||||
|
let leftIndex = overIndex;
|
||||||
|
let rightIndex = overIndex + 1;
|
||||||
|
if (overIndex < activeIndex) {
|
||||||
|
leftIndex = overIndex - 1;
|
||||||
|
rightIndex = overIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
onDragEnd(items[activeIndex].id, newLexorank);
|
||||||
|
const sortedItems = sortByLexorank(items);
|
||||||
|
setItems([...sortedItems]);
|
||||||
|
setActive(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderWithSwiper = () => (
|
||||||
|
<Swiper
|
||||||
|
modules={[Scrollbar, Mousewheel, FreeMode]}
|
||||||
|
spaceBetween={15}
|
||||||
|
slidesPerView={"auto"}
|
||||||
|
scrollbar={{ hide: false }}
|
||||||
|
mousewheel={{
|
||||||
|
enabled: true,
|
||||||
|
sensitivity: 0.2,
|
||||||
|
}}
|
||||||
|
className={classes["swiper-container"]}
|
||||||
|
style={containerStyle}
|
||||||
|
direction={vertical ? "vertical" : "horizontal"}
|
||||||
|
freeMode={{ enabled: true }}
|
||||||
|
grabCursor>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<SwiperSlide
|
||||||
|
style={{ width: "fit-content" }}
|
||||||
|
key={index}
|
||||||
|
onClick={e => {
|
||||||
|
if (!onItemClick) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onItemClick(item);
|
||||||
|
}}>
|
||||||
|
<SortableItem
|
||||||
|
id={item.id}
|
||||||
|
disabled={disabled}
|
||||||
|
renderItem={renderDraggable =>
|
||||||
|
renderItem(item, renderDraggable)
|
||||||
|
}
|
||||||
|
renderDraggable={
|
||||||
|
renderDraggable
|
||||||
|
? () => renderDraggable(item)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
dragHandleStyle={dragHandleStyle}
|
||||||
|
/>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderWithFlex = () => (
|
||||||
|
<Flex
|
||||||
|
style={{
|
||||||
|
gap: 0,
|
||||||
|
flexWrap: "nowrap",
|
||||||
|
flexDirection: vertical ? "column" : "row",
|
||||||
|
...containerStyle,
|
||||||
|
}}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<Box
|
||||||
|
key={index}
|
||||||
|
onClick={e => {
|
||||||
|
if (!onItemClick) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
onItemClick(item);
|
||||||
|
}}>
|
||||||
|
<SortableItem
|
||||||
|
id={item.id}
|
||||||
|
disabled={disabled}
|
||||||
|
renderItem={renderDraggable =>
|
||||||
|
renderItem(item, renderDraggable)
|
||||||
|
}
|
||||||
|
renderDraggable={
|
||||||
|
renderDraggable
|
||||||
|
? () => renderDraggable(item)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
dragHandleStyle={dragHandleStyle}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
modifiers={[restrictToHorizontalAxis]}
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={({ active }) => setActive(active)}
|
||||||
|
onDragEnd={onDragEndLocal}
|
||||||
|
onDragCancel={() => setActive(null)}>
|
||||||
|
<SortableContext
|
||||||
|
items={items}
|
||||||
|
disabled={disabled}>
|
||||||
|
{swiperEnabled ? renderWithSwiper() : renderWithFlex()}
|
||||||
|
</SortableContext>
|
||||||
|
<SortableOverlay>
|
||||||
|
{activeItem ? renderItem(activeItem, renderDraggable) : null}
|
||||||
|
</SortableOverlay>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SortableDnd;
|
||||||
@ -18,7 +18,7 @@ const dropAnimationConfig: DropAnimation = {
|
|||||||
export function SortableOverlay({ children }: PropsWithChildren) {
|
export function SortableOverlay({ children }: PropsWithChildren) {
|
||||||
return (
|
return (
|
||||||
<DragOverlay dropAnimation={dropAnimationConfig}>
|
<DragOverlay dropAnimation={dropAnimationConfig}>
|
||||||
{children}
|
<div style={{ cursor: "grabbing" }}>{children}</div>
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
3
src/components/dnd/SortableDnd/index.ts
Normal file
3
src/components/dnd/SortableDnd/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import SortableDnd from "@/components/dnd/SortableDnd/SortableDnd";
|
||||||
|
|
||||||
|
export default SortableDnd;
|
||||||
34
src/components/dnd/SortableItem/DragHandle.tsx
Normal file
34
src/components/dnd/SortableItem/DragHandle.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React, { CSSProperties, ReactNode } from "react";
|
||||||
|
import { useDraggable } from "@dnd-kit/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
id: number | string;
|
||||||
|
children: ReactNode;
|
||||||
|
style?: CSSProperties;
|
||||||
|
disabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DragHandle = ({ id, children, style, disabled }: Props) => {
|
||||||
|
const { attributes, listeners, setNodeRef } = useDraggable({
|
||||||
|
id,
|
||||||
|
disabled,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
style={{
|
||||||
|
width: "100wv",
|
||||||
|
cursor: disabled ? "default" : "grab",
|
||||||
|
touchAction: "auto",
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
className={disabled ? "" : "swiper-no-swiping"}
|
||||||
|
ref={setNodeRef}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DragHandle;
|
||||||
59
src/components/dnd/SortableItem/SortableItem.tsx
Normal file
59
src/components/dnd/SortableItem/SortableItem.tsx
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import React, { CSSProperties, ReactNode } from "react";
|
||||||
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import DragHandle from "./DragHandle";
|
||||||
|
|
||||||
|
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 SortableItem = ({
|
||||||
|
renderItem,
|
||||||
|
dragHandleStyle,
|
||||||
|
renderDraggable,
|
||||||
|
id,
|
||||||
|
disabled = false,
|
||||||
|
}: Props) => {
|
||||||
|
const { isDragging, setNodeRef, transform, transition } = useSortable({
|
||||||
|
id,
|
||||||
|
animateLayoutChanges: () => false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style: CSSProperties = {
|
||||||
|
opacity: isDragging ? 0.4 : undefined,
|
||||||
|
transform: CSS.Translate.toString(transform),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
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 SortableItem;
|
||||||
3
src/components/dnd/SortableItem/index.ts
Normal file
3
src/components/dnd/SortableItem/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import SortableItem from "./SortableItem";
|
||||||
|
|
||||||
|
export default SortableItem;
|
||||||
3
src/components/dnd/types/types.ts
Normal file
3
src/components/dnd/types/types.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export type BaseDraggable = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
61
src/components/layout/PageBlock/PageBlock.module.css
Normal file
61
src/components/layout/PageBlock/PageBlock.module.css
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
.container {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
padding: var(--mantine-spacing-md);
|
||||||
|
|
||||||
|
@mixin dark {
|
||||||
|
box-shadow: var(--dark-thick-shadow);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
box-shadow: var(--light-thick-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-padding-height {
|
||||||
|
height: 100% !important;
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
height: 89vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-full-height {
|
||||||
|
min-height: calc(100vh - (var(--mantine-spacing-md) * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-full-height-fixed {
|
||||||
|
height: calc(100vh - (var(--mantine-spacing-md) * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-no-border-radius {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-full-screen-mobile {
|
||||||
|
@media (max-width: 48em) {
|
||||||
|
min-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 100;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transparent {
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
box-shadow: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,36 +1,44 @@
|
|||||||
import { CSSProperties, FC, ReactNode } from "react";
|
import { CSSProperties, FC, ReactNode } from "react";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import styles from "./PageBlock.module.css";
|
import styles from "./PageBlock.module.css";
|
||||||
5
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
className?: string;
|
||||||
fullHeight?: boolean;
|
fullHeight?: boolean;
|
||||||
fullHeightFixed?: boolean;
|
fullHeightFixed?: boolean;
|
||||||
noBorderRadius?: boolean;
|
noBorderRadius?: boolean;
|
||||||
fullScreenMobile?: boolean;
|
fullScreenMobile?: boolean;
|
||||||
|
transparent?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PageBlock: FC<Props> = ({
|
const PageBlock: FC<Props> = ({
|
||||||
children,
|
children,
|
||||||
style,
|
style,
|
||||||
|
className = "",
|
||||||
fullHeight = false,
|
fullHeight = false,
|
||||||
fullHeightFixed = false,
|
fullHeightFixed = false,
|
||||||
noBorderRadius = false,
|
noBorderRadius = false,
|
||||||
fullScreenMobile = false,
|
fullScreenMobile = false,
|
||||||
|
transparent = false,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box
|
||||||
|
bdrs={"lg"}
|
||||||
style={style}
|
style={style}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
styles.container,
|
styles.container,
|
||||||
fullHeight && styles["container-full-height"],
|
fullHeight && styles["container-full-height"],
|
||||||
fullHeightFixed && styles["container-full-height-fixed"],
|
fullHeightFixed && styles["container-full-height-fixed"],
|
||||||
noBorderRadius && styles["container-no-border-radius"],
|
noBorderRadius && styles["container-no-border-radius"],
|
||||||
fullScreenMobile && styles["container-full-screen-mobile"]
|
fullScreenMobile && styles["container-full-screen-mobile"],
|
||||||
|
transparent && styles.transparent,
|
||||||
|
styles[className]
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default PageBlock;
|
export default PageBlock;
|
||||||
@ -1,7 +1,10 @@
|
|||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: rem(10);
|
min-height: 100vh;
|
||||||
min-height: 86vh;
|
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
|
|
||||||
|
@media (min-width: 48em) {
|
||||||
|
gap: rem(10);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
.container {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7-5);
|
||||||
|
box-shadow: var(--dark-shadow);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
background-color: var(--color-light-whitesmoke);
|
||||||
|
box-shadow: var(--light-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container-active {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
box-shadow: var(--dark-thick-shadow);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: var(--light-thick-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container:hover {
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-8);
|
||||||
|
box-shadow: var(--dark-thick-shadow);
|
||||||
|
}
|
||||||
|
@mixin light {
|
||||||
|
background-color: white;
|
||||||
|
box-shadow: var(--light-thick-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/components/layout/SmallPageBlock/SmallPageBlock.tsx
Normal file
25
src/components/layout/SmallPageBlock/SmallPageBlock.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { CSSProperties, FC, ReactNode } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import styles from "@/components/layout/SmallPageBlock/SmallPageBlock.module.css";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
children: ReactNode;
|
||||||
|
style?: CSSProperties;
|
||||||
|
active?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SmallPageBlock: FC<Props> = ({ children, style, active = false }) => {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
bdrs={"lg"}
|
||||||
|
className={classNames(
|
||||||
|
styles.container,
|
||||||
|
active && styles["container-active"]
|
||||||
|
)}
|
||||||
|
style={style}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default SmallPageBlock;
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
.container {
|
||||||
|
border-width: 0;
|
||||||
|
@mixin dark {
|
||||||
|
background-color: var(--mantine-color-dark-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: rem(18px);
|
||||||
|
height: rem(18px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme='dark']) .light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global([data-mantine-color-scheme='dark']) .dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
43
src/components/ui/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal file
43
src/components/ui/ColorSchemeToggle/ColorSchemeToggle.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { IconMoon, IconSun } from "@tabler/icons-react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
useComputedColorScheme,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
|
||||||
|
import style from "./ColorSchemeToggle.module.css";
|
||||||
|
|
||||||
|
export function ColorSchemeToggle() {
|
||||||
|
const { setColorScheme } = useMantineColorScheme();
|
||||||
|
const computedColorScheme = useComputedColorScheme(undefined, {
|
||||||
|
getInitialValueInEffect: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleColorScheme = () => {
|
||||||
|
setColorScheme(computedColorScheme === "light" ? "dark" : "light");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SmallPageBlock>
|
||||||
|
<ActionIcon
|
||||||
|
onClick={toggleColorScheme}
|
||||||
|
variant="default"
|
||||||
|
size="xl"
|
||||||
|
radius="lg"
|
||||||
|
aria-label="Toggle color scheme"
|
||||||
|
className={style.container}>
|
||||||
|
<IconSun
|
||||||
|
className={classNames(style.icon, style.light)}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
<IconMoon
|
||||||
|
className={classNames(style.icon, style.dark)}
|
||||||
|
stroke={1.5}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
</SmallPageBlock>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/ui/InPlaceInput/InPlaceInput.tsx
Normal file
26
src/components/ui/InPlaceInput/InPlaceInput.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React, { FC, ReactNode } from "react";
|
||||||
|
import { Styles } from "@mantine/core/lib/core/styles-api/styles-api.types";
|
||||||
|
import useIsMobile from "@/hooks/useIsMobile";
|
||||||
|
import InPlaceInputDesktop from "./InPlaceInputDesktop";
|
||||||
|
import InPlaceInputMobile from "./InPlaceInputMobile";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultValue?: string;
|
||||||
|
onComplete: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
getChildren: (startEditing: () => void) => ReactNode;
|
||||||
|
inputStyles?: Styles<any>;
|
||||||
|
modalTitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InPlaceInput: FC<Props> = (props) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return <InPlaceInputMobile {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <InPlaceInputDesktop {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InPlaceInput;
|
||||||
86
src/components/ui/InPlaceInput/InPlaceInputDesktop.tsx
Normal file
86
src/components/ui/InPlaceInput/InPlaceInputDesktop.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import React, { FC, ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import { TextInput } from "@mantine/core";
|
||||||
|
import { Styles } from "@mantine/core/lib/core/styles-api/styles-api.types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultValue?: string;
|
||||||
|
onComplete: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
getChildren: (startEditing: () => void) => ReactNode;
|
||||||
|
inputStyles?: Styles<any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InPlaceInputDesktop: FC<Props> = ({
|
||||||
|
onComplete,
|
||||||
|
placeholder,
|
||||||
|
inputStyles,
|
||||||
|
getChildren,
|
||||||
|
defaultValue = "",
|
||||||
|
}) => {
|
||||||
|
const [isWriting, setIsWriting] = useState<boolean>(false);
|
||||||
|
const [value, setValue] = useState<string>(defaultValue);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isWriting && inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, [isWriting]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
isWriting &&
|
||||||
|
inputRef.current &&
|
||||||
|
!inputRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
onCompleteCreating();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () =>
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, [isWriting, value]);
|
||||||
|
|
||||||
|
const onStartCreating = () => {
|
||||||
|
setValue(defaultValue);
|
||||||
|
setIsWriting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancelCreating = () => {
|
||||||
|
setValue(defaultValue);
|
||||||
|
setIsWriting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCompleteCreating = () => {
|
||||||
|
const localValue = value.trim();
|
||||||
|
if (localValue) {
|
||||||
|
onComplete(localValue);
|
||||||
|
}
|
||||||
|
setIsWriting(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isWriting) {
|
||||||
|
return (
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
placeholder={placeholder}
|
||||||
|
variant={"unstyled"}
|
||||||
|
value={value}
|
||||||
|
onChange={e => setValue(e.target.value)}
|
||||||
|
onKeyDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === "Enter") onCompleteCreating();
|
||||||
|
if (e.key === "Escape") onCancelCreating();
|
||||||
|
}}
|
||||||
|
styles={inputStyles}
|
||||||
|
miw={150}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getChildren(onStartCreating);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InPlaceInputDesktop;
|
||||||
32
src/components/ui/InPlaceInput/InPlaceInputMobile.tsx
Normal file
32
src/components/ui/InPlaceInput/InPlaceInputMobile.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { FC, ReactNode } from "react";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
defaultValue?: string;
|
||||||
|
onComplete: (value: string) => void;
|
||||||
|
getChildren: (startEditing: () => void) => ReactNode;
|
||||||
|
modalTitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InPlaceInputMobile: FC<Props> = ({
|
||||||
|
onComplete,
|
||||||
|
getChildren,
|
||||||
|
modalTitle = "",
|
||||||
|
defaultValue = "",
|
||||||
|
}) => {
|
||||||
|
const onStartCreating = () => {
|
||||||
|
modals.openContextModal({
|
||||||
|
modal: "enterNameModal",
|
||||||
|
title: modalTitle,
|
||||||
|
withCloseButton: true,
|
||||||
|
innerProps: {
|
||||||
|
onComplete,
|
||||||
|
defaultValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return getChildren(onStartCreating);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InPlaceInputMobile;
|
||||||
@ -2,5 +2,5 @@ import type { CreateClientConfig } from "@/lib/client/client.gen";
|
|||||||
|
|
||||||
export const createClientConfig: CreateClientConfig = config => ({
|
export const createClientConfig: CreateClientConfig = config => ({
|
||||||
...config,
|
...config,
|
||||||
baseUrl: "http://crm.logidex.ru/api",
|
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
||||||
});
|
});
|
||||||
|
|||||||
128
src/hooks/useBoardsOperations.tsx
Normal file
128
src/hooks/useBoardsOperations.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { LexoRank } from "lexorank";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import {
|
||||||
|
BoardSchema,
|
||||||
|
HttpValidationError,
|
||||||
|
UpdateBoardSchema,
|
||||||
|
} from "@/lib/client";
|
||||||
|
import {
|
||||||
|
createBoardMutation,
|
||||||
|
deleteBoardMutation,
|
||||||
|
updateBoardMutation,
|
||||||
|
} from "@/lib/client/@tanstack/react-query.gen";
|
||||||
|
import { notifications } from "@/lib/notifications";
|
||||||
|
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank";
|
||||||
|
|
||||||
|
type UseBoardsOperationsProps = {
|
||||||
|
boards: BoardSchema[];
|
||||||
|
setBoards: React.Dispatch<React.SetStateAction<BoardSchema[]>>;
|
||||||
|
refetchBoards: () => void;
|
||||||
|
projectId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BoardsOperations = {
|
||||||
|
onCreateBoard: (name: string) => void;
|
||||||
|
onUpdateBoard: (boardId: number, board: UpdateBoardSchema) => void;
|
||||||
|
onDeleteBoard: (board: BoardSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useBoardsOperations = ({
|
||||||
|
boards,
|
||||||
|
setBoards,
|
||||||
|
refetchBoards,
|
||||||
|
projectId,
|
||||||
|
}: UseBoardsOperationsProps): BoardsOperations => {
|
||||||
|
const onError = (error: AxiosError<HttpValidationError>) => {
|
||||||
|
console.error(error);
|
||||||
|
notifications.error({
|
||||||
|
message: error.response?.data?.detail as string | undefined,
|
||||||
|
});
|
||||||
|
refetchBoards();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBoard = useMutation({
|
||||||
|
...createBoardMutation(),
|
||||||
|
onError,
|
||||||
|
onSuccess: res => {
|
||||||
|
setBoards([...boards, res.board]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateBoard = useMutation({
|
||||||
|
...updateBoardMutation(),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBoard = useMutation({
|
||||||
|
...deleteBoardMutation(),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCreateBoard = (name: string) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
const lastBoard = getMaxByLexorank(boards);
|
||||||
|
const newLexorank = getNewLexorank(
|
||||||
|
lastBoard ? LexoRank.parse(lastBoard.lexorank) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
createBoard.mutate({
|
||||||
|
body: {
|
||||||
|
board: {
|
||||||
|
name,
|
||||||
|
projectId,
|
||||||
|
lexorank: newLexorank.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateBoard = (boardId: number, board: UpdateBoardSchema) => {
|
||||||
|
updateBoard.mutate({
|
||||||
|
path: { boardId },
|
||||||
|
body: { board },
|
||||||
|
});
|
||||||
|
|
||||||
|
setBoards(boards =>
|
||||||
|
boards.map(oldBoard =>
|
||||||
|
oldBoard.id !== boardId
|
||||||
|
? oldBoard
|
||||||
|
: {
|
||||||
|
id: oldBoard.id,
|
||||||
|
name: board.name ? board.name : oldBoard.name,
|
||||||
|
lexorank: board.lexorank
|
||||||
|
? board.lexorank
|
||||||
|
: oldBoard.lexorank,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteBoard = (board: BoardSchema) => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: "Удаление доски",
|
||||||
|
children: (
|
||||||
|
<Text>
|
||||||
|
Вы уверены, что хотите удалить доску "{board.name}"?
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: "Да", cancel: "Нет" },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: () => {
|
||||||
|
deleteBoard.mutate({
|
||||||
|
path: { boardId: board.id },
|
||||||
|
});
|
||||||
|
setBoards(boards => boards.filter(b => b.id !== board.id));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onCreateBoard,
|
||||||
|
onUpdateBoard,
|
||||||
|
onDeleteBoard,
|
||||||
|
};
|
||||||
|
};
|
||||||
7
src/hooks/useIsMobile.ts
Normal file
7
src/hooks/useIsMobile.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { useMediaQuery } from "@mantine/hooks";
|
||||||
|
|
||||||
|
const useIsMobile = (): boolean => {
|
||||||
|
return useMediaQuery("(max-width: 48em)");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useIsMobile;
|
||||||
@ -1,12 +1,22 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { ProjectSchema } from "@/lib/client";
|
||||||
import { getProjectsOptions } from "@/lib/client/@tanstack/react-query.gen";
|
import { getProjectsOptions } from "@/lib/client/@tanstack/react-query.gen";
|
||||||
|
|
||||||
const useProjectsList = () => {
|
const useProjectsList = () => {
|
||||||
|
const [projects, setProjects] = useState<ProjectSchema[]>([]);
|
||||||
|
|
||||||
const { data, refetch, isLoading } = useQuery({
|
const { data, refetch, isLoading } = useQuery({
|
||||||
...getProjectsOptions(),
|
...getProjectsOptions(),
|
||||||
});
|
});
|
||||||
const projects = !data ? [] : data.projects;
|
|
||||||
return { projects, refetch, isLoading };
|
useEffect(() => {
|
||||||
|
if (data?.projects) {
|
||||||
|
setProjects(data.projects);
|
||||||
|
}
|
||||||
|
}, [data?.projects]);
|
||||||
|
|
||||||
|
return { projects, setProjects, refetch, isLoading };
|
||||||
};
|
};
|
||||||
|
|
||||||
export default useProjectsList;
|
export default useProjectsList;
|
||||||
|
|||||||
118
src/hooks/useProjectsOperations.tsx
Normal file
118
src/hooks/useProjectsOperations.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import {
|
||||||
|
HttpValidationError,
|
||||||
|
ProjectSchema,
|
||||||
|
UpdateProjectSchema,
|
||||||
|
} from "@/lib/client";
|
||||||
|
import {
|
||||||
|
createProjectMutation,
|
||||||
|
deleteProjectMutation,
|
||||||
|
updateProjectMutation,
|
||||||
|
} from "@/lib/client/@tanstack/react-query.gen";
|
||||||
|
import { notifications } from "@/lib/notifications";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
projects: ProjectSchema[];
|
||||||
|
setProjects: React.Dispatch<React.SetStateAction<ProjectSchema[]>>;
|
||||||
|
refetchProjects: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectsOperations = {
|
||||||
|
onCreateProject: (name: string) => void;
|
||||||
|
onUpdateProject: (projectId: number, project: UpdateProjectSchema) => void;
|
||||||
|
onDeleteProject: (project: ProjectSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useProjectsOperations = ({
|
||||||
|
projects,
|
||||||
|
setProjects,
|
||||||
|
refetchProjects,
|
||||||
|
}: Props): ProjectsOperations => {
|
||||||
|
const onError = (error: AxiosError<HttpValidationError>) => {
|
||||||
|
console.error(error);
|
||||||
|
notifications.error({
|
||||||
|
message: error.response?.data?.detail as string | undefined,
|
||||||
|
});
|
||||||
|
refetchProjects();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createProject = useMutation({
|
||||||
|
...createProjectMutation(),
|
||||||
|
onError,
|
||||||
|
onSuccess: res => {
|
||||||
|
setProjects([...projects, res.project]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateProject = useMutation({
|
||||||
|
...updateProjectMutation(),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteProject = useMutation({
|
||||||
|
...deleteProjectMutation(),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCreateProject = (name: string) => {
|
||||||
|
createProject.mutate({
|
||||||
|
body: {
|
||||||
|
project: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateProject = (
|
||||||
|
projectId: number,
|
||||||
|
project: UpdateProjectSchema
|
||||||
|
) => {
|
||||||
|
updateProject.mutate({
|
||||||
|
query: { projectId },
|
||||||
|
body: { project },
|
||||||
|
});
|
||||||
|
|
||||||
|
setProjects(boards =>
|
||||||
|
boards.map(oldProject =>
|
||||||
|
oldProject.id !== projectId
|
||||||
|
? oldProject
|
||||||
|
: {
|
||||||
|
id: oldProject.id,
|
||||||
|
name: project.name ? project.name : oldProject.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteProject = (project: ProjectSchema) => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: "Удаление проекта",
|
||||||
|
children: (
|
||||||
|
<Text>
|
||||||
|
Вы уверены, что хотите удалить проект "{project.name}"?
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: "Да", cancel: "Нет" },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: () => {
|
||||||
|
deleteProject.mutate({
|
||||||
|
query: { projectId: project.id },
|
||||||
|
});
|
||||||
|
setProjects(projects =>
|
||||||
|
projects.filter(p => p.id !== project.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onCreateProject,
|
||||||
|
onUpdateProject,
|
||||||
|
onDeleteProject,
|
||||||
|
};
|
||||||
|
};
|
||||||
130
src/hooks/useStatusesOperations.tsx
Normal file
130
src/hooks/useStatusesOperations.tsx
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { LexoRank } from "lexorank";
|
||||||
|
import { Text } from "@mantine/core";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import {
|
||||||
|
HttpValidationError,
|
||||||
|
StatusSchema,
|
||||||
|
UpdateStatusSchema,
|
||||||
|
} from "@/lib/client";
|
||||||
|
import {
|
||||||
|
createStatusMutation,
|
||||||
|
deleteStatusMutation,
|
||||||
|
updateStatusMutation,
|
||||||
|
} from "@/lib/client/@tanstack/react-query.gen";
|
||||||
|
import { notifications } from "@/lib/notifications";
|
||||||
|
import { getMaxByLexorank, getNewLexorank } from "@/utils/lexorank";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
statuses: StatusSchema[];
|
||||||
|
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
|
||||||
|
refetchStatuses: () => void;
|
||||||
|
boardId?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatusesOperations = {
|
||||||
|
onCreateStatus: (name: string) => void;
|
||||||
|
onUpdateStatus: (statusId: number, status: UpdateStatusSchema) => void;
|
||||||
|
onDeleteStatus: (status: StatusSchema) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useStatusesOperations = ({
|
||||||
|
statuses,
|
||||||
|
setStatuses,
|
||||||
|
refetchStatuses,
|
||||||
|
boardId,
|
||||||
|
}: Props): StatusesOperations => {
|
||||||
|
const onError = (error: AxiosError<HttpValidationError>) => {
|
||||||
|
console.error(error);
|
||||||
|
notifications.error({
|
||||||
|
message: error.response?.data?.detail as string | undefined,
|
||||||
|
});
|
||||||
|
refetchStatuses();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStatus = useMutation({
|
||||||
|
...createStatusMutation(),
|
||||||
|
onError,
|
||||||
|
onSuccess: res => {
|
||||||
|
setStatuses([...statuses, res.status]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStatus = useMutation({
|
||||||
|
...updateStatusMutation(),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteStatus = useMutation({
|
||||||
|
...deleteStatusMutation(),
|
||||||
|
onError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCreateStatus = (name: string) => {
|
||||||
|
if (!boardId) return;
|
||||||
|
const lastStatus = getMaxByLexorank(statuses);
|
||||||
|
const newLexorank = getNewLexorank(
|
||||||
|
lastStatus ? LexoRank.parse(lastStatus.lexorank) : null
|
||||||
|
);
|
||||||
|
|
||||||
|
createStatus.mutate({
|
||||||
|
body: {
|
||||||
|
status: {
|
||||||
|
name,
|
||||||
|
boardId,
|
||||||
|
lexorank: newLexorank.toString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpdateStatus = (statusId: number, status: UpdateStatusSchema) => {
|
||||||
|
updateStatus.mutate({
|
||||||
|
path: { statusId },
|
||||||
|
body: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatuses(statuses =>
|
||||||
|
statuses.map(oldStatus =>
|
||||||
|
oldStatus.id !== statusId
|
||||||
|
? oldStatus
|
||||||
|
: {
|
||||||
|
id: oldStatus.id,
|
||||||
|
name: status.name ? status.name : oldStatus.name,
|
||||||
|
lexorank: status.lexorank
|
||||||
|
? status.lexorank
|
||||||
|
: oldStatus.lexorank,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDeleteStatus = (status: StatusSchema) => {
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: "Удаление колонки",
|
||||||
|
children: (
|
||||||
|
<Text>
|
||||||
|
Вы уверены, что хотите удалить колонку "{status.name}"?
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
labels: { confirm: "Да", cancel: "Нет" },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: () => {
|
||||||
|
deleteStatus.mutate({
|
||||||
|
path: { statusId: status.id },
|
||||||
|
});
|
||||||
|
setStatuses(statuses =>
|
||||||
|
statuses.filter(s => s.id !== status.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onCreateStatus,
|
||||||
|
onUpdateStatus,
|
||||||
|
onDeleteStatus,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -1,22 +1,80 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import { type Options, getBoards, updateBoard, getDeals, updateDeal, getProjects, getStatuses, updateStatus } from '../sdk.gen';
|
import { queryOptions, type UseMutationOptions } from "@tanstack/react-query";
|
||||||
import { queryOptions, type UseMutationOptions } from '@tanstack/react-query';
|
import type { AxiosError } from "axios";
|
||||||
import type { GetBoardsData, UpdateBoardData, UpdateBoardError, UpdateBoardResponse2, GetDealsData, UpdateDealData, UpdateDealError, UpdateDealResponse2, GetProjectsData, GetStatusesData, UpdateStatusData, UpdateStatusError, UpdateStatusResponse2 } from '../types.gen';
|
import { client as _heyApiClient } from "../client.gen";
|
||||||
import type { AxiosError } from 'axios';
|
import {
|
||||||
import { client as _heyApiClient } from '../client.gen';
|
createBoard,
|
||||||
|
createProject,
|
||||||
|
createStatus,
|
||||||
|
deleteBoard,
|
||||||
|
deleteProject,
|
||||||
|
deleteStatus,
|
||||||
|
getBoards,
|
||||||
|
getDeals,
|
||||||
|
getProjects,
|
||||||
|
getStatuses,
|
||||||
|
updateBoard,
|
||||||
|
updateDeal,
|
||||||
|
updateProject,
|
||||||
|
updateStatus,
|
||||||
|
type Options,
|
||||||
|
} from "../sdk.gen";
|
||||||
|
import type {
|
||||||
|
CreateBoardData,
|
||||||
|
CreateBoardError,
|
||||||
|
CreateBoardResponse2,
|
||||||
|
CreateProjectData,
|
||||||
|
CreateProjectError,
|
||||||
|
CreateProjectResponse2,
|
||||||
|
CreateStatusData,
|
||||||
|
CreateStatusError,
|
||||||
|
CreateStatusResponse2,
|
||||||
|
DeleteBoardData,
|
||||||
|
DeleteBoardError,
|
||||||
|
DeleteBoardResponse2,
|
||||||
|
DeleteProjectData,
|
||||||
|
DeleteProjectError,
|
||||||
|
DeleteProjectResponse2,
|
||||||
|
DeleteStatusData,
|
||||||
|
DeleteStatusError,
|
||||||
|
DeleteStatusResponse2,
|
||||||
|
GetBoardsData,
|
||||||
|
GetDealsData,
|
||||||
|
GetProjectsData,
|
||||||
|
GetStatusesData,
|
||||||
|
UpdateBoardData,
|
||||||
|
UpdateBoardError,
|
||||||
|
UpdateBoardResponse2,
|
||||||
|
UpdateDealData,
|
||||||
|
UpdateDealError,
|
||||||
|
UpdateDealResponse2,
|
||||||
|
UpdateProjectData,
|
||||||
|
UpdateProjectError,
|
||||||
|
UpdateProjectResponse2,
|
||||||
|
UpdateStatusData,
|
||||||
|
UpdateStatusError,
|
||||||
|
UpdateStatusResponse2,
|
||||||
|
} from "../types.gen";
|
||||||
|
|
||||||
export type QueryKey<TOptions extends Options> = [
|
export type QueryKey<TOptions extends Options> = [
|
||||||
Pick<TOptions, 'baseURL' | 'body' | 'headers' | 'path' | 'query'> & {
|
Pick<TOptions, "baseURL" | "body" | "headers" | "path" | "query"> & {
|
||||||
_id: string;
|
_id: string;
|
||||||
_infinite?: boolean;
|
_infinite?: boolean;
|
||||||
}
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, infinite?: boolean): [
|
const createQueryKey = <TOptions extends Options>(
|
||||||
QueryKey<TOptions>[0]
|
id: string,
|
||||||
] => {
|
options?: TOptions,
|
||||||
const params: QueryKey<TOptions>[0] = { _id: id, baseURL: options?.baseURL || (options?.client ?? _heyApiClient).getConfig().baseURL } as QueryKey<TOptions>[0];
|
infinite?: boolean
|
||||||
|
): [QueryKey<TOptions>[0]] => {
|
||||||
|
const params: QueryKey<TOptions>[0] = {
|
||||||
|
_id: id,
|
||||||
|
baseURL:
|
||||||
|
options?.baseURL ||
|
||||||
|
(options?.client ?? _heyApiClient).getConfig().baseURL,
|
||||||
|
} as QueryKey<TOptions>[0];
|
||||||
if (infinite) {
|
if (infinite) {
|
||||||
params._infinite = infinite;
|
params._infinite = infinite;
|
||||||
}
|
}
|
||||||
@ -32,12 +90,11 @@ const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions
|
|||||||
if (options?.query) {
|
if (options?.query) {
|
||||||
params.query = options.query;
|
params.query = options.query;
|
||||||
}
|
}
|
||||||
return [
|
return [params];
|
||||||
params
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getBoardsQueryKey = (options: Options<GetBoardsData>) => createQueryKey('getBoards', options);
|
export const getBoardsQueryKey = (options: Options<GetBoardsData>) =>
|
||||||
|
createQueryKey("getBoards", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Boards
|
* Get Boards
|
||||||
@ -49,32 +106,118 @@ export const getBoardsOptions = (options: Options<GetBoardsData>) => {
|
|||||||
...options,
|
...options,
|
||||||
...queryKey[0],
|
...queryKey[0],
|
||||||
signal,
|
signal,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
queryKey: getBoardsQueryKey(options)
|
queryKey: getBoardsQueryKey(options),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createBoardQueryKey = (options: Options<CreateBoardData>) =>
|
||||||
|
createQueryKey("createBoard", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Board
|
||||||
|
*/
|
||||||
|
export const createBoardOptions = (options: Options<CreateBoardData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await createBoard({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: createBoardQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Board
|
||||||
|
*/
|
||||||
|
export const createBoardMutation = (
|
||||||
|
options?: Partial<Options<CreateBoardData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
CreateBoardResponse2,
|
||||||
|
AxiosError<CreateBoardError>,
|
||||||
|
Options<CreateBoardData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
CreateBoardResponse2,
|
||||||
|
AxiosError<CreateBoardError>,
|
||||||
|
Options<CreateBoardData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await createBoard({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Board
|
||||||
|
*/
|
||||||
|
export const deleteBoardMutation = (
|
||||||
|
options?: Partial<Options<DeleteBoardData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
DeleteBoardResponse2,
|
||||||
|
AxiosError<DeleteBoardError>,
|
||||||
|
Options<DeleteBoardData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
DeleteBoardResponse2,
|
||||||
|
AxiosError<DeleteBoardError>,
|
||||||
|
Options<DeleteBoardData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await deleteBoard({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update Board
|
* Update Board
|
||||||
*/
|
*/
|
||||||
export const updateBoardMutation = (options?: Partial<Options<UpdateBoardData>>): UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> => {
|
export const updateBoardMutation = (
|
||||||
const mutationOptions: UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> = {
|
options?: Partial<Options<UpdateBoardData>>
|
||||||
mutationFn: async (localOptions) => {
|
): UseMutationOptions<
|
||||||
|
UpdateBoardResponse2,
|
||||||
|
AxiosError<UpdateBoardError>,
|
||||||
|
Options<UpdateBoardData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
UpdateBoardResponse2,
|
||||||
|
AxiosError<UpdateBoardError>,
|
||||||
|
Options<UpdateBoardData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
const { data } = await updateBoard({
|
const { data } = await updateBoard({
|
||||||
...options,
|
...options,
|
||||||
...localOptions,
|
...localOptions,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDealsQueryKey = (options: Options<GetDealsData>) => createQueryKey('getDeals', options);
|
export const getDealsQueryKey = (options: Options<GetDealsData>) =>
|
||||||
|
createQueryKey("getDeals", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Deals
|
* Get Deals
|
||||||
@ -86,32 +229,43 @@ export const getDealsOptions = (options: Options<GetDealsData>) => {
|
|||||||
...options,
|
...options,
|
||||||
...queryKey[0],
|
...queryKey[0],
|
||||||
signal,
|
signal,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
queryKey: getDealsQueryKey(options)
|
queryKey: getDealsQueryKey(options),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update Deal
|
* Update Deal
|
||||||
*/
|
*/
|
||||||
export const updateDealMutation = (options?: Partial<Options<UpdateDealData>>): UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> => {
|
export const updateDealMutation = (
|
||||||
const mutationOptions: UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> = {
|
options?: Partial<Options<UpdateDealData>>
|
||||||
mutationFn: async (localOptions) => {
|
): UseMutationOptions<
|
||||||
|
UpdateDealResponse2,
|
||||||
|
AxiosError<UpdateDealError>,
|
||||||
|
Options<UpdateDealData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
UpdateDealResponse2,
|
||||||
|
AxiosError<UpdateDealError>,
|
||||||
|
Options<UpdateDealData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
const { data } = await updateDeal({
|
const { data } = await updateDeal({
|
||||||
...options,
|
...options,
|
||||||
...localOptions,
|
...localOptions,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectsQueryKey = (options?: Options<GetProjectsData>) => createQueryKey('getProjects', options);
|
export const getProjectsQueryKey = (options?: Options<GetProjectsData>) =>
|
||||||
|
createQueryKey("getProjects", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Projects
|
* Get Projects
|
||||||
@ -123,15 +277,118 @@ export const getProjectsOptions = (options?: Options<GetProjectsData>) => {
|
|||||||
...options,
|
...options,
|
||||||
...queryKey[0],
|
...queryKey[0],
|
||||||
signal,
|
signal,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
queryKey: getProjectsQueryKey(options)
|
queryKey: getProjectsQueryKey(options),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStatusesQueryKey = (options: Options<GetStatusesData>) => createQueryKey('getStatuses', options);
|
export const createProjectQueryKey = (options: Options<CreateProjectData>) =>
|
||||||
|
createQueryKey("createProject", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Project
|
||||||
|
*/
|
||||||
|
export const createProjectOptions = (options: Options<CreateProjectData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await createProject({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: createProjectQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Project
|
||||||
|
*/
|
||||||
|
export const createProjectMutation = (
|
||||||
|
options?: Partial<Options<CreateProjectData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
CreateProjectResponse2,
|
||||||
|
AxiosError<CreateProjectError>,
|
||||||
|
Options<CreateProjectData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
CreateProjectResponse2,
|
||||||
|
AxiosError<CreateProjectError>,
|
||||||
|
Options<CreateProjectData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await createProject({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Project
|
||||||
|
*/
|
||||||
|
export const deleteProjectMutation = (
|
||||||
|
options?: Partial<Options<DeleteProjectData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
DeleteProjectResponse2,
|
||||||
|
AxiosError<DeleteProjectError>,
|
||||||
|
Options<DeleteProjectData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
DeleteProjectResponse2,
|
||||||
|
AxiosError<DeleteProjectError>,
|
||||||
|
Options<DeleteProjectData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await deleteProject({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Project
|
||||||
|
*/
|
||||||
|
export const updateProjectMutation = (
|
||||||
|
options?: Partial<Options<UpdateProjectData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
UpdateProjectResponse2,
|
||||||
|
AxiosError<UpdateProjectError>,
|
||||||
|
Options<UpdateProjectData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
UpdateProjectResponse2,
|
||||||
|
AxiosError<UpdateProjectError>,
|
||||||
|
Options<UpdateProjectData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await updateProject({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getStatusesQueryKey = (options: Options<GetStatusesData>) =>
|
||||||
|
createQueryKey("getStatuses", options);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Statuses
|
* Get Statuses
|
||||||
@ -143,27 +400,112 @@ export const getStatusesOptions = (options: Options<GetStatusesData>) => {
|
|||||||
...options,
|
...options,
|
||||||
...queryKey[0],
|
...queryKey[0],
|
||||||
signal,
|
signal,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
queryKey: getStatusesQueryKey(options)
|
queryKey: getStatusesQueryKey(options),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createStatusQueryKey = (options: Options<CreateStatusData>) =>
|
||||||
|
createQueryKey("createStatus", options);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Status
|
||||||
|
*/
|
||||||
|
export const createStatusOptions = (options: Options<CreateStatusData>) => {
|
||||||
|
return queryOptions({
|
||||||
|
queryFn: async ({ queryKey, signal }) => {
|
||||||
|
const { data } = await createStatus({
|
||||||
|
...options,
|
||||||
|
...queryKey[0],
|
||||||
|
signal,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
queryKey: createStatusQueryKey(options),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create Status
|
||||||
|
*/
|
||||||
|
export const createStatusMutation = (
|
||||||
|
options?: Partial<Options<CreateStatusData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
CreateStatusResponse2,
|
||||||
|
AxiosError<CreateStatusError>,
|
||||||
|
Options<CreateStatusData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
CreateStatusResponse2,
|
||||||
|
AxiosError<CreateStatusError>,
|
||||||
|
Options<CreateStatusData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await createStatus({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete Status
|
||||||
|
*/
|
||||||
|
export const deleteStatusMutation = (
|
||||||
|
options?: Partial<Options<DeleteStatusData>>
|
||||||
|
): UseMutationOptions<
|
||||||
|
DeleteStatusResponse2,
|
||||||
|
AxiosError<DeleteStatusError>,
|
||||||
|
Options<DeleteStatusData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
DeleteStatusResponse2,
|
||||||
|
AxiosError<DeleteStatusError>,
|
||||||
|
Options<DeleteStatusData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
|
const { data } = await deleteStatus({
|
||||||
|
...options,
|
||||||
|
...localOptions,
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return mutationOptions;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update Status
|
* Update Status
|
||||||
*/
|
*/
|
||||||
export const updateStatusMutation = (options?: Partial<Options<UpdateStatusData>>): UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> => {
|
export const updateStatusMutation = (
|
||||||
const mutationOptions: UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> = {
|
options?: Partial<Options<UpdateStatusData>>
|
||||||
mutationFn: async (localOptions) => {
|
): UseMutationOptions<
|
||||||
|
UpdateStatusResponse2,
|
||||||
|
AxiosError<UpdateStatusError>,
|
||||||
|
Options<UpdateStatusData>
|
||||||
|
> => {
|
||||||
|
const mutationOptions: UseMutationOptions<
|
||||||
|
UpdateStatusResponse2,
|
||||||
|
AxiosError<UpdateStatusError>,
|
||||||
|
Options<UpdateStatusData>
|
||||||
|
> = {
|
||||||
|
mutationFn: async localOptions => {
|
||||||
const { data } = await updateStatus({
|
const { data } = await updateStatus({
|
||||||
...options,
|
...options,
|
||||||
...localOptions,
|
...localOptions,
|
||||||
throwOnError: true
|
throwOnError: true,
|
||||||
});
|
});
|
||||||
return data;
|
return data;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
return mutationOptions;
|
return mutationOptions;
|
||||||
};
|
};
|
||||||
@ -1,8 +1,13 @@
|
|||||||
// This file is auto-generated by @hey-api/openapi-ts
|
// This file is auto-generated by @hey-api/openapi-ts
|
||||||
|
|
||||||
import type { ClientOptions } from './types.gen';
|
import { createClientConfig } from "../../hey-api-config";
|
||||||
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client';
|
import {
|
||||||
import { createClientConfig } from '../../hey-api-config';
|
createClient,
|
||||||
|
createConfig,
|
||||||
|
type Config,
|
||||||
|
type ClientOptions as DefaultClientOptions,
|
||||||
|
} from "./client";
|
||||||
|
import type { ClientOptions } from "./types.gen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `createClientConfig()` function will be called on client initialization
|
* The `createClientConfig()` function will be called on client initialization
|
||||||
@ -12,8 +17,15 @@ import { createClientConfig } from '../../hey-api-config';
|
|||||||
* `setConfig()`. This is useful for example if you're using Next.js
|
* `setConfig()`. This is useful for example if you're using Next.js
|
||||||
* to ensure your client always has the correct values.
|
* to ensure your client always has the correct values.
|
||||||
*/
|
*/
|
||||||
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> = (override?: Config<DefaultClientOptions & T>) => Config<Required<DefaultClientOptions> & T>;
|
export type CreateClientConfig<T extends DefaultClientOptions = ClientOptions> =
|
||||||
|
(
|
||||||
|
override?: Config<DefaultClientOptions & T>
|
||||||
|
) => Config<Required<DefaultClientOptions> & T>;
|
||||||
|
|
||||||
export const client = createClient(createClientConfig(createConfig<ClientOptions>({
|
export const client = createClient(
|
||||||
baseURL: '/api'
|
createClientConfig(
|
||||||
})));
|
createConfig<ClientOptions>({
|
||||||
|
baseURL: "/api",
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user