Compare commits

31 Commits

Author SHA1 Message Date
095f3dae76 add environment variable for API URL and update client configuration 2025-08-05 20:17:46 +03:00
cd034bcce6 refactor: store folder for redux 2025-08-05 21:04:23 +04:00
316cca712d refactor: moved client to lib/client 2025-08-05 20:51:55 +04:00
74f7cc7664 feat: add hey-api configuration and update OpenAPI TypeScript plugin settings 2025-08-05 17:48:33 +03:00
7bb8ab97c7 feat: add zod library to dependencies 2025-08-05 17:36:36 +03:00
abbf782945 refactor: straightened logic, replaces throttle with mantine debounced 2025-08-05 17:47:39 +04:00
c13cc4a0a5 feat: pointer cursor for boards and deals 2025-08-05 16:52:26 +04:00
236c0dcf10 feat: deal updating on the server 2025-08-05 16:33:04 +04:00
c98a5cc811 feat: status updating on the server and statuses fetching 2025-08-04 18:49:27 +04:00
24de9f5446 feat: board updating on the server 2025-08-04 16:57:54 +04:00
f13417e73a fix: fixed deal dragging end 2025-08-04 11:20:22 +04:00
2ae9c619c7 fix: fixed dragging of deal in the same status 2025-08-04 00:13:40 +04:00
315e7db3db feat: deals fetch 2025-08-03 16:55:36 +04:00
5435750fb5 feat: boards with statuses fetch 2025-08-03 13:40:09 +04:00
624c94155c fix: replaces old project schema 2025-08-03 11:29:04 +04:00
3e1d544b33 feat: hey-api and projects fetch 2025-08-03 11:07:56 +04:00
459487a896 refactor: refactoring of deals and statuses dnd 2025-08-02 10:58:24 +04:00
8ae198897d feat: optimization of render during dnd 2025-08-02 09:56:35 +04:00
586af488da feat: raw statuses dnd 2025-08-01 17:50:27 +04:00
943b2d63f5 feat: grabbing cursor for deals dnd 2025-08-01 14:30:42 +04:00
921ab4c89f feat: scrolls for statuses and boards 2025-08-01 12:28:40 +04:00
d13997ba80 fix: deals setting during dragOver optimization 2025-08-01 11:59:56 +04:00
5137836265 fix: fixed rerender of boards component after changes in statuses 2025-08-01 11:08:53 +04:00
5fe9ea6747 feat: raw deals dnd between statuses 2025-08-01 10:01:39 +04:00
8af4a908e6 fix: moved projects from redux to context 2025-07-30 22:11:31 +04:00
128a1b3c4f feat: tanstack query provider 2025-07-30 18:26:15 +04:00
cb168b6415 feat: projects redux storage and select 2025-07-30 17:44:30 +04:00
b8d431ae99 feat: raw boards dnd 2025-07-30 10:59:39 +04:00
cb6a814918 feat: openapi client generation 2025-07-28 17:42:25 +04:00
fe6e87f97c feat: modals 2025-07-27 12:32:56 +04:00
948480c219 feat: notifications, redux, tailwind 2025-07-27 11:41:43 +04:00
94 changed files with 14172 additions and 2211 deletions

1
.env.example Normal file
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://api.example.com/api

View File

@ -1,3 +0,0 @@
*.js
*.mjs
*.cjs

View File

@ -1,3 +0,0 @@
{
"extends": "next/core-web-vitals"
}

9
.gitignore vendored
View File

@ -102,7 +102,6 @@ dist
# vuepress v2.x temp and cache directory # vuepress v2.x temp and cache directory
.temp .temp
.cache
# Docusaurus cache and generated files # Docusaurus cache and generated files
.docusaurus .docusaurus
@ -123,11 +122,9 @@ dist
.vscode-test .vscode-test
# yarn v2 # yarn v2
.yarn/cache .yarn
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
.DS_Store .DS_Store
.idea .idea
.yarnrc.yml

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v24.3.0

View File

@ -1,6 +1 @@
# Ignore artifacts: .next
build
coverage
# Ignore all HTML files:
**/*.html

View File

@ -1,6 +1,39 @@
{ {
"trailingComma": "es5", "singleAttributePerLine": true,
"tabWidth": 4, "singleQuote": false,
"semi": false, "semi": true,
"singleQuote": false "quoteProps": "consistent",
"bracketSpacing": true,
"trailingComma": "es5",
"tabWidth": 4,
"bracketSameLine": true,
"arrowParens": "avoid",
"plugins": [
"@ianvs/prettier-plugin-sort-imports"
],
"importOrder": [
".*styles.css$",
"dayjs",
"^react$",
"^next$",
"^next/.*$",
"<BUILTIN_MODULES>",
"<THIRD_PARTY_MODULES>",
"^@mantine/(.*)$",
"^@mantinex/(.*)$",
"^@mantine-tests/(.*)$",
"^@docs/(.*)$",
"^@/.*$",
"^../(?!.*.css$).*$",
"^./(?!.*.css$).*$",
"\\.css$"
],
"overrides": [
{
"files": "*.mdx",
"options": {
"printWidth": 70
}
}
]
} }

2
.stylelintignore Normal file
View File

@ -0,0 +1,2 @@
.next
out

28
.stylelintrc.json Normal file
View File

@ -0,0 +1,28 @@
{
"extends": ["stylelint-config-standard-scss"],
"rules": {
"custom-property-pattern": null,
"selector-class-pattern": null,
"scss/no-duplicate-mixins": null,
"declaration-empty-line-before": null,
"declaration-block-no-redundant-longhand-properties": null,
"alpha-value-notation": null,
"custom-property-empty-line-before": null,
"property-no-vendor-prefix": null,
"color-function-notation": null,
"length-zero-no-unit": null,
"selector-not-notation": null,
"no-descending-specificity": null,
"comment-empty-line-before": null,
"scss/at-mixin-pattern": null,
"scss/at-rule-no-unknown": null,
"value-keyword-case": null,
"media-feature-range-notation": null,
"selector-pseudo-class-no-unknown": [
true,
{
"ignorePseudoClasses": ["global"]
}
]
}
}

View File

@ -1,5 +1,37 @@
# Mantine Next Template # Mantine Next.js template
Get started with the template by clicking `Use this template` button on the top of the page. This is a template for [Next.js](https://nextjs.org/) app router + [Mantine](https://mantine.dev/).
If you want to use pages router instead, see [next-pages-template](https://github.com/mantinedev/next-pages-template).
[Documentation](https://mantine.dev/guides/next/) ## Features
This template comes with the following features:
- [PostCSS](https://postcss.org/) with [mantine-postcss-preset](https://mantine.dev/styles/postcss-preset)
- [TypeScript](https://www.typescriptlang.org/)
- [Storybook](https://storybook.js.org/)
- [Jest](https://jestjs.io/) setup with [React Testing Library](https://testing-library.com/docs/react-testing-library/intro)
- ESLint setup with [eslint-config-mantine](https://github.com/mantinedev/eslint-config-mantine)
## npm scripts
### Build and dev scripts
- `dev` start dev server
- `build` bundle application for production
- `analyze` analyzes application bundle with [@next/bundle-analyzer](https://www.npmjs.com/package/@next/bundle-analyzer)
### Testing scripts
- `typecheck` checks TypeScript types
- `lint` runs ESLint
- `prettier:check` checks files with Prettier
- `jest` runs jest tests
- `jest:watch` starts jest watch
- `test` runs `jest`, `prettier:check`, `lint` and `typecheck` scripts
### Other scripts
- `storybook` starts storybook dev server
- `storybook:build` build production storybook bundle to `storybook-static`
- `prettier:write` formats all files with Prettier

30
eslint.config.mjs Normal file
View File

@ -0,0 +1,30 @@
import mantine from "eslint-config-mantine";
import tseslint from "typescript-eslint";
export default tseslint.config(
...mantine,
{ ignores: ["**/*.{mjs,cjs,js,d.ts,d.mts}"] },
{
files: ["**/*.story.tsx"],
rules: {
"no-console": "off",
},
},
{
files: ["**/*.{ts,tsx}"],
rules: {
"no-console": "off",
"react/jsx-curly-brace-presence": "off",
"curly": "off",
},
},
{
files: ["src/client/**/*.{ts,tsx}"],
rules: {
"import/no-useless-path-segments": "off",
},
linterOptions: {
reportUnusedDisableDirectives: false,
},
}
);

16
jest.config.cjs Normal file
View File

@ -0,0 +1,16 @@
const nextJest = require('next/jest');
const createJestConfig = nextJest({
dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
moduleNameMapper: {
'^@/components/(.*)$': '<rootDir>/components/$1',
'^@/pages/(.*)$': '<rootDir>/pages/$1',
},
testEnvironment: 'jest-environment-jsdom',
};
module.exports = createJestConfig(customJestConfig);

27
jest.setup.cjs Normal file
View File

@ -0,0 +1,27 @@
require('@testing-library/jest-dom');
const { getComputedStyle } = window;
window.getComputedStyle = (elt) => getComputedStyle(elt);
window.HTMLElement.prototype.scrollIntoView = () => {};
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
window.ResizeObserver = ResizeObserver;

View File

@ -1,12 +1,16 @@
/** @type {import('next').NextConfig} */ import bundleAnalyzer from '@next/bundle-analyzer';
const nextConfig = {
reactStrictMode: true,
experimental: {
optimizePackageImports: [
"@mantine/core",
"@mantine/hooks",
],
},
}
export default nextConfig const withBundleAnalyzer = bundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
});
export default withBundleAnalyzer({
output: "standalone",
reactStrictMode: false,
eslint: {
ignoreDuringBuilds: true,
},
experimental: {
optimizePackageImports: ['@mantine/core', '@mantine/hooks'],
},
});

28
openapi-ts.config.ts Normal file
View File

@ -0,0 +1,28 @@
import { defineConfig } from "@hey-api/openapi-ts";
export default defineConfig({
input: "http://localhost:8000/openapi.json",
output: "src/lib/client",
plugins: [
"@hey-api/client-axios",
"@tanstack/react-query",
"@hey-api/typescript",
{
name: "zod",
requests: true,
responses: true,
definitions: true,
metadata: true,
},
{
name: "@hey-api/sdk",
asClass: false,
validator: "zod",
},
{
name: "@hey-api/client-next",
runtimeConfigPath: "./src/hey-api-config.ts",
},
],
});

View File

@ -1,46 +1,83 @@
{ {
"name": "crm-frontend", "name": "crm-frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"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"
"dependencies": { },
"@mantine/core": "^8.2.1", "dependencies": {
"@mantine/dates": "^8.2.1", "@dnd-kit/core": "^6.3.1",
"@mantine/dropzone": "^8.2.1", "@dnd-kit/sortable": "^10.0.0",
"@mantine/form": "^8.2.1", "@mantine/core": "8.1.2",
"@mantine/hooks": "^8.2.1", "@mantine/form": "^8.1.3",
"@mantine/modals": "^8.2.1", "@mantine/hooks": "8.1.2",
"@mantine/notifications": "^8.2.1", "@mantine/modals": "^8.2.1",
"@reduxjs/toolkit": "^2.8.2", "@mantine/notifications": "^8.2.1",
"@tabler/icons-react": "^3.34.1", "@next/bundle-analyzer": "^15.3.3",
"@tailwindcss/postcss": "^4.1.11", "@reduxjs/toolkit": "^2.8.2",
"@tanstack/react-query": "^5.83.0", "@tabler/icons-react": "^3.34.0",
"dayjs": "^1.11.13", "@tailwindcss/postcss": "^4.1.11",
"lodash": "^4.17.21", "@tanstack/react-query": "^5.83.0",
"next": "15.3.3", "axios": "^1.11.0",
"react": "19.1.0", "classnames": "^2.5.1",
"react-dom": "19.1.0", "framer-motion": "^12.23.7",
"react-redux": "^9.2.0", "i18n-iso-countries": "^7.14.0",
"tailwind-preset-mantine": "^2.1.0", "lexorank": "^1.0.5",
"tailwindcss": "^4.1.11" "libphonenumber-js": "^1.12.10",
}, "next": "15.3.3",
"devDependencies": { "react": "19.1.0",
"@types/lodash": "^4", "react-dom": "19.1.0",
"@types/node": "22.13.11", "react-imask": "^7.6.1",
"@types/react": "19.0.12", "react-redux": "^9.2.0",
"@types/react-dom": "19.0.4", "redux-persist": "^6.0.0",
"eslint": "9.23.0", "sharp": "^0.34.3",
"eslint-config-next": "15.2.3", "zod": "^4.0.14"
"postcss": "^8.5.3", },
"postcss-preset-mantine": "1.17.0", "devDependencies": {
"postcss-simple-vars": "^7.0.1", "@babel/core": "^7.27.4",
"prettier": "3.6.2", "@eslint/js": "^9.29.0",
"typescript": "5.8.2" "@hey-api/client-axios": "^0.9.1",
}, "@hey-api/client-next": "^0.5.1",
"packageManager": "yarn@4.9.2" "@hey-api/openapi-ts": "^0.80.1",
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
"@storybook/nextjs": "^8.6.8",
"@storybook/react": "^8.6.8",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/eslint-plugin-jsx-a11y": "^6",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.20",
"@types/node": "^22.13.11",
"@types/react": "19.1.8",
"@types/react-redux": "^7.1.34",
"@types/redux-persist": "^4.3.1",
"autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"eslint": "^9.29.0",
"eslint-config-mantine": "^4.0.3",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.3",
"storybook": "^8.6.8",
"storybook-dark-mode": "^4.0.2",
"stylelint": "^16.20.0",
"stylelint-config-standard-scss": "^15.0.1",
"tailwindcss": "^4.1.11",
"ts-jest": "^29.4.0",
"typescript": "5.8.3",
"typescript-eslint": "^8.34.0"
},
"packageManager": "yarn@4.9.2"
} }

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
plugins: { plugins: {
"@tailwindcss/postcss": {},
"postcss-preset-mantine": {}, "postcss-preset-mantine": {},
"@tailwindcss/postcss": {},
"postcss-simple-vars": { "postcss-simple-vars": {
variables: { variables: {
"mantine-breakpoint-xs": "36em", "mantine-breakpoint-xs": "36em",
@ -12,4 +12,4 @@ module.exports = {
}, },
}, },
}, },
} };

View File

@ -1 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><g fill="none" fill-rule="evenodd"><rect width="500" height="500" fill="#339AF0" rx="250"/><g fill="#FFF"><path fill-rule="nonzero" d="M202.055 135.706c-6.26 8.373-4.494 20.208 3.944 26.42 29.122 21.45 45.824 54.253 45.824 90.005 0 35.752-16.702 68.559-45.824 90.005-8.436 6.215-10.206 18.043-3.944 26.42 6.26 8.378 18.173 10.13 26.611 3.916a153.835 153.835 0 0024.509-22.54h53.93c10.506 0 19.023-8.455 19.023-18.885 0-10.43-8.517-18.886-19.023-18.886h-29.79c8.196-18.594 12.553-38.923 12.553-60.03s-4.357-41.436-12.552-60.03h29.79c10.505 0 19.022-8.455 19.022-18.885 0-10.43-8.517-18.886-19.023-18.886h-53.93a153.835 153.835 0 00-24.509-22.54c-8.438-6.215-20.351-4.46-26.61 3.916z"/><path d="M171.992 246.492c0-15.572 12.624-28.195 28.196-28.195 15.572 0 28.195 12.623 28.195 28.195 0 15.572-12.623 28.196-28.195 28.196-15.572 0-28.196-12.624-28.196-28.196z"/></g></g></svg> <svg width="41" height="47" viewBox="0 0 41 47" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_431_24446)">
<path opacity="0.958" fill-rule="evenodd" clip-rule="evenodd" d="M20.2179 -0.0939941C20.406 -0.0939941 20.5941 -0.0939941 20.7822 -0.0939941C27.0194 3.59767 33.2885 7.26367 39.5895 10.904C33.2187 14.6831 26.8242 18.4118 20.406 22.09C13.9877 18.4118 7.59324 14.6831 1.22253 10.904C7.56616 7.23297 13.898 3.56697 20.2179 -0.0939941ZM19.6537 3.85401C19.9938 3.85239 20.3073 3.94639 20.5941 4.13601C24.2301 6.39201 27.8663 8.64801 31.5024 10.904C23.6659 11.0293 15.8296 11.0293 7.99318 10.904C11.9233 8.59642 15.8101 6.24642 19.6537 3.85401Z" fill="#44A8C6"/>
<path opacity="0.962" fill-rule="evenodd" clip-rule="evenodd" d="M-0.0939941 13.442C6.3424 16.991 12.7369 20.6257 19.0895 24.346C19.2776 31.8649 19.3402 39.3849 19.2776 46.906C19.0895 46.906 18.9014 46.906 18.7133 46.906C12.4971 43.203 6.22796 39.5684 -0.0939941 36.002C-0.0939941 28.482 -0.0939941 20.962 -0.0939941 13.442ZM2.91518 19.646C6.9531 26.4762 10.9653 33.3382 14.9519 40.232C10.9005 38.3163 6.91964 36.2169 3.00922 33.934C2.91518 29.1718 2.88385 24.4092 2.91518 19.646Z" fill="#334B63"/>
<path opacity="0.972" fill-rule="evenodd" clip-rule="evenodd" d="M40.906 13.442C40.906 21.0246 40.906 28.6074 40.906 36.19C34.5741 39.6675 28.305 43.2395 22.0986 46.906C21.9732 46.906 21.8479 46.906 21.7225 46.906C21.6911 39.3858 21.7225 31.8658 21.8165 24.346C28.1747 20.6832 34.5378 17.0485 40.906 13.442ZM25.8601 40.326C29.6787 33.4443 33.5969 26.6137 37.6147 19.834C37.7401 24.534 37.7401 29.234 37.6147 33.934C33.7364 36.1387 29.8183 38.2693 25.8601 40.326Z" fill="#3C83B4"/>
</g>
<defs>
<clipPath id="clip0_431_24446">
<rect width="41" height="47" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 937 B

After

Width:  |  Height:  |  Size: 1.8 KiB

16
src/.storybook/main.ts Normal file
View File

@ -0,0 +1,16 @@
import type { StorybookConfig } from '@storybook/nextjs';
const config: StorybookConfig = {
core: {
disableWhatsNewNotifications: true,
disableTelemetry: true,
enableCrashReports: false,
},
stories: ['../components/**/*.(stories|story).@(js|jsx|ts|tsx)'],
addons: ['storybook-dark-mode'],
framework: {
name: '@storybook/nextjs',
options: {},
},
};
export default config;

View File

@ -0,0 +1,36 @@
import '@mantine/core/styles.css';
import React, { useEffect } from 'react';
import { addons } from '@storybook/preview-api';
import { DARK_MODE_EVENT_NAME } from 'storybook-dark-mode';
import { MantineProvider, useMantineColorScheme } from '@mantine/core';
import { theme } from '../theme';
export const parameters = {
layout: 'fullscreen',
options: {
showPanel: false,
storySort: (a, b) => {
return a.title.localeCompare(b.title, undefined, { numeric: true });
},
},
};
const channel = addons.getChannel();
function ColorSchemeWrapper({ children }: { children: React.ReactNode }) {
const { setColorScheme } = useMantineColorScheme();
const handleColorScheme = (value: boolean) => setColorScheme(value ? 'dark' : 'light');
useEffect(() => {
channel.on(DARK_MODE_EVENT_NAME, handleColorScheme);
return () => channel.off(DARK_MODE_EVENT_NAME, handleColorScheme);
}, [channel]);
return <>{children}</>;
}
export const decorators = [
(renderStory: any) => <ColorSchemeWrapper>{renderStory()}</ColorSchemeWrapper>,
(renderStory: any) => <MantineProvider theme={theme}>{renderStory()}</MantineProvider>,
];

View File

@ -0,0 +1,13 @@
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;

View File

@ -0,0 +1,65 @@
"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;

View File

@ -0,0 +1,12 @@
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;

View File

@ -0,0 +1,25 @@
import React, { FC, useMemo } from "react";
import { Box } from "@mantine/core";
import DealCard from "@/app/deals/components/DealCard/DealCard";
import { DealSchema } from "@/lib/client";
import { SortableItem } from "@/components/SortableDnd/SortableItem";
type Props = {
deal: DealSchema;
};
const DealContainer: FC<Props> = ({ deal }) => {
const dealBody = useMemo(() => <DealCard deal={deal} />, [deal]);
return (
<Box>
<SortableItem
dragHandleStyle={{ cursor: "pointer" }}
id={deal.id}>
{dealBody}
</SortableItem>
</Box>
);
};
export default DealContainer;

View File

@ -0,0 +1,36 @@
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;

View File

@ -0,0 +1,24 @@
"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;

View File

@ -0,0 +1,37 @@
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;

View File

@ -0,0 +1,67 @@
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;

View File

@ -0,0 +1,76 @@
"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;

View File

@ -0,0 +1,80 @@
"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;

View File

@ -0,0 +1,80 @@
"use client";
import React, {
createContext,
FC,
useContext,
useEffect,
useState,
} from "react";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { BoardSchema } from "@/lib/client";
import useBoardsList from "@/hooks/useBoardsList";
type BoardsContextState = {
boards: BoardSchema[];
setBoards: React.Dispatch<React.SetStateAction<BoardSchema[]>>;
selectedBoard: BoardSchema | null;
setSelectedBoard: React.Dispatch<React.SetStateAction<BoardSchema | null>>;
refetchBoards: () => void;
};
const BoardsContext = createContext<BoardsContextState | undefined>(undefined);
const useBoardsContextState = () => {
const { selectedProject: project } = useProjectsContext();
const { boards, setBoards, refetch: refetchBoards } = useBoardsList({ projectId: project?.id });
const [selectedBoard, setSelectedBoard] = useState<BoardSchema | null>(
null
);
useEffect(() => {
if (boards.length > 0 && selectedBoard === null) {
setSelectedBoard(boards[0]);
return;
}
if (selectedBoard) {
let newBoard = boards.find(board => board.id === selectedBoard.id);
if (!newBoard && boards.length > 0) {
newBoard = boards[0];
}
setSelectedBoard(newBoard ?? null);
}
}, [boards]);
return {
boards,
setBoards,
selectedBoard,
setSelectedBoard,
refetchBoards,
};
};
type BoardsContextProviderProps = {
children: React.ReactNode;
};
export const BoardsContextProvider: FC<BoardsContextProviderProps> = ({
children,
}) => {
const state = useBoardsContextState();
return (
<BoardsContext.Provider value={state}>
{children}
</BoardsContext.Provider>
);
};
export const useBoardsContext = () => {
const context = useContext(BoardsContext);
if (!context) {
throw new Error(
"useBoardsContext must be used within a BoardsContextProvider"
);
}
return context;
};

View File

@ -0,0 +1,77 @@
"use client";
import React, {
createContext,
FC,
useContext,
useEffect,
useState,
} from "react";
import { ProjectSchema } from "@/lib/client";
import useProjectsList from "@/hooks/useProjectsList";
type ProjectsContextState = {
selectedProject: ProjectSchema | null;
setSelectedProject: React.Dispatch<
React.SetStateAction<ProjectSchema | null>
>;
projects: ProjectSchema[];
};
const ProjectsContext = createContext<ProjectsContextState | undefined>(
undefined
);
const useProjectsContextState = () => {
const { projects } = useProjectsList();
const [selectedProject, setSelectedProject] =
useState<ProjectSchema | null>(null);
useEffect(() => {
if (projects.length > 0) {
if (selectedProject) {
setSelectedProject(
projects.find(
project => project.id === selectedProject.id
) ?? null
);
return;
}
setSelectedProject(projects[0]);
return;
}
setSelectedProject(null);
}, [projects]);
return {
projects,
selectedProject,
setSelectedProject,
};
};
type ProjectsContextProviderProps = {
children: React.ReactNode;
};
export const ProjectsContextProvider: FC<ProjectsContextProviderProps> = ({
children,
}) => {
const state = useProjectsContextState();
return (
<ProjectsContext.Provider value={state}>
{children}
</ProjectsContext.Provider>
);
};
export const useProjectsContext = () => {
const context = useContext(ProjectsContext);
if (!context) {
throw new Error(
"useProjectsContext must be used within a ProjectsContextProvider"
);
}
return context;
};

View File

@ -0,0 +1,76 @@
"use client";
import React, { createContext, FC, useContext, useEffect } from "react";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { DealSchema, StatusSchema } from "@/lib/client";
import useDealsList from "@/hooks/useDealsList";
import useStatusesList from "@/hooks/useStatusesList";
type StatusesContextState = {
statuses: StatusSchema[];
setStatuses: React.Dispatch<React.SetStateAction<StatusSchema[]>>;
deals: DealSchema[];
setDeals: React.Dispatch<React.SetStateAction<DealSchema[]>>;
refetchStatuses: () => void;
refetchDeals: () => void;
};
const StatusesContext = createContext<StatusesContextState | undefined>(
undefined
);
const useStatusesContextState = () => {
const { selectedBoard } = useBoardsContext();
const {
statuses,
setStatuses,
refetch: refetchStatuses,
} = useStatusesList({
boardId: selectedBoard?.id,
});
const {
deals,
setDeals,
refetch: refetchDeals,
} = useDealsList({ boardId: selectedBoard?.id });
useEffect(() => {
refetchStatuses();
}, [selectedBoard]);
return {
statuses,
setStatuses,
deals,
setDeals,
refetchStatuses,
refetchDeals,
};
};
type StatusesContextProviderProps = {
children: React.ReactNode;
};
export const StatusesContextProvider: FC<StatusesContextProviderProps> = ({
children,
}) => {
const state = useStatusesContextState();
return (
<StatusesContext.Provider value={state}>
{children}
</StatusesContext.Provider>
);
};
export const useStatusesContext = () => {
const context = useContext(StatusesContext);
if (!context) {
throw new Error(
"useStatusesContext must be used within a StatusesContextProvider"
);
}
return context;
};

View File

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

View File

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

View File

@ -0,0 +1,26 @@
import {
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
const useDndSensors = () => {
const sensorOptions = {
activationConstraint: {
distance: 5,
},
};
return useSensors(
useSensor(PointerSensor, sensorOptions),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, sensorOptions)
);
};
export default useDndSensors;

28
src/app/deals/page.tsx Normal file
View File

@ -0,0 +1,28 @@
import { Divider } from "@mantine/core";
import Boards from "@/app/deals/components/Boards/Boards";
import Header from "@/app/deals/components/Header/Header";
import StatusColumns from "@/app/deals/components/StatusColumns/StatusColumns";
import { BoardsContextProvider } from "@/app/deals/contexts/BoardsContext";
import { ProjectsContextProvider } from "@/app/deals/contexts/ProjectsContext";
import { StatusesContextProvider } from "@/app/deals/contexts/StatusesContext";
import PageBlock from "@/components/PageBlock/PageBlock";
import PageContainer from "@/components/PageContainer/PageContainer";
export default function DealsPage() {
return (
<PageContainer>
<PageBlock>
<ProjectsContextProvider>
<BoardsContextProvider>
<Header />
<Boards />
<Divider my={"xl"} />
<StatusesContextProvider>
<StatusColumns />
</StatusesContextProvider>
</BoardsContextProvider>
</ProjectsContextProvider>
</PageBlock>
</PageContainer>
);
}

View File

@ -0,0 +1,6 @@
const STATUS_POSTFIX = "-status";
export const isStatusId = (rawId: string) => rawId.endsWith(STATUS_POSTFIX);
export const getStatusId = (rawId: string) =>
Number(rawId.replace(STATUS_POSTFIX, ""));

7
src/app/global.css Normal file
View File

@ -0,0 +1,7 @@
@import "tailwindcss";
body {
@mixin light {
background-color: whitesmoke;
}
}

View File

@ -1,2 +0,0 @@
@import "tailwind-preset-mantine";
@import "./theme.css";

View File

@ -1,41 +1,71 @@
import "@mantine/core/styles.css" import "@mantine/core/styles.css";
import "@mantine/dates/styles.css" import "@mantine/notifications/styles.css";
import "@mantine/notifications/styles.css" import { ReactNode } from "react";
import '@mantine/dropzone/styles.css';
import React from "react"
import { import {
ColorSchemeScript, ColorSchemeScript,
mantineHtmlProps, mantineHtmlProps,
MantineProvider, MantineProvider,
} from "@mantine/core" } from "@mantine/core";
import { theme } from "./theme" import { theme } from "@/theme";
import "./globals.css" import "@/app/global.css";
import { Notifications } from "@mantine/notifications" import { ModalsProvider } from "@mantine/modals";
import { ModalsProvider } from "@mantine/modals" import { Notifications } from "@mantine/notifications";
import { modals } from "@/modals/modals";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import ReduxProvider from "@/providers/ReduxProvider";
export const metadata = { export const metadata = {
title: "Mantine Next.js template", title: "CRM LogiDex",
description: "I am using Mantine with Next.js!", description: "CRM LogiDex",
};
declare module "@mantine/modals" {
export interface MantineModalsOverride {
modals: typeof modals;
}
} }
export default function RootLayout({ children }: { children: any }) { type Props = {
children: ReactNode;
};
export default function RootLayout({ children }: Props) {
return ( return (
<html lang="en" {...mantineHtmlProps}> <html
lang="ru"
{...mantineHtmlProps}>
<head> <head>
<ColorSchemeScript /> <ColorSchemeScript defaultColorScheme={"auto"} />
<link rel="shortcut icon" href="/favicon.svg" /> <link
rel="shortcut icon"
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"
/> />
<title />
</head> </head>
<body> <body>
<MantineProvider theme={theme}> <MantineProvider
<Notifications /> theme={theme}
<ModalsProvider>{children}</ModalsProvider> defaultColorScheme={"auto"}>
<ReactQueryProvider>
<ReduxProvider>
<ModalsProvider
labels={{ confirm: "Да", cancel: "Нет" }}
modals={modals}>
{children}
</ModalsProvider>
</ReduxProvider>
<Notifications position="bottom-right" />
</ReactQueryProvider>
</MantineProvider> </MantineProvider>
</body> </body>
</html> </html>
) );
} }

View File

@ -1,52 +1,5 @@
import { ColorSchemesSwitcher } from "@/components/color-schemes-switcher" import { redirect } from "next/navigation";
import {
AppShell,
AppShellHeader,
AppShellMain,
Text,
Title,
} from "@mantine/core"
export default function Home() { export default function HomePage() {
return ( redirect("/deals");
<AppShell header={{ height: 60 }} padding="md">
<AppShellHeader></AppShellHeader>
<AppShellMain>
<Title className="text-center mt-20">
Welcome to{" "}
<Text
inherit
variant="gradient"
component="span"
gradient={{ from: "pink", to: "yellow" }}
>
Mantine
</Text>{" "}
+
<Text
inherit
variant="gradient"
component="span"
gradient={{ from: "blue", to: "green" }}
>
TailwindCSS
</Text>
</Title>
<Text
className="text-bold text-center text-gray-700 dark:text-gray-300 max-w-[500px] mx-auto mt-xl"
ta="center"
size="lg"
maw={580}
mx="auto"
mt="xl"
>
This starter Next.js project includes a minimal setup for
Mantine with TailwindCSS. To get started edit page.tsx file.
</Text>
<div className="flex justify-center mt-10">
<ColorSchemesSwitcher />
</div>
</AppShellMain>
</AppShell>
)
} }

View File

@ -1,133 +0,0 @@
/** This file is autogenerated by the script. Do not edit it manually. */
@theme {
/* colors - all */
/* colors - variant specific */
/* breakpoints */
--breakpoint-*: initial;
--breakpoint-xs: 36em;
--breakpoint-sm: 48em;
--breakpoint-md: 62em;
--breakpoint-lg: 75em;
--breakpoint-xl: 88em;
/* readd back tailwind's default containers vars to fix #24 */
--size-3xs: 16rem;
--size-2xs: 18rem;
--size-xs: 20rem;
--size-sm: 24rem;
--size-md: 28rem;
--size-lg: 32rem;
--size-xl: 36rem;
--size-2xl: 42rem;
--size-3xl: 48rem;
--size-4xl: 56rem;
--size-5xl: 64rem;
--size-6xl: 72rem;
--size-7xl: 80rem;
--container-3xs: var(--size-3xs);
--container-2xs: var(--size-2xs);
--container-xs: var(--size-xs);
--container-sm: var(--size-sm);
--container-md: var(--size-md);
--container-lg: var(--size-lg);
--container-xl: var(--size-xl);
--container-2xl: var(--size-2xl);
--container-3xl: var(--size-3xl);
--container-4xl: var(--size-4xl);
--container-5xl: var(--size-5xl);
--container-6xl: var(--size-6xl);
--container-7xl: var(--size-7xl);
--width-3xs: var(--size-3xs);
--width-2xs: var(--size-2xs);
--width-xs: var(--size-xs);
--width-sm: var(--size-sm);
--width-md: var(--size-md);
--width-lg: var(--size-lg);
--width-xl: var(--size-xl);
--width-2xl: var(--size-2xl);
--width-3xl: var(--size-3xl);
--width-4xl: var(--size-4xl);
--width-5xl: var(--size-5xl);
--width-6xl: var(--size-6xl);
--width-7xl: var(--size-7xl);
--min-width-3xs: var(--size-3xs);
--min-width-2xs: var(--size-2xs);
--min-width-xs: var(--size-xs);
--min-width-sm: var(--size-sm);
--min-width-md: var(--size-md);
--min-width-lg: var(--size-lg);
--min-width-xl: var(--size-xl);
--min-width-2xl: var(--size-2xl);
--min-width-3xl: var(--size-3xl);
--min-width-4xl: var(--size-4xl);
--min-width-5xl: var(--size-5xl);
--min-width-6xl: var(--size-6xl);
--min-width-7xl: var(--size-7xl);
--max-width-3xs: var(--size-3xs);
--max-width-2xs: var(--size-2xs);
--max-width-xs: var(--size-xs);
--max-width-sm: var(--size-sm);
--max-width-md: var(--size-md);
--max-width-lg: var(--size-lg);
--max-width-xl: var(--size-xl);
--max-width-2xl: var(--size-2xl);
--max-width-3xl: var(--size-3xl);
--max-width-4xl: var(--size-4xl);
--max-width-5xl: var(--size-5xl);
--max-width-6xl: var(--size-6xl);
--max-width-7xl: var(--size-7xl);
--height-3xs: var(--size-3xs);
--height-2xs: var(--size-2xs);
--height-xs: var(--size-xs);
--height-sm: var(--size-sm);
--height-md: var(--size-md);
--height-lg: var(--size-lg);
--height-xl: var(--size-xl);
--height-2xl: var(--size-2xl);
--height-3xl: var(--size-3xl);
--height-4xl: var(--size-4xl);
--height-5xl: var(--size-5xl);
--height-6xl: var(--size-6xl);
--height-7xl: var(--size-7xl);
--min-height-3xs: var(--size-3xs);
--min-height-2xs: var(--size-2xs);
--min-height-xs: var(--size-xs);
--min-height-sm: var(--size-sm);
--min-height-md: var(--size-md);
--min-height-lg: var(--size-lg);
--min-height-xl: var(--size-xl);
--min-height-2xl: var(--size-2xl);
--min-height-3xl: var(--size-3xl);
--min-height-4xl: var(--size-4xl);
--min-height-5xl: var(--size-5xl);
--min-height-6xl: var(--size-6xl);
--min-height-7xl: var(--size-7xl);
--max-height-3xs: var(--size-3xs);
--max-height-2xs: var(--size-2xs);
--max-height-xs: var(--size-xs);
--max-height-sm: var(--size-sm);
--max-height-md: var(--size-md);
--max-height-lg: var(--size-lg);
--max-height-xl: var(--size-xl);
--max-height-2xl: var(--size-2xl);
--max-height-3xl: var(--size-3xl);
--max-height-4xl: var(--size-4xl);
--max-height-5xl: var(--size-5xl);
--max-height-6xl: var(--size-6xl);
--max-height-7xl: var(--size-7xl);
}

View File

@ -1,15 +0,0 @@
"use client"
import { createTheme } from "@mantine/core"
export const theme = createTheme({
breakpoints: {
xs: "36em",
sm: "48em",
md: "62em",
lg: "75em",
xl: "88em",
},
})
export default theme

View File

@ -0,0 +1,28 @@
"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>
);
}

View File

@ -0,0 +1,30 @@
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;

View File

@ -0,0 +1,25 @@
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;

View File

@ -0,0 +1,41 @@
.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;
}
}

View File

@ -0,0 +1,36 @@
import { CSSProperties, FC, ReactNode } from "react";
import classNames from "classnames";
import styles from "./PageBlock.module.css";
5
type Props = {
children: ReactNode;
style?: CSSProperties;
fullHeight?: boolean;
fullHeightFixed?: boolean;
noBorderRadius?: boolean;
fullScreenMobile?: boolean;
};
const PageBlock: FC<Props> = ({
children,
style,
fullHeight = false,
fullHeightFixed = false,
noBorderRadius = false,
fullScreenMobile = false,
}) => {
return (
<div
style={style}
className={classNames(
styles.container,
fullHeight && styles["container-full-height"],
fullHeightFixed && styles["container-full-height-fixed"],
noBorderRadius && styles["container-no-border-radius"],
fullScreenMobile && styles["container-full-screen-mobile"]
)}>
{children}
</div>
);
};
export default PageBlock;

View File

@ -0,0 +1,7 @@
.container {
display: flex;
flex-direction: column;
gap: rem(10);
min-height: 86vh;
background-color: transparent;
}

View File

@ -0,0 +1,24 @@
import { CSSProperties, FC, ReactNode } from "react";
import styles from "./PageContainer.module.css";
type Props = {
children: ReactNode;
style?: CSSProperties;
center?: boolean;
};
const PageContainer: FC<Props> = ({ children, style, center }) => {
return (
<div
className={styles.container}
style={{
...style,
alignItems: center ? "center" : "",
justifyContent: center ? "center" : "",
}}>
{children}
</div>
);
};
export default PageContainer;

View File

@ -0,0 +1,27 @@
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;

View File

@ -0,0 +1,130 @@
"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;

View File

@ -0,0 +1,54 @@
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>
);
};

View File

@ -0,0 +1,16 @@
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;

View File

@ -0,0 +1,24 @@
import type { PropsWithChildren } from "react";
import {
defaultDropAnimationSideEffects,
DragOverlay,
DropAnimation,
} from "@dnd-kit/core";
const dropAnimationConfig: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
};
export function SortableOverlay({ children }: PropsWithChildren) {
return (
<DragOverlay dropAnimation={dropAnimationConfig}>
{children}
</DragOverlay>
);
}

View File

@ -0,0 +1,3 @@
import SortableDnd from "@/components/SortableDnd/SortableDnd";
export default SortableDnd;

View File

@ -1,18 +0,0 @@
"use client"
import { Button, Group, useMantineColorScheme } from "@mantine/core"
export function ColorSchemesSwitcher() {
const { setColorScheme, clearColorScheme } = useMantineColorScheme()
return (
<Group>
<Button variant={"filled"} onClick={() => setColorScheme("light")}>
Light
</Button>
<Button onClick={() => setColorScheme("dark")}>Dark</Button>
<Button onClick={() => setColorScheme("auto")}>Auto</Button>
<Button onClick={clearColorScheme}>Clear</Button>
</Group>
)
}

View File

@ -0,0 +1,110 @@
import { useEffect, useMemo, useState } from "react";
import { groupBy, omit } from "lodash";
import { Select, SelectProps } from "@mantine/core";
interface ObjectWithIdAndName {
id: number;
name: string;
}
export type SelectObjectType<T> = T;
type ControlledValueProps<T> = {
value: SelectObjectType<T>;
onChange: (value: SelectObjectType<T>) => void;
};
type CustomLabelAndKeyProps<T> = {
getLabelFn: (item: SelectObjectType<T>) => string;
getValueFn: (item: SelectObjectType<T>) => string;
};
type RestProps<T> = {
defaultValue?: SelectObjectType<T>;
onChange: (value: SelectObjectType<T>) => void;
data: SelectObjectType<T>[];
groupBy?: (item: SelectObjectType<T>) => string;
filterBy?: (item: SelectObjectType<T>) => boolean;
};
const defaultGetLabelFn = <T extends { name: string }>(item: T): string => {
return item.name;
};
const defaultGetValueFn = <T extends { id: number }>(item: T): string => {
if (!item) return item;
return item.id.toString();
};
export type ObjectSelectProps<T> = (RestProps<T> &
Partial<ControlledValueProps<T>>) &
Omit<SelectProps, "value" | "onChange" | "data"> &
(T extends ObjectWithIdAndName
? Partial<CustomLabelAndKeyProps<T>>
: CustomLabelAndKeyProps<T>);
const ObjectSelect = <T,>(props: ObjectSelectProps<T>) => {
const isControlled = "value" in props;
const haveGetValueFn = "getValueFn" in props;
const haveGetLabelFn = "getLabelFn" in props;
const [internalValue, setInternalValue] = useState<
SelectObjectType<T> | undefined
>(props.defaultValue);
const value = isControlled ? props.value : internalValue;
const getValueFn =
(haveGetValueFn && props.getValueFn) || defaultGetValueFn;
const getLabelFn =
(haveGetLabelFn && props.getLabelFn) || defaultGetLabelFn;
const data = useMemo(() => {
const propsData = props.filterBy
? props.data.filter(props.filterBy)
: props.data;
if (props.groupBy) {
const groupedData = groupBy(propsData, props.groupBy);
return Object.entries(groupedData).map(([group, items]) => ({
group,
items: items.map(item => ({
label: getLabelFn(item),
value: getValueFn(item),
})),
}));
}
return propsData.map(item => ({
label: getLabelFn(item),
value: getValueFn(item),
}));
}, [props.data, props.groupBy]);
const handleOnChange = (event: string | null) => {
if (!event) return;
const object = props.data.find(item => event === getValueFn(item));
if (!object) return;
if (isControlled) {
props.onChange(object);
return;
}
setInternalValue(object);
};
useEffect(() => {
if (isControlled || !internalValue) return;
props.onChange(internalValue);
}, [internalValue]);
const restProps = omit(props, [
"filterBy",
"groupBy",
"getValueFn",
"getLabelFn",
]);
return (
<Select
{...restProps}
value={value && getValueFn(value)}
onChange={handleOnChange}
data={data}
/>
);
};
export default ObjectSelect;

View File

@ -0,0 +1,28 @@
"use client";
import { FC } from "react";
import { ProjectSchema } from "@/lib/client";
import ObjectSelect, {
ObjectSelectProps,
} from "@/components/selects/ObjectSelect/ObjectSelect";
type Props = Omit<
ObjectSelectProps<ProjectSchema | null>,
"getLabelFn" | "getValueFn"
>;
const ProjectSelect: FC<Props> = ({ data, ...props }) => {
const onClear = () => props.onChange(null);
return (
<ObjectSelect
data={data}
searchable
placeholder={"Выберите проект"}
onClear={onClear}
{...props}
/>
);
};
export default ProjectSelect;

6
src/hey-api-config.ts Normal file
View File

@ -0,0 +1,6 @@
import type {CreateClientConfig} from "@/lib/client/client.gen";
export const createClientConfig: CreateClientConfig = config => ({
...config,
baseUrl: process.env.NEXT_PUBLIC_API_URL,
});

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BoardSchema } from "@/lib/client";
import { getBoardsOptions } from "@/lib/client/@tanstack/react-query.gen";
type Props = {
projectId?: number;
};
const useBoardsList = ({ projectId }: Props) => {
const [boards, setBoards] = useState<BoardSchema[]>([]);
const { data, refetch, isLoading } = useQuery({
...getBoardsOptions({ path: { projectId: projectId! } }),
enabled: projectId !== undefined,
});
useEffect(() => {
if (projectId === undefined) {
setBoards([]);
return;
}
if (data?.boards) {
setBoards(data.boards);
}
}, [data?.boards, projectId]);
return { boards, setBoards, refetch, isLoading };
};
export default useBoardsList;

31
src/hooks/useDealsList.ts Normal file
View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { DealSchema } from "@/lib/client";
import { getDealsOptions } from "@/lib/client/@tanstack/react-query.gen";
type Props = {
boardId?: number;
};
const useDealsList = ({ boardId }: Props) => {
const [deals, setDeals] = useState<DealSchema[]>([]);
const { data, refetch, isLoading } = useQuery({
...getDealsOptions({ path: { boardId: boardId! } }),
enabled: boardId !== undefined,
});
useEffect(() => {
if (boardId === undefined) {
setDeals([]);
return;
}
if (data?.deals) {
setDeals(data.deals);
}
}, [data?.deals, boardId]);
return { deals, setDeals, refetch, isLoading };
};
export default useDealsList;

View File

@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { getProjectsOptions } from "@/lib/client/@tanstack/react-query.gen";
const useProjectsList = () => {
const { data, refetch, isLoading } = useQuery({
...getProjectsOptions(),
});
const projects = !data ? [] : data.projects;
return { projects, refetch, isLoading };
};
export default useProjectsList;

View File

@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { StatusSchema } from "@/lib/client";
import { getStatusesOptions } from "@/lib/client/@tanstack/react-query.gen";
type Props = {
boardId?: number;
};
const useStatusesList = ({ boardId }: Props) => {
const [statuses, setStatuses] = useState<StatusSchema[]>([]);
const { data, refetch, isLoading } = useQuery({
...getStatusesOptions({ path: { boardId: boardId! } }),
enabled: boardId !== undefined,
});
useEffect(() => {
if (boardId === undefined) {
setStatuses([]);
return;
}
if (data?.statuses) {
setStatuses(data.statuses);
}
}, [data?.statuses, boardId]);
return { statuses, setStatuses, refetch, isLoading };
};
export default useStatusesList;

View File

@ -0,0 +1,169 @@
// 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 type { GetBoardsData, UpdateBoardData, UpdateBoardError, UpdateBoardResponse2, GetDealsData, UpdateDealData, UpdateDealError, UpdateDealResponse2, GetProjectsData, GetStatusesData, UpdateStatusData, UpdateStatusError, UpdateStatusResponse2 } from '../types.gen';
import type { AxiosError } from 'axios';
import { client as _heyApiClient } from '../client.gen';
export type QueryKey<TOptions extends Options> = [
Pick<TOptions, 'baseURL' | 'body' | 'headers' | 'path' | 'query'> & {
_id: string;
_infinite?: boolean;
}
];
const createQueryKey = <TOptions extends Options>(id: string, options?: TOptions, 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) {
params._infinite = infinite;
}
if (options?.body) {
params.body = options.body;
}
if (options?.headers) {
params.headers = options.headers;
}
if (options?.path) {
params.path = options.path;
}
if (options?.query) {
params.query = options.query;
}
return [
params
];
};
export const getBoardsQueryKey = (options: Options<GetBoardsData>) => createQueryKey('getBoards', options);
/**
* Get Boards
*/
export const getBoardsOptions = (options: Options<GetBoardsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getBoards({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getBoardsQueryKey(options)
});
};
/**
* Update Board
*/
export const updateBoardMutation = (options?: Partial<Options<UpdateBoardData>>): UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> => {
const mutationOptions: UseMutationOptions<UpdateBoardResponse2, AxiosError<UpdateBoardError>, Options<UpdateBoardData>> = {
mutationFn: async (localOptions) => {
const { data } = await updateBoard({
...options,
...localOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getDealsQueryKey = (options: Options<GetDealsData>) => createQueryKey('getDeals', options);
/**
* Get Deals
*/
export const getDealsOptions = (options: Options<GetDealsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getDeals({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getDealsQueryKey(options)
});
};
/**
* Update Deal
*/
export const updateDealMutation = (options?: Partial<Options<UpdateDealData>>): UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> => {
const mutationOptions: UseMutationOptions<UpdateDealResponse2, AxiosError<UpdateDealError>, Options<UpdateDealData>> = {
mutationFn: async (localOptions) => {
const { data } = await updateDeal({
...options,
...localOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};
export const getProjectsQueryKey = (options?: Options<GetProjectsData>) => createQueryKey('getProjects', options);
/**
* Get Projects
*/
export const getProjectsOptions = (options?: Options<GetProjectsData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getProjects({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getProjectsQueryKey(options)
});
};
export const getStatusesQueryKey = (options: Options<GetStatusesData>) => createQueryKey('getStatuses', options);
/**
* Get Statuses
*/
export const getStatusesOptions = (options: Options<GetStatusesData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await getStatuses({
...options,
...queryKey[0],
signal,
throwOnError: true
});
return data;
},
queryKey: getStatusesQueryKey(options)
});
};
/**
* Update Status
*/
export const updateStatusMutation = (options?: Partial<Options<UpdateStatusData>>): UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> => {
const mutationOptions: UseMutationOptions<UpdateStatusResponse2, AxiosError<UpdateStatusError>, Options<UpdateStatusData>> = {
mutationFn: async (localOptions) => {
const { data } = await updateStatus({
...options,
...localOptions,
throwOnError: true
});
return data;
}
};
return mutationOptions;
};

View File

@ -0,0 +1,19 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { ClientOptions } from './types.gen';
import { type Config, type ClientOptions as DefaultClientOptions, createClient, createConfig } from './client';
import { createClientConfig } from '../../hey-api-config';
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* 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 const client = createClient(createClientConfig(createConfig<ClientOptions>({
baseURL: '/api'
})));

View File

@ -0,0 +1,115 @@
import type { AxiosError, RawAxiosRequestHeaders } from 'axios';
import axios from 'axios';
import type { Client, Config } from './types';
import {
buildUrl,
createConfig,
mergeConfigs,
mergeHeaders,
setAuthParams,
} from './utils';
export const createClient = (config: Config = {}): Client => {
let _config = mergeConfigs(createConfig(), config);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { auth, ...configWithoutAuth } = _config;
const instance = axios.create(configWithoutAuth);
const getConfig = (): Config => ({ ..._config });
const setConfig = (config: Config): Config => {
_config = mergeConfigs(_config, config);
instance.defaults = {
...instance.defaults,
..._config,
// @ts-expect-error
headers: mergeHeaders(instance.defaults.headers, _config.headers),
};
return getConfig();
};
// @ts-expect-error
const request: Client['request'] = async (options) => {
const opts = {
..._config,
...options,
axios: options.axios ?? _config.axios ?? instance,
headers: mergeHeaders(_config.headers, options.headers),
};
if (opts.security) {
await setAuthParams({
...opts,
security: opts.security,
});
}
if (opts.requestValidator) {
await opts.requestValidator(opts);
}
if (opts.body && opts.bodySerializer) {
opts.body = opts.bodySerializer(opts.body);
}
const url = buildUrl(opts);
try {
// assign Axios here for consistency with fetch
const _axios = opts.axios!;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { auth, ...optsWithoutAuth } = opts;
const response = await _axios({
...optsWithoutAuth,
baseURL: opts.baseURL as string,
data: opts.body,
headers: opts.headers as RawAxiosRequestHeaders,
// let `paramsSerializer()` handle query params if it exists
params: opts.paramsSerializer ? opts.query : undefined,
url,
});
let { data } = response;
if (opts.responseType === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return {
...response,
data: data ?? {},
};
} catch (error) {
const e = error as AxiosError;
if (opts.throwOnError) {
throw e;
}
// @ts-expect-error
e.error = e.response?.data ?? {};
return e;
}
};
return {
buildUrl,
delete: (options) => request({ ...options, method: 'DELETE' }),
get: (options) => request({ ...options, method: 'GET' }),
getConfig,
head: (options) => request({ ...options, method: 'HEAD' }),
instance,
options: (options) => request({ ...options, method: 'OPTIONS' }),
patch: (options) => request({ ...options, method: 'PATCH' }),
post: (options) => request({ ...options, method: 'POST' }),
put: (options) => request({ ...options, method: 'PUT' }),
request,
setConfig,
} as Client;
};

View File

@ -0,0 +1,21 @@
export type { Auth } from '../core/auth';
export type { QuerySerializerOptions } from '../core/bodySerializer';
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from '../core/bodySerializer';
export { buildClientParams } from '../core/params';
export { createClient } from './client';
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
Options,
OptionsLegacyParser,
RequestOptions,
RequestResult,
TDataShape,
} from './types';
export { createConfig } from './utils';

View File

@ -0,0 +1,179 @@
import type {
AxiosError,
AxiosInstance,
AxiosRequestHeaders,
AxiosResponse,
AxiosStatic,
CreateAxiosDefaults,
} from 'axios';
import type { Auth } from '../core/auth';
import type {
Client as CoreClient,
Config as CoreConfig,
} from '../core/types';
export interface Config<T extends ClientOptions = ClientOptions>
extends Omit<CreateAxiosDefaults, 'auth' | 'baseURL' | 'headers' | 'method'>,
CoreConfig {
/**
* Axios implementation. You can use this option to provide a custom
* Axios instance.
*
* @default axios
*/
axios?: AxiosStatic;
/**
* Base URL for all requests made by this client.
*/
baseURL?: T['baseURL'];
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| AxiosRequestHeaders
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* Throw an error instead of returning it in the response?
*
* @default false
*/
throwOnError?: T['throwOnError'];
}
export interface RequestOptions<
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
throwOnError: ThrowOnError;
}> {
/**
* Any body that you want to add to your request.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#body}
*/
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
/**
* Security mechanism(s) to use for the request.
*/
security?: ReadonlyArray<Auth>;
url: Url;
}
export type RequestResult<
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = boolean,
> = ThrowOnError extends true
? Promise<
AxiosResponse<
TData extends Record<string, unknown> ? TData[keyof TData] : TData
>
>
: Promise<
| (AxiosResponse<
TData extends Record<string, unknown> ? TData[keyof TData] : TData
> & { error: undefined })
| (AxiosError<
TError extends Record<string, unknown> ? TError[keyof TError] : TError
> & {
data: undefined;
error: TError extends Record<string, unknown>
? TError[keyof TError]
: TError;
})
>;
export interface ClientOptions {
baseURL?: string;
throwOnError?: boolean;
}
type MethodFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
>(
options: Omit<RequestOptions<ThrowOnError>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError>;
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
>(
options: Omit<RequestOptions<ThrowOnError>, 'method'> &
Pick<Required<RequestOptions<ThrowOnError>>, 'method'>,
) => RequestResult<TData, TError, ThrowOnError>;
type BuildUrlFn = <
TData extends {
body?: unknown;
path?: Record<string, unknown>;
query?: Record<string, unknown>;
url: string;
},
>(
options: Pick<TData, 'url'> & Omit<Options<TData>, 'axios'>,
) => string;
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
instance: AxiosInstance;
};
/**
* The `createClientConfig()` function will be called on client initialization
* and the returned object will become the client's initial configuration.
*
* You may want to initialize your client this way instead of calling
* `setConfig()`. This is useful for example if you're using Next.js
* to ensure your client always has the correct values.
*/
export type CreateClientConfig<T extends ClientOptions = ClientOptions> = (
override?: Config<ClientOptions & T>,
) => Config<Required<ClientOptions> & T>;
export interface TDataShape {
body?: unknown;
headers?: unknown;
path?: unknown;
query?: unknown;
url: string;
}
type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>;
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
> = OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'path' | 'query' | 'url'> &
Omit<TData, 'url'>;
export type OptionsLegacyParser<
TData = unknown,
ThrowOnError extends boolean = boolean,
> = TData extends { body?: any }
? TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'headers' | 'url'> & TData
: OmitKeys<RequestOptions<ThrowOnError>, 'body' | 'url'> &
TData &
Pick<RequestOptions<ThrowOnError>, 'headers'>
: TData extends { headers?: any }
? OmitKeys<RequestOptions<ThrowOnError>, 'headers' | 'url'> &
TData &
Pick<RequestOptions<ThrowOnError>, 'body'>
: OmitKeys<RequestOptions<ThrowOnError>, 'url'> & TData;

View File

@ -0,0 +1,286 @@
import { getAuthToken } from '../core/auth';
import type {
QuerySerializer,
QuerySerializerOptions,
} from '../core/bodySerializer';
import type { ArraySeparatorStyle } from '../core/pathSerializer';
import {
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from '../core/pathSerializer';
import type { Client, ClientOptions, Config, RequestOptions } from './types';
interface PathSerializer {
path: Record<string, unknown>;
url: string;
}
const PATH_PARAM_RE = /\{[^{}]+\}/g;
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url;
const matches = _url.match(PATH_PARAM_RE);
if (matches) {
for (const match of matches) {
let explode = false;
let name = match.substring(1, match.length - 1);
let style: ArraySeparatorStyle = 'simple';
if (name.endsWith('*')) {
explode = true;
name = name.substring(0, name.length - 1);
}
if (name.startsWith('.')) {
name = name.substring(1);
style = 'label';
} else if (name.startsWith(';')) {
name = name.substring(1);
style = 'matrix';
}
const value = path[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
url = url.replace(
match,
serializeArrayParam({ explode, name, style, value }),
);
continue;
}
if (typeof value === 'object') {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
);
continue;
}
if (style === 'matrix') {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
);
continue;
}
const replaceValue = encodeURIComponent(
style === 'label' ? `.${value as string}` : (value as string),
);
url = url.replace(match, replaceValue);
}
}
return url;
};
export const createQuerySerializer = <T = unknown>({
allowReserved,
array,
object,
}: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
const search: string[] = [];
if (queryParams && typeof queryParams === 'object') {
for (const name in queryParams) {
const value = queryParams[name];
if (value === undefined || value === null) {
continue;
}
if (Array.isArray(value)) {
const serializedArray = serializeArrayParam({
allowReserved,
explode: true,
name,
style: 'form',
value,
...array,
});
if (serializedArray) search.push(serializedArray);
} else if (typeof value === 'object') {
const serializedObject = serializeObjectParam({
allowReserved,
explode: true,
name,
style: 'deepObject',
value: value as Record<string, unknown>,
...object,
});
if (serializedObject) search.push(serializedObject);
} else {
const serializedPrimitive = serializePrimitiveParam({
allowReserved,
name,
value: value as string,
});
if (serializedPrimitive) search.push(serializedPrimitive);
}
}
}
return search.join('&');
};
return querySerializer;
};
export const setAuthParams = async ({
security,
...options
}: Pick<Required<RequestOptions>, 'security'> &
Pick<RequestOptions, 'auth' | 'query'> & {
headers: Record<any, unknown>;
}) => {
for (const auth of security) {
const token = await getAuthToken(auth, options.auth);
if (!token) {
continue;
}
const name = auth.name ?? 'Authorization';
switch (auth.in) {
case 'query':
if (!options.query) {
options.query = {};
}
options.query[name] = token;
break;
case 'cookie': {
const value = `${name}=${token}`;
if ('Cookie' in options.headers && options.headers['Cookie']) {
options.headers['Cookie'] = `${options.headers['Cookie']}; ${value}`;
} else {
options.headers['Cookie'] = value;
}
break;
}
case 'header':
default:
options.headers[name] = token;
break;
}
return;
}
};
export const buildUrl: Client['buildUrl'] = (options) => {
const url = getUrl({
path: options.path,
// let `paramsSerializer()` handle query params if it exists
query: !options.paramsSerializer ? options.query : undefined,
querySerializer:
typeof options.querySerializer === 'function'
? options.querySerializer
: createQuerySerializer(options.querySerializer),
url: options.url,
});
return url;
};
export const getUrl = ({
path,
query,
querySerializer,
url: _url,
}: {
path?: Record<string, unknown>;
query?: Record<string, unknown>;
querySerializer: QuerySerializer;
url: string;
}) => {
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
let url = pathUrl;
if (path) {
url = defaultPathSerializer({ path, url });
}
let search = query ? querySerializer(query) : '';
if (search.startsWith('?')) {
search = search.substring(1);
}
if (search) {
url += `?${search}`;
}
return url;
};
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b };
config.headers = mergeHeaders(a.headers, b.headers);
return config;
};
/**
* Special Axios headers keywords allowing to set headers by request method.
*/
export const axiosHeadersKeywords = [
'common',
'delete',
'get',
'head',
'patch',
'post',
'put',
] as const;
export const mergeHeaders = (
...headers: Array<Required<Config>['headers'] | undefined>
): Record<any, unknown> => {
const mergedHeaders: Record<any, unknown> = {};
for (const header of headers) {
if (!header || typeof header !== 'object') {
continue;
}
const iterator = Object.entries(header);
for (const [key, value] of iterator) {
if (
axiosHeadersKeywords.includes(
key as (typeof axiosHeadersKeywords)[number],
) &&
typeof value === 'object'
) {
mergedHeaders[key] = {
...(mergedHeaders[key] as Record<any, unknown>),
...value,
};
} else if (value === null) {
delete mergedHeaders[key];
} else if (Array.isArray(value)) {
for (const v of value) {
// @ts-expect-error
mergedHeaders[key] = [...(mergedHeaders[key] ?? []), v as string];
}
} else if (value !== undefined) {
// assume object headers are meant to be JSON stringified, i.e. their
// content value in OpenAPI specification is 'application/json'
mergedHeaders[key] =
typeof value === 'object' ? JSON.stringify(value) : (value as string);
}
}
}
return mergedHeaders;
};
export const createConfig = <T extends ClientOptions = ClientOptions>(
override: Config<Omit<ClientOptions, keyof T> & T> = {},
): Config<Omit<ClientOptions, keyof T> & T> => ({
...override,
});

View File

@ -0,0 +1,40 @@
export type AuthToken = string | undefined;
export interface Auth {
/**
* Which part of the request do we use to send the auth?
*
* @default 'header'
*/
in?: 'header' | 'query' | 'cookie';
/**
* Header or query parameter name.
*
* @default 'Authorization'
*/
name?: string;
scheme?: 'basic' | 'bearer';
type: 'apiKey' | 'http';
}
export const getAuthToken = async (
auth: Auth,
callback: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken,
): Promise<string | undefined> => {
const token =
typeof callback === 'function' ? await callback(auth) : callback;
if (!token) {
return;
}
if (auth.scheme === 'bearer') {
return `Bearer ${token}`;
}
if (auth.scheme === 'basic') {
return `Basic ${btoa(token)}`;
}
return token;
};

View File

@ -0,0 +1,88 @@
import type {
ArrayStyle,
ObjectStyle,
SerializerOptions,
} from './pathSerializer';
export type QuerySerializer = (query: Record<string, unknown>) => string;
export type BodySerializer = (body: any) => any;
export interface QuerySerializerOptions {
allowReserved?: boolean;
array?: SerializerOptions<ArrayStyle>;
object?: SerializerOptions<ObjectStyle>;
}
const serializeFormDataPair = (
data: FormData,
key: string,
value: unknown,
): void => {
if (typeof value === 'string' || value instanceof Blob) {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
const serializeUrlSearchParamsPair = (
data: URLSearchParams,
key: string,
value: unknown,
): void => {
if (typeof value === 'string') {
data.append(key, value);
} else {
data.append(key, JSON.stringify(value));
}
};
export const formDataBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): FormData => {
const data = new FormData();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeFormDataPair(data, key, v));
} else {
serializeFormDataPair(data, key, value);
}
});
return data;
},
};
export const jsonBodySerializer = {
bodySerializer: <T>(body: T): string =>
JSON.stringify(body, (_key, value) =>
typeof value === 'bigint' ? value.toString() : value,
),
};
export const urlSearchParamsBodySerializer = {
bodySerializer: <T extends Record<string, any> | Array<Record<string, any>>>(
body: T,
): string => {
const data = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value === undefined || value === null) {
return;
}
if (Array.isArray(value)) {
value.forEach((v) => serializeUrlSearchParamsPair(data, key, v));
} else {
serializeUrlSearchParamsPair(data, key, value);
}
});
return data.toString();
},
};

View File

@ -0,0 +1,151 @@
type Slot = 'body' | 'headers' | 'path' | 'query';
export type Field =
| {
in: Exclude<Slot, 'body'>;
/**
* Field name. This is the name we want the user to see and use.
*/
key: string;
/**
* Field mapped name. This is the name we want to use in the request.
* If omitted, we use the same value as `key`.
*/
map?: string;
}
| {
in: Extract<Slot, 'body'>;
/**
* Key isn't required for bodies.
*/
key?: string;
map?: string;
};
export interface Fields {
allowExtra?: Partial<Record<Slot, boolean>>;
args?: ReadonlyArray<Field>;
}
export type FieldsConfig = ReadonlyArray<Field | Fields>;
const extraPrefixesMap: Record<string, Slot> = {
$body_: 'body',
$headers_: 'headers',
$path_: 'path',
$query_: 'query',
};
const extraPrefixes = Object.entries(extraPrefixesMap);
type KeyMap = Map<
string,
{
in: Slot;
map?: string;
}
>;
const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => {
if (!map) {
map = new Map();
}
for (const config of fields) {
if ('in' in config) {
if (config.key) {
map.set(config.key, {
in: config.in,
map: config.map,
});
}
} else if (config.args) {
buildKeyMap(config.args, map);
}
}
return map;
};
interface Params {
body: unknown;
headers: Record<string, unknown>;
path: Record<string, unknown>;
query: Record<string, unknown>;
}
const stripEmptySlots = (params: Params) => {
for (const [slot, value] of Object.entries(params)) {
if (value && typeof value === 'object' && !Object.keys(value).length) {
delete params[slot as Slot];
}
}
};
export const buildClientParams = (
args: ReadonlyArray<unknown>,
fields: FieldsConfig,
) => {
const params: Params = {
body: {},
headers: {},
path: {},
query: {},
};
const map = buildKeyMap(fields);
let config: FieldsConfig[number] | undefined;
for (const [index, arg] of args.entries()) {
if (fields[index]) {
config = fields[index];
}
if (!config) {
continue;
}
if ('in' in config) {
if (config.key) {
const field = map.get(config.key)!;
const name = field.map || config.key;
(params[field.in] as Record<string, unknown>)[name] = arg;
} else {
params.body = arg;
}
} else {
for (const [key, value] of Object.entries(arg ?? {})) {
const field = map.get(key);
if (field) {
const name = field.map || key;
(params[field.in] as Record<string, unknown>)[name] = value;
} else {
const extra = extraPrefixes.find(([prefix]) =>
key.startsWith(prefix),
);
if (extra) {
const [prefix, slot] = extra;
(params[slot] as Record<string, unknown>)[
key.slice(prefix.length)
] = value;
} else {
for (const [slot, allowed] of Object.entries(
config.allowExtra ?? {},
)) {
if (allowed) {
(params[slot as Slot] as Record<string, unknown>)[key] = value;
break;
}
}
}
}
}
}
}
stripEmptySlots(params);
return params;
};

View File

@ -0,0 +1,179 @@
interface SerializeOptions<T>
extends SerializePrimitiveOptions,
SerializerOptions<T> {}
interface SerializePrimitiveOptions {
allowReserved?: boolean;
name: string;
}
export interface SerializerOptions<T> {
/**
* @default true
*/
explode: boolean;
style: T;
}
export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited';
export type ArraySeparatorStyle = ArrayStyle | MatrixStyle;
type MatrixStyle = 'label' | 'matrix' | 'simple';
export type ObjectStyle = 'form' | 'deepObject';
type ObjectSeparatorStyle = ObjectStyle | MatrixStyle;
interface SerializePrimitiveParam extends SerializePrimitiveOptions {
value: string;
}
export const separatorArrayExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => {
switch (style) {
case 'form':
return ',';
case 'pipeDelimited':
return '|';
case 'spaceDelimited':
return '%20';
default:
return ',';
}
};
export const separatorObjectExplode = (style: ObjectSeparatorStyle) => {
switch (style) {
case 'label':
return '.';
case 'matrix':
return ';';
case 'simple':
return ',';
default:
return '&';
}
};
export const serializeArrayParam = ({
allowReserved,
explode,
name,
style,
value,
}: SerializeOptions<ArraySeparatorStyle> & {
value: unknown[];
}) => {
if (!explode) {
const joinedValues = (
allowReserved ? value : value.map((v) => encodeURIComponent(v as string))
).join(separatorArrayNoExplode(style));
switch (style) {
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
case 'simple':
return joinedValues;
default:
return `${name}=${joinedValues}`;
}
}
const separator = separatorArrayExplode(style);
const joinedValues = value
.map((v) => {
if (style === 'label' || style === 'simple') {
return allowReserved ? v : encodeURIComponent(v as string);
}
return serializePrimitiveParam({
allowReserved,
name,
value: v as string,
});
})
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};
export const serializePrimitiveParam = ({
allowReserved,
name,
value,
}: SerializePrimitiveParam) => {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'object') {
throw new Error(
'Deeply-nested arrays/objects arent supported. Provide your own `querySerializer()` to handle these.',
);
}
return `${name}=${allowReserved ? value : encodeURIComponent(value)}`;
};
export const serializeObjectParam = ({
allowReserved,
explode,
name,
style,
value,
valueOnly,
}: SerializeOptions<ObjectSeparatorStyle> & {
value: Record<string, unknown> | Date;
valueOnly?: boolean;
}) => {
if (value instanceof Date) {
return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`;
}
if (style !== 'deepObject' && !explode) {
let values: string[] = [];
Object.entries(value).forEach(([key, v]) => {
values = [
...values,
key,
allowReserved ? (v as string) : encodeURIComponent(v as string),
];
});
const joinedValues = values.join(',');
switch (style) {
case 'form':
return `${name}=${joinedValues}`;
case 'label':
return `.${joinedValues}`;
case 'matrix':
return `;${name}=${joinedValues}`;
default:
return joinedValues;
}
}
const separator = separatorObjectExplode(style);
const joinedValues = Object.entries(value)
.map(([key, v]) =>
serializePrimitiveParam({
allowReserved,
name: style === 'deepObject' ? `${name}[${key}]` : key,
value: v as string,
}),
)
.join(separator);
return style === 'label' || style === 'matrix'
? separator + joinedValues
: joinedValues;
};

View File

@ -0,0 +1,118 @@
import type { Auth, AuthToken } from './auth';
import type {
BodySerializer,
QuerySerializer,
QuerySerializerOptions,
} from './bodySerializer';
export interface Client<
RequestFn = never,
Config = unknown,
MethodFn = never,
BuildUrlFn = never,
> {
/**
* Returns the final request URL.
*/
buildUrl: BuildUrlFn;
connect: MethodFn;
delete: MethodFn;
get: MethodFn;
getConfig: () => Config;
head: MethodFn;
options: MethodFn;
patch: MethodFn;
post: MethodFn;
put: MethodFn;
request: RequestFn;
setConfig: (config: Config) => Config;
trace: MethodFn;
}
export interface Config {
/**
* Auth token or a function returning auth token. The resolved value will be
* added to the request payload as defined by its `security` array.
*/
auth?: ((auth: Auth) => Promise<AuthToken> | AuthToken) | AuthToken;
/**
* A function for serializing request body parameter. By default,
* {@link JSON.stringify()} will be used.
*/
bodySerializer?: BodySerializer | null;
/**
* An object containing any HTTP headers that you want to pre-populate your
* `Headers` object with.
*
* {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more}
*/
headers?:
| RequestInit['headers']
| Record<
string,
| string
| number
| boolean
| (string | number | boolean)[]
| null
| undefined
| unknown
>;
/**
* The request method.
*
* {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more}
*/
method?:
| 'CONNECT'
| 'DELETE'
| 'GET'
| 'HEAD'
| 'OPTIONS'
| 'PATCH'
| 'POST'
| 'PUT'
| 'TRACE';
/**
* A function for serializing request query parameters. By default, arrays
* will be exploded in form style, objects will be exploded in deepObject
* style, and reserved characters are percent-encoded.
*
* This method will have no effect if the native `paramsSerializer()` Axios
* API function is used.
*
* {@link https://swagger.io/docs/specification/serialization/#query View examples}
*/
querySerializer?: QuerySerializer | QuerySerializerOptions;
/**
* A function validating request data. This is useful if you want to ensure
* the request conforms to the desired shape, so it can be safely sent to
* the server.
*/
requestValidator?: (data: unknown) => Promise<unknown>;
/**
* A function transforming response data before it's returned. This is useful
* for post-processing data, e.g. converting ISO strings into Date objects.
*/
responseTransformer?: (data: unknown) => Promise<unknown>;
/**
* A function validating response data. This is useful if you want to ensure
* the response conforms to the desired shape, so it can be safely passed to
* the transformers and returned to the user.
*/
responseValidator?: (data: unknown) => Promise<unknown>;
}
type IsExactlyNeverOrNeverUndefined<T> = [T] extends [never]
? true
: [T] extends [never | undefined]
? [undefined] extends [T]
? false
: true
: false;
export type OmitNever<T extends Record<string, unknown>> = {
[K in keyof T as IsExactlyNeverOrNeverUndefined<T[K]> extends true
? never
: K]: T[K];
};

3
src/lib/client/index.ts Normal file
View File

@ -0,0 +1,3 @@
// This file is auto-generated by @hey-api/openapi-ts
export * from './types.gen';
export * from './sdk.gen';

151
src/lib/client/sdk.gen.ts Normal file
View File

@ -0,0 +1,151 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Options as ClientOptions, TDataShape, Client } from './client';
import type { GetBoardsData, GetBoardsResponses, GetBoardsErrors, UpdateBoardData, UpdateBoardResponses, UpdateBoardErrors, GetDealsData, GetDealsResponses, GetDealsErrors, UpdateDealData, UpdateDealResponses, UpdateDealErrors, GetProjectsData, GetProjectsResponses, GetStatusesData, GetStatusesResponses, GetStatusesErrors, UpdateStatusData, UpdateStatusResponses, UpdateStatusErrors } from './types.gen';
import { zGetBoardsData, zGetBoardsResponse2, zUpdateBoardData, zUpdateBoardResponse2, zGetDealsData, zGetDealsResponse2, zUpdateDealData, zUpdateDealResponse2, zGetProjectsData, zGetProjectsResponse2, zGetStatusesData, zGetStatusesResponse2, zUpdateStatusData, zUpdateStatusResponse2 } from './zod.gen';
import { client as _heyApiClient } from './client.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
/**
* You can provide a client instance returned by `createClient()` instead of
* individual options. This might be also useful if you want to implement a
* custom client.
*/
client?: Client;
/**
* You can pass arbitrary values through the `meta` object. This can be
* used to access values that aren't defined as part of the SDK function.
*/
meta?: Record<string, unknown>;
};
/**
* Get Boards
*/
export const getBoards = <ThrowOnError extends boolean = false>(options: Options<GetBoardsData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetBoardsResponses, GetBoardsErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zGetBoardsData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zGetBoardsResponse2.parseAsync(data);
},
url: '/board/{projectId}',
...options
});
};
/**
* Update Board
*/
export const updateBoard = <ThrowOnError extends boolean = false>(options: Options<UpdateBoardData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).patch<UpdateBoardResponses, UpdateBoardErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zUpdateBoardData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zUpdateBoardResponse2.parseAsync(data);
},
url: '/board/{boardId}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* Get Deals
*/
export const getDeals = <ThrowOnError extends boolean = false>(options: Options<GetDealsData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetDealsResponses, GetDealsErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zGetDealsData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zGetDealsResponse2.parseAsync(data);
},
url: '/deal/{boardId}',
...options
});
};
/**
* Update Deal
*/
export const updateDeal = <ThrowOnError extends boolean = false>(options: Options<UpdateDealData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).patch<UpdateDealResponses, UpdateDealErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zUpdateDealData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zUpdateDealResponse2.parseAsync(data);
},
url: '/deal/{dealId}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
/**
* Get Projects
*/
export const getProjects = <ThrowOnError extends boolean = false>(options?: Options<GetProjectsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetProjectsResponses, unknown, ThrowOnError>({
requestValidator: async (data) => {
return await zGetProjectsData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zGetProjectsResponse2.parseAsync(data);
},
url: '/project/',
...options
});
};
/**
* Get Statuses
*/
export const getStatuses = <ThrowOnError extends boolean = false>(options: Options<GetStatusesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<GetStatusesResponses, GetStatusesErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zGetStatusesData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zGetStatusesResponse2.parseAsync(data);
},
url: '/status/{boardId}',
...options
});
};
/**
* Update Status
*/
export const updateStatus = <ThrowOnError extends boolean = false>(options: Options<UpdateStatusData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).patch<UpdateStatusResponses, UpdateStatusErrors, ThrowOnError>({
requestValidator: async (data) => {
return await zUpdateStatusData.parseAsync(data);
},
responseType: 'json',
responseValidator: async (data) => {
return await zUpdateStatusResponse2.parseAsync(data);
},
url: '/status/{statusId}',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};

438
src/lib/client/types.gen.ts Normal file
View File

@ -0,0 +1,438 @@
// This file is auto-generated by @hey-api/openapi-ts
/**
* BoardSchema
*/
export type BoardSchema = {
/**
* Name
*/
name: string;
/**
* Id
*/
id: number;
/**
* Lexorank
*/
lexorank: string;
};
/**
* DealSchema
*/
export type DealSchema = {
/**
* Name
*/
name: string;
/**
* Id
*/
id: number;
/**
* Lexorank
*/
lexorank: string;
/**
* Statusid
*/
statusId: number;
};
/**
* GetBoardsResponse
*/
export type GetBoardsResponse = {
/**
* Boards
*/
boards: Array<BoardSchema>;
};
/**
* GetDealsResponse
*/
export type GetDealsResponse = {
/**
* Deals
*/
deals: Array<DealSchema>;
};
/**
* GetProjectsResponse
*/
export type GetProjectsResponse = {
/**
* Projects
*/
projects: Array<ProjectSchema>;
};
/**
* GetStatusesResponse
*/
export type GetStatusesResponse = {
/**
* Statuses
*/
statuses: Array<StatusSchema>;
};
/**
* HTTPValidationError
*/
export type HttpValidationError = {
/**
* Detail
*/
detail?: Array<ValidationError>;
};
/**
* ProjectSchema
*/
export type ProjectSchema = {
/**
* Name
*/
name: string;
/**
* Id
*/
id: number;
};
/**
* StatusSchema
*/
export type StatusSchema = {
/**
* Name
*/
name: string;
/**
* Id
*/
id: number;
/**
* Lexorank
*/
lexorank: string;
};
/**
* UpdateBoardRequest
*/
export type UpdateBoardRequest = {
board: UpdateBoardSchema;
};
/**
* UpdateBoardResponse
*/
export type UpdateBoardResponse = {
/**
* Message
*/
message: string;
};
/**
* UpdateBoardSchema
*/
export type UpdateBoardSchema = {
/**
* Name
*/
name?: string | null;
/**
* Lexorank
*/
lexorank?: string | null;
};
/**
* UpdateDealRequest
*/
export type UpdateDealRequest = {
deal: UpdateDealSchema;
};
/**
* UpdateDealResponse
*/
export type UpdateDealResponse = {
/**
* Message
*/
message: string;
};
/**
* UpdateDealSchema
*/
export type UpdateDealSchema = {
/**
* Name
*/
name?: string | null;
/**
* Lexorank
*/
lexorank?: string | null;
/**
* Statusid
*/
statusId?: number | null;
};
/**
* UpdateStatusRequest
*/
export type UpdateStatusRequest = {
status: UpdateStatusSchema;
};
/**
* UpdateStatusResponse
*/
export type UpdateStatusResponse = {
/**
* Message
*/
message: string;
};
/**
* UpdateStatusSchema
*/
export type UpdateStatusSchema = {
/**
* Name
*/
name?: string | null;
/**
* Lexorank
*/
lexorank?: string | null;
};
/**
* ValidationError
*/
export type ValidationError = {
/**
* Location
*/
loc: Array<string | number>;
/**
* Message
*/
msg: string;
/**
* Error Type
*/
type: string;
};
export type GetBoardsData = {
body?: never;
path: {
/**
* Projectid
*/
projectId: number;
};
query?: never;
url: '/board/{projectId}';
};
export type GetBoardsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetBoardsError = GetBoardsErrors[keyof GetBoardsErrors];
export type GetBoardsResponses = {
/**
* Successful Response
*/
200: GetBoardsResponse;
};
export type GetBoardsResponse2 = GetBoardsResponses[keyof GetBoardsResponses];
export type UpdateBoardData = {
body: UpdateBoardRequest;
path: {
/**
* Boardid
*/
boardId: number;
};
query?: never;
url: '/board/{boardId}';
};
export type UpdateBoardErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateBoardError = UpdateBoardErrors[keyof UpdateBoardErrors];
export type UpdateBoardResponses = {
/**
* Successful Response
*/
200: UpdateBoardResponse;
};
export type UpdateBoardResponse2 = UpdateBoardResponses[keyof UpdateBoardResponses];
export type GetDealsData = {
body?: never;
path: {
/**
* Boardid
*/
boardId: number;
};
query?: never;
url: '/deal/{boardId}';
};
export type GetDealsErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetDealsError = GetDealsErrors[keyof GetDealsErrors];
export type GetDealsResponses = {
/**
* Successful Response
*/
200: GetDealsResponse;
};
export type GetDealsResponse2 = GetDealsResponses[keyof GetDealsResponses];
export type UpdateDealData = {
body: UpdateDealRequest;
path: {
/**
* Dealid
*/
dealId: number;
};
query?: never;
url: '/deal/{dealId}';
};
export type UpdateDealErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateDealError = UpdateDealErrors[keyof UpdateDealErrors];
export type UpdateDealResponses = {
/**
* Successful Response
*/
200: UpdateDealResponse;
};
export type UpdateDealResponse2 = UpdateDealResponses[keyof UpdateDealResponses];
export type GetProjectsData = {
body?: never;
path?: never;
query?: never;
url: '/project/';
};
export type GetProjectsResponses = {
/**
* Successful Response
*/
200: GetProjectsResponse;
};
export type GetProjectsResponse2 = GetProjectsResponses[keyof GetProjectsResponses];
export type GetStatusesData = {
body?: never;
path: {
/**
* Boardid
*/
boardId: number;
};
query?: never;
url: '/status/{boardId}';
};
export type GetStatusesErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetStatusesError = GetStatusesErrors[keyof GetStatusesErrors];
export type GetStatusesResponses = {
/**
* Successful Response
*/
200: GetStatusesResponse;
};
export type GetStatusesResponse2 = GetStatusesResponses[keyof GetStatusesResponses];
export type UpdateStatusData = {
body: UpdateStatusRequest;
path: {
/**
* Statusid
*/
statusId: number;
};
query?: never;
url: '/status/{statusId}';
};
export type UpdateStatusErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type UpdateStatusError = UpdateStatusErrors[keyof UpdateStatusErrors];
export type UpdateStatusResponses = {
/**
* Successful Response
*/
200: UpdateStatusResponse;
};
export type UpdateStatusResponse2 = UpdateStatusResponses[keyof UpdateStatusResponses];
export type ClientOptions = {
baseURL: `${string}://${string}/api` | (string & {});
};

263
src/lib/client/zod.gen.ts Normal file
View File

@ -0,0 +1,263 @@
// This file is auto-generated by @hey-api/openapi-ts
import { z } from 'zod';
/**
* BoardSchema
*/
export const zBoardSchema = z.object({
name: z.string(),
id: z.int(),
lexorank: z.string()
});
/**
* DealSchema
*/
export const zDealSchema = z.object({
name: z.string(),
id: z.int(),
lexorank: z.string(),
statusId: z.int()
});
/**
* GetBoardsResponse
*/
export const zGetBoardsResponse = z.object({
boards: z.array(zBoardSchema)
});
/**
* GetDealsResponse
*/
export const zGetDealsResponse = z.object({
deals: z.array(zDealSchema)
});
/**
* ProjectSchema
*/
export const zProjectSchema = z.object({
name: z.string(),
id: z.int()
});
/**
* GetProjectsResponse
*/
export const zGetProjectsResponse = z.object({
projects: z.array(zProjectSchema)
});
/**
* StatusSchema
*/
export const zStatusSchema = z.object({
name: z.string(),
id: z.int(),
lexorank: z.string()
});
/**
* GetStatusesResponse
*/
export const zGetStatusesResponse = z.object({
statuses: z.array(zStatusSchema)
});
/**
* ValidationError
*/
export const zValidationError = z.object({
loc: z.array(z.union([
z.string(),
z.int()
])),
msg: z.string(),
type: z.string()
});
/**
* HTTPValidationError
*/
export const zHttpValidationError = z.object({
detail: z.optional(z.array(zValidationError))
});
/**
* UpdateBoardSchema
*/
export const zUpdateBoardSchema = z.object({
name: z.optional(z.union([
z.string(),
z.null()
])),
lexorank: z.optional(z.union([
z.string(),
z.null()
]))
});
/**
* UpdateBoardRequest
*/
export const zUpdateBoardRequest = z.object({
board: zUpdateBoardSchema
});
/**
* UpdateBoardResponse
*/
export const zUpdateBoardResponse = z.object({
message: z.string()
});
/**
* UpdateDealSchema
*/
export const zUpdateDealSchema = z.object({
name: z.optional(z.union([
z.string(),
z.null()
])),
lexorank: z.optional(z.union([
z.string(),
z.null()
])),
statusId: z.optional(z.union([
z.int(),
z.null()
]))
});
/**
* UpdateDealRequest
*/
export const zUpdateDealRequest = z.object({
deal: zUpdateDealSchema
});
/**
* UpdateDealResponse
*/
export const zUpdateDealResponse = z.object({
message: z.string()
});
/**
* UpdateStatusSchema
*/
export const zUpdateStatusSchema = z.object({
name: z.optional(z.union([
z.string(),
z.null()
])),
lexorank: z.optional(z.union([
z.string(),
z.null()
]))
});
/**
* UpdateStatusRequest
*/
export const zUpdateStatusRequest = z.object({
status: zUpdateStatusSchema
});
/**
* UpdateStatusResponse
*/
export const zUpdateStatusResponse = z.object({
message: z.string()
});
export const zGetBoardsData = z.object({
body: z.optional(z.never()),
path: z.object({
projectId: z.int()
}),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zGetBoardsResponse2 = zGetBoardsResponse;
export const zUpdateBoardData = z.object({
body: zUpdateBoardRequest,
path: z.object({
boardId: z.int()
}),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zUpdateBoardResponse2 = zUpdateBoardResponse;
export const zGetDealsData = z.object({
body: z.optional(z.never()),
path: z.object({
boardId: z.int()
}),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zGetDealsResponse2 = zGetDealsResponse;
export const zUpdateDealData = z.object({
body: zUpdateDealRequest,
path: z.object({
dealId: z.int()
}),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zUpdateDealResponse2 = zUpdateDealResponse;
export const zGetProjectsData = z.object({
body: z.optional(z.never()),
path: z.optional(z.never()),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zGetProjectsResponse2 = zGetProjectsResponse;
export const zGetStatusesData = z.object({
body: z.optional(z.never()),
path: z.object({
boardId: z.int()
}),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zGetStatusesResponse2 = zGetStatusesResponse;
export const zUpdateStatusData = z.object({
body: zUpdateStatusRequest,
path: z.object({
statusId: z.int()
}),
query: z.optional(z.never())
});
/**
* Successful Response
*/
export const zUpdateStatusResponse2 = zUpdateStatusResponse;

View File

@ -0,0 +1 @@
export * from "./notifications";

View File

@ -0,0 +1,46 @@
import { notifications } from "@mantine/notifications";
type CustomNotifications = {
notify: (...params: Parameters<typeof notifications.show>) => void;
success: (...params: Parameters<typeof notifications.show>) => void;
warn: (...params: Parameters<typeof notifications.show>) => void;
error: (...params: Parameters<typeof notifications.show>) => void;
guess: (
ok: boolean,
...params: Parameters<typeof notifications.show>
) => void;
} & typeof notifications;
const customNotifications: CustomNotifications = {
...notifications,
notify: params => {
return notifications.show({
...params,
color: "blue",
});
},
success: params => {
return notifications.show({
...params,
color: "green",
});
},
warn: params => {
return notifications.show({
...params,
color: "yellow",
});
},
error: params => {
return notifications.show({
...params,
color: "red",
});
},
guess: (ok: boolean, params) => {
if (ok) return customNotifications.success(params);
return customNotifications.error(params);
},
};
export { customNotifications as notifications };

View File

@ -0,0 +1,23 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface AuthState {
phoneNumber: string | null;
}
const initialState: AuthState = {
phoneNumber: null,
};
export const authSlice = createSlice({
name: "authentication",
initialState,
reducers: {
setPhoneNumber: (state, action: PayloadAction<string | null>) => {
state.phoneNumber = action.payload;
},
},
});
export const { setPhoneNumber } = authSlice.actions;
export default authSlice.reducer;

View File

@ -0,0 +1,8 @@
import { combineReducers } from "@reduxjs/toolkit";
import authReducer from "@/lib/store/features/auth/authSlice";
const rootReducer = combineReducers({
auth: authReducer,
});
export default rootReducer;

28
src/lib/store/store.ts Normal file
View File

@ -0,0 +1,28 @@
import { configureStore } from "@reduxjs/toolkit";
import { useDispatch } from "react-redux";
import { persistReducer, persistStore } from "redux-persist";
import storage from "redux-persist/lib/storage";
import rootReducer from "@/lib/store/features/rootReducer";
const persistConfig = {
key: "root",
storage,
whitelist: ["targetService", "verification", "auth"],
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
reducer: persistedReducer,
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}),
});
export const persistor = persistStore(store);
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();

View File

@ -0,0 +1,16 @@
"use client"
import { Flex, rem, Text } from "@mantine/core";
import { ContextModalProps } from "@mantine/modals";
const TestModal = ({ id, context, innerProps }: ContextModalProps) => {
return (
<Flex
gap={rem(10)}
direction={"column"}>
<Text>Hi</Text>
</Flex>
);
};
export default TestModal;

5
src/modals/modals.ts Normal file
View File

@ -0,0 +1,5 @@
import TestModal from "@/modals/TestModal/TestModal";
export const modals = {
testModal: TestModal,
};

View File

@ -0,0 +1,27 @@
"use client";
import { ReactNode, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
type Props = {
children: ReactNode;
};
export function ReactQueryProvider({ children }: Props) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 3 * 60 * 1000,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}

View File

@ -0,0 +1,23 @@
"use client";
import { ReactNode } from "react";
import { Provider } from "react-redux";
import { PersistGate } from "redux-persist/integration/react";
import { persistor, store } from "@/lib/store/store";
type Props = {
children: ReactNode;
};
export default function ReduxProvider({ children }: Props) {
return (
<Provider store={store}>
{" "}
<PersistGate
loading={null}
persistor={persistor}>
{children}
</PersistGate>
</Provider>
);
}

5
src/redux-persist.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module "redux-persist/lib/storage" {
import { WebStorage } from "redux-persist/es/types";
const localStorage: WebStorage;
export default localStorage;
}

43
src/theme.ts Normal file
View File

@ -0,0 +1,43 @@
import { createTheme, MantineColorsTuple } from "@mantine/core";
export const myColor: MantineColorsTuple = [
"#e2faff",
"#d4eff8",
"#afdce9",
"#87c8db",
"#65b7cf",
"#4aaac7",
"#3fa7c6",
"#2c92af",
"#1b829e",
"#00718c",
];
const radius = "md";
const size = "md";
export const theme = createTheme({
colors: {
myColor,
},
primaryColor: "myColor",
components: {
Button: {
defaultProps: {
radius,
size,
},
},
InputBase: {
defaultProps: {
radius,
size,
},
},
Modal: {
defaultProps: {
radius,
},
},
},
});

36
src/utils/lexorank.ts Normal file
View File

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

View File

@ -1,28 +1,25 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "es5", "types": ["node", "jest", "@testing-library/jest-dom"],
"lib": ["dom", "dom.iterable", "esnext"], "target": "ES2020",
"allowJs": true, "lib": ["dom", "dom.iterable", "esnext"],
"skipLibCheck": true, "allowJs": true,
"strict": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "strict": true,
"noEmit": true, "forceConsistentCasingInFileNames": true,
"esModuleInterop": true, "noEmit": true,
"module": "esnext", "esModuleInterop": true,
"moduleResolution": "node", "module": "esnext",
"resolveJsonModule": true, "moduleResolution": "node",
"isolatedModules": true, "resolveJsonModule": true,
"jsx": "preserve", "isolatedModules": true,
"incremental": true, "jsx": "preserve",
"plugins": [ "incremental": true,
{ "paths": {
"name": "next" "@/*": ["./src/*"]
}
],
"paths": {
"@/*": ["./src/*"]
}
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "plugins": [{ "name": "next" }]
"exclude": ["node_modules"] },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
} }

11372
yarn.lock

File diff suppressed because it is too large Load Diff