256 Commits

Author SHA1 Message Date
44b89245f0 fix: added react-dom dep 2025-11-07 11:28:15 +04:00
b0eab6cce7 feat: create first project modal 2025-11-07 10:13:32 +04:00
1a1f584b81 feat: header in module editor page for mobiles 2025-11-06 12:23:54 +04:00
cc910c8495 feat: modules page and module editor page for mobiles 2025-11-06 12:09:27 +04:00
d3270a3532 feat: dnd for options in select editor 2025-11-05 20:52:08 +04:00
38ae35795b fix: fixed attributes page header 2025-11-04 16:22:44 +04:00
3b1aca2df1 fix: product view barcodes display fixed 2025-11-04 16:05:11 +04:00
4d3264a92f fix: fixed options editor 2025-11-04 12:51:06 +04:00
33dd1e1c0f feat: attribute selects and options editors 2025-11-04 12:19:44 +04:00
311210394f fix: fixed showing default option label 2025-11-03 10:42:13 +04:00
03be3903cb feat: attributes page 2025-11-02 16:07:49 +04:00
8020561da6 fix: showing only unused attributes in the left part of module editor 2025-11-02 12:39:08 +04:00
a383f218f1 refactor: refactored useAttributesActions hook 2025-11-02 12:21:55 +04:00
fd5e878c29 fix: fixed deal module attributes refetch after module editing 2025-11-02 11:26:17 +04:00
e8ffafa6c5 fix: fixed attribute creating 2025-11-01 23:17:43 +04:00
95cc7f6259 fix: fixed height of swiper on mobiles 2025-11-01 16:05:57 +04:00
69bf7848e1 feat: deal barcodes printing, refactored ff module 2025-11-01 14:15:11 +04:00
4370684c58 fix: fixed ui of deal ff info editor 2025-11-01 09:46:46 +04:00
2948189291 feat: ff deal attributes editor 2025-10-31 20:54:04 +04:00
ee90ebe0f0 fix: fixed inner attr table header overlaying on outer table header 2025-10-31 17:52:57 +04:00
418e4b6b63 fix: fixed ActionIcon bg for mobiles 2025-10-31 17:49:36 +04:00
3355c73b8b fix: fixed statuses mobile editor 2025-10-31 17:45:14 +04:00
07736ee374 feat: mobile drawer editors ui enhancement 2025-10-31 17:32:20 +04:00
42f1c75603 fix: scrollable table in tags table for mobiles 2025-10-31 16:04:23 +04:00
4342ab17ed fix: try-catch in prefetching function for correct building 2025-10-30 20:58:43 +04:00
e9c9f6b65d fix: fixed client utils generation 2025-10-30 20:52:24 +04:00
d38454f3af fix: added dayjs pkg 2025-10-30 20:05:27 +04:00
0c2fca2c23 fix: product barcode image showing 2025-10-30 15:41:13 +04:00
9207bd915d fix: left only button when deal services table is empty on mobiles 2025-10-30 14:04:48 +04:00
da769fa2c0 refactor: refactored product services table 2025-10-30 13:52:57 +04:00
4cc6360bb4 feat: deal attributes with select and options 2025-10-29 19:36:58 +04:00
8019fa566c feat: setting default attributes after deal creating 2025-10-28 17:21:21 +04:00
ea6a6df371 fix: applied timezone to default values, removed value nesting 2025-10-28 11:43:13 +04:00
3575b9f34a fix: removed attr is_shown_on_dashboard 2025-10-27 17:31:48 +04:00
71c9d08b8c fix: removed unused dayjs 2025-10-27 10:06:08 +04:00
d4c0eac4a0 feat: deal attributes editing 2025-10-27 10:05:29 +04:00
e39df47520 refactor: refactored products utils 2025-10-25 19:35:00 +04:00
d9da3d1bc5 feat: product info in product select 2025-10-25 19:25:58 +04:00
5b754865cf feat: modules creation 2025-10-25 18:05:49 +04:00
2bdbebc453 feat: modules and module-editor pages 2025-10-25 12:11:14 +04:00
57a7ab0871 refactor: renamed built_in_modules into modules 2025-10-21 12:34:42 +04:00
4d5723bc72 feat: product barcode images 2025-10-21 11:10:27 +04:00
82f08b4f83 feat: product images upload and display 2025-10-20 16:13:05 +04:00
8cc11bca67 feat: barcodes display and printing in deal editor 2025-10-19 21:00:38 +04:00
e44691d118 feat: tags page for mobiles 2025-10-19 20:47:53 +04:00
3a1d8e23e3 feat: deal tags 2025-10-19 12:12:28 +04:00
9023b07c65 refactor: moved ProjectEditorDrawer into common drawers directory 2025-10-18 13:06:37 +04:00
159d6948c7 feat: groups creating on mobile 2025-10-18 10:23:13 +04:00
f90b335ee1 feat: context menu for deal groups 2025-10-18 00:33:12 +04:00
5e59d54011 feat: fixed group name input 2025-10-18 00:26:31 +04:00
f117605ea3 feat: routers client and version prefixes 2025-10-17 21:41:20 +04:00
30bc7bbee4 feat: creating and updating groups 2025-10-17 19:47:47 +04:00
daa9d12983 fix: fixed swiping slider during holding group 2025-10-17 12:02:07 +04:00
0fe41656f8 feat: displaying and sorting groups of deals 2025-10-17 11:52:19 +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
515 changed files with 50116 additions and 2242 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"
}

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

2
.stylelintignore Normal file
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,
},
}
);

25
fix-client.ts Normal file
View File

@ -0,0 +1,25 @@
import * as fs from "fs";
const zodPath = "src/lib/client/zod.gen.ts";
let content = fs.readFileSync(zodPath, "utf8");
// Replace only for the upload schema
const zodTarget = "upload_file: z.string";
while (content.includes(zodTarget)) {
content = content.replace(zodTarget, "upload_file: z.any");
}
fs.writeFileSync(zodPath, content);
console.log("✅ Fixed zod schema for upload_file");
const utilsPath = "src/lib/client/client/utils.ts";
content = fs.readFileSync(utilsPath, "utf8");
const utilsTarget = "@ts-expect-error";
while (content.includes(utilsTarget)) {
content = content.replace(utilsTarget, "@ts-ignore");
}
content = content.replace(
"...(mergedHeaders[key] ?? []),",
"...(mergedHeaders[key] ?? []) as any,"
);
fs.writeFileSync(utilsPath, content);
console.log("✅ Fixed utils.ts");

16
jest.config.cjs Normal file
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

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

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,71 @@
"use client";
import { RefObject, useMemo, useRef } from "react";
import { IconApps, IconList, IconTag } from "@tabler/icons-react";
import { SimpleGrid, Stack } from "@mantine/core";
import Action from "@/app/actions/components/Action/Action";
import mobileButtonsData from "@/app/actions/data/mobileButtonsData";
import { useProjectsContext } from "@/app/deals/contexts/ProjectsContext";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
import ProjectSelect from "@/components/selects/ProjectSelect/ProjectSelect";
import BuiltInLinkData from "@/types/BuiltInLinkData";
const PageBody = () => {
const { selectedProject, setSelectedProjectId, projects, modulesSet } =
useProjectsContext();
const filteredMobileButtonsData = useMemo(
() =>
mobileButtonsData.filter(
link => !link.moduleName || modulesSet.has(link.moduleName)
),
[modulesSet]
);
const commonActionsData: RefObject<BuiltInLinkData[]> = useRef([
{
icon: IconList,
label: "Атрибуты",
href: "/attributes",
},
{
icon: IconApps,
label: "Модули",
href: "/modules",
},
{
icon: IconTag,
label: "Теги",
href: "/tags",
},
]);
return (
<PageBlock fullScreenMobile>
<Stack p={"xs"}>
<ProjectSelect
onChange={project =>
setSelectedProjectId(project?.id ?? null)
}
value={selectedProject}
data={projects}
/>
<SimpleGrid
type={"container"}
cols={2}>
{[
...commonActionsData.current,
...filteredMobileButtonsData,
].map((data, index) => (
<Action
linkData={data}
key={index}
/>
))}
</SimpleGrid>
</Stack>
</PageBlock>
);
};
export default PageBody;

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,27 @@
import { FC } from "react";
import AttributePageView from "@/app/attributes/types/view";
import BaseSegmentedControl, {
BaseSegmentedControlProps,
} from "@/components/ui/BaseSegmentedControl/BaseSegmentedControl";
type Props = Omit<BaseSegmentedControlProps<AttributePageView>, "data">;
const data = [
{
label: "Аттрибуты",
value: AttributePageView.ATTRIBUTES,
},
{
label: "Справочники",
value: AttributePageView.SELECTS,
},
];
const AttrViewSegmentedControl: FC<Props> = props => (
<BaseSegmentedControl
data={data}
{...props}
/>
);
export default AttrViewSegmentedControl;

View File

@ -0,0 +1,87 @@
"use client";
import { Dispatch, FC, SetStateAction, useMemo } from "react";
import { Divider, Flex, Group, TextInput } from "@mantine/core";
import AttrViewSegmentedControl from "@/app/attributes/components/AttrViewSegmentedControl";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import AttributePageView from "@/app/attributes/types/view";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
import useIsMobile from "@/hooks/utils/useIsMobile";
type Props = {
view: AttributePageView;
setView: Dispatch<SetStateAction<AttributePageView>>;
};
const AttributesHeader: FC<Props> = ({ view, setView }) => {
const {
attributesActions,
selectsActions,
attrSearch,
setAttrSearch,
selectSearch,
setSelectSearch,
} = useAttributesContext();
const isMobile = useIsMobile();
const attributeActions = useMemo(
() => (
<Group wrap={"nowrap"}>
<InlineButton
onClick={attributesActions.onCreate}
w={isMobile ? "100%" : "auto"}>
Создать атрибут
</InlineButton>
<TextInput
value={attrSearch}
onChange={e => setAttrSearch(e.currentTarget.value)}
w={isMobile ? "100%" : "auto"}
placeholder={"Поиск..."}
/>
</Group>
),
[isMobile, attrSearch]
);
const selectActions = useMemo(
() => (
<Group wrap={"nowrap"}>
<InlineButton
onClick={selectsActions.onCreate}
w={isMobile ? "100%" : "auto"}>
Создать справочник
</InlineButton>
<TextInput
value={selectSearch}
onChange={e => setSelectSearch(e.currentTarget.value)}
w={isMobile ? "100%" : "auto"}
placeholder={"Поиск..."}
/>
</Group>
),
[isMobile, selectSearch]
);
return (
<Flex
wrap={"nowrap"}
gap={"xs"}
align={isMobile ? "unset" : "center"}
mt={isMobile ? "xs" : ""}
mx={isMobile ? "xs" : ""}
direction={isMobile ? "column-reverse" : "row"}
justify={"space-between"}>
{view === AttributePageView.ATTRIBUTES
? attributeActions
: selectActions}
{isMobile && <Divider />}
<AttrViewSegmentedControl
value={view}
onChange={setView}
styles={{ root: { margin: 0, padding: 0 } }}
/>
</Flex>
);
};
export default AttributesHeader;

View File

@ -0,0 +1,40 @@
"use client";
import { FC } from "react";
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import useAttributesTableColumns from "@/app/attributes/hooks/useAttributesTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const AttributesTable: FC = () => {
const isMobile = useIsMobile();
const { attributes } = useAttributesContext();
const columns = useAttributesTableColumns();
return (
<BaseTable
withTableBorder
columns={columns}
records={attributes}
verticalSpacing={"md"}
emptyState={
<Group mt={attributes.length === 0 ? "xl" : 0}>
<Text>Нет атрибутов</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
styles={{
table: {
width: "100%",
},
header: { zIndex: 1 },
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default AttributesTable;

View File

@ -0,0 +1,42 @@
"use client";
import { useState } from "react";
import AttributesHeader from "@/app/attributes/components/AttributesHeader";
import AttributesTable from "@/app/attributes/components/AttributesTable";
import SelectsTable from "@/app/attributes/components/SelectsTable";
import AttributePageView from "@/app/attributes/types/view";
import PageBlock from "@/components/layout/PageBlock/PageBlock";
const PageBody = () => {
const [view, setView] = useState<AttributePageView>(
AttributePageView.ATTRIBUTES
);
return (
<PageBlock
style={{ flex: 1, minHeight: 0 }}
fullScreenMobile>
<div
style={{
height: "100%",
display: "flex",
flexDirection: "column",
gap: "var(--mantine-spacing-md)",
}}>
<AttributesHeader
view={view}
setView={setView}
/>
<div style={{ flex: 1, overflow: "auto" }}>
{view === AttributePageView.ATTRIBUTES ? (
<AttributesTable />
) : (
<SelectsTable />
)}
</div>
</div>
</PageBlock>
);
};
export default PageBody;

View File

@ -0,0 +1,36 @@
import { IconMoodSad } from "@tabler/icons-react";
import { Group, Text } from "@mantine/core";
import { useAttributesContext } from "@/app/attributes/contexts/AttributesContext";
import useSelectsTableColumns from "@/app/attributes/hooks/useSelectsTableColumns";
import BaseTable from "@/components/ui/BaseTable/BaseTable";
import useIsMobile from "@/hooks/utils/useIsMobile";
const SelectsTable = () => {
const { selects } = useAttributesContext();
const isMobile = useIsMobile();
const columns = useSelectsTableColumns();
return (
<BaseTable
withTableBorder
columns={columns}
records={selects}
verticalSpacing={"md"}
emptyState={
<Group mt={selects.length === 0 ? "xl" : 0}>
<Text>Нет справочников</Text>
<IconMoodSad />
</Group>
}
groups={undefined}
styles={{
table: {
width: "100%",
},
}}
mx={isMobile ? "xs" : 0}
/>
);
};
export default SelectsTable;

View File

@ -0,0 +1,62 @@
"use client";
import { Dispatch, SetStateAction } from "react";
import useFilteredAttributes from "@/app/attributes/hooks/useFilteredAttributes";
import useSelectsActions, {
SelectsActions,
} from "@/app/attributes/hooks/useSelectsActions";
import useAttributesActions, {
AttributesActions,
} from "@/app/module-editor/[moduleId]/hooks/useAttributesActions";
import useAttributesList from "@/app/module-editor/[moduleId]/hooks/useAttributesList";
import useAttrSelectsList from "@/hooks/lists/useAttrSelectsList";
import { AttributeSchema, AttrSelectSchema } from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
import useFilteredSelects from "@/app/attributes/hooks/useFilteredSelects";
type AttributesContextState = {
attributes: AttributeSchema[];
attributesActions: AttributesActions;
attrSearch: string;
setAttrSearch: Dispatch<SetStateAction<string>>;
selects: AttrSelectSchema[];
selectsActions: SelectsActions;
selectSearch: string;
setSelectSearch: Dispatch<SetStateAction<string>>;
};
const useAttributesContextState = (): AttributesContextState => {
const { attributes, refetch: refetchAttributes } = useAttributesList();
const attributesActions = useAttributesActions({
refetchAttributes,
});
const {
search: attrSearch,
setSearch: setAttrSearch,
filteredAttributes,
} = useFilteredAttributes({ attributes });
const { selects, queryKey } = useAttrSelectsList();
const selectsActions = useSelectsActions({ queryKey });
const {
search: selectSearch,
setSearch: setSelectSearch,
filteredSelects,
} = useFilteredSelects({ selects });
return {
attributes: filteredAttributes,
attributesActions,
attrSearch,
setAttrSearch,
selects: filteredSelects,
selectsActions,
selectSearch,
setSelectSearch,
};
};
export const [AttributesContextProvider, useAttributesContext] =
makeContext<AttributesContextState>(useAttributesContextState, "Attribute");

View File

@ -0,0 +1,49 @@
"use client";
import React, { FC } from "react";
import { Drawer } from "@mantine/core";
import EditorBody from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/EditorBody";
import { SelectEditorContextProvider } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import { DrawerProps } from "@/drawers/types";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttrSelectSchema, UpdateAttrSelectSchema } from "@/lib/client";
type Props = {
select: AttrSelectSchema;
onSelectChange: (
values: UpdateAttrSelectSchema,
onSuccess: () => void
) => void;
};
const AttrSelectEditorDrawer: FC<DrawerProps<Props>> = ({
onClose,
opened,
props,
}) => {
const isMobile = useIsMobile();
return (
<Drawer
size={isMobile ? "100%" : "30%"}
title={"Редактирование справочника"}
position={"left"}
onClose={onClose}
removeScrollProps={{ allowPinchZoom: true }}
withCloseButton
opened={opened}
trapFocus={false}
styles={{
body: {
display: "flex",
flexDirection: "column",
},
}}>
<SelectEditorContextProvider {...props}>
<EditorBody />
</SelectEditorContextProvider>
</Drawer>
);
};
export default AttrSelectEditorDrawer;

View File

@ -0,0 +1,38 @@
import { Button, Flex, TextInput } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import { UpdateAttrSelectSchema } from "@/lib/client";
const CommonInfoEditor = () => {
const { select, onSelectChange } = useSelectEditorContext();
const form = useForm<UpdateAttrSelectSchema>({
initialValues: select || {
name: "",
},
validate: {
name: name => !name && "Введите название",
},
});
return (
<form onSubmit={form.onSubmit(values => onSelectChange(values))}>
<Flex
gap={"xs"}
direction={"column"}>
<TextInput
label={"Название справочника"}
{...form.getInputProps("name")}
data-autofocus
/>
<Button
variant={"default"}
type={"submit"}>
Сохранить
</Button>
</Flex>
</form>
);
};
export default CommonInfoEditor;

View File

@ -0,0 +1,47 @@
import { IconCheck } from "@tabler/icons-react";
import { Flex, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import InlineButton from "@/components/ui/InlineButton/InlineButton";
const CreateOptionButton = () => {
const {
optionsActions: {
isCreatingOption,
createOptionForm,
onStartCreating,
onFinishCreating,
},
} = useSelectEditorContext();
if (!isCreatingOption) {
return (
<Flex flex={1}>
<InlineButton
fullWidth
onClick={onStartCreating}>
Добавить опцию
</InlineButton>
</Flex>
);
}
return (
<Flex
gap={"xs"}
flex={1}>
<TextInput
{...createOptionForm.getInputProps("name")}
flex={1}
placeholder={"Название"}
/>
<ActionIconWithTip
tipLabel={"Сохранить"}
onClick={onFinishCreating}>
<IconCheck />
</ActionIconWithTip>
</Flex>
);
};
export default CreateOptionButton;

View File

@ -0,0 +1,22 @@
import { Divider, Flex } from "@mantine/core";
import CommonInfoEditor from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/CommonInfoEditor";
import CreateOptionButton from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/CreateOptionButton";
import OptionsTable from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/OptionsTable";
const EditorBody = () => {
return (
<Flex
gap={"xs"}
direction={"column"}>
<CommonInfoEditor />
<Divider
label={"Опции"}
my={"xs"}
/>
<CreateOptionButton />
<OptionsTable />
</Flex>
);
};
export default EditorBody;

View File

@ -0,0 +1,82 @@
import React, { FC, ReactNode } from "react";
import { IconCheck, IconEdit, IconTrash } from "@tabler/icons-react";
import { Divider, Flex, Group, Stack, TextInput } from "@mantine/core";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import ActionIconWithTip from "@/components/ui/ActionIconWithTip/ActionIconWithTip";
import { AttrOptionSchema } from "@/lib/client";
type Props = {
option: AttrOptionSchema;
renderDraggable?: (item: AttrOptionSchema) => ReactNode;
};
const OptionTableRow: FC<Props> = ({ option, renderDraggable }) => {
const {
optionsActions: {
onStartEditing,
onFinishEditing,
onDelete,
editingOptionsData,
setEditingOptionsData,
},
} = useSelectEditorContext();
const onChange = (
e: React.ChangeEvent<HTMLInputElement>,
optionId: number
) => {
setEditingOptionsData(prev => {
prev.set(optionId, e.currentTarget.value);
return new Map(prev);
});
};
return (
<Stack
gap={"xs"}
mt={"xs"}>
<Group
wrap={"nowrap"}
justify={"space-between"}>
<Group wrap={"nowrap"}>
{renderDraggable && renderDraggable(option)}
{editingOptionsData.has(option.id) ? (
<TextInput
value={editingOptionsData.get(option.id)}
onChange={e => onChange(e, option.id)}
/>
) : (
option.name
)}
</Group>
<Flex
justify={"center"}
gap={"xs"}>
{editingOptionsData.has(option.id) ? (
<ActionIconWithTip
onClick={() => onFinishEditing(option)}
tipLabel={"Сохранить"}>
<IconCheck />
</ActionIconWithTip>
) : (
<ActionIconWithTip
onClick={() => onStartEditing(option)}
tipLabel={"Редактировать"}>
<IconEdit />
</ActionIconWithTip>
)}
<ActionIconWithTip
color={"red"}
onClick={() => onDelete(option)}
tipLabel={"Удалить"}>
<IconTrash />
</ActionIconWithTip>
</Flex>
</Group>
<Divider />
</Stack>
);
};
export default OptionTableRow;

View File

@ -0,0 +1,38 @@
import React from "react";
import { IconGripVertical } from "@tabler/icons-react";
import { Box, Divider, Stack } from "@mantine/core";
import OptionTableRow from "@/app/attributes/drawers/AttrSelectEditorDrawer/components/OptionTableRow";
import { useSelectEditorContext } from "@/app/attributes/drawers/AttrSelectEditorDrawer/contexts/SelectEditorContext";
import SortableDnd from "@/components/dnd/SortableDnd";
const OptionsTable = () => {
const { options } = useSelectEditorContext();
const { onDragEnd } = useSelectEditorContext();
const renderDraggable = () => (
<Box p={"xs"}>
<IconGripVertical />
</Box>
);
return (
<Stack gap={0}>
<Divider />
<SortableDnd
initialItems={options}
onDragEnd={onDragEnd}
renderItem={(item, renderDraggable) => (
<OptionTableRow
option={item}
renderDraggable={renderDraggable}
/>
)}
renderDraggable={renderDraggable}
dragHandleStyle={{ width: "auto" }}
vertical
/>
</Stack>
);
};
export default OptionsTable;

View File

@ -0,0 +1,62 @@
"use client";
import useAttrOptionsList from "@/app/attributes/drawers/AttrSelectEditorDrawer/hooks/useAttrOptionsList";
import {
AttrOptionSchema,
AttrSelectSchema,
UpdateAttrSelectSchema,
} from "@/lib/client";
import makeContext from "@/lib/contextFactory/contextFactory";
import { notifications } from "@/lib/notifications";
import useOptionsActions, { OptionsActions } from "../hooks/useOptionsActions";
type SelectEditorContextState = {
select: AttrSelectSchema;
onSelectChange: (values: UpdateAttrSelectSchema) => void;
options: AttrOptionSchema[];
optionsActions: OptionsActions;
onDragEnd: (itemId: number, newLexorank: string) => void;
};
type Props = {
select: AttrSelectSchema;
onSelectChange: (
values: UpdateAttrSelectSchema,
onSuccess: () => void
) => void;
};
const useSelectEditorContextState = ({
select,
onSelectChange,
}: Props): SelectEditorContextState => {
const { options, queryKey } = useAttrOptionsList({ selectId: select.id });
const optionsActions = useOptionsActions({ queryKey, select, options });
const onSelectChangeWithMsg = (values: UpdateAttrSelectSchema) => {
onSelectChange(values, () => {
notifications.success({
message: "Название справочника сохранено",
});
});
};
const onDragEnd = (itemId: number, newLexorank: string) => {
optionsActions.onUpdate(itemId, { lexorank: newLexorank });
};
return {
select,
onSelectChange: onSelectChangeWithMsg,
options,
optionsActions,
onDragEnd,
};
};
export const [SelectEditorContextProvider, useSelectEditorContext] =
makeContext<SelectEditorContextState, Props>(
useSelectEditorContextState,
"SelectEditor"
);

View File

@ -0,0 +1,68 @@
import { LexoRank } from "lexorank";
import { useCrudOperations } from "@/hooks/cruds/baseCrud";
import {
AttrOptionSchema,
CreateAttrOptionSchema,
UpdateAttrOptionSchema,
} from "@/lib/client";
import {
createAttrOptionMutation,
deleteAttrOptionMutation,
updateAttrOptionMutation,
} from "@/lib/client/@tanstack/react-query.gen";
import { getNewLexorank } from "@/utils/lexorank/generation";
import { getMaxByLexorank } from "@/utils/lexorank/max";
type Props = {
queryKey: any[];
options: AttrOptionSchema[];
};
export type AttrOptionsCrud = {
onCreate: (
data: Partial<CreateAttrOptionSchema>,
onSuccess?: () => void
) => void;
onUpdate: (
optionId: number,
option: UpdateAttrOptionSchema,
onSuccess?: () => void
) => void;
onDelete: (option: AttrOptionSchema, onSuccess?: () => void) => void;
};
export const useAttrOptionsCrud = ({
queryKey,
options,
}: Props): AttrOptionsCrud => {
return useCrudOperations<
AttrOptionSchema,
UpdateAttrOptionSchema,
CreateAttrOptionSchema
>({
key: "getAttrOptions",
queryKey,
mutations: {
create: createAttrOptionMutation(),
update: updateAttrOptionMutation(),
delete: deleteAttrOptionMutation(),
},
getCreateEntity: data => {
const lastOption = getMaxByLexorank(options);
const newLexorank = getNewLexorank(
lastOption ? LexoRank.parse(lastOption.lexorank) : null
);
return {
name: data.name!,
selectId: data.selectId!,
lexorank: newLexorank.toString(),
};
},
getUpdateEntity: (old, update) => ({
...old,
name: update.name ?? old.name,
lexorank: update.lexorank ?? old.lexorank,
}),
getDeleteConfirmTitle: () => "Удаление опции",
});
};

View File

@ -0,0 +1,38 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AttrOptionSchema } from "@/lib/client";
import {
getAttrOptionsOptions,
getAttrOptionsQueryKey,
} from "@/lib/client/@tanstack/react-query.gen";
import { sortByLexorank } from "@/utils/lexorank/sort";
type Props = {
selectId: number;
};
const useAttrOptionsList = ({ selectId }: Props) => {
const queryClient = useQueryClient();
const options = { path: { selectId } };
const { data, refetch } = useQuery(getAttrOptionsOptions(options));
const queryKey = getAttrOptionsQueryKey(options);
const setOptions = (options: AttrOptionSchema[]) => {
queryClient.setQueryData(
queryKey,
(old: { items: AttrOptionSchema[] }) => ({
...old,
items: options,
})
);
};
return {
options: sortByLexorank(data?.items ?? []),
setOptions,
refetch,
queryKey,
};
};
export default useAttrOptionsList;

View File

@ -0,0 +1,107 @@
import { Dispatch, SetStateAction, useState } from "react";
import { useForm, UseFormReturnType } from "@mantine/form";
import {
AttrOptionSchema,
AttrSelectSchema,
CreateAttrOptionSchema,
UpdateAttrOptionSchema,
} from "@/lib/client";
import { notifications } from "@/lib/notifications";
import { useAttrOptionsCrud } from "@/app/attributes/drawers/AttrSelectEditorDrawer/hooks/useAttrOptionsCrud";
type Props = {
queryKey: any[];
select: AttrSelectSchema;
options: AttrOptionSchema[];
};
export type OptionsActions = {
isCreatingOption: boolean;
createOptionForm: UseFormReturnType<CreateAttrOptionSchema>;
onStartCreating: () => void;
onFinishCreating: () => void;
editingOptionsData: Map<number, string>;
setEditingOptionsData: Dispatch<SetStateAction<Map<number, string>>>;
onStartEditing: (option: AttrOptionSchema) => void;
onFinishEditing: (option: AttrOptionSchema) => void;
onUpdate: (optionId: number, data: UpdateAttrOptionSchema) => void;
onDelete: (option: AttrOptionSchema) => void;
};
const useOptionsActions = ({ queryKey, select, options }: Props) => {
const [isCreatingOption, setIsCreatingOption] = useState<boolean>(false);
const [editingOptionsData, setEditingOptionsData] = useState<
Map<number, string>
>(new Map());
const createOptionForm = useForm<CreateAttrOptionSchema>({
initialValues: {
name: "",
lexorank: "",
selectId: select.id,
},
validate: {
name: name => !name && "Введите название",
},
});
const optionCrud = useAttrOptionsCrud({ queryKey, options });
const onStartCreating = () => {
setIsCreatingOption(true);
};
const onFinishCreating = () => {
if (createOptionForm.validate().hasErrors) return;
optionCrud.onCreate(createOptionForm.values, () => {
notifications.success({ message: "Опция успешно создана" });
createOptionForm.reset();
setIsCreatingOption(false);
});
};
const onStartEditing = (option: AttrOptionSchema) => {
setEditingOptionsData(prev => {
prev.set(option.id, option.name);
return new Map(prev);
});
};
const onFinishEditing = (option: AttrOptionSchema) => {
if (!editingOptionsData.has(option.id)) return;
const newName = editingOptionsData.get(option.id);
if (!newName) {
notifications.error({ message: "Название не может быть пустым" });
return;
}
optionCrud.onUpdate(option.id, { ...option, name: newName }, () => {
notifications.success({ message: "Опция сохранена" });
setEditingOptionsData(prev => {
prev.delete(option.id);
return new Map(prev);
});
});
};
const onDelete = (option: AttrOptionSchema) => {
optionCrud.onDelete(option, () =>
notifications.success({ message: "Опция удалена" })
);
};
const onUpdate = optionCrud.onUpdate;
return {
isCreatingOption,
createOptionForm,
onStartCreating,
onFinishCreating,
editingOptionsData,
setEditingOptionsData,
onStartEditing,
onFinishEditing,
onDelete,
onUpdate,
};
};
export default useOptionsActions;

View File

@ -0,0 +1,72 @@
"use client";
import { useMemo } from "react";
import { IconCheck, IconX } from "@tabler/icons-react";
import { DataTableColumn } from "mantine-datatable";
import { Box, Center } from "@mantine/core";
import AttributeTableActions from "@/app/module-editor/[moduleId]/components/shared/AttributeTableActions/AttributeTableActions";
import AttributeDefaultValue from "@/components/ui/AttributeDefaultValue/AttributeDefaultValue";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttributeSchema } from "@/lib/client";
import { useAttributesContext } from "../contexts/AttributesContext";
const useAttributesTableColumns = () => {
const isMobile = useIsMobile();
const { attributesActions } = useAttributesContext();
const renderCheck = (value: boolean) => (value ? <IconCheck /> : <IconX />);
return useMemo(
() =>
[
{
title: "Название атрибута",
accessor: "label",
},
{
title: "Тип",
accessor: "type.name",
render: attr =>
attr.type.type === "select"
? `Выбор "${attr.label}"`
: attr.type.name,
},
{
title: "Значение по умолчанию",
accessor: "defaultValue",
render: attr => <AttributeDefaultValue attribute={attr} />,
},
{
title: isMobile
? "Синх. в группе"
: "Синхронизировано в группе",
accessor: "isApplicableToGroup",
render: attr => renderCheck(attr.isApplicableToGroup),
},
{
title: "Может быть пустым",
accessor: "isNullable",
render: attr => renderCheck(attr.isNullable),
},
{
title: "Описаниие",
accessor: "description",
render: attr => <Box>{attr.description}</Box>,
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: attribute => (
<AttributeTableActions
attribute={attribute}
onUpdate={attributesActions.onUpdate}
onDelete={attributesActions.onDelete}
/>
),
},
] as DataTableColumn<AttributeSchema>[],
[isMobile]
);
};
export default useAttributesTableColumns;

View File

@ -0,0 +1,29 @@
import { useMemo, useState } from "react";
import { AttributeSchema } from "@/lib/client";
type Props = {
attributes: AttributeSchema[];
};
const useFilteredAttributes = ({ attributes }: Props) => {
const [search, setSearch] = useState<string>("");
const filteredAttributes = useMemo(
() =>
attributes.filter(
attr =>
attr.type.name.includes(search) ||
attr.label.includes(search) ||
attr.description.includes(search)
),
[attributes, search]
);
return {
search,
setSearch,
filteredAttributes,
};
};
export default useFilteredAttributes;

View File

@ -0,0 +1,23 @@
import { useMemo, useState } from "react";
import { AttrSelectSchema } from "@/lib/client";
type Props = {
selects: AttrSelectSchema[];
};
const useFilteredSelects = ({ selects }: Props) => {
const [search, setSearch] = useState<string>("");
const filteredSelects = useMemo(
() => selects.filter(s => s.name.includes(search)),
[selects, search]
);
return {
search,
setSearch,
filteredSelects,
};
};
export default useFilteredSelects;

View File

@ -0,0 +1,50 @@
import { modals } from "@mantine/modals";
import { useDrawersContext } from "@/drawers/DrawersContext";
import { useAttrSelectsCrud } from "@/hooks/cruds/useSelectsCrud";
import { AttrSelectSchema } from "@/lib/client";
type Props = {
queryKey: any[];
};
export type SelectsActions = {
onCreate: () => void;
onUpdate: (select: AttrSelectSchema) => void;
onDelete: (select: AttrSelectSchema) => void;
};
const useSelectsActions = (props: Props): SelectsActions => {
const attrSelectsCrud = useAttrSelectsCrud(props);
const { openDrawer } = useDrawersContext();
const onCreate = () => {
modals.openContextModal({
modal: "enterNameModal",
title: "Создание справочника",
innerProps: {
onChange: values => attrSelectsCrud.onCreate(values),
},
});
};
const onUpdate = (select: AttrSelectSchema) => {
openDrawer({
key: "attrSelectEditorDrawer",
props: {
onSelectChange: (values, onSuccess) =>
attrSelectsCrud.onUpdate(select.id, values, onSuccess),
select,
},
});
};
const onDelete = attrSelectsCrud.onDelete;
return {
onCreate,
onUpdate,
onDelete,
};
};
export default useSelectsActions;

View File

@ -0,0 +1,38 @@
"use client";
import { useMemo } from "react";
import { DataTableColumn } from "mantine-datatable";
import { Center } from "@mantine/core";
import UpdateDeleteTableActions from "@/components/ui/BaseTable/components/UpdateDeleteTableActions";
import useIsMobile from "@/hooks/utils/useIsMobile";
import { AttrSelectSchema } from "@/lib/client";
import { useAttributesContext } from "../contexts/AttributesContext";
const useSelectsTableColumns = () => {
const isMobile = useIsMobile();
const { selectsActions } = useAttributesContext();
return useMemo(
() =>
[
{
title: "Название справочника",
accessor: "name",
},
{
accessor: "actions",
title: <Center>Действия</Center>,
width: "0%",
render: select => (
<UpdateDeleteTableActions
onDelete={() => selectsActions.onDelete(select)}
onChange={() => selectsActions.onUpdate(select)}
/>
),
},
] as DataTableColumn<AttrSelectSchema>[],
[isMobile]
);
};
export default useSelectsTableColumns;

View File

@ -0,0 +1,22 @@
import { Suspense } from "react";
import { Center, Loader } from "@mantine/core";
import PageBody from "@/app/attributes/components/PageBody";
import { AttributesContextProvider } from "@/app/attributes/contexts/AttributesContext";
import PageContainer from "@/components/layout/PageContainer/PageContainer";
export default async function AttributesPage() {
return (
<Suspense
fallback={
<Center h="50vh">
<Loader size="lg" />
</Center>
}>
<PageContainer>
<AttributesContextProvider>
<PageBody />
</AttributesContextProvider>
</PageContainer>
</Suspense>
);
}

View File

@ -0,0 +1,6 @@
enum AttributePageView {
ATTRIBUTES,
SELECTS,
}
export default AttributePageView;

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;

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