Compare commits

...

203 Commits

Author SHA1 Message Date
36c2a3a2af feat: funnel dnd using pragmatic, not finished groups 2025-10-16 15:26:53 +04:00
fc176ec9e4 fix: fixed border radius 2025-10-14 21:29:44 +04:00
4a4b05769d feat: marketplaces editor in clients page 2025-10-13 12:47:31 +04:00
2052737561 feat: total price and products count display for deals 2025-10-11 16:21:31 +04:00
a899177623 feat: statuses colors 2025-10-11 12:15:03 +04:00
5e56daa765 fix: fixed product editor usage 2025-10-11 09:19:29 +04:00
92602549f8 fix: disable duplicate services when only 1 product added 2025-10-10 23:02:06 +04:00
6351642838 fix: centered project select 2025-10-10 22:55:41 +04:00
4db3c3a1bf fix: centered deal services title 2025-10-10 22:33:44 +04:00
5aa3b4d9e2 fix: hide service type select when editing 2025-10-10 20:57:30 +04:00
73e3fd4ba2 feat: barcodes printing 2025-10-10 20:47:44 +04:00
8af4fcce2f feat: products page 2025-10-08 22:32:16 +04:00
820d9b4d33 fix: fixed redirecting on project select 2025-10-07 11:14:06 +04:00
428a6aca82 feat: actions page for mobiles 2025-10-07 09:48:05 +04:00
7b0b3bc529 feat: schedule view for mobiles 2025-10-06 12:38:07 +04:00
b316cf4f7a feat: table view for mobiles 2025-10-06 09:37:58 +04:00
665625557d feat: navbar buttons depended on selected project 2025-10-05 19:54:02 +04:00
b35961329e refactor: replaced rem with mantine constants 2025-10-05 12:25:14 +04:00
0fcf086861 feat: client tab in deal editor 2025-10-05 12:05:23 +04:00
d14920df7d fix: fixed navbar link tooltips 2025-10-04 19:38:10 +04:00
50ade0e832 fix: fixed client table spacing 2025-10-04 19:25:28 +04:00
e9bfd39ab4 feat: clients page 2025-10-04 18:18:17 +04:00
f641e9ef8c feat: barcode templates page 2025-10-04 10:15:58 +04:00
1a2895da59 feat: services table with dnd 2025-10-03 09:07:02 +04:00
f3a0179467 services table dnd to fix 2025-09-30 23:21:34 +04:00
b51467cbf6 fix: fixed services table header 2025-09-28 12:55:18 +04:00
61f0a9069b feat: actions for services and categories 2025-09-28 12:46:57 +04:00
47533ad7f5 feat: services table, base segmented control 2025-09-27 18:24:22 +04:00
14140826a7 feat: services kits table in service page 2025-09-25 09:36:22 +04:00
a83328492e feat: products and services tabs for mobile 2025-09-23 10:41:55 +04:00
41ff994ad1 fix: ru locale for numbers 2025-09-21 10:18:06 +04:00
6d6c430e88 feat: a few tabs for module, deal services tab for mobiles 2025-09-21 09:47:55 +04:00
6e445d5ebf feat: deal status history table 2025-09-20 10:06:33 +04:00
30e0de5c5e refactor: removed extra folder from modules 2025-09-19 20:13:45 +04:00
de82e639b2 fix: deal service price input fixed 2025-09-19 20:03:52 +04:00
e7416155be fix: total price for deal 2025-09-19 18:15:55 +04:00
05edac23f1 fix: scroll of deal services fixed 2025-09-19 17:22:36 +04:00
9ba22b9bdf fix: styles for deal drawer fixed 2025-09-19 11:55:08 +04:00
e049494fa5 fix: fixed labels in tabs in deal editor 2025-09-19 09:24:49 +04:00
79189bea9a fix: product quantity input and modules dependencies title fixed 2025-09-18 20:37:35 +04:00
053c1da5db fix: dots icons color and dnd border radius fixed 2025-09-18 19:46:59 +04:00
0805a86335 feat: module dependencies 2025-09-18 17:53:26 +04:00
a95d05e28b feat: styles for deal drawer 2025-09-18 09:50:06 +04:00
6b4e2f193a fix: fixed price in productServiceEditorModal 2025-09-17 12:04:33 +04:00
4c5b9c7734 fix: fixed deal service editor modal 2025-09-16 19:51:37 +04:00
681c2c3bc8 feat: total price in product services table 2025-09-16 19:27:00 +04:00
d927da46df feat: adding services kit to deal 2025-09-16 18:13:35 +04:00
553e76d610 feat: modules, products, services, services kits 2025-09-16 10:56:10 +04:00
f2746b8b65 refactor: using isDirty in forms 2025-09-13 09:02:07 +04:00
c76304b7bc fix: fixed status column droppable area 2025-09-10 16:05:25 +04:00
c4381d86c7 fix: fixed input for desktop 2025-09-08 11:13:49 +04:00
0515dd8a49 refactor: deal drawer fixed 2025-09-06 11:29:28 +04:00
d76dc82cb8 refactor: drawers refactored 2025-09-06 11:09:42 +04:00
67780b5251 refactor: styles refactored 2025-09-05 15:55:20 +04:00
0236379898 fix: deals table column width fixed 2025-09-05 14:49:25 +04:00
d0c734d481 refactor: modals refactored 2025-09-05 14:25:36 +04:00
7694b4ae03 refactor: filters modal with context 2025-09-04 14:54:20 +04:00
a5afb03be6 refactor: removed unnecessary view context 2025-09-04 13:00:51 +04:00
dce4dec2f5 refactor: moved logic from columns to table 2025-09-04 12:19:51 +04:00
0be2b8bb6b fix: gap under column for mobiles 2025-09-04 12:05:49 +04:00
96ea0bba5e fix: equal gaps above and under column 2025-09-04 12:03:50 +04:00
6d58add2e7 fix: changes height of create status button 2025-09-04 10:48:11 +04:00
b0e2703479 fix: smoother shadow 2025-09-04 10:35:39 +04:00
018c6a06ea fix: controls hidden in id input in filters 2025-09-04 10:25:33 +04:00
dcf069aa1b fix: theme icon fixed 2025-09-04 10:23:55 +04:00
604238ca43 refactor: theme icon for icon size setting 2025-09-04 09:56:05 +04:00
b5934a7ed2 fix: centered button add status 2025-09-03 14:40:43 +04:00
d69dee7caa fix: fixed swiping of a deal during dragging on mobile 2025-09-03 14:03:19 +04:00
5f621c295b feat: header in deal editor for mobile 2025-09-03 12:17:49 +04:00
9d8ec496a1 fix: dots icons smaller 2025-09-03 11:06:07 +04:00
492b7ac32e fix: fixed status select when deal form cleared 2025-09-02 19:48:22 +04:00
dca7d5f6a5 refactor: refactored filters 2025-09-02 18:19:08 +04:00
72ed69db24 feat: board and status selects in deal editor 2025-09-02 14:41:28 +04:00
a6d8948e9d feat: deals filters indicator and refactoring 2025-09-01 18:50:29 +04:00
48d539154c feat: deals filters 2025-09-01 17:54:31 +04:00
ab7ef1e753 feat: loading and error pages 2025-08-30 23:46:46 +04:00
26c7209de0 refactor: height for a logo 2025-08-30 19:47:04 +04:00
d0948fb583 fix: projects prefetch fixed 2025-08-30 18:04:13 +04:00
db5b886455 fix: dark shadow changed 2025-08-30 15:41:45 +04:00
b363554c46 feat: project editor 2025-08-30 15:31:42 +04:00
1b97739063 feat: project creating 2025-08-30 14:46:56 +04:00
a0522357d4 fix: query invalidating fixed in crud operations 2025-08-30 08:29:37 +04:00
9d3028e4c9 fix: create deal button on mobile fixed 2025-08-29 23:47:43 +04:00
568bd4ad36 fix: only tanstack usage in optimistic updates 2025-08-29 23:39:51 +04:00
8b06d08664 fix: display pages when more than one 2025-08-29 14:45:48 +04:00
50d4705c5e feat: project select width 2025-08-29 14:18:33 +04:00
658d7a2a0e feat: narrowed navbar 2025-08-29 14:06:29 +04:00
3dec614f2a feat: deal ids 2025-08-29 14:01:40 +04:00
9404091d69 fix: shortened datetimes and set background color for navbar 2025-08-29 09:36:15 +04:00
19e5ef2a7e feat: deals table 2025-08-28 20:23:58 +04:00
4323695069 feat: select view buttons 2025-08-28 11:00:41 +04:00
e9b8cdb010 fix: links instead of buttons for navigation 2025-08-27 15:24:45 +04:00
a280f7ad12 fix: closing input on click on swiper 2025-08-27 15:12:13 +04:00
e6001ed59e fix: roboto font, column scrolling fixed, column input width 2025-08-27 14:44:38 +04:00
44766bb7aa fix: boards scrolling with wheel 2025-08-27 09:38:07 +04:00
4a758e4cf0 feat: providers combiner 2025-08-26 16:53:47 +04:00
31bd888357 feat: context factory 2025-08-26 16:11:40 +04:00
5b5c2fe230 feat: ssr prefetch of projects and boards 2025-08-26 12:03:36 +04:00
e0f86f2018 fix: optimized rerenders caused by useList hooks 2025-08-26 10:21:11 +04:00
226e52a1c6 fix: boards gap on mobile fixed 2025-08-25 20:15:31 +04:00
cc5ccf86a4 fix: deal editor drawer size changed 2025-08-25 19:37:53 +04:00
e5602551c5 feat: datetimes with timezones 2025-08-24 14:54:10 +04:00
d5be9ce61a feat: deal create, update, delete 2025-08-24 12:49:19 +04:00
10f50ac254 refactor: sorted hooks 2025-08-23 19:01:21 +04:00
6ad813ea1d refactor: crud objects in contexts 2025-08-23 11:20:32 +04:00
f2084ae3d4 refactor: base crud hook 2025-08-23 10:28:31 +04:00
b105510c23 fix: fixed drawers sorting 2025-08-22 17:32:01 +04:00
b5753ed3a2 feat: drawers registry 2025-08-22 17:04:59 +04:00
cb67c913ad fix: boards rerender optimization 2025-08-21 16:45:04 +04:00
f3df8840df feat: board inplace input in form of tab 2025-08-21 09:08:55 +04:00
d5b6e28311 fix: fixed boards scroll 2025-08-20 23:56:27 +04:00
e3acf3aa89 fix: fixed swiping during deal holding 2025-08-20 22:56:18 +04:00
32ea2aa060 feat: temp shitty fixes to alexender know how to do better 2025-08-20 12:17:19 +03:00
7dba5b5ed9 feat: column scroll offset only when scroll needed 2025-08-19 18:18:02 +04:00
de7e334453 feat: project select in the boards row 2025-08-19 18:11:17 +04:00
179b89c786 fix: create board and status inputs fixed 2025-08-19 12:13:15 +04:00
be034ebbd0 feat: navbar and footer 2025-08-19 11:59:58 +04:00
d3d8c5117b feat: permanent scrollbar in funnel column 2025-08-18 11:52:53 +04:00
0bb546940a refactor: removed unused page block 2025-08-18 11:36:10 +04:00
83432b3f33 feat: boards on desktop as on mobile 2025-08-18 11:35:52 +04:00
49b1a235be feat: scrollable columns with deals 2025-08-18 09:45:54 +04:00
19a386319c feat: mock deal view 2025-08-17 20:28:50 +04:00
3ccebeb123 fix: centered columns in funnel dnd for mobile 2025-08-17 19:35:21 +04:00
e5e87f775d fix: fixed deal dragging from another column to the current 2025-08-17 18:33:05 +04:00
85ed974f5e fix: fixed status button for mobile 2025-08-17 10:48:51 +04:00
92efe3fb66 fix: set default collision detection algorithm for funnel 2025-08-17 10:47:07 +04:00
c405c802aa fix: fixed columns draggables and styles 2025-08-17 10:38:28 +04:00
4ff663536e fix: removed mantine carousel from dependencies 2025-08-16 20:00:48 +04:00
2e9ed02722 feat: swiper for boards on desktop 2025-08-16 19:57:22 +04:00
a4bcd62189 fix: fixed scroll color and width of columns slides on desktop 2025-08-16 19:03:53 +04:00
0a13070d9e feat: swiper 2025-08-16 14:59:37 +04:00
219689b947 feat: selected board style, boards spacing, text font size 2025-08-16 09:20:01 +04:00
3ece4677fb fix: hidden creating statuses when board is not selected 2025-08-15 11:11:29 +04:00
3d213cb0d9 Merge remote-tracking branch 'origin/main' 2025-08-15 11:03:22 +04:00
6d0c48be23 feat: margin for a carousel container 2025-08-15 11:03:06 +04:00
a169600908 refactor: remove size prop from theme configuration 2025-08-14 23:16:21 +03:00
43355b6ce3 refactor: css variables for colors and shadows 2025-08-14 18:18:24 +04:00
28004dc2a0 refactor: return types for hooks 2025-08-14 16:15:10 +04:00
c3b0da1e0d refactor: obvious mixin light 2025-08-14 15:27:49 +04:00
8fb4121ed1 fix: fixed in place input, refactored create board button for mobile 2025-08-14 12:51:39 +04:00
95e49eafc1 refactor: in place input division 2025-08-14 12:32:42 +04:00
255a39e2bb refactor: removed constant sizes 2025-08-14 12:15:09 +04:00
b6cec9a308 fix: mantine carousel 2025-08-14 11:16:33 +04:00
20ade53d52 fix: fixed dnd of boards 2025-08-13 22:12:14 +04:00
7932f3f5c8 fix: fixed scrolling by draggable on mobile 2025-08-13 18:18:37 +04:00
0836e4f0ca fix: removed back button on projects editor 2025-08-13 15:17:22 +04:00
90582b329e feat: projects create, update, delete 2025-08-13 15:03:09 +04:00
f2bba7e469 feat: styled create status button and header 2025-08-13 10:51:02 +04:00
838c9640a1 feat: division between mobile and desktop components, boards for mobile 2025-08-13 09:55:27 +04:00
1a98facd72 feat: scrolling of dnd during dragging, visible overlay for mobile 2025-08-12 19:15:11 +04:00
5144c83e93 feat: layouts and styles for desktop and mobile 2025-08-12 14:23:55 +04:00
6715e4bd38 fix: replaced isMobile with mantine hook 2025-08-10 19:48:29 +04:00
7815f99fa4 feat: raw slider for deals on mobile 2025-08-10 19:29:02 +04:00
54cf883a3c fix: sortable dnd twitching fix 2025-08-09 18:36:53 +04:00
45dc8901fd feat: color scheme toggle 2025-08-09 17:41:37 +04:00
067094c78a fix: removed autofocus on drawers 2025-08-09 17:19:38 +04:00
301821a682 feat: statuses dnd editor for mobile 2025-08-09 17:07:45 +04:00
9fb9e794db feat: boards dnd editor for mobile 2025-08-09 15:51:23 +04:00
e3137de46d feat: boards dnd editor for mobile 2025-08-09 10:13:25 +04:00
5ecdd3d887 feat: disable dnds for mobile 2025-08-08 18:06:42 +04:00
d3febcdfb0 feat: confirm modals on deleting 2025-08-08 15:32:56 +04:00
afad1b4605 feat: boards and statuses editing and creating for mobiles 2025-08-08 15:01:10 +04:00
f52fde0097 feat: status creating 2025-08-08 11:31:27 +04:00
e29664ecc5 feat: status editing and deleting 2025-08-07 15:46:11 +04:00
7e2dd9763b feat: board name editing 2025-08-07 12:31:00 +04:00
41f8d19d49 feat: board deletion 2025-08-07 10:13:08 +04:00
335fbfe81c feat: board creation and actions dropdown 2025-08-07 09:19:30 +04:00
4b843d8e5d refactor: moved dnd part from Funnel into FunnelDnd 2025-08-06 18:21:07 +04:00
96c53380e0 refactor: separation of shared components 2025-08-06 11:39:44 +04:00
9a780e99ae update .dockerignore to ensure source maps are ignored 2025-08-06 04:50:42 +03:00
1047a0b5fe feat: add .dockerignore and update Dockerfile for improved caching 2025-08-05 22:40:17 +03:00
573f50acc1 feat: add Dockerfile for multi-stage build and remove global stylesheet link 2025-08-05 21:54:02 +03:00
24edefa242 feat: add environment variable for API URL and update client configuration 2025-08-05 20:20:00 +03:00
cd034bcce6 refactor: store folder for redux 2025-08-05 21:04:23 +04:00
316cca712d refactor: moved client to lib/client 2025-08-05 20:51:55 +04:00
74f7cc7664 feat: add hey-api configuration and update OpenAPI TypeScript plugin settings 2025-08-05 17:48:33 +03:00
7bb8ab97c7 feat: add zod library to dependencies 2025-08-05 17:36:36 +03:00
abbf782945 refactor: straightened logic, replaces throttle with mantine debounced 2025-08-05 17:47:39 +04:00
c13cc4a0a5 feat: pointer cursor for boards and deals 2025-08-05 16:52:26 +04:00
236c0dcf10 feat: deal updating on the server 2025-08-05 16:33:04 +04:00
c98a5cc811 feat: status updating on the server and statuses fetching 2025-08-04 18:49:27 +04:00
24de9f5446 feat: board updating on the server 2025-08-04 16:57:54 +04:00
f13417e73a fix: fixed deal dragging end 2025-08-04 11:20:22 +04:00
2ae9c619c7 fix: fixed dragging of deal in the same status 2025-08-04 00:13:40 +04:00
315e7db3db feat: deals fetch 2025-08-03 16:55:36 +04:00
5435750fb5 feat: boards with statuses fetch 2025-08-03 13:40:09 +04:00
624c94155c fix: replaces old project schema 2025-08-03 11:29:04 +04:00
3e1d544b33 feat: hey-api and projects fetch 2025-08-03 11:07:56 +04:00
459487a896 refactor: refactoring of deals and statuses dnd 2025-08-02 10:58:24 +04:00
8ae198897d feat: optimization of render during dnd 2025-08-02 09:56:35 +04:00
586af488da feat: raw statuses dnd 2025-08-01 17:50:27 +04:00
943b2d63f5 feat: grabbing cursor for deals dnd 2025-08-01 14:30:42 +04:00
921ab4c89f feat: scrolls for statuses and boards 2025-08-01 12:28:40 +04:00
d13997ba80 fix: deals setting during dragOver optimization 2025-08-01 11:59:56 +04:00
5137836265 fix: fixed rerender of boards component after changes in statuses 2025-08-01 11:08:53 +04:00
5fe9ea6747 feat: raw deals dnd between statuses 2025-08-01 10:01:39 +04:00
8af4a908e6 fix: moved projects from redux to context 2025-07-30 22:11:31 +04:00
128a1b3c4f feat: tanstack query provider 2025-07-30 18:26:15 +04:00
cb168b6415 feat: projects redux storage and select 2025-07-30 17:44:30 +04:00
b8d431ae99 feat: raw boards dnd 2025-07-30 10:59:39 +04:00
cb6a814918 feat: openapi client generation 2025-07-28 17:42:25 +04:00
fe6e87f97c feat: modals 2025-07-27 12:32:56 +04:00
948480c219 feat: notifications, redux, tailwind 2025-07-27 11:41:43 +04:00
425 changed files with 41244 additions and 2232 deletions

21
.dockerignore Normal file
View 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
View File

@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://test.crm.logidex.ru/api

View File

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

View File

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

11
.gitignore vendored
View File

@ -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
.idea
.yarnrc.yml

1
.nvmrc Normal file
View File

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

View File

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

View File

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

2
.stylelintignore Normal file
View File

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

28
.stylelintrc.json Normal file
View File

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

48
Dockerfile Normal file
View 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"]

View File

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

30
eslint.config.mjs Normal file
View File

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

16
jest.config.cjs Normal file
View File

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

27
jest.setup.cjs Normal file
View File

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

View File

@ -1,12 +1,16 @@
/** @type {import('next').NextConfig} */
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
View 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",
},
],
});

View File

@ -1,46 +1,105 @@
{
"name": "crm-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"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",
"@mantine/modals": "^8.2.1",
"@mantine/notifications": "^8.2.1",
"@reduxjs/toolkit": "^2.8.2",
"@tabler/icons-react": "^3.34.1",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"dayjs": "^1.11.13",
"lodash": "^4.17.21",
"next": "15.3.3",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-redux": "^9.2.0",
"tailwind-preset-mantine": "^2.1.0",
"tailwindcss": "^4.1.11"
},
"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",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.6.2",
"typescript": "5.8.2"
},
"packageManager": "yarn@4.9.2"
"name": "crm-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"build": "next build",
"start": "next start",
"lint": "next lint",
"generate-client": "openapi-ts && prettier --write ./src/lib/client/**/*.ts && git add ./src/lib/client",
"generate-modules": "sudo npx tsc ./src/modules/modulesFileGen/modulesFileGen.ts && mv -f ./src/modules/modulesFileGen/modulesFileGen.js ./src/modules/modulesFileGen/modulesFileGen.cjs && sudo node ./src/modules/modulesFileGen/modulesFileGen.cjs"
},
"dependencies": {
"@atlaskit/avatar": "^25.4.2",
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^2.1.2",
"@atlaskit/pragmatic-drag-and-drop-flourish": "^2.0.7",
"@atlaskit/pragmatic-drag-and-drop-live-region": "^1.3.1",
"@atlaskit/pragmatic-drag-and-drop-react-drop-indicator": "^3.2.7",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@hello-pangea/dnd": "^18.0.1",
"@mantine/core": "8.1.2",
"@mantine/dates": "^8.2.7",
"@mantine/dropzone": "^8.3.1",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "8.1.2",
"@mantine/modals": "^8.2.1",
"@mantine/notifications": "^8.2.1",
"@next/bundle-analyzer": "^15.3.3",
"@reduxjs/toolkit": "^2.8.2",
"@tabler/icons-react": "^3.34.0",
"@tailwindcss/postcss": "^4.1.11",
"@tanstack/react-query": "^5.83.0",
"@types/react-dom": "19.1.2",
"axios": "1.12.0",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"dayjs": "^1.11.15",
"framer-motion": "^12.23.7",
"handlebars": "^4.7.8",
"i18n-iso-countries": "^7.14.0",
"lexorank": "^1.0.5",
"libphonenumber-js": "^1.12.10",
"mantine-datatable": "^8.2.0",
"next": "15.4.7",
"phone": "^3.1.67",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-imask": "^7.6.1",
"react-redux": "^9.2.0",
"redux-persist": "^6.0.0",
"sharp": "^0.34.3",
"swiper": "^11.2.10",
"zod": "^4.0.14"
},
"devDependencies": {
"@babel/core": "^7.27.4",
"@eslint/js": "^9.29.0",
"@hey-api/client-axios": "^0.9.1",
"@hey-api/client-next": "^0.5.1",
"@hey-api/openapi-ts": "^0.80.1",
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
"@storybook/nextjs": "^8.6.8",
"@storybook/react": "^8.6.8",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/eslint-plugin-jsx-a11y": "^6",
"@types/jest": "^29.5.14",
"@types/lodash": "^4.17.20",
"@types/node": "^22.13.11",
"@types/react": "19.1.8",
"@types/react-redux": "^7.1.34",
"@types/react-slick": "^0",
"@types/redux-persist": "^4.3.1",
"@types/slick-carousel": "^1",
"autoprefixer": "^10.4.21",
"babel-loader": "^10.0.0",
"eslint": "^9.29.0",
"eslint-config-mantine": "^4.0.3",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"jest": "^30.0.0",
"jest-environment-jsdom": "^30.0.0",
"postcss": "^8.5.6",
"postcss-preset-mantine": "1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.5.3",
"storybook": "^8.6.8",
"storybook-dark-mode": "^4.0.2",
"stylelint": "^16.20.0",
"stylelint-config-standard-scss": "^15.0.1",
"tailwindcss": "^4.1.11",
"ts-jest": "^29.4.0",
"typescript": "5.8.3",
"typescript-eslint": "^8.34.0"
},
"packageManager": "yarn@4.9.2"
}

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 937 B

After

Width:  |  Height:  |  Size: 1.8 KiB

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

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

View File

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

View File

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

View 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;

View File

@ -0,0 +1,48 @@
"use client";
import { useMemo } from "react";
import { SimpleGrid, Stack } from "@mantine/core";
import Action from "@/app/actions/components/Action/Action";
import mobileButtonsData from "@/app/actions/data/mobileButtonsData";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
const PageBody = () => {
const { selectedProject, setSelectedProjectId, projects, modulesSet } =
useProjectsContext();
const filteredMobileButtonsData = useMemo(
() =>
mobileButtonsData.filter(
link => !link.moduleName || modulesSet.has(link.moduleName)
),
[modulesSet]
);
return (
<PageBlock fullScreenMobile>
<Stack p={"xs"}>
<ProjectSelect
onChange={project =>
setSelectedProjectId(project?.id ?? null)
}
value={selectedProject}
data={projects}
/>
<SimpleGrid
type={"container"}
cols={2}>
{filteredMobileButtonsData.map((data, index) => (
<Action
linkData={data}
key={index}
/>
))}
</SimpleGrid>
</Stack>
</PageBlock>
);
};
export default PageBody;

View 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
View 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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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>[],
[]
);
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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: () => "Удаление шаблона штрихкода",
});
};

View 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;

View File

@ -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;

View 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>
);
}

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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]
);
};

View 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;

View 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");

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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"
);

View File

@ -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;

View File

@ -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>[],
[]
);
};

View File

@ -0,0 +1,3 @@
import ClientMarketplaceDrawer from "./ClientMarketplacesDrawer";
export default ClientMarketplaceDrawer;

View File

@ -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;

View 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: () => "Удаление клиента",
});
};

View 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: () => "Удаление маркетплейса",
});
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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>
);
}

View File

@ -0,0 +1,5 @@
const isValidInn = (inn: string | null | undefined) => {
return inn && inn.match(/^(\d{12}|\d{10})$/);
};
export default isValidInn;

View File

@ -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;

View File

@ -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);
}

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1,76 @@
"use client";
import { IconChevronLeft, IconSettings } from "@tabler/icons-react";
import { Box, Group, Stack, Text } from "@mantine/core";
import Boards from "@/app/deals/components/shared/Boards/Boards";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { useDrawersContext } from "@/drawers/DrawersContext";
import useIsMobile from "@/hooks/utils/useIsMobile";
const MainBlockHeader = () => {
const { setSelectedProjectId, refetchProjects, selectedProject } =
useProjectsContext();
const { refetchBoards } = useBoardsContext();
const { openDrawer } = useDrawersContext();
const isMobile = useIsMobile();
const selectProjectId = async (projectId: number | null) => {
await refetchProjects();
setSelectedProjectId(projectId);
};
const openProjectsEditorDrawer = () => {
openDrawer({
key: "projectsMobileEditorDrawer",
props: {
onSelect: project => selectProjectId(project?.id ?? null),
},
});
};
const openBoardsEditorDrawer = () => {
if (!selectedProject) return;
openDrawer({
key: "boardsMobileEditorDrawer",
props: {
project: selectedProject,
},
onClose: refetchBoards,
});
};
return (
<Stack
gap={0}
w={"100%"}>
{isMobile && (
<Group justify={"space-between"}>
<Box
p={"md"}
onClick={openProjectsEditorDrawer}>
<IconChevronLeft />
</Box>
<Text>{selectedProject?.name}</Text>
<Box
p={"md"}
onClick={openBoardsEditorDrawer}>
<IconSettings />
</Box>
</Group>
)}
<Group
wrap={"nowrap"}
gap={0}
align={"end"}>
<Boards />
<Box
flex={1}
style={{ borderBottom: "2px solid gray" }}
/>
</Group>
</Stack>
);
};
export default MainBlockHeader;

View File

@ -0,0 +1,14 @@
.board {
min-width: 50px;
flex-wrap: nowrap;
gap: 3px;
border-top-left-radius: 15px;
border-top-right-radius: 15px;
border-bottom: 2px solid gray;
}
.board-selected {
border: 2px solid gray;
border-bottom: 0;
}

View File

@ -0,0 +1,67 @@
import React, { FC, useState } from "react";
import classNames from "classnames";
import { Box, Group, Text } from "@mantine/core";
import BoardMenu from "@/app/deals/components/shared/BoardMenu/BoardMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { BoardSchema } from "@/lib/client";
import styles from "./Board.module.css";
type Props = {
board: BoardSchema;
};
const Board: FC<Props> = ({ board }) => {
const { selectedBoard, boardsCrud } = useBoardsContext();
const isMobile = useIsMobile();
const [isHovered, setIsHovered] = useState(false);
return (
<Group
px={"md"}
py={"xs"}
className={classNames(
styles.board,
selectedBoard?.id === board.id && styles["board-selected"]
)}
justify={"space-between"}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}>
<InPlaceInput
value={board.name}
onChange={value =>
boardsCrud.onUpdate(board.id, { name: value })
}
inputStyles={{
input: {
height: 24,
minHeight: 24,
},
}}
getChildren={startEditing => (
<Group wrap={"nowrap"}>
<Box>
<Text style={{ textWrap: "nowrap" }}>
{board.name}
</Text>
</Box>
{!isMobile && (
<BoardMenu
isHovered={
selectedBoard?.id === board.id || isHovered
}
onDeleteBoard={boardsCrud.onDelete}
board={board}
startEditing={startEditing}
/>
)}
</Group>
)}
modalTitle={"Редактирование доски"}
/>
</Group>
);
};
export default Board;

View File

@ -0,0 +1,51 @@
import React, { FC } from "react";
import { IconDotsVertical, IconEdit, IconTrash } from "@tabler/icons-react";
import { Box, Menu } from "@mantine/core";
import DropdownMenuItem from "@/components/ui/DropdownMenuItem/DropdownMenuItem";
import ThemeIcon from "@/components/ui/ThemeIcon/ThemeIcon";
import { BoardSchema } from "@/lib/client";
type Props = {
board: BoardSchema;
startEditing: () => void;
onDeleteBoard: (board: BoardSchema) => void;
isHovered?: boolean;
};
const BoardMenu: FC<Props> = ({
board,
startEditing,
onDeleteBoard,
isHovered = true,
}) => {
return (
<Menu>
<Menu.Target>
<Box
style={{
opacity: isHovered ? 1 : 0,
cursor: "pointer",
}}
onClick={e => e.stopPropagation()}>
<ThemeIcon size={"sm"}>
<IconDotsVertical />
</ThemeIcon>
</Box>
</Menu.Target>
<Menu.Dropdown>
<DropdownMenuItem
onClick={startEditing}
icon={<IconEdit />}
label={"Переименовать"}
/>
<DropdownMenuItem
onClick={() => onDeleteBoard(board)}
icon={<IconTrash />}
label={"Удалить"}
/>
</Menu.Dropdown>
</Menu>
);
};
export default BoardMenu;

View File

@ -0,0 +1,8 @@
.container {
@media (min-width: 48em) {
max-width: calc(100vw - 210px - var(--mantine-spacing-md));
}
@media (max-width: 48em) {
max-width: 100vw;
}
}

View File

@ -0,0 +1,54 @@
"use client";
import React from "react";
import { Flex, ScrollArea } from "@mantine/core";
import Board from "@/app/deals/components/shared/Board/Board";
import CreateBoardButton from "@/app/deals/components/shared/CreateBoardButton/CreateBoardButton";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import SortableDnd from "@/components/dnd/SortableDnd";
import useHorizontalWheel from "@/hooks/utils/useHorizontalWheel";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { BoardSchema } from "@/lib/client";
import styles from "./Boards.module.css";
const Boards = () => {
const { boards, setSelectedBoardId, boardsCrud } = useBoardsContext();
const isMobile = useIsMobile();
const { ref, onWheel } = useHorizontalWheel<HTMLDivElement>();
const renderBoard = (board: BoardSchema) => <Board board={board} />;
const onDragEnd = (itemId: number, newLexorank: string) => {
boardsCrud.onUpdate(itemId, { lexorank: newLexorank });
};
const selectBoard = (board: BoardSchema) => {
setSelectedBoardId(board.id);
};
return (
<Flex
align={"end"}
className={styles.container}>
<ScrollArea
viewportRef={ref}
onWheel={onWheel}
offsetScrollbars={"x"}
scrollbars={"x"}
scrollbarSize={0}>
<SortableDnd
initialItems={boards}
renderItem={renderBoard}
onDragEnd={onDragEnd}
onItemClick={selectBoard}
containerStyle={{ flexWrap: "nowrap" }}
dragHandleStyle={{ cursor: "pointer" }}
disabled={isMobile}
/>
</ScrollArea>
<CreateBoardButton />
</Flex>
);
};
export default Boards;

View File

@ -0,0 +1,10 @@
.create-button {
padding: 10px 10px 9px;
cursor: pointer;
}
.spacer {
height: 45px;
width: 100%;
}

View File

@ -0,0 +1,39 @@
import { IconPlus } from "@tabler/icons-react";
import { Box, Flex, rem } from "@mantine/core";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import styles from "./CreateBoardButton.module.css";
const CreateBoardButton = () => {
const { boardsCrud } = useBoardsContext();
return (
<Flex style={{ borderBottom: "2px solid gray" }}>
<InPlaceInput
placeholder={"Название доски"}
onChange={name => boardsCrud.onCreate({ name })}
getChildren={startEditing => (
<Box
onClick={startEditing}
className={styles["create-button"]}>
<IconPlus />
</Box>
)}
inputStyles={{
wrapper: {
marginRight: "var(--mantine-spacing-xs)",
paddingBlock: rem(3),
paddingLeft: "var(--mantine-spacing-xs)",
backgroundColor:
"light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-6))",
borderTopLeftRadius: "var(--mantine-spacing-xs)",
borderTopRightRadius: "var(--mantine-spacing-xs)",
},
}}
modalTitle={"Создание доски"}
/>
</Flex>
);
};
export default CreateBoardButton;

View File

@ -0,0 +1,11 @@
.create-button {
cursor: pointer;
min-height: max-content;
@mixin light {
background-color: var(--color-light-white-blue);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}

View File

@ -0,0 +1,51 @@
import { useState } from "react";
import { IconPlus } from "@tabler/icons-react";
import { Card, Center, Group, Text, Transition } from "@mantine/core";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import CreateCardForm, { CreateDealForm } from "./components/CreateCardForm";
import styles from "./CreateDealButton.module.css";
const CreateCardButton = () => {
const [isCreating, setIsCreating] = useState(false);
const [isTransitionEnded, setIsTransitionEnded] = useState(true);
const { dealsCrud } = useDealsContext();
const onSubmit = (values: CreateDealForm) => {
dealsCrud.onCreate(values);
setIsCreating(prevState => !prevState);
setIsTransitionEnded(false);
};
return (
<Card
className={styles["create-button"]}
onClick={() => {
if (isCreating) return;
setIsCreating(prevState => !prevState);
setIsTransitionEnded(false);
}}>
{!isCreating && isTransitionEnded && (
<Center>
<Group gap={"xs"}>
<IconPlus />
<Text>Добавить</Text>
</Group>
</Center>
)}
<Transition
mounted={isCreating}
transition={"scale-y"}
onExited={() => setIsTransitionEnded(true)}>
{styles => (
<div style={styles}>
<CreateCardForm
onCancel={() => setIsCreating(false)}
onSubmit={onSubmit}
/>
</div>
)}
</Transition>
</Card>
);
};
export default CreateCardButton;

View File

@ -0,0 +1,77 @@
import { FC } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { ClientSchema } from "@/lib/client";
import ClientSelect from "@/modules/dealModularEditorTabs/Clients/components/ClientSelect";
import { ModuleNames } from "@/modules/modules";
export type CreateDealForm = {
name: string;
client?: ClientSchema;
clientId?: number;
};
type Props = {
onSubmit: (values: CreateDealForm) => void;
onCancel: () => void;
};
const CreateCardForm: FC<Props> = ({ onSubmit, onCancel }) => {
const { modulesSet } = useProjectsContext();
const form = useForm<CreateDealForm>({
initialValues: {
name: "",
},
validate: {
name: value => !value && "Введите название",
client: client =>
modulesSet.has(ModuleNames.CLIENTS) &&
!client &&
"Выберите клиента",
},
});
return (
<form
onSubmit={form.onSubmit(values => {
onSubmit(values);
form.reset();
})}>
<Stack>
<TextInput
placeholder={"Название"}
{...form.getInputProps("name")}
/>
{modulesSet.has(ModuleNames.CLIENTS) && (
<ClientSelect
placeholder={"Клиент"}
{...form.getInputProps("client")}
onChange={client => {
form.setFieldValue("client", client);
form.setFieldValue("clientId", client?.id);
}}
/>
)}
<Group wrap={"nowrap"}>
<Button
variant={"default"}
w={"100%"}
onClick={onCancel}>
<IconX />
</Button>
<Button
variant={"default"}
w={"100%"}
type={"submit"}>
<IconCheck />
</Button>
</Group>
</Stack>
</form>
);
};
export default CreateCardForm;

View File

@ -0,0 +1,23 @@
.container {
cursor: pointer;
height: 100%;
width: fit-content;
@media (max-width: 48em) {
width: 80vw;
height: 73.5vh;
}
}
.inner-container {
border-radius: var(--mantine-spacing-md);
flex-wrap: nowrap;
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}

View File

@ -0,0 +1,51 @@
import React from "react";
import { IconPlus } from "@tabler/icons-react";
import { Box, Center, Group, Text } from "@mantine/core";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import useIsMobile from "@/hooks/utils/useIsMobile";
import styles from "./CreateStatusButton.module.css";
const CreateStatusButton = () => {
const { statusesCrud } = useStatusesContext();
const isMobile = useIsMobile();
return (
<Box className={styles.container}>
<Box
p={isMobile ? "sm" : 0}
className={styles["inner-container"]}>
<InPlaceInput
placeholder={"Название колонки"}
onChange={name => statusesCrud.onCreate({ name })}
getChildren={startEditing => (
<Center
p={"sm"}
onClick={() => startEditing()}>
<Group
gap={"xs"}
wrap={"nowrap"}
align={"center"}>
<IconPlus />
{isMobile && <Text>Добавить</Text>}
</Group>
</Center>
)}
modalTitle={"Создание колонки"}
inputStyles={{
wrapper: {
width: 250,
paddingInline: "var(--mantine-spacing-md)",
paddingBlock: "var(--mantine-spacing-xs)",
},
input: {
width: 250,
},
}}
/>
</Box>
</Box>
);
};
export default CreateStatusButton;

View File

@ -0,0 +1,46 @@
.container {
flex: 1;
padding: 0;
@mixin light {
background-color: var(--color-light-white-blue);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.deal-data {
padding: var(--mantine-spacing-xs);
gap: var(--mantine-spacing-xs);
}
.deal-id {
border-top-right-radius: var(--mantine-radius-md);
border-bottom-left-radius: var(--mantine-radius-md);
padding-inline: var(--mantine-spacing-xs);
@mixin light {
background-color: lightblue;
}
@mixin dark {
background-color: var(--color-dark);
}
}
.first-tag {
@mixin light {
background-color: lightblue;
}
@mixin dark {
background-color: darkslateblue;
}
}
.second-tag {
@mixin light {
background-color: lightgray;
}
@mixin dark {
background-color: var(--mantine-color-dark-4);
}
}

View File

@ -0,0 +1,73 @@
import { Box, Card, Group, Pill, Stack, Text } from "@mantine/core";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { useDrawersContext } from "@/drawers/DrawersContext";
import { DealSchema } from "@/lib/client";
import { ModuleNames } from "@/modules/modules";
import styles from "./DealCard.module.css";
type Props = {
deal: DealSchema;
};
const DealCard = ({ deal }: Props) => {
const { selectedProject, modulesSet } = useProjectsContext();
const { dealsCrud, refetchDeals } = useDealsContext();
const { openDrawer } = useDrawersContext();
const onClick = () => {
openDrawer({
key: "dealEditorDrawer",
props: {
value: deal,
onChange: deal => dealsCrud.onUpdate(deal.id, deal),
onDelete: dealsCrud.onDelete,
project: selectedProject,
},
onClose: refetchDeals,
});
};
return (
<Card
onClick={onClick}
className={styles.container}>
<Group
justify={"space-between"}
wrap={"nowrap"}
pl={"xs"}
gap={"xs"}
align={"start"}>
<Text
c={"dodgerblue"}
mt={"xs"}>
{deal.name}
</Text>
<Box className={styles["deal-id"]}>
<Text style={{ textWrap: "nowrap" }}>ID: {deal.id}</Text>
</Box>
</Group>
<Stack className={styles["deal-data"]}>
<Stack gap={0}>
{modulesSet.has(ModuleNames.CLIENTS) && (
<Text>{deal.client?.name}</Text>
)}
{modulesSet.has(ModuleNames.FULFILLMENT_BASE) && (
<>
<Text key={"price"}>{deal.totalPrice} руб.</Text>
<Text key={"count"}>
{deal.productsQuantity} тов.
</Text>
</>
)}
</Stack>
<Group gap={"xs"}>
<Pill className={styles["first-tag"]}>Срочно</Pill>
<Pill className={styles["second-tag"]}>Бесплатно</Pill>
</Group>
</Stack>
</Card>
);
};
export default DealCard;

View File

@ -0,0 +1,89 @@
import { FC, useEffect, useState } from "react";
import { IconGripHorizontal } from "@tabler/icons-react";
import { Flex, rem, TextInput, useMantineColorScheme } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import FulfillmentGroupInfo from "@/app/deals/components/shared/DealGroupCard/components/FulfillmentGroupInfo";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import { notifications } from "@/lib/notifications";
import { ModuleNames } from "@/modules/modules";
import GroupWithDealsSchema from "@/types/GroupWithDealsSchema";
type Props = {
group: GroupWithDealsSchema;
};
const DealGroupCard: FC<Props> = ({ group }) => {
const theme = useMantineColorScheme();
const [name, setName] = useState<string>(group.name ?? "");
const [debouncedName] = useDebouncedValue(name, 200);
const { modulesSet } = useProjectsContext();
const isServicesAndProductsIncluded = modulesSet.has(
ModuleNames.FULFILLMENT_BASE
);
const updateName = () => {
if (debouncedName === group.name) return;
CardGroupService.updateCardGroup({
requestBody: {
data: {
...group,
name: debouncedName,
},
},
}).then(response => {
if (response.ok) return;
setName(group.name || "");
notifications.guess(response.ok, { message: response.message });
});
};
useEffect(() => {
updateName();
}, [debouncedName]);
return (
<Flex
style={{
border: "dashed var(--item-border-size) var(--mantine-color-default-border)",
borderRadius: "0.5rem",
}}
p={rem(5)}
py={"xs"}
bg={
theme.colorScheme === "dark"
? "var(--mantine-color-dark-5)"
: "var(--mantine-color-gray-1)"
}
gap={"xs"}
direction={"column"}>
<Flex
justify={"space-between"}
align={"center"}
gap={"xs"}
px={"xs"}>
<TextInput
value={name}
onChange={event => setName(event.currentTarget.value)}
variant={"unstyled"}
/>
<IconGripHorizontal />
</Flex>
<Flex
direction={"column"}
gap={"xs"}>
{group.deals?.map(deal => (
<DealCard
key={deal.id}
deal={deal}
/>
))}
</Flex>
{isServicesAndProductsIncluded && (
<FulfillmentGroupInfo group={group} />
)}
</Flex>
);
};
export default DealGroupCard;

View File

@ -0,0 +1,51 @@
import { Flex, Text, useMantineColorScheme } from "@mantine/core";
import { FC, useMemo } from "react";
import { DealGroupSchema } from "@/lib/client";
type Props = {
group: DealGroupSchema;
}
const FulfillmentGroupInfo: FC<Props> = ({ group }) => {
const theme = useMantineColorScheme();
const totalPrice = useMemo(
() =>
group.deals?.reduce((acc, deal) => acc + (deal.totalPrice ?? 0), 0),
[group.deals]
);
const totalProducts = useMemo(
() =>
group.deals?.reduce(
(acc, deal) => acc + (deal.productsQuantity ?? 0),
0
),
[group.deals]
);
return (
<Flex
p={"xs"}
direction={"column"}
bg={
theme.colorScheme === "dark"
? "var(--mantine-color-dark-6)"
: "var(--mantine-color-gray-2)"
}
style={{ borderRadius: "0.5rem" }}>
<Text
c={"gray.6"}
size={"xs"}>
Сумма: {totalPrice?.toLocaleString("ru-RU")} руб.
</Text>
<Text
c={"gray.6"}
size={"xs"}>
Всего товаров: {totalProducts?.toLocaleString("ru-RU")}{" "}
шт.
</Text>
</Flex>
)
}
export default FulfillmentGroupInfo;

View File

@ -0,0 +1,94 @@
import { FC, useCallback } from "react";
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Pagination, Stack, Text } from "@mantine/core";
import useDealsTableColumns from "@/app/deals/components/shared/DealsTable/useDealsTableColumns";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import { useDrawersContext } from "@/drawers/DrawersContext";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema } from "@/lib/client";
const DealsTable: FC = () => {
const isMobile = useIsMobile();
const { selectedProject, modulesSet } = useProjectsContext();
const {
deals,
paginationInfo,
page,
setPage,
sortingForm,
dealsCrud,
refetchDeals,
} = useDealsContext();
const { openDrawer } = useDrawersContext();
const onEditClick = useCallback(
(deal: DealSchema) => {
openDrawer({
key: "dealEditorDrawer",
props: {
value: deal,
onChange: deal => dealsCrud.onUpdate(deal.id, deal),
onDelete: dealsCrud.onDelete,
project: selectedProject,
},
onClose: refetchDeals,
});
},
[openDrawer, dealsCrud]
);
const columns = useDealsTableColumns({ onEditClick, modulesSet });
return (
<Stack
p={isMobile ? "xs" : ""}
gap={"xs"}
h={"100%"}>
<BaseTable
withTableBorder
records={[...deals]}
columns={columns}
sortStatus={{
columnAccessor: sortingForm.values.sortingField ?? "",
direction: sortingForm.values.sortingDirection,
}}
onSortStatusChange={sorting => {
sortingForm.setFieldValue(
"sortingField",
sorting.columnAccessor
);
sortingForm.setFieldValue(
"sortingDirection",
sorting.direction
);
}}
emptyState={
<Group
align={"center"}
gap={"xs"}>
<Text>Нет сделок</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
style={{
height: "100%",
}}
/>
{paginationInfo && paginationInfo.totalPages > 1 && (
<Group justify={"flex-end"}>
<Pagination
withEdges
total={paginationInfo.totalPages}
value={page}
onChange={setPage}
/>
</Group>
)}
</Stack>
);
};
export default DealsTable;

View File

@ -0,0 +1,70 @@
import { useMemo } from "react";
import { IconEdit } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { DealSchema } from "@/lib/client";
import { ModuleNames } from "@/modules/modules";
import { utcDateTimeToLocalString } from "@/utils/datetime";
type Props = {
onEditClick: (deal: DealSchema) => void;
modulesSet: Set<ModuleNames>;
};
const useDealsTableColumns = ({ onEditClick, modulesSet }: Props) => {
const isMobile = useIsMobile();
return useMemo(
() =>
[
{
accessor: "actions",
title: isMobile ? "" : "Действия",
sortable: false,
textAlign: "center",
width: "0%",
render: deal => (
<ActionIconWithTip
tipLabel={"Редактировать"}
onClick={() => onEditClick(deal)}
variant={isMobile ? "subtle" : "default"}>
<IconEdit />
</ActionIconWithTip>
),
},
{
accessor: "id",
title: isMobile ? "№" : "Номер",
sortable: true,
},
{
accessor: "name",
title: "Название",
},
{
title: "Дата создания",
accessor: "createdAt",
render: deal => utcDateTimeToLocalString(deal.createdAt),
sortable: true,
},
{
title: "Клиент",
accessor: "client.name",
hidden: !modulesSet.has(ModuleNames.CLIENTS),
},
{
title: "Общая стоимость",
accessor: "totalPrice",
render: deal =>
deal.totalPrice
? `${deal.totalPrice.toLocaleString("ru")}`
: "0₽",
hidden: !modulesSet.has(ModuleNames.FULFILLMENT_BASE),
},
] as DataTableColumn<DealSchema>[],
[onEditClick]
);
};
export default useDealsTableColumns;

View File

@ -0,0 +1,69 @@
"use client";
import React, { FC } from "react";
import { Box } from "@mantine/core";
import DealCard from "@/app/deals/components/shared/DealCard/DealCard";
import StatusColumnHeader from "@/app/deals/components/shared/StatusColumnHeader/StatusColumnHeader";
import { useDealsContext } from "@/app/deals/contexts/DealsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import DndFunnel from "@/components/dnd-pragmatic/DndFunnel/DndFunnel";
import { sortByLexorank } from "@/utils/lexorank/sort";
const Funnel: FC = () => {
const { statuses, setStatuses, statusesCrud } = useStatusesContext();
const { dealsWithoutGroup, groupsWithDeals, deals, setDeals, dealsCrud } =
useDealsContext();
const updateStatus = (statusId: number, lexorank: string) => {
setStatuses(
statuses.map(status =>
status.id === statusId ? { ...status, lexorank } : status
)
);
statusesCrud.onUpdate(statusId, { lexorank });
};
const updateDeal = (dealId: number, lexorank: string, statusId: number) => {
const status = statuses.find(s => s.id === statusId);
if (!status) return;
setDeals(
deals.map(deal =>
deal.id === dealId ? { ...deal, lexorank, status } : deal
)
);
dealsCrud.onUpdate(dealId, { lexorank, statusId });
};
return (
<DndFunnel
columns={statuses}
updateColumn={updateStatus}
items={dealsWithoutGroup}
groups={groupsWithDeals}
updateItem={updateDeal}
getColumnItemsGroups={statusId =>
sortByLexorank([
...dealsWithoutGroup.filter(d => d.status.id === statusId),
...groupsWithDeals.filter(
g =>
g.items.length > 0 &&
g.items[0].status.id === statusId
),
])
}
renderColumnHeader={status => (
<StatusColumnHeader status={status} />
)}
renderItem={deal => (
<DealCard
key={deal.id}
deal={deal}
/>
)}
renderGroup={group => <Box flex={1}>{group.name}</Box>}
/>
);
};
export default Funnel;

View File

@ -0,0 +1,59 @@
"use client";
import { Flex } from "@mantine/core";
import TopToolPanel from "@/app/deals/components/desktop/TopToolPanel/TopToolPanel";
import {
BoardView,
ScheduleView,
TableView,
} from "@/app/deals/components/shared/views";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { DealsContextProvider } from "@/app/deals/contexts/DealsContext";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import useView from "@/app/deals/hooks/useView";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
const PageBody = () => {
const { selectedBoard } = useBoardsContext();
const { selectedProject } = useProjectsContext();
const { view, setView } = useView();
const getViewContent = () => {
switch (view) {
case "board":
return <BoardView />;
case "table":
return <TableView />;
default:
return <ScheduleView />;
}
};
const getContextProps = () => {
if (view === "table") {
return { withPagination: true, projectId: selectedProject?.id };
}
return { boardId: selectedBoard?.id };
};
return (
<DealsContextProvider {...getContextProps()}>
<TopToolPanel
view={view}
setView={setView}
/>
<PageBlock
fullScreenMobile
style={{ flex: 1 }}>
<Flex
direction={"column"}
h={"100%"}>
{getViewContent()}
</Flex>
</PageBlock>
</DealsContextProvider>
);
};
export default PageBody;

View File

@ -0,0 +1,64 @@
import React, { FC } from "react";
import { Group, Text } from "@mantine/core";
import StatusMenu from "@/app/deals/components/shared/StatusMenu/StatusMenu";
import { useBoardsContext } from "@/app/deals/contexts/BoardsContext";
import { useStatusesContext } from "@/app/deals/contexts/StatusesContext";
import InPlaceInput from "@/components/ui/InPlaceInput/InPlaceInput";
import { StatusSchema } from "@/lib/client";
type Props = {
status: StatusSchema;
};
const StatusColumnHeader: FC<Props> = ({ status }) => {
const { statusesCrud, refetchStatuses } = useStatusesContext();
const { selectedBoard } = useBoardsContext();
const handleSave = (value: string) => {
const newValue = value.trim();
if (newValue && newValue !== status.name) {
statusesCrud.onUpdate(status.id, { name: newValue });
}
};
return (
<Group
justify={"space-between"}
p={"sm"}
wrap={"nowrap"}
mb={"xs"}
w={"100%"}
style={{
borderBottom: `solid ${status.color} 3px`,
}}>
<InPlaceInput
value={status.name}
onChange={value => handleSave(value)}
inputStyles={{
input: {
height: 25,
minHeight: 25,
},
}}
getChildren={startEditing => (
<>
<Text>{status.name}</Text>
<StatusMenu
board={selectedBoard}
status={status}
handleEdit={startEditing}
onStatusColorChange={color =>
statusesCrud.onUpdate(status.id, { color })
}
refetchStatuses={refetchStatuses}
onDeleteStatus={statusesCrud.onDelete}
/>
</>
)}
modalTitle={"Редактирование статуса"}
/>
</Group>
);
};
export default StatusColumnHeader;

View File

@ -0,0 +1,23 @@
.container {
height: calc(100vh - 215px);
@media (max-width: 48em) {
width: 80vw;
}
}
.inner-container {
border-radius: var(--mantine-spacing-lg);
gap: 0;
@media (max-width: 48em) {
max-height: 100%;
}
@mixin light {
background-color: var(--color-light-aqua);
}
@mixin dark {
background-color: var(--mantine-color-dark-6);
}
}

View File

@ -0,0 +1,43 @@
import React, { ReactNode } from "react";
import { Box, ScrollArea, Stack } from "@mantine/core";
import CreateCardButton from "@/app/deals/components/shared/CreateDealButton/CreateDealButton";
import { StatusSchema } from "@/lib/client";
import styles from "./StatusColumnWrapper.module.css";
type Props = {
status: StatusSchema;
renderHeader: () => ReactNode;
children: ReactNode;
createFormEnabled?: boolean;
};
const StatusColumnWrapper = ({
renderHeader,
children,
createFormEnabled = false,
}: Props) => {
return (
<Box className={styles.container}>
<Stack
px={"xs"}
pb={"xs"}
className={styles["inner-container"]}>
{renderHeader()}
<ScrollArea
offsetScrollbars={"present"}
scrollbarSize={10}
type={"always"}
scrollbars={"y"}>
<Stack
gap={"xs"}
mah={"calc(100vh - 285px)"}>
{createFormEnabled && <CreateCardButton />}
{children}
</Stack>
</ScrollArea>
</Stack>
</Box>
);
};
export default StatusColumnWrapper;

Some files were not shown because too many files have changed in this diff Show More