Compare commits
203 Commits
5e6cfe8070
...
pragmatic-
| Author | SHA1 | Date | |
|---|---|---|---|
| 36c2a3a2af | |||
| fc176ec9e4 | |||
| 4a4b05769d | |||
| 2052737561 | |||
| a899177623 | |||
| 5e56daa765 | |||
| 92602549f8 | |||
| 6351642838 | |||
| 4db3c3a1bf | |||
| 5aa3b4d9e2 | |||
| 73e3fd4ba2 | |||
| 8af4fcce2f | |||
| 820d9b4d33 | |||
| 428a6aca82 | |||
| 7b0b3bc529 | |||
| b316cf4f7a | |||
| 665625557d | |||
| b35961329e | |||
| 0fcf086861 | |||
| d14920df7d | |||
| 50ade0e832 | |||
| e9bfd39ab4 | |||
| f641e9ef8c | |||
| 1a2895da59 | |||
| f3a0179467 | |||
| b51467cbf6 | |||
| 61f0a9069b | |||
| 47533ad7f5 | |||
| 14140826a7 | |||
| a83328492e | |||
| 41ff994ad1 | |||
| 6d6c430e88 | |||
| 6e445d5ebf | |||
| 30e0de5c5e | |||
| de82e639b2 | |||
| e7416155be | |||
| 05edac23f1 | |||
| 9ba22b9bdf | |||
| e049494fa5 | |||
| 79189bea9a | |||
| 053c1da5db | |||
| 0805a86335 | |||
| a95d05e28b | |||
| 6b4e2f193a | |||
| 4c5b9c7734 | |||
| 681c2c3bc8 | |||
| d927da46df | |||
| 553e76d610 | |||
| f2746b8b65 | |||
| c76304b7bc | |||
| c4381d86c7 | |||
| 0515dd8a49 | |||
| d76dc82cb8 | |||
| 67780b5251 | |||
| 0236379898 | |||
| d0c734d481 | |||
| 7694b4ae03 | |||
| a5afb03be6 | |||
| dce4dec2f5 | |||
| 0be2b8bb6b | |||
| 96ea0bba5e | |||
| 6d58add2e7 | |||
| b0e2703479 | |||
| 018c6a06ea | |||
| dcf069aa1b | |||
| 604238ca43 | |||
| b5934a7ed2 | |||
| d69dee7caa | |||
| 5f621c295b | |||
| 9d8ec496a1 | |||
| 492b7ac32e | |||
| dca7d5f6a5 | |||
| 72ed69db24 | |||
| a6d8948e9d | |||
| 48d539154c | |||
| ab7ef1e753 | |||
| 26c7209de0 | |||
| d0948fb583 | |||
| db5b886455 | |||
| b363554c46 | |||
| 1b97739063 | |||
| a0522357d4 | |||
| 9d3028e4c9 | |||
| 568bd4ad36 | |||
| 8b06d08664 | |||
| 50d4705c5e | |||
| 658d7a2a0e | |||
| 3dec614f2a | |||
| 9404091d69 | |||
| 19e5ef2a7e | |||
| 4323695069 | |||
| e9b8cdb010 | |||
| a280f7ad12 | |||
| e6001ed59e | |||
| 44766bb7aa | |||
| 4a758e4cf0 | |||
| 31bd888357 | |||
| 5b5c2fe230 | |||
| e0f86f2018 | |||
| 226e52a1c6 | |||
| cc5ccf86a4 | |||
| e5602551c5 | |||
| d5be9ce61a | |||
| 10f50ac254 | |||
| 6ad813ea1d | |||
| f2084ae3d4 | |||
| b105510c23 | |||
| b5753ed3a2 | |||
| cb67c913ad | |||
| f3df8840df | |||
| d5b6e28311 | |||
| e3acf3aa89 | |||
| 32ea2aa060 | |||
| 7dba5b5ed9 | |||
| de7e334453 | |||
| 179b89c786 | |||
| be034ebbd0 | |||
| d3d8c5117b | |||
| 0bb546940a | |||
| 83432b3f33 | |||
| 49b1a235be | |||
| 19a386319c | |||
| 3ccebeb123 | |||
| e5e87f775d | |||
| 85ed974f5e | |||
| 92efe3fb66 | |||
| c405c802aa | |||
| 4ff663536e | |||
| 2e9ed02722 | |||
| a4bcd62189 | |||
| 0a13070d9e | |||
| 219689b947 | |||
| 3ece4677fb | |||
| 3d213cb0d9 | |||
| 6d0c48be23 | |||
| a169600908 | |||
| 43355b6ce3 | |||
| 28004dc2a0 | |||
| c3b0da1e0d | |||
| 8fb4121ed1 | |||
| 95e49eafc1 | |||
| 255a39e2bb | |||
| b6cec9a308 | |||
| 20ade53d52 | |||
| 7932f3f5c8 | |||
| 0836e4f0ca | |||
| 90582b329e | |||
| f2bba7e469 | |||
| 838c9640a1 | |||
| 1a98facd72 | |||
| 5144c83e93 | |||
| 6715e4bd38 | |||
| 7815f99fa4 | |||
| 54cf883a3c | |||
| 45dc8901fd | |||
| 067094c78a | |||
| 301821a682 | |||
| 9fb9e794db | |||
| e3137de46d | |||
| 5ecdd3d887 | |||
| d3febcdfb0 | |||
| afad1b4605 | |||
| f52fde0097 | |||
| e29664ecc5 | |||
| 7e2dd9763b | |||
| 41f8d19d49 | |||
| 335fbfe81c | |||
| 4b843d8e5d | |||
| 96c53380e0 | |||
| 9a780e99ae | |||
| 1047a0b5fe | |||
| 573f50acc1 | |||
| 24edefa242 | |||
| cd034bcce6 | |||
| 316cca712d | |||
| 74f7cc7664 | |||
| 7bb8ab97c7 | |||
| abbf782945 | |||
| c13cc4a0a5 | |||
| 236c0dcf10 | |||
| c98a5cc811 | |||
| 24de9f5446 | |||
| f13417e73a | |||
| 2ae9c619c7 | |||
| 315e7db3db | |||
| 5435750fb5 | |||
| 624c94155c | |||
| 3e1d544b33 | |||
| 459487a896 | |||
| 8ae198897d | |||
| 586af488da | |||
| 943b2d63f5 | |||
| 921ab4c89f | |||
| d13997ba80 | |||
| 5137836265 | |||
| 5fe9ea6747 | |||
| 8af4a908e6 | |||
| 128a1b3c4f | |||
| cb168b6415 | |||
| b8d431ae99 | |||
| cb6a814918 | |||
| fe6e87f97c | |||
| 948480c219 |
21
.dockerignore
Normal file
21
.dockerignore
Normal file
@ -0,0 +1,21 @@
|
||||
.storybook
|
||||
tests
|
||||
__tests__
|
||||
*.spec.ts
|
||||
*.test.ts
|
||||
node_modules
|
||||
.yarn/cache
|
||||
.eslint
|
||||
.prettier
|
||||
.stylelint
|
||||
.env
|
||||
.idea
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile
|
||||
README.md
|
||||
*.log
|
||||
test
|
||||
docs
|
||||
coverage
|
||||
*.map
|
||||
1
.env.example
Normal file
1
.env.example
Normal file
@ -0,0 +1 @@
|
||||
NEXT_PUBLIC_API_URL=http://test.crm.logidex.ru/api
|
||||
@ -1,3 +0,0 @@
|
||||
*.js
|
||||
*.mjs
|
||||
*.cjs
|
||||
@ -1,3 +0,0 @@
|
||||
{
|
||||
"extends": "next/core-web-vitals"
|
||||
}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -102,7 +102,6 @@ dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
@ -123,11 +122,9 @@ dist
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
.yarn
|
||||
|
||||
.DS_Store
|
||||
|
||||
.idea
|
||||
.yarnrc.yml
|
||||
@ -1,6 +1 @@
|
||||
# Ignore artifacts:
|
||||
build
|
||||
coverage
|
||||
|
||||
# Ignore all HTML files:
|
||||
**/*.html
|
||||
.next
|
||||
@ -1,6 +1,39 @@
|
||||
{
|
||||
"singleAttributePerLine": true,
|
||||
"singleQuote": false,
|
||||
"semi": true,
|
||||
"quoteProps": "consistent",
|
||||
"bracketSpacing": true,
|
||||
"trailingComma": "es5",
|
||||
"tabWidth": 4,
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
"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
2
.stylelintignore
Normal file
@ -0,0 +1,2 @@
|
||||
.next
|
||||
out
|
||||
28
.stylelintrc.json
Normal file
28
.stylelintrc.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@ -0,0 +1,48 @@
|
||||
FROM node:lts-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable
|
||||
COPY .yarn ./.yarn
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .yarnrc.yml ./
|
||||
|
||||
RUN yarn && rm -rf .yarn/cache .yarn/unplugged .yarn/build-state.yml
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN yarn build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
CMD ["node", "server.js"]
|
||||
38
README.md
38
README.md
@ -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
30
eslint.config.mjs
Normal 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
16
jest.config.cjs
Normal 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
27
jest.setup.cjs
Normal 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;
|
||||
@ -1,12 +1,16 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
optimizePackageImports: [
|
||||
"@mantine/core",
|
||||
"@mantine/hooks",
|
||||
],
|
||||
},
|
||||
}
|
||||
import bundleAnalyzer from '@next/bundle-analyzer';
|
||||
|
||||
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'],
|
||||
},
|
||||
});
|
||||
|
||||
30
openapi-ts.config.ts
Normal file
30
openapi-ts.config.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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,
|
||||
definitions: true,
|
||||
metadata: true,
|
||||
dates: {
|
||||
offset: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "@hey-api/sdk",
|
||||
asClass: false,
|
||||
validator: "zod",
|
||||
},
|
||||
{
|
||||
name: "@hey-api/client-next",
|
||||
runtimeConfigPath: "./src/hey-api-config.ts",
|
||||
},
|
||||
],
|
||||
});
|
||||
103
package.json
103
package.json
@ -3,44 +3,103 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
"lint": "next lint",
|
||||
"generate-client": "openapi-ts && prettier --write ./src/lib/client/**/*.ts && git add ./src/lib/client",
|
||||
"generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^8.2.1",
|
||||
"@mantine/dates": "^8.2.1",
|
||||
"@mantine/dropzone": "^8.2.1",
|
||||
"@mantine/form": "^8.2.1",
|
||||
"@mantine/hooks": "^8.2.1",
|
||||
"@atlaskit/avatar": "^25.4.2",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
|
||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.7",
|
||||
"@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@mantine/core": "8.1.2",
|
||||
"@mantine/dates": "^8.2.7",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/form": "^8.1.3",
|
||||
"@mantine/hooks": "8.1.2",
|
||||
"@mantine/modals": "^8.2.1",
|
||||
"@mantine/notifications": "^8.2.1",
|
||||
"@next/bundle-analyzer": "^15.3.3",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@tabler/icons-react": "^3.34.1",
|
||||
"@tabler/icons-react": "^3.34.0",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"lodash": "^4.17.21",
|
||||
"next": "15.3.3",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"axios": "1.12.0",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"dayjs": "^1.11.15",
|
||||
"framer-motion": "^12.23.7",
|
||||
"handlebars": "^4.7.8",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"lexorank": "^1.0.5",
|
||||
"libphonenumber-js": "^1.12.10",
|
||||
"mantine-datatable": "^8.2.0",
|
||||
"next": "15.4.7",
|
||||
"phone": "^3.1.67",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-imask": "^7.6.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"tailwind-preset-mantine": "^2.1.0",
|
||||
"tailwindcss": "^4.1.11"
|
||||
"redux-persist": "^6.0.0",
|
||||
"sharp": "^0.34.3",
|
||||
"swiper": "^11.2.10",
|
||||
"zod": "^4.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4",
|
||||
"@types/node": "22.13.11",
|
||||
"@types/react": "19.0.12",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"eslint": "9.23.0",
|
||||
"eslint-config-next": "15.2.3",
|
||||
"postcss": "^8.5.3",
|
||||
"@babel/core": "^7.27.4",
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@hey-api/client-axios": "^0.9.1",
|
||||
"@hey-api/client-next": "^0.5.1",
|
||||
"@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/react-slick": "^0",
|
||||
"@types/redux-persist": "^4.3.1",
|
||||
"@types/slick-carousel": "^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.6.2",
|
||||
"typescript": "5.8.2"
|
||||
"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"
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
"postcss-preset-mantine": {},
|
||||
"@tailwindcss/postcss": {},
|
||||
"postcss-simple-vars": {
|
||||
variables: {
|
||||
"mantine-breakpoint-xs": "36em",
|
||||
@ -12,4 +12,4 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@ -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
16
src/.storybook/main.ts
Normal 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;
|
||||
36
src/.storybook/preview.tsx
Normal file
36
src/.storybook/preview.tsx
Normal 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>,
|
||||
];
|
||||
15
src/app/actions/components/Action/Action.module.css
Normal file
15
src/app/actions/components/Action/Action.module.css
Normal file
@ -0,0 +1,15 @@
|
||||
.link {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
@mixin light {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
@mixin dark {
|
||||
color: var(--mantine-color-dark-0);
|
||||
}
|
||||
}
|
||||
36
src/app/actions/components/Action/Action.tsx
Normal file
36
src/app/actions/components/Action/Action.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { FC } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Stack, Text } from "@mantine/core";
|
||||
import ThemeIcon from "@/components/ui/ThemeIcon/ThemeIcon";
|
||||
import LinkData from "@/types/LinkData";
|
||||
import styles from "./Action.module.css";
|
||||
|
||||
type Props = {
|
||||
linkData: LinkData;
|
||||
};
|
||||
|
||||
const Action: FC<Props> = ({ linkData }) => {
|
||||
return (
|
||||
<Link
|
||||
href={linkData.href}
|
||||
className={styles.link}>
|
||||
<Button
|
||||
w={"100%"}
|
||||
h={"100px"}
|
||||
variant={"default"}>
|
||||
<Stack
|
||||
px={"xs"}
|
||||
w={"100%"}
|
||||
align={"center"}
|
||||
gap={"xs"}>
|
||||
<ThemeIcon size={"sm"}>
|
||||
<linkData.icon />
|
||||
</ThemeIcon>
|
||||
<Text>{linkData.label}</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default Action;
|
||||
48
src/app/actions/components/PageBody/PageBody.tsx
Normal file
48
src/app/actions/components/PageBody/PageBody.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { SimpleGrid, Stack } from "@mantine/core";
|
||||
import Action from "@/app/actions/components/Action/Action";
|
||||
import mobileButtonsData from "@/app/actions/data/mobileButtonsData";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
|
||||
|
||||
const PageBody = () => {
|
||||
const { selectedProject, setSelectedProjectId, projects, modulesSet } =
|
||||
useProjectsContext();
|
||||
|
||||
const filteredMobileButtonsData = useMemo(
|
||||
() =>
|
||||
mobileButtonsData.filter(
|
||||
link => !link.moduleName || modulesSet.has(link.moduleName)
|
||||
),
|
||||
[modulesSet]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageBlock fullScreenMobile>
|
||||
<Stack p={"xs"}>
|
||||
<ProjectSelect
|
||||
onChange={project =>
|
||||
setSelectedProjectId(project?.id ?? null)
|
||||
}
|
||||
value={selectedProject}
|
||||
data={projects}
|
||||
/>
|
||||
<SimpleGrid
|
||||
type={"container"}
|
||||
cols={2}>
|
||||
{filteredMobileButtonsData.map((data, index) => (
|
||||
<Action
|
||||
linkData={data}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</PageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBody;
|
||||
37
src/app/actions/data/mobileButtonsData.tsx
Normal file
37
src/app/actions/data/mobileButtonsData.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import {
|
||||
IconBox,
|
||||
IconColumns,
|
||||
IconFileBarcode,
|
||||
IconUsers,
|
||||
} from "@tabler/icons-react";
|
||||
import { ModuleNames } from "@/modules/modules";
|
||||
import LinkData from "@/types/LinkData";
|
||||
|
||||
const mobileButtonsData: LinkData[] = [
|
||||
{
|
||||
icon: IconUsers,
|
||||
label: "Клиенты",
|
||||
href: "/clients",
|
||||
moduleName: ModuleNames.CLIENTS,
|
||||
},
|
||||
{
|
||||
icon: IconColumns,
|
||||
label: "Услуги",
|
||||
href: "/services",
|
||||
moduleName: ModuleNames.FULFILLMENT_BASE,
|
||||
},
|
||||
{
|
||||
icon: IconBox,
|
||||
label: "Товары",
|
||||
href: "/products",
|
||||
moduleName: ModuleNames.FULFILLMENT_BASE,
|
||||
},
|
||||
{
|
||||
icon: IconFileBarcode,
|
||||
label: "Шаблоны штрихкодов",
|
||||
href: "/barcode-templates",
|
||||
moduleName: ModuleNames.FULFILLMENT_BASE,
|
||||
},
|
||||
];
|
||||
|
||||
export default mobileButtonsData;
|
||||
19
src/app/actions/page.tsx
Normal file
19
src/app/actions/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import PageContainer from "@/components/layout/PageContainer/PageContainer";
|
||||
import PageBody from "./components/PageBody/PageBody";
|
||||
|
||||
export default async function ActionsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="50vh">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
}>
|
||||
<PageContainer>
|
||||
<PageBody />
|
||||
</PageContainer>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,19 @@
|
||||
import { FC } from "react";
|
||||
import { Group } from "@mantine/core";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
type Props = {
|
||||
onCreateClick: () => void;
|
||||
};
|
||||
|
||||
const BarcodeTemplatesDesktopHeader: FC<Props> = ({ onCreateClick }) => {
|
||||
return (
|
||||
<Group>
|
||||
<InlineButton onClick={onCreateClick}>
|
||||
Создать шаблон
|
||||
</InlineButton>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarcodeTemplatesDesktopHeader;
|
||||
@ -0,0 +1,23 @@
|
||||
import { FC } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
type Props = {
|
||||
onCreateClick: () => void;
|
||||
};
|
||||
|
||||
const BarcodeTemplatesMobileHeader: FC<Props> = ({ onCreateClick }) => {
|
||||
return (
|
||||
<Box
|
||||
px={"xs"}
|
||||
pt={"xs"}>
|
||||
<InlineButton
|
||||
onClick={onCreateClick}
|
||||
w={"100%"}>
|
||||
Создать шаблон
|
||||
</InlineButton>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarcodeTemplatesMobileHeader;
|
||||
@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import useBarcodeTemplateAttributesList from "@/app/barcode-templates/hooks/useBarcodeTemplateAttributesList";
|
||||
import ObjectMultiSelect, {
|
||||
ObjectMultiSelectProps,
|
||||
} from "@/components/selects/ObjectMultiSelect/ObjectMultiSelect";
|
||||
import { BarcodeTemplateAttributeSchema } from "@/lib/client";
|
||||
|
||||
type Props = Omit<
|
||||
ObjectMultiSelectProps<BarcodeTemplateAttributeSchema>,
|
||||
"data" | "getLabelFn" | "getValueFn"
|
||||
>;
|
||||
|
||||
const BarcodeTemplateAttributeMultiselect: FC<Props> = (props: Props) => {
|
||||
const { barcodeTemplateAttributes } = useBarcodeTemplateAttributesList();
|
||||
|
||||
return (
|
||||
<ObjectMultiSelect
|
||||
data={barcodeTemplateAttributes}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarcodeTemplateAttributeMultiselect;
|
||||
@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import useBarcodeTemplateSizesList from "@/app/barcode-templates/hooks/useBarcodeTemplateSizesList";
|
||||
import ObjectSelect, {
|
||||
ObjectSelectProps,
|
||||
} from "@/components/selects/ObjectSelect/ObjectSelect";
|
||||
import { BarcodeTemplateSizeSchema } from "@/lib/client";
|
||||
|
||||
type Props = Omit<ObjectSelectProps<BarcodeTemplateSizeSchema>, "data">;
|
||||
|
||||
const BarcodeTemplateSizeSelect = (props: Props) => {
|
||||
const { barcodeTemplateSizes } = useBarcodeTemplateSizesList();
|
||||
|
||||
return (
|
||||
<ObjectSelect
|
||||
data={barcodeTemplateSizes}
|
||||
getLabelFn={size => `${size.name} (${size.width}x${size.height})`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default BarcodeTemplateSizeSelect;
|
||||
@ -0,0 +1,30 @@
|
||||
import { FC } from "react";
|
||||
import { useBarcodeTemplatesTableColumns } from "@/app/barcode-templates/components/shared/BarcodeTemplatesTable/columns";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { BarcodeTemplateSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
items: BarcodeTemplateSchema[];
|
||||
onDelete: (template: BarcodeTemplateSchema) => void;
|
||||
onChange: (template: BarcodeTemplateSchema) => void;
|
||||
};
|
||||
|
||||
const BarcodeTemplatesTable: FC<Props> = ({ items, ...props }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const columns = useBarcodeTemplatesTableColumns(props);
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
striped
|
||||
withTableBorder
|
||||
records={items}
|
||||
columns={columns}
|
||||
groups={undefined}
|
||||
verticalSpacing={"md"}
|
||||
mx={isMobile ? "xs" : ""}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarcodeTemplatesTable;
|
||||
@ -0,0 +1,60 @@
|
||||
import { useMemo } from "react";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import { Center } from "@mantine/core";
|
||||
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
|
||||
import { BarcodeTemplateSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
onDelete: (template: BarcodeTemplateSchema) => void;
|
||||
onChange: (template: BarcodeTemplateSchema) => void;
|
||||
};
|
||||
|
||||
export const useBarcodeTemplatesTableColumns = ({
|
||||
onDelete,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessor: "actions",
|
||||
title: <Center>Действия</Center>,
|
||||
width: "0%",
|
||||
render: template => (
|
||||
<UpdateDeleteTableActions
|
||||
onDelete={() => onDelete(template)}
|
||||
onChange={() => onChange(template)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "name",
|
||||
title: "Название",
|
||||
},
|
||||
{
|
||||
accessor: "attributes",
|
||||
title: "Атрибуты",
|
||||
render: template => (
|
||||
<>
|
||||
{template.attributes
|
||||
.map(attr => attr.name)
|
||||
.join(", ")}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "size.name",
|
||||
title: "Размер",
|
||||
render: template => `${template.size.name} (${template.size.width}x${template.size.height})`
|
||||
},
|
||||
{
|
||||
accessor: "isDefault",
|
||||
title: "По умолчанию",
|
||||
render: template =>
|
||||
template.isDefault ? <IconCheck /> : <IconX />,
|
||||
},
|
||||
] as DataTableColumn<BarcodeTemplateSchema>[],
|
||||
[]
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import BarcodeTemplatesDesktopHeader from "@/app/barcode-templates/components/desktop/BarcodeTemplatesDesktopHeader/BarcodeTemplatesDesktopHeader";
|
||||
import BarcodeTemplatesMobileHeader from "@/app/barcode-templates/components/mobile/BarcodeTemplatesMobileHeader/BarcodeTemplatesMobileHeader";
|
||||
import BarcodeTemplatesTable from "@/app/barcode-templates/components/shared/BarcodeTemplatesTable/BarcodeTemplatesTable";
|
||||
import useBarcodeTemplateActions from "@/app/barcode-templates/hooks/useBarcodeTemplateActions";
|
||||
import { useBarcodeTemplatesCrud } from "@/app/barcode-templates/hooks/useBarcodeTemplatesCrud";
|
||||
import useBarcodeTemplatesList from "@/app/barcode-templates/hooks/useBarcodeTemplatesList";
|
||||
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const PageBody = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { barcodeTemplates, queryKey } = useBarcodeTemplatesList();
|
||||
const barcodeTemplatesCrud = useBarcodeTemplatesCrud({ queryKey });
|
||||
|
||||
const { onCreate, onChange } = useBarcodeTemplateActions();
|
||||
|
||||
return (
|
||||
<Stack h={"100%"}>
|
||||
{!isMobile && (
|
||||
<PageBlock>
|
||||
<BarcodeTemplatesDesktopHeader onCreateClick={onCreate} />
|
||||
</PageBlock>
|
||||
)}
|
||||
<PageBlock
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
fullScreenMobile>
|
||||
<Stack
|
||||
gap={"xs"}
|
||||
h={"100%"}>
|
||||
{isMobile && (
|
||||
<BarcodeTemplatesMobileHeader
|
||||
onCreateClick={onCreate}
|
||||
/>
|
||||
)}
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
<BarcodeTemplatesTable
|
||||
items={barcodeTemplates}
|
||||
onChange={onChange}
|
||||
onDelete={barcodeTemplatesCrud.onDelete}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</PageBlock>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBody;
|
||||
@ -0,0 +1,42 @@
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useBarcodeTemplatesCrud } from "@/app/barcode-templates/hooks/useBarcodeTemplatesCrud";
|
||||
import useBarcodeTemplatesList from "@/app/barcode-templates/hooks/useBarcodeTemplatesList";
|
||||
import { BarcodeTemplateSchema } from "@/lib/client";
|
||||
|
||||
const useBarcodeTemplateActions = () => {
|
||||
const { queryKey } = useBarcodeTemplatesList();
|
||||
const barcodeTemplatesCrud = useBarcodeTemplatesCrud({ queryKey });
|
||||
|
||||
const onChange = (template: BarcodeTemplateSchema) => {
|
||||
modals.openContextModal({
|
||||
modal: "barcodeTemplateEditorModal",
|
||||
title: "Редактирование шаблона",
|
||||
withCloseButton: false,
|
||||
innerProps: {
|
||||
onChange: updated =>
|
||||
barcodeTemplatesCrud.onUpdate(template.id, updated),
|
||||
entity: template,
|
||||
isEditing: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onCreate = () => {
|
||||
modals.openContextModal({
|
||||
modal: "barcodeTemplateEditorModal",
|
||||
title: "Создание шаблона",
|
||||
withCloseButton: false,
|
||||
innerProps: {
|
||||
onCreate: barcodeTemplatesCrud.onCreate,
|
||||
isEditing: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onChange,
|
||||
onCreate,
|
||||
};
|
||||
};
|
||||
|
||||
export default useBarcodeTemplateActions;
|
||||
@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBarcodeTemplateAttributesOptions } from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
const useBarcodeTemplateAttributesList = () => {
|
||||
const { isLoading, data, refetch } = useQuery(
|
||||
getBarcodeTemplateAttributesOptions()
|
||||
);
|
||||
|
||||
return { barcodeTemplateAttributes: data?.items ?? [], refetch, isLoading };
|
||||
};
|
||||
export default useBarcodeTemplateAttributesList;
|
||||
@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getBarcodeTemplateSizesOptions } from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
const useBarcodeTemplateSizesList = () => {
|
||||
const { isLoading, data, refetch } = useQuery(
|
||||
getBarcodeTemplateSizesOptions()
|
||||
);
|
||||
|
||||
return { barcodeTemplateSizes: data?.items ?? [], refetch, isLoading };
|
||||
};
|
||||
|
||||
export default useBarcodeTemplateSizesList;
|
||||
50
src/app/barcode-templates/hooks/useBarcodeTemplatesCrud.tsx
Normal file
50
src/app/barcode-templates/hooks/useBarcodeTemplatesCrud.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
|
||||
import {
|
||||
BarcodeTemplateSchema,
|
||||
CreateBarcodeTemplateSchema,
|
||||
UpdateBarcodeTemplateSchema,
|
||||
} from "@/lib/client";
|
||||
import {
|
||||
createBarcodeTemplateMutation,
|
||||
deleteBarcodeTemplateMutation,
|
||||
updateBarcodeTemplateMutation,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
type UseBarcodeTemplateOperationsProps = {
|
||||
queryKey: any[];
|
||||
};
|
||||
|
||||
export type BarcodeTemplateCrud = {
|
||||
onCreate: (template: CreateBarcodeTemplateSchema) => void;
|
||||
onUpdate: (
|
||||
templateId: number,
|
||||
template: UpdateBarcodeTemplateSchema
|
||||
) => void;
|
||||
onDelete: (template: BarcodeTemplateSchema) => void;
|
||||
};
|
||||
|
||||
export const useBarcodeTemplatesCrud = ({
|
||||
queryKey,
|
||||
}: UseBarcodeTemplateOperationsProps): BarcodeTemplateCrud => {
|
||||
return useCrudOperations<
|
||||
BarcodeTemplateSchema,
|
||||
UpdateBarcodeTemplateSchema,
|
||||
CreateBarcodeTemplateSchema
|
||||
>({
|
||||
key: "getBarcodeTemplates",
|
||||
queryKey,
|
||||
mutations: {
|
||||
create: createBarcodeTemplateMutation(),
|
||||
update: updateBarcodeTemplateMutation(),
|
||||
delete: deleteBarcodeTemplateMutation(),
|
||||
},
|
||||
getUpdateEntity: (old, update) => ({
|
||||
...old,
|
||||
name: update.name ?? old.name,
|
||||
attributes: update.attributes ?? old.attributes,
|
||||
size: update.size ?? old.size,
|
||||
isDefault: update.isDefault ?? old.isDefault,
|
||||
}),
|
||||
getDeleteConfirmTitle: () => "Удаление шаблона штрихкода",
|
||||
});
|
||||
};
|
||||
15
src/app/barcode-templates/hooks/useBarcodeTemplatesList.tsx
Normal file
15
src/app/barcode-templates/hooks/useBarcodeTemplatesList.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getBarcodeTemplatesOptions,
|
||||
getBarcodeTemplatesQueryKey,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
const useBarcodeTemplatesList = () => {
|
||||
const { isLoading, data, refetch } = useQuery(getBarcodeTemplatesOptions());
|
||||
|
||||
const queryKey = getBarcodeTemplatesQueryKey();
|
||||
|
||||
return { barcodeTemplates: data?.items ?? [], queryKey, refetch, isLoading };
|
||||
};
|
||||
|
||||
export default useBarcodeTemplatesList;
|
||||
@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox, Flex, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { ContextModalProps } from "@mantine/modals";
|
||||
import BarcodeTemplateAttributeMultiselect from "@/app/barcode-templates/components/shared/BarcodeTemplateAttributeMultiselect/BarcodeTemplateAttributeMultiselect";
|
||||
import BarcodeTemplateSizeSelect from "@/app/barcode-templates/components/shared/BarcodeTemplateSizeSelect/BarcodeTemplateSizeSelect";
|
||||
import {
|
||||
BarcodeTemplateSchema,
|
||||
CreateBarcodeTemplateSchema,
|
||||
UpdateBarcodeTemplateSchema,
|
||||
} from "@/lib/client";
|
||||
import BaseFormModal, {
|
||||
CreateEditFormProps,
|
||||
} from "@/modals/base/BaseFormModal/BaseFormModal";
|
||||
|
||||
type Props = CreateEditFormProps<
|
||||
BarcodeTemplateSchema,
|
||||
CreateBarcodeTemplateSchema,
|
||||
UpdateBarcodeTemplateSchema
|
||||
>;
|
||||
|
||||
const BarcodeTemplateEditorModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<Props>) => {
|
||||
const initialValues = innerProps.isEditing
|
||||
? innerProps.entity
|
||||
: ({
|
||||
name: "",
|
||||
isDefault: false,
|
||||
attributes: [],
|
||||
} as Partial<CreateBarcodeTemplateSchema>);
|
||||
|
||||
const form = useForm({
|
||||
initialValues,
|
||||
validate: {
|
||||
attributes: attributes =>
|
||||
!attributes && "Необходимо добавить хотя бы один атрибут",
|
||||
name: name =>
|
||||
!name ||
|
||||
(name.trim() === "" && "Необходимо ввести название шаблона"),
|
||||
size: size => !size && "Необходимо выбрать размер шаблона",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseFormModal
|
||||
{...innerProps}
|
||||
closeOnSubmit
|
||||
form={form}
|
||||
onClose={() => context.closeContextModal(id)}>
|
||||
<Flex
|
||||
direction={"column"}
|
||||
gap={"md"}>
|
||||
<TextInput
|
||||
label={"Название"}
|
||||
placeholder={"Введите название шаблона"}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<BarcodeTemplateSizeSelect
|
||||
label={"Размер"}
|
||||
placeholder={"Выберите размер шаблона"}
|
||||
{...form.getInputProps("size")}
|
||||
/>
|
||||
<BarcodeTemplateAttributeMultiselect
|
||||
label={"Стандартные атрибуты"}
|
||||
placeholder={
|
||||
!form.values.attributes?.length
|
||||
? "Выберите атрибуты"
|
||||
: undefined
|
||||
}
|
||||
{...form.getInputProps("attributes")}
|
||||
/>
|
||||
<Checkbox
|
||||
label={"Использовать по умолчанию"}
|
||||
{...form.getInputProps("isDefault", {
|
||||
type: "checkbox",
|
||||
})}
|
||||
/>
|
||||
</Flex>
|
||||
</BaseFormModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default BarcodeTemplateEditorModal;
|
||||
19
src/app/barcode-templates/page.tsx
Normal file
19
src/app/barcode-templates/page.tsx
Normal file
@ -0,0 +1,19 @@
|
||||
import { Suspense } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import PageBody from "@/app/barcode-templates/components/shared/PageBody/PageBody";
|
||||
import PageContainer from "@/components/layout/PageContainer/PageContainer";
|
||||
|
||||
export default async function BarcodeTemplatesPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="50vh">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
}>
|
||||
<PageContainer>
|
||||
<PageBody />
|
||||
</PageContainer>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { FC } from "react";
|
||||
import { Group, TextInput } from "@mantine/core";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import useClientsActions from "@/app/clients/hooks/utils/useClientsActions";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
const ClientDesktopHeader: FC = () => {
|
||||
const { search, setSearch } = useClientsContext();
|
||||
const { onCreateClick } = useClientsActions();
|
||||
|
||||
return (
|
||||
<Group gap={"xs"}>
|
||||
<InlineButton onClick={onCreateClick}>Создать клиента</InlineButton>
|
||||
<TextInput
|
||||
placeholder={"Поиск"}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientDesktopHeader;
|
||||
@ -0,0 +1,31 @@
|
||||
import { FC } from "react";
|
||||
import { Flex, TextInput } from "@mantine/core";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import useClientsActions from "@/app/clients/hooks/utils/useClientsActions";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
const ClientMobileHeader: FC = () => {
|
||||
const { search, setSearch } = useClientsContext();
|
||||
const { onCreateClick } = useClientsActions();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={"xs"}
|
||||
px={"xs"}
|
||||
pt={"xs"}>
|
||||
<InlineButton
|
||||
w={"100%"}
|
||||
onClick={onCreateClick}>
|
||||
Создать клиента
|
||||
</InlineButton>
|
||||
<TextInput
|
||||
w={"100%"}
|
||||
placeholder={"Поиск"}
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientMobileHeader;
|
||||
@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { useClientsTableColumns } from "@/app/clients/components/shared/ClientsTable/columns";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import useClientsActions from "@/app/clients/hooks/utils/useClientsActions";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import { useDrawersContext } from "@/drawers/DrawersContext";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
const ClientsTable: FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { modulesSet } = useProjectsContext();
|
||||
const { clientsCrud, clients } = useClientsContext();
|
||||
const { onUpdateClick } = useClientsActions();
|
||||
const { openDrawer } = useDrawersContext();
|
||||
|
||||
const onOpenMarketplacesList = (client: ClientSchema) => {
|
||||
openDrawer({
|
||||
key: "clientMarketplaceDrawer",
|
||||
props: { client },
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useClientsTableColumns({
|
||||
onDelete: clientsCrud.onDelete,
|
||||
onChange: onUpdateClick,
|
||||
onOpenMarketplacesList,
|
||||
modulesSet,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
withTableBorder
|
||||
records={clients}
|
||||
columns={columns}
|
||||
verticalSpacing={"md"}
|
||||
mx={isMobile ? "xs" : 0}
|
||||
groups={undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsTable;
|
||||
78
src/app/clients/components/shared/ClientsTable/columns.tsx
Normal file
78
src/app/clients/components/shared/ClientsTable/columns.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useMemo } from "react";
|
||||
import { IconBasket } from "@tabler/icons-react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import { Center } from "@mantine/core";
|
||||
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
import { ModuleNames } from "@/modules/modules";
|
||||
|
||||
type Props = {
|
||||
onChange: (client: ClientSchema) => void;
|
||||
onDelete: (client: ClientSchema) => void;
|
||||
onOpenMarketplacesList: (client: ClientSchema) => void;
|
||||
modulesSet: Set<ModuleNames>;
|
||||
};
|
||||
|
||||
export const useClientsTableColumns = ({
|
||||
onChange,
|
||||
onDelete,
|
||||
onOpenMarketplacesList,
|
||||
modulesSet,
|
||||
}: Props) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessor: "actions",
|
||||
title: <Center>Действия</Center>,
|
||||
width: "0%",
|
||||
render: client => (
|
||||
<UpdateDeleteTableActions
|
||||
onDelete={() => onDelete(client)}
|
||||
onChange={() => onChange(client)}
|
||||
otherActions={[
|
||||
{
|
||||
label: "Маркетплейсы",
|
||||
icon: <IconBasket />,
|
||||
onClick: () =>
|
||||
onOpenMarketplacesList(client),
|
||||
hidden: !modulesSet.has(
|
||||
ModuleNames.FULFILLMENT_BASE
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "name",
|
||||
title: "Имя",
|
||||
},
|
||||
{
|
||||
accessor: "details.telegram",
|
||||
title: "Телеграм",
|
||||
},
|
||||
{
|
||||
accessor: "details.email",
|
||||
title: "Почта",
|
||||
},
|
||||
{
|
||||
accessor: "details.phoneNumber",
|
||||
title: "Телефон",
|
||||
},
|
||||
{
|
||||
accessor: "details.inn",
|
||||
title: "ИНН",
|
||||
},
|
||||
{
|
||||
accessor: "companyName",
|
||||
title: "Название компании",
|
||||
},
|
||||
{
|
||||
accessor: "comment",
|
||||
title: "Комментарий",
|
||||
},
|
||||
] as DataTableColumn<ClientSchema>[],
|
||||
[onChange, onDelete]
|
||||
);
|
||||
};
|
||||
37
src/app/clients/components/shared/PageBody/PageBody.tsx
Normal file
37
src/app/clients/components/shared/PageBody/PageBody.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { Stack } from "@mantine/core";
|
||||
import ClientDesktopHeader from "@/app/clients/components/desktop/ClientDesktopHeader/ClientDesktopHeader";
|
||||
import ClientsTable from "@/app/clients/components/shared/ClientsTable/ClientsTable";
|
||||
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import ClientMobileHeader from "@/app/clients/components/mobile/ClientMobileHeader/ClientMobileHeader";
|
||||
|
||||
const PageBody: FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Stack h={"100%"}>
|
||||
{!isMobile && (
|
||||
<PageBlock>
|
||||
<ClientDesktopHeader />
|
||||
</PageBlock>
|
||||
)}
|
||||
<PageBlock
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
fullScreenMobile>
|
||||
<Stack
|
||||
gap={"xs"}
|
||||
h={"100%"}>
|
||||
{isMobile && <ClientMobileHeader />}
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
<ClientsTable />
|
||||
</div>
|
||||
</Stack>
|
||||
</PageBlock>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBody;
|
||||
39
src/app/clients/contexts/ClientsContext.tsx
Normal file
39
src/app/clients/contexts/ClientsContext.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
ClientsCrud,
|
||||
useClientsCrud,
|
||||
} from "@/app/clients/hooks/cruds/useClientsCrud";
|
||||
import useClientsFilter from "@/app/clients/hooks/utils/useClientsFilter";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
import makeContext from "@/lib/contextFactory/contextFactory";
|
||||
import useClientsList from "../hooks/lists/useClientsList";
|
||||
|
||||
type ClientsContextState = {
|
||||
clients: ClientSchema[];
|
||||
refetchClients: () => void;
|
||||
search: string;
|
||||
setSearch: Dispatch<SetStateAction<string>>;
|
||||
clientsCrud: ClientsCrud;
|
||||
};
|
||||
|
||||
const useClientsContextState = (): ClientsContextState => {
|
||||
const clientsList = useClientsList();
|
||||
|
||||
const { filteredClients, search, setSearch } =
|
||||
useClientsFilter(clientsList);
|
||||
|
||||
const clientsCrud = useClientsCrud(clientsList);
|
||||
|
||||
return {
|
||||
clients: filteredClients,
|
||||
refetchClients: clientsList.refetch,
|
||||
search,
|
||||
setSearch,
|
||||
clientsCrud,
|
||||
};
|
||||
};
|
||||
|
||||
export const [ClientsContextProvider, useClientsContext] =
|
||||
makeContext<ClientsContextState>(useClientsContextState, "Clients");
|
||||
@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { Drawer } from "@mantine/core";
|
||||
import DrawerBody from "@/app/clients/drawers/ClientMarketplacesDrawer/components/DrawerBody";
|
||||
import { MarketplacesContextProvider } from "@/app/clients/drawers/ClientMarketplacesDrawer/contexts/MarketplacesContext";
|
||||
import { DrawerProps } from "@/drawers/types";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
client: ClientSchema;
|
||||
};
|
||||
|
||||
const ClientMarketplaceDrawer: FC<DrawerProps<Props>> = ({
|
||||
opened,
|
||||
onClose,
|
||||
props,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
size={isMobile ? "100%" : "40%"}
|
||||
position={"right"}
|
||||
onClose={onClose}
|
||||
removeScrollProps={{ allowPinchZoom: true }}
|
||||
withCloseButton={isMobile}
|
||||
opened={opened}
|
||||
trapFocus={false}
|
||||
title={isMobile ? "Маркетплейсы" : ""}
|
||||
styles={{
|
||||
body: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: isMobile ? "var(--mobile-page-height)" : "100%",
|
||||
padding: isMobile ? 0 : "var(--mantine-spacing-xs)",
|
||||
},
|
||||
header: {
|
||||
paddingBlock: 0,
|
||||
},
|
||||
}}>
|
||||
<MarketplacesContextProvider client={props.client}>
|
||||
<DrawerBody />
|
||||
</MarketplacesContextProvider>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientMarketplaceDrawer;
|
||||
@ -0,0 +1,53 @@
|
||||
import { FC } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
ComboboxItem,
|
||||
ComboboxLikeRenderOptionInput,
|
||||
Image,
|
||||
} from "@mantine/core";
|
||||
import useBaseMarketplacesList from "@/app/clients/hooks/lists/useBaseMarketplacesList";
|
||||
import ObjectSelect, {
|
||||
ObjectSelectProps,
|
||||
} from "@/components/selects/ObjectSelect/ObjectSelect";
|
||||
import { BaseMarketplaceSchema } from "@/lib/client";
|
||||
|
||||
type Props = Omit<
|
||||
ObjectSelectProps<BaseMarketplaceSchema>,
|
||||
"data" | "getValueFn" | "getLabelFn"
|
||||
>;
|
||||
|
||||
const BaseMarketplaceSelect: FC<Props> = props => {
|
||||
const { baseMarketplaces } = useBaseMarketplacesList();
|
||||
|
||||
const renderOption = (
|
||||
baseMarketplace: ComboboxLikeRenderOptionInput<ComboboxItem>
|
||||
) => (
|
||||
<>
|
||||
<ActionIcon
|
||||
radius={"md"}
|
||||
variant={"transparent"}>
|
||||
<Image
|
||||
src={
|
||||
baseMarketplaces.find(
|
||||
el =>
|
||||
baseMarketplace.option.value ===
|
||||
el.id.toString()
|
||||
)?.iconUrl || ""
|
||||
}
|
||||
/>
|
||||
</ActionIcon>
|
||||
{baseMarketplace.option.label}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ObjectSelect
|
||||
renderOption={renderOption}
|
||||
getValueFn={baseMarketplace => baseMarketplace.id.toString()}
|
||||
getLabelFn={baseMarketplace => baseMarketplace.name}
|
||||
data={baseMarketplaces}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
export default BaseMarketplaceSelect;
|
||||
@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { Flex } from "@mantine/core";
|
||||
import MarketplacesHeader from "@/app/clients/drawers/ClientMarketplacesDrawer/components/MarketplacesHeader";
|
||||
import MarketplacesTable from "@/app/clients/drawers/ClientMarketplacesDrawer/components/MarketplacesTable";
|
||||
|
||||
const DrawerBody = () => {
|
||||
return (
|
||||
<Flex
|
||||
gap={"xs"}
|
||||
h={"100%"}
|
||||
direction={"column"}>
|
||||
<MarketplacesHeader />
|
||||
<MarketplacesTable />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default DrawerBody;
|
||||
@ -0,0 +1,21 @@
|
||||
import { FC } from "react";
|
||||
import BaseMarketplaceType from "@/modules/dealModularEditorTabs/FulfillmentBase/shared/types/baseMarketplaceType";
|
||||
import OzonInputs from "./components/OzonInputs";
|
||||
import WildberriesInputs from "./components/WildberriesInputs";
|
||||
import YandexMarketInputs from "./components/YandexMarketInputs";
|
||||
import MpAuthDataInputProps from "./types/MpAuthDataInputProps";
|
||||
|
||||
const MarketplaceAuthDataInput: FC<MpAuthDataInputProps> = props => {
|
||||
switch (props.baseMarketplace.id) {
|
||||
case BaseMarketplaceType.WILDBERRIES:
|
||||
return <WildberriesInputs {...props} />;
|
||||
case BaseMarketplaceType.OZON:
|
||||
return <OzonInputs {...props} />;
|
||||
case BaseMarketplaceType.YANDEX_MARKET:
|
||||
return <YandexMarketInputs {...props} />;
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
};
|
||||
|
||||
export default MarketplaceAuthDataInput;
|
||||
@ -0,0 +1,39 @@
|
||||
import { FC } from "react";
|
||||
import { omit } from "lodash";
|
||||
import { NumberInput, TextInput } from "@mantine/core";
|
||||
import MpAuthDataInputProps from "../types/MpAuthDataInputProps";
|
||||
|
||||
const OzonInputs: FC<MpAuthDataInputProps> = props => {
|
||||
const restProps = omit(props, ["baseMarketplace"]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NumberInput
|
||||
{...restProps}
|
||||
label={"Client-Id"}
|
||||
placeholder={"Введите Client-Id"}
|
||||
value={props.value?.["Client-Id"] || undefined}
|
||||
onChange={value =>
|
||||
props.onChange({
|
||||
...props.value,
|
||||
"Client-Id": value.toString(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
{...restProps}
|
||||
label={"Api-Key"}
|
||||
placeholder={"Введите Api-Key"}
|
||||
value={props.value?.["Api-Key"] || ""}
|
||||
onChange={value =>
|
||||
props.onChange({
|
||||
...props.value,
|
||||
"Api-Key": value.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default OzonInputs;
|
||||
@ -0,0 +1,25 @@
|
||||
import { FC } from "react";
|
||||
import { omit } from "lodash";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import MpAuthDataInputProps from "../types/MpAuthDataInputProps";
|
||||
|
||||
const WildberriesInputs: FC<MpAuthDataInputProps> = props => {
|
||||
const restProps = omit(props, ["baseMarketplace"]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...restProps}
|
||||
label={"Ключ авторизации"}
|
||||
placeholder={"Введите ключ авторизации"}
|
||||
value={props.value?.Authorization || ""}
|
||||
onChange={value =>
|
||||
props.onChange({
|
||||
...props.value,
|
||||
Authorization: value.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WildberriesInputs;
|
||||
@ -0,0 +1,25 @@
|
||||
import { FC } from "react";
|
||||
import { omit } from "lodash";
|
||||
import { TextInput } from "@mantine/core";
|
||||
import MpAuthDataInputProps from "../types/MpAuthDataInputProps";
|
||||
|
||||
const YandexMarketInputs: FC<MpAuthDataInputProps> = props => {
|
||||
const restProps = omit(props, ["baseMarketplace"]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...restProps}
|
||||
label={"Api-Key"}
|
||||
placeholder={"Введите Api-Key"}
|
||||
value={props.value?.["Api-Key"] || ""}
|
||||
onChange={value => {
|
||||
props.onChange({
|
||||
...props.value,
|
||||
"Api-Key": value.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default YandexMarketInputs;
|
||||
@ -0,0 +1,12 @@
|
||||
import BaseFormInputProps from "@/utils/baseFormInputProps";
|
||||
import { BaseMarketplaceSchema } from "@/lib/client";
|
||||
|
||||
type RestProps = {
|
||||
baseMarketplace: BaseMarketplaceSchema;
|
||||
};
|
||||
|
||||
type MarketplaceAuthData = Record<string, string>;
|
||||
|
||||
type MpAuthDataInputProps = BaseFormInputProps<MarketplaceAuthData> & RestProps;
|
||||
|
||||
export default MpAuthDataInputProps;
|
||||
@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Group } from "@mantine/core";
|
||||
import useMarketplacesActions from "@/app/clients/drawers/ClientMarketplacesDrawer/hooks/useMarketplaceActions";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const MarketplacesHeader = () => {
|
||||
const { onCreateClick } = useMarketplacesActions();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<InlineButton
|
||||
onClick={onCreateClick}
|
||||
mx={isMobile ? "xs" : ""}
|
||||
w={isMobile ? "100%" : "auto"}>
|
||||
<IconPlus />
|
||||
Создать
|
||||
</InlineButton>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketplacesHeader;
|
||||
@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { IconMoodSad } from "@tabler/icons-react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { useMarketplacesContext } from "@/app/clients/drawers/ClientMarketplacesDrawer/contexts/MarketplacesContext";
|
||||
import useMarketplacesActions from "@/app/clients/drawers/ClientMarketplacesDrawer/hooks/useMarketplaceActions";
|
||||
import { useMarketplacesTableColumns } from "@/app/clients/drawers/ClientMarketplacesDrawer/hooks/useMarketplacesTableColumns";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const MarketplacesTable = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { onUpdateClick } = useMarketplacesActions();
|
||||
const { marketplaces, marketplacesCrud } = useMarketplacesContext();
|
||||
|
||||
const columns = useMarketplacesTableColumns({
|
||||
onChange: onUpdateClick,
|
||||
onDelete: marketplacesCrud.onDelete,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
withTableBorder
|
||||
records={marketplaces}
|
||||
columns={columns}
|
||||
groups={undefined}
|
||||
verticalSpacing={"md"}
|
||||
mx={isMobile ? "xs" : ""}
|
||||
styles={{
|
||||
table: {
|
||||
height: "100%",
|
||||
},
|
||||
}}
|
||||
emptyState={
|
||||
<Group mt={marketplaces.length === 0 ? "xl" : 0}>
|
||||
<Text>Нет маркетплейсов</Text>
|
||||
<IconMoodSad />
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketplacesTable;
|
||||
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
MarketplacesCrud,
|
||||
useMarketplacesCrud,
|
||||
} from "@/app/clients/hooks/cruds/useMarketplacesCrud";
|
||||
import useMarketplacesList from "@/app/clients/hooks/lists/useMarketplacesList";
|
||||
import { ClientSchema, MarketplaceSchema } from "@/lib/client";
|
||||
import makeContext from "@/lib/contextFactory/contextFactory";
|
||||
|
||||
type MarketplacesContextState = {
|
||||
client: ClientSchema;
|
||||
marketplaces: MarketplaceSchema[];
|
||||
refetchMarketplaces: () => void;
|
||||
marketplacesCrud: MarketplacesCrud;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
client: ClientSchema;
|
||||
};
|
||||
|
||||
const useMarketplacesContextState = ({
|
||||
client,
|
||||
}: Props): MarketplacesContextState => {
|
||||
const marketplacesList = useMarketplacesList({ clientId: client.id });
|
||||
|
||||
const marketplacesCrud = useMarketplacesCrud(marketplacesList);
|
||||
|
||||
return {
|
||||
client,
|
||||
marketplaces: marketplacesList.marketplaces,
|
||||
refetchMarketplaces: marketplacesList.refetch,
|
||||
marketplacesCrud,
|
||||
};
|
||||
};
|
||||
|
||||
export const [MarketplacesContextProvider, useMarketplacesContext] =
|
||||
makeContext<MarketplacesContextState, Props>(
|
||||
useMarketplacesContextState,
|
||||
"Marketplaces"
|
||||
);
|
||||
@ -0,0 +1,39 @@
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useMarketplacesContext } from "@/app/clients/drawers/ClientMarketplacesDrawer/contexts/MarketplacesContext";
|
||||
import { MarketplaceSchema } from "@/lib/client";
|
||||
|
||||
const useMarketplacesActions = () => {
|
||||
const { marketplacesCrud, client } = useMarketplacesContext();
|
||||
|
||||
const onCreateClick = () => {
|
||||
modals.openContextModal({
|
||||
modal: "marketplaceEditorModal",
|
||||
title: "Создание маркетплейса",
|
||||
innerProps: {
|
||||
onCreate: values =>
|
||||
marketplacesCrud.onCreate({ ...values, client }),
|
||||
isEditing: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateClick = (marketplace: MarketplaceSchema) => {
|
||||
modals.openContextModal({
|
||||
modal: "marketplaceEditorModal",
|
||||
title: "Редактирование маркетплейса",
|
||||
innerProps: {
|
||||
onChange: updates =>
|
||||
marketplacesCrud.onUpdate(marketplace.id, updates),
|
||||
entity: marketplace,
|
||||
isEditing: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onCreateClick,
|
||||
onUpdateClick,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMarketplacesActions;
|
||||
@ -0,0 +1,48 @@
|
||||
import { useMemo } from "react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import { ActionIcon, Center, Flex, Image } from "@mantine/core";
|
||||
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
|
||||
import { MarketplaceSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
onDelete: (mp: MarketplaceSchema) => void;
|
||||
onChange: (mp: MarketplaceSchema) => void;
|
||||
};
|
||||
|
||||
export const useMarketplacesTableColumns = ({ onDelete, onChange }: Props) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessor: "actions",
|
||||
title: <Center>Действия</Center>,
|
||||
width: "0%",
|
||||
render: mp => (
|
||||
<UpdateDeleteTableActions
|
||||
onDelete={() => onDelete(mp)}
|
||||
onChange={() => onChange(mp)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "Маркетплейс",
|
||||
accessor: "baseMarketplace",
|
||||
cellsStyle: () => ({}),
|
||||
render: mp => (
|
||||
<Flex key={`${mp.id}mp`}>
|
||||
<ActionIcon variant={"transparent"}>
|
||||
<Image
|
||||
src={mp.baseMarketplace?.iconUrl || ""}
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "name",
|
||||
title: "Название",
|
||||
},
|
||||
] as DataTableColumn<MarketplaceSchema>[],
|
||||
[]
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,3 @@
|
||||
import ClientMarketplaceDrawer from "./ClientMarketplacesDrawer";
|
||||
|
||||
export default ClientMarketplaceDrawer;
|
||||
@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Stack, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { ContextModalProps } from "@mantine/modals";
|
||||
import BaseMarketplaceSelect from "@/app/clients/drawers/ClientMarketplacesDrawer/components/BaseMarketplaceSelect";
|
||||
import MarketplaceAuthDataInput from "@/app/clients/drawers/ClientMarketplacesDrawer/components/MarketplaceAuthDataInput/MarketplaceAuthDataInput";
|
||||
import {
|
||||
CreateMarketplaceSchema,
|
||||
MarketplaceSchema,
|
||||
UpdateMarketplaceSchema,
|
||||
} from "@/lib/client";
|
||||
import BaseFormModal, {
|
||||
CreateEditFormProps,
|
||||
} from "@/modals/base/BaseFormModal/BaseFormModal";
|
||||
|
||||
type Props = CreateEditFormProps<
|
||||
MarketplaceSchema,
|
||||
CreateMarketplaceSchema,
|
||||
UpdateMarketplaceSchema
|
||||
>;
|
||||
|
||||
const MarketplaceEditorModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<Props>) => {
|
||||
const initialValues: UpdateMarketplaceSchema | CreateMarketplaceSchema =
|
||||
innerProps.isEditing
|
||||
? innerProps.entity
|
||||
: {
|
||||
name: "",
|
||||
authData: {
|
||||
"Authorization": "",
|
||||
"Client-Id": "",
|
||||
"Api-Key": "",
|
||||
"CampaignId": "",
|
||||
},
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues,
|
||||
validate: {
|
||||
name: name =>
|
||||
(!name || name.trim() === "") &&
|
||||
"Необходимо ввести название маркетплейса",
|
||||
baseMarketplace: baseMarketplace =>
|
||||
!baseMarketplace && "Необходимо указать базовый маркетплейс",
|
||||
authData: authData =>
|
||||
!authData && "Необходимо указать данные авторизации",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseFormModal
|
||||
{...innerProps}
|
||||
closeOnSubmit
|
||||
form={form}
|
||||
onClose={() => context.closeContextModal(id)}>
|
||||
<Stack gap={"xs"}>
|
||||
<TextInput
|
||||
label={"Название"}
|
||||
placeholder={"Введите название маркетплейса"}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<BaseMarketplaceSelect
|
||||
label={"Базовый маркетплейс"}
|
||||
placeholder={"Выберите базовый маркетплейс"}
|
||||
{...form.getInputProps("baseMarketplace")}
|
||||
/>
|
||||
|
||||
{form.values.baseMarketplace && (
|
||||
<MarketplaceAuthDataInput
|
||||
baseMarketplace={form.values.baseMarketplace}
|
||||
value={form.values.authData as Record<string, string>}
|
||||
onChange={value =>
|
||||
form.setFieldValue("authData", value)
|
||||
}
|
||||
error={form.getInputProps("authData").error}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</BaseFormModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketplaceEditorModal;
|
||||
49
src/app/clients/hooks/cruds/useClientsCrud.tsx
Normal file
49
src/app/clients/hooks/cruds/useClientsCrud.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
|
||||
import {
|
||||
ClientSchema,
|
||||
CreateClientSchema,
|
||||
UpdateClientSchema,
|
||||
} from "@/lib/client";
|
||||
import {
|
||||
createClientMutation,
|
||||
deleteClientMutation,
|
||||
updateClientMutation,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
type UseClientsProps = {
|
||||
queryKey: any[];
|
||||
};
|
||||
|
||||
export type ClientsCrud = {
|
||||
onCreate: (client: CreateClientSchema) => void;
|
||||
onUpdate: (
|
||||
clientId: number,
|
||||
client: UpdateClientSchema,
|
||||
onSuccess?: () => void
|
||||
) => void;
|
||||
onDelete: (client: ClientSchema) => void;
|
||||
};
|
||||
|
||||
export const useClientsCrud = ({ queryKey }: UseClientsProps): ClientsCrud => {
|
||||
return useCrudOperations<
|
||||
ClientSchema,
|
||||
UpdateClientSchema,
|
||||
CreateClientSchema
|
||||
>({
|
||||
key: "getClients",
|
||||
queryKey,
|
||||
mutations: {
|
||||
create: createClientMutation(),
|
||||
update: updateClientMutation(),
|
||||
delete: deleteClientMutation(),
|
||||
},
|
||||
getUpdateEntity: (old, update) => ({
|
||||
...old,
|
||||
details: update.details ?? old.details,
|
||||
name: update.name ?? old.name,
|
||||
companyName: update.companyName ?? old.companyName,
|
||||
comment: update.comment ?? old.comment,
|
||||
}),
|
||||
getDeleteConfirmTitle: () => "Удаление клиента",
|
||||
});
|
||||
};
|
||||
50
src/app/clients/hooks/cruds/useMarketplacesCrud.tsx
Normal file
50
src/app/clients/hooks/cruds/useMarketplacesCrud.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
|
||||
import {
|
||||
CreateMarketplaceSchema,
|
||||
MarketplaceSchema,
|
||||
UpdateMarketplaceSchema,
|
||||
} from "@/lib/client";
|
||||
import {
|
||||
createMarketplaceMutation,
|
||||
deleteMarketplaceMutation,
|
||||
updateMarketplaceMutation,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
type UseMarketplacesProps = {
|
||||
queryKey: any[];
|
||||
};
|
||||
|
||||
export type MarketplacesCrud = {
|
||||
onCreate: (mp: CreateMarketplaceSchema) => void;
|
||||
onUpdate: (
|
||||
mpId: number,
|
||||
mp: UpdateMarketplaceSchema,
|
||||
onSuccess?: () => void
|
||||
) => void;
|
||||
onDelete: (mp: MarketplaceSchema) => void;
|
||||
};
|
||||
|
||||
export const useMarketplacesCrud = ({
|
||||
queryKey,
|
||||
}: UseMarketplacesProps): MarketplacesCrud => {
|
||||
return useCrudOperations<
|
||||
MarketplaceSchema,
|
||||
UpdateMarketplaceSchema,
|
||||
CreateMarketplaceSchema
|
||||
>({
|
||||
key: "getMarketplaces",
|
||||
queryKey,
|
||||
mutations: {
|
||||
create: createMarketplaceMutation(),
|
||||
update: updateMarketplaceMutation(),
|
||||
delete: deleteMarketplaceMutation(),
|
||||
},
|
||||
getUpdateEntity: (old, update) => ({
|
||||
...old,
|
||||
baseMarketplace: update.baseMarketplace ?? old.baseMarketplace,
|
||||
name: update.name ?? old.name,
|
||||
authData: update.authData ?? old.authData,
|
||||
}),
|
||||
getDeleteConfirmTitle: () => "Удаление маркетплейса",
|
||||
});
|
||||
};
|
||||
15
src/app/clients/hooks/lists/useBaseMarketplacesList.tsx
Normal file
15
src/app/clients/hooks/lists/useBaseMarketplacesList.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getBaseMarketplacesOptions,
|
||||
getBaseMarketplacesQueryKey,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
const useBaseMarketplacesList = () => {
|
||||
const { data, refetch } = useQuery(getBaseMarketplacesOptions());
|
||||
|
||||
const queryKey = getBaseMarketplacesQueryKey();
|
||||
|
||||
return { baseMarketplaces: data?.items ?? [], refetch, queryKey };
|
||||
};
|
||||
|
||||
export default useBaseMarketplacesList;
|
||||
25
src/app/clients/hooks/lists/useClientsList.tsx
Normal file
25
src/app/clients/hooks/lists/useClientsList.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getClientsOptions,
|
||||
getClientsQueryKey,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
type Props = {
|
||||
includeDeleted?: boolean;
|
||||
};
|
||||
|
||||
const useClientsList = (
|
||||
{ includeDeleted = false }: Props = { includeDeleted: false }
|
||||
) => {
|
||||
const { data, refetch } = useQuery(
|
||||
getClientsOptions({ query: { includeDeleted } })
|
||||
);
|
||||
const clients = useMemo(() => data?.items ?? [], [data]);
|
||||
|
||||
const queryKey = getClientsQueryKey();
|
||||
|
||||
return { clients, refetch, queryKey };
|
||||
};
|
||||
|
||||
export default useClientsList;
|
||||
21
src/app/clients/hooks/lists/useMarketplacesList.tsx
Normal file
21
src/app/clients/hooks/lists/useMarketplacesList.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
getMarketplacesOptions,
|
||||
getMarketplacesQueryKey,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
|
||||
type Props = {
|
||||
clientId: number;
|
||||
};
|
||||
|
||||
const useMarketplacesList = ({ clientId }: Props) => {
|
||||
const { data, refetch } = useQuery(
|
||||
getMarketplacesOptions({ path: { clientId } })
|
||||
);
|
||||
|
||||
const queryKey = getMarketplacesQueryKey({ path: { clientId } });
|
||||
|
||||
return { marketplaces: data?.items ?? [], refetch, queryKey };
|
||||
};
|
||||
|
||||
export default useMarketplacesList;
|
||||
37
src/app/clients/hooks/utils/useClientsActions.tsx
Normal file
37
src/app/clients/hooks/utils/useClientsActions.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useClientsContext } from "@/app/clients/contexts/ClientsContext";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
const useClientsActions = () => {
|
||||
const { clientsCrud } = useClientsContext();
|
||||
|
||||
const onCreateClick = () => {
|
||||
modals.openContextModal({
|
||||
modal: "clientEditorModal",
|
||||
title: "Создание клиента",
|
||||
innerProps: {
|
||||
onCreate: clientsCrud.onCreate,
|
||||
isEditing: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdateClick = (client: ClientSchema) => {
|
||||
modals.openContextModal({
|
||||
modal: "clientEditorModal",
|
||||
title: "Редактирование клиента",
|
||||
innerProps: {
|
||||
onChange: updates => clientsCrud.onUpdate(client.id, updates),
|
||||
entity: client,
|
||||
isEditing: true,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onCreateClick,
|
||||
onUpdateClick,
|
||||
};
|
||||
};
|
||||
|
||||
export default useClientsActions;
|
||||
46
src/app/clients/hooks/utils/useClientsFilter.tsx
Normal file
46
src/app/clients/hooks/utils/useClientsFilter.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
clients: ClientSchema[];
|
||||
};
|
||||
|
||||
const useClientsFilter = ({ clients }: Props) => {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
const [debouncedSearch] = useDebouncedValue(search, 400);
|
||||
const [filteredClients, setFilteredClients] = useState<ClientSchema[]>([]);
|
||||
|
||||
const filterClients = () => {
|
||||
if (debouncedSearch.length === 0) {
|
||||
setFilteredClients(clients);
|
||||
return;
|
||||
}
|
||||
|
||||
const loweredSearch = debouncedSearch.toLowerCase();
|
||||
const filtered = clients.filter(
|
||||
client =>
|
||||
client.name.toLowerCase().includes(loweredSearch) ||
|
||||
client.details?.inn?.includes(loweredSearch) ||
|
||||
client.details?.email?.toLowerCase().includes(loweredSearch) ||
|
||||
client.details?.telegram
|
||||
?.toLowerCase()
|
||||
.includes(loweredSearch) ||
|
||||
client.details?.phoneNumber?.includes(loweredSearch) ||
|
||||
client.companyName.toLowerCase().includes(loweredSearch)
|
||||
);
|
||||
setFilteredClients(filtered);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
filterClients();
|
||||
}, [debouncedSearch, clients]);
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
filteredClients,
|
||||
};
|
||||
};
|
||||
|
||||
export default useClientsFilter;
|
||||
105
src/app/clients/modals/ClientFormModal/ClientFormModal.tsx
Normal file
105
src/app/clients/modals/ClientFormModal/ClientFormModal.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { Fieldset, Stack, Textarea, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { ContextModalProps } from "@mantine/modals";
|
||||
import isValidInn from "@/app/clients/utils/isValidInn";
|
||||
import {
|
||||
ClientSchema,
|
||||
CreateClientSchema,
|
||||
UpdateClientSchema,
|
||||
} from "@/lib/client";
|
||||
import BaseFormModal, {
|
||||
CreateEditFormProps,
|
||||
} from "@/modals/base/BaseFormModal/BaseFormModal";
|
||||
|
||||
type Props = CreateEditFormProps<
|
||||
ClientSchema,
|
||||
CreateClientSchema,
|
||||
UpdateClientSchema
|
||||
>;
|
||||
|
||||
const ClientEditorModal = ({
|
||||
context,
|
||||
id,
|
||||
innerProps,
|
||||
}: ContextModalProps<Props>) => {
|
||||
const initialValues = innerProps.isEditing
|
||||
? innerProps.entity
|
||||
: ({
|
||||
name: "",
|
||||
companyName: "",
|
||||
details: {
|
||||
telegram: "",
|
||||
phoneNumber: "",
|
||||
email: "",
|
||||
inn: "",
|
||||
},
|
||||
comment: "",
|
||||
} as CreateClientSchema);
|
||||
|
||||
const form = useForm({
|
||||
initialValues,
|
||||
validate: {
|
||||
name: name =>
|
||||
(!name || name.trim() === "") &&
|
||||
"Необходимо ввести название клиента",
|
||||
details: {
|
||||
inn: inn => inn.length > 0 && !isValidInn(inn) && "Некорректный ИНН",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseFormModal
|
||||
{...innerProps}
|
||||
closeOnSubmit
|
||||
form={form}
|
||||
onClose={() => context.closeContextModal(id)}>
|
||||
<Fieldset legend={"Основная информация"}>
|
||||
<TextInput
|
||||
required
|
||||
label={"Название клиента"}
|
||||
placeholder={"Введите название клиента"}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
</Fieldset>
|
||||
<Fieldset legend={"Дополнительная информация"}>
|
||||
<Stack gap={"xs"}>
|
||||
<TextInput
|
||||
label={"Телеграм"}
|
||||
placeholder={"Введите телеграм"}
|
||||
{...form.getInputProps("details.telegram")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"Номер телефона"}
|
||||
placeholder={"Введите номер телефона"}
|
||||
{...form.getInputProps("details.phoneNumber")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"Почта"}
|
||||
placeholder={"Введите почту"}
|
||||
{...form.getInputProps("details.email")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"ИНН"}
|
||||
placeholder={"Введите ИНН"}
|
||||
{...form.getInputProps("details.inn")}
|
||||
/>
|
||||
<TextInput
|
||||
label={"Название компании"}
|
||||
placeholder={"Введите название компании"}
|
||||
{...form.getInputProps("companyName")}
|
||||
/>
|
||||
<Textarea
|
||||
label={"Комментарий"}
|
||||
placeholder={"Введите комментарий"}
|
||||
{...form.getInputProps("comment")}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</BaseFormModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientEditorModal;
|
||||
22
src/app/clients/page.tsx
Normal file
22
src/app/clients/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Suspense } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { ClientsContextProvider } from "@/app/clients/contexts/ClientsContext";
|
||||
import PageContainer from "@/components/layout/PageContainer/PageContainer";
|
||||
import PageBody from "@/app/clients/components/shared/PageBody/PageBody";
|
||||
|
||||
export default async function ClientsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="50vh">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
}>
|
||||
<PageContainer>
|
||||
<ClientsContextProvider>
|
||||
<PageBody />
|
||||
</ClientsContextProvider>
|
||||
</PageContainer>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
5
src/app/clients/utils/isValidInn.ts
Normal file
5
src/app/clients/utils/isValidInn.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const isValidInn = (inn: string | null | undefined) => {
|
||||
return inn && inn.match(/^(\d{12}|\d{10})$/);
|
||||
};
|
||||
|
||||
export default isValidInn;
|
||||
@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { IconFilter } from "@tabler/icons-react";
|
||||
import { Button, Divider, Flex, Group, Indicator } from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import style from "@/app/deals/components/desktop/ViewSelectButton/ViewSelectButton.module.css";
|
||||
import ViewSelector from "@/app/deals/components/desktop/ViewSelector/ViewSelector";
|
||||
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import { DealsFiltersForm } from "@/app/deals/hooks/useDealsFilters";
|
||||
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
export enum View {
|
||||
BOARD = "board",
|
||||
TABLE = "table",
|
||||
SCHEDULE = "schedule"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
view: View;
|
||||
setView: (view: View) => void;
|
||||
};
|
||||
|
||||
const TopToolPanel: FC<Props> = ({ view, setView }) => {
|
||||
const { dealsFiltersForm, isChangedFilters } = useDealsContext();
|
||||
const { selectedProject } = useProjectsContext();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (isMobile) return;
|
||||
|
||||
const viewFiltersModalMap = {
|
||||
table: "dealsTableFiltersModal",
|
||||
board: "dealsBoardFiltersModal",
|
||||
schedule: "dealsScheduleFiltersModal",
|
||||
};
|
||||
|
||||
const onFiltersClick = () => {
|
||||
modals.openContextModal({
|
||||
modal: viewFiltersModalMap[view],
|
||||
title: "Фильтры",
|
||||
withCloseButton: true,
|
||||
innerProps: {
|
||||
value: dealsFiltersForm.values,
|
||||
onChange: (values: DealsFiltersForm) =>
|
||||
dealsFiltersForm.setValues(values),
|
||||
project: selectedProject,
|
||||
boardAndStatusEnabled: view === View.TABLE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Group>
|
||||
<ViewSelector
|
||||
value={view}
|
||||
onChange={setView}
|
||||
/>
|
||||
<Divider orientation={"vertical"} />
|
||||
<Flex
|
||||
wrap={"nowrap"}
|
||||
align={"center"}
|
||||
gap={"sm"}>
|
||||
<Indicator
|
||||
zIndex={100}
|
||||
disabled={!isChangedFilters}
|
||||
offset={5}
|
||||
size={8}>
|
||||
<SmallPageBlock
|
||||
style={{ borderRadius: "var(--mantine-radius-xl)" }}>
|
||||
<Button
|
||||
unstyled
|
||||
onClick={onFiltersClick}
|
||||
radius="xl"
|
||||
className={style.container}>
|
||||
<IconFilter />
|
||||
</Button>
|
||||
</SmallPageBlock>
|
||||
</Indicator>
|
||||
</Flex>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default TopToolPanel;
|
||||
@ -0,0 +1,9 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
border-radius: var(--mantine-radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: var(--mantine-spacing-xs);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
"use client";
|
||||
|
||||
import { FC, PropsWithChildren } from "react";
|
||||
import { Button } from "@mantine/core";
|
||||
import SmallPageBlock from "@/components/layout/SmallPageBlock/SmallPageBlock";
|
||||
import style from "./ViewSelectButton.module.css";
|
||||
|
||||
type Props = {
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
const ViewSelectButton: FC<PropsWithChildren<Props>> = ({
|
||||
selected,
|
||||
onSelect,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<SmallPageBlock
|
||||
active={selected}
|
||||
style={{ borderRadius: "var(--mantine-radius-xl)" }}>
|
||||
<Button
|
||||
unstyled
|
||||
onClick={onSelect}
|
||||
radius="xl"
|
||||
className={style.container}>
|
||||
{children}
|
||||
</Button>
|
||||
</SmallPageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewSelectButton;
|
||||
@ -0,0 +1,46 @@
|
||||
import { FC } from "react";
|
||||
import {
|
||||
IconCalendarWeekFilled,
|
||||
IconLayoutDashboard,
|
||||
IconMenu2,
|
||||
} from "@tabler/icons-react";
|
||||
import { Group } from "@mantine/core";
|
||||
import { View } from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel";
|
||||
import ViewSelectButton from "@/app/deals/components/desktop/ViewSelectButton/ViewSelectButton";
|
||||
|
||||
type Props = {
|
||||
value: View;
|
||||
onChange: (view: View) => void;
|
||||
};
|
||||
|
||||
const ViewSelector: FC<Props> = ({ value, onChange }) => {
|
||||
const views = [
|
||||
{
|
||||
value: View.BOARD,
|
||||
icon: <IconLayoutDashboard />,
|
||||
},
|
||||
{
|
||||
value: View.TABLE,
|
||||
icon: <IconMenu2 />,
|
||||
},
|
||||
{
|
||||
value: View.SCHEDULE,
|
||||
icon: <IconCalendarWeekFilled />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Group>
|
||||
{views.map(view => (
|
||||
<ViewSelectButton
|
||||
key={view.value}
|
||||
selected={value === view.value}
|
||||
onSelect={() => onChange(view.value)}>
|
||||
{view.icon}
|
||||
</ViewSelectButton>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewSelector;
|
||||
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { IconChevronLeft, IconSettings } from "@tabler/icons-react";
|
||||
import { Box, Group, Stack, Text } from "@mantine/core";
|
||||
import Boards from "@/app/deals/components/shared/Boards/Boards";
|
||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import { useDrawersContext } from "@/drawers/DrawersContext";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const MainBlockHeader = () => {
|
||||
const { setSelectedProjectId, refetchProjects, selectedProject } =
|
||||
useProjectsContext();
|
||||
const { refetchBoards } = useBoardsContext();
|
||||
const { openDrawer } = useDrawersContext();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const selectProjectId = async (projectId: number | null) => {
|
||||
await refetchProjects();
|
||||
setSelectedProjectId(projectId);
|
||||
};
|
||||
|
||||
const openProjectsEditorDrawer = () => {
|
||||
openDrawer({
|
||||
key: "projectsMobileEditorDrawer",
|
||||
props: {
|
||||
onSelect: project => selectProjectId(project?.id ?? null),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openBoardsEditorDrawer = () => {
|
||||
if (!selectedProject) return;
|
||||
openDrawer({
|
||||
key: "boardsMobileEditorDrawer",
|
||||
props: {
|
||||
project: selectedProject,
|
||||
},
|
||||
onClose: refetchBoards,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={0}
|
||||
w={"100%"}>
|
||||
{isMobile && (
|
||||
<Group justify={"space-between"}>
|
||||
<Box
|
||||
p={"md"}
|
||||
onClick={openProjectsEditorDrawer}>
|
||||
<IconChevronLeft />
|
||||
</Box>
|
||||
<Text>{selectedProject?.name}</Text>
|
||||
<Box
|
||||
p={"md"}
|
||||
onClick={openBoardsEditorDrawer}>
|
||||
<IconSettings />
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
<Group
|
||||
wrap={"nowrap"}
|
||||
gap={0}
|
||||
align={"end"}>
|
||||
<Boards />
|
||||
<Box
|
||||
flex={1}
|
||||
style={{ borderBottom: "2px solid gray" }}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default MainBlockHeader;
|
||||
14
src/app/deals/components/shared/Board/Board.module.css
Normal file
14
src/app/deals/components/shared/Board/Board.module.css
Normal file
@ -0,0 +1,14 @@
|
||||
|
||||
.board {
|
||||
min-width: 50px;
|
||||
flex-wrap: nowrap;
|
||||
gap: 3px;
|
||||
border-top-left-radius: 15px;
|
||||
border-top-right-radius: 15px;
|
||||
border-bottom: 2px solid gray;
|
||||
}
|
||||
|
||||
.board-selected {
|
||||
border: 2px solid gray;
|
||||
border-bottom: 0;
|
||||
}
|
||||
67
src/app/deals/components/shared/Board/Board.tsx
Normal file
67
src/app/deals/components/shared/Board/Board.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import React, { FC, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
|
||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { BoardSchema } from "@/lib/client";
|
||||
import styles from "./Board.module.css";
|
||||
|
||||
type Props = {
|
||||
board: BoardSchema;
|
||||
};
|
||||
|
||||
const Board: FC<Props> = ({ board }) => {
|
||||
const { selectedBoard, boardsCrud } = useBoardsContext();
|
||||
const isMobile = useIsMobile();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
<Group
|
||||
px={"md"}
|
||||
py={"xs"}
|
||||
className={classNames(
|
||||
styles.board,
|
||||
selectedBoard?.id === board.id && styles["board-selected"]
|
||||
)}
|
||||
justify={"space-between"}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}>
|
||||
<InPlaceInput
|
||||
value={board.name}
|
||||
onChange={value =>
|
||||
boardsCrud.onUpdate(board.id, { name: value })
|
||||
}
|
||||
inputStyles={{
|
||||
input: {
|
||||
height: 24,
|
||||
minHeight: 24,
|
||||
},
|
||||
}}
|
||||
getChildren={startEditing => (
|
||||
<Group wrap={"nowrap"}>
|
||||
<Box>
|
||||
<Text style={{ textWrap: "nowrap" }}>
|
||||
{board.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{!isMobile && (
|
||||
<BoardMenu
|
||||
isHovered={
|
||||
selectedBoard?.id === board.id || isHovered
|
||||
}
|
||||
onDeleteBoard={boardsCrud.onDelete}
|
||||
board={board}
|
||||
startEditing={startEditing}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
modalTitle={"Редактирование доски"}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default Board;
|
||||
51
src/app/deals/components/shared/BoardMenu/BoardMenu.tsx
Normal file
51
src/app/deals/components/shared/BoardMenu/BoardMenu.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { FC } from "react";
|
||||
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import { Box, Menu } from "@mantine/core";
|
||||
import DropdownMenuItem from "@/components/ui/DropdownMenuItem/DropdownMenuItem";
|
||||
import ThemeIcon from "@/components/ui/ThemeIcon/ThemeIcon";
|
||||
import { BoardSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
board: BoardSchema;
|
||||
startEditing: () => void;
|
||||
onDeleteBoard: (board: BoardSchema) => void;
|
||||
isHovered?: boolean;
|
||||
};
|
||||
|
||||
const BoardMenu: FC<Props> = ({
|
||||
board,
|
||||
startEditing,
|
||||
onDeleteBoard,
|
||||
isHovered = true,
|
||||
}) => {
|
||||
return (
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<Box
|
||||
style={{
|
||||
opacity: isHovered ? 1 : 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<ThemeIcon size={"sm"}>
|
||||
<IconDotsVertical />
|
||||
</ThemeIcon>
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<DropdownMenuItem
|
||||
onClick={startEditing}
|
||||
icon={<IconEdit />}
|
||||
label={"Переименовать"}
|
||||
/>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteBoard(board)}
|
||||
icon={<IconTrash />}
|
||||
label={"Удалить"}
|
||||
/>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardMenu;
|
||||
8
src/app/deals/components/shared/Boards/Boards.module.css
Normal file
8
src/app/deals/components/shared/Boards/Boards.module.css
Normal file
@ -0,0 +1,8 @@
|
||||
.container {
|
||||
@media (min-width: 48em) {
|
||||
max-width: calc(100vw - 210px - var(--mantine-spacing-md));
|
||||
}
|
||||
@media (max-width: 48em) {
|
||||
max-width: 100vw;
|
||||
}
|
||||
}
|
||||
54
src/app/deals/components/shared/Boards/Boards.tsx
Normal file
54
src/app/deals/components/shared/Boards/Boards.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Flex, ScrollArea } from "@mantine/core";
|
||||
import Board from "@/app/deals/components/shared/Board/Board";
|
||||
import CreateBoardButton from "@/app/deals/components/shared/CreateBoardButton/CreateBoardButton";
|
||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||
import SortableDnd from "@/components/dnd/SortableDnd";
|
||||
import useHorizontalWheel from "@/hooks/utils/useHorizontalWheel";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { BoardSchema } from "@/lib/client";
|
||||
import styles from "./Boards.module.css";
|
||||
|
||||
const Boards = () => {
|
||||
const { boards, setSelectedBoardId, boardsCrud } = useBoardsContext();
|
||||
const isMobile = useIsMobile();
|
||||
const { ref, onWheel } = useHorizontalWheel<HTMLDivElement>();
|
||||
|
||||
const renderBoard = (board: BoardSchema) => <Board board={board} />;
|
||||
|
||||
const onDragEnd = (itemId: number, newLexorank: string) => {
|
||||
boardsCrud.onUpdate(itemId, { lexorank: newLexorank });
|
||||
};
|
||||
|
||||
const selectBoard = (board: BoardSchema) => {
|
||||
setSelectedBoardId(board.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align={"end"}
|
||||
className={styles.container}>
|
||||
<ScrollArea
|
||||
viewportRef={ref}
|
||||
onWheel={onWheel}
|
||||
offsetScrollbars={"x"}
|
||||
scrollbars={"x"}
|
||||
scrollbarSize={0}>
|
||||
<SortableDnd
|
||||
initialItems={boards}
|
||||
renderItem={renderBoard}
|
||||
onDragEnd={onDragEnd}
|
||||
onItemClick={selectBoard}
|
||||
containerStyle={{ flexWrap: "nowrap" }}
|
||||
dragHandleStyle={{ cursor: "pointer" }}
|
||||
disabled={isMobile}
|
||||
/>
|
||||
</ScrollArea>
|
||||
<CreateBoardButton />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Boards;
|
||||
@ -0,0 +1,10 @@
|
||||
|
||||
.create-button {
|
||||
padding: 10px 10px 9px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 45px;
|
||||
width: 100%;
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Box, Flex, rem } from "@mantine/core";
|
||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||
import styles from "./CreateBoardButton.module.css";
|
||||
|
||||
const CreateBoardButton = () => {
|
||||
const { boardsCrud } = useBoardsContext();
|
||||
|
||||
return (
|
||||
<Flex style={{ borderBottom: "2px solid gray" }}>
|
||||
<InPlaceInput
|
||||
placeholder={"Название доски"}
|
||||
onChange={name => boardsCrud.onCreate({ name })}
|
||||
getChildren={startEditing => (
|
||||
<Box
|
||||
onClick={startEditing}
|
||||
className={styles["create-button"]}>
|
||||
<IconPlus />
|
||||
</Box>
|
||||
)}
|
||||
inputStyles={{
|
||||
wrapper: {
|
||||
marginRight: "var(--mantine-spacing-xs)",
|
||||
paddingBlock: rem(3),
|
||||
paddingLeft: "var(--mantine-spacing-xs)",
|
||||
backgroundColor:
|
||||
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))",
|
||||
borderTopLeftRadius: "var(--mantine-spacing-xs)",
|
||||
borderTopRightRadius: "var(--mantine-spacing-xs)",
|
||||
},
|
||||
}}
|
||||
modalTitle={"Создание доски"}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateBoardButton;
|
||||
@ -0,0 +1,11 @@
|
||||
.create-button {
|
||||
cursor: pointer;
|
||||
min-height: max-content;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--color-light-white-blue);
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { useState } from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Card, Center, Group, Text, Transition } from "@mantine/core";
|
||||
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||
import CreateCardForm, { CreateDealForm } from "./components/CreateCardForm";
|
||||
import styles from "./CreateDealButton.module.css";
|
||||
|
||||
const CreateCardButton = () => {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isTransitionEnded, setIsTransitionEnded] = useState(true);
|
||||
const { dealsCrud } = useDealsContext();
|
||||
|
||||
const onSubmit = (values: CreateDealForm) => {
|
||||
dealsCrud.onCreate(values);
|
||||
setIsCreating(prevState => !prevState);
|
||||
setIsTransitionEnded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={styles["create-button"]}
|
||||
onClick={() => {
|
||||
if (isCreating) return;
|
||||
setIsCreating(prevState => !prevState);
|
||||
setIsTransitionEnded(false);
|
||||
}}>
|
||||
{!isCreating && isTransitionEnded && (
|
||||
<Center>
|
||||
<Group gap={"xs"}>
|
||||
<IconPlus />
|
||||
<Text>Добавить</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
)}
|
||||
<Transition
|
||||
mounted={isCreating}
|
||||
transition={"scale-y"}
|
||||
onExited={() => setIsTransitionEnded(true)}>
|
||||
{styles => (
|
||||
<div style={styles}>
|
||||
<CreateCardForm
|
||||
onCancel={() => setIsCreating(false)}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Transition>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
export default CreateCardButton;
|
||||
@ -0,0 +1,77 @@
|
||||
import { FC } from "react";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { Button, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import { ClientSchema } from "@/lib/client";
|
||||
import ClientSelect from "@/modules/dealModularEditorTabs/Clients/components/ClientSelect";
|
||||
import { ModuleNames } from "@/modules/modules";
|
||||
|
||||
export type CreateDealForm = {
|
||||
name: string;
|
||||
client?: ClientSchema;
|
||||
clientId?: number;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onSubmit: (values: CreateDealForm) => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
const CreateCardForm: FC<Props> = ({ onSubmit, onCancel }) => {
|
||||
const { modulesSet } = useProjectsContext();
|
||||
|
||||
const form = useForm<CreateDealForm>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
},
|
||||
validate: {
|
||||
name: value => !value && "Введите название",
|
||||
client: client =>
|
||||
modulesSet.has(ModuleNames.CLIENTS) &&
|
||||
!client &&
|
||||
"Выберите клиента",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={form.onSubmit(values => {
|
||||
onSubmit(values);
|
||||
form.reset();
|
||||
})}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
placeholder={"Название"}
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
{modulesSet.has(ModuleNames.CLIENTS) && (
|
||||
<ClientSelect
|
||||
placeholder={"Клиент"}
|
||||
{...form.getInputProps("client")}
|
||||
onChange={client => {
|
||||
form.setFieldValue("client", client);
|
||||
form.setFieldValue("clientId", client?.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Group wrap={"nowrap"}>
|
||||
<Button
|
||||
variant={"default"}
|
||||
w={"100%"}
|
||||
onClick={onCancel}>
|
||||
<IconX />
|
||||
</Button>
|
||||
<Button
|
||||
variant={"default"}
|
||||
w={"100%"}
|
||||
type={"submit"}>
|
||||
<IconCheck />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCardForm;
|
||||
@ -0,0 +1,23 @@
|
||||
|
||||
.container {
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
|
||||
width: fit-content;
|
||||
@media (max-width: 48em) {
|
||||
width: 80vw;
|
||||
height: 73.5vh;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-container {
|
||||
border-radius: var(--mantine-spacing-md);
|
||||
flex-wrap: nowrap;
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--color-light-aqua);
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { IconPlus } from "@tabler/icons-react";
|
||||
import { Box, Center, Group, Text } from "@mantine/core";
|
||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import styles from "./CreateStatusButton.module.css";
|
||||
|
||||
const CreateStatusButton = () => {
|
||||
const { statusesCrud } = useStatusesContext();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Box className={styles.container}>
|
||||
<Box
|
||||
p={isMobile ? "sm" : 0}
|
||||
className={styles["inner-container"]}>
|
||||
<InPlaceInput
|
||||
placeholder={"Название колонки"}
|
||||
onChange={name => statusesCrud.onCreate({ name })}
|
||||
getChildren={startEditing => (
|
||||
<Center
|
||||
p={"sm"}
|
||||
onClick={() => startEditing()}>
|
||||
<Group
|
||||
gap={"xs"}
|
||||
wrap={"nowrap"}
|
||||
align={"center"}>
|
||||
<IconPlus />
|
||||
{isMobile && <Text>Добавить</Text>}
|
||||
</Group>
|
||||
</Center>
|
||||
)}
|
||||
modalTitle={"Создание колонки"}
|
||||
inputStyles={{
|
||||
wrapper: {
|
||||
width: 250,
|
||||
paddingInline: "var(--mantine-spacing-md)",
|
||||
paddingBlock: "var(--mantine-spacing-xs)",
|
||||
},
|
||||
input: {
|
||||
width: 250,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateStatusButton;
|
||||
46
src/app/deals/components/shared/DealCard/DealCard.module.css
Normal file
46
src/app/deals/components/shared/DealCard/DealCard.module.css
Normal file
@ -0,0 +1,46 @@
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
@mixin light {
|
||||
background-color: var(--color-light-white-blue);
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-7);
|
||||
}
|
||||
}
|
||||
|
||||
.deal-data {
|
||||
padding: var(--mantine-spacing-xs);
|
||||
gap: var(--mantine-spacing-xs);
|
||||
}
|
||||
|
||||
.deal-id {
|
||||
border-top-right-radius: var(--mantine-radius-md);
|
||||
border-bottom-left-radius: var(--mantine-radius-md);
|
||||
padding-inline: var(--mantine-spacing-xs);
|
||||
@mixin light {
|
||||
background-color: lightblue;
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--color-dark);
|
||||
}
|
||||
}
|
||||
|
||||
.first-tag {
|
||||
@mixin light {
|
||||
background-color: lightblue;
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: darkslateblue;
|
||||
}
|
||||
}
|
||||
|
||||
.second-tag {
|
||||
@mixin light {
|
||||
background-color: lightgray;
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
}
|
||||
73
src/app/deals/components/shared/DealCard/DealCard.tsx
Normal file
73
src/app/deals/components/shared/DealCard/DealCard.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { Box, Card, Group, Pill, Stack, Text } from "@mantine/core";
|
||||
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import { useDrawersContext } from "@/drawers/DrawersContext";
|
||||
import { DealSchema } from "@/lib/client";
|
||||
import { ModuleNames } from "@/modules/modules";
|
||||
import styles from "./DealCard.module.css";
|
||||
|
||||
type Props = {
|
||||
deal: DealSchema;
|
||||
};
|
||||
|
||||
const DealCard = ({ deal }: Props) => {
|
||||
const { selectedProject, modulesSet } = useProjectsContext();
|
||||
const { dealsCrud, refetchDeals } = useDealsContext();
|
||||
const { openDrawer } = useDrawersContext();
|
||||
|
||||
const onClick = () => {
|
||||
openDrawer({
|
||||
key: "dealEditorDrawer",
|
||||
props: {
|
||||
value: deal,
|
||||
onChange: deal => dealsCrud.onUpdate(deal.id, deal),
|
||||
onDelete: dealsCrud.onDelete,
|
||||
project: selectedProject,
|
||||
},
|
||||
onClose: refetchDeals,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
onClick={onClick}
|
||||
className={styles.container}>
|
||||
<Group
|
||||
justify={"space-between"}
|
||||
wrap={"nowrap"}
|
||||
pl={"xs"}
|
||||
gap={"xs"}
|
||||
align={"start"}>
|
||||
<Text
|
||||
c={"dodgerblue"}
|
||||
mt={"xs"}>
|
||||
{deal.name}
|
||||
</Text>
|
||||
<Box className={styles["deal-id"]}>
|
||||
<Text style={{ textWrap: "nowrap" }}>ID: {deal.id}</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<Stack className={styles["deal-data"]}>
|
||||
<Stack gap={0}>
|
||||
{modulesSet.has(ModuleNames.CLIENTS) && (
|
||||
<Text>{deal.client?.name}</Text>
|
||||
)}
|
||||
{modulesSet.has(ModuleNames.FULFILLMENT_BASE) && (
|
||||
<>
|
||||
<Text key={"price"}>{deal.totalPrice} руб.</Text>
|
||||
<Text key={"count"}>
|
||||
{deal.productsQuantity} тов.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
<Group gap={"xs"}>
|
||||
<Pill className={styles["first-tag"]}>Срочно</Pill>
|
||||
<Pill className={styles["second-tag"]}>Бесплатно</Pill>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default DealCard;
|
||||
@ -0,0 +1,89 @@
|
||||
import { FC, useEffect, useState } from "react";
|
||||
import { IconGripHorizontal } from "@tabler/icons-react";
|
||||
import { Flex, rem, TextInput, useMantineColorScheme } from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
|
||||
import FulfillmentGroupInfo from "@/app/deals/components/shared/DealGroupCard/components/FulfillmentGroupInfo";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import { notifications } from "@/lib/notifications";
|
||||
import { ModuleNames } from "@/modules/modules";
|
||||
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
|
||||
|
||||
type Props = {
|
||||
group: GroupWithDealsSchema;
|
||||
};
|
||||
|
||||
const DealGroupCard: FC<Props> = ({ group }) => {
|
||||
const theme = useMantineColorScheme();
|
||||
const [name, setName] = useState<string>(group.name ?? "");
|
||||
const [debouncedName] = useDebouncedValue(name, 200);
|
||||
const { modulesSet } = useProjectsContext();
|
||||
const isServicesAndProductsIncluded = modulesSet.has(
|
||||
ModuleNames.FULFILLMENT_BASE
|
||||
);
|
||||
|
||||
const updateName = () => {
|
||||
if (debouncedName === group.name) return;
|
||||
CardGroupService.updateCardGroup({
|
||||
requestBody: {
|
||||
data: {
|
||||
...group,
|
||||
name: debouncedName,
|
||||
},
|
||||
},
|
||||
}).then(response => {
|
||||
if (response.ok) return;
|
||||
setName(group.name || "");
|
||||
notifications.guess(response.ok, { message: response.message });
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
updateName();
|
||||
}, [debouncedName]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
style={{
|
||||
border: "dashed var(--item-border-size) var(--mantine-color-default-border)",
|
||||
borderRadius: "0.5rem",
|
||||
}}
|
||||
p={rem(5)}
|
||||
py={"xs"}
|
||||
bg={
|
||||
theme.colorScheme === "dark"
|
||||
? "var(--mantine-color-dark-5)"
|
||||
: "var(--mantine-color-gray-1)"
|
||||
}
|
||||
gap={"xs"}
|
||||
direction={"column"}>
|
||||
<Flex
|
||||
justify={"space-between"}
|
||||
align={"center"}
|
||||
gap={"xs"}
|
||||
px={"xs"}>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChange={event => setName(event.currentTarget.value)}
|
||||
variant={"unstyled"}
|
||||
/>
|
||||
<IconGripHorizontal />
|
||||
</Flex>
|
||||
<Flex
|
||||
direction={"column"}
|
||||
gap={"xs"}>
|
||||
{group.deals?.map(deal => (
|
||||
<DealCard
|
||||
key={deal.id}
|
||||
deal={deal}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
{isServicesAndProductsIncluded && (
|
||||
<FulfillmentGroupInfo group={group} />
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default DealGroupCard;
|
||||
@ -0,0 +1,51 @@
|
||||
import { Flex, Text, useMantineColorScheme } from "@mantine/core";
|
||||
import { FC, useMemo } from "react";
|
||||
import { DealGroupSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
group: DealGroupSchema;
|
||||
}
|
||||
|
||||
const FulfillmentGroupInfo: FC<Props> = ({ group }) => {
|
||||
const theme = useMantineColorScheme();
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() =>
|
||||
group.deals?.reduce((acc, deal) => acc + (deal.totalPrice ?? 0), 0),
|
||||
[group.deals]
|
||||
);
|
||||
const totalProducts = useMemo(
|
||||
() =>
|
||||
group.deals?.reduce(
|
||||
(acc, deal) => acc + (deal.productsQuantity ?? 0),
|
||||
0
|
||||
),
|
||||
[group.deals]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
p={"xs"}
|
||||
direction={"column"}
|
||||
bg={
|
||||
theme.colorScheme === "dark"
|
||||
? "var(--mantine-color-dark-6)"
|
||||
: "var(--mantine-color-gray-2)"
|
||||
}
|
||||
style={{ borderRadius: "0.5rem" }}>
|
||||
<Text
|
||||
c={"gray.6"}
|
||||
size={"xs"}>
|
||||
Сумма: {totalPrice?.toLocaleString("ru-RU")} руб.
|
||||
</Text>
|
||||
<Text
|
||||
c={"gray.6"}
|
||||
size={"xs"}>
|
||||
Всего товаров: {totalProducts?.toLocaleString("ru-RU")}{" "}
|
||||
шт.
|
||||
</Text>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
export default FulfillmentGroupInfo;
|
||||
94
src/app/deals/components/shared/DealsTable/DealsTable.tsx
Normal file
94
src/app/deals/components/shared/DealsTable/DealsTable.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { FC, useCallback } from "react";
|
||||
import { IconMoodSad } from "@tabler/icons-react";
|
||||
import { Group, Pagination, Stack, Text } from "@mantine/core";
|
||||
import useDealsTableColumns from "@/app/deals/components/shared/DealsTable/useDealsTableColumns";
|
||||
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import { useDrawersContext } from "@/drawers/DrawersContext";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { DealSchema } from "@/lib/client";
|
||||
|
||||
const DealsTable: FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { selectedProject, modulesSet } = useProjectsContext();
|
||||
const {
|
||||
deals,
|
||||
paginationInfo,
|
||||
page,
|
||||
setPage,
|
||||
sortingForm,
|
||||
dealsCrud,
|
||||
refetchDeals,
|
||||
} = useDealsContext();
|
||||
const { openDrawer } = useDrawersContext();
|
||||
|
||||
const onEditClick = useCallback(
|
||||
(deal: DealSchema) => {
|
||||
openDrawer({
|
||||
key: "dealEditorDrawer",
|
||||
props: {
|
||||
value: deal,
|
||||
onChange: deal => dealsCrud.onUpdate(deal.id, deal),
|
||||
onDelete: dealsCrud.onDelete,
|
||||
project: selectedProject,
|
||||
},
|
||||
onClose: refetchDeals,
|
||||
});
|
||||
},
|
||||
[openDrawer, dealsCrud]
|
||||
);
|
||||
|
||||
const columns = useDealsTableColumns({ onEditClick, modulesSet });
|
||||
|
||||
return (
|
||||
<Stack
|
||||
p={isMobile ? "xs" : ""}
|
||||
gap={"xs"}
|
||||
h={"100%"}>
|
||||
<BaseTable
|
||||
withTableBorder
|
||||
records={[...deals]}
|
||||
columns={columns}
|
||||
sortStatus={{
|
||||
columnAccessor: sortingForm.values.sortingField ?? "",
|
||||
direction: sortingForm.values.sortingDirection,
|
||||
}}
|
||||
onSortStatusChange={sorting => {
|
||||
sortingForm.setFieldValue(
|
||||
"sortingField",
|
||||
sorting.columnAccessor
|
||||
);
|
||||
sortingForm.setFieldValue(
|
||||
"sortingDirection",
|
||||
sorting.direction
|
||||
);
|
||||
}}
|
||||
emptyState={
|
||||
<Group
|
||||
align={"center"}
|
||||
gap={"xs"}>
|
||||
<Text>Нет сделок</Text>
|
||||
<IconMoodSad />
|
||||
</Group>
|
||||
}
|
||||
groups={undefined}
|
||||
style={{
|
||||
height: "100%",
|
||||
}}
|
||||
/>
|
||||
{paginationInfo && paginationInfo.totalPages > 1 && (
|
||||
<Group justify={"flex-end"}>
|
||||
<Pagination
|
||||
withEdges
|
||||
total={paginationInfo.totalPages}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DealsTable;
|
||||
@ -0,0 +1,70 @@
|
||||
import { useMemo } from "react";
|
||||
import { IconEdit } from "@tabler/icons-react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { DealSchema } from "@/lib/client";
|
||||
import { ModuleNames } from "@/modules/modules";
|
||||
import { utcDateTimeToLocalString } from "@/utils/datetime";
|
||||
|
||||
type Props = {
|
||||
onEditClick: (deal: DealSchema) => void;
|
||||
modulesSet: Set<ModuleNames>;
|
||||
};
|
||||
|
||||
const useDealsTableColumns = ({ onEditClick, modulesSet }: Props) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
accessor: "actions",
|
||||
title: isMobile ? "" : "Действия",
|
||||
sortable: false,
|
||||
textAlign: "center",
|
||||
width: "0%",
|
||||
render: deal => (
|
||||
<ActionIconWithTip
|
||||
tipLabel={"Редактировать"}
|
||||
onClick={() => onEditClick(deal)}
|
||||
variant={isMobile ? "subtle" : "default"}>
|
||||
<IconEdit />
|
||||
</ActionIconWithTip>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessor: "id",
|
||||
title: isMobile ? "№" : "Номер",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
accessor: "name",
|
||||
title: "Название",
|
||||
},
|
||||
{
|
||||
title: "Дата создания",
|
||||
accessor: "createdAt",
|
||||
render: deal => utcDateTimeToLocalString(deal.createdAt),
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
title: "Клиент",
|
||||
accessor: "client.name",
|
||||
hidden: !modulesSet.has(ModuleNames.CLIENTS),
|
||||
},
|
||||
{
|
||||
title: "Общая стоимость",
|
||||
accessor: "totalPrice",
|
||||
render: deal =>
|
||||
deal.totalPrice
|
||||
? `${deal.totalPrice.toLocaleString("ru")}₽`
|
||||
: "0₽",
|
||||
hidden: !modulesSet.has(ModuleNames.FULFILLMENT_BASE),
|
||||
},
|
||||
] as DataTableColumn<DealSchema>[],
|
||||
[onEditClick]
|
||||
);
|
||||
};
|
||||
|
||||
export default useDealsTableColumns;
|
||||
69
src/app/deals/components/shared/Funnel/Funnel.tsx
Normal file
69
src/app/deals/components/shared/Funnel/Funnel.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
|
||||
import StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader";
|
||||
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
|
||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||
import DndFunnel from "@/components/dnd-pragmatic/DndFunnel/DndFunnel";
|
||||
import { sortByLexorank } from "@/utils/lexorank/sort";
|
||||
|
||||
const Funnel: FC = () => {
|
||||
const { statuses, setStatuses, statusesCrud } = useStatusesContext();
|
||||
const { dealsWithoutGroup, groupsWithDeals, deals, setDeals, dealsCrud } =
|
||||
useDealsContext();
|
||||
|
||||
const updateStatus = (statusId: number, lexorank: string) => {
|
||||
setStatuses(
|
||||
statuses.map(status =>
|
||||
status.id === statusId ? { ...status, lexorank } : status
|
||||
)
|
||||
);
|
||||
|
||||
statusesCrud.onUpdate(statusId, { lexorank });
|
||||
};
|
||||
|
||||
const updateDeal = (dealId: number, lexorank: string, statusId: number) => {
|
||||
const status = statuses.find(s => s.id === statusId);
|
||||
if (!status) return;
|
||||
setDeals(
|
||||
deals.map(deal =>
|
||||
deal.id === dealId ? { ...deal, lexorank, status } : deal
|
||||
)
|
||||
);
|
||||
dealsCrud.onUpdate(dealId, { lexorank, statusId });
|
||||
};
|
||||
|
||||
return (
|
||||
<DndFunnel
|
||||
columns={statuses}
|
||||
updateColumn={updateStatus}
|
||||
items={dealsWithoutGroup}
|
||||
groups={groupsWithDeals}
|
||||
updateItem={updateDeal}
|
||||
getColumnItemsGroups={statusId =>
|
||||
sortByLexorank([
|
||||
...dealsWithoutGroup.filter(d => d.status.id === statusId),
|
||||
...groupsWithDeals.filter(
|
||||
g =>
|
||||
g.items.length > 0 &&
|
||||
g.items[0].status.id === statusId
|
||||
),
|
||||
])
|
||||
}
|
||||
renderColumnHeader={status => (
|
||||
<StatusColumnHeader status={status} />
|
||||
)}
|
||||
renderItem={deal => (
|
||||
<DealCard
|
||||
key={deal.id}
|
||||
deal={deal}
|
||||
/>
|
||||
)}
|
||||
renderGroup={group => <Box flex={1}>{group.name}</Box>}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Funnel;
|
||||
59
src/app/deals/components/shared/PageBody/PageBody.tsx
Normal file
59
src/app/deals/components/shared/PageBody/PageBody.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { Flex } from "@mantine/core";
|
||||
import TopToolPanel from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel";
|
||||
import {
|
||||
BoardView,
|
||||
ScheduleView,
|
||||
TableView,
|
||||
} from "@/app/deals/components/shared/views";
|
||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||
import { DealsContextProvider } from "@/app/deals/contexts/DealsContext";
|
||||
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
|
||||
import useView from "@/app/deals/hooks/useView";
|
||||
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||
|
||||
const PageBody = () => {
|
||||
const { selectedBoard } = useBoardsContext();
|
||||
const { selectedProject } = useProjectsContext();
|
||||
|
||||
const { view, setView } = useView();
|
||||
|
||||
const getViewContent = () => {
|
||||
switch (view) {
|
||||
case "board":
|
||||
return <BoardView />;
|
||||
case "table":
|
||||
return <TableView />;
|
||||
default:
|
||||
return <ScheduleView />;
|
||||
}
|
||||
};
|
||||
|
||||
const getContextProps = () => {
|
||||
if (view === "table") {
|
||||
return { withPagination: true, projectId: selectedProject?.id };
|
||||
}
|
||||
return { boardId: selectedBoard?.id };
|
||||
};
|
||||
|
||||
return (
|
||||
<DealsContextProvider {...getContextProps()}>
|
||||
<TopToolPanel
|
||||
view={view}
|
||||
setView={setView}
|
||||
/>
|
||||
<PageBlock
|
||||
fullScreenMobile
|
||||
style={{ flex: 1 }}>
|
||||
<Flex
|
||||
direction={"column"}
|
||||
h={"100%"}>
|
||||
{getViewContent()}
|
||||
</Flex>
|
||||
</PageBlock>
|
||||
</DealsContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBody;
|
||||
@ -0,0 +1,64 @@
|
||||
import React, { FC } from "react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
|
||||
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
|
||||
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
|
||||
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
|
||||
import { StatusSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
status: StatusSchema;
|
||||
};
|
||||
|
||||
const StatusColumnHeader: FC<Props> = ({ status }) => {
|
||||
const { statusesCrud, refetchStatuses } = useStatusesContext();
|
||||
const { selectedBoard } = useBoardsContext();
|
||||
|
||||
const handleSave = (value: string) => {
|
||||
const newValue = value.trim();
|
||||
if (newValue && newValue !== status.name) {
|
||||
statusesCrud.onUpdate(status.id, { name: newValue });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group
|
||||
justify={"space-between"}
|
||||
p={"sm"}
|
||||
wrap={"nowrap"}
|
||||
mb={"xs"}
|
||||
w={"100%"}
|
||||
style={{
|
||||
borderBottom: `solid ${status.color} 3px`,
|
||||
}}>
|
||||
<InPlaceInput
|
||||
value={status.name}
|
||||
onChange={value => handleSave(value)}
|
||||
inputStyles={{
|
||||
input: {
|
||||
height: 25,
|
||||
minHeight: 25,
|
||||
},
|
||||
}}
|
||||
getChildren={startEditing => (
|
||||
<>
|
||||
<Text>{status.name}</Text>
|
||||
<StatusMenu
|
||||
board={selectedBoard}
|
||||
status={status}
|
||||
handleEdit={startEditing}
|
||||
onStatusColorChange={color =>
|
||||
statusesCrud.onUpdate(status.id, { color })
|
||||
}
|
||||
refetchStatuses={refetchStatuses}
|
||||
onDeleteStatus={statusesCrud.onDelete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
modalTitle={"Редактирование статуса"}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusColumnHeader;
|
||||
@ -0,0 +1,23 @@
|
||||
|
||||
.container {
|
||||
height: calc(100vh - 215px);
|
||||
@media (max-width: 48em) {
|
||||
width: 80vw;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-container {
|
||||
border-radius: var(--mantine-spacing-lg);
|
||||
gap: 0;
|
||||
|
||||
@media (max-width: 48em) {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--color-light-aqua);
|
||||
}
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-dark-6);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import React, { ReactNode } from "react";
|
||||
import { Box, ScrollArea, Stack } from "@mantine/core";
|
||||
import CreateCardButton from "@/app/deals/components/shared/CreateDealButton/CreateDealButton";
|
||||
import { StatusSchema } from "@/lib/client";
|
||||
import styles from "./StatusColumnWrapper.module.css";
|
||||
|
||||
type Props = {
|
||||
status: StatusSchema;
|
||||
renderHeader: () => ReactNode;
|
||||
children: ReactNode;
|
||||
createFormEnabled?: boolean;
|
||||
};
|
||||
|
||||
const StatusColumnWrapper = ({
|
||||
renderHeader,
|
||||
children,
|
||||
createFormEnabled = false,
|
||||
}: Props) => {
|
||||
return (
|
||||
<Box className={styles.container}>
|
||||
<Stack
|
||||
px={"xs"}
|
||||
pb={"xs"}
|
||||
className={styles["inner-container"]}>
|
||||
{renderHeader()}
|
||||
<ScrollArea
|
||||
offsetScrollbars={"present"}
|
||||
scrollbarSize={10}
|
||||
type={"always"}
|
||||
scrollbars={"y"}>
|
||||
<Stack
|
||||
gap={"xs"}
|
||||
mah={"calc(100vh - 285px)"}>
|
||||
{createFormEnabled && <CreateCardButton />}
|
||||
{children}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusColumnWrapper;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user