Vadim 4 months ago
parent
commit
e0d2c3e7e9
38 changed files with 4140 additions and 268 deletions
  1. 616 40
      package-lock.json
  2. 18 12
      package.json
  3. 46 34
      quasar.config.ts
  4. 73 0
      src/api/api.ts
  5. 0 15
      src/assets/quasar-logo-vertical.svg
  6. 78 14
      src/boot/axios.ts
  7. 5 5
      src/boot/pinia.ts
  8. 0 35
      src/components/EssentialLink.vue
  9. 0 37
      src/components/ExampleComponent.vue
  10. 30 0
      src/components/custom-form/CustomForm.vue
  11. 41 0
      src/components/custom-form/CustomFormField.vue
  12. 14 0
      src/components/custom-form/custom-form-types.ts
  13. 45 0
      src/components/custom-form/validation-functions.ts
  14. 0 8
      src/components/models.ts
  15. 54 0
      src/components/pickers/tz/TimezonePicker.vue
  16. 1497 0
      src/components/pickers/tz/timezones.ts
  17. 21 0
      src/modules/part-entities/CreatePartEntityPage.vue
  18. 25 0
      src/modules/part-entities/MyPartEntitiesPage.vue
  19. 48 0
      src/modules/part-entities/create-pe-store.ts
  20. 25 0
      src/modules/part-entities/part-entity-types.ts
  21. 64 0
      src/modules/part-entities/pe-store.ts
  22. BIN
      src/modules/users/auth/assets/login-bg.jpg
  23. 135 0
      src/modules/users/auth/components/ConfirmRegistration.vue
  24. 15 0
      src/modules/users/auth/components/LoginAndRegistration.vue
  25. 88 0
      src/modules/users/auth/components/LoginComponent.vue
  26. 68 0
      src/modules/users/auth/components/RegistrationComponent.vue
  27. 32 0
      src/modules/users/auth/pages/AuthPage.vue
  28. 239 0
      src/modules/users/auth/stores/auth-store.ts
  29. 128 0
      src/modules/users/other/ConfirmPinInput.vue
  30. 0 43
      src/pages/IndexPage.vue
  31. 138 0
      src/plugins/dayjs/dayjs.ts
  32. 370 0
      src/plugins/notify/notify.scss
  33. 129 0
      src/plugins/notify/notify.ts
  34. 21 3
      src/router/index.ts
  35. 30 0
      src/router/routes.ts
  36. 0 21
      src/stores/example-store.ts
  37. 45 0
      src/utils/error-middleware.ts
  38. 2 1
      tsconfig.json

File diff suppressed because it is too large
+ 616 - 40
package-lock.json


+ 18 - 12
package.json

@@ -15,31 +15,37 @@
     "postinstall": "quasar prepare"
   },
   "dependencies": {
+    "@quasar/cli": "^2.5.0",
+    "@quasar/extras": "^1.16.4",
     "axios": "^1.2.1",
+    "dayjs": "^1.11.13",
+    "fuzzysort": "^3.1.0",
     "pinia": "^3.0.1",
-    "@quasar/extras": "^1.16.4",
     "quasar": "^2.16.0",
-    "vue": "^3.4.18",
-    "vue-router": "^4.0.12"
+    "validator": "^13.15.0",
+    "vue": "^3.5.13",
+    "vue-router": "^4.5.1",
+    "zod": "^3.24.3"
   },
   "devDependencies": {
     "@eslint/js": "^9.14.0",
+    "@quasar/app-vite": "^2.1.0",
+    "@types/node": "^20.5.9",
+    "@types/validator": "^13.15.0",
+    "@vue/eslint-config-prettier": "^10.1.0",
+    "@vue/eslint-config-typescript": "^14.4.0",
+    "autoprefixer": "^10.4.2",
     "eslint": "^9.14.0",
     "eslint-plugin-vue": "^9.30.0",
     "globals": "^15.12.0",
-    "vue-tsc": "^2.0.29",
-    "@vue/eslint-config-typescript": "^14.4.0",
-    "vite-plugin-checker": "^0.9.0",
-    "@vue/eslint-config-prettier": "^10.1.0",
     "prettier": "^3.3.3",
-    "@types/node": "^20.5.9",
-    "@quasar/app-vite": "^2.1.0",
-    "autoprefixer": "^10.4.2",
-    "typescript": "~5.5.3"
+    "typescript": "~5.5.3",
+    "vite-plugin-checker": "^0.9.0",
+    "vue-tsc": "^2.0.29"
   },
   "engines": {
     "node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
     "npm": ">= 6.13.4",
     "yarn": ">= 1.21.1"
   }
-}
+}

+ 46 - 34
quasar.config.ts

@@ -11,14 +11,10 @@ export default defineConfig((/* ctx */) => {
     // app boot file (/src/boot)
     // --> boot files are part of "main.js"
     // https://v2.quasar.dev/quasar-cli-vite/boot-files
-    boot: [
-      'axios'
-    ],
+    boot: ['axios'],
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
-    css: [
-      'app.scss'
-    ],
+    css: ['app.scss'],
 
     // https://github.com/quasarframework/quasar/tree/dev/extras
     extras: [
@@ -37,13 +33,13 @@ export default defineConfig((/* ctx */) => {
     // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#build
     build: {
       target: {
-        browser: [ 'es2022', 'firefox115', 'chrome115', 'safari14' ],
-        node: 'node20'
+        browser: ['es2022', 'firefox115', 'chrome115', 'safari14'],
+        node: 'node20',
       },
 
       typescript: {
         strict: true,
-        vueShim: true
+        vueShim: true,
         // extendTsConfig (tsConfig) {}
       },
 
@@ -63,24 +59,42 @@ export default defineConfig((/* ctx */) => {
       // polyfillModulePreload: true,
       // distDir
 
-      // extendViteConf (viteConf) {},
+      // extendViteConf(viteConf) {
+      //   if (!viteConf.resolve) {
+      //     viteConf.resolve = {};
+      //   }
+      //   if (!viteConf.resolve.alias) {
+      //     viteConf.resolve.alias = {};
+      //   }
+
+      //   Object.assign(viteConf.resolve.alias, {
+      //     // Стандартный '@' (если еще не определен Quasar)
+      //     '@': path.resolve(__dirname, './src'),
+      //     // Ваши псевдонимы с '#'
+      //     '@api': path.resolve(__dirname, './src/api/api.ts'),
+      //   });
+      // },
       // viteVuePluginOptions: {},
-      
+
       vitePlugins: [
-        ['vite-plugin-checker', {
-          vueTsc: true,
-          eslint: {
-            lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
-            useFlatConfig: true
-          }
-        }, { server: false }]
-      ]
+        [
+          'vite-plugin-checker',
+          {
+            vueTsc: true,
+            eslint: {
+              lintCommand: 'eslint -c ./eslint.config.js "./src*/**/*.{ts,js,mjs,cjs,vue}"',
+              useFlatConfig: true,
+            },
+          },
+          { server: false },
+        ],
+      ],
     },
 
     // Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#devserver
     devServer: {
       // https: true,
-      open: true // opens browser window automatically
+      open: true, // opens browser window automatically
     },
 
     // https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#framework
@@ -98,7 +112,7 @@ export default defineConfig((/* ctx */) => {
       // directives: [],
 
       // Quasar plugins
-      plugins: []
+      plugins: [],
     },
 
     // animations: 'all', // --- includes all animations
@@ -121,10 +135,10 @@ export default defineConfig((/* ctx */) => {
     // https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
     ssr: {
       prodPort: 3000, // The default port that the production server should use
-                      // (gets superseded if process.env.PORT is specified at runtime)
+      // (gets superseded if process.env.PORT is specified at runtime)
 
       middlewares: [
-        'render' // keep this as last one
+        'render', // keep this as last one
       ],
 
       // extendPackageJson (json) {},
@@ -135,7 +149,7 @@ export default defineConfig((/* ctx */) => {
       // manualStoreHydration: true,
       // manualPostHydrationTrigger: true,
 
-      pwa: false
+      pwa: false,
       // pwaOfflineHtmlFilename: 'offline.html', // do NOT use index.html as name!
 
       // pwaExtendGenerateSWOptions (cfg) {},
@@ -144,7 +158,7 @@ export default defineConfig((/* ctx */) => {
 
     // https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
     pwa: {
-      workboxMode: 'GenerateSW' // 'GenerateSW' or 'InjectManifest'
+      workboxMode: 'GenerateSW', // 'GenerateSW' or 'InjectManifest'
       // swFilename: 'sw.js',
       // manifestFilename: 'manifest.json',
       // extendManifestJson (json) {},
@@ -162,7 +176,7 @@ export default defineConfig((/* ctx */) => {
 
     // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
     capacitor: {
-      hideSplashscreen: true
+      hideSplashscreen: true,
     },
 
     // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
@@ -173,7 +187,7 @@ export default defineConfig((/* ctx */) => {
       // extendPackageJson (json) {},
 
       // Electron preload scripts (if any) from /src-electron, WITHOUT file extension
-      preloadScripts: [ 'electron-preload' ],
+      preloadScripts: ['electron-preload'],
 
       // specify the debugging port to use for the Electron app when running in development mode
       inspectPort: 5858,
@@ -182,13 +196,11 @@ export default defineConfig((/* ctx */) => {
 
       packager: {
         // https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
-
         // OS X / Mac App Store
         // appBundleId: '',
         // appCategoryType: '',
         // osxSign: '',
         // protocol: 'myapp://path',
-
         // Windows only
         // win32metadata: { ... }
       },
@@ -196,8 +208,8 @@ export default defineConfig((/* ctx */) => {
       builder: {
         // https://www.electron.build/configuration/configuration
 
-        appId: 'event-front'
-      }
+        appId: 'event-front',
+      },
     },
 
     // Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
@@ -213,7 +225,7 @@ export default defineConfig((/* ctx */) => {
        *
        * @example [ 'my-script.ts', 'sub-folder/my-other-script.js' ]
        */
-      extraScripts: []
-    }
-  }
+      extraScripts: [],
+    },
+  };
 });

+ 73 - 0
src/api/api.ts

@@ -0,0 +1,73 @@
+import { z } from 'zod';
+
+class partEntitiesApi {
+  GET_PartEntities = {
+    res: z.object({
+      code: z.enum(['success']),
+      partEntities: z.array(z.object({ peId: z.string().uuid(), name: z.string() })),
+    }),
+  };
+
+  FieldTypeCode = z.enum(['text', 'number', 'checkbox', 'audio']);
+  Validator = z.discriminatedUnion('fieldType', [
+    z.object({
+      fieldType: z.literal('text'),
+      code: z.enum(['max', 'min']),
+      name: z.string(),
+      value: z.string(),
+    }),
+    z.object({
+      fieldType: z.literal('number'),
+      code: z.enum(['max', 'min']),
+      name: z.string(),
+      value: z.string(),
+    }),
+    z.object({
+      fieldType: z.literal('checkbox'),
+      code: z.literal('required'),
+      name: z.string(),
+      value: z.string(),
+    }),
+  ]);
+
+  CustomField = z.object({
+    fieldId: z.string().uuid(),
+    title: z.string(),
+    typeCode: this.FieldTypeCode,
+    options: z.array(z.string()),
+    mask: z.string(),
+    validators: z.array(this.Validator),
+  });
+
+  GET_PeCreateTypeData = {
+    res: z.object({
+      code: z.enum(['success']),
+      peType: z.object({
+        peTypeId: z.string().uuid(),
+        code: z.string(),
+        fields: z.array(this.CustomField),
+      }),
+    }),
+  };
+
+  GET_PartEntity = {
+    req: z.object({
+      peId: z.string().uuid(),
+    }),
+    res: z.object({
+      peId: z.string().uuid(),
+      name: z.string(),
+      members: z.array(
+        z.object({
+          memberId: z.string(),
+          userId: z.string().uuid(),
+          email: z.string().email(),
+          firstName: z.string(),
+          lastName: z.string(),
+          patronymic: z.string(),
+        }),
+      ),
+    }),
+  };
+}
+export const PartEntitiesApi = new partEntitiesApi();

+ 0 - 15
src/assets/quasar-logo-vertical.svg

@@ -1,15 +0,0 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
-	<path
-		d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
-	<path fill="#050A14"
-		d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
-	<path fill="#00B4FF"
-		d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
-	<path fill="#00B4FF"
-		d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
-	<path fill="#050A14"
-		d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
-	<path fill="#00B4FF"
-		d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
-	<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
-</svg>

+ 78 - 14
src/boot/axios.ts

@@ -1,31 +1,95 @@
-import { defineBoot } from '#q-app/wrappers';
-import axios, { type AxiosInstance } from 'axios';
+import { boot } from 'quasar/wrappers';
+import type { AxiosInstance } from 'axios';
+import axios from 'axios';
 
-declare module 'vue' {
+import { LocalStorage } from 'quasar';
+import { Loading } from 'quasar';
+import { useAuthStore } from 'src/modules/users/auth/stores/auth-store';
+
+declare module '@vue/runtime-core' {
   interface ComponentCustomProperties {
     $axios: AxiosInstance;
-    $api: AxiosInstance;
   }
 }
 
+export const FILES_URL = 'http://localhost:3000/files';
+export const API_URL = 'http://localhost:3000/api';
+
 // Be careful when using SSR for cross-request state pollution
 // due to creating a Singleton instance here;
 // If any client changes this (global) instance, it might be a
 // good idea to move this instance creation inside of the
 // "export default () => {}" function below (which runs individually
 // for each client)
-const api = axios.create({ baseURL: 'https://api.example.com' });
+const $api = axios.create({
+  withCredentials: true, // для cookie
+  baseURL: API_URL,
+});
+const $apiWithoutAuth = axios.create({
+  withCredentials: true, // для cookie
+  baseURL: API_URL,
+});
+
+export default boot(({ router }) => {
+  $apiWithoutAuth.interceptors.request.use(async (config) => {
+    const accessToken = LocalStorage.getItem('accessToken');
+
+    if (typeof accessToken !== 'string') {
+      await router.push({ name: 'login' });
+      console.error('Не найден accessToken');
+      throw new Error('НЕ АВТОРИЗОВАН');
+    }
+
+    config.headers.Authorization = `Bearer ${accessToken}`;
+
+    Loading.show();
+    return config;
+  });
+
+  $api.interceptors.response.use(
+    (config) => {
+      Loading.hide();
+      return config;
+    },
+    async (error) => {
+      Loading.hide();
+      const originalRequest = error.config;
+
+      if (error.response?.status == 401 && error.config && !error.config._isRetry) {
+        originalRequest._isRetry = true;
+
+        try {
+          await useAuthStore().refreshAuth();
+
+          return $api.request(originalRequest);
+        } catch (error) {
+          await router.push({ name: 'login' });
+          console.error('НЕ АВТОРИЗОВАН', error);
+        }
+      }
 
-export default defineBoot(({ app }) => {
-  // for use inside Vue files (Options API) through this.$axios and this.$api
+      if (error.response?.status == 401) {
+        await router.push({ name: 'login' });
+        console.error('РЕФРЕШ ПРОСРОЧЕН');
+      }
 
-  app.config.globalProperties.$axios = axios;
-  // ^ ^ ^ this will allow you to use this.$axios (for Vue Options API form)
-  //       so you won't necessarily have to import axios in each vue file
+      throw error;
+    },
+  );
 
-  app.config.globalProperties.$api = api;
-  // ^ ^ ^ this will allow you to use this.$api (for Vue Options API form)
-  //       so you can easily perform requests against your app's API
+  // 400 статус проброс ошибки
+  // $api.interceptors.response.use(
+  //   function (response) {
+  //     if (response.status === 400) {
+  //       return Promise.reject(response)
+  //     }
+  //     return response
+  //   },
+  //   function (error) {
+  //     // Do something with response error
+  //     return Promise.reject(error)
+  //   },
+  // )
 });
 
-export { api };
+export { $api, $apiWithoutAuth };

+ 5 - 5
src/stores/index.ts → src/boot/pinia.ts

@@ -1,5 +1,5 @@
-import { defineStore } from '#q-app/wrappers'
-import { createPinia } from 'pinia'
+import { defineStore } from '#q-app/wrappers';
+import { createPinia } from 'pinia';
 
 /*
  * When adding new properties to stores, you should also
@@ -23,10 +23,10 @@ declare module 'pinia' {
  */
 
 export default defineStore((/* { ssrContext } */) => {
-  const pinia = createPinia()
+  const pinia = createPinia();
 
   // You can add Pinia plugins here
   // pinia.use(SomePiniaPlugin)
 
-  return pinia
-})
+  return pinia;
+});

+ 0 - 35
src/components/EssentialLink.vue

@@ -1,35 +0,0 @@
-<template>
-  <q-item
-    clickable
-    tag="a"
-    target="_blank"
-    :href="link"
-  >
-    <q-item-section
-      v-if="icon"
-      avatar
-    >
-      <q-icon :name="icon" />
-    </q-item-section>
-
-    <q-item-section>
-      <q-item-label>{{ title }}</q-item-label>
-      <q-item-label caption>{{ caption }}</q-item-label>
-    </q-item-section>
-  </q-item>
-</template>
-
-<script setup lang="ts">
-export interface EssentialLinkProps {
-  title: string;
-  caption?: string;
-  link?: string;
-  icon?: string;
-};
-
-withDefaults(defineProps<EssentialLinkProps>(), {
-  caption: '',
-  link: '#',
-  icon: '',
-});
-</script>

+ 0 - 37
src/components/ExampleComponent.vue

@@ -1,37 +0,0 @@
-<template>
-  <div>
-    <p>{{ title }}</p>
-    <ul>
-      <li v-for="todo in todos" :key="todo.id" @click="increment">
-        {{ todo.id }} - {{ todo.content }}
-      </li>
-    </ul>
-    <p>Count: {{ todoCount }} / {{ meta.totalCount }}</p>
-    <p>Active: {{ active ? 'yes' : 'no' }}</p>
-    <p>Clicks on todos: {{ clickCount }}</p>
-  </div>
-</template>
-
-<script setup lang="ts">
-import { computed, ref } from 'vue';
-import type { Todo, Meta } from './models';
-
-interface Props {
-  title: string;
-  todos?: Todo[];
-  meta: Meta;
-  active: boolean;
-};
-
-const props = withDefaults(defineProps<Props>(), {
-  todos: () => []
-});
-
-const clickCount = ref(0);
-function increment() {
-  clickCount.value += 1;
-  return clickCount.value;
-}
-
-const todoCount = computed(() => props.todos.length);
-</script>

+ 30 - 0
src/components/custom-form/CustomForm.vue

@@ -0,0 +1,30 @@
+<template>
+  <q-form @submit="emit('submit', formWithValues)">
+    <CustomFormField
+      v-for="field in formWithValues"
+      :key="field.fieldId"
+      :field="field"
+      v-model="field.value"
+    />
+  </q-form>
+</template>
+
+<script setup lang="ts">
+import CustomFormField from './CustomFormField.vue';
+import type { CustomField, FieldWithValue } from './custom-form-types';
+
+const { fields } = defineProps<{
+  fields: CustomField[];
+}>();
+
+const emit = defineEmits<{
+  submit: [FieldWithValue[]];
+}>();
+
+const formWithValues: FieldWithValue[] = fields.map((f) => {
+  return {
+    ...f,
+    value: null,
+  };
+});
+</script>

+ 41 - 0
src/components/custom-form/CustomFormField.vue

@@ -0,0 +1,41 @@
+<template>
+  <q-input
+    v-model="fieldValue"
+    v-if="field.typeCode === 'text'"
+    type="text"
+    :title="field.title"
+    :mask="field.mask"
+    :rules="rules"
+  ></q-input>
+  <q-input
+    v-model="fieldValue"
+    v-if="field.typeCode === 'number'"
+    type="number"
+    :title="field.title"
+    :mask="field.mask"
+    :rules="rules"
+  ></q-input>
+  <q-checkbox
+    v-model="fieldValue"
+    v-if="field.typeCode === 'checkbox'"
+    :title="field.title"
+    :rules="rules"
+  ></q-checkbox>
+</template>
+
+<script setup lang="ts">
+import type { ValidationRule } from 'quasar';
+import type { CustomField, FieldValue } from './custom-form-types';
+import { getValidationFunc } from './validation-functions';
+
+const { field } = defineProps<{
+  field: CustomField;
+}>();
+
+const fieldValue = defineModel<FieldValue>({ required: true });
+
+// rules
+const rules: ValidationRule[] = field.validators.map((v) => {
+  return getValidationFunc(v);
+});
+</script>

+ 14 - 0
src/components/custom-form/custom-form-types.ts

@@ -0,0 +1,14 @@
+import type { PartEntitiesApi } from 'src/api/api';
+import type { z } from 'zod';
+
+export type FieldTypeCode = z.infer<typeof PartEntitiesApi.FieldTypeCode>;
+
+export type CustomField = z.infer<typeof PartEntitiesApi.CustomField>;
+
+export type FieldValue = string | null;
+
+export type FieldWithValue = CustomField & {
+  value: FieldValue;
+};
+
+export type Validator = z.infer<typeof PartEntitiesApi.Validator>;

+ 45 - 0
src/components/custom-form/validation-functions.ts

@@ -0,0 +1,45 @@
+import type { ValidationRule } from 'quasar';
+import type { Validator } from './custom-form-types';
+import { z } from 'zod';
+
+export const getValidationFunc = (validator: Validator): ValidationRule => {
+  switch (validator.fieldType) {
+    case 'text':
+      return getTextFunc(validator);
+    case 'number':
+      return getNumberFunc(validator);
+    case 'checkbox':
+      return getCheckboxFunc(validator);
+  }
+};
+
+const getTextFunc = (validator: Validator & { fieldType: 'text' }): ValidationRule => {
+  const parsedValue = z.number().parse(JSON.parse(validator.value));
+
+  switch (validator.code) {
+    case 'max':
+      return (v: string) =>
+        v.length <= parsedValue || `Поле должно содержать максимум ${parsedValue} символов`;
+    case 'min':
+      return (v: string) =>
+        v.length >= parsedValue || `Поле должно содержать минимум ${parsedValue} символов`;
+  }
+};
+
+const getNumberFunc = (validator: Validator & { fieldType: 'number' }): ValidationRule => {
+  const parsedValue = z.number().parse(JSON.parse(validator.value));
+
+  switch (validator.code) {
+    case 'max':
+      return (v: number) => v <= parsedValue || `Значение не должно быть больше ${parsedValue}`;
+    case 'min':
+      return (v: number) => v >= parsedValue || `Значение не должно быть меньше ${parsedValue}`;
+  }
+};
+
+const getCheckboxFunc = (validator: Validator & { fieldType: 'checkbox' }): ValidationRule => {
+  switch (validator.code) {
+    case 'required':
+      return (v: boolean) => v || `Поле должно быть заполнено`;
+  }
+};

+ 0 - 8
src/components/models.ts

@@ -1,8 +0,0 @@
-export interface Todo {
-  id: number;
-  content: string;
-}
-
-export interface Meta {
-  totalCount: number;
-}

+ 54 - 0
src/components/pickers/tz/TimezonePicker.vue

@@ -0,0 +1,54 @@
+<template>
+  <q-select
+    filled
+    v-model="selectedTz"
+    use-input
+    hide-selected
+    fill-input
+    input-debounce="0"
+    :options="filteredTz"
+    emit-value
+    map-options
+    option-label="text"
+    option-value="value"
+    label="Выберите часовой пояс"
+    @filter="filterFn"
+  >
+    <template v-slot:no-option>
+      <q-item>
+        <q-item-section class="text-grey"> Пояс не найден </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup lang="ts">
+import { timezones } from './timezones';
+import { ref } from 'vue';
+
+// tz
+
+const filteredTz = ref(timezones);
+
+const selectedTz = defineModel();
+
+// fuzzy
+import fuzzysort from 'fuzzysort';
+
+function fuzzySearch(query: string, items: typeof timezones): typeof timezones {
+  const results = fuzzysort.go(query, items, {
+    keys: ['text'],
+    threshold: -10000, // Настройте порог в зависимости от нужд
+  });
+
+  return results.map((result) => result.obj);
+}
+
+// filter
+const filterFn = (filterString: string, update: any, abort: any) => {
+  update(() => {
+    const filtered = fuzzySearch(filterString, timezones);
+    filteredTz.value = filtered.length ? filtered : timezones;
+  });
+};
+</script>

+ 1497 - 0
src/components/pickers/tz/timezones.ts

@@ -0,0 +1,1497 @@
+export const timezones: { offset: string; text: string; value: string }[] = [
+  {
+    offset: '-11:00',
+    text: '(UTC-11:00) Алофи',
+    value: 'Pacific/Niue',
+  },
+  {
+    offset: '-11:00',
+    text: '(UTC-11:00) Паго-Паго',
+    value: 'Pacific/Samoa',
+  },
+  {
+    offset: '-10:00',
+    text: '(UTC-10:00) Гонолулу, Хило, Милилани',
+    value: 'Pacific/Honolulu',
+  },
+  {
+    offset: '-10:00',
+    text: '(UTC-10:00) Папеэте, Хуахине, Маупити',
+    value: 'Pacific/Tahiti',
+  },
+  {
+    offset: '-10:00',
+    text: '(UTC-10:00) Аваруа, Аитутаки',
+    value: 'Pacific/Rarotonga',
+  },
+  {
+    offset: '-09:30',
+    text: '(UTC-09:30) Нуку Хива',
+    value: 'Pacific/Marquesas',
+  },
+  {
+    offset: '-08:00',
+    text: '(UTC-08:00) Анкоридж, Фэрбанкс, Ситка',
+    value: 'America/Anchorage',
+  },
+  {
+    offset: '-08:00',
+    text: '(UTC-08:00) Джуно, Кетчикан',
+    value: 'America/Juneau',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Доусон',
+    value: 'America/Dawson',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Ванкувер, Суррей, Бернаби',
+    value: 'America/Vancouver',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Финикс, Тусон, Меса',
+    value: 'America/Phoenix',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Эрмосильо, Сьюдад-Обрегон, Ногалес',
+    value: 'America/Hermosillo',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Уайтхорс',
+    value: 'America/Whitehorse',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Тихуана, Мехикали, Энсенада',
+    value: 'America/Tijuana',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Форт-Сент-Джон, Кранбрук, Досон-Крик',
+    value: 'America/Dawson_Creek',
+  },
+  {
+    offset: '-07:00',
+    text: '(UTC-07:00) Сан-Франциско, Лос-Анджелес, Сан-Диего',
+    value: 'America/Los_Angeles',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Саскатун, Реджайна, Принс-Альберт',
+    value: 'America/Swift_Current',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Денвер, Альбукерке, Колорадо-Спрингс',
+    value: 'America/Denver',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Йеллоунайф, Хей-Ривер, Инувик',
+    value: 'America/Yellowknife',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Манагуа, Матагальпа, Леон',
+    value: 'America/Managua',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Сьюдад-Хуарес, Чиуауа, Кульякан',
+    value: 'America/Chihuahua',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Сан-Хосе, Картаго, Лимон',
+    value: 'America/Costa_Rica',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Пуэрто Айора, Пуэрто-Бакерисо-Морено, Бальтра',
+    value: 'Pacific/Galapagos',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Ханга-Роа',
+    value: 'Pacific/Easter',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Сан-Педро-Сула, Тегусигальпа, Ла-Сейба',
+    value: 'America/Tegucigalpa',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Калгари, Эдмонтон, Ред Дир',
+    value: 'America/Edmonton',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Белиз, Сан-Игнасио, Бельмопан',
+    value: 'America/Belize',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Бойсе, Меридиан, Айдахо-Фолс',
+    value: 'America/Boise',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Гватемала, Сан-Сальвадор, Кесальтенанго',
+    value: 'America/Guatemala',
+  },
+  {
+    offset: '-06:00',
+    text: '(UTC-06:00) Мус-Джо, Свифт-Керрент, Йорктон',
+    value: 'America/Regina',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Джорджтаун, Кайман-Брак',
+    value: 'America/Cayman',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Богота, Медельин, Кали',
+    value: 'America/Bogota',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Эвансвилл, Мичиган Сити, Вальпараисо',
+    value: 'America/Indiana/Knox',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Риу-Бранку, Табатинга',
+    value: 'America/Rio_Branco',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Чикаго, Эль-Пасо, Нашвилл',
+    value: 'America/Chicago',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Гуаякиль, Кито, Куэнка',
+    value: 'America/Guayaquil',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Кенора, Драйден, Ред-Лейк',
+    value: 'America/Atikokan',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Виннипег, Брандон, Томпсон',
+    value: 'America/Winnipeg',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Канкун, Четумаль, Плайя-дель-Кармен',
+    value: 'America/Cancun',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Мемфис, Джексон, Кливленд',
+    value: 'America/Menominee',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Лима, Панама, Трухильо',
+    value: 'America/Lima',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Мехико, Гвадалахара, Леон',
+    value: 'America/Mexico_City',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Монтеррей',
+    value: 'America/Monterrey',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Хьюстон, Сан-Антонио, Даллас',
+    value: 'America/North_Dakota/Center',
+  },
+  {
+    offset: '-05:00',
+    text: '(UTC-05:00) Кингстон, Монтего-Бей, Мей Пен',
+    value: 'America/Jamaica',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Манаус, Боа-Виста, Паринтинс',
+    value: 'America/Manaus',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Санта-Крус-де-ла-Сьерра, Эль-Альто, Кочабамба',
+    value: 'America/La_Paz',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Сент-Джорджес',
+    value: 'America/Grenada',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Асунсьон, Сьюдад-дель-Эсте, Энкарнасьон',
+    value: 'America/Asuncion',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Нассау, Фрипорт, Джордж Таун',
+    value: 'America/Nassau',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Икалуит',
+    value: 'America/Iqaluit',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Индианаполис, Кармел, Кокомо',
+    value: 'America/Indiana/Marengo',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Виллемстад, Филипсбург, Гран-Каз',
+    value: 'America/Curacao',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Кингстаун',
+    value: 'America/St_Vincent',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Нью-Йорк, Цицеро, Филадельфия',
+    value: 'America/New_York',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Коберн-Таун',
+    value: 'America/Grand_Turk',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Сан-Хуан, Каролина, Понсе',
+    value: 'America/Puerto_Rico',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Саут-Бенд, Блумингтон, Мунчи',
+    value: 'America/Indianapolis',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Тандер-Бей',
+    value: 'America/Thunder_Bay',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Сент-Джонс',
+    value: 'America/Antigua',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Квебек, Монреаль, Лаваль',
+    value: 'America/Montreal',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Санто-Доминго, Сантьяго-де-лос-Кабальерос, Санто-Доминго-Есте',
+    value: 'America/Santo_Domingo',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Бастер',
+    value: 'America/St_Kitts',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Валли',
+    value: 'America/Anguilla',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Порт-о-Пренс, Гонаив, Кап-Аитьен',
+    value: 'America/Port-au-Prince',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Кралендейк',
+    value: 'America/Kralendijk',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Кампу-Гранди, Дорадус, Корумба',
+    value: 'America/Campo_Grande',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Сантьяго, Антофагаста, Винья-дель-Мар',
+    value: 'America/Santiago',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Порту-Велью, Жи-Парана, Вильена',
+    value: 'America/Porto_Velho',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Густавия',
+    value: 'America/St_Barthelemy',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Джорджтаун, Нью-Амстердам',
+    value: 'America/Guyana',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Каракас, Валенсия, Маракай',
+    value: 'America/Caracas',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Порт-оф-Спейн',
+    value: 'America/Port_of_Spain',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Плимут',
+    value: 'America/Montserrat',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Род-Таун, Кристианстед',
+    value: 'America/Tortola',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Форт-Уэйн, Терр Хот',
+    value: 'America/Indiana/Vevay',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Колумбус, Луисвилл, Лексингтон',
+    value: 'America/Louisville',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Бриджтаун',
+    value: 'America/Barbados',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Ле-Гозье, Пуант-а-Питр, Сент-Франсуа',
+    value: 'America/Guadeloupe',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Куяба, Варзеа-Гранди, Рондонополис',
+    value: 'America/Cuiaba',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Фор-де-Франс, Кастри, Грос Ислет',
+    value: 'America/Dominica',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Шарлотта-Амалия',
+    value: 'America/St_Thomas',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Атланта',
+    value: 'America/Kentucky/Monticello',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Гавана, Тринидад, Сантьяго-де-Куба',
+    value: 'America/Havana',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Ораньестад, Ноорд, Пальм Бич',
+    value: 'America/Aruba',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Детройт, Гранд-Рапидс, Уоррен',
+    value: 'America/Detroit',
+  },
+  {
+    offset: '-04:00',
+    text: '(UTC-04:00) Торонто, Оттава, Миссиссага',
+    value: 'America/Toronto',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Хеппи-Валли-Гуз-Бэй, Вабуш, Нейн',
+    value: 'America/Goose_Bay',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Сент-Джордж, Гамильтон',
+    value: 'Atlantic/Bermuda',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Салвадор, Масейо, Натал',
+    value: 'America/Maceio',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Галифакс, Сент-Джон, Дартмут',
+    value: 'America/Halifax',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Форталеза, Аракажу, Каукая',
+    value: 'America/Fortaleza',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Парамарибо, Кайенна',
+    value: 'America/Cayenne',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Ресифи, Терезина, Жабоатан-дус-Гуарарапес',
+    value: 'America/Recife',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Сан-Паулу, Рио-де-Жанейро, Бразилиа',
+    value: 'America/Sao_Paulo',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Сан-Луис, Жуан-Песоа, Кампина-Гранди',
+    value: 'America/Santarem',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Белен, Ананиндеуа, Сантарен',
+    value: 'America/Belem',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Монтевидео, Сальто, Пайсанду',
+    value: 'America/Montevideo',
+  },
+  {
+    offset: '-03:00',
+    text: '(UTC-03:00) Буэнос-Айрес, Кордова, Росарио',
+    value: 'America/Argentina/Buenos_Aires',
+  },
+  {
+    offset: '-02:30',
+    text: '(UTC-02:30) Сент-Джонс, Корнер-Брук, Гандер',
+    value: 'America/St_Johns',
+  },
+  {
+    offset: '-02:00',
+    text: '(UTC-02:00) Нуук, Какорток, Маниитсок',
+    value: 'America/Godthab',
+  },
+  {
+    offset: '-02:00',
+    text: '(UTC-02:00) Фернанду-ди-Норонья',
+    value: 'America/Noronha',
+  },
+  {
+    offset: '-01:00',
+    text: '(UTC-01:00) Прая, Сан-Филип, Минделу',
+    value: 'Atlantic/Cape_Verde',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Бамако, Уагадугу, Бобо-Диуласо',
+    value: 'Africa/Bamako',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Монровия',
+    value: 'Africa/Monrovia',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Дакар, Мбур, Сен-Луи',
+    value: 'Africa/Dakar',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Аккра, Кумаси, Тамале',
+    value: 'Africa/Accra',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Фритаун, Бо, Макени',
+    value: 'Africa/Freetown',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Нуакшот, Нуадибу, Росо',
+    value: 'Africa/Nouakchott',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Мамуджу, Атафу, Факаофо',
+    value: '',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Конакри, Канкан, Лабе',
+    value: 'Africa/Conakry',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Вестманнаэйяр',
+    value: 'Atlantic/Reykjavik',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Сан-Томе',
+    value: 'Africa/Sao_Tome',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Орта',
+    value: 'Atlantic/Azores',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Бисау, Сан-Домингус, Бафата',
+    value: 'Africa/Bissau',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Банжул, Фарафенни',
+    value: 'Africa/Banjul',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Абиджан, Буаке, Сан-Педро',
+    value: 'Africa/Abidjan',
+  },
+  {
+    offset: '+00:00',
+    text: '(UTC+00:00) Ломе, Кара, Кпалиме',
+    value: 'Africa/Lome',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Банги, Бамбари, Нола',
+    value: 'Africa/Bangui',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Котону, Джугу, Порто-Ново',
+    value: 'Africa/Porto-Novo',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Ниамей, Зиндер, Тахуа',
+    value: 'Africa/Niamey',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Вила-Нова-ди-Гая, Матозиньюш, Уэйраш',
+    value: 'Europe/Lisbon',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Киншаса, Луанда, Мбужи-Майи',
+    value: 'Africa/Kinshasa',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Дублин, Свордс, Дроэда',
+    value: 'Europe/Dublin',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Эль-Аюн, Бир-Лелу',
+    value: 'Africa/El_Aaiun',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Тунис, Сфакс, Бизерта',
+    value: 'Africa/Tunis',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Киркьюбур',
+    value: 'Atlantic/Faeroe',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Алжир, Оран, Константина',
+    value: 'Africa/Algiers',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Либревиль, Порт-Жантиль, Гамба',
+    value: 'Africa/Libreville',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Лас-Пальмас-де-Гран-Канария, Санта-Крус-де-Тенерифе, Арона',
+    value: 'Atlantic/Canary',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Нджамена, Абеше, Файя-Ларжо',
+    value: 'Africa/Ndjamena',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Лондон, Бирмингем, Лидс',
+    value: 'Europe/London',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Браззавиль, Пуэнт-Нуар, Долизи',
+    value: 'Africa/Brazzaville',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Бата, Малабо',
+    value: 'Africa/Malabo',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Дуала, Яунде, Баменда',
+    value: 'Africa/Douala',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Лагос, Кано, Ибадан',
+    value: 'Africa/Lagos',
+  },
+  {
+    offset: '+01:00',
+    text: '(UTC+01:00) Касабланка, Фес, Рабат',
+    value: 'Africa/Casablanca',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Рим, Милан, Неаполь',
+    value: 'Europe/Rome',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Сан-Марино',
+    value: 'Europe/San_Marino',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Сараево',
+    value: 'Europe/Sarajevo',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Варшава, Лодзь, Краков',
+    value: 'Europe/Warsaw',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Будапешт, Шопрон, Хайдусобосло',
+    value: 'Europe/Budapest',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Дикирх, Гревенмахер, Клерво',
+    value: 'Europe/Luxembourg',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Шарлеруа, Алст, Кортрейк',
+    value: 'Europe/Brussels',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Мадрид, Барселона, Валенсия',
+    value: 'Europe/Madrid',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Вадуц',
+    value: 'Europe/Vaduz',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Мапуту, Нампула, Бейра',
+    value: 'Africa/Maputo',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Берлин, Гамбург, Мюнхен',
+    value: 'Europe/Berlin',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Йоханнесбург, Кейптаун, Претория',
+    value: 'Africa/Johannesburg',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Кигали',
+    value: 'Africa/Kigali',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Каир, Александрия, Гиза',
+    value: 'Africa/Cairo',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Зеница, Мостар, Тузла',
+    value: 'Europe/Skopje',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Гибралтар',
+    value: 'Europe/Gibraltar',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Прага, Йиглава, Байё',
+    value: 'Europe/Prague',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Париж, Марсель, Лион',
+    value: 'Europe/Paris',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Белград, Лесковац, Биела',
+    value: 'Europe/Belgrade',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Ватикан',
+    value: 'Europe/Vatican',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Бужумбура',
+    value: 'Africa/Bujumbura',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Саннес, Олесунн, Стрюн',
+    value: 'Europe/Oslo',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Триполи, Бенгази, Аль-Байда',
+    value: 'Africa/Tripoli',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Сант-Жулиа-де-Лория, Энкам',
+    value: 'Europe/Andorra',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Сент-Полс-Бэй, Меллиеха, Валлетта',
+    value: 'Europe/Malta',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Риека, Винковцы, Пореч',
+    value: 'Europe/Zagreb',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Тилбург, Неймеген, Бреда',
+    value: 'Europe/Amsterdam',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Виндхук, Уолфиш-Бей, Рунду',
+    value: 'Africa/Windhoek',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Омдурман, Хартум, Ньяла',
+    value: 'Africa/Khartoum',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Масеру',
+    value: 'Africa/Maseru',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Мбабане, Манзини',
+    value: 'Africa/Mbabane',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Эсбьерг, Раннерс, Кольдинг',
+    value: 'Europe/Copenhagen',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Липтовски-Микулаш, Братислава',
+    value: 'Europe/Bratislava',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Марибор, Крань, Целе',
+    value: 'Europe/Ljubljana',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Саранда, Химара, Тирана',
+    value: 'Europe/Tirane',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Булавайо, Хараре, Гверу',
+    value: 'Africa/Harare',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Габороне, Франсистаун, Молепололе',
+    value: 'Africa/Gaborone',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Винтертур, Тун, Кройцлинген',
+    value: 'Europe/Zurich',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Калининград, Советск, Черняховск',
+    value: 'Europe/Kaliningrad',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Лунд, Бурос, Хельсингборг',
+    value: 'Europe/Stockholm',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Вена, Вельс, Винер-Нойштадт',
+    value: 'Europe/Vienna',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Лусака, Лилонгве, Блантайр',
+    value: 'Africa/Lusaka',
+  },
+  {
+    offset: '+02:00',
+    text: '(UTC+02:00) Монако',
+    value: 'Europe/Monaco',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Ришон-ле-Цион, Холон, Рамла',
+    value: 'Asia/Jerusalem',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Багдад, Басра, Мосул',
+    value: 'Asia/Baghdad',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Киев, Харьков, Днепр',
+    value: 'Europe/Kiev',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Эспоо, Вантаа, Хамина',
+    value: 'Europe/Helsinki',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Джибути',
+    value: 'Africa/Djibouti',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Рамалла',
+    value: 'Asia/Gaza',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Кампала, Джинджа, Энтеббе',
+    value: 'Africa/Kampala',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Бухарест, Дорохой, Отопени',
+    value: 'Europe/Bucharest',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Бейрут, Эн-Набатия, Захле',
+    value: 'Asia/Beirut',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Таллин, Тарту, Нарва',
+    value: 'Europe/Tallinn',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Морони',
+    value: 'Indian/Comoro',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Вильнюс, Каунас, Клайпеда',
+    value: 'Europe/Vilnius',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Стамбул, Анкара, Измир',
+    value: 'Europe/Istanbul',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Рига, Даугавпилс, Лиепая',
+    value: 'Europe/Riga',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Найроби, Момбаса, Кисуму',
+    value: 'Africa/Nairobi',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) София, Варна, Тырговиште',
+    value: 'Europe/Sofia',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Эр-Рияд, Джидда, Мекка',
+    value: 'Asia/Riyadh',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Москва, Санкт-Петербург, Нижний Новгород',
+    value: 'Europe/Moscow',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Могадишо, Харгейса, Бербера',
+    value: 'Africa/Mogadishu',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Амман, Маан, Акаба',
+    value: 'Asia/Amman',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Дар-эс-Салам, Мванза, Аруша',
+    value: 'Africa/Dar_es_Salaam',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Эль-Кувейт',
+    value: 'Asia/Kuwait',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Антананариву, Туамасина, Анцирабе',
+    value: 'Indian/Antananarivo',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Асмэра, Массауа',
+    value: 'Africa/Asmera',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Афины, Пирей, Волос',
+    value: 'Europe/Athens',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Минск, Гомель, Витебск',
+    value: 'Europe/Minsk',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Сана, Эль-Бейда, Амран',
+    value: 'Asia/Aden',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Аддис-Абеба, Дыре-Дауа, Гондэр',
+    value: 'Africa/Addis_Ababa',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Паралимни, Лапитос, Пейя',
+    value: 'Asia/Nicosia',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Алеппо, Дамаск, Эль-Хасака',
+    value: 'Asia/Damascus',
+  },
+  {
+    offset: '+03:00',
+    text: '(UTC+03:00) Доха',
+    value: 'Asia/Bahrain',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Эс-Сиб, Хайма, Маскат',
+    value: 'Asia/Muscat',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Дубай, Абу-Даби',
+    value: 'Asia/Dubai',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Ульяновск, Димитровград, Инза',
+    value: 'Europe/Ulyanovsk',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Порт-Луи, Вакоа-Феникс, Виктория',
+    value: 'Indian/Mauritius',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Баку, Сумгаит, Гянджа',
+    value: 'Asia/Baku',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Астрахань, Ахтубинск, Знаменск',
+    value: 'Europe/Astrakhan',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Саратов, Энгельс, Балаково',
+    value: 'Europe/Saratov',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Волгоград, Волжский, Камышин',
+    value: 'Europe/Volgograd',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Самара, Тольятти, Ижевск',
+    value: 'Europe/Samara',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Сен-Дени, Сен-Пьер, Силао',
+    value: 'Indian/Reunion',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Тбилиси, Зугдиди, Озургети',
+    value: 'Asia/Tbilisi',
+  },
+  {
+    offset: '+04:00',
+    text: '(UTC+04:00) Ереван, Гюмри, Ванадзор',
+    value: 'Asia/Yerevan',
+  },
+  {
+    offset: '+04:30',
+    text: '(UTC+04:30) Кабул, Кандагар, Герат',
+    value: 'Asia/Kabul',
+  },
+  {
+    offset: '+04:30',
+    text: '(UTC+04:30) Тегеран, Мешхед, Исфахан',
+    value: 'Asia/Tehran',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Душанбе, Худжанд, Куляб',
+    value: 'Asia/Dushanbe',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Адду, Фувахмулах, Кулудуффуши',
+    value: 'Indian/Maldives',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Актау',
+    value: 'Asia/Aqtau',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Карачи, Лахор, Фейсалабад',
+    value: 'Asia/Karachi',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Актобе, Уральск, Атырау',
+    value: 'Asia/Aqtobe',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Кызылорда, Байконур, Аральск',
+    value: 'Asia/Qyzylorda',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Екатеринбург, Челябинск, Уфа',
+    value: 'Asia/Yekaterinburg',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Ашхабад, Туркменабад, Дашогуз',
+    value: 'Asia/Ashgabat',
+  },
+  {
+    offset: '+05:00',
+    text: '(UTC+05:00) Ташкент, Наманган, Андижан',
+    value: 'Asia/Tashkent',
+  },
+  {
+    offset: '+05:30',
+    text: '(UTC+05:30) Мумбаи, Дели, Бангалор',
+    value: 'Asia/Kolkata',
+  },
+  {
+    offset: '+05:30',
+    text: '(UTC+05:30) Полоннарува, Дехивала-Маунт-Лавиния, Негомбо',
+    value: 'Asia/Colombo',
+  },
+  {
+    offset: '+05:45',
+    text: '(UTC+05:45) Покхара, Бхаратпур, Биргандж',
+    value: 'Asia/Kathmandu',
+  },
+  {
+    offset: '+06:00',
+    text: '(UTC+06:00) Алматы, Нур-Султан (Астана), Шымкент',
+    value: 'Asia/Almaty',
+  },
+  {
+    offset: '+06:00',
+    text: '(UTC+06:00) Бишкек, Ош, Джалал-Абад',
+    value: 'Asia/Bishkek',
+  },
+  {
+    offset: '+06:00',
+    text: '(UTC+06:00) Омск, Тара, Исилькуль',
+    value: 'Asia/Omsk',
+  },
+  {
+    offset: '+06:00',
+    text: '(UTC+06:00) Тхимпху',
+    value: 'Asia/Thimphu',
+  },
+  {
+    offset: '+06:00',
+    text: '(UTC+06:00) Дакка, Читтагонг, Нараянгандж',
+    value: 'Asia/Dhaka',
+  },
+  {
+    offset: '+06:30',
+    text: '(UTC+06:30) Уэст-Айленд',
+    value: 'Indian/Cocos',
+  },
+  {
+    offset: '+06:30',
+    text: '(UTC+06:30) Янгон, Мандалай, Патейн',
+    value: 'Asia/Rangoon',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Красноярск, Абакан, Норильск',
+    value: 'Asia/Krasnoyarsk',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Кемерово, Новокузнецк, Прокопьевск',
+    value: 'Asia/Novokuznetsk',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Хошимин, Ханой, Чавинь',
+    value: 'Asia/Vientiane',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Новосибирск, Бердск, Искитим',
+    value: 'Asia/Novosibirsk',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Томск, Северск, Стрежевой',
+    value: 'Asia/Tomsk',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Бангкок, Самутпракан, Самутсонгкхрам',
+    value: 'Asia/Bangkok',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Джакарта, Сурабая, Бандунг',
+    value: 'Asia/Jakarta',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Пномпень, Баттамбанг, Сиануквиль',
+    value: 'Asia/Phnom_Penh',
+  },
+  {
+    offset: '+07:00',
+    text: '(UTC+07:00) Барнаул, Бийск, Рубцовск',
+    value: 'Asia/Barnaul',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Сингапур',
+    value: 'Asia/Singapore',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Макасар, Сукавати, Танджунгселор',
+    value: 'Asia/Makassar',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Иркутск, Улан-Удэ, Братск',
+    value: 'Asia/Irkutsk',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Чунцин, Шанхай, Пекин',
+    value: 'Asia/Shanghai',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Кесон-Сити, Давао, Манила',
+    value: 'Asia/Manila',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Куала-Лумпур, Ипох, Шах-Алам',
+    value: 'Asia/Kuala_Lumpur',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Перт, Рокингем, Банбери',
+    value: 'Australia/Perth',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Улан-Батор, Сайншанд, Арвайхээр',
+    value: 'Asia/Ulaanbaatar',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Харбин',
+    value: 'Asia/Harbin',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Макао (Аомынь)',
+    value: 'Asia/Macau',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Бандар-Сери-Бегаван',
+    value: 'Asia/Brunei',
+  },
+  {
+    offset: '+08:00',
+    text: '(UTC+08:00) Нанкин',
+    value: 'Asia/Taipei',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Якутск, Благовещенск, Белогорск',
+    value: 'Asia/Yakutsk',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Корор, Нгерулмуд',
+    value: 'Pacific/Palau',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Сеул, Пусан, Инчхон',
+    value: 'Asia/Seoul',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Чита, Краснокаменск, Борзя',
+    value: 'Asia/Chita',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Дили',
+    value: 'Asia/Dili',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Токио, Йокогама, Осака',
+    value: 'Asia/Tokyo',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Пхеньян, Чхонджин',
+    value: 'Asia/Pyongyang',
+  },
+  {
+    offset: '+09:00',
+    text: '(UTC+09:00) Мерауке',
+    value: 'Asia/Jayapura',
+  },
+  {
+    offset: '+09:30',
+    text: '(UTC+09:30) Аделаида, Маунт-Гамбир, Уайалла',
+    value: 'Australia/Adelaide',
+  },
+  {
+    offset: '+09:30',
+    text: '(UTC+09:30) Брокен Хилл',
+    value: 'Australia/Broken_Hill',
+  },
+  {
+    offset: '+09:30',
+    text: '(UTC+09:30) Дарвин, Алис-Спрингс, Кэтрин',
+    value: 'Australia/Darwin',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Сидней, Канберра, Вуллонгонг',
+    value: 'Australia/Sydney',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Хабаровск, Владивосток, Комсомольск-на-Амуре',
+    value: 'Asia/Vladivostok',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Брисбен, Голд-Кост, Саншайн-Кост',
+    value: 'Australia/Brisbane',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Мельбурн, Джелонг, Балларат',
+    value: 'Australia/Melbourne',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Вено, Колониа, Паликир',
+    value: 'Pacific/Yap',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Порт-Морсби, Лаэ, Маунт-Хаген',
+    value: 'Pacific/Port_Moresby',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Хагатна',
+    value: 'Pacific/Guam',
+  },
+  {
+    offset: '+10:00',
+    text: '(UTC+10:00) Хобарт, Лонсестон, Девонпорт',
+    value: 'Australia/Hobart',
+  },
+  {
+    offset: '+11:00',
+    text: '(UTC+11:00) Среднеколымск, Северо-Курильск',
+    value: 'Asia/Srednekolymsk',
+  },
+  {
+    offset: '+11:00',
+    text: '(UTC+11:00) Хониара, Порт-Вила, Сола',
+    value: 'Pacific/Efate',
+  },
+  {
+    offset: '+11:00',
+    text: '(UTC+11:00) Южно-Сахалинск, Корсаков, Холмск',
+    value: 'Asia/Sakhalin',
+  },
+  {
+    offset: '+11:00',
+    text: '(UTC+11:00) Кингстон',
+    value: 'Pacific/Norfolk',
+  },
+  {
+    offset: '+11:00',
+    text: '(UTC+11:00) Нумеа, Кумак, Ве',
+    value: 'Pacific/Noumea',
+  },
+  {
+    offset: '+11:00',
+    text: '(UTC+11:00) Магадан, Сусуман',
+    value: 'Asia/Magadan',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Окленд, Веллингтон, Крайстчерч',
+    value: 'Pacific/Auckland',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Фунафути',
+    value: 'Pacific/Funafuti',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Южная Тарава, Байрики',
+    value: 'Pacific/Tarawa',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Маджуро',
+    value: 'Pacific/Majuro',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Петропавловск-Камчатский, Елизово, Вилючинск',
+    value: 'Asia/Kamchatka',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Сува, Нанди, Лабаса',
+    value: 'Pacific/Fiji',
+  },
+  {
+    offset: '+12:00',
+    text: '(UTC+12:00) Ярен',
+    value: 'Pacific/Nauru',
+  },
+  {
+    offset: '+13:00',
+    text: '(UTC+13:00) Апиа',
+    value: 'Pacific/Apia',
+  },
+  {
+    offset: '+13:00',
+    text: '(UTC+13:00) Нукуалофа, Нейафу, Пангаи',
+    value: 'Pacific/Tongatapu',
+  },
+];

+ 21 - 0
src/modules/part-entities/CreatePartEntityPage.vue

@@ -0,0 +1,21 @@
+<template>
+  <div>
+    <q-card v-if="partEntityFields">
+      <q-card-section>
+        <CustomForm :fields="partEntityFields" />
+      </q-card-section>
+      <q-card-section v-if="">
+    </q-card>
+  </div>
+</template>
+
+<script setup lang="ts">
+import CustomForm from 'src/components/custom-form/CustomForm.vue';
+import { usePartEntitiesStore } from 'src/modules/part-entities/part-entities-store';
+const partEntitiesStore = usePartEntitiesStore();
+const partEntityFields = await partEntitiesStore.getPartEntityFields();
+
+import { useRoute } from 'vue-router';
+const route = useRoute();
+const partEntityId = route.params.partEntityId;
+</script>

+ 25 - 0
src/modules/part-entities/MyPartEntitiesPage.vue

@@ -0,0 +1,25 @@
+<template>
+  <q-page>
+    <div>
+      <q-card
+        clickable
+        @click="partEntitiesStore.goToPartEntity(partEntity.peId)"
+        v-for="partEntity in partEntities"
+        :key="partEntity.peId"
+      >
+        <q-card-section>
+          {{ partEntity.name }}
+        </q-card-section>
+      </q-card>
+    </div>
+  </q-page>
+</template>
+
+<script setup lang="ts">
+import { storeToRefs } from 'pinia';
+import { usePartEntitiesStore } from 'src/modules/part-entities/part-entities-store';
+const partEntitiesStore = usePartEntitiesStore();
+const { partEntities } = storeToRefs(partEntitiesStore);
+
+void partEntitiesStore.getPartEntities();
+</script>

+ 48 - 0
src/modules/part-entities/create-pe-store.ts

@@ -0,0 +1,48 @@
+import { defineStore } from 'pinia';
+import { computed, ref } from 'vue';
+import type { PartEntityFull, PartEntityShort, PeCreateType } from './part-entity-types';
+import { $api } from 'src/boot/axios';
+import errorMiddleware from 'src/utils/error-middleware';
+import type { PartEntitiesApi } from 'src/api/api';
+import type { z } from 'zod';
+import { useRoute, useRouter } from 'vue-router';
+
+export const useCreatePeStore = defineStore('create-pe-store', () => {
+  const route = useRoute();
+  const router = useRouter();
+
+  //
+  const partEntities = ref<PartEntityShort[]>([]);
+
+  const currentPeCreateTypeCode = computed({
+    get: () => {
+      const peCreateTypeCode = route.params.peCreateTypeCode;
+      if (peCreateTypeCode != undefined && typeof peCreateTypeCode === 'string') {
+        return peCreateTypeCode;
+      }
+      return undefined;
+    },
+    set: (value: string) => {
+      void router.push({
+        path: `/pe/create/${value}`,
+      });
+    },
+  });
+  const currentCreatePeData = ref<null | PeCreateType>(null);
+
+  const getPeCreateTypeData = async (peTypeCode: string) => {
+    try {
+      const { data } = await $api.get<z.infer<typeof PartEntitiesApi.GET_PeCreateTypeData.res>>(
+        `/part-entities/create/${peTypeCode}`,
+      );
+      currentCreatePeData.value = data.peType;
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  return {
+    partEntities,
+    getPeCreateTypeData,
+  };
+});

+ 25 - 0
src/modules/part-entities/part-entity-types.ts

@@ -0,0 +1,25 @@
+import type { PartEntitiesApi } from 'src/api/api';
+import type { z } from 'zod';
+
+export type PartEntityShort = {
+  peId: string;
+  name: string;
+};
+
+export type PartEntityFull = PartEntityShort & {
+  members: {
+    memberId: string;
+    userId: string;
+    email: string;
+    firstName: string;
+    lastName: string;
+    patronymic: string;
+  }[];
+};
+
+type CustomField = z.infer<typeof PartEntitiesApi.CustomField>;
+export type PeCreateType = {
+  peTypeId: string;
+  code: string;
+  fields: CustomField[];
+};

+ 64 - 0
src/modules/part-entities/pe-store.ts

@@ -0,0 +1,64 @@
+import { defineStore } from 'pinia';
+import { computed, ref } from 'vue';
+import type { PartEntityFull, PartEntityShort } from './part-entity-types';
+import { $api } from 'src/boot/axios';
+import errorMiddleware from 'src/utils/error-middleware';
+import type { PartEntitiesApi } from 'src/api/api';
+import type { z } from 'zod';
+import { useRoute, useRouter } from 'vue-router';
+
+export const usePeStore = defineStore('part-entities-store', () => {
+  const route = useRoute();
+  const router = useRouter();
+
+  //
+  const partEntities = ref<PartEntityShort[]>([]);
+
+  const currentPartEntityId = computed({
+    get: () => {
+      const peId = route.params.partEntityId;
+      if (peId != undefined && typeof peId === 'string') {
+        return peId;
+      }
+      return undefined;
+    },
+    set: (value: string) => {
+      void router.push({
+        path: `/pe/${value}`,
+      });
+    },
+  });
+  const currentPartEntityData = ref<null | PartEntityFull>(null);
+
+  const getPartEntities = async () => {
+    try {
+      const { data } =
+        await $api.get<z.infer<typeof PartEntitiesApi.GET_PartEntities.res>>(
+          '/part-entities?=short',
+        );
+      partEntities.value = data.partEntities;
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  const getCurrentPartEntity = async () => {
+    try {
+      if (!currentPartEntityId.value) throw new Error('Сущность участия не выбрана');
+
+      const { data } = await $api.get<z.infer<typeof PartEntitiesApi.GET_PartEntity.res>>(
+        `/part-entities/${currentPartEntityId.value}`,
+      );
+      currentPartEntityData.value = data;
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  return {
+    partEntities,
+    getPartEntities,
+    getPartEntityFields,
+    getCurrentPartEntity,
+  };
+});

BIN
src/modules/users/auth/assets/login-bg.jpg


+ 135 - 0
src/modules/users/auth/components/ConfirmRegistration.vue

@@ -0,0 +1,135 @@
+<template>
+  <q-form
+    ref="registrationFormSubmit"
+    @submit="
+      if (pin !== undefined) {
+        authStore.confirmRegistration(pin, name, password);
+      }
+    "
+  >
+    <q-input
+      @keydown.enter.prevent="passwordInput.focus()"
+      color="grey"
+      dark
+      v-model="name"
+      label="Имя"
+      :rules="[
+        (val) => (val && val.length > 0) || 'Пожалуйста, введите ваше имя',
+      ]"
+    >
+      <template v-slot:prepend>
+        <q-icon name="mdi-account" />
+      </template>
+    </q-input>
+
+    <q-input
+      @keydown.enter.prevent="confirmPasswordInput.focus()"
+      ref="passwordInput"
+      color="grey"
+      type="password"
+      dark
+      v-model="password"
+      label="Пароль"
+      :rules="[
+        (val) => (val && val.length > 0) || 'Пожалуйста, введите пароль',
+        (val) =>
+          (val && val.length > 5) ||
+          'Пароль должен содержать минимум 6 символов.',
+      ]"
+    >
+      <template v-slot:prepend>
+        <q-icon name="mdi-lock" />
+      </template>
+    </q-input>
+
+    <q-input
+      @keydown.enter.prevent="pinInput?.focus()"
+      ref="confirmPasswordInput"
+      color="grey"
+      type="password"
+      dark
+      v-model="confirmPassword"
+      label="Повторите пароль"
+      :rules="[
+        (val) => (val && val.length > 0) || 'Пожалуйста, повторите пароль',
+        (val) => val === password || 'Пароли не совпадают',
+      ]"
+    >
+      <template v-slot:prepend>
+        <q-icon name="mdi-lock" />
+      </template>
+    </q-input>
+
+    <q-input
+      @keydown.enter.prevent="loginFormSubmit?.submit()"
+      ref="pinInput"
+      color="grey"
+      dark
+      v-model.number="pin"
+      label="Пинкод"
+      :erros="registrationPinError"
+      :rules="[(val) => val !== undefined || 'Пожалуйста, введите пин']"
+    >
+      <template v-slot:prepend>
+        <q-icon name="mdi-lock" />
+      </template>
+    </q-input>
+
+    <div class="row justify-center q-pt-sm">
+      <q-btn
+        type="submit"
+        style="
+          background-color: rgba(255, 255, 255, 0.212);
+          border-radius: 8px;
+          width: 200px;
+        "
+        >Подтвердить</q-btn
+      >
+    </div>
+  </q-form>
+
+  <!-- <confirm-pin-input
+    @pin-is-complete="authStore.confirmRegistration($event)"
+    :error="registrationPinError"
+    :tries-remained="registrationPinTriesRemained"
+  ></confirm-pin-input> -->
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+// import ConfirmPinInput from 'src/modules/users-management/other/ConfirmPinInput.vue';
+
+import { useAuthStore } from '../stores/auth-store';
+import { storeToRefs } from 'pinia';
+const authStore = useAuthStore();
+
+const { registrationPinError, registrationPinTriesRemained } =
+  storeToRefs(authStore);
+
+//
+const name = ref('');
+const password = ref('');
+const confirmPassword = ref('');
+const pin = ref<number>();
+
+// refs
+const passwordInput = ref();
+const confirmPasswordInput = ref();
+const pinInput = ref();
+
+const isAgree = ref(false);
+
+// form
+import { QForm } from 'quasar';
+
+const loginFormSubmit = ref<QForm>();
+//
+//
+
+const sum = (a: number, b: number) => {
+  return a + b;
+};
+
+sum(1, 2);
+</script>

+ 15 - 0
src/modules/users/auth/components/LoginAndRegistration.vue

@@ -0,0 +1,15 @@
+<template>
+  <!-- login -->
+  <login-component v-if="authStore.authStage === 'login'" />
+  <registration-component v-if="authStore.authStage === 'registration'" />
+  <confirm-registration v-if="authStore.authStage === 'pin'" />
+</template>
+
+<script setup lang="ts">
+import LoginComponent from './LoginComponent.vue';
+import RegistrationComponent from './RegistrationComponent.vue';
+import ConfirmRegistration from './ConfirmRegistration.vue';
+
+import { useAuthStore } from '../stores/auth-store';
+const authStore = useAuthStore();
+</script>

+ 88 - 0
src/modules/users/auth/components/LoginComponent.vue

@@ -0,0 +1,88 @@
+<template>
+  <div>
+    <q-form ref="loginFormSubmit" @submit="authStore.login(mail, password)">
+      <q-input
+        @keydown.enter.prevent="passwordInput.focus()"
+        class="q-mb-lg"
+        color="grey"
+        dark
+        v-model="mail"
+        label="Почита"
+        :rules="[
+          (val) => (val && val.length > 0) || 'Пожалуйста, введите вашу почту',
+          (val) =>
+            validator.isEmail(val) || 'Пожалуйста, введите корректную почту',
+        ]"
+      >
+        <template v-slot:prepend>
+          <q-icon name="mdi-account" />
+        </template>
+      </q-input>
+
+      <q-input
+        @keydown.enter.prevent="loginFormSubmit?.submit()"
+        ref="passwordInput"
+        class="q-mb-lg"
+        color="grey"
+        type="password"
+        dark
+        v-model="password"
+        label="Пароль"
+        :rules="[
+          (val) => (val && val.length > 0) || 'Пожалуйста, введите пароль',
+        ]"
+      >
+        <template v-slot:prepend>
+          <q-icon name="mdi-lock" />
+        </template>
+      </q-input>
+
+      <!-- <q-checkbox
+          style="color: rgb(194, 194, 194)"
+          class="q-mb-lg"
+          color="red"
+          dark
+          v-model="isRemember"
+          label="Запомнить меня"
+        /> -->
+
+      <div class="row justify-center q-pt-sm">
+        <q-btn type="submit" class="login-btn">Войти</q-btn>
+      </div>
+    </q-form>
+
+    <div class="text-center q-mt-sm">
+      <q-btn @click="authStore.authStage = 'registration'" size="sm" flat
+        >У меня нет профиля</q-btn
+      >
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+
+import { useAuthStore } from '../stores/auth-store';
+const authStore = useAuthStore();
+
+const passwordInput = ref();
+
+let mail = ref('');
+let password = ref('');
+// let isRemember = ref(false);
+
+// form
+import { QForm } from 'quasar';
+const loginFormSubmit = ref<QForm>();
+
+// valid
+import validator from 'validator';
+</script>
+
+<style>
+.login-btn {
+  background-color: rgba(255, 255, 255, 0.212);
+  border-radius: 8px;
+  width: 200px;
+}
+</style>

+ 68 - 0
src/modules/users/auth/components/RegistrationComponent.vue

@@ -0,0 +1,68 @@
+<template>
+  <div>
+    <q-form
+      ref="registrationFormSubmit"
+      @submit="authStore.registration(email)"
+    >
+      <q-input
+        color="grey"
+        dark
+        v-model="email"
+        label="Почита"
+        :rules="[
+          (val) => (val && val.length > 0) || 'Пожалуйста, введите вашу почту',
+          (val) =>
+            validator.isEmail(val) || 'Пожалуйста, введите корректную почту',
+        ]"
+      >
+        <template v-slot:prepend>
+          <q-icon name="mdi-account" />
+        </template>
+      </q-input>
+
+      <!--
+      <q-field
+        :model-value="isAgree"
+        :rules="[(val) => val || 'Поставьте своё согласие']"
+        hide-bottom-space
+        dense
+        borderless
+      >
+        <q-checkbox style="color: rgb(194, 194, 194)" dark v-model="isAgree" />
+        <a href="https://ya.ru/" target="_blank">
+          Согласен с политикой конфедициальности</a
+        >
+      </q-field> -->
+
+      <div class="row justify-center q-pt-sm">
+        <q-btn
+          type="submit"
+          style="
+            background-color: rgba(255, 255, 255, 0.212);
+            border-radius: 8px;
+            width: 200px;
+          "
+          >Зерегистрироваться</q-btn
+        >
+      </div>
+    </q-form>
+
+    <div class="text-center q-mt-sm">
+      <q-btn @click="authStore.authStage = 'login'" size="sm" flat
+        >У меня есть профиль</q-btn
+      >
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useAuthStore } from '../stores/auth-store';
+import { ref } from 'vue';
+
+const email = ref('');
+
+const authStore = useAuthStore();
+
+// valid
+import validator from 'validator';
+</script>

+ 32 - 0
src/modules/users/auth/pages/AuthPage.vue

@@ -0,0 +1,32 @@
+<template>
+  <q-img class="fixed-full" img-class="bg-img" :src="bg">
+    <q-card
+      class="fixed-center"
+      style="
+        padding: 0;
+        width: 350px;
+        border-radius: 30px;
+        background-color: rgba(145, 145, 145, 0.192);
+      "
+    >
+      <q-card-section style="background-color: rgba(255, 255, 255, 0.212)">
+        <div class="text-h6 text-center">АВТОРИЗАЦИЯ</div>
+      </q-card-section>
+      <q-card-section class="q-pa-lg">
+        <!-- login -->
+        <login-and-registration />
+      </q-card-section>
+    </q-card>
+  </q-img>
+</template>
+
+<script setup lang="ts">
+import bg from '../assets/login-bg.jpg';
+import LoginAndRegistration from '../components/LoginAndRegistration.vue';
+</script>
+
+<style>
+.bg-img {
+  filter: saturate(30%);
+}
+</style>

+ 239 - 0
src/modules/users/auth/stores/auth-store.ts

@@ -0,0 +1,239 @@
+import { defineStore } from 'pinia';
+import { AxiosError } from 'axios';
+import { LocalStorage } from 'quasar';
+import { $api, $apiWithoutAuth } from 'src/boot/axios';
+
+import type { AuthApi } from 'src/api/api.ts';
+
+import errorMiddleware from 'src/utils/error-middleware';
+import { notify } from 'src/plugins/notify/notify';
+import { useRouter } from 'vue-router';
+import { computed, ref } from 'vue';
+import type { z } from 'zod';
+
+export const useAuthStore = defineStore('authStore', () => {
+  const router = useRouter();
+
+  const authStage = ref<'login' | 'registration' | 'pin'>('login');
+  const registrationPinError = ref<'pinIsWrong' | 'pinIsRotten' | 'tooManyTries'>();
+  const registrationPinTriesRemained = ref<undefined | number>(undefined);
+
+  const registrationPinTransactionId = ref<string | null>(null);
+
+  const isUserAuth = computed(() => {
+    return LocalStorage.has('accessToken');
+  });
+
+  const afterLogin = async (accessToken: string) => {
+    LocalStorage.set('accessToken', accessToken);
+
+    await router.push('/');
+  };
+
+  const login = async (email: string, password: string) => {
+    try {
+      const req: z.infer<typeof AuthApi.POST_Login.req> = {
+        email,
+        password,
+      };
+      await $apiWithoutAuth
+        .post<z.infer<typeof AuthApi.POST_Login.res>>('/auth/login', req)
+        .then(async (res) => {
+          const resData = res.data;
+
+          if (resData.code === 'success') {
+            if (!resData.userData) throw Error('ВТФ');
+
+            await afterLogin(resData.accessToken);
+          }
+        })
+        .catch((e) => {
+          if (e instanceof AxiosError) {
+            if (e.status === 400 && e.response?.data.code) {
+              const data: z.infer<typeof AuthApi.POST_Login.res> = e.response.data;
+
+              switch (data.code) {
+                case 'passIsWrong':
+                  notify({
+                    type: 'negative',
+                    message: 'Пароль неправильный',
+                    caption:
+                      data.triesRemained && data.triesRemained <= 3
+                        ? `Попыток осталось: ${data.triesRemained}`
+                        : 'Повторите попытку',
+                  });
+
+                  return;
+
+                case 'tooManyTries':
+                  notify({
+                    type: 'negative',
+                    message: 'Слишком много попыток.',
+                    caption: 'Повторите попытку позже',
+                  });
+                  return;
+
+                case 'userNotFound':
+                  notify({
+                    type: 'negative',
+                    message: 'Пользователь не найден.',
+                    caption: 'Проверьте почту или зарегистрируйтесь.',
+                  });
+                  return;
+              }
+            }
+          }
+        });
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  const registration = (email: string) => {
+    try {
+      const req: z.infer<typeof AuthApi.POST_Registration.req> = {
+        email: email,
+      };
+      $api
+        .post<z.infer<typeof AuthApi.POST_Registration.res>>('/auth/registration', req)
+        .then((res) => {
+          if (res.data.code === 'pinIsSent') {
+            registrationPinTransactionId.value = res.data.transactionId;
+            authStage.value = 'pin';
+          }
+        })
+        .catch((e) => {
+          if (e instanceof AxiosError) {
+            if (e.status === 400 && e.response?.data.code) {
+              const data: z.infer<typeof AuthApi.POST_Registration.res> = e.response.data;
+
+              switch (data.code) {
+                case 'alreadyExists':
+                  notify({
+                    type: 'negative',
+                    message: 'Пользователь с такой почтой уже зарегистрирован.',
+                    caption: 'Попробуйте войти или восстановите пароль.',
+                  });
+
+                  return;
+
+                case 'pinIsNotSent':
+                  notify({
+                    type: 'negative',
+                    message: 'Письмо с кодом не отправлено.',
+                    caption: 'Проверьте правильность почты.',
+                  });
+
+                  return;
+              }
+            }
+          }
+        });
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  const confirmRegistration = async (confirmPin: number, name: string, password: string) => {
+    try {
+      if (!registrationPinTransactionId.value) {
+        throw Error('Отсутсвуют необходимые данные при вводе пина.');
+      }
+
+      const req: z.infer<typeof AuthApi.POST_ConfirmRegistration.req> = {
+        name: name,
+        password: password,
+        confirmPin,
+        transactionId: registrationPinTransactionId.value,
+      };
+      await $api
+        .post<z.infer<typeof AuthApi.POST_ConfirmRegistration.res>>(
+          '/auth/confirm-registration',
+          req,
+        )
+        .then(async (res) => {
+          if (res.data.code === 'registered') {
+            registrationPinTriesRemained.value = undefined;
+            await afterLogin(res.data.accessToken);
+          }
+        })
+        .catch((e) => {
+          if (e instanceof AxiosError) {
+            if (e.status === 400 && e.response?.data.code) {
+              const data: z.infer<typeof AuthApi.POST_ConfirmRegistration.res> = e.response.data;
+
+              switch (data.code) {
+                case 'pinIsWrong':
+                  registrationPinError.value = 'pinIsWrong';
+                  registrationPinTriesRemained.value = data.triesRemained;
+                  return;
+
+                case 'pinIsRotten':
+                  registrationPinError.value = 'pinIsRotten';
+                  registrationPinTransactionId.value = null;
+                  authStage.value = 'registration';
+                  return;
+
+                case 'tooManyTries':
+                  registrationPinError.value = 'tooManyTries';
+                  registrationPinTransactionId.value = null;
+                  authStage.value = 'registration';
+                  return;
+              }
+            }
+          }
+        });
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  const logout = async () => {
+    try {
+      await $api.post('/auth/logout');
+      LocalStorage.removeItem('accessToken');
+
+      await router.push({ name: 'login' });
+    } catch (e) {
+      errorMiddleware(e);
+    }
+  };
+
+  const refreshAuth = async () => {
+    try {
+      const res = await $apiWithoutAuth.get<z.infer<typeof AuthApi.POST_Refresh.res>>(
+        '/auth/refresh',
+        {
+          withCredentials: true,
+        },
+      );
+
+      const resData = res.data;
+
+      LocalStorage.set('accessToken', resData.accessToken);
+    } catch (e) {
+      if (e instanceof AxiosError) {
+        if (e.status === 401) {
+          LocalStorage.remove('accessToken');
+
+          // this.router.push({ name: 'login' });
+        }
+      }
+
+      errorMiddleware(e);
+    }
+  };
+
+  return {
+    authStage,
+    registrationPinError,
+    registrationPinTriesRemained,
+    registrationPinTransactionId,
+    isUserAuth,
+    login,
+    registration,
+    confirmRegistration,
+    logout,
+    refreshAuth,
+  };
+});

+ 128 - 0
src/modules/users/other/ConfirmPinInput.vue

@@ -0,0 +1,128 @@
+<template>
+  <div @click.stop.prevent="focus()">
+    <q-field
+      :error="error ? true : false"
+      :error-message="error ? errorMessage(error) : ''"
+      borderless
+      no-error-icon
+    >
+      <q-input
+        @keydown.backspace="backspace()"
+        mask="#"
+        :error="error ? true : false"
+        hide-bottom-space
+        no-error-icon
+        dense
+        outlined
+        item-aligned
+        input-class="text-center"
+        v-for="i of [0, 1, 2, 3]"
+        :key="i"
+        v-model="pin[i]"
+        :ref="`pinInputRef${i}`"
+      ></q-input>
+    </q-field>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive, watch, toRef } from 'vue';
+import { QInput } from 'quasar';
+import { toRefs } from 'vue';
+
+const emit = defineEmits<{
+  (e: 'pinIsComplete', pin: number): void;
+}>();
+
+type Error = 'pinIsRotten' | 'pinIsWrong' | 'tooManyTries';
+// props
+const props = defineProps<{
+  error?: Error;
+  triesRemained?: number;
+}>();
+const { error, triesRemained } = toRefs(props);
+
+const pin = reactive<string[]>(['', '', '', '']);
+
+const pinInputRef0 = ref();
+const pinInputRef1 = ref();
+const pinInputRef2 = ref();
+const pinInputRef3 = ref();
+
+const focus = () => {
+  if (pin[0] === '') {
+    pinInputRef0.value[0].focus();
+    return;
+  }
+  if (pin[1] === '') {
+    pinInputRef1.value[0].focus();
+    return;
+  }
+  if (pin[2] === '') {
+    pinInputRef2.value[0].focus();
+    return;
+  }
+  if (pin[3] === '') {
+    pinInputRef3.value[0].focus();
+    return;
+  }
+};
+
+//
+const backspace = () => {
+  if (pin[0] === '') {
+    return;
+  }
+  if (pin[1] === '') {
+    pin.splice(0, 1);
+
+    return;
+  }
+  if (pin[2] === '') {
+    pin.splice(1, 1);
+
+    return;
+  }
+
+  if (pin[3] === '') {
+    pin.splice(2, 1);
+
+    return;
+  }
+
+  pin.splice(3, 1);
+};
+
+watch(
+  pin,
+  () => {
+    if (pin[0] !== '' && pin[1] !== '' && pin[2] !== '' && pin[3] !== '') {
+      emit('pinIsComplete', Number([pin[0] + pin[1] + pin[2] + pin[3]]));
+      return;
+    }
+
+    focus();
+  },
+  { deep: true }
+);
+
+const errorMessage = (error: Error) => {
+  switch (error) {
+    case 'pinIsWrong':
+      if (triesRemained.value === 0) {
+        return 'Слишком много неправильных попыток. Отправьте код еще раз.';
+      }
+      return `Код неправильный. ${
+        triesRemained.value !== undefined && triesRemained.value < 4
+          ? 'Попыток осталось: ' + triesRemained.value
+          : 'Попробуйте снова.'
+      } `;
+
+    case 'pinIsRotten':
+      return 'Срок действия кода истёк. Отправьте код еще раз.';
+
+    case 'tooManyTries':
+      return 'Слишком много неправильных попыток. Отправьте код еще раз.';
+  }
+};
+</script>

+ 0 - 43
src/pages/IndexPage.vue

@@ -1,43 +0,0 @@
-<template>
-  <q-page class="row items-center justify-evenly">
-    <example-component
-      title="Example component"
-      active
-      :todos="todos"
-      :meta="meta"
-    ></example-component>
-  </q-page>
-</template>
-
-<script setup lang="ts">
-import { ref } from 'vue';
-import type { Todo, Meta } from 'components/models';
-import ExampleComponent from 'components/ExampleComponent.vue';
-
-const todos = ref<Todo[]>([
-  {
-    id: 1,
-    content: 'ct1'
-  },
-  {
-    id: 2,
-    content: 'ct2'
-  },
-  {
-    id: 3,
-    content: 'ct3'
-  },
-  {
-    id: 4,
-    content: 'ct4'
-  },
-  {
-    id: 5,
-    content: 'ct5'
-  }
-]);
-
-const meta = ref<Meta>({
-  totalCount: 1200
-});
-</script>

+ 138 - 0
src/plugins/dayjs/dayjs.ts

@@ -0,0 +1,138 @@
+type dayjsInput = string | number | dayjs.Dayjs | Date | null | undefined;
+type Dayjs = dayjs.Dayjs;
+
+//  plugins
+import dayjs from 'dayjs';
+
+import utc from 'dayjs/plugin/utc.js';
+dayjs.extend(utc);
+
+import timezone from 'dayjs/plugin/timezone.js';
+dayjs.extend(timezone);
+
+import weekOfYear from 'dayjs/plugin/weekOfYear.js';
+dayjs.extend(weekOfYear);
+
+import weekday from 'dayjs/plugin/weekday.js';
+dayjs.extend(weekday);
+
+import ru from 'dayjs/locale/ru';
+dayjs.locale(ru);
+
+import customParseFormat from 'dayjs/plugin/customParseFormat';
+dayjs.extend(customParseFormat);
+
+// import localeData from 'dayjs/plugin/localeData';
+// dayjs.extend(localeData);
+
+import localizedFormat from 'dayjs/plugin/localizedFormat';
+dayjs.extend(localizedFormat);
+
+const DayjsUtils = new (class dayjsUtils {})();
+
+//
+import { useEventsManagementStore } from 'src/modules/events-management/events-management-store';
+import { BigAlert } from '../notify/notify';
+import { useCompaniesStore } from 'src/modules/company-management/companies-store';
+//
+
+/**
+ * Создать dayjs в tz компании и кастомным форматом
+ */
+const dayjsCompanyTzWithFormat = (date: dayjsInput, format: string) => {
+  const companiesStore = useCompaniesStore();
+  const tz = companiesStore.selectedCompanyData?.timezone;
+  if (!tz) {
+    throw Error('Нет таймзоны у компании');
+  }
+  return dayjs.tz(date, format, tz);
+};
+
+/**
+ * Создать dayjs в tz юзера (не браузера)
+ */
+const dayjsCompanyTz = (date?: dayjsInput) => {
+  const companiesStore = useCompaniesStore();
+  const tz = companiesStore.selectedCompanyData?.timezone;
+  if (!tz) {
+    throw Error('Нет таймзоны у компании');
+  }
+
+  return dayjs.tz(date, tz);
+};
+
+/**
+ * Создать dayjs в tz браузера
+ */
+const dayjsLocalTz = (
+  date?: dayjsInput,
+  format?: dayjs.OptionType | undefined
+) => {
+  return dayjs(date, format);
+};
+
+/**
+ * Создать dayjs в utc
+ */
+const dayjsUtcTz = (date?: dayjsInput, format?: string | undefined) => {
+  return dayjs.utc(date, format);
+};
+
+/**
+ * Создать dayjs в нужном tz
+ */
+const dayjsInCustomTz = (date?: dayjsInput, timezone?: string | undefined) => {
+  return dayjs.tz(date, timezone);
+};
+
+/**
+ * Распарсить строку с сервера в нужный tz
+ */
+const dayjsFromCustomTz = (
+  date?: dayjsInput,
+  timezone?: string | undefined
+) => {
+  return dayjsUtcTz(date).tz(timezone);
+};
+
+/**
+ * Распарсить строку с сервера в tz текущего ивента
+ */
+const dayjsFromCurrentEventTz = (date?: dayjsInput) => {
+  const eventStore = useEventsManagementStore();
+
+  if (!eventStore.currentEventData) {
+    BigAlert({
+      title: 'Нет текущего события',
+      message: 'dayjsFromCurrentEventTz',
+      type: 'negative',
+    });
+    throw Error('Нет текущего события');
+  }
+
+  const timezone = eventStore.currentEventData?.timezone;
+
+  if (!timezone) {
+    BigAlert({
+      title: 'Нет таймзоны текущего события',
+      message: 'dayjsFromCurrentEventTz',
+      type: 'negative',
+    });
+    throw Error('Нет таймзоны текущего события');
+  }
+
+  return dayjsUtcTz(date).tz(timezone);
+};
+
+export {
+  dayjs,
+  DayjsUtils,
+  dayjsLocalTz,
+  dayjsUtcTz,
+  dayjsInCustomTz,
+  dayjsFromCustomTz,
+  dayjsFromCurrentEventTz,
+  dayjsCompanyTzWithFormat,
+  dayjsCompanyTz,
+};
+export type { Dayjs };

+ 370 - 0
src/plugins/notify/notify.scss

@@ -0,0 +1,370 @@
+.f-modal-alert .f-modal-icon {
+  border-radius: 50%;
+  border: 4px solid gray;
+  box-sizing: content-box;
+  height: 80px;
+  margin: 20px auto;
+  padding: 0;
+  position: relative;
+  width: 80px;
+
+  // Success icon
+  &.f-modal-success,
+  &.f-modal-error {
+    border-color: #a5dc86;
+
+    &:after,
+    &:before {
+      background: #fff;
+      content: "";
+      height: 120px;
+      position: absolute;
+      transform: rotate(45deg);
+      width: 60px;
+    }
+
+    &:before {
+      border-radius: 120px 0 0 120px;
+      left: -33px;
+      top: -7px;
+      transform-origin: 60px 60px;
+      transform: rotate(-45deg);
+    }
+
+    &:after {
+      border-radius: 0 120px 120px 0;
+      left: 30px;
+      top: -11px;
+      transform-origin: 0 60px;
+      transform: rotate(-45deg);
+    }
+
+    .f-modal-placeholder {
+      border-radius: 50%;
+      border: 4px solid rgba(165, 220, 134, 0.2);
+      box-sizing: content-box;
+      height: 80px;
+      left: -4px;
+      position: absolute;
+      top: -4px;
+      width: 80px;
+      z-index: 2;
+    }
+
+    .f-modal-fix {
+      background-color: #fff;
+      height: 90px;
+      left: 28px;
+      position: absolute;
+      top: 8px;
+      transform: rotate(-45deg);
+      width: 5px;
+      z-index: 1;
+    }
+
+    .f-modal-line {
+      background-color: #a5dc86;
+      border-radius: 2px;
+      display: block;
+      height: 5px;
+      position: absolute;
+      z-index: 2;
+
+      &.f-modal-tip {
+        left: 14px;
+        top: 46px;
+        transform: rotate(45deg);
+        width: 25px;
+      }
+
+      &.f-modal-long {
+        right: 8px;
+        top: 38px;
+        transform: rotate(-45deg);
+        width: 47px;
+      }
+    }
+  }
+
+  // Error icon
+  &.f-modal-error {
+    border-color: #f27474;
+
+    .f-modal-x-mark {
+      display: block;
+      position: relative;
+      z-index: 2;
+    }
+
+    .f-modal-placeholder {
+      border: 4px solid rgba(200, 0, 0, 0.2);
+    }
+
+    .f-modal-line {
+      background-color: #f27474;
+      top: 37px;
+      width: 47px;
+
+      &.f-modal-left {
+        left: 17px;
+        transform: rotate(45deg);
+      }
+
+      &.f-modal-right {
+        right: 16px;
+        transform: rotate(-45deg);
+      }
+    }
+  }
+
+  // Warning icon
+
+  &.f-modal-warning {
+    border-color: #f8bb86;
+
+    &:before {
+      animation: pulseWarning 2s linear infinite;
+      background-color: #fff;
+      border-radius: 50%;
+      content: "";
+      display: inline-block;
+      height: 100%;
+      opacity: 0;
+      position: absolute;
+      width: 100%;
+    }
+
+    &:after {
+      background-color: #fff;
+      border-radius: 50%;
+      content: "";
+      display: block;
+      height: 100%;
+      position: absolute;
+      width: 100%;
+      z-index: 1;
+    }
+  }
+
+  &.f-modal-warning .f-modal-body {
+    background-color: #f8bb86;
+    border-radius: 2px;
+    height: 47px;
+    left: 50%;
+    margin-left: -2px;
+    position: absolute;
+    top: 10px;
+    width: 5px;
+    z-index: 2;
+  }
+
+  &.f-modal-warning .f-modal-dot {
+    background-color: #f8bb86;
+    border-radius: 50%;
+    bottom: 10px;
+    height: 7px;
+    left: 50%;
+    margin-left: -3px;
+    position: absolute;
+    width: 7px;
+    z-index: 2;
+  }
+
+  + .f-modal-icon {
+    margin-top: 50px;
+  }
+}
+
+.animateSuccessTip {
+  animation: animateSuccessTip 0.75s;
+}
+
+.animateSuccessLong {
+  animation: animateSuccessLong 0.75s;
+}
+
+.f-modal-icon.f-modal-success.animate:after {
+  animation: rotatePlaceholder 4.25s ease-in;
+}
+
+.f-modal-icon.f-modal-error.animate:after {
+  animation: rotatePlaceholder 4.25s ease-in;
+}
+
+.animateErrorIcon {
+  animation: animateErrorIcon 0.5s;
+}
+
+.animateXLeft {
+  animation: animateXLeft 0.75s;
+}
+
+.animateXRight {
+  animation: animateXRight 0.75s;
+}
+
+.scaleWarning {
+  animation: scaleWarning 0.75s infinite alternate;
+}
+
+.pulseWarningIns {
+  animation: pulseWarningIns 0.75s infinite alternate;
+}
+
+@keyframes animateSuccessTip {
+  0%,
+  54% {
+    width: 0;
+    left: 1px;
+    top: 19px;
+  }
+
+  70% {
+    width: 50px;
+    left: -8px;
+    top: 37px;
+  }
+
+  84% {
+    width: 17px;
+    left: 21px;
+    top: 48px;
+  }
+
+  100% {
+    width: 25px;
+    left: 14px;
+    top: 45px;
+  }
+}
+
+@keyframes animateSuccessLong {
+  0%,
+  65% {
+    width: 0;
+    right: 46px;
+    top: 54px;
+  }
+
+  84% {
+    width: 55px;
+    right: 0;
+    top: 35px;
+  }
+
+  100% {
+    width: 47px;
+    right: 8px;
+    top: 38px;
+  }
+}
+
+@keyframes rotatePlaceholder {
+  0%,
+  5% {
+    transform: rotate(-45deg);
+  }
+
+  100%,
+  12% {
+    transform: rotate(-405deg);
+  }
+}
+
+@keyframes animateErrorIcon {
+  0% {
+    transform: rotateX(100deg);
+    opacity: 0;
+  }
+
+  100% {
+    transform: rotateX(0deg);
+    opacity: 1;
+  }
+}
+
+@keyframes animateXLeft {
+  0%,
+  65% {
+    left: 82px;
+    top: 95px;
+    width: 0;
+  }
+
+  84% {
+    left: 14px;
+    top: 33px;
+    width: 47px;
+  }
+
+  100% {
+    left: 17px;
+    top: 37px;
+    width: 47px;
+  }
+}
+
+@keyframes animateXRight {
+  0%,
+  65% {
+    right: 82px;
+    top: 95px;
+    width: 0;
+  }
+
+  84% {
+    right: 14px;
+    top: 33px;
+    width: 47px;
+  }
+
+  100% {
+    right: 16px;
+    top: 37px;
+    width: 47px;
+  }
+}
+
+@keyframes scaleWarning {
+  0% {
+    transform: scale(1);
+  }
+
+  30% {
+    transform: scale(1.02);
+  }
+
+  100% {
+    transform: scale(1);
+  }
+}
+
+@keyframes pulseWarning {
+  0% {
+    background-color: #fff;
+    transform: scale(1);
+    opacity: 0.5;
+  }
+
+  30% {
+    background-color: #fff;
+    transform: scale(1);
+    opacity: 0.5;
+  }
+
+  100% {
+    background-color: #f8bb86;
+    transform: scale(2);
+    opacity: 0;
+  }
+}
+
+@keyframes pulseWarningIns {
+  0% {
+    background-color: #f8d486;
+  }
+
+  100% {
+    background-color: #f8bb86;
+  }
+}

+ 129 - 0
src/plugins/notify/notify.ts

@@ -0,0 +1,129 @@
+import { Notify, Dialog } from 'quasar';
+import './notify.scss';
+
+const simpleError = (e: string) => {
+  Notify.create({
+    message: 'Произошла непредвиденная ошибка. Повторите попытку позже.',
+    type: 'negative',
+    caption: e,
+    timeout: 100000,
+    actions: [
+      {
+        icon: 'close',
+        color: 'white',
+        round: true,
+        handler: () => {
+          /* ... */
+        },
+      },
+    ],
+  });
+};
+
+///////////////////////////
+
+const connectionError = () => {
+  Notify.create({
+    message: 'Ошибка! Проверьте подключение к интернету!',
+    type: 'negative',
+    timeout: 100000,
+    actions: [
+      {
+        icon: 'close',
+        color: 'white',
+        round: true,
+        handler: () => {
+          /* ... */
+        },
+      },
+    ],
+  });
+};
+
+////////////////////
+
+type TypeNotify = {
+  type?: 'positive' | 'negative' | 'warning' | 'info' | 'ongoing';
+  message?: string;
+  icon?: string;
+  caption?: string;
+  color?: string;
+  timeout?: number;
+};
+
+const notify = ({ type, message, icon, caption, timeout }: TypeNotify) => {
+  Notify.create({
+    type: type,
+    message: message,
+    icon: icon,
+    caption: caption,
+    timeout: timeout,
+    actions: [
+      {
+        icon: 'close',
+        color: 'white',
+        round: true,
+        handler: () => {
+          /* ... */
+        },
+      },
+    ],
+  });
+};
+
+type TypeAlert = {
+  type?: 'positive' | 'negative' | 'warning';
+  title?: string;
+  message?: string;
+};
+
+const BigAlert = ({ type, title, message }: TypeAlert) => {
+  let icon = '';
+
+  if (type === 'negative') {
+    icon = `
+    <div class="f-modal-alert">
+    <div class="f-modal-icon f-modal-error animate">
+      <span class="f-modal-x-mark">
+        <span class="f-modal-line f-modal-left animateXLeft"></span>
+        <span class="f-modal-line f-modal-right animateXRight"></span>
+      </span>
+      <div class="f-modal-placeholder"></div>
+      <div class="f-modal-fix"></div>
+    </div>
+    <div class="text-center">${title || 'Ошибка!'}</div>
+    </div>`;
+  }
+
+  if (type === 'positive') {
+    icon = `
+    <div class="f-modal-alert">
+    <div class="f-modal-icon f-modal-success animate">
+      <span class="f-modal-line f-modal-tip animateSuccessTip"></span>
+      <span class="f-modal-line f-modal-long animateSuccessLong"></span>
+      <div class="f-modal-placeholder"></div>
+      <div class="f-modal-fix"></div>
+    </div>
+    <div class="text-center">${title || 'Успех!'}</div>
+    </div>`;
+  }
+
+  if (type === 'warning') {
+    icon = `
+    <div class="f-modal-alert">
+    <div class="f-modal-icon f-modal-warning scaleWarning">
+      <span class="f-modal-body pulseWarningIns"></span>
+      <span class="f-modal-dot pulseWarningIns"></span>
+    </div>
+    <div class="text-center">${title || 'Внимание!'}</div>
+    </div>`;
+  }
+
+  Dialog.create({
+    title: icon,
+    message: `<div class="text-center">${message}</div>`,
+    html: true,
+  });
+};
+
+export { simpleError, notify, connectionError, BigAlert };

+ 21 - 3
src/router/index.ts

@@ -1,11 +1,13 @@
-import { defineRouter } from '#q-app/wrappers';
+import { route } from 'quasar/wrappers';
 import {
   createMemoryHistory,
   createRouter,
   createWebHashHistory,
   createWebHistory,
 } from 'vue-router';
+
 import routes from './routes';
+import { useAuthStore } from 'src/modules/users/auth/stores/auth-store';
 
 /*
  * If not building with SSR mode, you can
@@ -16,10 +18,12 @@ import routes from './routes';
  * with the Router instance.
  */
 
-export default defineRouter(function (/* { store, ssrContext } */) {
+export default route(function (/* { store, ssrContext } */) {
   const createHistory = process.env.SERVER
     ? createMemoryHistory
-    : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory);
+    : process.env.VUE_ROUTER_MODE === 'history'
+      ? createWebHistory
+      : createWebHashHistory;
 
   const Router = createRouter({
     scrollBehavior: () => ({ left: 0, top: 0 }),
@@ -31,5 +35,19 @@ export default defineRouter(function (/* { store, ssrContext } */) {
     history: createHistory(process.env.VUE_ROUTER_BASE),
   });
 
+  // middleware auth
+  Router.beforeEach((to, from, next) => {
+    const requireAuth = to.matched.some((record) => record.meta.auth);
+
+    const authStore = useAuthStore();
+
+    if (requireAuth && !authStore.isUserAuth) {
+      next('/login');
+      return;
+    }
+
+    next();
+  });
+
   return Router;
 });

+ 30 - 0
src/router/routes.ts

@@ -7,11 +7,41 @@ const routes: RouteRecordRaw[] = [
     children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
   },
 
+  {
+    path: '/login',
+    name: 'login',
+    component: () => import('src/modules/users-management/auth/pages/AuthPage.vue'),
+  },
+
+  {
+    path: '/pe',
+    name: 'part-entities',
+    meta: { auth: true },
+    children: [
+      {
+        path: '',
+        name: 'part-entities',
+        component: () => import('src/modules/part-entities/MyPartEntitiesPage.vue'),
+      },
+      {
+        path: 'create',
+        name: 'create-part-entity',
+        component: () => import('src/modules/part-entities/CreatePartEntityPage.vue'),
+      },
+      {
+        path: ':partEntityId',
+        name: 'part-entity',
+        component: () => import('src/modules/part-entities/PartEntityPage.vue'),
+      },
+    ],
+  },
+
   // Always leave this as last one,
   // but you can also remove it
   {
     path: '/:catchAll(.*)*',
     component: () => import('pages/ErrorNotFound.vue'),
+    name: 'not-found',
   },
 ];
 

+ 0 - 21
src/stores/example-store.ts

@@ -1,21 +0,0 @@
-import { defineStore, acceptHMRUpdate } from 'pinia';
-
-export const useCounterStore = defineStore('counter', {
-  state: () => ({
-    counter: 0,
-  }),
-
-  getters: {
-    doubleCount: (state) => state.counter * 2,
-  },
-
-  actions: {
-    increment() {
-      this.counter++;
-    },
-  },
-});
-
-if (import.meta.hot) {
-  import.meta.hot.accept(acceptHMRUpdate(useCounterStore, import.meta.hot));
-}

+ 45 - 0
src/utils/error-middleware.ts

@@ -0,0 +1,45 @@
+// import { ZodError } from 'zod';
+import { AxiosError } from 'axios';
+import { BigAlert } from 'src/plugins/notify/notify';
+
+export default function errorMiddleware(err: unknown) {
+  if (!err) throw err;
+
+  // if (err instanceof ZodError) {
+  //   console.error({ message: 'Ошибка валидации Zod на клиенте', err: err });
+  //   BigAlert({
+  //     title: 'Ошибка валидации Zod на клиенте',
+  //     message: JSON.stringify({ message: err.message, errors: err.errors }),
+  //     type: 'negative',
+  //   });
+  //   return;
+  // }
+
+  if (err instanceof AxiosError) {
+    console.error({ message: 'Ошибка запроса к серверу', err: err });
+
+    if (err.status === 404) {
+      BigAlert({
+        title: 'Ошибка запроса к серверу',
+        message: err.message,
+        type: 'negative',
+      });
+      throw err;
+    }
+
+    BigAlert({
+      title: 'Ошибка запроса к серверу',
+      message: JSON.stringify(err.response?.data || err),
+      type: 'negative',
+    });
+    throw err;
+  }
+
+  console.error({ message: 'Непредвиденная ошибка', err: err });
+  BigAlert({
+    title: 'Непредвиденная ошибка',
+    message: err.toString(),
+    type: 'negative',
+  });
+  throw err;
+}

+ 2 - 1
tsconfig.json

@@ -1,3 +1,4 @@
+// tsconfig.json
 {
   "extends": "./.quasar/tsconfig.json"
-}
+}

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