Forráskód Böngészése

funcionalidades clonadas do sistema web

Gustavo Zanatta 2 hónapja
szülő
commit
69f265b1eb
63 módosított fájl, 5465 hozzáadás és 1061 törlés
  1. 1 0
      .gitignore
  2. 258 46
      package-lock.json
  3. 8 1
      package.json
  4. 5 3
      quasar.config.js
  5. 2 17
      src/App.vue
  6. 63 0
      src/api/appointment.js
  7. 10 0
      src/api/auth.js
  8. 7 0
      src/api/category.js
  9. 81 0
      src/api/notification.js
  10. 132 0
      src/api/partnerAgreement.js
  11. 48 0
      src/api/partnerAgreementService.js
  12. 26 0
      src/api/profile.js
  13. 105 0
      src/api/storeItem.js
  14. 6 0
      src/assets/logo_serprati.svg
  15. 6 0
      src/assets/logo_serprati_associado.svg
  16. 6 0
      src/assets/logo_serprati_associado_small.svg
  17. 6 0
      src/assets/logo_serprati_parceiro.svg
  18. 6 0
      src/assets/logo_serprati_parceiro_small.svg
  19. BIN
      src/assets/logo_serprati_small.png
  20. BIN
      src/assets/pessoas_fundo.jpg
  21. 18 0
      src/boot/capacitor.js
  22. 2 2
      src/boot/defaultPropsComponents.js
  23. 9 8
      src/components/defaults/DefaultCurrencyInput.vue
  24. 101 137
      src/components/defaults/DefaultFilePicker.vue
  25. 5 4
      src/components/defaults/DefaultInputDatePicker.vue
  26. 9 2
      src/components/defaults/DefaultTable.vue
  27. 61 69
      src/components/defaults/DefaultTableServerSide.vue
  28. 1 1
      src/components/defaults/DefaultTabs.vue
  29. 83 68
      src/components/layout/DefaultHeaderPage.vue
  30. 37 77
      src/components/layout/LeftMenuLayout.vue
  31. 56 21
      src/components/layout/LeftMenuLayoutMobile.vue
  32. 76 0
      src/components/selects/CategorySelect.vue
  33. 17 0
      src/components/selects/PartnerAgreementCategorySelect.vue
  34. 75 0
      src/components/selects/PartnerAgreementSelect.vue
  35. 78 0
      src/components/selects/PartnerAgreementServiceSelect.vue
  36. 68 0
      src/components/selects/PositionSelect.vue
  37. 68 0
      src/components/selects/SectorSelect.vue
  38. 8 4
      src/composables/useAuth.js
  39. 32 0
      src/composables/usePushNotifications.js
  40. 59 56
      src/css/app.scss
  41. 157 112
      src/css/quasar.variables.scss
  42. 12 84
      src/css/table.scss
  43. 20 1
      src/helpers/utils.js
  44. 342 8
      src/i18n/locales/en.json
  45. 351 17
      src/i18n/locales/es.json
  46. 343 10
      src/i18n/locales/pt.json
  47. 27 50
      src/layouts/MainLayout.vue
  48. 408 0
      src/pages/associado/agendamentos/AgendamentosPage.vue
  49. 263 0
      src/pages/associado/carteirinha/CarteirinhaPage.vue
  50. 158 0
      src/pages/associado/convenios/ConveniosPage.vue
  51. 224 0
      src/pages/associado/convenios/components/PartnerAgreementCard.vue
  52. 110 0
      src/pages/associado/interesses/InteressesPage.vue
  53. 430 0
      src/pages/associado/loja/AssociadoLojaPage.vue
  54. 250 0
      src/pages/associado/notificacoes/NotificacoesAssociadoPage.vue
  55. 333 0
      src/pages/associado/profile/ProfilePage.vue
  56. 118 0
      src/pages/associado/profile/components/AddEditDependentDialog.vue
  57. 95 57
      src/pages/login/LoginPage.vue
  58. 13 1
      src/router/index.js
  59. 8 37
      src/router/routes.js
  60. 68 0
      src/router/routes/associado.route.js
  61. 1 98
      src/router/routes/config.route.js
  62. 66 63
      src/stores/navigation.js
  63. 29 7
      src/stores/user.js

+ 1 - 0
.gitignore

@@ -24,6 +24,7 @@ Thumbs.db
 /src-cordova/platforms
 /src-cordova/plugins
 /src-cordova/www
+/src-capacitor
 
 /android
 /ios

+ 258 - 46
package-lock.json

@@ -1,14 +1,19 @@
 {
-  "name": "serprati-associado",
+  "name": "serprati-digital",
   "version": "0.0.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
-      "name": "serprati-associado",
+      "name": "serprati-digital",
       "version": "0.0.1",
       "dependencies": {
         "@bufbuild/protobuf": "^2.5.1",
+        "@capacitor/app": "^8.1.0",
+        "@capacitor/core": "^8.3.4",
+        "@capacitor/keyboard": "^8.0.3",
+        "@capacitor/push-notifications": "^8.1.1",
+        "@capacitor/status-bar": "^8.0.2",
         "@quasar/cli": "^2.5.0",
         "@quasar/extras": "^1.17.0",
         "axios": "^1.13.2",
@@ -17,11 +22,13 @@
         "date-fns": "^3.6.0",
         "fast-deep-equal": "^3.1.3",
         "pinia": "^3.0.2",
+        "qrcode": "^1.5.4",
         "quasar": "^2.18.6",
         "socket.io-client": "^4.8.1",
         "vue": "^3.5.13",
         "vue-chartjs": "^5.3.3",
         "vue-currency-input": "^3.2.1",
+        "vue-eslint-parser": "^10.4.0",
         "vue-i18n": "^11.1.4",
         "vue-router": "^4.6.4"
       },
@@ -305,6 +312,51 @@
         "typescript": "5.4.5"
       }
     },
+    "node_modules/@capacitor/app": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-8.1.0.tgz",
+      "integrity": "sha512-MlmttTOWHDedr/G4SrhNRxsXMqY+R75S4MM4eIgzsgCzOYhb/MpCkA5Q3nuOCfL1oHm26xjUzqZ5aupbOwdfYg==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
+    "node_modules/@capacitor/core": {
+      "version": "8.3.4",
+      "resolved": "https://registry.npmjs.org/@capacitor/core/-/core-8.3.4.tgz",
+      "integrity": "sha512-CqRQCkb6HXxcx/N7s+hHTN6ef2CmamFiRMITwm4qB840ph56mS42bzUgn6tKCP+RZjdDweiRHj9ytDDeN6jFag==",
+      "license": "MIT",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@capacitor/keyboard": {
+      "version": "8.0.3",
+      "resolved": "https://registry.npmjs.org/@capacitor/keyboard/-/keyboard-8.0.3.tgz",
+      "integrity": "sha512-27Bv5/2w1Ss2njguBgTS98O0Bb8DRJhAARyzXYib0JlT/n6BrJw/EZ0CokM4C8GFUjFDjJnEKF1Ie01buTMEXQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
+    "node_modules/@capacitor/push-notifications": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-8.1.1.tgz",
+      "integrity": "sha512-WqzjPKIbYbARMN+GC0XMAJcxJpUUzqgzS/Ny8RODLrro38pQhm3GXYwX2Mwd+LZlLY39rGImkCkrKyQSNfuikA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
+    "node_modules/@capacitor/status-bar": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/@capacitor/status-bar/-/status-bar-8.0.2.tgz",
+      "integrity": "sha512-WXs8YB8B9eEaPZz+bcdY6t2nForF1FLoj/JU0Dl9RRgQnddnS98FEEyDooQhaY7wivr000j4+SC1FyeJkrFO7A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@capacitor/core": ">=8.0.0"
+      }
+    },
     "node_modules/@esbuild/aix-ppc64": {
       "version": "0.25.12",
       "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -2558,7 +2610,7 @@
       "version": "1.19.6",
       "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
       "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/connect": "*",
@@ -2591,7 +2643,7 @@
       "version": "3.4.38",
       "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
       "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*"
@@ -2615,7 +2667,7 @@
       "version": "4.17.25",
       "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz",
       "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/body-parser": "*",
@@ -2628,7 +2680,7 @@
       "version": "4.19.7",
       "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz",
       "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*",
@@ -2671,7 +2723,7 @@
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
       "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/http-proxy": {
@@ -2694,7 +2746,7 @@
       "version": "1.3.5",
       "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
       "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/node": {
@@ -2719,21 +2771,21 @@
       "version": "6.14.0",
       "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
       "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/range-parser": {
       "version": "1.2.7",
       "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
       "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT"
     },
     "node_modules/@types/send": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
       "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/node": "*"
@@ -2743,7 +2795,7 @@
       "version": "1.15.10",
       "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz",
       "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/http-errors": "*",
@@ -2755,7 +2807,7 @@
       "version": "0.17.6",
       "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz",
       "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==",
-      "devOptional": true,
+      "dev": true,
       "license": "MIT",
       "dependencies": {
         "@types/mime": "^1",
@@ -3135,7 +3187,6 @@
       "version": "8.15.0",
       "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
       "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
-      "dev": true,
       "license": "MIT",
       "bin": {
         "acorn": "bin/acorn"
@@ -3148,7 +3199,6 @@
       "version": "5.3.2",
       "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
       "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
-      "dev": true,
       "license": "MIT",
       "peerDependencies": {
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -3222,7 +3272,6 @@
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
       "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "color-convert": "^2.0.1"
@@ -4131,7 +4180,6 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
       "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "color-name": "~1.1.4"
@@ -4144,7 +4192,6 @@
       "version": "1.1.4",
       "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
       "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
-      "dev": true,
       "license": "MIT"
     },
     "node_modules/colorjs.io": {
@@ -4483,7 +4530,6 @@
       "version": "4.4.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
       "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "ms": "^2.1.3"
@@ -4497,6 +4543,15 @@
         }
       }
     },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/decompress-response": {
       "version": "6.0.0",
       "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
@@ -4646,6 +4701,12 @@
         "node": ">=0.10"
       }
     },
+    "node_modules/dijkstrajs": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
+      "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
+      "license": "MIT"
+    },
     "node_modules/dot-case": {
       "version": "3.0.4",
       "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@@ -5139,7 +5200,6 @@
       "version": "8.4.0",
       "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
       "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "esrecurse": "^4.3.0",
@@ -5205,7 +5265,6 @@
       "version": "10.4.0",
       "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
       "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "acorn": "^8.15.0",
@@ -5223,7 +5282,6 @@
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
       "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
-      "dev": true,
       "license": "Apache-2.0",
       "engines": {
         "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -5250,7 +5308,6 @@
       "version": "1.6.0",
       "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
       "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==",
-      "dev": true,
       "license": "BSD-3-Clause",
       "dependencies": {
         "estraverse": "^5.1.0"
@@ -5263,7 +5320,6 @@
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
       "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "dependencies": {
         "estraverse": "^5.2.0"
@@ -5276,7 +5332,6 @@
       "version": "5.3.0",
       "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
       "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
-      "dev": true,
       "license": "BSD-2-Clause",
       "engines": {
         "node": ">=4.0"
@@ -5820,7 +5875,6 @@
       "version": "2.0.5",
       "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
       "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
-      "dev": true,
       "license": "ISC",
       "engines": {
         "node": "6.* || 8.* || >= 10.*"
@@ -7431,6 +7485,15 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/package-json": {
       "version": "8.1.1",
       "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz",
@@ -7530,7 +7593,6 @@
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
       "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=8"
@@ -7640,6 +7702,15 @@
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/pngjs": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
+      "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/postcss": {
       "version": "8.5.6",
       "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -7801,6 +7872,136 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/qrcode": {
+      "version": "1.5.4",
+      "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
+      "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
+      "license": "MIT",
+      "dependencies": {
+        "dijkstrajs": "^1.0.1",
+        "pngjs": "^5.0.0",
+        "yargs": "^15.3.1"
+      },
+      "bin": {
+        "qrcode": "bin/qrcode"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qrcode/node_modules/cliui": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
+      "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
+      "license": "ISC",
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^6.2.0"
+      }
+    },
+    "node_modules/qrcode/node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "license": "MIT",
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "license": "MIT",
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "license": "MIT",
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/qrcode/node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "license": "MIT",
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/y18n": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
+      "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
+      "license": "ISC"
+    },
+    "node_modules/qrcode/node_modules/yargs": {
+      "version": "15.4.1",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
+      "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
+      "license": "MIT",
+      "dependencies": {
+        "cliui": "^6.0.0",
+        "decamelize": "^1.2.0",
+        "find-up": "^4.1.0",
+        "get-caller-file": "^2.0.1",
+        "require-directory": "^2.1.1",
+        "require-main-filename": "^2.0.0",
+        "set-blocking": "^2.0.0",
+        "string-width": "^4.2.0",
+        "which-module": "^2.0.0",
+        "y18n": "^4.0.0",
+        "yargs-parser": "^18.1.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/qrcode/node_modules/yargs-parser": {
+      "version": "18.1.3",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
+      "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
+      "license": "ISC",
+      "dependencies": {
+        "camelcase": "^5.0.0",
+        "decamelize": "^1.2.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/qs": {
       "version": "6.14.1",
       "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
@@ -8070,12 +8271,17 @@
       "version": "2.1.1",
       "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
       "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
-      "dev": true,
       "license": "MIT",
       "engines": {
         "node": ">=0.10.0"
       }
     },
+    "node_modules/require-main-filename": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
+      "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
+      "license": "ISC"
+    },
     "node_modules/requires-port": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -8939,6 +9145,12 @@
         "node": ">= 0.8.0"
       }
     },
+    "node_modules/set-blocking": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
+      "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
+      "license": "ISC"
+    },
     "node_modules/setprototypeof": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -9489,7 +9701,6 @@
       "version": "2.8.1",
       "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
-      "dev": true,
       "license": "0BSD"
     },
     "node_modules/type-check": {
@@ -9544,7 +9755,7 @@
       "version": "5.4.5",
       "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
       "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
-      "devOptional": true,
+      "dev": true,
       "license": "Apache-2.0",
       "bin": {
         "tsc": "bin/tsc",
@@ -10477,17 +10688,15 @@
       }
     },
     "node_modules/vue-eslint-parser": {
-      "version": "10.2.0",
-      "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.2.0.tgz",
-      "integrity": "sha512-CydUvFOQKD928UzZhTp4pr2vWz1L+H99t7Pkln2QSPdvmURT0MoC4wUccfCnuEaihNsu9aYYyk+bep8rlfkUXw==",
-      "dev": true,
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz",
+      "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==",
       "license": "MIT",
-      "peer": true,
       "dependencies": {
         "debug": "^4.4.0",
-        "eslint-scope": "^8.2.0",
-        "eslint-visitor-keys": "^4.2.0",
-        "espree": "^10.3.0",
+        "eslint-scope": "^8.2.0 || ^9.0.0",
+        "eslint-visitor-keys": "^4.2.0 || ^5.0.0",
+        "espree": "^10.3.0 || ^11.0.0",
         "esquery": "^1.6.0",
         "semver": "^7.6.3"
       },
@@ -10498,18 +10707,16 @@
         "url": "https://github.com/sponsors/mysticatea"
       },
       "peerDependencies": {
-        "eslint": "^8.57.0 || ^9.0.0"
+        "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
       }
     },
     "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
-      "version": "4.2.1",
-      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
-      "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
-      "dev": true,
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+      "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
       "license": "Apache-2.0",
-      "peer": true,
       "engines": {
-        "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+        "node": "^20.19.0 || ^22.13.0 || >=24"
       },
       "funding": {
         "url": "https://opencollective.com/eslint"
@@ -10609,6 +10816,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/which-module": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
+      "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
+      "license": "ISC"
+    },
     "node_modules/widest-line": {
       "version": "4.0.1",
       "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz",
@@ -10695,7 +10908,6 @@
       "version": "6.2.0",
       "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
       "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
-      "dev": true,
       "license": "MIT",
       "dependencies": {
         "ansi-styles": "^4.0.0",

+ 8 - 1
package.json

@@ -15,6 +15,11 @@
   },
   "dependencies": {
     "@bufbuild/protobuf": "^2.5.1",
+    "@capacitor/app": "^8.1.0",
+    "@capacitor/core": "^8.3.4",
+    "@capacitor/keyboard": "^8.0.3",
+    "@capacitor/push-notifications": "^8.1.1",
+    "@capacitor/status-bar": "^8.0.2",
     "@quasar/cli": "^2.5.0",
     "@quasar/extras": "^1.17.0",
     "axios": "^1.13.2",
@@ -23,20 +28,22 @@
     "date-fns": "^3.6.0",
     "fast-deep-equal": "^3.1.3",
     "pinia": "^3.0.2",
+    "qrcode": "^1.5.4",
     "quasar": "^2.18.6",
     "socket.io-client": "^4.8.1",
     "vue": "^3.5.13",
     "vue-chartjs": "^5.3.3",
     "vue-currency-input": "^3.2.1",
+    "vue-eslint-parser": "^10.4.0",
     "vue-i18n": "^11.1.4",
     "vue-router": "^4.6.4"
   },
   "devDependencies": {
     "@bufbuild/buf": "^1.61.0",
     "@bufbuild/protoc-gen-es": "^2.10.2",
+    "@eslint/js": "^9.27.0",
     "@intlify/eslint-plugin-vue-i18n": "^4.0.1",
     "@intlify/unplugin-vue-i18n": "^6.0.8",
-    "@eslint/js": "^9.27.0",
     "@quasar/app-vite": "^2.4.0",
     "@vue/eslint-config-prettier": "^10.2.0",
     "autoprefixer": "^10.4.21",

+ 5 - 3
quasar.config.js

@@ -18,6 +18,7 @@ export default defineConfig((ctx) => {
       "axios",
       "i18n",
       "defaultPropsComponents",
+      "capacitor",
       // "socket.io",
     ],
 
@@ -45,7 +46,7 @@ export default defineConfig((ctx) => {
         node: "node22",
       },
 
-      vueRouterMode: "history", // available values: 'hash', 'history'
+      vueRouterMode: "hash", // Capacitor não tem servidor HTTP para history mode
       // vueRouterBase,
       // vueDevtools,
       // vueOptionsAPI: false,
@@ -56,7 +57,8 @@ export default defineConfig((ctx) => {
       // analyze: true,
       env: {
         APP_NAME: "serprati-associado",
-        API_URL: ctx.dev ? "http://localhost:8000" : "http://localhost:8000",
+        API_URL: ctx.dev ? "http://localhost:3000" : "https://api.serprati.softpar.inf.br",
+        // API_URL: ctx.dev ? "http://10.0.2.2:8000" : "https://api.serprati.softpar.inf.br",
         PASSWORD: ctx.dev ? "S@ft2080." : "",
         WEBSOCKET_API: ctx.dev
           ? "http://localhost:4321/"
@@ -107,7 +109,7 @@ export default defineConfig((ctx) => {
     framework: {
       lang: "pt-BR",
       config: {
-        dark: "auto",
+        dark: false,
         notify: {
           position: "top-right",
         },

+ 2 - 17
src/App.vue

@@ -14,24 +14,9 @@ defineOptions({
 const { locale } = useI18n();
 
 const $q = useQuasar();
-const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
-  ? "dark"
-  : "light";
 
-const theme = Cookies.get("theme") || systemTheme;
-
-$q.dark.set(theme == "dark");
-
-watch(
-  () => $q.dark.isActive,
-  (value) => {
-    Cookies.set("theme", value ? "dark" : "light", {
-      expires: 365,
-      sameSite: "Lax",
-      path: "/",
-    });
-  },
-);
+$q.dark.set(false);
+Cookies.remove("theme");
 
 watch(
   () => locale.value,

+ 63 - 0
src/api/appointment.js

@@ -0,0 +1,63 @@
+import api from "src/api";
+
+// ─── Rotas do Associado ───────────────────────────────────────────────────────
+
+export const getMyAppointments = async () => {
+  const { data } = await api.get("/associado/appointment/my");
+  return data.payload;
+};
+
+export const createAppointment = async (appointment) => {
+  const { data } = await api.post("/associado/appointment", appointment);
+  return data.payload;
+};
+
+export const cancelAppointment = async (id) => {
+  const { data } = await api.put(`/associado/appointment/${id}`, { status: "cancelado" });
+  return data.payload;
+};
+
+export const updateAppointment = async (id, payload) => {
+  const { data } = await api.put(`/associado/appointment/${id}`, payload);
+  return data.payload;
+};
+
+// ─── Rotas do Parceiro ────────────────────────────────────────────────────────
+
+export const getPartnerAppointments = async () => {
+  const { data } = await api.get("/parceiro/appointment");
+  return data.payload;
+};
+
+export const approveAppointmentParceiro = async (id) => {
+  const { data } = await api.put(`/parceiro/appointment/${id}/approve`);
+  return data.payload;
+};
+
+export const rejectAppointmentParceiro = async (id) => {
+  const { data } = await api.put(`/parceiro/appointment/${id}/reject`);
+  return data.payload;
+};
+
+export const getAdminCounters = async () => {
+  const { data } = await api.get("/appointment/admin/counters");
+  return data.payload;
+};
+
+export const getAdminAppointmentsPaginated = async ({ page = 1, perPage = 10, filter, status } = {}) => {
+  const params = { page, per_page: perPage };
+  if (filter) params.search = filter;
+  if (status) params.status = status;
+  const { data } = await api.get("/appointment/admin/list", { params });
+  return { data: { result: data.payload } };
+};
+
+export const approveAppointment = async (id) => {
+  const { data } = await api.put(`/appointment/${id}/approve`);
+  return data.payload;
+};
+
+export const rejectAppointment = async (id) => {
+  const { data } = await api.put(`/appointment/${id}/reject`);
+  return data.payload;
+};

+ 10 - 0
src/api/auth.js

@@ -0,0 +1,10 @@
+import api from "src/api";
+
+export const forgotPassword = (email, tipo) =>
+  api.post("/forgot-password", { email, tipo });
+
+export const verifyCode = (email, codigo) =>
+  api.post("/verify-code", { email, codigo });
+
+export const resetPassword = (email, codigo, password, password_confirmation) =>
+  api.post("/reset-password", { email, codigo, password, password_confirmation });

+ 7 - 0
src/api/category.js

@@ -0,0 +1,7 @@
+import api from "src/api";
+
+export const getCategories = async (type = null) => {
+  const params = type ? { type } : {};
+  const { data } = await api.get("/category", { params });
+  return data.payload;
+};

+ 81 - 0
src/api/notification.js

@@ -0,0 +1,81 @@
+import api from "src/api";
+
+// ─── Rotas do Associado ───────────────────────────────────────────────────────
+
+export const getMyNotificationsAssociado = async () => {
+  const { data } = await api.get("/associado/notification/my");
+  return data.payload;
+};
+
+export const getMyUnreadNotificationsAssociado = async () => {
+  const { data } = await api.get("/associado/notification/my/unread");
+  return data.payload;
+};
+
+export const markNotificationAsReadAssociado = async (sendId) => {
+  const { data } = await api.patch(`/associado/notification/${sendId}/read`);
+  return data.payload;
+};
+
+// ─── Rotas do Parceiro ────────────────────────────────────────────────────────
+
+export const getMyNotificationsParceiro = async () => {
+  const { data } = await api.get("/parceiro/notification/my");
+  return data.payload;
+};
+
+export const getMyUnreadNotificationsParceiro = async () => {
+  const { data } = await api.get("/parceiro/notification/my/unread");
+  return data.payload;
+};
+
+export const markNotificationAsReadParceiro = async (sendId) => {
+  const { data } = await api.patch(`/parceiro/notification/${sendId}/read`);
+  return data.payload;
+};
+
+// ─── Rotas Admin ──────────────────────────────────────────────────────────────
+
+export const getMyUnreadNotifications = async () => {
+  const { data } = await api.get("/notification/my/unread");
+  return data.payload;
+};
+
+export const getMyNotifications = async () => {
+  const { data } = await api.get("/notification/my");
+  return data.payload;
+};
+
+export const markNotificationAsRead = async (sendId) => {
+  const { data } = await api.patch(`/notification/${sendId}/read`);
+  return data.payload;
+};
+
+export const getNotificationsPaginated = async ({ page = 1, perPage = 12 } = {}) => {
+  const { data } = await api.get("/notification/paginated", {
+    params: { page, per_page: perPage },
+  });
+  return data.payload;
+};
+
+export const sendNotification = async (payload) => {
+  const form = new FormData();
+  form.append("title", payload.title);
+  form.append("message", payload.message);
+  form.append("recipient", payload.recipient);
+
+  if (payload.recipient_position_id != null) {
+    form.append("recipient_position_id", payload.recipient_position_id);
+  }
+  if (payload.recipient_sector_id != null) {
+    form.append("recipient_sector_id", payload.recipient_sector_id);
+  }
+  if (payload.image instanceof File) {
+    form.append("image", payload.image);
+  }
+
+  const { data } = await api.post("/notification", form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};

+ 132 - 0
src/api/partnerAgreement.js

@@ -0,0 +1,132 @@
+import api from "src/api";
+
+export const getPartnerAgreements = async () => {
+  const { data } = await api.get("/associado-partner-agreement");
+  return data.payload;
+};
+
+export const getPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, filter, expiresInDays, createdMonth } = {}) => {
+  const params = { page, per_page: perPage };
+  if (filter)        params.search          = filter;
+  if (expiresInDays) params.expires_in_days = expiresInDays;
+  if (createdMonth)  params.created_month   = createdMonth;
+  const { data } = await api.get("/partner-agreement/paginated", { params });
+  return { data: { result: data.payload } };
+};
+
+export const getExpiringPartnerAgreementsPaginated = async ({ page = 1, perPage = 10, days = 30 } = {}) => {
+  const { data } = await api.get("/partner-agreement/expiring", { params: { page, per_page: perPage, days } });
+  return { data: { result: data.payload } };
+};
+
+export const getPartnerAgreement = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}`);
+  return data.payload;
+};
+
+export const getPartnerAgreementDados = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/dados`);
+  return data.payload;
+};
+
+export const getPartnerAgreementContato = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/contato`);
+  return data.payload;
+};
+
+export const getPartnerAgreementEndereco = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/endereco`);
+  return data.payload;
+};
+
+export const getPartnerAgreementContrato = async (id) => {
+  const { data } = await api.get(`/partner-agreement/${id}/contrato`);
+  return data.payload;
+};
+
+export const createPartnerAgreement = async (payload) => {
+  const { data } = await api.post("/partner-agreement", payload);
+  return data.payload;
+};
+
+export const updatePartnerAgreement = async (id, payload) => {
+  const { data } = await api.put(`/partner-agreement/${id}`, payload);
+  return data.payload;
+};
+
+export const deletePartnerAgreement = async (id) => {
+  await api.delete(`/partner-agreement/${id}`);
+};
+
+export const uploadPartnerLogo = async (id, file) => {
+  const form = new FormData();
+  form.append("logo", file);
+  const { data } = await api.post(`/partner-agreement/${id}/logo`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const uploadPartnerMedia = async (id, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/partner-agreement/${id}/media`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deletePartnerMedia = async (id, mediaId) => {
+  await api.delete(`/partner-agreement/${id}/media/${mediaId}`);
+};
+
+// ─── Rotas do Associado ───────────────────────────────────────────────────────
+
+export const getConvenios = async () => {
+  const { data } = await api.get("/associado/partner-agreement");
+  return data.payload;
+};
+
+export const getConvenio = async (id) => {
+  const { data } = await api.get(`/associado/partner-agreement/${id}`);
+  return data.payload;
+};
+
+export const getConvenioDados = async (id) => {
+  const { data } = await api.get(`/associado/partner-agreement/${id}/dados`);
+  return data.payload;
+};
+
+// ─── Rotas do Parceiro (my) ───────────────────────────────────────────────────
+
+export const getMyPartnerAgreement = async () => {
+  const { data } = await api.get("/partner-agreement/my");
+  return data.payload;
+};
+
+export const updateMyPartnerAgreement = async (payload) => {
+  const { data } = await api.put("/partner-agreement/my", payload);
+  return data.payload;
+};
+
+export const uploadMyPartnerLogo = async (file) => {
+  const form = new FormData();
+  form.append("logo", file);
+  const { data } = await api.post("/partner-agreement/my/logo", form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const uploadMyPartnerMedia = async (file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post("/partner-agreement/my/media", form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteMyPartnerMedia = async (mediaId) => {
+  await api.delete(`/partner-agreement/my/media/${mediaId}`);
+};

+ 48 - 0
src/api/partnerAgreementService.js

@@ -0,0 +1,48 @@
+import api from "src/api";
+
+export const getServicesByConvenio = async (partnerAgreementId) => {
+  const { data } = await api.get(`/associado/partner-agreement-service/partner/${partnerAgreementId}`);
+  return data.payload;
+};
+
+export const getConvenioService = async (id) => {
+  const { data } = await api.get(`/associado/partner-agreement-service/${id}`);
+  return data.payload;
+};
+
+export const getServicesByPartner = async (partnerAgreementId) => {
+  const { data } = await api.get(`/partner-agreement-service/partner/${partnerAgreementId}`);
+  return data.payload;
+};
+
+export const getPartnerAgreementService = async (id) => {
+  const { data } = await api.get(`/partner-agreement-service/${id}`);
+  return data.payload;
+};
+
+export const createService = async (payload) => {
+  const { data } = await api.post("/partner-agreement-service", payload);
+  return data.payload;
+};
+
+export const updateService = async (id, payload) => {
+  const { data } = await api.put(`/partner-agreement-service/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteService = async (id) => {
+  await api.delete(`/partner-agreement-service/${id}`);
+};
+
+export const uploadServiceMedia = async (id, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/partner-agreement-service/${id}/media`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteServiceMedia = async (id, mediaId) => {
+  await api.delete(`/partner-agreement-service/${id}/media/${mediaId}`);
+};

+ 26 - 0
src/api/profile.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const updateProfile = async (id, data) => {
+  const { data: res } = await api.put(`/user/${id}`, data);
+  return res.payload;
+};
+
+export const getDependentsByUser = async (userId) => {
+  const { data } = await api.get(`/user-dependent/user/${userId}`);
+  return data.payload;
+};
+
+export const createDependent = async (dependent) => {
+  const { data } = await api.post("/user-dependent", dependent);
+  return data.payload;
+};
+
+export const updateDependent = async (id, dependent) => {
+  const { data } = await api.put(`/user-dependent/${id}`, dependent);
+  return data.payload;
+};
+
+export const deleteDependent = async (id) => {
+  const { data } = await api.delete(`/user-dependent/${id}`);
+  return data.payload;
+};

+ 105 - 0
src/api/storeItem.js

@@ -0,0 +1,105 @@
+import api from "src/api";
+
+// ─── Rotas do Associado ───────────────────────────────────────────────────────
+
+export const getStoreItemsAssociado = async () => {
+  const { data } = await api.get("/associado/store-item");
+  return data.payload;
+};
+
+export const getStoreItemAssociado = async (id) => {
+  const { data } = await api.get(`/associado/store-item/${id}`);
+  return data.payload;
+};
+
+export const getMyInterestsAssociado = async () => {
+  const { data } = await api.get("/associado/store-item/my/interests");
+  return data.payload;
+};
+
+export const toggleInterestAssociado = async (id) => {
+  const { data } = await api.post(`/associado/store-item/${id}/interest`);
+  return data.payload;
+};
+
+// ─── Rotas Admin ──────────────────────────────────────────────────────────────
+
+export const getStoreItems = async () => {
+  const { data } = await api.get("/store-item");
+  return data.payload;
+};
+
+export const getStoreItemsPaginated = async ({ page = 1, perPage = 10, filter, status, category_id } = {}) => {
+  const params = { page, per_page: perPage };
+  if (filter)      params.search      = filter;
+  if (status)      params.status      = status;
+  if (category_id) params.category_id = category_id;
+  const { data } = await api.get("/store-item/paginated", { params });
+  return { data: { result: data.payload } };
+};
+
+export const getStoreItem = async (id) => {
+  const { data } = await api.get(`/store-item/${id}`);
+  return data.payload;
+};
+
+export const createStoreItem = async (payload) => {
+  const { data } = await api.post("/store-item", payload);
+  return data.payload;
+};
+
+export const updateStoreItem = async (id, payload) => {
+  const { data } = await api.put(`/store-item/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteStoreItem = async (id) => {
+  await api.delete(`/store-item/${id}`);
+};
+
+export const toggleStoreItemStatus = async (id) => {
+  const { data } = await api.patch(`/store-item/${id}/toggle-status`);
+  return data.payload;
+};
+
+export const getMyInterests = async () => {
+  const { data } = await api.get("/store-item/my/interests");
+  return data.payload;
+};
+
+export const toggleInterest = async (id) => {
+  const { data } = await api.post(`/store-item/${id}/interest`);
+  return data.payload;
+};
+
+export const uploadStoreItemMedia = async (id, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/store-item/${id}/media`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteStoreItemMedia = async (id, mediaId) => {
+  await api.delete(`/store-item/${id}/media/${mediaId}`);
+};
+
+export const getStoreItemInterests = async (id) => {
+  const { data } = await api.get(`/store-item/${id}/interests`);
+  return data.payload;
+};
+
+export const addStoreItemVariation = async (itemId, payload) => {
+  const { data } = await api.post(`/store-item/${itemId}/variation`, payload);
+  return data.payload;
+};
+
+export const updateStoreItemVariation = async (itemId, variationId, payload) => {
+  const { data } = await api.patch(`/store-item/${itemId}/variation/${variationId}`, payload);
+  return data.payload;
+};
+
+export const deleteStoreItemVariation = async (itemId, variationId) => {
+  await api.delete(`/store-item/${itemId}/variation/${variationId}`);
+};

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 6 - 0
src/assets/logo_serprati.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 6 - 0
src/assets/logo_serprati_associado.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 6 - 0
src/assets/logo_serprati_associado_small.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 6 - 0
src/assets/logo_serprati_parceiro.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 6 - 0
src/assets/logo_serprati_parceiro_small.svg


BIN
src/assets/logo_serprati_small.png


BIN
src/assets/pessoas_fundo.jpg


+ 18 - 0
src/boot/capacitor.js

@@ -0,0 +1,18 @@
+import { defineBoot } from "#q-app/wrappers";
+import { Capacitor } from "@capacitor/core";
+import { StatusBar, Style } from "@capacitor/status-bar";
+import { Keyboard } from "@capacitor/keyboard";
+
+export default defineBoot(async () => {
+  if (!Capacitor.isNativePlatform()) return;
+
+  await StatusBar.setStyle({ style: Style.Dark });
+  await StatusBar.setBackgroundColor({ color: "#661d75" });
+
+  Keyboard.addListener("keyboardWillShow", () => {
+    document.body.style.height = "auto";
+  });
+  Keyboard.addListener("keyboardWillHide", () => {
+    document.body.style.height = "";
+  });
+});

+ 2 - 2
src/boot/defaultPropsComponents.js

@@ -21,14 +21,14 @@ export default defineBoot(() => {
   });
   SetComponentDefaults(QInput, {
     rounded: true,
-    dark: true,
+    dark: false,
     standout: true,
     dense: true,
   });
   SetComponentDefaults(QSelect, {
     rounded: true,
     standout: true,
-    dark: true,
+    dark: false,
     dense: true,
   });
   SetComponentDefaults(QCard, {

+ 9 - 8
src/components/defaults/DefaultCurrencyInput.vue

@@ -3,7 +3,7 @@
     ref="inputRef"
     v-model="formattedValue"
     v-bind="$attrs"
-    :label="label"
+    :label="displayLabel"
     :rules="finalRules"
     :input-class="inputClass"
   />
@@ -13,18 +13,16 @@
 import { watch, onBeforeMount, ref } from "vue";
 import { useCurrencyInput } from "vue-currency-input";
 import { useI18n } from "vue-i18n";
-import { useInputRules } from "src/composables/useInputRules";
 
 import DefaultInput from "./DefaultInput.vue";
 
-const { inputRules } = useInputRules();
+const { t } = useI18n();
 
 const model = defineModel({ type: Number });
 
-const defaultRules = [inputRules.minValue(0)];
 const finalRules = ref([]);
 
-const { options, label, rules } = defineProps({
+const props = defineProps({
   options: {
     type: Object,
     default: () => ({
@@ -41,7 +39,7 @@ const { options, label, rules } = defineProps({
   },
   label: {
     type: String,
-    default: useI18n().t("common.terms.currency"),
+    default: null,
   },
   errorMessage: {
     type: String,
@@ -57,6 +55,9 @@ const { options, label, rules } = defineProps({
   },
 });
 
+const displayLabel = props.label ?? t("common.terms.currency");
+const { options, rules, inputClass } = props;
+
 const { inputRef, formattedValue, numberValue, setValue } =
   useCurrencyInput(options);
 
@@ -77,11 +78,11 @@ watch(
 watch(
   () => rules,
   (value) => {
-    finalRules.value = [...value, ...defaultRules];
+    finalRules.value = [...value];
   },
 );
 
 onBeforeMount(() => {
-  finalRules.value = [...rules, ...defaultRules];
+  finalRules.value = [...rules];
 });
 </script>

+ 101 - 137
src/components/defaults/DefaultFilePicker.vue

@@ -14,7 +14,7 @@
       <span v-if="required" class="text-negative q-ml-xs">*</span>
     </div>
     <q-field
-      v-model="model"
+      :model-value="preview"
       v-bind="inputAttrs"
       borderless
       hide-bottom-space
@@ -24,7 +24,7 @@
       class="image-preview-container"
     >
       <div
-        class=""
+        class="file-picker-inner"
         :class="{
           'has-image': preview,
           'is-dragging': isDragging,
@@ -43,51 +43,35 @@
             color="grey-6"
             class="absolute-center"
           />
-          <div
-            class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
-          >
+          <div class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md">
             {{
               isDragging
                 ? $t("common.ui.file.drag_and_drop")
-                : type == "image"
+                : type === "image"
                   ? $t("common.ui.file.click_select_image")
                   : $t("common.ui.file.click_select")
             }}
           </div>
         </template>
 
-        <q-img
+        <div
           v-else-if="type === 'image'"
-          :src="preview"
-          fit="cover"
-          class="full-height"
+          class="file-picker-image"
+          :style="{ backgroundImage: `url('${preview}')` }"
         />
 
-        <div v-else class="position-relative column full-height flex-center">
+        <div v-else class="column flex-center" style="height: 100%">
           <q-icon name="mdi-file-check" size="48px" color="grey-6" />
-          <div
-            class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
-          >
-            {{ model.name }}
+          <div class="text-caption text-grey-6 text-center q-mt-sm q-px-md">
+            {{ internalFile?.name }}
           </div>
         </div>
-
-        <div v-if="preview" class="absolute-top-right q-ma-xs">
-          <q-btn
-            flat
-            dense
-            round
-            color="negative"
-            icon="mdi-close"
-            @click.stop="clearFile"
-          />
-        </div>
       </div>
 
       <q-file
         v-show="false"
         ref="fileInputRef"
-        v-model="model"
+        v-model="internalFile"
         :accept="accept"
       />
     </q-field>
@@ -95,21 +79,13 @@
 </template>
 
 <script setup>
-import {
-  ref,
-  watch,
-  onUnmounted,
-  useTemplateRef,
-  useAttrs,
-  computed,
-  onBeforeMount,
-} from "vue";
+import { ref, watch, computed, onUnmounted, useTemplateRef, useAttrs } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
-const { label, rules, accept, type, initialImage } = defineProps({
+const props = defineProps({
   label: {
     type: String,
     default: "Select Image",
@@ -147,117 +123,91 @@ const model = defineModel({ type: [File, String, null], default: null });
 const base64File = defineModel("base64File", { type: String, default: null });
 
 const isDragging = ref(false);
-const preview = ref(initialImage || null);
-const required = ref(false);
+const internalFile = ref(null);
+const objectUrl = ref(null);
 
-let objectUrl = null;
+const required = computed(() => props.rules.some((r) => r?.$id === "required"));
 
-const cleanupObjectURL = () => {
-  if (objectUrl) {
-    URL.revokeObjectURL(objectUrl);
-    objectUrl = null;
+const preview = computed(() => {
+  if (internalFile.value instanceof File) {
+    return props.type === "image" ? objectUrl.value : "file_selected";
   }
-};
+  return props.initialImage || null;
+});
 
-const generateBase64 = (file) => {
-  if (!file) {
-    base64File.value = null;
-    return;
-  }
-  const reader = new FileReader();
-  reader.onload = (e) => {
-    base64File.value = e.target.result;
-  };
-  reader.onerror = () => {
-    console.error("FileReader failed to read file.");
-    base64File.value = null;
-  };
-  reader.readAsDataURL(file);
-};
+const inputAttrs = computed(() => {
+  // eslint-disable-next-line
+  const { class: _, style: __, ...rest } = attrs;
+  return rest;
+});
 
-const pickFile = () => {
+function pickFile() {
   fileInputRef.value?.pickFiles();
-};
-
-const clearFile = () => {
-  model.value = null;
-};
+}
 
-const handleDragOver = (event) => {
+function handleDragOver(event) {
   event.preventDefault();
   event.dataTransfer.dropEffect = "copy";
   isDragging.value = true;
-};
+}
 
-const handleDragLeave = () => {
+function handleDragLeave() {
   isDragging.value = false;
-};
+}
 
-const handleDrop = (event) => {
+function handleDrop(event) {
   event.preventDefault();
   isDragging.value = false;
-
   const file = event.dataTransfer?.files?.[0];
   if (!file) return;
-
-  const acceptedMime = accept;
-
+  const acceptedMime = props.accept;
   if (acceptedMime.endsWith("/*")) {
     const baseMime = acceptedMime.replace("/*", "");
-    if (file.type.startsWith(baseMime + "/")) {
-      model.value = file;
-    }
+    if (file.type.startsWith(baseMime + "/")) internalFile.value = file;
   } else {
-    if (
-      acceptedMime
-        .split(",")
-        .map((m) => m.trim())
-        .includes(file.type)
-    ) {
-      model.value = file;
+    if (acceptedMime.split(",").map((m) => m.trim()).includes(file.type)) {
+      internalFile.value = file;
     }
   }
-};
-
-const inputAttrs = computed(() => {
-  // eslint-disable-next-line
-  const { class: _, style: __, ...rest } = attrs;
-  return rest;
-});
+}
 
-watch(model, (newFile) => {
-  cleanupObjectURL();
+function generateBase64(file) {
+  if (!file) { base64File.value = null; return; }
+  const reader = new FileReader();
+  reader.onload = (e) => { base64File.value = e.target.result; };
+  reader.onerror = () => { base64File.value = null; };
+  reader.readAsDataURL(file);
+}
 
-  if (newFile && newFile instanceof File) {
-    if (type === "image") {
-      objectUrl = URL.createObjectURL(newFile);
-      preview.value = objectUrl;
-    } else {
-      preview.value = "file_selected";
+watch(internalFile, (newFile) => {
+  if (objectUrl.value) {
+    URL.revokeObjectURL(objectUrl.value);
+    objectUrl.value = null;
+  }
+  if (newFile instanceof File) {
+    if (props.type === "image") {
+      objectUrl.value = URL.createObjectURL(newFile);
     }
+    model.value = newFile;
     generateBase64(newFile);
   } else {
-    preview.value = initialImage || null;
+    model.value = null;
     base64File.value = null;
   }
 });
 
-watch(
-  () => rules,
-  (values) => {
-    values.forEach((r) => {
-      if (r?.$id === "required") return (required.value = true);
-    });
-  },
-);
-
-onBeforeMount(() => {
-  rules.forEach((r) => {
-    if (r?.$id === "required") return (required.value = true);
-  });
+watch(model, (val) => {
+  if (!val && internalFile.value) {
+    internalFile.value = null;
+  }
 });
 
-onUnmounted(cleanupObjectURL);
+onUnmounted(() => {
+  if (objectUrl.value) {
+    URL.revokeObjectURL(objectUrl.value);
+    objectUrl.value = null;
+  }
+});
 </script>
 
 <style lang="scss">
@@ -267,46 +217,44 @@ onUnmounted(cleanupObjectURL);
 .image-preview-container {
   display: flex;
   justify-content: center;
-  align-items: center;
+  align-items: stretch;
 
   .q-field__inner {
-    .body--dark & {
-      --image-bg-color: #{map.get($colors-dark, "surface")};
-      --image-border-color: #{map.get($colors-dark, "primary")};
-      --image-border-hover-color: #{map.get($colors-dark, "primary-dark")};
-    }
-
     .body--light & {
       --image-bg-color: #{map.get($colors, "surface")};
-      --image-border-color: #{map.get($colors, "primary")};
-      --image-border-hover-color: #{map.get($colors, "primary-dark")};
+      --image-border-color: #{map.get($colors, "violet-normal")};
+      --image-border-hover-color: #{map.get($colors, "violet-normal-hover")};
     }
 
+    height: 100%;
     display: flex;
     flex-direction: column;
-    transition: all 0.3s;
-
-    &.is-dragging {
-      border-color: var(--image-border-hover-color);
-      background-color: var(--image-bg-color);
-      opacity: 0.8;
-    }
-
-    &.has-image {
-      border-style: solid;
-      margin: auto;
-    }
+    transition: border-color 0.3s;
   }
+
   .q-field__control {
     height: 100%;
     min-height: 200px;
     border: 2px dashed var(--image-border-color);
     border-radius: 8px;
     cursor: pointer;
+    overflow: hidden;
+
+    &:has(.has-image) {
+      border-style: solid;
+      border-color: var(--image-border-color);
+    }
+
+    &:has(.is-dragging) {
+      border-color: var(--image-border-hover-color);
+      opacity: 0.8;
+    }
   }
 
   .q-field__control-container {
-    justify-content: center;
+    height: 100%;
+    justify-content: stretch;
+    align-items: stretch;
   }
 
   .q-field__append {
@@ -314,5 +262,21 @@ onUnmounted(cleanupObjectURL);
     top: -5px;
     right: 10px;
   }
+
+  .file-picker-inner {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    min-height: 196px;
+  }
+
+  .file-picker-image {
+    position: absolute;
+    inset: 0;
+    background-size: cover;
+    background-position: center;
+    background-repeat: no-repeat;
+    border-radius: 6px;
+  }
 }
 </style>

+ 5 - 4
src/components/defaults/DefaultInputDatePicker.vue

@@ -15,9 +15,9 @@
       >
         <q-popup-proxy cover transition-show="scale" transition-hide="scale">
           <template v-if="!time">
-            <q-date v-model="date" mask="YYYY-MM-DD">
+            <q-date v-model="date" mask="YYYY-MM-DD" color="violet-normal">
               <div class="row items-center justify-end">
-                <q-btn v-close-popup label="OK" color="primary" flat />
+                <q-btn v-close-popup label="OK" color="violet-normal" flat />
               </div>
             </q-date>
           </template>
@@ -34,12 +34,13 @@
                 <q-date
                   v-model="date"
                   mask="YYYY-MM-DD HH:mm"
+                  color="violet-normal"
                   @update:model-value="handleDateSelection"
                 />
               </q-tab-panel>
 
               <q-tab-panel name="time" class="q-pa-none">
-                <q-time v-model="date" mask="YYYY-MM-DD HH:mm" format24h />
+                <q-time v-model="date" mask="YYYY-MM-DD HH:mm" format24h color="violet-normal" />
               </q-tab-panel>
             </q-tab-panels>
           </template>
@@ -102,7 +103,7 @@ const unformatDate = (value) => {
 };
 
 const inputMask = computed(() => {
-  if (!inputRef.value) return "";
+  // if (!inputRef.value) return "";
 
   if (time) {
     return masks.Brasil.datetime;

+ 9 - 2
src/components/defaults/DefaultTable.vue

@@ -12,11 +12,13 @@
     :columns
     :loading
     :rows
-    class="softpar-table q-pa-sm q-mt-md"
+    :hide-top="semTableTop"
+    class="softpar-table q-pa-sm"
     @row-click="onRowClick"
   >
-    <template #top>
+    <template v-if="!semTableTop" #top>
       <div
+        v-if="title || showSearchField || showColumnsSelect || addItem || $slots.top"
         class="flex full-width align-center q-mb-md q-pl-sm"
         style="gap: 1rem"
       >
@@ -160,6 +162,7 @@ const {
   noApiCall,
   hideNoDataLabel,
   deleteFunction,
+  semTableTop,
 } = defineProps({
   title: {
     type: String,
@@ -217,6 +220,10 @@ const {
     type: Function,
     default: null,
   },
+  semTableTop: {
+    type: Boolean,
+    default: true,
+  },
 });
 
 const router = useRouter();

+ 61 - 69
src/components/defaults/DefaultTableServerSide.vue

@@ -14,13 +14,13 @@
     :filter="pagination.filter"
     :grid="$q.screen.lt.sm"
     :loading="loading"
+    :hide-top="semTableTop"
     v-bind="$attrs"
     @row-click="onRowClick"
   >
-    <template #top>
+    <template v-if="!semTableTop" #top>
       <div
-        class="flex full-width justify-between items-center q-mb-md q-pl-sm"
-        style="gap: 1rem"
+        class="flex full-width justify-between items-center"
       >
         <DefaultInput
           v-if="showSearchField"
@@ -28,11 +28,12 @@
           outlined
           dense
           debounce="500"
-          :placeholder="$t('common.actions.search')"
+          :placeholder="$t('associado.search_placeholder')"
           clearable
           autofocus
+          color="violet-normal"
         >
-          <template #append>
+          <template #prepend>
             <q-icon name="mdi-magnify" />
           </template>
         </DefaultInput>
@@ -53,7 +54,7 @@
           options-selected-class="text-bold"
         />
 
-        <q-space />
+        <q-space v-if="showSearchField || showColumnsSelect" />
 
         <q-btn
           v-if="addItem"
@@ -67,9 +68,11 @@
     </template>
 
     <template #body-cell-actions="{ row }">
-      <q-td v-if="deleteFunction">
-        <q-item-section>
+      <q-td auto-width>
+        <q-item-section class="no-wrap" style="flex-direction: row">
+          <slot name="body-cell-actions" :row="row" />
           <q-btn
+            v-if="deleteFunction"
             color="negative"
             flat
             dense
@@ -92,59 +95,36 @@
     </template>
 
     <template #bottom="scope">
-      <div class="flex full-width justify-end">
-        <div class="flex items-center">
-          {{ $t("common.ui.table.rows_per_page") }}
-          <DefaultSelect
-            v-model="pagination.rowsPerPage"
-            class="q-mx-sm"
-            dense
-            borderless
-            :options="rowsPerPageOptions"
-          >
-            <template #option="selectData">
-              <q-item v-bind="selectData.itemProps">
-                <q-item-section>
-                  <q-item-label>{{
-                    selectData.opt == 0
-                      ? $t("common.ui.misc.all")
-                      : selectData.opt
-                  }}</q-item-label>
-                </q-item-section>
-              </q-item>
-            </template>
-          </DefaultSelect>
-        </div>
-        <div class="flex items-center">
-          {{ pagination.from + "-" + pagination.to }}
+      <div class="flex full-width justify-end items-center" style="gap: 8px">
+        <span class="text-caption text-grey-7">
+          {{ $t("common.ui.table.rows_per_page") }} 10 &nbsp;|&nbsp;
+          {{ pagination.from }}-{{ pagination.to }}
           {{ $t("common.ui.table.of") }}
           {{ pagination.rowsNumber }}
-        </div>
-        <div class="flex items-center">
-          <q-btn
-            icon="mdi-chevron-left"
-            color="grey-8"
-            round
-            dense
-            flat
-            :disable="scope.isFirstPage"
-            @click="prevPage"
-          />
-          <q-btn
-            icon="mdi-chevron-right"
-            color="grey-8"
-            round
-            dense
-            flat
-            :disable="scope.isLastPage"
-            @click="nextPage"
-          />
-        </div>
+        </span>
+        <q-btn
+          icon="mdi-chevron-left"
+          color="grey-8"
+          round
+          dense
+          flat
+          :disable="scope.isFirstPage"
+          @click="prevPage"
+        />
+        <q-btn
+          icon="mdi-chevron-right"
+          color="grey-8"
+          round
+          dense
+          flat
+          :disable="scope.isLastPage"
+          @click="nextPage"
+        />
       </div>
     </template>
 
-    <template v-for="name in $slots" #[name]="data">
-      <slot :name="name" v-bind="data"></slot>
+    <template v-for="slotName in usableSlots($slots)" #[slotName]="data">
+      <slot :name="slotName" v-bind="data" />
     </template>
   </q-table>
 </template>
@@ -176,6 +156,7 @@ const {
   showSearchField,
   hideNoDataLabel,
   deleteFunction,
+  semTableTop,
 } = defineProps({
   columns: {
     type: Array,
@@ -233,6 +214,10 @@ const {
     type: Function,
     default: null,
   },
+  semTableTop: {
+    type: Boolean,
+    default: false,
+  },
 });
 
 const { t } = useI18n();
@@ -240,7 +225,6 @@ const router = useRouter();
 const rows = ref([]);
 const loading = ref(true);
 const fullscreen = ref(false);
-const rowsPerPageOptions = [10, 15, 25, 50];
 
 const pagination = ref({
   filter: undefined,
@@ -350,27 +334,35 @@ watch(
 );
 
 watch(
-  pagination,
-  async (newVal, oldVal) => {
-    if (!oldVal || loading.value) return;
-
-    if (
-      newVal.rowsPerPage !== oldVal.rowsPerPage ||
-      newVal.filter !== oldVal.filter ||
-      newVal.page !== oldVal.page
-    ) {
-      await onRequest();
-    }
+  () => pagination.value.filter,
+  (newFilter, oldFilter) => {
+    if (newFilter === oldFilter || loading.value) return;
+    pagination.value.page = 1;
+    onRequest();
+  },
+);
+
+watch(
+  () => pagination.value.rowsPerPage,
+  (newVal, oldVal) => {
+    if (newVal === oldVal || loading.value) return;
+    pagination.value.page = 1;
+    onRequest();
   },
-  { deep: true },
 );
 
 onMounted(async () => {
   await onRequest();
 });
 
+const usableSlots = (slots) => {
+  const handled = ["top", "body-cell-actions", "no-data", "bottom", "loading"];
+  return Object.keys(slots).filter((s) => !handled.includes(s));
+};
+
 defineExpose({
   refresh: onRequest,
+  getFilter: () => pagination.value.filter,
 });
 </script>
 

+ 1 - 1
src/components/defaults/DefaultTabs.vue

@@ -3,7 +3,7 @@
     v-model="tab"
     class="button bg-background-2 text-font"
     indicator-color="transparent"
-    active-color="primary"
+    active-color="violet-normal"
     v-bind="$attrs"
     align="justify"
     active-bg-color="white"

+ 83 - 68
src/components/layout/DefaultHeaderPage.vue

@@ -1,8 +1,9 @@
 <template>
   <div>
-    <q-breadcrumbs
+    <!-- <q-breadcrumbs
       v-if="displayBreadcrumbs != null"
       class="q-mb-xs"
+      active-color="violet-normal"
       :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
     >
       <q-breadcrumbs-el
@@ -11,44 +12,55 @@
         :label="crumb.title"
         :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
       />
-    </q-breadcrumbs>
-    <div
+    </q-breadcrumbs> -->
+    <!-- <div
       v-else
       style="max-width: 180px"
       :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
     >
       <q-skeleton type="text" />
-    </div>
+    </div> -->
 
     <div class="flex items-center justify-between">
-      <div class="column q-pl-xs" :class="$q.screen.lt.sm ? '' : 'q-pt-md'">
-        <span v-if="displayTitle" class="text-h4 text-primary q-mb-xs">
-          {{ displayTitle }}
-        </span>
-        <div v-else style="width: 280px">
-          <q-skeleton type="text" height="40px" />
-        </div>
-        <span v-if="displayDescription" class="text-body2">
-          {{ displayDescription }}
-        </span>
-        <div v-else style="width: 380px">
-          <q-skeleton type="text" height="20px" />
+      <div class="row items-center no-wrap" :class="$q.screen.lt.sm ? '' : 'q-pt-md'">
+        <q-btn
+          v-if="showBack"
+          flat
+          round
+          dense
+          icon="mdi-arrow-left"
+          color="violet-normal"
+          class="q-mr-xs"
+          @click="router.back()"
+        />
+        <div class="column q-pl-xs">
+          <span v-if="displayTitle" class="text-h4 text-violet-normal q-mb-xs">
+            {{ displayTitle }}
+          </span>
+          <div v-else style="width: 280px">
+            <q-skeleton type="text" height="40px" />
+          </div>
+          <!-- <span v-if="displayDescription != null" class="text-body2">
+            {{ displayDescription }}
+          </span> -->
+          <!-- <div v-else style="width: 380px">
+            <q-skeleton type="text" height="20px" />
+          </div> -->
         </div>
       </div>
       <div>
         <slot name="after" />
       </div>
     </div>
-    <q-separator class="q-my-sm" />
   </div>
 </template>
 
 <script setup>
 import { computed } from "vue";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 
-const { title, description, breadcrumbs } = defineProps({
+const { title } = defineProps({
   title: {
     type: Object,
     default: null,
@@ -63,8 +75,11 @@ const { title, description, breadcrumbs } = defineProps({
   },
 });
 
-const route = useRoute();
-const { t } = useI18n();
+const route  = useRoute();
+const router = useRouter();
+const { t }  = useI18n();
+
+const showBack = computed(() => (route.meta?.breadcrumbs?.length ?? 0) > 1);
 
 const displayTitle = computed(() => {
   if (title) {
@@ -83,52 +98,52 @@ const displayTitle = computed(() => {
   return null;
 });
 
-const displayDescription = computed(() => {
-  if (description) {
-    if (description.translate) {
-      return t(description.value);
-    } else {
-      return description.value;
-    }
-  } else if (route.meta?.description) {
-    if (route.meta?.description.translate) {
-      return t(route.meta?.description.value);
-    } else {
-      return route.meta?.description.value;
-    }
-  }
-  return null;
-});
+// const displayDescription = computed(() => {
+//   if (description) {
+//     if (description.translate) {
+//       return t(description.value);
+//     } else {
+//       return description.value;
+//     }
+//   } else if (route.meta?.description) {
+//     if (route.meta?.description.translate) {
+//       return t(route.meta?.description.value);
+//     } else {
+//       return route.meta?.description.value;
+//     }
+//   }
+//   return null;
+// });
 
-const displayBreadcrumbs = computed(() => {
-  if (!breadcrumbs && breadcrumbs?.length <= 0) {
-    return null;
-  } else if (breadcrumbs && breadcrumbs?.length > 0) {
-    return (breadcrumbs || []).map((b) => {
-      if (b.translate) {
-        return t(b.title);
-      } else {
-        return b.title;
-      }
-    });
-  }
-  if (!route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length <= 0) {
-    return null;
-  } else if (route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length > 0) {
-    return (route.meta?.breadcrumbs || []).map((b) => {
-      if (b.translate) {
-        return {
-          ...b,
-          title: t(b.title),
-        };
-      } else {
-        return {
-          ...b,
-          title: b.title,
-        };
-      }
-    });
-  }
-  return null;
-});
+// const displayBreadcrumbs = computed(() => {
+//   if (!breadcrumbs && breadcrumbs?.length <= 0) {
+//     return null;
+//   } else if (breadcrumbs && breadcrumbs?.length > 0) {
+//     return (breadcrumbs || []).map((b) => {
+//       if (b.translate) {
+//         return t(b.title);
+//       } else {
+//         return b.title;
+//       }
+//     });
+//   }
+//   if (!route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length <= 0) {
+//     return null;
+//   } else if (route.meta?.breadcrumbs && route.meta?.breadcrumbs?.length > 0) {
+//     return (route.meta?.breadcrumbs || []).map((b) => {
+//       if (b.translate) {
+//         return {
+//           ...b,
+//           title: t(b.title),
+//         };
+//       } else {
+//         return {
+//           ...b,
+//           title: b.title,
+//         };
+//       }
+//     });
+//   }
+//   return null;
+// });
 </script>

+ 37 - 77
src/components/layout/LeftMenuLayout.vue

@@ -13,21 +13,16 @@
     class="detached-container"
   >
     <div class="column full-height no-wrap">
-      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
-        <div
-          class="flex flex-center full-width q-pa-sm"
-          style="height: 60px"
-        >
-          <q-img
-            v-if="!miniState"
-            :src="Logo"
-            style="width: 92px; height: 32px"
-          />
-          <q-img v-else :src="Logo" style="width: 32px" />
-        </div>
+      <div class="menu-header">
+        <q-img
+          v-if="!miniState"
+          :src="LogoParceiro"
+          style="width: 100px; height: 36px"
+        />
+        <q-img v-else :src="LogoParceiro" style="width: 32px; height: 32px" />
       </div>
 
-      <div class="column full-height no-wrap">
+      <div class="menu-content column full-height no-wrap">
         <div
           v-if="!$q.screen.lt.md"
           class="toggle-button-wrapper absolute"
@@ -185,34 +180,6 @@
         <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
         </q-list>
         <q-list class="q-mt-auto">
-          <q-item v-ripple clickable @click="changeTheme">
-            <div class="flex">
-              <q-item-section avatar>
-                <q-icon
-                  :name="
-                    $q.dark.isActive ? 'mdi-weather-sunny' : 'mdi-weather-night'
-                  "
-                  style="font-size: 20px"
-                />
-              </q-item-section>
-              <q-item-section>{{
-                $q.dark.isActive
-                  ? $t("common.terms.light")
-                  : $t("common.terms.dark")
-              }}</q-item-section>
-            </div>
-            <q-tooltip
-              v-if="miniState"
-              anchor="center right"
-              self="center left"
-              :offset="[10, 10]"
-              >{{
-                $q.dark.isActive
-                  ? $t("common.terms.light")
-                  : $t("common.terms.dark")
-              }}</q-tooltip
-            >
-          </q-item>
           <q-item v-ripple clickable @click="logoutFn">
             <div class="flex">
               <q-item-section avatar>
@@ -235,19 +202,9 @@
           <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
             <div class="flex full-width justify-center">
               <q-img
-                :src="
-                  miniState || $q.screen.lt.md
-                    ? LogoSoftparMini
-                    : $q.dark.isActive
-                      ? LogoSoftparLight
-                      : LogoSoftparDark
-                "
+                :src="miniState || $q.screen.lt.md ? LogoSoftparMini : LogoSoftparDark"
                 style="width: 100%; height: 30px"
-                :style="
-                  miniState || $q.screen.lt.md
-                    ? 'max-width: 48px'
-                    : 'max-width: 100px'
-                "
+                :style="miniState || $q.screen.lt.md ? 'max-width: 48px' : 'max-width: 100px'"
               />
             </div>
           </q-item>
@@ -277,8 +234,7 @@ import { navigationStore } from "src/stores/navigation";
 import { useQuasar, Cookies } from "quasar";
 import { version } from "src/../package.json";
 
-import Logo from "src/assets/logo.png";
-import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
+import LogoParceiro from "src/assets/logo_serprati_parceiro.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
 import LogoSoftparMini from "src/assets/softpar_logo_mini.svg";
 
@@ -288,9 +244,6 @@ const route = useRoute();
 const navigation_store = navigationStore();
 const $q = useQuasar();
 
-const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
-  ? "dark"
-  : "light";
 const miniStateCookies = Cookies.get("miniState");
 const miniState = ref(miniStateCookies === "true" ? true : false);
 
@@ -312,15 +265,6 @@ watchEffect(() => {
   }
 });
 
-const changeTheme = async () => {
-  const theme = $q.cookies.get("theme") || systemTheme;
-  if (theme == "dark") {
-    $q.dark.set(false);
-  } else {
-    $q.dark.set(true);
-  }
-};
-
 const logoutFn = async () => {
   await logout();
   router.push({ name: "LoginPage" });
@@ -348,23 +292,39 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
-@import "/src/css/quasar.variables.scss";
+@use "src/css/quasar.variables.scss" as vars;
+
+.menu-header {
+  background: vars.$violet-normal;
+  border-radius: 8px 8px 0 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px 16px;
+  min-height: 60px;
+  flex-shrink: 0;
+}
 
-.text-subtitle3 {
-  font-size: 1.1rem !important;
-  font-weight: 400 !important;
+.menu-content {
+  flex: 1;
+  background: linear-gradient(180deg, vars.$violet-light 0%, white 100%);
+  border-radius: 0 0 8px 8px;
+  overflow: hidden;
 }
 
 .menu-selected {
-  background-color: rgba($primary, 0.1);
-  color: $primary;
+  background-color: vars.$violet-normal !important;
+  color: white !important;
+  border-radius: 8px;
+  margin: 2px 8px;
+
+  :deep(.q-icon) {
+    color: white !important;
+  }
 }
 
 .toggle-button-wrapper {
   transition: transform 0.3s ease;
-}
-
-.toggle-button-wrapper:hover {
-  transform: scale(1.1);
+  &:hover { transform: scale(1.1); }
 }
 </style>

+ 56 - 21
src/components/layout/LeftMenuLayoutMobile.vue

@@ -8,17 +8,12 @@
     behavior="mobile"
     class="detached-container"
   >
-    <div class="column full-height no-wrap">
-      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
-        <div
-          class="flex flex-center full-width q-pa-sm"
-          style="height: 50px"
-        >
-          <q-img :src="Logo" style="max-width: 92px" />
-        </div>
+    <div class="menu-wrapper">
+      <div class="menu-header">
+        <q-img :src="LogoParceiro" style="max-width: 100px" />
       </div>
 
-      <div class="column full-height no-wrap">
+      <div class="menu-content">
         <q-list class="column no-wrap">
           <template v-for="item in navigation_store.navigationItems">
             <template v-if="item.permission">
@@ -30,7 +25,7 @@
                 clickable
                 exact-active-class="menu-selected"
                 active-class="menu-selected"
-                :exact="item.name == 'HomePage'"
+                :exact="item.name == 'CarteirinhaPage'"
                 :to="{ name: item.name }"
               >
                 <q-item-section avatar>
@@ -75,20 +70,26 @@
         </q-list>
 
         <q-list class="q-mt-auto">
+          <q-item v-ripple clickable @click="logoutFn">
+            <q-item-section avatar>
+              <q-icon name="mdi-logout" color="negative" style="font-size: 20px" />
+            </q-item-section>
+            <q-item-section>{{ $t("auth.logout") }}</q-item-section>
+          </q-item>
           <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
             <div class="flex full-width justify-center">
               <q-img
-                :src="$q.dark.isActive ? LogoSoftparLight : LogoSoftparDark"
+                :src="LogoSoftparDark"
                 style="width: 100%; height: 30px; max-width: 114px"
               />
             </div>
           </q-item>
         </q-list>
         <div
-          class="full-width text-center text-subtitle3 cursor-pointer"
+          class="full-width text-center cursor-pointer q-pb-xs"
           @click="gotoVersionPage()"
         >
-          <span class="text-caption text-weight-light">{{
+          <span class="text-caption text-weight-light text-grey-6">{{
             $t("common.terms.version") + " " + version
           }}</span>
         </div>
@@ -101,15 +102,21 @@
 import { ref, onMounted } from "vue";
 import { useRoute, useRouter } from "vue-router";
 import { navigationStore } from "src/stores/navigation";
+import { useAuth } from "src/composables/useAuth";
 import { version } from "src/../package.json";
 
-import Logo from "src/assets/logo.png";
-import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
+import LogoParceiro from "src/assets/logo_serprati_parceiro.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
 
 const router = useRouter();
 const route = useRoute();
 const navigation_store = navigationStore();
+const { logout } = useAuth();
+
+const logoutFn = async () => {
+  await logout();
+  router.push({ name: "LoginPage" });
+};
 
 const leftDrawerOpen = defineModel({ type: Boolean });
 const isExpasionItemExpanded = ref([]);
@@ -139,14 +146,42 @@ onMounted(() => {
 </script>
 
 <style lang="scss" scoped>
-@import "/src/css/quasar.variables.scss";
-.text-subtitle3 {
-  font-size: 1.1rem !important;
-  font-weight: 400 !important;
+@use "src/css/quasar.variables.scss" as vars;
+
+.menu-wrapper {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+
+.menu-header {
+  background: vars.$violet-normal;
+  border-radius: 8px 8px 0 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 10px 16px;
+  min-height: 56px;
+  flex-shrink: 0;
+}
+
+.menu-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  background: linear-gradient(180deg, vars.$violet-light 0%, white 100%);
+  border-radius: 0 0 8px 8px;
+  overflow: hidden;
 }
 
 .menu-selected {
-  background-color: rgba($primary, 0.1);
-  color: $primary;
+  background-color: vars.$violet-normal !important;
+  color: white !important;
+  border-radius: 8px;
+  margin: 2px 8px;
+
+  :deep(.q-icon) {
+    color: white !important;
+  }
 }
 </style>

+ 76 - 0
src/components/selects/CategorySelect.vue

@@ -0,0 +1,76 @@
+<template>
+  <DefaultSelect
+    v-model="selectedCategory"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="categoryOptions"
+    :label
+    :loading
+    :placeholder
+    class="input-violet"
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getCategories } from "src/api/category";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder, type } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("associado.category"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+  type: {
+    type: String,
+    default: null,
+  },
+});
+
+const selectedCategory = defineModel({ type: Object });
+
+const loading = ref(true);
+const baseOptions = ref([]);
+const categoryOptions = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  categoryOptions.value = baseOptions.value.filter((v) =>
+    normalizeString(v.label).includes(needle),
+  );
+  update();
+};
+
+onMounted(async () => {
+  try {
+    const categories = await getCategories(type);
+    baseOptions.value = categories.map((c) => ({
+      label: c.name,
+      value: c.id,
+    }));
+    categoryOptions.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 17 - 0
src/components/selects/PartnerAgreementCategorySelect.vue

@@ -0,0 +1,17 @@
+<template>
+  <CategorySelect v-model="model" v-bind="$attrs" type="partner" :label="label" />
+</template>
+
+<script setup>
+import { useI18n } from "vue-i18n";
+import CategorySelect from "src/components/selects/CategorySelect.vue";
+
+const { label } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("parceiro.category"),
+  },
+});
+
+const model = defineModel({ type: Object });
+</script>

+ 75 - 0
src/components/selects/PartnerAgreementSelect.vue

@@ -0,0 +1,75 @@
+<template>
+  <DefaultSelect
+    v-model="selectedPartner"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="partnerOptions"
+    :label
+    :loading
+    :placeholder
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getPartnerAgreements, getConvenios } from "src/api/partnerAgreement";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder, forAssociado } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("ui.navigation.convenios"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+  forAssociado: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const selectedPartner = defineModel({ type: Object });
+
+const loading = ref(true);
+const baseOptions = ref([]);
+const partnerOptions = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  partnerOptions.value = baseOptions.value.filter((v) =>
+    normalizeString(v.label).includes(needle),
+  );
+  update();
+};
+
+onMounted(async () => {
+  try {
+    const partners = await (forAssociado ? getConvenios() : getPartnerAgreements());
+    baseOptions.value = partners.map((p) => ({
+      label: p.trade_name || p.company_name,
+      value: p.id,
+    }));
+    partnerOptions.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 78 - 0
src/components/selects/PartnerAgreementServiceSelect.vue

@@ -0,0 +1,78 @@
+<template>
+  <DefaultSelect
+    v-model="selectedService"
+    v-bind="$attrs"
+    :options="serviceOptions"
+    :label
+    :loading
+    :placeholder
+    :disable="!partnerAgreementId"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, watch } from "vue";
+import { getServicesByPartner, getServicesByConvenio } from "src/api/partnerAgreementService";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder, partnerAgreementId, forAssociado } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("associado.service"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+  partnerAgreementId: {
+    type: Number,
+    default: null,
+  },
+  forAssociado: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const selectedService = defineModel({ type: Object });
+
+const loading = ref(false);
+const serviceOptions = ref([]);
+
+watch(
+  () => partnerAgreementId,
+  async (id) => {
+    const savedSelection = selectedService.value;
+    selectedService.value = null;
+    serviceOptions.value = [];
+    if (!id) return;
+    loading.value = true;
+    try {
+      const services = await (forAssociado ? getServicesByConvenio(id) : getServicesByPartner(id));
+      serviceOptions.value = services.map((s) => ({
+        label: s.name,
+        value: s.id,
+        data: s,
+      }));
+      if (savedSelection?.value) {
+        const match = serviceOptions.value.find((s) => s.value === savedSelection.value);
+        if (match) selectedService.value = match;
+      }
+    } catch (e) {
+      console.error(e);
+    } finally {
+      loading.value = false;
+    }
+  },
+  { immediate: true },
+);
+</script>

+ 68 - 0
src/components/selects/PositionSelect.vue

@@ -0,0 +1,68 @@
+<template>
+  <DefaultSelect
+    v-model="selected"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="filteredOptions"
+    :label
+    :loading
+    :placeholder
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getPositions } from "src/api/position";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("associado.position"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+});
+
+const selected = defineModel({ type: Object });
+
+const loading = ref(true);
+const baseOptions = ref([]);
+const filteredOptions = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  filteredOptions.value = baseOptions.value.filter((v) =>
+    normalizeString(v.label).includes(needle),
+  );
+  update();
+};
+
+onMounted(async () => {
+  try {
+    const items = await getPositions();
+    baseOptions.value = items.map((p) => ({ label: p.name, value: p.id }));
+    filteredOptions.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 68 - 0
src/components/selects/SectorSelect.vue

@@ -0,0 +1,68 @@
+<template>
+  <DefaultSelect
+    v-model="selected"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="filteredOptions"
+    :label
+    :loading
+    :placeholder
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t("http.errors.no_records_found") }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getSectors } from "src/api/sector";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label, placeholder } = defineProps({
+  label: {
+    type: String,
+    default: () => useI18n().t("associado.sector"),
+  },
+  placeholder: {
+    type: String,
+    default: () => useI18n().t("common.actions.search"),
+  },
+});
+
+const selected = defineModel({ type: Object });
+
+const loading = ref(true);
+const baseOptions = ref([]);
+const filteredOptions = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  filteredOptions.value = baseOptions.value.filter((v) =>
+    normalizeString(v.label).includes(needle),
+  );
+  update();
+};
+
+onMounted(async () => {
+  try {
+    const items = await getSectors();
+    baseOptions.value = items.map((s) => ({ label: s.name, value: s.id }));
+    filteredOptions.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 8 - 4
src/composables/useAuth.js

@@ -13,8 +13,9 @@ export const useAuth = () => {
   const login = async (email, password) => {
     try {
       const response = await api.post("/login", {
-        email: email,
-        password: password,
+        email,
+        password,
+        tipo: "associado",
       });
 
       if (response.status === 200) {
@@ -31,9 +32,11 @@ export const useAuth = () => {
       const response = await api.post("/logout");
       if (response.status === 200) {
         userStore().resetUser();
+        permissionStore().resetScopes();
       }
-    } catch (error) {
-      console.error(error);
+    } catch {
+      userStore().resetUser();
+      permissionStore().resetScopes();
     }
   };
 
@@ -53,5 +56,6 @@ export const useAuth = () => {
     login,
     logout,
     refresh,
+    setAuthDataFromPayload,
   };
 };

+ 32 - 0
src/composables/usePushNotifications.js

@@ -0,0 +1,32 @@
+import { Capacitor } from "@capacitor/core";
+import { PushNotifications } from "@capacitor/push-notifications";
+
+export const usePushNotifications = () => {
+  const initPush = async () => {
+    if (!Capacitor.isNativePlatform()) return;
+
+    const perm = await PushNotifications.requestPermissions();
+    if (perm.receive !== "granted") return;
+
+    await PushNotifications.register();
+
+    PushNotifications.addListener("registration", (token) => {
+      console.log("Push token:", token.value);
+      // TODO: enviar token para a API Laravel via endpoint POST /device/token
+    });
+
+    PushNotifications.addListener("registrationError", (error) => {
+      console.error("Push registration error:", error);
+    });
+
+    PushNotifications.addListener("pushNotificationReceived", (notification) => {
+      console.log("Push received:", notification);
+    });
+
+    PushNotifications.addListener("pushNotificationActionPerformed", (action) => {
+      console.log("Push action:", action);
+    });
+  };
+
+  return { initPush };
+};

+ 59 - 56
src/css/app.scss

@@ -48,38 +48,28 @@ body.body--light {
   }
 
   .q-menu {
-    background: #{map.get($colors, "surface")};
+    background: $neutral-light !important;
+    color: $color-text !important;
+
+    .q-item {
+      color: $color-text;
+    }
+
+    .q-item--active,
+    .q-item:hover {
+      background: $violet-light !important;
+      color: $violet-normal !important;
+    }
   }
 
   .q-dialog {
     .q-card {
-      background: #{map.get($colors, "surface-dark")};
+      background: #{map.get($colors, "surface")};
     }
   }
 
   background: #{map.get($colors, "page")} !important;
 
-  .q-list--dark,
-  .q-item--dark:not(.q-item--active) {
-    color: black;
-  }
-
-  .q-field--dark {
-    .q-field__control:not(.text-negative) {
-      .q-field__marginal {
-        color: rgba(0, 0, 0, 0.54);
-      }
-      & ~ .q-field__bottom {
-        color: rgba(0, 0, 0, 0.54);
-      }
-    }
-    &:not(.q-field--highlighted)
-      .q-field__control:not(.text-negative)
-      .q-field__label {
-      color: rgba(0, 0, 0, 0.54);
-    }
-  }
-
   .card-ring {
     box-shadow: 0 0 0 1px #c0c0c0c0 !important;
   }
@@ -89,22 +79,6 @@ body.body--light {
   }
 }
 
-body.body--dark {
-  background: #{map.get($colors-dark, "page")};
-
-  .q-drawer:has(.detached-container) {
-    background: #{map.get($colors-dark, "surface")} !important;
-  }
-
-  .q-menu {
-    background: #{map.get($colors-dark, "surface")};
-  }
-
-  .card-ring {
-    box-shadow: 0 0 0 1px #505050 !important;
-  }
-}
-
 .q-card__actions .q-btn {
   padding: 10px 16px;
 }
@@ -139,23 +113,6 @@ input[type="number"]::-webkit-outer-spin-button {
   background: #fff !important;
 }
 
-.q-field--dark .q-field__native {
-  color: black;
-}
-
-.q-field--dark .q-field__native,
-.q-field--dark .q-field__prefix,
-.q-field--dark .q-field__suffix,
-.q-field--dark .q-field__input {
-  color: black;
-}
-
-.q-field--dark:not(.q-field--highlighted) .q-field__label,
-.q-field--dark .q-field__marginal,
-.q-field--dark {
-  color: black;
-}
-
 .q-field--standout.q-field--rounded .q-field__control {
   border-radius: 8px;
   box-shadow: 0 0 0 1px #c0c0c0c0;
@@ -174,3 +131,49 @@ input[type="number"]::-webkit-outer-spin-button {
     }
   }
 }
+
+.input-violet {
+  .q-field__control {
+    background: $violet-light !important;
+    border-radius: 8px;
+  }
+
+  .q-field__native,
+  .q-field__input {
+    color: $violet-dark !important;
+  }
+
+  .q-field__label {
+    color: $violet-normal !important;
+  }
+
+  .q-field--outlined .q-field__control:before {
+    border-color: transparent !important;
+  }
+
+  .q-field--outlined .q-field__control:after {
+    border-color: $violet-normal !important;
+  }
+}
+
+// Mobile safe area support
+.q-layout {
+  padding-top: env(safe-area-inset-top);
+}
+
+// Prevent horizontal overflow in the Quasar scroll area internal container
+.q-scrollarea__container {
+  overflow-x: hidden !important;
+}
+
+// q-scrollarea__content is position:absolute — min-height: 100% ensures it fills
+// the full visible scroll area so page backgrounds extend to the bottom of the screen
+.q-scrollarea__content {
+  min-height: 100% !important;
+}
+
+// All pages fill exactly the viewport width — never wider
+.q-page > * {
+  max-width: 100vw;
+  box-sizing: border-box;
+}

+ 157 - 112
src/css/quasar.variables.scss

@@ -1,55 +1,94 @@
 // Quasar SCSS Variables with Material Design Color System
 // --------------------------------------------------
 
-// Primary Theme Colors
-$primary: #1976d2; // Material Blue 700
-$secondary: #9c27b0; // Material Purple 500
-$accent: #e91e63; // Material Pink 500
-
-// Dark Theme Base Colors
+// ─── Standalone variables (usable directly in SCSS via $var-name) ────────────
+
+// Brand
+$primary:             #661d75;
+$primary-4:           #cde8c2;
+
+// Violet (SerPrati brand)
+$violet-light:        #f0e8f1;
+$violet-light-hover:  #e8ddea;
+$violet-light-active: #d0b9d4;
+$violet-normal:       #661d75;
+$violet-normal-hover: #5c1a69;
+$violet-normal-active:#52175e;
+$violet-dark:         #4d1658;
+$violet-dark-hover:   #3d1146;
+$violet-dark-active:  #2e0d35;
+$violet-darker:       #240a29;
+
+// Neutral
+$neutral-light:        #fefefe;
+$neutral-light-hover:  #fdfdfd;
+$neutral-light-active: #fbfbfb;
+$neutral-normal:       #f2f2f2;
+$neutral-normal-hover: #dadada;
+$neutral-normal-active:#c2c2c2;
+$neutral-dark:         #b6b6b6;
+$neutral-dark-hover:   #919191;
+$neutral-dark-active:  #6d6d6d;
+$neutral-darker:       #555555;
+
+// Surface
+$surface: #FEFEFE;
+
+// Text
+$color-text:  #161616;
+$color-text-2:#505050;
+$white-2:     #fefcff;
+$color-border:#c0c0c0;
+
+// Dark base
 $dark: #1d1d1d;
 
 // Status Colors
 $positive: #2e7d32; // Material Green 800
 $negative: #d32f2f; // Material Red 700
 $info: #0288d1; // Material Light Blue 700
-$warning: #ed6c02; // Material Orange 800
+$inactive: #FFE100; // Material Orange 800
 
 // Extended Color System with Light/Dark Variants
 $colors: (
-  // Primary Colors and Variants
-  "primary": #1976d2,
-  // Base - Blue 700
-  "primary-light": #42a5f5,
-  // Light - Blue 400
-  "primary-dark": #1565c0,
-
-  // Dark - Blue 800
-  // Secondary Colors and Variants
-  "secondary": #9c27b0,
-  // Base - Purple 500
-  "secondary-light": #ba68c8,
-  // Light - Purple 300
-  "secondary-dark": #7b1fa2,
-
-  // Terceary Colors and Variants
-  "terciary": #ff9800,
-  // Base - Orange 500
-  "terciary-light": #ffb74d,
-  // Light - Orange 300
-  "terciary-dark": #f57c00,
-
-  // Dark - Purple 700
-  // Background Colors
-  "page": #f1f1f1,
-
-  // Surface Colors
-  "surface": #ffffff,
-  "surface-light": #f5f5f5,
-  "surface-dark": #f1f1f1,
+  // Brand Colors
+  // $primary: #35a30a;
+  "primary": #661d75,
+  "primary-4": #cde8c2,
+
+  "text": #161616,
+  "text-2": #505050,
+  "white-2": #fefcff,
+
+  "border": #c0c0c0,
+  // "error": #d4183d,
+
+  // Violet Colors
+  "violet-light": #f0e8f1,
+  "violet-light-hover": #e8ddea,
+  "violet-light-active": #d0b9d4,
+  "violet-normal": #661d75,
+  "violet-normal-hover": #5c1a69,
+  "violet-normal-active": #52175e,
+  "violet-dark": #4d1658,
+  "violet-dark-hover": #3d1146,
+  "violet-dark-active": #2e0d35,
+  "violet-darker": #240a29,
+
+  // Neutral Colors
+  "neutral-light": #fefefe,
+  "neutral-light-hover": #fdfdfd,
+  "neutral-light-active": #fbfbfb,
+  "neutral-normal": #f2f2f2,
+  "neutral-normal-hover": #dadada,
+  "neutral-normal-active": #c2c2c2,
+  "neutral-dark": #b6b6b6,
+  "neutral-dark-hover": #919191,
+  "neutral-dark-active": #6d6d6d,
+  "neutral-darker": #555555,
 
   //text color
-  "text": #000000,
+  // "text": #000000,
 
   // Status Colors with Variants
   "success": #2e7d32,
@@ -66,7 +105,7 @@ $colors: (
   "error-dark": #c62828,
 
   // Red 800
-  "warning": #ed6c02,
+  "warning": #FFE100,
   // Orange 800
   "warning-light": #ff9800,
   // Orange 500
@@ -77,73 +116,79 @@ $colors: (
   // Light Blue 700
   "info-light": #03a9f4,
   // Light Blue 500
-  "info-dark": #01579b
-);
+  "info-dark": #01579b,
 
-// Dark Theme Color Overrides
-$colors-dark: (
-  // Primary Colors and Variants
-  "primary": #1976d2,
-  // Base - Blue 700
-  "primary-light": #42a5f5,
-  // Blue 50
-  "primary-dark": #1565c0,
-
-  "dark": #1d1d1d,
-
-  // Blue 400
-  // Secondary Colors - Lighter in Dark Mode
-  "secondary": #ce93d8,
-  // Purple 200
-  "secondary-light": #f3e5f5,
-  // Purple 50
-  "secondary-dark": #ab47bc,
-
-  //Terceary Colors - Lighter in Dark Mode
-  "terciary": #ffd191,
-  // Yellow 200
-  "terciary-light": #ffecb3,
-  // Yellow 50
-  "terciary-dark": #ffab40,
-
-  "page": #121212,
-
-  "surface": #1d1d1d,
-  "surface-light": #333333,
-  "surface-dark": #121212,
-
-  "text": #ffffff,
-
-  // Black Background
-  // Status Colors - Adjusted for Dark Mode
-  "success": #66bb6a,
-  // Green 400
-  "success-light": #81c784,
-  // Green 300
-  "success-dark": #388e3c,
-
-  // Green 700
-  "error": #f44336,
-  // Red 500
-  "error-light": #e57373,
-  // Red 300
-  "error-dark": #d32f2f,
+  // Surface
+  "surface": #FEFEFE,
 
-  // Red 700
-  "warning": #ffa726,
-  // Orange 400
-  "warning-light": #ffb74d,
-  // Orange 300
-  "warning-dark": #f57c00,
-
-  // Orange 700
-  "info": #29b6f6,
-  // Light Blue 400
-  "info-light": #4fc3f7,
-  // Light Blue 300
-  "info-dark": #0288d1 // Light Blue 700
+  // Inactive
+  "inactive": #ed6c02
 );
 
+// Dark Theme Color Overrides
+// $colors-dark: (
+//   // Primary Colors and Variants
+//   "primary": #1976d2,
+//   // Base - Blue 700
+//   "primary-light": #42a5f5,
+//   // Blue 50
+//   "primary-dark": #1565c0,
+
+//   "dark": #1d1d1d,
+
+//   // Blue 400
+//   // Secondary Colors - Lighter in Dark Mode
+//   "secondary": #9c27b0,
+//   // Purple 500
+//   "secondary-light": #ce93d8,
+//   // Purple 200
+//   "secondary-dark": #661D75,
+
+//   //Terceary Colors - Lighter in Dark Mode
+//   "terciary": #ffd191,
+//   // Yellow 200
+//   "terciary-light": #ffecb3,
+//   // Yellow 50
+//   "terciary-dark": #ffab40,
+
+//   "page": #121212,
+
+//   "surface": #1d1d1d,
+//   "surface-light": #333333,
+//   "surface-dark": #121212,
+
+//   "text": #ffffff,
+
+//   // Black Background
+//   // Status Colors - Adjusted for Dark Mode
+//   "success": #66bb6a,
+//   // Green 400
+//   "success-light": #81c784,
+//   // Green 300
+//   "success-dark": #388e3c,
+
+//   // Green 700
+//   "error": #f44336,
+//   // Red 500
+//   "error-light": #e57373,
+//   // Red 300
+//   "error-dark": #d32f2f,
+
+//   // Red 700
+//   "warning": #ffa726,
+//   // Orange 400
+//   "warning-light": #ffb74d,
+//   // Orange 300
+//   "warning-dark": #f57c00,
+
+//   // Orange 700
+//   "info": #29b6f6,
+//   // Light Blue 400
+//   "info-light": #4fc3f7,
+//   // Light Blue 300
+//   "info-dark": #0288d1 // Light Blue 700
+// );
+
 // Generate color utility classes for light theme
 @each $name, $color in $colors {
   .text-#{$name} {
@@ -155,13 +200,13 @@ $colors-dark: (
 }
 
 // Generate color utility classes for dark theme
-.body--dark {
-  @each $name, $color in $colors-dark {
-    .text-#{$name} {
-      color: $color !important;
-    }
-    .bg-#{$name} {
-      background: $color !important;
-    }
-  }
-}
+// .body--dark {
+//   @each $name, $color in $colors-dark {
+//     .text-#{$name} {
+//       color: $color !important;
+//     }
+//     .bg-#{$name} {
+//       background: $color !important;
+//     }
+//   }
+// }

+ 12 - 84
src/css/table.scss

@@ -1,19 +1,10 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
 .softpar-table {
-  .body--dark & {
-    --table-bg-color: #{map.get($colors-dark, "surface")}; // Using our dark background
-    --table-border-color: #{map.get($colors-dark, "surface-light")}; // Darker border
-    --table-header-color: #{map.get($colors-dark, "text")}; // Light text for dark mode
-    --table-ring-color: #505050;
-  }
-
-  .body--light & {
-    --table-bg-color: #{map.get($colors, "surface")}; // Light background
-    --table-border-color: #{map.get($colors, "surface-light")}; // Border color
-    --table-header-color: #{map.get($colors, "text")}; // Dark text for light mode
-    --table-ring-color: #c0c0c0c0;
-  }
+  --table-bg-color: #{map.get($colors, "surface")};
+  --table-border-color: #{map.get($colors, "surface-light")};
+  --table-header-color: #{map.get($colors, "text")};
+  --table-ring-color: #c0c0c0c0;
 
   padding-left: 16px !important;
   padding-right: 16px !important;
@@ -71,16 +62,7 @@
   width: fit-content;
   margin-right: auto;
   margin-left: auto;
-
-  .body--dark & {
-    background: none;
-    border: 1px solid #{map.get($colors, "positive-1")};
-  }
-
-  .body--light & {
-    background: #{map.get($colors, "positive-1")};
-    border: none;
-  }
+  background: #{map.get($colors, "positive-1")};
 }
 
 .inativo {
@@ -91,16 +73,7 @@
   width: fit-content;
   margin-right: auto;
   margin-left: auto;
-
-  .body--dark & {
-    background: none;
-    border: 1px solid #{map.get($colors, "negative-1")};
-  }
-
-  .body--light & {
-    background: #{map.get($colors, "negative-1")};
-    border: none;
-  }
+  background: #{map.get($colors, "negative-1")};
 }
 
 .rejeitado {
@@ -111,16 +84,7 @@
   width: fit-content;
   margin-right: auto;
   margin-left: auto;
-
-  .body--dark & {
-    background: none;
-    border: 1px solid #{map.get($colors, "negative-1")};
-  }
-
-  .body--light & {
-    background: #{map.get($colors, "negative-1")};
-    border: none;
-  }
+  background: #{map.get($colors, "negative-1")};
 }
 
 .gerado {
@@ -131,16 +95,7 @@
   width: fit-content;
   margin-right: auto;
   margin-left: auto;
-
-  .body--dark & {
-    background: none;
-    border: 1px solid #{map.get($colors, "primary-1")};
-  }
-
-  .body--light & {
-    background: #{map.get($colors, "primary-1")};
-    border: none;
-  }
+  background: #{map.get($colors, "primary-1")};
 }
 
 .pendente {
@@ -151,46 +106,19 @@
   width: fit-content;
   margin-right: auto;
   margin-left: auto;
-
-  .body--dark & {
-    background: none;
-    border: 1px solid #{map.get($colors, "warning")};
-  }
-
-  .body--light & {
-    background: #{map.get($colors, "warning")};
-    border: none;
-  }
+  background: #{map.get($colors, "warning")};
 }
 
 .table-bottom {
   .q-table__top {
-    .body--light & {
-      background: #{map.get($colors, "page")};
-    }
-
-    .body--dark & {
-      background: #{map.get($colors, "background-3")};
-    }
+    background: #{map.get($colors, "page")};
   }
 
   .q-table__bottom {
-    .body--light & {
-      background: #{map.get($colors, "page")};
-    }
-
-    .body--dark & {
-      background: #{map.get($colors, "background-3")};
-    }
+    background: #{map.get($colors, "page")};
   }
 }
 
 .q-table__grid-item-card {
-  .body--light & {
-    background: #{map.get($colors, "dark")};
-  }
-
-  .body--dark & {
-    background: #{map.get($colors, "background-3")};
-  }
+  background: #{map.get($colors, "dark")};
 }

+ 20 - 1
src/helpers/utils.js

@@ -68,6 +68,7 @@ const convertDateTime = (dateTimeString) => {
  * // Output: 23/05/2023 10:07:27
  */
 const formatDateYMDtoDMY = (dateTime) => {
+  if (!dateTime) return "—";
   const [datePart, timePart] = dateTime.split(" ");
   const [year, month, day] = datePart.split("-");
   const formattedDate = `${day}/${month}/${year}`;
@@ -100,7 +101,23 @@ const normalizeString = (val) =>
   val
     .toLowerCase()
     .normalize("NFKD")
-    .replace(/[\u0300-\u036f~]/g, "");
+    .replace(/[̀-ͯ~]/g, "");
+
+const getStatusColor = (status) => {
+  const s = typeof status === "object" ? status?.value : status;
+  if (s === "active")  return "positive";
+  if (s === "pending") return "warning";
+  if (s === "inactive") return "inactive";
+  return "grey-6";
+};
+
+const getStatusI18nKey = (status) => {
+  const s = typeof status === "object" ? status?.value : status;
+  if (s === "active")   return "common.status.active";
+  if (s === "inactive") return "common.status.inactive";
+  if (s === "pending")  return "common.status.pending";
+  return "common.status.inactive";
+};
 
 export {
   formatDateDMYtoYMD,
@@ -109,4 +126,6 @@ export {
   convertDateTime,
   formatToBRLCurrency,
   normalizeString,
+  getStatusColor,
+  getStatusI18nKey,
 };

+ 342 - 8
src/i18n/locales/en.json

@@ -1,5 +1,34 @@
 {
   "page": {
+    "loja": {
+      "description": "Manage the products available in the store"
+    },
+    "associado": {
+      "profile": {
+        "description": "View and edit your personal data and dependents"
+      },
+      "carteirinha": {
+        "description": "Your digital membership card with QR Code"
+      },
+      "convenios": {
+        "description": "Browse available partners and agreements"
+      },
+      "loja": {
+        "description": "Browse available store items"
+      },
+      "interesses": {
+        "description": "Items you marked as interest"
+      },
+      "agendamentos": {
+        "description": "Request and track your appointments"
+      },
+      "notificacoes": {
+        "description": "Your notifications and alerts"
+      }
+    },
+    "gestao_associados": {
+      "description": "View, edit, and add members to the system"
+    },
     "city": {
       "description": "View, edit, and add cities to the system"
     },
@@ -17,6 +46,18 @@
     },
     "versions": {
       "description": "Informations about updates, fixes and changes for each system version"
+    },
+    "parceiros_convenios": {
+      "description": "View, edit and add partners and agreements to the system"
+    },
+    "notificacoes": {
+      "description": "History"
+    },
+    "relatorios": {
+      "description": "View management reports"
+    },
+    "agendamentos": {
+      "description": "Manage associate appointments"
     }
   },
   "common": {
@@ -26,6 +67,7 @@
       "cancel": "Cancel",
       "edit": "Edit",
       "add": "Add",
+      "new": "New",
       "search": "Search",
       "delete": "Delete",
       "view": "View",
@@ -34,7 +76,8 @@
       "resend_email": "Resend email",
       "download_certificate": "Download certificate",
       "download_boleto": "Download Boleto",
-      "copy_paste_code": "Copy and paste the code below to make the payment"
+      "copy_paste_code": "Copy and paste the code below to make the payment",
+      "go_home": "Go Home"
     },
     "terms": {
       "actions": "Actions",
@@ -88,12 +131,15 @@
       "week": "Week",
       "day": "Day",
       "hour": "Hour",
+      "hour2": "Time",
       "minute": "Minute",
       "second": "Second",
       "year": "Year",
       "all": "All",
       "certificate": "Certificate",
-      "version": "Version"
+      "version": "Version",
+      "whatsapp": "WhatsApp",
+      "today": "Today"
     },
     "months": {
       "january": "January",
@@ -112,6 +158,7 @@
     "status": {
       "active": "Active",
       "inactive": "Inactive",
+      "pending": "Pending",
       "canceled": "Canceled",
       "loading": "Please wait...",
       "yes": "Yes",
@@ -138,7 +185,9 @@
         "confirm_action": "Are you sure?",
         "are_you_sure_delete": "Are you sure you want to delete this item?",
         "welcome": "Welcome",
-        "enjoy_the_event": "Enjoy the event!"
+        "enjoy_the_event": "Enjoy the event!",
+        "saved": "Saved successfully",
+        "error": "An error occurred. Please try again."
       },
       "misc": {
         "all": "All",
@@ -153,6 +202,19 @@
       "created_at": "Created at",
       "updated_at": "Updated at",
       "created_by": "Created by"
+    },
+    "placeholders": {
+      "city_full_name": "Full city name",
+      "country_full_name": "Full country name",
+      "state_full_name": "Full state name",
+      "select_country": "Select country",
+      "select_state": "Select state",
+      "country_code": "e.g. US, UK...",
+      "state_code": "e.g. NY, CA...",
+      "email": "email{'@'}example.com",
+      "cpf": "000.000.000-00",
+      "registration": "e.g. 1111111",
+      "password": "Enter a secure password"
     }
   },
   "auth": {
@@ -162,7 +224,33 @@
     "registration": "Registration",
     "confirm_password": "Confirm Password",
     "agreed_terms": "I agree with the terms",
-    "agreed_privacy": "I agree with the privacy policy"
+    "agreed_privacy": "I agree with the privacy policy",
+    "select_type_hint": "Select your profile and sign in",
+    "type": {
+      "administrador": "Administrator",
+      "associado": "Associate",
+      "parceiro": "Partner"
+    },
+    "forgot_password": "Forgot password?",
+    "forgot_password_title": "Enter your email to reset your password",
+    "forgot_password_description": "We will send a verification code to your email.",
+    "continue": "Continue",
+    "verify_email_title": "Check your email",
+    "verify_email_description": "Please check your inbox and click the link to proceed.",
+    "no_email_hint": "Did not receive the email? Check your",
+    "check_spam": "spam or junk folder.",
+    "enter_code": "Enter Code",
+    "enter_code_title": "Enter your code here",
+    "verify": "Verify",
+    "resend_email": "Resend email",
+    "remember_email": "Remember email",
+    "remember_password": "Remember your password?",
+    "do_login": "Sign in",
+    "new_password_title": "Create your new password",
+    "password_hint": "Minimum 6 characters with uppercase, lowercase and numbers",
+    "confirm": "Confirm",
+    "back_to_site": "Back to Site",
+    "wrong_type": "User found, but the selected type is incorrect. Please select the correct login type."
   },
   "business": {
     "advertise": "Advertise",
@@ -185,14 +273,16 @@
       "cpf": "This field must be a valid CPF",
       "cnpj": "This field must be a valid CNPJ",
       "cep": "This field must be a valid ZIP code",
-      "value_smaller_than_zero": "Value cannot be less than zero"
+      "value_smaller_than_zero": "Value cannot be less than zero",
+      "code_length": "The code must have 6 digits"
     },
     "permissions": {
       "view": "You don't have permission to view this",
       "create": "You don't have permission to create this",
       "edit": "You don't have permission to edit this",
       "delete": "You don't have permission to delete this",
-      "add": "You don't have permission to add this"
+      "add": "You don't have permission to add this",
+      "wrong_type": "You do not have permission to access this area."
     }
   },
   "http": {
@@ -243,6 +333,7 @@
   "user": {
     "singular": "User",
     "plural": "Users",
+    "type_hint": "The type defines the user's permissions",
     "profile": {
       "singular": "Profile",
       "name_and_surname": "Name and Surname",
@@ -312,9 +403,101 @@
       "city": "City",
       "state": "State",
       "country": "Country",
-      "exit": "Exit"
+      "exit": "Exit",
+      "partners": "Partners/Agreements",
+      "partner_agreements": "Agreements",
+      "partner_services": "Services",
+      "store": "Store",
+      "store_items": "Items",
+      "store_orders": "Orders",
+      "appointments": "Appointments",
+      "my_appointments": "My Appointments",
+      "received_appointments": "Received Appointments",
+      "notifications": "Notifications",
+      "categories": "Categories",
+      "my_profile": "My Profile",
+      "my_services": "My Services",
+      "associados": "Member Management",
+      "relatorios": "Reports",
+      "validar_carteirinha": "Validate Card",
+      "meus_dados": "My Data",
+      "meu_perfil": "My Profile",
+      "carteirinha": "Digital Membership Card",
+      "convenios": "Agreements",
+      "dependentes": "Dependents",
+      "loja": "Store",
+      "meus_interesses": "My Interests",
+      "meus_agendamentos": "Appointments"
     }
   },
+  "associado": {
+    "associado": "Associate",
+    "personal_data": "Personal Data",
+    "registration": "Registration",
+    "cracha": "Badge",
+    "position": "Position",
+    "sector": "Sector",
+    "admission_date": "Admission Date",
+    "admission": "Admission",
+    "dependent": "Dependent",
+    "dependents": "Dependents",
+    "import_afastamentos": "Absences",
+    "search_placeholder": "Search Members",
+    "no_dependents": "No dependents registered",
+    "kinship": "Kinship",
+    "dependent_statuses": {
+      "approved": "Approved",
+      "refused": "Refused",
+      "pending": "Pending"
+    },
+    "kinship_options": {
+      "conjuge": "Spouse",
+      "filho": "Son",
+      "filha": "Daughter",
+      "pai": "Father",
+      "mae": "Mother",
+      "irmao": "Brother",
+      "irma": "Sister",
+      "outro": "Other"
+    },
+    "horizontal": "Horizontal",
+    "vertical": "Vertical",
+    "category": "Category",
+    "service": "Service",
+    "services": "Services",
+    "filter_by_category": "Filter by category",
+    "view_services": "View Services",
+    "search_by_segment": "Search by segment",
+    "discount": "Discount",
+    "validity_until": "Valid until",
+    "price": "Price",
+    "available_sizes": "Available sizes",
+    "i_want": "I want it",
+    "remove_interest": "Remove interest",
+    "no_interests": "You have no registered interests yet",
+    "new_appointment": "New Request",
+    "my_appointments": "My Appointments",
+    "notes": "Notes",
+    "schedule": "Schedule",
+    "confirm_cancel_appointment": "Are you sure you want to cancel this appointment?",
+    "appointment_status": {
+      "pendente": "Pending",
+      "confirmado": "Confirmed",
+      "cancelado": "Cancelled",
+      "concluido": "Completed"
+    },
+    "no_notifications": "No notifications at the moment",
+    "mark_as_read": "Mark as read",
+    "validity": "Validity",
+    "language_options": {
+      "pt": "Português",
+      "en": "English",
+      "es": "Español"
+    },
+    "cpf": "CPF",
+    "phone": "Phone",
+    "email": "E-mail"
+  },
   "charts": {
     "nps": {
       "promotion_zone": "Promotion Zone",
@@ -332,6 +515,16 @@
   },
   "dashboard": {
     "currency_format": "$ {value}",
+    "stats": {
+      "total_associados": "Total Members",
+      "associados_ativos": "Active Members",
+      "parceiros": "Partners",
+      "contratos_a_vencer": "Expiring Contracts",
+      "novos_mes": "New (month)",
+      "associados_pendentes": "Pending Members",
+      "association_date": "Association Date",
+      "registration_date": "Registration Date"
+    },
     "cards": {
       "total_earnings": "Total Earnings",
       "tickets_sold": "Tickets Sold",
@@ -365,5 +558,146 @@
         }
       }
     }
+  },
+  "loja": {
+    "product": "Product",
+    "products": "Products",
+    "supplier_price": "Supplier Price",
+    "supplier": "Supplier",
+    "stock": "Stock",
+    "interests": "Interested",
+    "interested_btn": "INTERESTED",
+    "interested_in": "Interested in",
+    "media": "Media",
+    "add_media": "Add Media",
+    "uploading_media": "Uploading media...",
+    "activate": "Activate",
+    "deactivate": "Deactivate",
+    "variation": "Variation",
+    "variation_tamanho": "Size",
+    "variation_cor": "Color",
+    "variation_modelo": "Model",
+    "variation_label": "Name",
+    "variation_value": "Value (R$)",
+    "variation_stock": "Stock",
+    "variation_cor_placeholder": "E.g.: Purple, White...",
+    "variation_modelo_placeholder": "E.g.: Associate, Premium...",
+    "variation_empty": "No variation added",
+    "add_variation": "Add",
+    "filter_all": "All",
+    "filter_recent": "Recent",
+    "filter_others": "Others",
+    "btn_want": "I WANT IT",
+    "btn_interested": "INTERESTED"
+  },
+  "parceiro": {
+    "category": "Category",
+    "company_name": "Company Name",
+    "responsible": "Responsible",
+    "discount_percentage": "Discount (%)",
+    "logo": "Logo",
+    "website": "Website",
+    "zip_code": "ZIP Code",
+    "address": "Address",
+    "neighborhood": "Neighborhood",
+    "working_hours": "Working Hours",
+    "working_hours_placeholder": "e.g. Mon-Fri 8am-6pm, Sat 8am-12pm",
+    "contract_start": "Contract Start",
+    "contract_end": "Contract End",
+    "contract_files": "Contract Files",
+    "add_file": "Add File",
+    "services": "Services",
+    "service": "Service",
+    "service_category": "Service Category",
+    "service_number": "Service Number",
+    "price": "Price",
+    "associate_price": "Associate Price",
+    "supplier_price": "Supplier Price",
+    "requires_scheduling": "Requires Scheduling",
+    "search_placeholder": "Search for service",
+    "cadastro_title": "Partner Registration",
+    "cadastro_parceiro": "Partner Registration",
+    "dados_parceiro": "Partner Data",
+    "tab_dados": "Company Data",
+    "tab_contato": "Contact",
+    "tab_endereco": "Address",
+    "tab_horario": "Schedule & Contract",
+    "tab_servicos": "My Services",
+    "usuario_parceiro": "Partner User",
+    "home": "Partner Portal",
+    "parceiro": {
+      "dashboard": {
+        "title": "Partner Portal",
+        "authorization": "Consultations for Authorization",
+        "scheduling": "Consultations for Scheduling",
+        "completed": "Completed Consultations",
+        "not_authorized": "Unauthorized Consultations"
+      }
+    }
+  },
+  "notification": {
+    "new": "New notification",
+    "history": "History",
+    "title_label": "Title",
+    "title_placeholder": "Notification title",
+    "message_label": "Message",
+    "message_placeholder": "Write the message",
+    "recipients": "Recipients",
+    "all": "All",
+    "by_position": "Recipients By Position",
+    "by_sector": "Recipients By Sector",
+    "media": "Media",
+    "send": "Send Notification",
+    "sent_to": "Sent to {count} contacts",
+    "seen_by": "Seen by {count} contacts",
+    "details": "Details",
+    "empty": "No notifications found",
+    "recipient_associado": "Associate",
+    "recipient_parceiro": "Partner/Agreements"
+  },
+  "relatorio": {
+    "novos_associados": "New Members",
+    "contatos": "Contacts",
+    "exclusoes_mes": "Monthly Exclusions",
+    "exportar_csv": "Export Excel",
+    "col": {
+      "cadastro": "Registration Date",
+      "exclusao": "Exclusion Date"
+    },
+    "exportar_excel": "Export Excel"
+  },
+  "agendamento": {
+    "nova_solicitacao": "New Request",
+    "visao_geral": "Overview",
+    "aprovados_automaticamente": "Auto Approved",
+    "associado": "Associate",
+    "solicitar": "Request",
+    "confirm_approve": "Are you sure you want to approve this appointment?",
+    "confirm_reject": "Are you sure you want to reject this appointment?",
+    "status": {
+      "pendente": "Waiting",
+      "confirmado": "Approved",
+      "recusado": "Rejected",
+      "cancelado": "Cancelled",
+      "concluido": "Completed"
+    },
+    "col": {
+      "pedido": "Order",
+      "parceiro": "Partner",
+      "servico": "Service",
+      "solicitacao": "Request Date"
+    }
+  },
+  "associate_validation": {
+    "title": "Validate Membership Card",
+    "description": "Enter associate information to verify status.",
+    "search_placeholder": "EX: Name, CPF, Registration",
+    "search": "Search",
+    "associate_found": "Associate Found",
+    "name": "Name",
+    "cpf": "CPF",
+    "registration": "Registration",
+    "qr_code": "Read QR CODE",
+    "not_found": "Associate not found"
   }
-}
+}

+ 351 - 17
src/i18n/locales/es.json

@@ -1,5 +1,34 @@
 {
   "page": {
+    "loja": {
+      "description": "Gestione los productos disponibles en la tienda"
+    },
+    "associado": {
+      "profile": {
+        "description": "Visualice y edite sus datos personales y dependientes"
+      },
+      "carteirinha": {
+        "description": "Su tarjeta digital con código QR de identificación"
+      },
+      "convenios": {
+        "description": "Consulte los socios y convenios disponibles"
+      },
+      "loja": {
+        "description": "Consulte los artículos disponibles en la tienda"
+      },
+      "interesses": {
+        "description": "Artículos que marcó como interés"
+      },
+      "agendamentos": {
+        "description": "Solicite y realice seguimiento de sus citas"
+      },
+      "notificacoes": {
+        "description": "Sus notificaciones y avisos"
+      }
+    },
+    "gestao_associados": {
+      "description": "Consulte, edite y agregue los asociados del sistema"
+    },
     "city": {
       "description": "Consulte, edite y agregue las ciudades del sistema"
     },
@@ -13,19 +42,32 @@
       "description": "Consulte, edite y agregue usuarios con acceso al sistema"
     },
     "system-dashboard": {
-      "description": "Visão general del sistema"
+      "description": "Visión general del sistema"
     },
     "versions": {
       "description": "Información sobre actualizaciones, correcciones y cambios en cada versión del sistema"
+    },
+    "parceiros_convenios": {
+      "description": "Consulte, edite y agregue los socios y convenios del sistema"
+    },
+    "notificacoes": {
+      "description": "Historial"
+    },
+    "relatorios": {
+      "description": "Visualice reportes gerenciales del sistema"
+    },
+    "agendamentos": {
+      "description": "Gestione las citas de los asociados"
     }
   },
   "common": {
     "actions": {
-      "import": "Import",
+      "import": "Importar",
       "save": "Guardar",
       "cancel": "Cancelar",
       "edit": "Editar",
       "add": "Añadir",
+      "new": "Nuevo",
       "search": "Buscar",
       "delete": "Eliminar",
       "view": "Ver",
@@ -34,14 +76,15 @@
       "resend_email": "Reenviar correo electrónico",
       "download_certificate": "Descargar certificado",
       "download_boleto": "Descargar Boleto",
-      "copy_paste_code": "Copie y pegue el código a continuación para realizar el pago"
+      "copy_paste_code": "Copie y pegue el código a continuación para realizar el pago",
+      "go_home": "Ir al Inicio"
     },
     "terms": {
-      "actions": "Ações",
+      "actions": "Acciones",
       "name": "Nombre",
       "email": "Correo electrónico",
-      "contact_info": "Contato",
-      "branch": "Branch",
+      "contact_info": "Información de Contacto",
+      "branch": "Rama de actuación",
       "password": "Contraseña",
       "description": "Descripción",
       "date": "Fecha",
@@ -68,8 +111,8 @@
       "cep": "Código Postal",
       "order_number": "Número de pedido",
       "order_amount": "Monto del pedido",
-      "owner_name": "Nome do dono",
-      "client_name": "Nome do cliente",
+      "owner_name": "Nombre del dueño",
+      "client_name": "Nombre del cliente",
       "total_amount": "Monto total",
       "payment": "Pago",
       "payment_method": "Método de pago",
@@ -82,18 +125,21 @@
       "banner": "Banner",
       "logo": "Logo",
       "light": "Claro",
-      "dark": "Escuro",
+      "dark": "Oscuro",
       "media": "Media",
       "month": "Mes",
       "week": "Semana",
       "day": "Día",
       "hour": "Hora",
+      "hour2": "Horário",
       "minute": "Minuto",
       "second": "Segundo",
       "year": "Año",
       "all": "Todos",
       "certificate": "Certificado",
-      "version": "Versión"
+      "version": "Versión",
+      "whatsapp": "WhatsApp",
+      "today": "Hoy"
     },
     "months": {
       "january": "Enero",
@@ -112,6 +158,7 @@
     "status": {
       "active": "Activo",
       "inactive": "Inactivo",
+      "pending": "Pendiente",
       "canceled": "Cancelado",
       "loading": "Por favor espere...",
       "yes": "Sí",
@@ -138,7 +185,9 @@
         "confirm_action": "¿Estás seguro?",
         "are_you_sure_delete": "¿Estás seguro de que quieres eliminar este elemento?",
         "welcome": "Bienvenido",
-        "enjoy_the_event": "¡Disfruta el evento!"
+        "enjoy_the_event": "¡Disfruta el evento!",
+        "saved": "Guardado con éxito",
+        "error": "Ocurrió un error. Intente nuevamente."
       },
       "misc": {
         "all": "Todos",
@@ -153,6 +202,19 @@
       "created_at": "Creado el",
       "updated_at": "Actualizado el",
       "created_by": "Creado por"
+    },
+    "placeholders": {
+      "city_full_name": "Nombre completo de la ciudad",
+      "country_full_name": "Nombre completo del país",
+      "state_full_name": "Nombre completo del estado",
+      "select_country": "Seleccione el país",
+      "select_state": "Seleccione el estado",
+      "country_code": "Ej. MX, ES...",
+      "state_code": "Ej. MC, BA...",
+      "email": "email{'@'}ejemplo.com",
+      "cpf": "000.000.000-00",
+      "registration": "Ej: 1111111",
+      "password": "Ingrese una contraseña segura"
     }
   },
   "auth": {
@@ -162,7 +224,33 @@
     "registration": "Registro",
     "confirm_password": "Confirmar contraseña",
     "agreed_terms": "Acepto los términos",
-    "agreed_privacy": "Acepto la política de privacidad"
+    "agreed_privacy": "Acepto la política de privacidad",
+    "select_type_hint": "Seleccione su perfil e inicie sesión",
+    "type": {
+      "administrador": "Administrador",
+      "associado": "Asociado",
+      "parceiro": "Socio"
+    },
+    "forgot_password": "¿Olvidé mi contraseña?",
+    "forgot_password_title": "Ingrese su correo para restablecer su contraseña",
+    "forgot_password_description": "Le enviaremos un código de verificación a su correo.",
+    "continue": "Continuar",
+    "verify_email_title": "Verifique su correo",
+    "verify_email_description": "Por favor, revise su bandeja de entrada y haga clic en el enlace para continuar.",
+    "no_email_hint": "¿No recibió el correo? Revise su carpeta de",
+    "check_spam": "spam o correo no deseado.",
+    "enter_code": "Ingresar Código",
+    "enter_code_title": "Ingrese su código aquí",
+    "verify": "Verificar",
+    "resend_email": "Reenviar correo",
+    "remember_email": "Recordar correo",
+    "remember_password": "¿Recuerda su contraseña?",
+    "do_login": "Iniciar sesión",
+    "new_password_title": "Cree su nueva contraseña",
+    "password_hint": "Mínimo 6 caracteres con mayúsculas, minúsculas y números",
+    "confirm": "Confirmar",
+    "back_to_site": "Volver al Sitio",
+    "wrong_type": "Usuario encontrado, pero el tipo seleccionado es incorrecto. Seleccione el tipo correcto."
   },
   "business": {
     "advertise": "Anunciar",
@@ -185,14 +273,16 @@
       "cpf": "Este campo debe ser un CPF válido",
       "cnpj": "Este campo debe ser un CNPJ válido",
       "cep": "Este campo debe ser un código postal válido",
-      "value_smaller_than_zero": "El valor no puede ser menor que cero"
+      "value_smaller_than_zero": "El valor no puede ser menor que cero",
+      "code_length": "El código debe tener 6 dígitos"
     },
     "permissions": {
       "view": "No tienes permiso para ver esto",
       "create": "No tienes permiso para crear esto",
       "edit": "No tienes permiso para editar esto",
       "delete": "No tienes permiso para eliminar esto",
-      "add": "No tienes permiso para añadir esto"
+      "add": "No tienes permiso para añadir esto",
+      "wrong_type": "No tiene permiso para acceder a esta área."
     }
   },
   "http": {
@@ -243,6 +333,7 @@
   "user": {
     "singular": "Usuario",
     "plural": "Usuarios",
+    "type_hint": "El tipo define los permisos del usuario",
     "profile": {
       "singular": "Perfil",
       "name_and_surname": "Nombre y Apellido",
@@ -287,7 +378,7 @@
     "navigation": {
       "collapse_menu": "Contraer menú",
       "dashboards": "Dashboards",
-      "home": "Início",
+      "home": "Inicio",
       "versions": "Versiónes",
       "expand_menu": "Expandir menú",
       "dashboard": "Panel",
@@ -312,9 +403,101 @@
       "city": "Ciudad",
       "state": "Estado/Provincia",
       "country": "País",
-      "exit": "Salir"
+      "exit": "Salir",
+      "partners": "Socios/Convenios",
+      "partner_agreements": "Convenios",
+      "partner_services": "Servicios",
+      "store": "Tienda",
+      "store_items": "Artículos",
+      "store_orders": "Pedidos",
+      "appointments": "Citas",
+      "my_appointments": "Mis Citas",
+      "received_appointments": "Citas Recibidas",
+      "notifications": "Notificaciones",
+      "categories": "Categorías",
+      "my_profile": "Mi Perfil",
+      "my_services": "Mis Servicios",
+      "associados": "Gestión de Asociados",
+      "relatorios": "Informes",
+      "validar_carteirinha": "Validar Carnet",
+      "meus_dados": "Mis Datos",
+      "meu_perfil": "Mi Perfil",
+      "carteirinha": "Carnet de Asociado Digital",
+      "convenios": "Convenios",
+      "dependentes": "Dependientes",
+      "loja": "Tienda",
+      "meus_interesses": "Mis Intereses",
+      "meus_agendamentos": "Citas"
     }
   },
+  "associado": {
+    "associado": "Asociado",
+    "personal_data": "Datos Personales",
+    "registration": "Matrícula",
+    "cracha": "Identificador",
+    "position": "Cargo",
+    "sector": "Sector",
+    "admission_date": "Fecha de Admisión",
+    "admission": "Admisión",
+    "dependent": "Dependiente",
+    "dependents": "Dependientes",
+    "import_afastamentos": "Ausencias",
+    "search_placeholder": "Buscar por asociados",
+    "no_dependents": "Ningún dependiente registrado",
+    "kinship": "Parentesco",
+    "dependent_statuses": {
+      "approved": "Aprobado",
+      "refused": "Rechazado",
+      "pending": "Pendiente"
+    },
+    "kinship_options": {
+      "conjuge": "Cónyuge",
+      "filho": "Hijo",
+      "filha": "Hija",
+      "pai": "Padre",
+      "mae": "Madre",
+      "irmao": "Hermano",
+      "irma": "Hermana",
+      "outro": "Otro"
+    },
+    "horizontal": "Horizontal",
+    "vertical": "Vertical",
+    "category": "Categoría",
+    "service": "Servicio",
+    "services": "Servicios",
+    "filter_by_category": "Filtrar por categoría",
+    "view_services": "Ver Servicios",
+    "search_by_segment": "Buscar por segmento",
+    "discount": "Descuento",
+    "validity_until": "Vigente hasta",
+    "price": "Precio",
+    "available_sizes": "Tallas disponibles",
+    "i_want": "Lo quiero",
+    "remove_interest": "Quitar interés",
+    "no_interests": "Aún no tienes intereses registrados",
+    "new_appointment": "Nueva Solicitud",
+    "my_appointments": "Mis Citas",
+    "notes": "Observaciones",
+    "schedule": "Agendar",
+    "confirm_cancel_appointment": "¿Está seguro de que desea cancelar esta cita?",
+    "appointment_status": {
+      "pendente": "Pendiente",
+      "confirmado": "Confirmado",
+      "cancelado": "Cancelado",
+      "concluido": "Concluido"
+    },
+    "no_notifications": "Sin notificaciones en este momento",
+    "mark_as_read": "Marcar como leída",
+    "validity": "Validez",
+    "language_options": {
+      "pt": "Português",
+      "en": "English",
+      "es": "Español"
+    },
+    "cpf": "CPF",
+    "phone": "Teléfono",
+    "email": "E-mail"
+  },
   "charts": {
     "nps": {
       "promotion_zone": "Zona de Promoción",
@@ -332,6 +515,16 @@
   },
   "dashboard": {
     "currency_format": "R$ {value}",
+    "stats": {
+      "total_associados": "Total Asociados",
+      "associados_ativos": "Asociados Activos",
+      "parceiros": "Socios",
+      "contratos_a_vencer": "Contratos por Vencer",
+      "novos_mes": "Nuevos (mes)",
+      "associados_pendentes": "Asociados Pendientes",
+      "association_date": "Fecha de Asociación",
+      "registration_date": "Fecha de Registro"
+    },
     "cards": {
       "total_earnings": "Ingresos Totales",
       "tickets_sold": "Entradas Vendidas",
@@ -365,5 +558,146 @@
         }
       }
     }
+  },
+  "loja": {
+    "product": "Producto",
+    "products": "Productos",
+    "supplier_price": "Precio Proveedor",
+    "supplier": "Proveedor",
+    "interests": "Interesados",
+    "interested_btn": "INTERESADOS",
+    "interested_in": "Interesados en",
+    "media": "Medios",
+    "add_media": "Agregar Medio",
+    "uploading_media": "Subiendo medio...",
+    "activate": "Activar",
+    "deactivate": "Desactivar",
+    "variation": "Variación",
+    "variation_tamanho": "Talla",
+    "variation_cor": "Color",
+    "variation_modelo": "Modelo",
+    "variation_label": "Nombre",
+    "variation_value": "Valor (R$)",
+    "variation_stock": "Stock",
+    "variation_cor_placeholder": "Ej: Morado, Blanco...",
+    "variation_modelo_placeholder": "Ej: Asociado, Premium...",
+    "variation_empty": "Sin variaciones agregadas",
+    "add_variation": "Agregar",
+    "filter_all": "Todos",
+    "filter_recent": "Recientes",
+    "filter_others": "Otros",
+    "btn_want": "LO QUIERO",
+    "btn_interested": "INTERESADO"
+  },
+  "parceiro": {
+    "category": "Categoría",
+    "company_name": "Nombre de la Empresa",
+    "responsible": "Responsable",
+    "linked_user": "Usuario Vinculado",
+    "discount_percentage": "Descuento (%)",
+    "logo": "Logo",
+    "website": "Sitio Web",
+    "zip_code": "Código Postal",
+    "address": "Dirección",
+    "neighborhood": "Barrio",
+    "working_hours": "Horario de Atención",
+    "working_hours_placeholder": "Ej: Lun-Vie 8h-18h, Sáb 8h-12h",
+    "contract_start": "Inicio del Contrato",
+    "contract_end": "Fin del Contrato",
+    "contract_files": "Archivos del Contrato",
+    "add_file": "Agregar Archivo",
+    "services": "Servicios",
+    "service": "Servicio",
+    "service_category": "Categoría del Servicio",
+    "service_number": "Número del Servicio",
+    "price": "Precio",
+    "associate_price": "Precio Asociado",
+    "supplier_price": "Precio Proveedor",
+    "requires_scheduling": "Requiere Programación",
+    "search_placeholder": "Buscar por servicio",
+    "cadastro_title": "Registro de Socio",
+    "cadastro_parceiro": "Registro de Socio",
+    "dados_parceiro": "Datos del Socio",
+    "tab_dados": "Datos de la Empresa",
+    "tab_contato": "Contacto",
+    "tab_endereco": "Dirección",
+    "tab_horario": "Horario y Contrato",
+    "tab_servicos": "Mis Servicios",
+    "usuario_parceiro": "Usuario Socio",
+    "home": "Portal del Socio",
+    "parceiro": {
+      "dashboard": {
+        " title": "Portal del Socio",
+        "authorization": "Consultas para Autorización",
+        "scheduling": "Consultas para Agendamiento",
+        "completed": "Consultas Realizadas",
+        "not_authorized": "Consultas No Autorizadas"
+      }
+    }
+  },
+  "notification": {
+    "new": "Nueva notificación",
+    "history": "Historial",
+    "title_label": "Título",
+    "title_placeholder": "Título de la notificación",
+    "message_label": "Mensaje",
+    "message_placeholder": "Escribe el mensaje",
+    "recipients": "Destinatarios",
+    "all": "Todos",
+    "by_position": "Destinatarios Por Cargo",
+    "by_sector": "Destinatarios Por Sector",
+    "media": "Medios",
+    "send": "Enviar Notificación",
+    "sent_to": "Enviado a {count} contactos",
+    "seen_by": "Visto por {count} contactos",
+    "details": "Detalles",
+    "empty": "No se encontraron notificaciones",
+    "recipient_associado": "Asociado",
+    "recipient_parceiro": "Socio/Convenios"
+  },
+  "relatorio": {
+    "novos_associados": "Nuevos Asociados",
+    "contatos": "Contactos",
+    "exclusoes_mes": "Exclusiones del mes",
+    "exportar_csv": "Exportar Excel",
+    "col": {
+      "cadastro": "Registro",
+      "exclusao": "Exclusión"
+    },
+    "exportar_excel": "Exportar Excel"
+  },
+  "agendamento": {
+    "nova_solicitacao": "Nueva Solicitud",
+    "visao_geral": "Visión General",
+    "aprovados_automaticamente": "Aprobados Automáticamente",
+    "associado": "Asociado",
+    "solicitar": "Solicitar",
+    "confirm_approve": "¿Estás seguro de que deseas aprobar esta cita?",
+    "confirm_reject": "¿Estás seguro de que deseas rechazar esta cita?",
+    "status": {
+      "pendente": "Esperando",
+      "confirmado": "Aprobado",
+      "recusado": "Rechazado",
+      "cancelado": "Cancelado",
+      "concluido": "Completado"
+    },
+    "col": {
+      "pedido": "Pedido",
+      "parceiro": "Socio",
+      "servico": "Servicio",
+      "solicitacao": "Solicitud"
+    }
+  },
+  "associate_validation": {
+    "title": "Validar Credencial",
+    "description": "Ingrese la información del asociado para verificar el estado.",
+    "search_placeholder": "EJ: Nombre, CPF, Matrícula",
+    "search": "Buscar",
+    "associate_found": "Asociado Encontrado",
+    "name": "Nombre",
+    "cpf": "CPF",
+    "registration": "Matrícula",
+    "qr_code": "Leer QR CODE",
+    "not_found": "Asociado no encontrado"
   }
-}
+}

+ 343 - 10
src/i18n/locales/pt.json

@@ -1,5 +1,34 @@
 {
   "page": {
+    "loja": {
+      "description": "Gerencie os produtos disponíveis na loja"
+    },
+    "associado": {
+      "profile": {
+        "description": "Visualize e edite seus dados pessoais e dependentes"
+      },
+      "carteirinha": {
+        "description": "Sua carteira digital com QR Code de identificação"
+      },
+      "convenios": {
+        "description": "Confira os parceiros e convênios disponíveis para você"
+      },
+      "loja": {
+        "description": "Confira os itens disponíveis na loja"
+      },
+      "interesses": {
+        "description": "Itens que você marcou como interesse"
+      },
+      "agendamentos": {
+        "description": "Solicite e acompanhe seus agendamentos"
+      },
+      "notificacoes": {
+        "description": "Suas notificações e avisos"
+      }
+    },
+    "gestao_associados": {
+      "description": "Consulte, edite e adicione os associados do sistema"
+    },
     "city": {
       "description": "Consulte edite e adicione os cidades do sistema"
     },
@@ -17,6 +46,18 @@
     },
     "versions": {
       "description": "Informações sobre atualizações, correções e mudanças em cada versão do sistema"
+    },
+    "parceiros_convenios": {
+      "description": "Consulte, edite e adicione os parceiros e convênios do sistema"
+    },
+    "notificacoes": {
+      "description": "Histórico"
+    },
+    "relatorios": {
+      "description": "Visualize relatórios gerenciais do sistema"
+    },
+    "agendamentos": {
+      "description": "Gerencie os agendamentos dos associados"
     }
   },
   "common": {
@@ -25,7 +66,8 @@
       "save": "Salvar",
       "cancel": "Cancelar",
       "edit": "Editar",
-      "add": "Cadastrar",
+      "add": "Adicionar",
+      "new": "Novo",
       "search": "Buscar",
       "delete": "Excluir",
       "view": "Visualizar",
@@ -34,7 +76,8 @@
       "resend_email": "Reenviar e-mail",
       "download_certificate": "Baixar certificado",
       "download_boleto": "Baixar Boleto",
-      "copy_paste_code": "Copie e cole o código abaixo para efetuar o pagamento"
+      "copy_paste_code": "Copie e cole o código abaixo para efetuar o pagamento",
+      "go_home": "Ir para o Início"
     },
     "terms": {
       "actions": "Ações",
@@ -88,12 +131,15 @@
       "week": "Semana",
       "day": "Dia",
       "hour": "Hora",
+      "hour2": "Horário",
       "minute": "Minuto",
       "second": "Segundo",
       "year": "Ano",
       "all": "Todos",
       "certificate": "Certificado",
-      "version": "Versão"
+      "version": "Versão",
+      "whatsapp": "WhatsApp",
+      "today": "Hoje"
     },
     "months": {
       "january": "Janeiro",
@@ -112,6 +158,7 @@
     "status": {
       "active": "Ativo",
       "inactive": "Inativo",
+      "pending": "Pendente",
       "canceled": "Cancelado",
       "loading": "Por favor, aguarde...",
       "yes": "Sim",
@@ -138,7 +185,9 @@
         "confirm_action": "Você tem certeza?",
         "are_you_sure_delete": "Tem certeza de que deseja excluir este item?",
         "welcome": "Bem-vindo",
-        "enjoy_the_event": "Aproveite o evento!"
+        "enjoy_the_event": "Aproveite o evento!",
+        "saved": "Salvo com sucesso",
+        "error": "Ocorreu um erro. Tente novamente."
       },
       "misc": {
         "all": "Todos",
@@ -153,6 +202,19 @@
       "created_at": "Criado em",
       "updated_at": "Atualizado em",
       "created_by": "Criado por"
+    },
+    "placeholders": {
+      "city_full_name": "Nome completo da cidade",
+      "country_full_name": "Nome completo do país",
+      "state_full_name": "Nome completo do estado",
+      "select_country": "Selecione o país",
+      "select_state": "Selecione o estado",
+      "country_code": "Ex. BR, PT...",
+      "state_code": "Ex. SP, PR...",
+      "email": "email{'@'}exemplo.com",
+      "cpf": "000.000.000-00",
+      "registration": "Ex: 1111111",
+      "password": "Digite uma senha segura"
     }
   },
   "auth": {
@@ -162,7 +224,33 @@
     "registration": "Cadastro",
     "confirm_password": "Confirmar Senha",
     "agreed_terms": "Eu concordo com os termos",
-    "agreed_privacy": "Eu concordo com a política de privacidade"
+    "agreed_privacy": "Eu concordo com a política de privacidade",
+    "select_type_hint": "Selecione seu perfil e faça o Login",
+    "type": {
+      "administrador": "Administrador",
+      "associado": "Associado",
+      "parceiro": "Parceiro"
+    },
+    "forgot_password": "Esqueci a senha?",
+    "forgot_password_title": "Informe seu e-mail para redefinir sua senha",
+    "forgot_password_description": "Enviaremos um código de verificação para o seu e-mail.",
+    "continue": "Continuar",
+    "verify_email_title": "Verifique seu e-mail",
+    "verify_email_description": "Por favor, confira sua caixa de entrada e clique no link para prosseguir.",
+    "no_email_hint": "Não recebeu o e-mail? Verifique a caixa de",
+    "check_spam": "spam ou lixo eletrônico.",
+    "enter_code": "Digitar Código",
+    "enter_code_title": "Digite seu código aqui",
+    "verify": "Verificar",
+    "resend_email": "Reenviar e-mail",
+    "remember_email": "Lembrar e-mail",
+    "remember_password": "Lembrou sua senha?",
+    "do_login": "Faça seu Login",
+    "new_password_title": "Vamos cadastrar sua nova senha",
+    "password_hint": "Mínimo de 6 caracteres com letras maiúsculas, minúsculas e números",
+    "confirm": "Confirmar",
+    "back_to_site": "Voltar ao Site",
+    "wrong_type": "Usuário encontrado, mas o tipo selecionado está incorreto. Selecione o login correto."
   },
   "business": {
     "advertise": "Anunciar",
@@ -185,14 +273,16 @@
       "cpf": "Este campo deve ser um CPF válido",
       "cnpj": "Este campo deve ser um CNPJ válido",
       "cep": "Este campo deve ser um CEP válido",
-      "value_smaller_than_zero": "O valor não pode ser menor que zero"
+      "value_smaller_than_zero": "O valor não pode ser menor que zero",
+      "code_length": "O código deve ter 6 dígitos"
     },
     "permissions": {
       "view": "Você não tem permissão para visualizar isto",
       "create": "Você não tem permissão para criar isto",
       "edit": "Você não tem permissão para editar isto",
       "delete": "Você não tem permissão para excluir isto",
-      "add": "Você não tem permissão para adicionar isto"
+      "add": "Você não tem permissão para adicionar isto",
+      "wrong_type": "Você não tem permissão para acessar esta área."
     }
   },
   "http": {
@@ -243,6 +333,7 @@
   "user": {
     "singular": "Usuário",
     "plural": "Usuários",
+    "type_hint": "O tipo delimita as permissões do usuário",
     "profile": {
       "singular": "Perfil",
       "name_and_surname": "Nome e Sobrenome",
@@ -290,7 +381,7 @@
       "home": "Início",
       "versions": "Versões",
       "expand_menu": "Expandir menu",
-      "dashboard": "Painel",
+      "dashboard": "Dashboard",
       "explore": "Explorar",
       "advertise": "Anunciar",
       "my_advertisements": "Meus Anúncios",
@@ -312,9 +403,101 @@
       "city": "Cidades",
       "state": "Estados",
       "country": "Países",
-      "exit": "Sair"
+      "exit": "Sair",
+      "partners": "Parceiros/Convênios",
+      "partner_agreements": "Convênios",
+      "partner_services": "Serviços",
+      "store": "Loja",
+      "store_items": "Itens",
+      "store_orders": "Pedidos",
+      "appointments": "Agendamentos",
+      "my_appointments": "Meus Agendamentos",
+      "received_appointments": "Agendamentos Recebidos",
+      "notifications": "Notificações",
+      "categories": "Categorias",
+      "my_profile": "Meu Perfil",
+      "my_services": "Meus Serviços",
+      "associados": "Gestão Associados",
+      "relatorios": "Relatórios",
+      "validar_carteirinha": "Validar Carteirinha",
+      "meus_dados": "Meus Dados",
+      "meu_perfil": "Meu Perfil",
+      "carteirinha": "Carteirinha Digital",
+      "convenios": "Convênios",
+      "dependentes": "Dependentes",
+      "loja": "Loja",
+      "meus_interesses": "Meus Interesses",
+      "meus_agendamentos": "Agendamentos"
     }
   },
+  "associado": {
+    "associado": "Associado",
+    "personal_data": "Dados Pessoais",
+    "registration": "Matrícula",
+    "cracha": "Crachá",
+    "position": "Cargo",
+    "sector": "Setor",
+    "admission_date": "Data de Admissão",
+    "admission": "Admissão",
+    "dependent": "Dependente",
+    "dependents": "Dependentes",
+    "import_afastamentos": "Afastamentos",
+    "search_placeholder": "Buscar por associados",
+    "no_dependents": "Nenhum dependente cadastrado",
+    "kinship": "Parentesco",
+    "dependent_statuses": {
+      "approved": "Aprovado",
+      "refused": "Recusado",
+      "pending": "Pendente"
+    },
+    "kinship_options": {
+      "conjuge": "Cônjuge",
+      "filho": "Filho",
+      "filha": "Filha",
+      "pai": "Pai",
+      "mae": "Mãe",
+      "irmao": "Irmão",
+      "irma": "Irmã",
+      "outro": "Outro"
+    },
+    "horizontal": "Horizontal",
+    "vertical": "Vertical",
+    "category": "Categoria",
+    "service": "Serviço",
+    "services": "Serviços",
+    "filter_by_category": "Filtrar por categoria",
+    "view_services": "Ver Serviços",
+    "search_by_segment": "Buscar por segmento",
+    "discount": "Desconto",
+    "validity_until": "Vigência até",
+    "price": "Preço",
+    "available_sizes": "Tamanhos disponíveis",
+    "i_want": "Eu quero",
+    "remove_interest": "Remover interesse",
+    "no_interests": "Você ainda não tem nenhum interesse registrado",
+    "new_appointment": "Nova Solicitação",
+    "my_appointments": "Meus Agendamentos",
+    "notes": "Observações",
+    "schedule": "Agendar",
+    "confirm_cancel_appointment": "Tem certeza que deseja cancelar este agendamento?",
+    "appointment_status": {
+      "pendente": "Pendente",
+      "confirmado": "Confirmado",
+      "cancelado": "Cancelado",
+      "concluido": "Concluído"
+    },
+    "no_notifications": "Nenhuma notificação no momento",
+    "mark_as_read": "Marcar como lida",
+    "validity": "Validade",
+    "language_options": {
+      "pt": "Português",
+      "en": "English",
+      "es": "Español"
+    },
+    "cpf": "CPF",
+    "phone": "Telefone",
+    "email": "E-mail"
+  },
   "charts": {
     "nps": {
       "promotion_zone": "Zona de Promoção",
@@ -332,6 +515,16 @@
   },
   "dashboard": {
     "currency_format": "R$ {value}",
+    "stats": {
+      "total_associados": "Total Associados",
+      "associados_ativos": "Associados Ativos",
+      "parceiros": "Parceiros",
+      "contratos_a_vencer": "Contratos a Vencer",
+      "novos_mes": "Novos (mês)",
+      "associados_pendentes": "Associados Pendentes",
+      "association_date": "Data Associação",
+      "registration_date": "Data Cadastro"
+    },
     "cards": {
       "total_earnings": "Total Ganho",
       "tickets_sold": "Ingressos Vendidos",
@@ -365,5 +558,145 @@
         }
       }
     }
+  },
+  "loja": {
+    "product": "Produto",
+    "products": "Produtos",
+    "supplier_price": "Preço Fornecedor",
+    "supplier": "Fornecedor",
+    "stock": "Estoque",
+    "interests": "Interessados",
+    "interested_btn": "INTERESSADOS",
+    "interested_in": "Interessados em",
+    "media": "Mídias",
+    "add_media": "Adicionar Mídia",
+    "uploading_media": "Enviando mídia...",
+    "activate": "Ativar",
+    "deactivate": "Desativar",
+    "variation": "Variação",
+    "variation_tamanho": "Tamanho",
+    "variation_cor": "Cor",
+    "variation_modelo": "Modelo",
+    "variation_label": "Nome",
+    "variation_value": "Valor (R$)",
+    "variation_stock": "Estoque",
+    "variation_cor_placeholder": "Ex: Roxo, Branco...",
+    "variation_modelo_placeholder": "Ex: Associado, Premium...",
+    "variation_empty": "Nenhuma variação adicionada",
+    "add_variation": "Adicionar",
+    "filter_all": "Todos",
+    "filter_recent": "Recentes",
+    "filter_others": "Outros",
+    "btn_want": "EU QUERO",
+    "btn_interested": "INTERESSADO"
+  },
+  "parceiro": {
+    "category": "Categoria",
+    "company_name": "Nome da Empresa",
+    "responsible": "Responsável",
+    "linked_user": "Usuário Vinculado",
+    "discount_percentage": "Desconto (%)",
+    "logo": "Logo",
+    "website": "Website",
+    "zip_code": "CEP",
+    "address": "Endereço",
+    "neighborhood": "Bairro",
+    "working_hours": "Horário de Funcionamento",
+    "working_hours_placeholder": "Ex: Seg-Sex 08h-18h, Sáb 08h-12h",
+    "contract_start": "Início do Contrato",
+    "contract_end": "Fim do Contrato",
+    "contract_files": "Arquivos do Contrato",
+    "add_file": "Adicionar Arquivo",
+    "services": "Serviços",
+    "service": "Serviço",
+    "service_category": "Categoria do Serviço",
+    "service_number": "Número do Serviço",
+    "price": "Preço",
+    "associate_price": "Preço Associado",
+    "supplier_price": "Preço Fornecedor",
+    "requires_scheduling": "Requer Agendamento",
+    "search_placeholder": "Pesquisar por serviço",
+    "cadastro_title": "Cadastro de Parceiro",
+    "cadastro_parceiro": "Cadastro de Parceiro",
+    "dados_parceiro": "Dados de Parceiro",
+    "tab_dados": "Dados da Empresa",
+    "tab_contato": "Contato",
+    "tab_endereco": "Endereço",
+    "tab_horario": "Horário e Contrato",
+    "tab_servicos": "Meus Serviços",
+    "usuario_parceiro": "Usuário Parceiro",
+    "home": "Portal do Parceiro",
+    "dashboard": {
+      "title": "Portal do Parceiro",
+      "authorization": "Consultas para Autorização",
+      "scheduling": "Consultas para Agendamento",
+      "completed": "Consultas Realizadas",
+      "not_authorized": "Consultas Não Autorizadas"
+    }
+  },
+  "notification": {
+    "new": "Nova notificação",
+    "history": "Histórico",
+    "title_label": "Título",
+    "title_placeholder": "Título da notificação",
+    "message_label": "Mensagem",
+    "message_placeholder": "Escreva a mensagem",
+    "recipients": "Destinatários",
+    "all": "Todos",
+    "by_position": "Destinatários Por Cargo",
+    "by_sector": "Destinatários Por Setor",
+    "media": "Mídias",
+    "send": "Enviar Notificação",
+    "sent_to": "Enviado para {count} contatos",
+    "seen_by": "Visto por {count} contatos",
+    "details": "Detalhes",
+    "empty": "Nenhuma notificação encontrada",
+    "recipient_associado": "Associado",
+    "recipient_parceiro": "Parceiro/Convênios"
+  },
+  "relatorio": {
+    "novos_associados": "Novos Associados",
+    "contatos": "Contatos",
+    "exclusoes_mes": "Exclusões no mês",
+    "exportar_csv": "Exportar Excel",
+    "col": {
+      "cadastro": "Cadastro",
+      "exclusao": "Exclusão"
+    },
+    "exportar_excel": "Exportar Excel"
+  },
+  "agendamento": {
+    "nova_solicitacao": "Nova Solicitação",
+    "visao_geral": "Visão Geral de Agendamentos",
+    "aprovados_automaticamente": "Aprovados Automaticamente",
+    "associado": "Associado",
+    "solicitar": "Solicitar",
+    "confirm_approve": "Tem certeza que deseja aprovar este agendamento?",
+    "confirm_reject": "Tem certeza que deseja recusar este agendamento?",
+    "status": {
+      "pendente": "Aguardando",
+      "confirmado": "Aprovado",
+      "recusado": "Recusado",
+      "cancelado": "Cancelado",
+      "concluido": "Concluído"
+    },
+    "col": {
+      "pedido": "Pedido",
+      "parceiro": "Parceiro",
+      "servico": "Serviço",
+      "solicitacao": "Solicitação"
+    }
+  },
+  "associate_validation": {
+    "title": "Validar Carteirinha",
+    "description": "Digite as informações do associado para verificar o status.",
+    "search_placeholder": "EX: Nome, CPF, Matrícula",
+    "search": "Buscar",
+    "associate_found": "Associado Encontrado",
+    "name": "Nome",
+    "cpf": "CPF",
+    "registration": "Matrícula",
+    "qr_code": "Ler QR CODE",
+    "not_found": "Associado não encontrado"
   }
-}
+}

+ 27 - 50
src/layouts/MainLayout.vue

@@ -3,41 +3,27 @@
     <LeftMenuLayout v-if="!$q.screen.lt.sm" />
     <LeftMenuLayoutMobile v-else v-model="leftDrawerOpen" />
     <q-header v-if="$q.screen.lt.sm" class="bg-transparent q-pa-sm">
-      <q-toolbar
-        class="flex justify-between bg-surface"
-        style="border-radius: 6px !important"
-      >
-        <q-btn dense flat @click="toggleLeftDrawer">
-          <q-icon
-            name="menu"
-            :color="$q.dark.isActive ? 'white' : 'black'"
-            style="font-size: 20px"
-          />
+      <q-toolbar class="bg-surface items-center" style="border-radius: 6px !important; padding: 0 8px; min-height: 50px;">
+        <q-btn dense flat round size="sm" @click="toggleLeftDrawer">
+          <q-icon name="menu" color="primary" size="28px" />
         </q-btn>
-        <div>
-          <q-btn dense flat class="q-mr-sm" @click="changeTheme">
-            <q-icon
-              :color="$q.dark.isActive ? 'white' : 'black'"
-              :name="
-                $q.dark.isActive ? 'mdi-weather-sunny' : 'mdi-weather-night'
-              "
-              style="font-size: 20px"
-            />
-          </q-btn>
-          <q-btn dense flat @click="logoutFn">
-            <q-icon name="logout" color="negative" style="font-size: 20px" />
-          </q-btn>
-        </div>
+        <span class="text-subtitle1 text-weight-bold text-primary q-ml-xs">
+          {{ $t('auth.type.associado') }}
+        </span>
+        <q-space />
+        <q-avatar size="36px" color="violet-normal" text-color="white" style="font-size: 15px; font-weight: 700; cursor: default;">
+          {{ userInitial }}
+        </q-avatar>
       </q-toolbar>
     </q-header>
     <q-page-container>
       <q-page>
         <q-scroll-area
           ref="scrollAreaRef"
-          :style="
+:style="
             $q.screen.lt.sm
-              ? 'height: calc(100dvh - 68px - env(safe-area-inset-top)) !important;'
-              : 'height: calc(100dvh - env(safe-area-inset-top)) !important;'
+              ? 'height: calc(100dvh - 68px - env(safe-area-inset-top)) !important; width: 100%; overflow-x: hidden;'
+              : 'height: calc(100dvh - env(safe-area-inset-top)) !important; width: 100%; overflow-x: hidden;'
           "
         >
           <router-view v-slot="{ Component }">
@@ -56,44 +42,35 @@
 </template>
 
 <script setup>
-import { ref, useTemplateRef, watch } from "vue";
-import { useRoute, useRouter } from "vue-router";
-import { useAuth } from "src/composables/useAuth";
-import { useQuasar } from "quasar";
+import { ref, computed, onMounted, useTemplateRef, watch } from "vue";
+import { useRoute } from "vue-router";
+import { usePushNotifications } from "src/composables/usePushNotifications";
+import { userStore } from "src/stores/user";
 
 import LeftMenuLayout from "src/components/layout/LeftMenuLayout.vue";
 import LeftMenuLayoutMobile from "src/components/layout/LeftMenuLayoutMobile.vue";
 
-const router = useRouter();
-const { logout } = useAuth();
+const { initPush } = usePushNotifications();
 const route = useRoute();
-const $q = useQuasar();
+const store = userStore();
 
 const leftDrawerOpen = ref(false);
 const scrollAreaRef = useTemplateRef("scrollAreaRef");
 
-let oldValue = route.path;
-const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
-  ? "dark"
-  : "light";
+const userInitial = computed(() => {
+  const name = store.user?.name || "";
+  return name.charAt(0).toUpperCase() || "A";
+});
 
-const logoutFn = async () => {
-  await logout();
-  router.push({ name: "LoginPage" });
-};
+let oldValue = route.path;
 
 const toggleLeftDrawer = () => {
   leftDrawerOpen.value = !leftDrawerOpen.value;
 };
 
-const changeTheme = async () => {
-  const theme = $q.cookies.get("theme") || systemTheme;
-  if (theme == "dark") {
-    $q.dark.set(false);
-  } else {
-    $q.dark.set(true);
-  }
-};
+onMounted(() => {
+  initPush();
+});
 
 watch(route, (value) => {
   if (oldValue.path != value.path) {

+ 408 - 0
src/pages/associado/agendamentos/AgendamentosPage.vue

@@ -0,0 +1,408 @@
+<template>
+  <div class="agendamentos-page">
+    <DefaultHeaderPage class="q-px-md" />
+
+    <div class="q-px-md q-pb-md">
+
+      <div class="tabs-row q-mb-md">
+        <div
+          v-for="tab in tabs"
+          :key="tab.name"
+          :class="['cat-chip', activeTab === tab.name ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = tab.name"
+        >
+          {{ tab.label }}
+        </div>
+      </div>
+
+      <!-- Tab: Nova Solicitação -->
+      <div v-if="activeTab === 'novo'">
+        <q-card flat class="form-card">
+          <q-card-section>
+            <q-form ref="appointmentFormRef" class="form-column" @submit="submitAppointment">
+              <PartnerAgreementSelect
+                v-model="selectedPartner"
+                :label="$t('ui.navigation.convenios')"
+                :rules="[inputRules.required]"
+                class="input-violet"
+                for-associado
+              />
+              <PartnerAgreementServiceSelect
+                v-model="selectedService"
+                :partner-agreement-id="selectedPartner?.value"
+                :label="$t('associado.service')"
+                :rules="[inputRules.required]"
+                class="input-violet"
+                for-associado
+              />
+              <DefaultInputDatePicker
+                v-model:untreated-date="appointmentForm.date"
+                :label="$t('common.terms.date')"
+                :rules="[inputRules.required]"
+                class="input-violet"
+                placeholder="dd/mm/aaaa"
+                lazy-rules
+              />
+              <DefaultInput
+                v-model="appointmentForm.time"
+                :label="$t('common.terms.hour')"
+                :rules="[inputRules.required]"
+                mask="##:##"
+                placeholder="HH:MM"
+                class="input-violet"
+              />
+              <DefaultInput
+                v-model="appointmentForm.observations"
+                :label="$t('associado.notes')"
+                type="textarea"
+                autogrow
+                class="input-violet"
+              />
+              <div class="flex justify-end q-mt-sm">
+                <q-btn
+                  unelevated
+                  type="submit"
+                  class="btn-gradient"
+                  :label="editingId ? $t('common.actions.save') : $t('associado.schedule')"
+                  :loading="submitting"
+                />
+              </div>
+            </q-form>
+          </q-card-section>
+        </q-card>
+      </div>
+
+      <!-- Tab: Meus Agendamentos -->
+      <div v-else>
+        <div v-if="loadingList" class="flex flex-center q-py-xl">
+          <q-spinner color="violet-normal" size="48px" />
+        </div>
+
+        <div v-else-if="appointments.length === 0" class="text-center text-grey q-py-xl">
+          {{ $t("http.errors.no_records_found") }}
+        </div>
+
+        <div v-else class="apmt-list">
+          <div
+            v-for="apt in pagedAppointments"
+            :key="apt.id"
+            class="apmt-card"
+          >
+            <div class="apmt-card__header">
+              <span class="apmt-card__order">#{{ apt.order_number }}</span>
+              <q-chip
+                outline
+                :color="statusColor(apt.status)"
+                :label="$t(`agendamento.status.${apt.status}`)"
+                size="sm"
+                dense
+                class="apmt-card__status"
+              />
+            </div>
+
+            <div class="apmt-card__row">
+              <q-icon name="mdi-handshake-outline" size="15px" class="apmt-card__icon" />
+              <span class="apmt-card__value">{{ apt.partner_agreement?.trade_name || apt.partner_agreement?.company_name || '—' }}</span>
+            </div>
+
+            <div class="apmt-card__row">
+              <q-icon name="mdi-briefcase-outline" size="15px" class="apmt-card__icon" />
+              <span class="apmt-card__value">{{ apt.partner_agreement_service?.name || '—' }}</span>
+            </div>
+
+            <div class="apmt-card__row">
+              <q-icon name="mdi-calendar-clock-outline" size="15px" class="apmt-card__icon" />
+              <span class="apmt-card__value">{{ formatDateTime(apt.date, apt.time) }}</span>
+            </div>
+
+            <div class="apmt-card__row">
+              <q-icon name="mdi-calendar-plus-outline" size="15px" class="apmt-card__icon" />
+              <span class="apmt-card__label">{{ $t('agendamento.col.solicitacao') }}:</span>
+              <span class="apmt-card__value">{{ formatDate(apt.created_at) }}</span>
+            </div>
+
+            <div v-if="apt.status === 'pendente'" class="apmt-card__actions">
+              <q-btn
+                dense
+                flat
+                icon="mdi-pencil-outline"
+                color="violet-normal"
+                size="sm"
+                :label="$t('common.actions.edit')"
+                @click="onEditAppointment(apt)"
+              />
+            </div>
+          </div>
+        </div>
+
+        <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
+          <q-pagination
+            v-model="currentPage"
+            :max="totalPages"
+            boundary-numbers
+            color="violet-normal"
+            active-color="violet-normal"
+            direction-links
+          />
+        </div>
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createAppointment, getMyAppointments, updateAppointment } from "src/api/appointment";
+import { useInputRules } from "src/composables/useInputRules";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import PartnerAgreementSelect from "src/components/selects/PartnerAgreementSelect.vue";
+import PartnerAgreementServiceSelect from "src/components/selects/PartnerAgreementServiceSelect.vue";
+import { userStore } from "src/stores/user";
+
+const $q = useQuasar();
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const appointmentFormRef = useTemplateRef("appointmentFormRef");
+const user = userStore();
+
+const activeTab = ref("novo");
+const PER_PAGE = 12;
+const currentPage = ref(1);
+
+const tabs = computed(() => [
+  { name: "novo",  label: t("associado.new_appointment") },
+  { name: "meus", label: t("associado.my_appointments") },
+]);
+
+const selectedPartner  = ref(null);
+const selectedService  = ref(null);
+const appointmentForm  = reactive({ date: "", time: "", observations: "" });
+const submitting       = ref(false);
+const editingId        = ref(null);
+const appointments     = ref([]);
+const loadingList      = ref(false);
+
+const totalPages = computed(() => Math.max(1, Math.ceil(appointments.value.length / PER_PAGE)));
+
+const pagedAppointments = computed(() => {
+  const start = (currentPage.value - 1) * PER_PAGE;
+  return appointments.value.slice(start, start + PER_PAGE);
+});
+
+const statusColor = (status) => {
+  const map = { pendente: "warning", confirmado: "positive", cancelado: "negative", recusado: "negative", concluido: "grey" };
+  return map[status] ?? "grey";
+};
+
+const formatDate = (isoStr) => {
+  if (!isoStr) return "—";
+  const d = new Date(isoStr);
+  if (isNaN(d)) return "—";
+  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+};
+
+const formatDateTime = (date, time) => {
+  if (!date) return "—";
+  const d = new Date(date + "T00:00:00");
+  if (isNaN(d)) return "—";
+  const dateStr = d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+  return time ? `${dateStr} ${time}` : dateStr;
+};
+
+const loadAppointments = async () => {
+  loadingList.value = true;
+  currentPage.value = 1;
+  try {
+    appointments.value = await getMyAppointments();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loadingList.value = false;
+  }
+};
+
+watch(activeTab, (val) => {
+  if (val === "meus") loadAppointments();
+});
+
+const resetForm = () => {
+  selectedPartner.value   = null;
+  selectedService.value   = null;
+  appointmentForm.date    = "";
+  appointmentForm.time    = "";
+  appointmentForm.observations = "";
+  editingId.value         = null;
+};
+
+const submitAppointment = async () => {
+  const valid = await appointmentFormRef.value?.validate();
+  if (!valid) return;
+  submitting.value = true;
+  const payload = {
+    partner_agreement_id:         selectedPartner.value.value,
+    partner_agreement_service_id: selectedService.value.value,
+    date:         appointmentForm.date,
+    time:         appointmentForm.time,
+    observations: appointmentForm.observations,
+  };
+  try {
+    if (editingId.value) {
+      await updateAppointment(editingId.value, payload);
+    } else {
+      payload.user_id = user.user.id;
+      await createAppointment(payload);
+    }
+    $q.notify({ type: "positive", message: t("http.success") });
+    resetForm();
+    activeTab.value = "meus";
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    submitting.value = false;
+  }
+};
+
+const onEditAppointment = (apt) => {
+  editingId.value        = apt.id;
+  selectedPartner.value  = { value: apt.partner_agreement_id, label: apt.partner_agreement?.trade_name || apt.partner_agreement?.company_name || "" };
+  selectedService.value  = { value: apt.partner_agreement_service_id, label: apt.partner_agreement_service?.name || "" };
+  appointmentForm.date   = apt.date ?? "";
+  appointmentForm.time   = apt.time ?? "";
+  appointmentForm.observations = apt.observations ?? "";
+  activeTab.value        = "novo";
+};
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.agendamentos-page {
+  background: vars.$violet-light;
+  min-height: calc(100dvh - 68px);
+  box-sizing: border-box;
+  overflow-x: hidden;
+}
+
+.tabs-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.cat-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 32px;
+  padding: 0 16px;
+  border-radius: 8px;
+  font-size: 13px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default  { background: #c9a3dc; color: #fff; }
+  &--selected { background: #4d1658; color: #fff; }
+}
+
+.form-card {
+  background: white !important;
+  border-radius: 12px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+
+.form-column {
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.btn-gradient {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important;
+  border-radius: 8px !important;
+
+  :deep(.q-icon) { color: white !important; }
+}
+
+.apmt-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.apmt-card {
+  background: white;
+  border-radius: 12px;
+  padding: 14px 16px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  overflow: hidden;
+
+  &__header {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 4px;
+  }
+
+  &__order {
+    font-size: 13px;
+    font-weight: 700;
+    color: vars.$violet-normal;
+    letter-spacing: 0.3px;
+  }
+
+  &__status {
+    font-size: 11px !important;
+  }
+
+  &__row {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    gap: 6px;
+    font-size: 13px;
+    color: vars.$color-text;
+    min-width: 0;
+  }
+
+  &__icon {
+    color: vars.$violet-normal;
+    flex-shrink: 0;
+    opacity: 0.8;
+  }
+
+  &__label {
+    color: vars.$color-text-2;
+    font-size: 12px;
+    flex-shrink: 0;
+  }
+
+  &__value {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    min-width: 0;
+  }
+
+  &__actions {
+    display: flex;
+    flex-direction: row;
+    justify-content: flex-end;
+    margin-top: 4px;
+    border-top: 1px solid vars.$violet-light;
+    padding-top: 6px;
+  }
+}
+</style>

+ 263 - 0
src/pages/associado/carteirinha/CarteirinhaPage.vue

@@ -0,0 +1,263 @@
+<template>
+  <div class="carteirinha-page">
+    <DefaultHeaderPage class="q-px-sm"/>
+
+    <div class="card-container">
+      <q-card
+        flat
+        class="associado-card"
+        :class="isVertical ? 'card-vertical' : 'card-horizontal'"
+      >
+        <div class="card-logo-row text-center">
+          <img :src="LogoSerPratiParceiro" class="card-logo" alt="SerPrati" />
+        </div>
+
+        <div class="card-body" :class="isVertical ? 'card-body--vertical' : 'card-body--horizontal'">
+          <q-avatar
+            class="card-avatar"
+            :size="isVertical ? '100px' : '76px'"
+          >
+            <img v-if="user?.avatar" :src="user.avatar" />
+            <q-icon v-else name="mdi-account" color="white" :size="isVertical ? '64px' : '40px'" />
+          </q-avatar>
+
+          <template v-if="!isVertical">
+            <div class="card-info">
+              <div class="card-info__row">
+                <span class="card-info__label">{{ $t('common.terms.name') }}</span>
+                <span class="card-info__value">{{ user?.name ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-sm">
+                <span class="card-info__label">{{ $t('common.terms.cpf') }}</span>
+                <span class="card-info__value">{{ user?.cpf ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-sm">
+                <span class="card-info__label">{{ $t('associado.registration') }}</span>
+                <span class="card-info__value">{{ user?.registration ?? '—' }}</span>
+              </div>
+            </div>
+            <div class="card-qr column">
+              <canvas v-if="qrReady" ref="qrCanvas" />
+              <q-spinner v-else color="white" size="48px" />
+              <div v-if="user?.expiry_date" class="card-info__row q-pt-sm text-right">
+                <span class="card-info__label text-right">{{ $t('associado.validity') }}</span>
+                <span class="card-info__value">{{ user.expiry_date }}</span>
+              </div>
+            </div>
+          </template>
+
+          <div v-if="isVertical" class="card-body__vertical-content">
+            <div class="card-info card-info--vertical">
+              <div class="card-info__row">
+                <span class="card-info__label">{{ $t('common.terms.name') }}</span>
+                <span class="card-info__value">{{ user?.name ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-xs">
+                <span class="card-info__label">{{ $t('common.terms.cpf') }}</span>
+                <span class="card-info__value">{{ user?.cpf ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-xs">
+                <span class="card-info__label">{{ $t('associado.registration') }}</span>
+                <span class="card-info__value">{{ user?.registration ?? '—' }}</span>
+              </div>
+              <div class="card-info__row q-pt-xs">
+                <span class="card-info__label">{{ $t('associado.validity') }}</span>
+                <span class="card-info__value">{{ user?.expiry_date ?? '—' }}</span>
+              </div>
+            </div>
+            <canvas v-if="qrReady" ref="qrCanvas" class="card-qr__canvas" />
+            <q-spinner v-else color="white" size="48px" />
+          </div>
+        </div>
+      </q-card>
+
+      <q-btn
+        round
+        flat
+        :icon="isVertical ? 'mdi-phone-rotate-landscape' : 'mdi-phone-rotate-portrait'"
+        color="violet-normal"
+        size="sm"
+        :title="isVertical ? $t('associado.horizontal') : $t('associado.vertical')"
+        @click="isVertical = !isVertical"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted, useTemplateRef, watch, nextTick } from "vue";
+import QRCode from "qrcode";
+import { userStore } from "src/stores/user";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import LogoSerPratiParceiro from "src/assets/logo_serprati_parceiro.svg";
+
+const store = userStore();
+const user = ref(null);
+const isVertical = ref(false);
+const qrCanvas = useTemplateRef("qrCanvas");
+const qrReady = ref(false);
+
+const QR_SIZE_HORIZONTAL = 90;
+const QR_SIZE_VERTICAL   = 110;
+
+const generateQR = async () => {
+  if (!user.value?.cpf && !user.value?.registration) return;
+  await nextTick();
+  const canvas = qrCanvas.value instanceof Array ? qrCanvas.value[0] : qrCanvas.value;
+  if (!canvas) return;
+  const qrData = `CPF:${user.value.cpf ?? ""}|MAT:${user.value.registration ?? ""}`;
+  const size = isVertical.value ? QR_SIZE_VERTICAL : QR_SIZE_HORIZONTAL;
+  await QRCode.toCanvas(canvas, qrData, { width: size, margin: 1, color: { dark: "#ffffff", light: "#661d75" } });
+};
+
+watch(isVertical, async () => {
+  await nextTick();
+  await generateQR();
+});
+
+onMounted(async () => {
+  await store.fetchUser();
+  user.value = store.user;
+  qrReady.value = true;
+  await generateQR();
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.associado-card {
+  border-radius: 16px;
+  background: linear-gradient(180deg, #661D75 0%, #BF36DB 100%);
+  overflow: hidden;
+  box-shadow: 0 8px 32px rgba(77, 22, 88, 0.35);
+  color: vars.$neutral-light;
+
+  .card-logo-row {
+    .card-logo {
+      height: 65px;
+    }
+  }
+
+  .card-body {
+    padding: 8px 16px 16px;
+    display: flex;
+    gap: 12px;
+
+    &--horizontal {
+      flex-direction: row;
+      align-items: center;
+    }
+
+    &--vertical {
+      flex-direction: column;
+      align-items: center;
+      text-align: center;
+    }
+  }
+
+  .card-avatar {
+    border: 2px solid rgba(255, 255, 255, 0.4);
+    background: rgba(255, 255, 255, 0.15);
+    flex-shrink: 0;
+  }
+
+  .card-info {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+
+    .card-info__label {
+      font-size: 11px;
+      opacity: 0.8;
+    }
+
+    .card-info__value {
+      font-size: 15px;
+      font-weight: 600;
+    }
+    &--vertical {
+      width: 100%;
+      align-items: flex-start;
+      text-align: left;
+
+    }
+
+    &__name {
+      font-size: 14px;
+      font-weight: 600;
+      color: vars.$neutral-light;
+      margin-bottom: 4px;
+    }
+
+    &__row {
+      display: flex;
+      flex-direction: column;
+      line-height: 1.2;
+    }
+
+    &__label {
+      font-size: 9px;
+      opacity: 0.75;
+      text-transform: uppercase;
+      letter-spacing: 0.4px;
+    }
+
+    &__value {
+      font-size: 11px;
+      font-weight: 500;
+      color: vars.$neutral-light;
+    }
+  }
+
+  .card-qr {
+    flex-shrink: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    canvas {
+      border-radius: 6px;
+    }
+  }
+
+  .card-body__vertical-content {
+    width: 100%;
+    display: flex;
+    flex-direction: row;
+    align-items: flex-end;
+    justify-content: space-between;
+    gap: 8px;
+  }
+
+  .card-qr__canvas {
+    border-radius: 6px;
+    flex-shrink: 0;
+  }
+
+  &.card-horizontal {
+    width: min(360px, 100%);
+  }
+
+  &.card-vertical {
+    width: min(260px, 100%);
+  }
+}
+
+.card-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 12px;
+  margin-top: 16px;
+  overflow: hidden;
+}
+
+.carteirinha-page {
+  background: vars.$violet-light;
+  min-height: calc(100dvh - 68px);
+  box-sizing: border-box;
+  overflow-x: hidden;
+}
+</style>

+ 158 - 0
src/pages/associado/convenios/ConveniosPage.vue

@@ -0,0 +1,158 @@
+<template>
+  <div class="convenios-page">
+    <DefaultHeaderPage class="q-mx-md"/>
+
+    <div class="q-pb-sm q-ma-md">
+      <q-input
+        v-model="searchQuery"
+        outlined
+        dense
+        bg-color="white"
+        :placeholder="$t('parceiro.search_placeholder')"
+        clearable
+        class="convenios-page__search-input"
+      >
+        <template #prepend>
+          <q-icon name="mdi-magnify" />
+        </template>
+      </q-input>
+    </div>
+
+    <div class="chips-scroll q-ma-md">
+      <div
+        :class="['cat-chip', activeCategory === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
+        @click="activeCategory = 'all'"
+      >
+        {{ $t('common.terms.all') }}
+      </div>
+      <div
+        v-for="cat in categories"
+        :key="cat.id"
+        :class="['cat-chip', activeCategory === String(cat.id) ? 'cat-chip--selected' : 'cat-chip--default']"
+        @click="activeCategory = String(cat.id)"
+      >
+        {{ cat.name }}
+      </div>
+    </div>
+
+    <div v-if="loading" class="flex flex-center q-pa-xl q-ma-md">
+      <q-spinner color="violet-normal" size="50px" />
+    </div>
+
+    <div v-else-if="filteredPartners.length === 0" class="flex flex-center q-pa-xl text-grey-6 q-ma-md">
+      {{ $t("http.errors.no_records_found") }}
+    </div>
+
+    <div v-else class="cards-column q-ma-md">
+      <PartnerAgreementCard
+        v-for="partner in filteredPartners"
+        :key="partner.id"
+        :partner="partner"
+        :editable="false"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { getPartnerAgreements } from "src/api/partnerAgreement";
+import { getCategories } from "src/api/category";
+import { normalizeString } from "src/helpers/utils";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import PartnerAgreementCard from "./components/PartnerAgreementCard.vue";
+
+const loading = ref(true);
+const allPartners = ref([]);
+const categories = ref([]);
+const activeCategory = ref("all");
+const searchQuery = ref("");
+
+const filteredPartners = computed(() => {
+  let list = allPartners.value;
+
+  if (activeCategory.value !== "all") {
+    list = list.filter((p) => String(p.category_id) === activeCategory.value);
+  }
+
+  if (searchQuery.value) {
+    const needle = normalizeString(searchQuery.value);
+    list = list.filter((p) => {
+      const fields = [p.company_name, p.cnpj, p.responsible, p.email, p.phone, p.category?.name, p.address, p.city?.name];
+      return fields.some((f) => f && normalizeString(String(f)).includes(needle));
+    });
+  }
+
+  return list;
+});
+
+onMounted(async () => {
+  try {
+    const [partners, cats] = await Promise.all([
+      getPartnerAgreements(),
+      getCategories("partner"),
+    ]);
+    allPartners.value = partners;
+    categories.value = cats;
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.convenios-page {
+  background: vars.$violet-light;
+  min-height: calc(100dvh - 68px);
+  box-sizing: border-box;
+  overflow-x: hidden;
+  max-width: 100vw;
+  width: 100%;
+  padding-bottom: 16px;
+}
+
+.convenios-page__search-input {
+  max-width: 700px;
+  width: 100%;
+}
+
+.chips-scroll {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.cat-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  white-space: nowrap;
+  transition: background 0.15s, color 0.15s;
+}
+
+.cat-chip--default {
+  background: #c9a3dc;
+  color: #fff;
+}
+
+.cat-chip--selected {
+  background: #4d1658;
+  color: #fff;
+}
+
+.cards-column {
+  display: flex;
+  flex-direction: column;
+  gap: 16px;
+}
+</style>

+ 224 - 0
src/pages/associado/convenios/components/PartnerAgreementCard.vue

@@ -0,0 +1,224 @@
+<template>
+  <div class="partner-card" :class="{ 'cursor-pointer': editable }" @click="editable && $emit('edit', partner)">
+    <!-- Image flush with card edges -->
+    <div class="partner-card__img-wrap q-pa-sm">
+      <q-img
+        v-if="partner.logo?.url"
+        :src="partner.logo.url"
+        fit="cover"
+        class="partner-card__img"
+      />
+      <div v-else class="partner-card__img-placeholder flex flex-center">
+        <q-icon name="mdi-domain" size="48px" color="grey-4" />
+      </div>
+    </div>
+
+    <!-- Content -->
+    <div class="partner-card__body">
+      <!-- Name -->
+      <div class="partner-card__name">{{ partner.company_name }}</div>
+
+      <!-- Badges: category chip + discount -->
+      <div class="partner-card__badges">
+        <span v-if="partner.category?.name" class="partner-card__chip">
+          {{ partner.category.name }}
+        </span>
+        <span v-if="partner.discount_percentage" class="partner-card__discount">
+          -{{ partner.discount_percentage }}%
+        </span>
+        <q-btn
+          v-if="editable"
+          round
+          unelevated
+          class="partner-card__edit-btn q-ml-auto"
+          icon="mdi-pencil"
+          size="xs"
+          @click.stop="$emit('edit', partner)"
+        />
+      </div>
+
+      <!-- Address -->
+      <div class="partner-card__info">
+        <q-icon name="mdi-map-marker-outline" size="14px" />
+        <span class="partner-card__info-text">{{ addressLine }}</span>
+      </div>
+
+      <!-- Phone -->
+      <div class="partner-card__info">
+        <q-icon name="mdi-phone-outline" size="14px" />
+        <span>{{ partner.phone ?? '--' }}</span>
+      </div>
+
+      <!-- Footer: rating + validity -->
+      <div class="partner-card__footer">
+        <div class="partner-card__rating">
+          <q-icon name="mdi-star" size="14px" color="amber" />
+          <span>4.5/5</span>
+        </div>
+        <div class="partner-card__validity">
+          <q-icon name="mdi-calendar-outline" size="13px" />
+          <span>{{ $t('associado.validity_until') }} {{ partner.contract_end ? formatDate(partner.contract_end) : '--' }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { date } from "quasar";
+
+defineEmits(["edit"]);
+
+const { partner, editable } = defineProps({
+  partner: { type: Object, required: true },
+  editable: { type: Boolean, default: false },
+});
+
+const addressLine = computed(() => {
+  const parts = [partner.address, partner.neighborhood, partner.city?.name].filter(Boolean);
+  return parts.length ? parts.join(", ") : "--";
+});
+
+const formatDate = (d) => (d ? date.formatDate(d, "MM/YYYY") : "—");
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as vars;
+
+.partner-card {
+  width: 100%;
+  box-sizing: border-box;
+  border-radius: 12px;
+  background: white;
+  overflow: hidden;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+  transition: box-shadow 0.2s;
+
+  &:hover {
+    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.15);
+  }
+
+  &__img-wrap {
+    width: 100%;
+    height: 140px;
+    overflow: hidden;
+  }
+
+  &__img {
+    width: 100%;
+    height: 140px;
+  }
+
+  &__img-placeholder {
+    width: 100%;
+    height: 140px;
+    background: #ececec;
+  }
+
+  &__body {
+    padding: 12px 16px 16px;
+    overflow: hidden;
+  }
+
+  &__name {
+    font-size: 14px;
+    font-weight: 600;
+    color: vars.$violet-normal;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    margin-bottom: 6px;
+  }
+
+  &__badges {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    flex-wrap: wrap;
+    gap: 4px;
+    margin-bottom: 8px;
+  }
+
+  &__chip {
+    display: inline-flex;
+    align-items: center;
+    background: #ead5f0;
+    color: vars.$violet-normal;
+    font-size: 11px;
+    font-weight: 500;
+    padding: 2px 8px;
+    border-radius: 20px;
+    white-space: nowrap;
+  }
+
+  &__discount {
+    display: inline-flex;
+    align-items: center;
+    background: vars.$violet-normal;
+    color: white;
+    font-size: 11px;
+    font-weight: 600;
+    padding: 2px 8px;
+    border-radius: 20px;
+    white-space: nowrap;
+  }
+
+  &__edit-btn {
+    background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+    color: white !important;
+  }
+
+  &__info {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    font-size: 12px;
+    color: vars.$violet-normal;
+    opacity: 0.75;
+    margin-bottom: 4px;
+    overflow: hidden;
+  }
+
+  &__info-text {
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    flex: 1;
+    min-width: 0;
+  }
+
+  &__footer {
+    display: flex;
+    flex-direction: row;
+    justify-content: space-between;
+    align-items: center;
+    margin-top: 8px;
+    font-size: 12px;
+    color: vars.$violet-normal;
+    opacity: 0.75;
+    overflow: hidden;
+  }
+
+  &__rating {
+    display: flex;
+    align-items: center;
+    gap: 2px;
+    flex-shrink: 0;
+  }
+
+  &__validity {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    min-width: 0;
+    overflow: hidden;
+
+    span {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+  }
+}
+</style>

+ 110 - 0
src/pages/associado/interesses/InteressesPage.vue

@@ -0,0 +1,110 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <div class="q-pa-md">
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <div v-else-if="items.length === 0" class="text-center text-grey q-py-xl">
+        <q-icon name="mdi-heart-off-outline" size="64px" class="q-mb-sm" />
+        <div>{{ $t("associado.no_interests") }}</div>
+      </div>
+
+      <div v-else class="row q-col-gutter-md">
+        <div
+          v-for="item in items"
+          :key="item.id"
+          class="col-12 col-sm-6 col-md-4 col-lg-3"
+        >
+          <q-card flat bordered class="interest-card full-height column">
+            <q-card-section class="col">
+              <div class="flex items-start justify-between q-mb-sm">
+                <div class="text-subtitle1 text-weight-medium">{{ item.name }}</div>
+                <q-badge v-if="item.category?.name" color="primary" :label="item.category.name" />
+              </div>
+              <div v-if="item.description" class="text-body2 text-grey-7 q-mb-sm ellipsis-3-lines">
+                {{ item.description }}
+              </div>
+              <div class="text-body2">
+                <span class="text-weight-medium">{{ $t('associado.price') }}:</span>
+                R$ {{ formatPrice(item.price) }}
+              </div>
+            </q-card-section>
+            <q-card-actions>
+              <q-btn
+                color="negative"
+                icon="mdi-heart-remove-outline"
+                :label="$t('associado.remove_interest')"
+                :loading="togglingId === item.id"
+                flat
+                @click="removeInterest(item)"
+              />
+            </q-card-actions>
+          </q-card>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { getMyInterestsAssociado, toggleInterestAssociado as apiToggleInterest } from "src/api/storeItem";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const items = ref([]);
+const loading = ref(true);
+const togglingId = ref(null);
+
+const formatPrice = (price) => {
+  if (!price) return "0,00";
+  return Number(price).toLocaleString("pt-BR", { minimumFractionDigits: 2 });
+};
+
+const removeInterest = async (item) => {
+  togglingId.value = item.id;
+  try {
+    await apiToggleInterest(item.id);
+    items.value = items.value.filter((i) => i.id !== item.id);
+    $q.notify({ type: "positive", message: t("http.success") });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    togglingId.value = null;
+  }
+};
+
+onMounted(async () => {
+  try {
+    items.value = await getMyInterestsAssociado();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+.interest-card {
+  transition: box-shadow 0.2s;
+  &:hover {
+    box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12);
+  }
+}
+.ellipsis-3-lines {
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  line-clamp: 3; /* For Firefox */
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+</style>

+ 430 - 0
src/pages/associado/loja/AssociadoLojaPage.vue

@@ -0,0 +1,430 @@
+<template>
+  <div class="loja-page">
+    <DefaultHeaderPage class="q-px-md" />
+
+    <div class="q-px-md q-pb-md">
+
+      <!-- Search -->
+      <div class="q-mb-md">
+        <q-input
+          v-model="search"
+          :placeholder="$t('associado.filter_by_category')"
+          outlined
+          dense
+          bg-color="white"
+          clearable
+        >
+          <template #prepend>
+            <q-icon name="mdi-magnify" />
+          </template>
+        </q-input>
+      </div>
+
+      <!-- Category chips -->
+      <div class="chips-row q-mb-md">
+        <div
+          :class="['cat-chip', activeTab === 'all' ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = 'all'"
+        >
+          {{ $t('loja.filter_all') }}
+        </div>
+        <div
+          v-for="cat in storeCategories"
+          :key="cat.value"
+          :class="['cat-chip', activeTab === cat.value ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = cat.value"
+        >
+          {{ cat.label }}
+        </div>
+        <div
+          :class="['cat-chip', activeTab === 'recent' ? 'cat-chip--selected' : 'cat-chip--default']"
+          @click="activeTab = 'recent'"
+        >
+          {{ $t('loja.filter_recent') }}
+        </div>
+        <div class="cat-chip cat-chip--disabled">
+          {{ $t('loja.filter_others') }}
+        </div>
+      </div>
+
+      <!-- Loading -->
+      <div v-if="loading" class="flex flex-center q-py-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <!-- Empty -->
+      <div v-else-if="pagedItems.length === 0" class="text-center text-grey q-py-xl">
+        {{ $t('http.errors.no_records_found') }}
+      </div>
+
+      <!-- Grid -->
+      <div v-else class="items-list">
+        <div
+          v-for="item in pagedItems"
+          :key="item.id"
+        >
+          <q-card flat bordered class="store-card">
+            <q-card-section class="q-pa-sm">
+              <div class="row no-wrap store-card-inner">
+
+                <!-- LEFT -->
+                <div class="col column justify-between q-pr-sm">
+                  <div>
+                    <!-- Category badge -->
+                    <div class="q-mb-xs">
+                      <span v-if="item.category?.name" class="badge-category">
+                        {{ item.category.name }}
+                      </span>
+                    </div>
+
+                    <div class="text-subtitle1 text-weight-bold text-violet-medium q-mb-xs leading-tight">
+                      {{ item.name }}
+                    </div>
+
+                    <div v-if="item.description" class="text-caption text-grey-7 q-mb-sm ellipsis-2-lines">
+                      {{ item.description }}
+                    </div>
+
+                    <!-- Variations -->
+                    <template v-if="item.variations?.length">
+                      <div class="text-caption text-grey-6 q-mb-xs">
+                        {{ variationTypeLabel(item) }}
+                      </div>
+                      <div class="row q-gutter-xs q-mb-sm">
+                        <div
+                          v-for="v in item.variations"
+                          :key="v.id"
+                          :class="[
+                            'variation-tag',
+                            activeVariation(item)?.id === v.id
+                              ? 'variation-tag--selected'
+                              : 'variation-tag--default'
+                          ]"
+                          @click="selectVariation(item, v)"
+                        >
+                          {{ v.variation_label }}
+                        </div>
+                      </div>
+                    </template>
+                  </div>
+
+                  <!-- Price -->
+                  <div class="column items-start q-mt-sm">
+                    <span v-if="item.price" class="text-caption text-grey-5 text-strike">
+                      R$ {{ formatPrice(item.price) }}
+                    </span>
+                    <span class="text-subtitle1 text-weight-bold text-violet-dark">
+                      R$ {{ formatPrice(displayPrice(item)) }}
+                    </span>
+                  </div>
+                </div>
+
+                <!-- RIGHT -->
+                <div class="store-card-right column items-stretch no-wrap">
+                  <div class="store-card-image col">
+                    <img
+                      v-if="item.media?.length"
+                      :src="item.media[0].url"
+                      :alt="item.name"
+                      class="store-card-img"
+                    />
+                    <q-icon
+                      v-else
+                      name="mdi-image-off-outline"
+                      size="32px"
+                      color="grey-4"
+                      class="absolute-center"
+                    />
+                  </div>
+
+                  <!-- Button -->
+                  <q-btn
+                    unelevated
+                    size="sm"
+                    class="q-mt-xs"
+                    :class="item.user_interested ? 'btn-interested' : 'btn-gradient'"
+                    :icon="item.user_interested ? 'mdi-check' : 'mdi-thumb-up'"
+                    :label="item.user_interested ? $t('loja.btn_interested') : $t('loja.btn_want')"
+                    style="width: 100%"
+                    padding="5px 8px"
+                    @click="toggleInterest(item)"
+                  />
+                </div>
+
+              </div>
+            </q-card-section>
+          </q-card>
+        </div>
+      </div>
+
+      <!-- Pagination -->
+      <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
+        <q-pagination
+          v-model="currentPage"
+          :max="totalPages"
+          boundary-links
+          color="violet-normal"
+        />
+      </div>
+
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { normalizeString } from "src/helpers/utils";
+import { getStoreItemsAssociado, toggleInterestAssociado as apiToggleInterest } from "src/api/storeItem";
+import { getCategories } from "src/api/category";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const items = ref([]);
+const loading = ref(true);
+const search = ref("");
+const activeTab = ref("all");
+const storeCategories = ref([]);
+const currentPage = ref(1);
+const perPage = 12;
+
+const selectedVariations = ref({});
+
+const selectVariation = (item, variation) => {
+  selectedVariations.value[item.id] = variation;
+};
+
+const activeVariation = (item) =>
+  selectedVariations.value[item.id] ?? item.variations?.[0] ?? null;
+
+const displayPrice = (item) => {
+  const v = activeVariation(item);
+  if (v?.variation_value != null) return v.variation_value;
+  return item.associate_price ?? item.price;
+};
+
+const variationTypeLabel = (item) => {
+  const type = item.variations?.[0]?.variation_type;
+  if (type === "tamanho") return t("loja.variation_tamanho");
+  if (type === "cor")     return t("loja.variation_cor");
+  if (type === "modelo")  return t("loja.variation_modelo");
+  return "";
+};
+
+const formatPrice = (price) =>
+  Number(price).toLocaleString("pt-BR", { minimumFractionDigits: 2 });
+
+const filteredItems = computed(() => {
+  let list = items.value;
+
+  if (activeTab.value === "recent") {
+    const cutoff = new Date().setDate(new Date().getDate() - 30);
+    list = list.filter((i) => new Date(i.created_at).getTime() >= cutoff);
+  } else if (activeTab.value !== "all") {
+    list = list.filter((i) => i.category_id === activeTab.value);
+  }
+
+  if (search.value) {
+    const needle = normalizeString(search.value);
+    list = list.filter((i) => normalizeString(i.name || "").includes(needle));
+  }
+
+  return list;
+});
+
+const totalPages = computed(() => Math.ceil(filteredItems.value.length / perPage));
+
+const pagedItems = computed(() => {
+  const start = (currentPage.value - 1) * perPage;
+  return filteredItems.value.slice(start, start + perPage);
+});
+
+watch([search, activeTab], () => {
+  currentPage.value = 1;
+});
+
+const toggleInterest = async (item) => {
+  try {
+    const result = await apiToggleInterest(item.id);
+    item.user_interested = result.interested;
+    item.interests_count = (item.interests_count ?? 0) + (result.interested ? 1 : -1);
+    $q.notify({
+      type: "positive",
+      message: result.interested
+        ? t("loja.btn_interested")
+        : t("http.success"),
+    });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  }
+};
+
+onMounted(async () => {
+  try {
+    const [itemsData, catsData] = await Promise.all([
+      getStoreItemsAssociado(),
+      getCategories("store"),
+    ]);
+    items.value = itemsData;
+    storeCategories.value = catsData.map((c) => ({ label: c.name, value: c.id }));
+    itemsData.forEach((item) => {
+      if (item.variations?.length) {
+        selectedVariations.value[item.id] = item.variations[0];
+      }
+    });
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.loja-page {
+  background: vars.$violet-light;
+  min-height: calc(100dvh - 68px);
+  box-sizing: border-box;
+  overflow-x: hidden;
+}
+
+.chips-row {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  gap: 8px;
+}
+
+.items-list {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.store-card {
+  width: 100%;
+  box-sizing: border-box;
+  transition: box-shadow 0.2s;
+  border-radius: 12px !important;
+  background: white !important;
+  overflow: hidden;
+  &:hover { box-shadow: 0 4px 16px rgba(102, 29, 117, 0.12); }
+}
+
+.store-card-inner {
+  min-height: 0;
+}
+
+.store-card-right {
+  width: 110px;
+  min-width: 110px;
+  flex-shrink: 0;
+}
+
+.store-card-image {
+  position: relative;
+  border-radius: 8px;
+  overflow: hidden;
+  border: 1px solid rgba(102, 29, 117, 0.15);
+  background: #f5f0f7;
+  min-height: 110px;
+}
+
+.store-card-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  display: block;
+}
+
+.ellipsis-2-lines {
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  line-clamp: 2;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+// --- category chips ---
+.cat-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 5px;
+  font-size: 12px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default  { background: #c9a3dc; color: #fff; }
+  &--selected { background: #4d1658; color: #fff; }
+  &--disabled {
+    background: #c9a3dc;
+    color: rgba(255, 255, 255, 0.5);
+    cursor: default;
+    pointer-events: none;
+  }
+}
+
+// --- category badge on card ---
+.badge-category {
+  display: inline-flex;
+  align-items: center;
+  background: #ede0f5;
+  color: #4d1658;
+  font-size: 10px;
+  font-weight: 500;
+  padding: 2px 8px;
+  border-radius: 20px;
+  line-height: 1.4;
+}
+
+// --- variation tags ---
+.variation-tag {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 28px;
+  height: 26px;
+  padding: 0 7px;
+  border-radius: 4px;
+  font-size: 11px;
+  font-weight: 500;
+  cursor: pointer;
+  user-select: none;
+  transition: background 0.15s, color 0.15s;
+
+  &--default  { background: #ede0f5; color: #4d1658; }
+  &--selected { background: #4d1658; color: #ede0f5; }
+}
+
+// --- buttons ---
+.btn-gradient {
+  background: linear-gradient(90deg, #4d1658 0%, #8b30a5 100%) !important;
+  color: white !important;
+  border-radius: 8px !important;
+}
+
+.btn-gradient :deep(.q-icon) { color: white !important; }
+
+.btn-interested {
+  background: #22c55e !important;
+  color: white !important;
+  border-radius: 8px !important;
+}
+
+.btn-interested :deep(.q-icon) { color: white !important; }
+
+// --- text colors ---
+.text-violet-dark   { color: #4d1658; }
+.text-violet-medium { color: #7b2d97; }
+.leading-tight      { line-height: 1.25; }
+</style>

+ 250 - 0
src/pages/associado/notificacoes/NotificacoesAssociadoPage.vue

@@ -0,0 +1,250 @@
+<template>
+  <div class="notificacoes-page">
+    <DefaultHeaderPage class="q-px-md"/>
+
+    <div v-if="loading" class="flex flex-center q-pa-xl ">
+      <q-spinner color="violet-normal" size="50px" />
+    </div>
+
+    <div v-else-if="pagedItems.length === 0" class="flex flex-center q-pa-xl text-grey-6 q-px-md">
+      {{ $t('notification.empty') }}
+    </div>
+
+    <div v-else class="notif-list q-px-md">
+      <div
+        v-for="item in pagedItems"
+        :key="item.id"
+        class="notif-item"
+        :class="{ 'notif-item--unread': !item.read }"
+        @click="onRead(item)"
+      >
+        <!-- Left: small image + date below -->
+        <div class="notif-item__left">
+          <div class="notif-item__img-wrap">
+            <img
+              v-if="imageUrl(item)"
+              :src="imageUrl(item)"
+              alt=""
+              class="notif-item__img"
+            />
+            <div v-else class="notif-item__placeholder flex flex-center">
+              <q-icon
+                name="mdi-bell-outline"
+                size="24px"
+                :color="item.read ? 'grey-4' : 'violet-normal'"
+              />
+            </div>
+          </div>
+          <span class="notif-item__date text-violet-normal">{{ formatDate(item.created_at) }}</span>
+        </div>
+
+        <!-- Right: title + message -->
+        <div class="notif-item__content ">
+          <div class="notif-item__title" :class="{ 'notif-item__title--read': item.read }">
+            {{ item.notification?.title }}
+          </div>
+          <div class="notif-item__message">{{ item.notification?.message }}</div>
+        </div>
+
+        <!-- Unread dot top-right -->
+        <q-badge
+          v-if="!item.read"
+          color="violet-normal"
+          rounded
+          class="notif-item__dot"
+        />
+      </div>
+    </div>
+
+    <div v-if="totalPages > 1" class="flex flex-center q-mt-lg">
+      <q-pagination
+        v-model="currentPage"
+        :max="totalPages"
+        :max-pages="6"
+        boundary-numbers
+        color="violet-normal"
+        active-color="violet-normal"
+        direction-links
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useI18n } from "vue-i18n";
+import { getMyNotificationsAssociado, markNotificationAsReadAssociado } from "src/api/notification";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const { t } = useI18n();
+
+const loading = ref(true);
+const notifications = ref([]);
+const currentPage = ref(1);
+const PER_PAGE = 20;
+
+const totalPages = computed(() => Math.max(1, Math.ceil(notifications.value.length / PER_PAGE)));
+
+const pagedItems = computed(() => {
+  const start = (currentPage.value - 1) * PER_PAGE;
+  return notifications.value.slice(start, start + PER_PAGE);
+});
+
+const imageUrl = (item) => {
+  const media = item.notification?.media?.[0]?.url;
+  if (media) return media;
+  const direct = item.notification?.image_url;
+  if (!direct) return null;
+  return direct.startsWith("http") ? direct : (process.env.API_URL + direct);
+};
+
+const formatDate = (dateStr) => {
+  if (!dateStr) return "";
+  const d = new Date(dateStr);
+  const today = new Date();
+  const isToday =
+    d.getDate() === today.getDate() &&
+    d.getMonth() === today.getMonth() &&
+    d.getFullYear() === today.getFullYear();
+  if (isToday) return t("common.terms.today");
+  return d.toLocaleDateString("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric" });
+};
+
+const onRead = async (item) => {
+  if (item.read) return;
+  try {
+    await markNotificationAsReadAssociado(item.id);
+    item.read = true;
+  } catch (e) {
+    console.error(e);
+  }
+};
+
+onMounted(async () => {
+  try {
+    notifications.value = await getMyNotificationsAssociado();
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+});
+</script>
+
+<style scoped lang="scss">
+@use "src/css/quasar.variables.scss" as vars;
+
+.notificacoes-page {
+  background: vars.$violet-light;
+  min-height: calc(100dvh - 68px);
+  box-sizing: border-box;
+  overflow-x: hidden;
+  padding-bottom: 16px;
+}
+
+.notif-list {
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  padding-top: 8px;
+}
+
+.notif-item {
+  background: white;
+  border-radius: 12px;
+  padding: 14px;
+  display: flex;
+  flex-direction: row;
+  align-items: flex-start;
+  gap: 14px;
+  cursor: pointer;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
+  position: relative;
+  overflow: hidden;
+  transition: box-shadow 0.2s;
+
+  &:hover {
+    box-shadow: 0 4px 14px rgba(102, 29, 117, 0.14);
+  }
+
+  &--unread {
+    border-left: 3px solid vars.$violet-normal;
+  }
+
+  &__left {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    gap: 6px;
+    flex-shrink: 0;
+    width: 68px;
+  }
+
+  &__img-wrap {
+    width: 68px;
+    height: 68px;
+    border-radius: 10px;
+    overflow: hidden;
+    background: vars.$violet-light;
+  }
+
+  &__img {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+    display: block;
+  }
+
+  &__placeholder {
+    width: 100%;
+    height: 100%;
+    background: vars.$violet-light;
+  }
+
+  &__date {
+    font-size: 10px;
+    color: vars.$violet-normal;
+    text-align: center;
+    line-height: 1.3;
+  }
+
+  &__content {
+    flex: 1;
+    min-width: 0;
+    padding-right: 10px;
+  }
+
+  &__title {
+    font-size: 14px;
+    font-weight: 600;
+    color: vars.$violet-normal;
+    line-height: 1.3;
+    margin-bottom: 5px;
+
+    &--read {
+    color: vars.$violet-dark;
+    }
+  }
+
+  &__message {
+    font-size: 12px;
+    color: vars.$color-text-2;
+    line-height: 1.5;
+    display: -webkit-box;
+    -webkit-line-clamp: 3;
+    line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  &__dot {
+    position: absolute;
+    top: 14px;
+    right: 14px;
+    width: 8px;
+    height: 8px;
+    min-height: unset;
+    padding: 0;
+  }
+}
+</style>

+ 333 - 0
src/pages/associado/profile/ProfilePage.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="profile-page">
+    <DefaultHeaderPage class="q-px-md" />
+
+    <q-card flat class="profile-card q-mt-sm q-ma-md">
+
+        <div class="profile-header row items-center q-pa-lg">
+          <div class="profile-avatar-wrap q-mr-md">
+            <q-avatar size="76px" class="profile-avatar">
+              <q-icon name="mdi-account" size="44px" color="white" />
+            </q-avatar>
+            <q-btn
+              round
+              unelevated
+              size="xs"
+              icon="mdi-pencil-outline"
+              class="profile-avatar-edit"
+              color="white"
+              text-color="violet-normal"
+              disable
+            />
+          </div>
+          <div class="column q-gutter-xs">
+            <span class="text-white text-h6 text-weight-bold">{{ user?.name || '—' }}</span>
+            <div>
+              <q-badge
+                :color="statusBadgeColor"
+                class="text-capitalize"
+                style="font-size:11px; padding: 3px 8px; border-radius: 20px;"
+              >
+                {{ statusLabel }}
+              </q-badge>
+            </div>
+            <span class="text-white profile-validity text-caption">
+              {{ $t('associado.validity') }} {{ user?.expiry_date || '—' }}
+            </span>
+          </div>
+        </div>
+
+        <div class="q-pa-md column">
+          <div
+            v-for="field in infoFields"
+            :key="field.label"
+            class="info-row row items-center no-wrap q-my-sm q-px-md"
+          >
+            <q-icon :name="field.icon" size="22px" class="info-icon q-mr-md" />
+            <div class="column">
+              <span class="info-label">{{ field.label }}</span>
+              <span class="info-value">{{ field.value || '—' }}</span>
+            </div>
+          </div>
+        </div>
+
+    </q-card>
+
+    <!-- Dependentes — fora do card branco, sobre fundo violet-light -->
+    <div class="dependents-section q-ma-md">
+      <div class="row items-center justify-between q-mb-sm">
+        <span class="text-h6 text-weight-bold text-violet-normal">
+          {{ $t('associado.dependents') }}
+        </span>
+        <q-btn
+          unelevated
+          size="md"
+          icon="mdi-plus"
+          color="violet-normal"
+          text-color="white"
+          @click="onAddDependent"
+        />
+      </div>
+
+      <div v-if="dependents.length > 0" class="column" style="gap: 8px;">
+        <div
+          v-for="dep in dependents"
+          :key="dep.id"
+          class="dependent-card row items-center no-wrap"
+        >
+          <q-avatar size="40px" class="dependent-avatar q-mr-md" text-color="white">
+            {{ initials(dep.name) }}
+          </q-avatar>
+
+          <div class="col column">
+            <span class="dependent-name">{{ dep.name }}</span>
+            <span class="dependent-kinship">
+              {{ $t(`associado.kinship_options.${dep.kinship}`) }}
+            </span>
+          </div>
+
+          <q-chip
+            :color="dependentStatusColor(dep.status)"
+            outline
+            dense
+            class="text-capitalize dependent-status-chip"
+          >
+            {{ dependentStatusLabel(dep.status) }}
+          </q-chip>
+        </div>
+      </div>
+
+      <div v-else class="text-center q-py-md text-grey">
+        {{ $t('associado.no_dependents') }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, defineAsyncComponent } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { userStore } from "src/stores/user";
+import { getDependentsByUser } from "src/api/profile";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const AddEditDependentDialog = defineAsyncComponent(
+  () => import("src/pages/associado/profile/components/AddEditDependentDialog.vue"),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const store = userStore();
+
+const user = ref(null);
+const dependents = ref([]);
+
+const statusBadgeColor = computed(() => {
+  switch (user.value?.status) {
+    case "active":   return "positive";
+    case "inactive": return "warning";
+    case "canceled": return "negative";
+    default:         return "grey";
+  }
+});
+
+const statusLabel = computed(() => {
+  const map = { active: t("common.status.active"), inactive: t("common.status.inactive"), canceled: t("common.status.canceled") };
+  return map[user.value?.status] ?? user.value?.status ?? "—";
+});
+
+const infoFields = computed(() => [
+  { icon: "mdi-card-account-details-outline", label: t("common.terms.cpf"),          value: user.value?.cpf },
+  { icon: "mdi-email-outline",                label: "E-mail",                        value: user.value?.email },
+  { icon: "mdi-briefcase-outline",            label: t("associado.position"),         value: user.value?.position?.name },
+  { icon: "mdi-domain",                       label: t("associado.sector"),           value: user.value?.sector?.name },
+  { icon: "mdi-card-text-outline",            label: t("associado.registration"),     value: user.value?.registration },
+  { icon: "mdi-calendar-outline",             label: t("associado.admission_date"),   value: user.value?.admission_date },
+]);
+
+const initials = (name) => {
+  if (!name) return "?";
+  return name.split(" ").slice(0, 2).map((w) => w[0]).join("").toUpperCase();
+};
+
+const dependentStatusColor = (status) => {
+  switch (status) {
+    case "approved": return "positive";
+    case "refused":  return "negative";
+    case "pending":  return "warning";
+    default:         return "grey";
+  }
+};
+
+const dependentStatusLabel = (status) => {
+  const map = {
+    approved: t("associado.dependent_statuses.approved"),
+    refused:  t("associado.dependent_statuses.refused"),
+    pending:  t("associado.dependent_statuses.pending"),
+  };
+  return map[status] ?? status ?? "—";
+};
+
+const loadUser = async () => {
+  await store.fetchUser();
+  user.value = store.user;
+};
+
+const loadDependents = async () => {
+  if (!user.value?.id) return;
+  try {
+    dependents.value = await getDependentsByUser(user.value.id);
+  } catch {
+    dependents.value = [];
+  }
+};
+
+onMounted(async () => {
+  await loadUser();
+  await loadDependents();
+});
+
+const onAddDependent = () => {
+  $q.dialog({
+    component: AddEditDependentDialog,
+    componentProps: {
+      userId: user.value.id,
+      title: () => t("common.actions.add") + " " + t("associado.dependent"),
+    },
+  }).onOk(() => loadDependents());
+};
+
+// const onEditDependent = (dep) => {
+//   $q.dialog({
+//     component: AddEditDependentDialog,
+//     componentProps: {
+//       dependent: dep,
+//       userId: user.value.id,
+//       title: () => t("common.actions.edit") + " " + t("associado.dependent"),
+//     },
+//   }).onOk(() => loadDependents());
+// };
+
+// const onDeleteDependent = (dep) => {
+//   $q.dialog({
+//     title: t("common.ui.messages.confirm_action"),
+//     message: t("common.ui.messages.are_you_sure_delete"),
+//     cancel: true,
+//     persistent: true,
+//   }).onOk(async () => {
+//     try {
+//       await deleteDependent(dep.id);
+//       await loadDependents();
+//       $q.notify({ type: "positive", message: t("http.success") });
+//     } catch {
+//       $q.notify({ type: "negative", message: t("http.errors.failed") });
+//     }
+//   });
+// };
+</script>
+
+<style lang="scss" scoped>
+@use "src/css/quasar.variables.scss" as *;
+
+.profile-page {
+  background: $violet-light;
+  min-height: 100%;
+}
+
+.profile-card {
+  background: white !important;
+  border-radius: 12px;
+  overflow: hidden;
+  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+}
+
+.profile-header {
+  background: linear-gradient(135deg, $violet-normal 0%, $violet-dark 100%);
+}
+
+.profile-avatar-wrap {
+  position: relative;
+  flex-shrink: 0;
+}
+
+.profile-avatar {
+  background: rgba(255, 255, 255, 0.18) !important;
+  border: 2px solid rgba(255, 255, 255, 0.45);
+}
+
+.profile-avatar-edit {
+  position: absolute;
+  bottom: -2px;
+  right: -2px;
+  width: 22px;
+  height: 22px;
+  min-height: 22px;
+}
+
+.profile-validity {
+  opacity: 0.85;
+}
+
+.info-row {
+  background: $violet-light;
+  border-radius: 8px;
+  padding: 12px 16px;
+}
+
+.info-icon {
+  color: $violet-normal;
+  flex-shrink: 0;
+}
+
+.info-label {
+  font-size: 11px;
+  color: $color-text-2;
+  line-height: 1.2;
+}
+
+.info-value {
+  font-size: 14px;
+  color: $color-text;
+  font-weight: 500;
+  line-height: 1.4;
+}
+
+.dependents-section {
+  padding-bottom: 16px;
+}
+
+.dependent-card {
+  background: white;
+  border-radius: 10px;
+  padding: 10px 12px;
+  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.07);
+}
+
+.dependent-avatar {
+  background: $violet-normal !important;
+  font-size: 14px;
+  font-weight: 700;
+  flex-shrink: 0;
+}
+
+.dependent-name {
+  font-size: 14px;
+  font-weight: 600;
+  color: $color-text;
+  line-height: 1.3;
+}
+
+.dependent-kinship {
+  font-size: 12px;
+  color: $color-text-2;
+  line-height: 1.2;
+}
+
+.dependent-status-chip {
+  font-size: 11px !important;
+  font-weight: 600;
+  flex-shrink: 0;
+}
+</style>

+ 118 - 0
src/pages/associado/profile/components/AddEditDependentDialog.vue

@@ -0,0 +1,118 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 500px">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm q-pt-none">
+          <DefaultInput
+            v-model="form.name"
+            v-model:error="validationErrors.name"
+            :rules="[inputRules.required]"
+            :label="$t('common.terms.name')"
+            class="col-12"
+          />
+          <DefaultSelect
+            v-model="form.kinship"
+            v-model:error="validationErrors.kinship"
+            :rules="[inputRules.required]"
+            :label="$t('associado.kinship')"
+            :options="kinshipOptions"
+            emit-value
+            map-options
+            class="col-md-6 col-12"
+          />
+          <DefaultSelect
+            v-model="form.status"
+            v-model:error="validationErrors.status"
+            :rules="[inputRules.required]"
+            :label="$t('common.terms.status')"
+            :options="statusOptions"
+            emit-value
+            map-options
+            class="col-md-6 col-12"
+          />
+        </q-card-section>
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            outline
+            color="negative"
+            :label="$t('common.actions.cancel')"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            color="primary"
+            :label="dependent ? $t('common.actions.save') : $t('common.actions.add')"
+            type="submit"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, useTemplateRef } from "vue";
+import { useInputRules } from "src/composables/useInputRules";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import { createDependent, updateDependent } from "src/api/profile";
+import { useFormUpdateTracker } from "src/composables/useFormUpdateTracker";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dependent, userId, title } = defineProps({
+  dependent: { type: Object, default: null },
+  userId: { type: Number, required: true },
+  title: { type: Function, default: () => "" },
+});
+
+const { t } = useI18n();
+const { inputRules } = useInputRules();
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const formRef = useTemplateRef("formRef");
+
+const kinshipOptions = computed(() => [
+  { label: t("associado.kinship_options.conjuge"), value: "conjuge" },
+  { label: t("associado.kinship_options.filho"), value: "filho" },
+  { label: t("associado.kinship_options.filha"), value: "filha" },
+  { label: t("associado.kinship_options.pai"), value: "pai" },
+  { label: t("associado.kinship_options.mae"), value: "mae" },
+  { label: t("associado.kinship_options.irmao"), value: "irmao" },
+  { label: t("associado.kinship_options.irma"), value: "irma" },
+  { label: t("associado.kinship_options.outro"), value: "outro" },
+]);
+
+const statusOptions = computed(() => [
+  { label: t("associado.dependent_statuses.approved"), value: "approved" },
+  { label: t("associado.dependent_statuses.refused"),  value: "refused" },
+  { label: t("associado.dependent_statuses.pending"),  value: "pending" },
+]);
+
+const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
+  name: dependent?.name ?? "",
+  kinship: dependent?.kinship ?? "",
+  status: dependent?.status ?? "pending",
+  responsible_user_id: userId,
+});
+
+const { loading, validationErrors, execute: submitForm } = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef,
+});
+
+const onOKClick = async () => {
+  if (dependent) {
+    await submitForm(() => updateDependent(dependent.id, getUpdatedFields.value));
+  } else {
+    await submitForm(() => createDependent({ ...form }));
+  }
+};
+</script>

+ 95 - 57
src/pages/login/LoginPage.vue

@@ -1,65 +1,63 @@
 <template>
-  <q-page class="column">
-    <div
-      flat
-      class="column justify-around items-center flex-grow full-width full-height z-top frosted-glass"
-    >
-      <div class="column flex-center full-width q-px-md">
-        <q-img :src="Logo" style="max-width: 650px" />
-        <div class="text-h5 q-mt-xl">{{ $t("auth.login") }}</div>
-      </div>
+  <q-page class="login-page column items-center justify-center">
+    <div class="login-overlay" />
+
+    <div class="login-card column items-center">
+      <div class="column items-center fields-card">
+        <q-img :src="Logo" class="login-logo q-mb-xl" />
+
+        <q-form
+          ref="formRef"
+          class="full-width"
+          autocorrect="off"
+          autocapitalize="off"
+          autocomplete="off"
+          spellcheck="false"
+          @submit="onSubmit"
+        >
+          <DefaultInput
+            v-model="form.email"
+            v-model:error="validationErrors.email"
+            type="email"
+            autofocus
+            :label="$t('common.terms.email')"
+            :rules="[inputRules.required, inputRules.email]"
+          >
+            <template #append>
+              <q-icon name="mdi-account-outline" color="grey-5" />
+            </template>
+          </DefaultInput>
+
+          <DefaultPasswordInput
+            v-model="form.password"
+            v-model:error="validationErrors.password"
+            :rules="[inputRules.required, inputRules.min(6)]"
+            :label="$t('common.terms.password')"
+          />
+
+          <q-checkbox
+            v-model="checkbox"
+            size="xs"
+            :label="$t('auth.remember_email') || 'Lembrar e-mail'"
+            class="q-mb-md"
+            style="margin-left: -6px"
+          />
 
-      <q-form
-        ref="formRef"
-        class="full-width q-pa-md q-pb-xl"
-        style="max-width: 400px"
-        autocorrect="off"
-        autocapitalize="off"
-        autocomplete="off"
-        spellcheck="false"
-        @submit="onSubmit"
-      >
-        <DefaultInput
-          v-model="form.email"
-          v-model:error="validationErrors.email"
-          type="email"
-          lazy-rules
-          autofocus
-          :label="$t('common.terms.email')"
-          :rules="[inputRules.required, inputRules.email]"
-        />
-        <DefaultPasswordInput
-          v-model="form.password"
-          v-model:error="validationErrors.password"
-          :rules="[inputRules.required, inputRules.min(6)]"
-          :label="$t('common.terms.password')"
-        />
-        <q-checkbox
-          v-model="checkbox"
-          size="xs"
-          label="Lembrar email"
-          class="q-mb-md"
-          style="margin-left: -6px"
-        />
-        <div>
           <q-btn
-            class="full-width"
-            color="primary"
+            class="full-width q-mt-sm login-btn"
+            color="violet-normal"
             :label="$t('auth.sign-in')"
-            size="md"
-            padding="sm"
             type="submit"
+            unelevated
             :loading
           >
             <template #loading>
               <q-spinner />
             </template>
           </q-btn>
-        </div>
-        <div style="height: 160px"></div>
-      </q-form>
+        </q-form>
+      </div>
     </div>
-    <WavePattern class="absolute-top" />
   </q-page>
 </template>
 
@@ -71,14 +69,12 @@ import { useRouter } from "vue-router";
 import { useInputRules } from "src/composables/useInputRules";
 import { useSubmitHandler } from "src/composables/useSubmitHandler";
 
-import Logo from "src/assets/logo.png";
+import Logo from "src/assets/logo_serprati.svg";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
-import WavePattern from "./component/WavePattern.vue";
 
 const router = useRouter();
 const $q = useQuasar();
-
 const { inputRules } = useInputRules();
 const { login } = useAuth();
 
@@ -89,8 +85,8 @@ const {
   validationErrors,
   execute: submitForm,
 } = useSubmitHandler({
-  onSuccess: () => router.push({ name: "HomePage" }),
-  formRef: formRef,
+  onSuccess: () => router.push({ name: "CarteirinhaPage" }),
+  formRef,
 });
 
 const form = ref({
@@ -124,11 +120,53 @@ onBeforeMount(() => {
 </script>
 
 <style lang="scss" scoped>
+@use "sass:map";
+@use "src/css/quasar.variables.scss" as *;
+
 .login-page {
+  min-height: 100dvh;
+  background-image: url("src/assets/pessoas_fundo.jpg");
+  background-size: cover;
+  background-position: center;
   position: relative;
 }
 
-.frosted-glass {
-  backdrop-filter: blur(60px);
+.login-overlay {
+  position: absolute;
+  inset: 0;
+  background: rgba(74, 20, 140, 0.48);
+  z-index: 0;
+}
+
+.login-card {
+  z-index: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: calc(100% - 32px);
+  min-height: calc(100dvh - 32px);
+  background: rgba(255, 255, 255, 0.747);
+  border-radius: 16px;
+  box-shadow: 0 4px 32px rgba(0, 0, 0, 0.15);
+  padding-bottom: env(safe-area-inset-bottom);
+}
+
+.fields-card {
+  width: 100%;
+  max-width: 400px;
+  padding: 32px;
+  border-radius: 12px;
+}
+
+.login-logo {
+  width: 280px;
+  max-width: 100%;
+}
+
+.login-btn {
+  height: 44px;
+  font-size: 15px;
+  font-weight: 600;
+  letter-spacing: 0.5px;
 }
 </style>

+ 13 - 1
src/router/index.js

@@ -11,6 +11,8 @@ import { permissionStore } from "src/stores/permission";
 import { i18n } from "src/boot/i18n";
 import { userStore } from "src/stores/user";
 import { useAuth } from "src/composables/useAuth";
+import { Capacitor } from "@capacitor/core";
+import { App as CapacitorApp } from "@capacitor/app";
 /*
  * If not building with SSR mode, you can
  * directly export the Router instantiation;
@@ -48,7 +50,7 @@ export default defineRouter(function (/* { store, ssrContext } */) {
     }
     if (userStore().accessToken) {
       if (to.name == "LoginPage") {
-        return next({ name: "HomePage" });
+        return next({ name: "CarteirinhaPage" });
       }
     }
     if (to.meta.requiredPermission) {
@@ -65,5 +67,15 @@ export default defineRouter(function (/* { store, ssrContext } */) {
     return next();
   });
 
+  if (Capacitor.isNativePlatform()) {
+    CapacitorApp.addListener("backButton", ({ canGoBack }) => {
+      if (!canGoBack) {
+        CapacitorApp.exitApp();
+      } else {
+        Router.go(-1);
+      }
+    });
+  }
+
   return Router;
 });

+ 8 - 37
src/router/routes.js

@@ -14,30 +14,13 @@ const routes = [
     children: [
       {
         path: "",
-        name: "HomePage",
-        component: () => import("src/pages/home/HomePage.vue"),
+        name: "CarteirinhaPage",
+        component: () => import("pages/associado/carteirinha/CarteirinhaPage.vue"),
         meta: {
+          title: { value: "ui.navigation.carteirinha", translate: true },
+          description: { value: "page.associado.carteirinha.description", translate: true },
           requireAuth: true,
-        },
-      },
-      {
-        path: "/dashboard",
-        name: "DashboardPage",
-        component: () => import("pages/dashboard/DashboardPage.vue"),
-        meta: {
-          title: { value: "Dashboard" },
-          description: {
-            value: "page.system-dashboard.description",
-            translate: true,
-          },
-          requireAuth: true,
-          requiredPermission: "dashboard",
-          breadcrumbs: [
-            {
-              name: "DashboardPage",
-              title: "Dashboard",
-            },
-          ],
+          breadcrumbs: [{ name: "CarteirinhaPage", title: "ui.navigation.carteirinha", translate: true }],
         },
       },
       {
@@ -45,22 +28,10 @@ const routes = [
         name: "SystemVersionsPage",
         component: () => import("pages/version/SystemVersionsPage.vue"),
         meta: {
-          title: {
-            value: "ui.navigation.versions",
-            translate: true,
-          },
-          description: {
-            value: "page.versions.description",
-            translate: true,
-          },
+          title: { value: "ui.navigation.versions", translate: true },
+          description: { value: "page.versions.description", translate: true },
           requireAuth: true,
-          breadcrumbs: [
-            {
-              name: "SystemVersionsPage",
-              title: "ui.navigation.versions",
-              translate: true,
-            },
-          ],
+          breadcrumbs: [{ name: "SystemVersionsPage", title: "ui.navigation.versions", translate: true }],
         },
       },
       ...sub_routes,

+ 68 - 0
src/router/routes/associado.route.js

@@ -0,0 +1,68 @@
+export default [
+  {
+    path: "/associado/perfil",
+    name: "MeuPerfilPage",
+    component: () => import("pages/associado/profile/ProfilePage.vue"),
+    meta: {
+      title: { value: "ui.navigation.meu_perfil", translate: true },
+      description: { value: "page.associado.profile.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "MeuPerfilPage", title: "ui.navigation.meu_perfil", translate: true }],
+    },
+  },
+  {
+    path: "/associado/convenios",
+    name: "ConveniosPage",
+    component: () => import("pages/associado/convenios/ConveniosPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.convenios", translate: true },
+      description: { value: "page.associado.convenios.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "ConveniosPage", title: "ui.navigation.convenios", translate: true }],
+    },
+  },
+  {
+    path: "/associado/loja",
+    name: "AssociadoLojaPage",
+    component: () => import("pages/associado/loja/AssociadoLojaPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.loja", translate: true },
+      description: { value: "page.associado.loja.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "AssociadoLojaPage", title: "ui.navigation.loja", translate: true }],
+    },
+  },
+  {
+    path: "/associado/interesses",
+    name: "AssociadoInteressesPage",
+    component: () => import("pages/associado/interesses/InteressesPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.meus_interesses", translate: true },
+      description: { value: "page.associado.interesses.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "AssociadoInteressesPage", title: "ui.navigation.meus_interesses", translate: true }],
+    },
+  },
+  {
+    path: "/associado/agendamentos",
+    name: "AssociadoAgendamentosPage",
+    component: () => import("pages/associado/agendamentos/AgendamentosPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.meus_agendamentos", translate: true },
+      description: { value: "page.associado.agendamentos.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "AssociadoAgendamentosPage", title: "ui.navigation.meus_agendamentos", translate: true }],
+    },
+  },
+  {
+    path: "/associado/notificacoes",
+    name: "NotificacoesAssociadoPage",
+    component: () => import("pages/associado/notificacoes/NotificacoesAssociadoPage.vue"),
+    meta: {
+      title: { value: "ui.navigation.notifications", translate: true },
+      description: { value: "page.associado.notificacoes.description", translate: true },
+      requireAuth: true,
+      breadcrumbs: [{ name: "NotificacoesAssociadoPage", title: "ui.navigation.notifications", translate: true }],
+    },
+  },
+];

+ 1 - 98
src/router/routes/config.route.js

@@ -1,98 +1 @@
-export default [
-  {
-    path: "/city",
-    name: "CityPage",
-    component: () => import("pages/city/CityPage.vue"),
-    meta: {
-      title: {
-        value: "ui.navigation.city",
-        translate: true,
-      },
-      description: {
-        value: "page.city.description",
-        translate: true,
-      },
-      requireAuth: true,
-      requiredPermission: "config.city",
-      breadcrumbs: [
-        {
-          name: "CityPage",
-          title: "ui.navigation.city",
-          translate: true,
-        },
-      ],
-    },
-  },
-  {
-    path: "/country",
-    name: "CountryPage",
-    component: () => import("pages/country/CountryPage.vue"),
-    meta: {
-      title: {
-        value: "ui.navigation.country",
-        translate: true,
-      },
-      description: {
-        value: "page.country.description",
-        translate: true,
-      },
-      requireAuth: true,
-      requiredPermission: "config.country",
-      breadcrumbs: [
-        {
-          name: "CountryPage",
-          title: "ui.navigation.country",
-          translate: true,
-        },
-      ],
-    },
-  },
-  {
-    path: "/state",
-    name: "StatePage",
-    component: () => import("pages/state/StatePage.vue"),
-    meta: {
-      title: {
-        value: "ui.navigation.state",
-        translate: true,
-      },
-      description: {
-        value: "page.state.description",
-        translate: true,
-      },
-      requireAuth: true,
-      requiredPermission: "config.state",
-      breadcrumbs: [
-        {
-          name: "StatePage",
-          title: "ui.navigation.state",
-          translate: true,
-        },
-      ],
-    },
-  },
-  {
-    path: "/users",
-    name: "UsersPage",
-    component: () => import("pages/users/UsersPage.vue"),
-    meta: {
-      title: {
-        value: "ui.navigation.users",
-        translate: true,
-      },
-      description: {
-        value: "page.users.description",
-        translate: true,
-      },
-      requireAuth: true,
-      requiredPermission: "config.user",
-      breadcrumbs: [
-        {
-          name: "UsersPage",
-          title: "ui.navigation.users",
-          translate: true,
-        },
-      ],
-    },
-  },
-];
+export default [];

+ 66 - 63
src/stores/navigation.js

@@ -1,91 +1,92 @@
 import { defineStore } from "pinia";
-import { computed } from "vue";
+import { ref, computed, watch } from "vue";
+import { Cookies } from "quasar";
 import { permissionStore } from "src/stores/permission";
 
 export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
     {
       type: "single",
-      title: "ui.navigation.home",
-      name: "HomePage",
-      icon: "mdi-home-outline",
+      title: "ui.navigation.carteirinha",
+      name: "CarteirinhaPage",
+      icon: "mdi-card-account-details-outline",
       disable: false,
       permission: true,
     },
     {
       type: "single",
-      title: "ui.navigation.dashboard",
-      name: "DashboardPage",
-      icon: "mdi-poll",
+      title: "ui.navigation.meu_perfil",
+      name: "MeuPerfilPage",
+      icon: "mdi-account-outline",
       disable: false,
       permission: false,
-      permissionScope: "dashboard",
+      permissionScope: "associado.perfil",
     },
     {
-      type: "expansive",
-      title: "ui.navigation.registration",
-      icon: "mdi-plus",
+      type: "single",
+      title: "ui.navigation.convenios",
+      name: "ConveniosPage",
+      icon: "mdi-handshake-outline",
       disable: false,
       permission: false,
-      permissionScope: "config",
-      childrens: [
-        {
-          type: "single",
-          title: "ui.navigation.users",
-          name: "UsersPage",
-          icon: "mdi-account-multiple-outline",
-          disable: false,
-          permission: false,
-          permissionScope: "config.user",
-        },
-        {
-          type: "single",
-          title: "ui.navigation.city",
-          name: "CityPage",
-          icon: "mdi-city-variant-outline",
-          disable: false,
-          permission: false,
-          permissionScope: "config.city",
-        },
-        {
-          type: "single",
-          title: "ui.navigation.country",
-          name: "CountryPage",
-          icon: "mdi-earth",
-          disable: false,
-          permission: false,
-          permissionScope: "config.country",
-        },
-        {
-          type: "single",
-          title: "ui.navigation.state",
-          name: "StatePage",
-          icon: "mdi-map-marker",
-          disable: false,
-          permission: false,
-          permissionScope: "config.state",
-        },
-      ],
+      permissionScope: "associado.convenio",
+    },
+    {
+      type: "single",
+      title: "ui.navigation.loja",
+      name: "AssociadoLojaPage",
+      icon: "mdi-cart-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "associado.loja",
+    },
+    {
+      type: "single",
+      title: "ui.navigation.meus_interesses",
+      name: "AssociadoInteressesPage",
+      icon: "mdi-heart-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "associado.interesses",
+    },
+    {
+      type: "single",
+      title: "ui.navigation.meus_agendamentos",
+      name: "AssociadoAgendamentosPage",
+      icon: "mdi-calendar-clock-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "associado.agendamento",
+    },
+    {
+      type: "single",
+      title: "ui.navigation.notifications",
+      name: "NotificacoesAssociadoPage",
+      icon: "mdi-bell-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "associado.notificacao",
     },
   ]);
 
+  const miniStateCookies = Cookies.get("miniState");
+  const miniState = ref(miniStateCookies === "true" ? true : false);
+
+  const toggleMini = () => {
+    miniState.value = !miniState.value;
+  };
+
+  watch(miniState, (val) => {
+    Cookies.set("miniState", val, { path: "/", sameSite: "Lax" });
+  });
+
   const getNavigationAccess = () => {
     const { getAccess } = permissionStore();
     return navigationStructure
       .map((menu) => {
-        if (menu.type === "expansive") {
-          if (getAccess(menu.permissionScope, "menu")) menu.permission = true;
-          menu.childrens = menu.childrens.filter((children) => {
-            if (!children?.permissionScope) return true;
-            children.permission = getAccess(children.permissionScope, "menu");
-            return children.permission;
-          });
-          return menu.childrens.length > 0 ? menu : null;
-        } else {
-          if (!menu?.permissionScope) return menu;
-          menu.permission = getAccess(menu.permissionScope, "menu");
-          return menu;
-        }
+        if (!menu?.permissionScope) return menu;
+        menu.permission = getAccess(menu.permissionScope, "menu");
+        return menu;
       })
       .filter((menu) => menu !== null);
   };
@@ -94,5 +95,7 @@ export const navigationStore = defineStore("navigation", () => {
 
   return {
     navigationItems,
+    miniState,
+    toggleMini,
   };
 });

+ 29 - 7
src/stores/user.js

@@ -1,21 +1,40 @@
 import { defineStore } from "pinia";
-import { ref } from "vue";
+import { ref, computed } from "vue";
 import { getUser } from "src/api/user";
 
 export const userStore = defineStore("user", () => {
   const user = ref(null);
   const accessToken = ref(null);
-  const isAdmin = ref(false);
+
+  const isAdministrador = computed(
+    () =>
+      user.value?.type?.value === "administrador" ||
+      user.value?.type === "administrador"
+  );
+  const isAssociado = computed(
+    () =>
+      user.value?.type?.value === "associado" ||
+      user.value?.type === "associado"
+  );
+  const isParceiro = computed(
+    () =>
+      user.value?.type?.value === "parceiro" ||
+      user.value?.type === "parceiro"
+  );
+
+  const userTipo = computed(() => {
+    const t = user.value?.type;
+    if (!t) return null;
+    return typeof t === "object" ? t.value : t;
+  });
 
   const setUser = (userData) => {
     user.value = userData;
-    isAdmin.value = userData.type === "admin";
   };
 
   const resetUser = () => {
     user.value = null;
-    isAdmin.value = false;
-    accessToken.value = false;
+    accessToken.value = null;
   };
 
   const fetchUser = async () => {
@@ -25,10 +44,13 @@ export const userStore = defineStore("user", () => {
 
   return {
     user,
-    isAdmin,
+    accessToken,
+    isAdministrador,
+    isAssociado,
+    isParceiro,
+    userTipo,
     setUser,
     resetUser,
     fetchUser,
-    accessToken,
   };
 });

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott