Compare commits
256 Commits
5e6cfe8070
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 44b89245f0 | |||
| b0eab6cce7 | |||
| 1a1f584b81 | |||
| cc910c8495 | |||
| d3270a3532 | |||
| 38ae35795b | |||
| 3b1aca2df1 | |||
| 4d3264a92f | |||
| 33dd1e1c0f | |||
| 311210394f | |||
| 03be3903cb | |||
| 8020561da6 | |||
| a383f218f1 | |||
| fd5e878c29 | |||
| e8ffafa6c5 | |||
| 95cc7f6259 | |||
| 69bf7848e1 | |||
| 4370684c58 | |||
| 2948189291 | |||
| ee90ebe0f0 | |||
| 418e4b6b63 | |||
| 3355c73b8b | |||
| 07736ee374 | |||
| 42f1c75603 | |||
| 4342ab17ed | |||
| e9c9f6b65d | |||
| d38454f3af | |||
| 0c2fca2c23 | |||
| 9207bd915d | |||
| da769fa2c0 | |||
| 4cc6360bb4 | |||
| 8019fa566c | |||
| ea6a6df371 | |||
| 3575b9f34a | |||
| 71c9d08b8c | |||
| d4c0eac4a0 | |||
| e39df47520 | |||
| d9da3d1bc5 | |||
| 5b754865cf | |||
| 2bdbebc453 | |||
| 57a7ab0871 | |||
| 4d5723bc72 | |||
| 82f08b4f83 | |||
| 8cc11bca67 | |||
| e44691d118 | |||
| 3a1d8e23e3 | |||
| 9023b07c65 | |||
| 159d6948c7 | |||
| f90b335ee1 | |||
| 5e59d54011 | |||
| f117605ea3 | |||
| 30bc7bbee4 | |||
| daa9d12983 | |||
| 0fe41656f8 | |||
| 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,
|
||||
},
|
||||
}
|
||||
);
|
||||
25
fix-client.ts
Normal file
25
fix-client.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
const zodPath = "src/lib/client/zod.gen.ts";
|
||||
let content = fs.readFileSync(zodPath, "utf8");
|
||||
// Replace only for the upload schema
|
||||
const zodTarget = "upload_file: z.string";
|
||||
while (content.includes(zodTarget)) {
|
||||
content = content.replace(zodTarget, "upload_file: z.any");
|
||||
}
|
||||
fs.writeFileSync(zodPath, content);
|
||||
console.log("✅ Fixed zod schema for upload_file");
|
||||
|
||||
const utilsPath = "src/lib/client/client/utils.ts";
|
||||
content = fs.readFileSync(utilsPath, "utf8");
|
||||
|
||||
const utilsTarget = "@ts-expect-error";
|
||||
while (content.includes(utilsTarget)) {
|
||||
content = content.replace(utilsTarget, "@ts-ignore");
|
||||
}
|
||||
content = content.replace(
|
||||
"...(mergedHeaders[key] ?? []),",
|
||||
"...(mergedHeaders[key] ?? []) as any,"
|
||||
);
|
||||
fs.writeFileSync(utilsPath, content);
|
||||
console.log("✅ Fixed utils.ts");
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
100
package.json
100
package.json
@ -3,44 +3,98 @@
|
||||
"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 & sudo npx tsc fix-client.ts && mv -f ./fix-client.js ./fix-client.cjs && sudo node ./fix-client.cjs",
|
||||
"generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"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.18",
|
||||
"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-contextmenu": "^8.2.0",
|
||||
"mantine-datatable": "^8.2.0",
|
||||
"next": "15.4.7",
|
||||
"phone": "^3.1.67",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-dom": "^19.2.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-dom": "^19",
|
||||
"@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;
|
||||
71
src/app/actions/components/PageBody/PageBody.tsx
Normal file
71
src/app/actions/components/PageBody/PageBody.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { RefObject, useMemo, useRef } from "react";
|
||||
import { IconApps, IconList, IconTag } from "@tabler/icons-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";
|
||||
import BuiltInLinkData from "@/types/BuiltInLinkData";
|
||||
|
||||
const PageBody = () => {
|
||||
const { selectedProject, setSelectedProjectId, projects, modulesSet } =
|
||||
useProjectsContext();
|
||||
|
||||
const filteredMobileButtonsData = useMemo(
|
||||
() =>
|
||||
mobileButtonsData.filter(
|
||||
link => !link.moduleName || modulesSet.has(link.moduleName)
|
||||
),
|
||||
[modulesSet]
|
||||
);
|
||||
|
||||
const commonActionsData: RefObject<BuiltInLinkData[]> = useRef([
|
||||
{
|
||||
icon: IconList,
|
||||
label: "Атрибуты",
|
||||
href: "/attributes",
|
||||
},
|
||||
{
|
||||
icon: IconApps,
|
||||
label: "Модули",
|
||||
href: "/modules",
|
||||
},
|
||||
{
|
||||
icon: IconTag,
|
||||
label: "Теги",
|
||||
href: "/tags",
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<PageBlock fullScreenMobile>
|
||||
<Stack p={"xs"}>
|
||||
<ProjectSelect
|
||||
onChange={project =>
|
||||
setSelectedProjectId(project?.id ?? null)
|
||||
}
|
||||
value={selectedProject}
|
||||
data={projects}
|
||||
/>
|
||||
<SimpleGrid
|
||||
type={"container"}
|
||||
cols={2}>
|
||||
{[
|
||||
...commonActionsData.current,
|
||||
...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>
|
||||
);
|
||||
}
|
||||
27
src/app/attributes/components/AttrViewSegmentedControl.tsx
Normal file
27
src/app/attributes/components/AttrViewSegmentedControl.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { FC } from "react";
|
||||
import AttributePageView from "@/app/attributes/types/view";
|
||||
import BaseSegmentedControl, {
|
||||
BaseSegmentedControlProps,
|
||||
} from "@/components/ui/BaseSegmentedControl/BaseSegmentedControl";
|
||||
|
||||
type Props = Omit<BaseSegmentedControlProps<AttributePageView>, "data">;
|
||||
|
||||
const data = [
|
||||
{
|
||||
label: "Аттрибуты",
|
||||
value: AttributePageView.ATTRIBUTES,
|
||||
},
|
||||
{
|
||||
label: "Справочники",
|
||||
value: AttributePageView.SELECTS,
|
||||
},
|
||||
];
|
||||
|
||||
const AttrViewSegmentedControl: FC<Props> = props => (
|
||||
<BaseSegmentedControl
|
||||
data={data}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default AttrViewSegmentedControl;
|
||||
87
src/app/attributes/components/AttributesHeader.tsx
Normal file
87
src/app/attributes/components/AttributesHeader.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, FC, SetStateAction, useMemo } from "react";
|
||||
import { Divider, Flex, Group, TextInput } from "@mantine/core";
|
||||
import AttrViewSegmentedControl from "@/app/attributes/components/AttrViewSegmentedControl";
|
||||
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
|
||||
import AttributePageView from "@/app/attributes/types/view";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
type Props = {
|
||||
view: AttributePageView;
|
||||
setView: Dispatch<SetStateAction<AttributePageView>>;
|
||||
};
|
||||
|
||||
const AttributesHeader: FC<Props> = ({ view, setView }) => {
|
||||
const {
|
||||
attributesActions,
|
||||
selectsActions,
|
||||
attrSearch,
|
||||
setAttrSearch,
|
||||
selectSearch,
|
||||
setSelectSearch,
|
||||
} = useAttributesContext();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const attributeActions = useMemo(
|
||||
() => (
|
||||
<Group wrap={"nowrap"}>
|
||||
<InlineButton
|
||||
onClick={attributesActions.onCreate}
|
||||
w={isMobile ? "100%" : "auto"}>
|
||||
Создать атрибут
|
||||
</InlineButton>
|
||||
<TextInput
|
||||
value={attrSearch}
|
||||
onChange={e => setAttrSearch(e.currentTarget.value)}
|
||||
w={isMobile ? "100%" : "auto"}
|
||||
placeholder={"Поиск..."}
|
||||
/>
|
||||
</Group>
|
||||
),
|
||||
[isMobile, attrSearch]
|
||||
);
|
||||
|
||||
const selectActions = useMemo(
|
||||
() => (
|
||||
<Group wrap={"nowrap"}>
|
||||
<InlineButton
|
||||
onClick={selectsActions.onCreate}
|
||||
w={isMobile ? "100%" : "auto"}>
|
||||
Создать справочник
|
||||
</InlineButton>
|
||||
<TextInput
|
||||
value={selectSearch}
|
||||
onChange={e => setSelectSearch(e.currentTarget.value)}
|
||||
w={isMobile ? "100%" : "auto"}
|
||||
placeholder={"Поиск..."}
|
||||
/>
|
||||
</Group>
|
||||
),
|
||||
[isMobile, selectSearch]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
wrap={"nowrap"}
|
||||
gap={"xs"}
|
||||
align={isMobile ? "unset" : "center"}
|
||||
mt={isMobile ? "xs" : ""}
|
||||
mx={isMobile ? "xs" : ""}
|
||||
direction={isMobile ? "column-reverse" : "row"}
|
||||
justify={"space-between"}>
|
||||
{view === AttributePageView.ATTRIBUTES
|
||||
? attributeActions
|
||||
: selectActions}
|
||||
{isMobile && <Divider />}
|
||||
<AttrViewSegmentedControl
|
||||
value={view}
|
||||
onChange={setView}
|
||||
styles={{ root: { margin: 0, padding: 0 } }}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributesHeader;
|
||||
40
src/app/attributes/components/AttributesTable.tsx
Normal file
40
src/app/attributes/components/AttributesTable.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { FC } from "react";
|
||||
import { IconMoodSad } from "@tabler/icons-react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
|
||||
import useAttributesTableColumns from "@/app/attributes/hooks/useAttributesTableColumns";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const AttributesTable: FC = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { attributes } = useAttributesContext();
|
||||
const columns = useAttributesTableColumns();
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
withTableBorder
|
||||
columns={columns}
|
||||
records={attributes}
|
||||
verticalSpacing={"md"}
|
||||
emptyState={
|
||||
<Group mt={attributes.length === 0 ? "xl" : 0}>
|
||||
<Text>Нет атрибутов</Text>
|
||||
<IconMoodSad />
|
||||
</Group>
|
||||
}
|
||||
groups={undefined}
|
||||
styles={{
|
||||
table: {
|
||||
width: "100%",
|
||||
},
|
||||
header: { zIndex: 1 },
|
||||
}}
|
||||
mx={isMobile ? "xs" : 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttributesTable;
|
||||
42
src/app/attributes/components/PageBody.tsx
Normal file
42
src/app/attributes/components/PageBody.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import AttributesHeader from "@/app/attributes/components/AttributesHeader";
|
||||
import AttributesTable from "@/app/attributes/components/AttributesTable";
|
||||
import SelectsTable from "@/app/attributes/components/SelectsTable";
|
||||
import AttributePageView from "@/app/attributes/types/view";
|
||||
import PageBlock from "@/components/layout/PageBlock/PageBlock";
|
||||
|
||||
const PageBody = () => {
|
||||
const [view, setView] = useState<AttributePageView>(
|
||||
AttributePageView.ATTRIBUTES
|
||||
);
|
||||
|
||||
return (
|
||||
<PageBlock
|
||||
style={{ flex: 1, minHeight: 0 }}
|
||||
fullScreenMobile>
|
||||
<div
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--mantine-spacing-md)",
|
||||
}}>
|
||||
<AttributesHeader
|
||||
view={view}
|
||||
setView={setView}
|
||||
/>
|
||||
<div style={{ flex: 1, overflow: "auto" }}>
|
||||
{view === AttributePageView.ATTRIBUTES ? (
|
||||
<AttributesTable />
|
||||
) : (
|
||||
<SelectsTable />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageBlock>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageBody;
|
||||
36
src/app/attributes/components/SelectsTable.tsx
Normal file
36
src/app/attributes/components/SelectsTable.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { IconMoodSad } from "@tabler/icons-react";
|
||||
import { Group, Text } from "@mantine/core";
|
||||
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
|
||||
import useSelectsTableColumns from "@/app/attributes/hooks/useSelectsTableColumns";
|
||||
import BaseTable from "@/components/ui/BaseTable/BaseTable";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
|
||||
const SelectsTable = () => {
|
||||
const { selects } = useAttributesContext();
|
||||
const isMobile = useIsMobile();
|
||||
const columns = useSelectsTableColumns();
|
||||
|
||||
return (
|
||||
<BaseTable
|
||||
withTableBorder
|
||||
columns={columns}
|
||||
records={selects}
|
||||
verticalSpacing={"md"}
|
||||
emptyState={
|
||||
<Group mt={selects.length === 0 ? "xl" : 0}>
|
||||
<Text>Нет справочников</Text>
|
||||
<IconMoodSad />
|
||||
</Group>
|
||||
}
|
||||
groups={undefined}
|
||||
styles={{
|
||||
table: {
|
||||
width: "100%",
|
||||
},
|
||||
}}
|
||||
mx={isMobile ? "xs" : 0}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectsTable;
|
||||
62
src/app/attributes/contexts/AttributesContext.tsx
Normal file
62
src/app/attributes/contexts/AttributesContext.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import useFilteredAttributes from "@/app/attributes/hooks/useFilteredAttributes";
|
||||
import useSelectsActions, {
|
||||
SelectsActions,
|
||||
} from "@/app/attributes/hooks/useSelectsActions";
|
||||
import useAttributesActions, {
|
||||
AttributesActions,
|
||||
} from "@/app/module-editor/[moduleId]/hooks/useAttributesActions";
|
||||
import useAttributesList from "@/app/module-editor/[moduleId]/hooks/useAttributesList";
|
||||
import useAttrSelectsList from "@/hooks/lists/useAttrSelectsList";
|
||||
import { AttributeSchema, AttrSelectSchema } from "@/lib/client";
|
||||
import makeContext from "@/lib/contextFactory/contextFactory";
|
||||
import useFilteredSelects from "@/app/attributes/hooks/useFilteredSelects";
|
||||
|
||||
type AttributesContextState = {
|
||||
attributes: AttributeSchema[];
|
||||
attributesActions: AttributesActions;
|
||||
attrSearch: string;
|
||||
setAttrSearch: Dispatch<SetStateAction<string>>;
|
||||
selects: AttrSelectSchema[];
|
||||
selectsActions: SelectsActions;
|
||||
selectSearch: string;
|
||||
setSelectSearch: Dispatch<SetStateAction<string>>;
|
||||
};
|
||||
|
||||
const useAttributesContextState = (): AttributesContextState => {
|
||||
const { attributes, refetch: refetchAttributes } = useAttributesList();
|
||||
const attributesActions = useAttributesActions({
|
||||
refetchAttributes,
|
||||
});
|
||||
|
||||
const {
|
||||
search: attrSearch,
|
||||
setSearch: setAttrSearch,
|
||||
filteredAttributes,
|
||||
} = useFilteredAttributes({ attributes });
|
||||
|
||||
const { selects, queryKey } = useAttrSelectsList();
|
||||
const selectsActions = useSelectsActions({ queryKey });
|
||||
|
||||
const {
|
||||
search: selectSearch,
|
||||
setSearch: setSelectSearch,
|
||||
filteredSelects,
|
||||
} = useFilteredSelects({ selects });
|
||||
|
||||
return {
|
||||
attributes: filteredAttributes,
|
||||
attributesActions,
|
||||
attrSearch,
|
||||
setAttrSearch,
|
||||
selects: filteredSelects,
|
||||
selectsActions,
|
||||
selectSearch,
|
||||
setSelectSearch,
|
||||
};
|
||||
};
|
||||
|
||||
export const [AttributesContextProvider, useAttributesContext] =
|
||||
makeContext<AttributesContextState>(useAttributesContextState, "Attribute");
|
||||
@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import React, { FC } from "react";
|
||||
import { Drawer } from "@mantine/core";
|
||||
import EditorBody from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/EditorBody";
|
||||
import { SelectEditorContextProvider } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
|
||||
import { DrawerProps } from "@/drawers/types";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { AttrSelectSchema, UpdateAttrSelectSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
select: AttrSelectSchema;
|
||||
onSelectChange: (
|
||||
values: UpdateAttrSelectSchema,
|
||||
onSuccess: () => void
|
||||
) => void;
|
||||
};
|
||||
|
||||
const AttrSelectEditorDrawer: FC<DrawerProps<Props>> = ({
|
||||
onClose,
|
||||
opened,
|
||||
props,
|
||||
}) => {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
size={isMobile ? "100%" : "30%"}
|
||||
title={"Редактирование справочника"}
|
||||
position={"left"}
|
||||
onClose={onClose}
|
||||
removeScrollProps={{ allowPinchZoom: true }}
|
||||
withCloseButton
|
||||
opened={opened}
|
||||
trapFocus={false}
|
||||
styles={{
|
||||
body: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}>
|
||||
<SelectEditorContextProvider {...props}>
|
||||
<EditorBody />
|
||||
</SelectEditorContextProvider>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttrSelectEditorDrawer;
|
||||
@ -0,0 +1,38 @@
|
||||
import { Button, Flex, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
|
||||
import { UpdateAttrSelectSchema } from "@/lib/client";
|
||||
|
||||
const CommonInfoEditor = () => {
|
||||
const { select, onSelectChange } = useSelectEditorContext();
|
||||
|
||||
const form = useForm<UpdateAttrSelectSchema>({
|
||||
initialValues: select || {
|
||||
name: "",
|
||||
},
|
||||
validate: {
|
||||
name: name => !name && "Введите название",
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(values => onSelectChange(values))}>
|
||||
<Flex
|
||||
gap={"xs"}
|
||||
direction={"column"}>
|
||||
<TextInput
|
||||
label={"Название справочника"}
|
||||
{...form.getInputProps("name")}
|
||||
data-autofocus
|
||||
/>
|
||||
<Button
|
||||
variant={"default"}
|
||||
type={"submit"}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommonInfoEditor;
|
||||
@ -0,0 +1,47 @@
|
||||
import { IconCheck } from "@tabler/icons-react";
|
||||
import { Flex, TextInput } from "@mantine/core";
|
||||
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
|
||||
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
|
||||
import InlineButton from "@/components/ui/InlineButton/InlineButton";
|
||||
|
||||
const CreateOptionButton = () => {
|
||||
const {
|
||||
optionsActions: {
|
||||
isCreatingOption,
|
||||
createOptionForm,
|
||||
onStartCreating,
|
||||
onFinishCreating,
|
||||
},
|
||||
} = useSelectEditorContext();
|
||||
|
||||
if (!isCreatingOption) {
|
||||
return (
|
||||
<Flex flex={1}>
|
||||
<InlineButton
|
||||
fullWidth
|
||||
onClick={onStartCreating}>
|
||||
Добавить опцию
|
||||
</InlineButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={"xs"}
|
||||
flex={1}>
|
||||
<TextInput
|
||||
{...createOptionForm.getInputProps("name")}
|
||||
flex={1}
|
||||
placeholder={"Название"}
|
||||
/>
|
||||
<ActionIconWithTip
|
||||
tipLabel={"Сохранить"}
|
||||
onClick={onFinishCreating}>
|
||||
<IconCheck />
|
||||
</ActionIconWithTip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateOptionButton;
|
||||
@ -0,0 +1,22 @@
|
||||
import { Divider, Flex } from "@mantine/core";
|
||||
import CommonInfoEditor from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/CommonInfoEditor";
|
||||
import CreateOptionButton from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/CreateOptionButton";
|
||||
import OptionsTable from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/OptionsTable";
|
||||
|
||||
const EditorBody = () => {
|
||||
return (
|
||||
<Flex
|
||||
gap={"xs"}
|
||||
direction={"column"}>
|
||||
<CommonInfoEditor />
|
||||
<Divider
|
||||
label={"Опции"}
|
||||
my={"xs"}
|
||||
/>
|
||||
<CreateOptionButton />
|
||||
<OptionsTable />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditorBody;
|
||||
@ -0,0 +1,82 @@
|
||||
import React, { FC, ReactNode } from "react";
|
||||
import { IconCheck, IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import { Divider, Flex, Group, Stack, TextInput } from "@mantine/core";
|
||||
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
|
||||
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
|
||||
import { AttrOptionSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
option: AttrOptionSchema;
|
||||
renderDraggable?: (item: AttrOptionSchema) => ReactNode;
|
||||
};
|
||||
|
||||
const OptionTableRow: FC<Props> = ({ option, renderDraggable }) => {
|
||||
const {
|
||||
optionsActions: {
|
||||
onStartEditing,
|
||||
onFinishEditing,
|
||||
onDelete,
|
||||
editingOptionsData,
|
||||
setEditingOptionsData,
|
||||
},
|
||||
} = useSelectEditorContext();
|
||||
|
||||
const onChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
optionId: number
|
||||
) => {
|
||||
setEditingOptionsData(prev => {
|
||||
prev.set(optionId, e.currentTarget.value);
|
||||
return new Map(prev);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack
|
||||
gap={"xs"}
|
||||
mt={"xs"}>
|
||||
<Group
|
||||
wrap={"nowrap"}
|
||||
justify={"space-between"}>
|
||||
<Group wrap={"nowrap"}>
|
||||
{renderDraggable && renderDraggable(option)}
|
||||
{editingOptionsData.has(option.id) ? (
|
||||
<TextInput
|
||||
value={editingOptionsData.get(option.id)}
|
||||
onChange={e => onChange(e, option.id)}
|
||||
/>
|
||||
) : (
|
||||
option.name
|
||||
)}
|
||||
</Group>
|
||||
<Flex
|
||||
justify={"center"}
|
||||
gap={"xs"}>
|
||||
{editingOptionsData.has(option.id) ? (
|
||||
<ActionIconWithTip
|
||||
onClick={() => onFinishEditing(option)}
|
||||
tipLabel={"Сохранить"}>
|
||||
<IconCheck />
|
||||
</ActionIconWithTip>
|
||||
) : (
|
||||
<ActionIconWithTip
|
||||
onClick={() => onStartEditing(option)}
|
||||
tipLabel={"Редактировать"}>
|
||||
<IconEdit />
|
||||
</ActionIconWithTip>
|
||||
)}
|
||||
|
||||
<ActionIconWithTip
|
||||
color={"red"}
|
||||
onClick={() => onDelete(option)}
|
||||
tipLabel={"Удалить"}>
|
||||
<IconTrash />
|
||||
</ActionIconWithTip>
|
||||
</Flex>
|
||||
</Group>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionTableRow;
|
||||
@ -0,0 +1,38 @@
|
||||
import React from "react";
|
||||
import { IconGripVertical } from "@tabler/icons-react";
|
||||
import { Box, Divider, Stack } from "@mantine/core";
|
||||
import OptionTableRow from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/OptionTableRow";
|
||||
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
|
||||
import SortableDnd from "@/components/dnd/SortableDnd";
|
||||
|
||||
const OptionsTable = () => {
|
||||
const { options } = useSelectEditorContext();
|
||||
const { onDragEnd } = useSelectEditorContext();
|
||||
|
||||
const renderDraggable = () => (
|
||||
<Box p={"xs"}>
|
||||
<IconGripVertical />
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack gap={0}>
|
||||
<Divider />
|
||||
<SortableDnd
|
||||
initialItems={options}
|
||||
onDragEnd={onDragEnd}
|
||||
renderItem={(item, renderDraggable) => (
|
||||
<OptionTableRow
|
||||
option={item}
|
||||
renderDraggable={renderDraggable}
|
||||
/>
|
||||
)}
|
||||
renderDraggable={renderDraggable}
|
||||
dragHandleStyle={{ width: "auto" }}
|
||||
vertical
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionsTable;
|
||||
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import useAttrOptionsList from "@/app/attributes/drawers/AttrSelectEditorDrawer/hooks/useAttrOptionsList";
|
||||
import {
|
||||
AttrOptionSchema,
|
||||
AttrSelectSchema,
|
||||
UpdateAttrSelectSchema,
|
||||
} from "@/lib/client";
|
||||
import makeContext from "@/lib/contextFactory/contextFactory";
|
||||
import { notifications } from "@/lib/notifications";
|
||||
import useOptionsActions, { OptionsActions } from "../hooks/useOptionsActions";
|
||||
|
||||
type SelectEditorContextState = {
|
||||
select: AttrSelectSchema;
|
||||
onSelectChange: (values: UpdateAttrSelectSchema) => void;
|
||||
options: AttrOptionSchema[];
|
||||
optionsActions: OptionsActions;
|
||||
onDragEnd: (itemId: number, newLexorank: string) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
select: AttrSelectSchema;
|
||||
onSelectChange: (
|
||||
values: UpdateAttrSelectSchema,
|
||||
onSuccess: () => void
|
||||
) => void;
|
||||
};
|
||||
|
||||
const useSelectEditorContextState = ({
|
||||
select,
|
||||
onSelectChange,
|
||||
}: Props): SelectEditorContextState => {
|
||||
const { options, queryKey } = useAttrOptionsList({ selectId: select.id });
|
||||
|
||||
const optionsActions = useOptionsActions({ queryKey, select, options });
|
||||
|
||||
const onSelectChangeWithMsg = (values: UpdateAttrSelectSchema) => {
|
||||
onSelectChange(values, () => {
|
||||
notifications.success({
|
||||
message: "Название справочника сохранено",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onDragEnd = (itemId: number, newLexorank: string) => {
|
||||
optionsActions.onUpdate(itemId, { lexorank: newLexorank });
|
||||
};
|
||||
|
||||
return {
|
||||
select,
|
||||
onSelectChange: onSelectChangeWithMsg,
|
||||
options,
|
||||
optionsActions,
|
||||
onDragEnd,
|
||||
};
|
||||
};
|
||||
|
||||
export const [SelectEditorContextProvider, useSelectEditorContext] =
|
||||
makeContext<SelectEditorContextState, Props>(
|
||||
useSelectEditorContextState,
|
||||
"SelectEditor"
|
||||
);
|
||||
@ -0,0 +1,68 @@
|
||||
import { LexoRank } from "lexorank";
|
||||
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
|
||||
import {
|
||||
AttrOptionSchema,
|
||||
CreateAttrOptionSchema,
|
||||
UpdateAttrOptionSchema,
|
||||
} from "@/lib/client";
|
||||
import {
|
||||
createAttrOptionMutation,
|
||||
deleteAttrOptionMutation,
|
||||
updateAttrOptionMutation,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
import { getNewLexorank } from "@/utils/lexorank/generation";
|
||||
import { getMaxByLexorank } from "@/utils/lexorank/max";
|
||||
|
||||
type Props = {
|
||||
queryKey: any[];
|
||||
options: AttrOptionSchema[];
|
||||
};
|
||||
|
||||
export type AttrOptionsCrud = {
|
||||
onCreate: (
|
||||
data: Partial<CreateAttrOptionSchema>,
|
||||
onSuccess?: () => void
|
||||
) => void;
|
||||
onUpdate: (
|
||||
optionId: number,
|
||||
option: UpdateAttrOptionSchema,
|
||||
onSuccess?: () => void
|
||||
) => void;
|
||||
onDelete: (option: AttrOptionSchema, onSuccess?: () => void) => void;
|
||||
};
|
||||
|
||||
export const useAttrOptionsCrud = ({
|
||||
queryKey,
|
||||
options,
|
||||
}: Props): AttrOptionsCrud => {
|
||||
return useCrudOperations<
|
||||
AttrOptionSchema,
|
||||
UpdateAttrOptionSchema,
|
||||
CreateAttrOptionSchema
|
||||
>({
|
||||
key: "getAttrOptions",
|
||||
queryKey,
|
||||
mutations: {
|
||||
create: createAttrOptionMutation(),
|
||||
update: updateAttrOptionMutation(),
|
||||
delete: deleteAttrOptionMutation(),
|
||||
},
|
||||
getCreateEntity: data => {
|
||||
const lastOption = getMaxByLexorank(options);
|
||||
const newLexorank = getNewLexorank(
|
||||
lastOption ? LexoRank.parse(lastOption.lexorank) : null
|
||||
);
|
||||
return {
|
||||
name: data.name!,
|
||||
selectId: data.selectId!,
|
||||
lexorank: newLexorank.toString(),
|
||||
};
|
||||
},
|
||||
getUpdateEntity: (old, update) => ({
|
||||
...old,
|
||||
name: update.name ?? old.name,
|
||||
lexorank: update.lexorank ?? old.lexorank,
|
||||
}),
|
||||
getDeleteConfirmTitle: () => "Удаление опции",
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AttrOptionSchema } from "@/lib/client";
|
||||
import {
|
||||
getAttrOptionsOptions,
|
||||
getAttrOptionsQueryKey,
|
||||
} from "@/lib/client/@tanstack/react-query.gen";
|
||||
import { sortByLexorank } from "@/utils/lexorank/sort";
|
||||
|
||||
type Props = {
|
||||
selectId: number;
|
||||
};
|
||||
|
||||
const useAttrOptionsList = ({ selectId }: Props) => {
|
||||
const queryClient = useQueryClient();
|
||||
const options = { path: { selectId } };
|
||||
const { data, refetch } = useQuery(getAttrOptionsOptions(options));
|
||||
|
||||
const queryKey = getAttrOptionsQueryKey(options);
|
||||
|
||||
const setOptions = (options: AttrOptionSchema[]) => {
|
||||
queryClient.setQueryData(
|
||||
queryKey,
|
||||
(old: { items: AttrOptionSchema[] }) => ({
|
||||
...old,
|
||||
items: options,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
options: sortByLexorank(data?.items ?? []),
|
||||
setOptions,
|
||||
refetch,
|
||||
queryKey,
|
||||
};
|
||||
};
|
||||
|
||||
export default useAttrOptionsList;
|
||||
@ -0,0 +1,107 @@
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import { useForm, UseFormReturnType } from "@mantine/form";
|
||||
import {
|
||||
AttrOptionSchema,
|
||||
AttrSelectSchema,
|
||||
CreateAttrOptionSchema,
|
||||
UpdateAttrOptionSchema,
|
||||
} from "@/lib/client";
|
||||
import { notifications } from "@/lib/notifications";
|
||||
import { useAttrOptionsCrud } from "@/app/attributes/drawers/AttrSelectEditorDrawer/hooks/useAttrOptionsCrud";
|
||||
|
||||
type Props = {
|
||||
queryKey: any[];
|
||||
select: AttrSelectSchema;
|
||||
options: AttrOptionSchema[];
|
||||
};
|
||||
|
||||
export type OptionsActions = {
|
||||
isCreatingOption: boolean;
|
||||
createOptionForm: UseFormReturnType<CreateAttrOptionSchema>;
|
||||
onStartCreating: () => void;
|
||||
onFinishCreating: () => void;
|
||||
editingOptionsData: Map<number, string>;
|
||||
setEditingOptionsData: Dispatch<SetStateAction<Map<number, string>>>;
|
||||
onStartEditing: (option: AttrOptionSchema) => void;
|
||||
onFinishEditing: (option: AttrOptionSchema) => void;
|
||||
onUpdate: (optionId: number, data: UpdateAttrOptionSchema) => void;
|
||||
onDelete: (option: AttrOptionSchema) => void;
|
||||
};
|
||||
|
||||
const useOptionsActions = ({ queryKey, select, options }: Props) => {
|
||||
const [isCreatingOption, setIsCreatingOption] = useState<boolean>(false);
|
||||
const [editingOptionsData, setEditingOptionsData] = useState<
|
||||
Map<number, string>
|
||||
>(new Map());
|
||||
|
||||
const createOptionForm = useForm<CreateAttrOptionSchema>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
lexorank: "",
|
||||
selectId: select.id,
|
||||
},
|
||||
validate: {
|
||||
name: name => !name && "Введите название",
|
||||
},
|
||||
});
|
||||
|
||||
const optionCrud = useAttrOptionsCrud({ queryKey, options });
|
||||
|
||||
const onStartCreating = () => {
|
||||
setIsCreatingOption(true);
|
||||
};
|
||||
const onFinishCreating = () => {
|
||||
if (createOptionForm.validate().hasErrors) return;
|
||||
optionCrud.onCreate(createOptionForm.values, () => {
|
||||
notifications.success({ message: "Опция успешно создана" });
|
||||
createOptionForm.reset();
|
||||
setIsCreatingOption(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onStartEditing = (option: AttrOptionSchema) => {
|
||||
setEditingOptionsData(prev => {
|
||||
prev.set(option.id, option.name);
|
||||
return new Map(prev);
|
||||
});
|
||||
};
|
||||
const onFinishEditing = (option: AttrOptionSchema) => {
|
||||
if (!editingOptionsData.has(option.id)) return;
|
||||
|
||||
const newName = editingOptionsData.get(option.id);
|
||||
if (!newName) {
|
||||
notifications.error({ message: "Название не может быть пустым" });
|
||||
return;
|
||||
}
|
||||
optionCrud.onUpdate(option.id, { ...option, name: newName }, () => {
|
||||
notifications.success({ message: "Опция сохранена" });
|
||||
setEditingOptionsData(prev => {
|
||||
prev.delete(option.id);
|
||||
return new Map(prev);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = (option: AttrOptionSchema) => {
|
||||
optionCrud.onDelete(option, () =>
|
||||
notifications.success({ message: "Опция удалена" })
|
||||
);
|
||||
};
|
||||
|
||||
const onUpdate = optionCrud.onUpdate;
|
||||
|
||||
return {
|
||||
isCreatingOption,
|
||||
createOptionForm,
|
||||
onStartCreating,
|
||||
onFinishCreating,
|
||||
editingOptionsData,
|
||||
setEditingOptionsData,
|
||||
onStartEditing,
|
||||
onFinishEditing,
|
||||
onDelete,
|
||||
onUpdate,
|
||||
};
|
||||
};
|
||||
|
||||
export default useOptionsActions;
|
||||
72
src/app/attributes/hooks/useAttributesTableColumns.tsx
Normal file
72
src/app/attributes/hooks/useAttributesTableColumns.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import { Box, Center } from "@mantine/core";
|
||||
import AttributeTableActions from "@/app/module-editor/[moduleId]/components/shared/AttributeTableActions/AttributeTableActions";
|
||||
import AttributeDefaultValue from "@/components/ui/AttributeDefaultValue/AttributeDefaultValue";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { AttributeSchema } from "@/lib/client";
|
||||
import { useAttributesContext } from "../contexts/AttributesContext";
|
||||
|
||||
const useAttributesTableColumns = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { attributesActions } = useAttributesContext();
|
||||
const renderCheck = (value: boolean) => (value ? <IconCheck /> : <IconX />);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
title: "Название атрибута",
|
||||
accessor: "label",
|
||||
},
|
||||
{
|
||||
title: "Тип",
|
||||
accessor: "type.name",
|
||||
render: attr =>
|
||||
attr.type.type === "select"
|
||||
? `Выбор "${attr.label}"`
|
||||
: attr.type.name,
|
||||
},
|
||||
{
|
||||
title: "Значение по умолчанию",
|
||||
accessor: "defaultValue",
|
||||
render: attr => <AttributeDefaultValue attribute={attr} />,
|
||||
},
|
||||
{
|
||||
title: isMobile
|
||||
? "Синх. в группе"
|
||||
: "Синхронизировано в группе",
|
||||
accessor: "isApplicableToGroup",
|
||||
render: attr => renderCheck(attr.isApplicableToGroup),
|
||||
},
|
||||
{
|
||||
title: "Может быть пустым",
|
||||
accessor: "isNullable",
|
||||
render: attr => renderCheck(attr.isNullable),
|
||||
},
|
||||
{
|
||||
title: "Описаниие",
|
||||
accessor: "description",
|
||||
render: attr => <Box>{attr.description}</Box>,
|
||||
},
|
||||
{
|
||||
accessor: "actions",
|
||||
title: <Center>Действия</Center>,
|
||||
width: "0%",
|
||||
render: attribute => (
|
||||
<AttributeTableActions
|
||||
attribute={attribute}
|
||||
onUpdate={attributesActions.onUpdate}
|
||||
onDelete={attributesActions.onDelete}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as DataTableColumn<AttributeSchema>[],
|
||||
[isMobile]
|
||||
);
|
||||
};
|
||||
|
||||
export default useAttributesTableColumns;
|
||||
29
src/app/attributes/hooks/useFilteredAttributes.ts
Normal file
29
src/app/attributes/hooks/useFilteredAttributes.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AttributeSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
attributes: AttributeSchema[];
|
||||
};
|
||||
|
||||
const useFilteredAttributes = ({ attributes }: Props) => {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const filteredAttributes = useMemo(
|
||||
() =>
|
||||
attributes.filter(
|
||||
attr =>
|
||||
attr.type.name.includes(search) ||
|
||||
attr.label.includes(search) ||
|
||||
attr.description.includes(search)
|
||||
),
|
||||
[attributes, search]
|
||||
);
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
filteredAttributes,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFilteredAttributes;
|
||||
23
src/app/attributes/hooks/useFilteredSelects.ts
Normal file
23
src/app/attributes/hooks/useFilteredSelects.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { AttrSelectSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
selects: AttrSelectSchema[];
|
||||
};
|
||||
|
||||
const useFilteredSelects = ({ selects }: Props) => {
|
||||
const [search, setSearch] = useState<string>("");
|
||||
|
||||
const filteredSelects = useMemo(
|
||||
() => selects.filter(s => s.name.includes(search)),
|
||||
[selects, search]
|
||||
);
|
||||
|
||||
return {
|
||||
search,
|
||||
setSearch,
|
||||
filteredSelects,
|
||||
};
|
||||
};
|
||||
|
||||
export default useFilteredSelects;
|
||||
50
src/app/attributes/hooks/useSelectsActions.tsx
Normal file
50
src/app/attributes/hooks/useSelectsActions.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import { modals } from "@mantine/modals";
|
||||
import { useDrawersContext } from "@/drawers/DrawersContext";
|
||||
import { useAttrSelectsCrud } from "@/hooks/cruds/useSelectsCrud";
|
||||
import { AttrSelectSchema } from "@/lib/client";
|
||||
|
||||
type Props = {
|
||||
queryKey: any[];
|
||||
};
|
||||
|
||||
export type SelectsActions = {
|
||||
onCreate: () => void;
|
||||
onUpdate: (select: AttrSelectSchema) => void;
|
||||
onDelete: (select: AttrSelectSchema) => void;
|
||||
};
|
||||
|
||||
const useSelectsActions = (props: Props): SelectsActions => {
|
||||
const attrSelectsCrud = useAttrSelectsCrud(props);
|
||||
const { openDrawer } = useDrawersContext();
|
||||
|
||||
const onCreate = () => {
|
||||
modals.openContextModal({
|
||||
modal: "enterNameModal",
|
||||
title: "Создание справочника",
|
||||
innerProps: {
|
||||
onChange: values => attrSelectsCrud.onCreate(values),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onUpdate = (select: AttrSelectSchema) => {
|
||||
openDrawer({
|
||||
key: "attrSelectEditorDrawer",
|
||||
props: {
|
||||
onSelectChange: (values, onSuccess) =>
|
||||
attrSelectsCrud.onUpdate(select.id, values, onSuccess),
|
||||
select,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onDelete = attrSelectsCrud.onDelete;
|
||||
|
||||
return {
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
};
|
||||
};
|
||||
|
||||
export default useSelectsActions;
|
||||
38
src/app/attributes/hooks/useSelectsTableColumns.tsx
Normal file
38
src/app/attributes/hooks/useSelectsTableColumns.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { DataTableColumn } from "mantine-datatable";
|
||||
import { Center } from "@mantine/core";
|
||||
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
|
||||
import useIsMobile from "@/hooks/utils/useIsMobile";
|
||||
import { AttrSelectSchema } from "@/lib/client";
|
||||
import { useAttributesContext } from "../contexts/AttributesContext";
|
||||
|
||||
const useSelectsTableColumns = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const { selectsActions } = useAttributesContext();
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
{
|
||||
title: "Название справочника",
|
||||
accessor: "name",
|
||||
},
|
||||
{
|
||||
accessor: "actions",
|
||||
title: <Center>Действия</Center>,
|
||||
width: "0%",
|
||||
render: select => (
|
||||
<UpdateDeleteTableActions
|
||||
onDelete={() => selectsActions.onDelete(select)}
|
||||
onChange={() => selectsActions.onUpdate(select)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
] as DataTableColumn<AttrSelectSchema>[],
|
||||
[isMobile]
|
||||
);
|
||||
};
|
||||
|
||||
export default useSelectsTableColumns;
|
||||
22
src/app/attributes/page.tsx
Normal file
22
src/app/attributes/page.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Suspense } from "react";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import PageBody from "@/app/attributes/components/PageBody";
|
||||
import { AttributesContextProvider } from "@/app/attributes/contexts/AttributesContext";
|
||||
import PageContainer from "@/components/layout/PageContainer/PageContainer";
|
||||
|
||||
export default async function AttributesPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="50vh">
|
||||
<Loader size="lg" />
|
||||
</Center>
|
||||
}>
|
||||
<PageContainer>
|
||||
<AttributesContextProvider>
|
||||
<PageBody />
|
||||
</AttributesContextProvider>
|
||||
</PageContainer>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
6
src/app/attributes/types/view.ts
Normal file
6
src/app/attributes/types/view.ts
Normal file
@ -0,0 +1,6 @@
|
||||
enum AttributePageView {
|
||||
ATTRIBUTES,
|
||||
SELECTS,
|
||||
}
|
||||
|
||||
export default AttributePageView;
|
||||
@ -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;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user