135 Commits c6b4d309ed ... 113a852ece

Autor SHA1 Mensagem Data
  Gustavo Mantovani 113a852ece feat: add track de historico para contrato + restricoes de data do contrato há 1 mês atrás
  Gustavo Mantovani 426ea398ad refactor: rules nas validacoes e composables de submit há 1 mês atrás
  Gustavo Mantovani 410cb40821 refactor: ajustes componentes e validacoes há 1 mês atrás
  Gustavo Mantovani 30bcb8bf6e feat: modifica para jogar validacao dentro das tabs ao inves de jogar para o componente pai + endpoint de rascunho para student há 1 mês atrás
  Gustavo Mantovani ec18774ee0 chore: renomeia cpf / cnh para cpf e responsavel para franqueado operador em unidades há 1 mês atrás
  Gustavo Mantovani c52465a03c feat: add campos de nome e cnpj da unidade na tela de contrato há 1 mês atrás
  Gustavo Mantovani 9ebda150b6 refactor: deixa address_number required há 1 mês atrás
  Gustavo Mantovani 26fd64ef27 refactor: deixa somente name e cpf obrigatorios no cadastro de unit_partner há 1 mês atrás
  Gustavo Mantovani e568cdff9a refactor: ajuste para mostrar erros dos campos + q-scroll-area nos forms em dialogs há 1 mês atrás
  Gustavo Mantovani 9b6961533a feat: add indicador de obrigatoriedade nos campos que tem rules.required + add use submit handler com use form update tracker e use scroll nos formularios há 1 mês atrás
  Gustavo Mantovani 9430f7c9f4 chore: renomeia e-mail secundario para e-mail administrativo há 1 mês atrás
  Gustavo Mantovani 0854cd0ea8 feat: add nome da unidade (name) há 1 mês atrás
  Gustavo Mantovani 33ac28b018 refactor: nome fantasia da unidade há 1 mês atrás
  ebagabee ebe2689169 feat(financial): streamline charges and lock paid status há 1 mês atrás
  ebagabee 170a0740ae fix(package): update author information in package.json há 1 mês atrás
  ebagabee f7d4b5cb87 feat(contracts): create student from contract dialog há 1 mês atrás
  ebagabee b6d75afdfd feat(dashboard): enable attendance shortcut há 1 mês atrás
  ebagabee 8e09449b15 docs(versions): update system changelog há 1 mês atrás
  ebagabee 3f26b9ec47 fix(header): display last login in Brazil timezone há 1 mês atrás
  ebagabee ecb834652a feat(users): update user fetching logic and enhance user type display há 1 mês atrás
  ebagabee 15d8a821f8 feat(date-formatting): enhance formatDateYMDtoDMY function with validation and normalization há 1 mês atrás
  ebagabee 3241c369c7 feat(permissions): refactor permission management with new dialogs and components há 1 mês atrás
  ebagabee f1387c1c02 feat(financial): add FinancialPlanAccountTreeNode component for account tree structure há 1 mês atrás
  ebagabee bcc8301bf5 feat(notifications): add new icons for account payable and payment credited notifications há 1 mês atrás
  ebagabee 560896bb86 feat(dashboard): disable buttons for unimplemented features with tooltips há 1 mês atrás
  ebagabee 3007806c25 feat(package-dialog): replace banner with styled hint for class duration há 1 mês atrás
  ebagabee 6a81009b42 feat(contract): update contract dialog to support student selection and improve layout há 1 mês atrás
  ebagabee e39941933d feat(classes): add time calculation for class duration and apply package schedule há 1 mês atrás
  ebagabee 2f0311488e feat(contract): implement default contract date handling and confirmation dialog for date changes há 1 mês atrás
  ebagabee f840fae831 feat(api): update API endpoints to use selects for cities, countries, states, and financial plan accounts; add new endpoints for selecting students and unit packages há 1 mês atrás
  ebagabee bfac35c125 feat(history): add permission check for viewing history tab and simplify component structure há 1 mês atrás
  ebagabee 4b68b42bfb refactor: remove unused country and state pages, dialogs, and routes há 1 mês atrás
  ebagabee 9a280d7da2 feat(permissions): implement user type management with CRUD operations and permissions handling há 1 mês atrás
  ebagabee 74cd3a90b1 refactor: update API endpoints to use franchisee-specific routes há 1 mês atrás
  ebagabee 38524c1151 feat(packages): update API routes to franchisee-specific endpoints and implement permission checks for package management há 1 mês atrás
  ebagabee 487a35a59f feat(financial): update API routes to franchisee-specific endpoints and enhance permission checks across financial components há 1 mês atrás
  ebagabee 3f22cf54d1 feat(classes): update API routes to franchisee-specific endpoints and enhance permission checks for class management há 1 mês atrás
  ebagabee 32ac68fc45 feat(api): update endpoints to use franchisee-specific routes for dashboard, holidays, students, contracts, and media há 1 mês atrás
  ebagabee 3c2b04054e feat(students): implement permission checks for adding, editing, and deleting student-related items há 1 mês atrás
  ebagabee ef7f53f72e feat(dashboard): enhance permissions for adding and editing records, update route permissions há 1 mês atrás
  ebagabee e212466e34 fix(userStore): update admin check to include multiple user types há 1 mês atrás
  ebagabee f86693b24b feat(StudentPage): add date filters for student list and enhance DefaultHeaderPage integration há 2 meses atrás
  ebagabee 418d412dea feat(DefaultHeaderPage): enhance user profile and settings menu with dynamic data há 2 meses atrás
  ebagabee ecf2e1d3ae feat(treasury): refactor dialogs for account and launch management há 2 meses atrás
  ebagabee dce15bc12e style(TreasuryPage): enhance dialog layout and responsiveness há 2 meses atrás
  ebagabee f236d71f7b fix(LeftMenuLayout): correct background image path in style há 2 meses atrás
  ebagabee fe68197b07 feat(receivable): criar conta a receber avulsa manual há 2 meses atrás
  ebagabee ed3f1d9769 feat(plan-account): conta pai/filho + plano da matriz somente leitura há 2 meses atrás
  ebagabee 97b432197e feat(notifications): conecta sino do franchisee aos dados reais e realtime há 2 meses atrás
  ebagabee 1d226b8932 feat(class): seleciona neurotrainer da unidade em vez de digitar o instrutor há 2 meses atrás
  ebagabee 62c970bfcf feat(financial): adiciona Tesouraria ao menu Financeiro há 2 meses atrás
  ebagabee c5a8da77d5 feat(financial): tela de tesouraria com saldo, extrato e movimentação manual há 2 meses atrás
  ebagabee 02b4404de3 feat(financial): cliente de API da tesouraria (contas e lançamentos) há 2 meses atrás
  ebagabee 4a7c6ce83a feat(financial): adiciona Plano de Contas ao menu Financeiro há 2 meses atrás
  ebagabee 9c399da504 feat(financial): tela de plano de contas (CRUD com tipo receita/despesa) há 2 meses atrás
  ebagabee 78774ed054 feat(financial): cliente de API do plano de contas da unidade há 2 meses atrás
  ebagabee fa962847e6 feat(financial): adiciona Visão Geral ao menu Financeiro há 2 meses atrás
  ebagabee 0840bd53e5 feat(financial): visão geral com dados reais (resultado, recebido, pago, pendências) há 2 meses atrás
  ebagabee c2cb7eb22c feat(financial): cliente de API da visão geral financeira há 2 meses atrás
  ebagabee 61b52611a9 feat(financial): botões de gerar cobrança e ver boleto na contas a receber há 2 meses atrás
  ebagabee 6b812bc44f feat(financial): endpoint de gerar cobrança no cliente de contas a receber há 2 meses atrás
  ebagabee d5474df91c feat(financial): adiciona Contas a Pagar ao menu Financeiro há 2 meses atrás
  ebagabee feea44248d feat(financial): tela de contas a pagar com baixa manual e lançamento avulso há 2 meses atrás
  ebagabee db6a03929f feat(financial): cliente de API de contas a pagar da unidade há 2 meses atrás
  ebagabee 647eba5684 feat(financial): religa menu Financeiro com o grupo de Contas a Receber há 2 meses atrás
  ebagabee 50a63376d9 feat(financial): tela de contas a receber com baixa manual e reabertura há 2 meses atrás
  ebagabee f528ed2409 feat(financial): cliente de API de contas a receber da unidade há 2 meses atrás
  ebagabee a958585e6e feat(apis): adiciona item "APIs" no menu lateral há 2 meses atrás
  ebagabee f71ab551af feat(apis): página de configuração da chave Asaas da unidade há 2 meses atrás
  ebagabee c2d8039ef0 feat(apis): cliente de API e rota da página de integrações há 2 meses atrás
  ebagabee 7114a41766 feat(class): add "ver mais" button for additional class events in month grid há 2 meses atrás
  ebagabee 65d6efb4d2 feat(class): página da agenda integrada à API há 2 meses atrás
  ebagabee 20351c9e42 feat(class): diálogos de nova atividade, presença e justificativa há 2 meses atrás
  ebagabee fc03c703b2 feat(class): visões de calendário (mês e timeline do dia) há 2 meses atrás
  ebagabee 06310e0b53 feat(class): cliente de API da agenda de aulas há 2 meses atrás
  ebagabee dbf3a6d757 feat: adiciona agenda há 2 meses atrás
  ebagabee 8974b29d91 feat: enhance AniversariantesCard to display message when no birthdays are present; update DashboardPage to reflect real-time data for anniversaries and active contracts há 2 meses atrás
  ebagabee f3f0be00cd feat: add actions to Accounts Receivable page and implement data loading from API; temporarily remove Financial menu from navigation há 2 meses atrás
  ebagabee 1739ab5e40 feat: implement financial module with overview page, accounts payable/receivable pages, and reusable card component há 3 meses atrás
  ebagabee 87969c6d59 feat: adiciona tab de midias há 3 meses atrás
  ebagabee 2493cf63be feat: adiciona botao de adicionar na linha há 3 meses atrás
  ebagabee cb2c63f447 chore: corrige catch há 3 meses atrás
  Gabriel Alves 59e3367ebb Merge branch 'feature/GINC-GAB-fluxo-kanban-franqueadora-franquia' of Softpar/sfp_vue_franchisee_ginastica_cerebro into development há 3 meses atrás
  ebagabee 92a75da427 feat(kanban): dialog/card redesign for franchisee há 3 meses atrás
  ebagabee e8f017a398 fix(kanban): reload cards when franchisee switches unit há 3 meses atrás
  ebagabee 4603f22335 fix(kanban): fix DnD — use reactive columnMap instead of filtered array há 3 meses atrás
  ebagabee bf3f2b8beb feat(kanban): drag-and-drop with order + phase persistence (franchisee) há 3 meses atrás
  ebagabee 02c950afc6 fix(kanban): simplify Para label — Interno or unit name only há 3 meses atrás
  ebagabee 6537c3cd3c feat(kanban): add De/Para and Responsável to franchisee kanban card há 3 meses atrás
  ebagabee 70a0fbf4d3 feat(kanban): add Atividades to franchisee navigation menu há 3 meses atrás
  ebagabee 606c920fe0 feat(kanban): add kanban board page to franchisee há 3 meses atrás
  ebagabee 3827c23803 feat: reload page data when selected unit changes há 3 meses atrás
  ebagabee d13bb48068 feat: add unit selector to DefaultHeaderPage há 3 meses atrás
  ebagabee 0558c1f5b8 feat: inject X-Unit-Id header on every axios request há 3 meses atrás
  ebagabee 3c021b883c feat: add multi-unit state management to user store há 3 meses atrás
  ebagabee fc363ed2d1 feat: adiciona ticket com # há 3 meses atrás
  ebagabee 3f14261a21 chore: remove visualizar arquivo há 3 meses atrás
  ebagabee c841495170 feat: altera layout do feriado há 3 meses atrás
  ebagabee 1902d4936e feat: adiciona nova logica em feriado criado por franchisor há 3 meses atrás
  ebagabee 12b0743089 feat: adiciona unidades há 3 meses atrás
  ebagabee 0dfa3ab2d8 feat: adiciona congelamento de contrato há 3 meses atrás
  ebagabee 7c56100a88 feat: novos campos de cadastro de unidade há 3 meses atrás
  ebagabee f409672494 feat: adiciona funcionalidade de criacao de contas a receber do aluno há 3 meses atrás
  ebagabee 3d6b763afb feat(packages): corrige, buscando apenas visible true há 3 meses atrás
  Gabriel Alves b46a0736e0 Merge branch 'feature/GINC-GAB-suporte' of Softpar/sfp_vue_franchisee_ginastica_cerebro into development há 3 meses atrás
  ebagabee c1165d9eb3 feat(support): adiciona validacao de suporte há 3 meses atrás
  ebagabee dd907e82f9 feat(support): adiciona ticket para franchisee há 3 meses atrás
  Gabriel Alves 02db949736 Merge branch 'feature/GINC-GAB-CB003-descongelar-contrato' of Softpar/sfp_vue_franchisee_ginastica_cerebro into development há 4 meses atrás
  ebagabee 62b5f0ae3d feat: adiciona Visualizacao do Contrato há 4 meses atrás
  ebagabee b08f64ac6e feat: adiciona funcionalidade de midias dentro de Contratos há 4 meses atrás
  ebagabee 6914dbbf3f feat: adiciona funcionalidade de visualizar contrato há 4 meses atrás
  ebagabee f2ee7c37e7 feat: adiciona botao de visualizacao há 4 meses atrás
  ebagabee 63d06ee907 feat: adiciona actions em tela de contratos há 4 meses atrás
  ebagabee c60efca81a feat(student_contracts): adiciona funcionalidade de reativar contrato, e remove dialog anterior há 4 meses atrás
  ebagabee b7873bb5b9 feat: adiciona funcionalidade de editar e excluir há 4 meses atrás
  ebagabee b6fa320947 fix: restaura ref há 4 meses atrás
  ebagabee 15568e7c88 feat: adiciona input ref e excluir user há 4 meses atrás
  ebagabee c7dec7c4ad feat: adiciona listagem de responsaveis há 4 meses atrás
  ebagabee ceb02fe5c1 feat: adiciona qual o pacote dentro de contratos há 4 meses atrás
  ebagabee d49ee40c99 feat: adiciona dia recorrente há 4 meses atrás
  ebagabee aed3e54c9e feat: ajusta estudante media há 4 meses atrás
  ebagabee eef6311388 feat: Novo Layout de Finanças há 4 meses atrás
  ebagabee 04f59bd305 feat: corrige campos faltantes e adiciona datas corretas há 4 meses atrás
  ebagabee 54cc4ff1e7 chore: altera dados da unidade para unidade há 4 meses atrás
  Gabriel Alves e8f60301ad Merge branch 'feature/GC-GAB-cadastro-colaboradores' of Softpar/sfp_vue_franchisee_ginastica_cerebro into development há 4 meses atrás
  ebagabee de61a447e5 feat: adiciona crud de tipo de usuarios e adiciona usuario para franquia há 4 meses atrás
  ebagabee 8fd51128cd feat: adiciona criacao e edicao de usuario há 4 meses atrás
  ebagabee 021fa8c857 feat: adiciona layout para adicionar usuarios para a unidade há 4 meses atrás
  ebagabee f27685e9a2 chore: ajuste de layout há 4 meses atrás
  ebagabee 8deee4c58e chore: ajuste de layout há 4 meses atrás
  ebagabee 0eb898c993 feat: corrige acronimo, adiciona taxas há 4 meses atrás
  ebagabee 7b8ac01732 feat: adiciona default header dialog em edicao de pacote há 4 meses atrás
  ebagabee 8065c2e90a feat: tela de contratos (Visualizacao em lista) há 4 meses atrás
  ebagabee 6f3e775a2d Merge branch 'development' of gogs.softpar.inf.br:Softpar/sfp_vue_franchisee_ginastica_cerebro into development há 4 meses atrás
  ebagabee 2fc1fb8fc3 feat: adiciona funcionalidade de congelar e cancelar contrato há 4 meses atrás
100 ficheiros alterados com 9504 adições e 2455 exclusões
  1. 20 1
      package-lock.json
  2. 4 3
      package.json
  3. 3 18
      src/api/city.js
  4. 38 0
      src/api/class.js
  5. 1 16
      src/api/country.js
  6. 26 0
      src/api/financial_plan_account.js
  7. 2 2
      src/api/franchisee_contract.js
  8. 6 0
      src/api/franchisee_dashboard.js
  9. 4 4
      src/api/holiday.js
  10. 1 1
      src/api/inhabitant_classification.js
  11. 30 0
      src/api/kanban.js
  12. 20 0
      src/api/kanban_media.js
  13. 21 0
      src/api/kanban_reply.js
  14. 21 0
      src/api/notification.js
  15. 12 7
      src/api/package.js
  16. 3 1
      src/api/product.js
  17. 2 17
      src/api/state.js
  18. 25 7
      src/api/student.js
  19. 35 5
      src/api/studentContract.js
  20. 4 4
      src/api/studentResponsible.js
  21. 21 5
      src/api/student_media.js
  22. 21 0
      src/api/support_reply.js
  23. 21 0
      src/api/support_ticket.js
  24. 42 0
      src/api/treasury.js
  25. 2 7
      src/api/unit.js
  26. 1 8
      src/api/unit_financial.js
  27. 6 0
      src/api/unit_financial_dashboard.js
  28. 1 1
      src/api/unit_history.js
  29. 2 2
      src/api/unit_media.js
  30. 4 4
      src/api/unit_partner.js
  31. 21 0
      src/api/unit_payable.js
  32. 11 0
      src/api/unit_payment_account.js
  33. 37 0
      src/api/unit_receivable.js
  34. 23 6
      src/api/user.js
  35. 11 0
      src/api/user_type.js
  36. 7 1
      src/boot/axios.js
  37. 3 1
      src/boot/defaultPropsComponents.js
  38. 10 34
      src/components/charts/AniversariantesCard.vue
  39. 85 0
      src/components/defaults/CustomFileInput.vue
  40. 9 1
      src/components/defaults/DefaultCepInput.vue
  41. 25 23
      src/components/defaults/DefaultCurrencyInput.vue
  42. 15 9
      src/components/defaults/DefaultDialogHeader.vue
  43. 76 60
      src/components/defaults/DefaultFilePicker.vue
  44. 38 0
      src/components/defaults/DefaultForm.vue
  45. 76 0
      src/components/defaults/DefaultImagePicker.vue
  46. 69 65
      src/components/defaults/DefaultInput.vue
  47. 40 27
      src/components/defaults/DefaultInputDatePicker.vue
  48. 2 1
      src/components/defaults/DefaultPasswordInput.vue
  49. 151 58
      src/components/defaults/DefaultSelect.vue
  50. 183 134
      src/components/defaults/DefaultTable.vue
  51. 167 133
      src/components/defaults/DefaultTableServerSide.vue
  52. 15 13
      src/components/defaults/DefaultTabs.vue
  53. 110 0
      src/components/financial/FinancialCard.vue
  54. 83 0
      src/components/financial/FinancialPlanAccountTreeNode.vue
  55. 537 34
      src/components/layout/DefaultHeaderPage.vue
  56. 166 114
      src/components/layout/LeftMenuLayout.vue
  57. 78 53
      src/components/layout/LeftMenuLayoutMobile.vue
  58. 164 85
      src/components/selects/CitySelect.vue
  59. 159 78
      src/components/selects/StateSelect.vue
  60. 24 28
      src/components/selects/UserTypeSelect.vue
  61. 12 35
      src/components/shared/ChangeImageDialog.vue
  62. 11 3
      src/components/shared/CustomTabComponent.vue
  63. 14 2
      src/composables/useAuth.js
  64. 81 84
      src/composables/useForm.js
  65. 301 44
      src/composables/useInputRules.js
  66. 157 67
      src/composables/useScroll.js
  67. 29 33
      src/composables/useSubmitHandler.js
  68. 52 4
      src/css/app.scss
  69. 54 3
      src/helpers/utils.js
  70. 14 0
      src/i18n/locales/en.json
  71. 14 0
      src/i18n/locales/es.json
  72. 15 1
      src/i18n/locales/pt.json
  73. 120 0
      src/pages/apis/ApisPage.vue
  74. 0 159
      src/pages/city/CityPage.vue
  75. 0 158
      src/pages/city/components/AddEditCityDialog.vue
  76. 202 0
      src/pages/classes/ClassPage.vue
  77. 388 0
      src/pages/classes/components/AddEditClassDialog.vue
  78. 287 0
      src/pages/classes/components/ClassAttendanceDialog.vue
  79. 108 0
      src/pages/classes/components/ClassEventCard.vue
  80. 211 0
      src/pages/classes/components/DayTimeline.vue
  81. 84 0
      src/pages/classes/components/JustifyAttendanceDialog.vue
  82. 195 0
      src/pages/classes/components/MonthGrid.vue
  83. 397 0
      src/pages/contracts/ContractsPage.vue
  84. 0 152
      src/pages/country/CountryPage.vue
  85. 0 136
      src/pages/country/components/AddEditCountryDialog.vue
  86. 469 211
      src/pages/dashboard/DashboardPage.vue
  87. 405 287
      src/pages/dashboard/components/FeriadosDialog.vue
  88. 237 0
      src/pages/dashboard/components/FeriadosEditDialog.vue
  89. 507 0
      src/pages/financial/AccountsPayablePage.vue
  90. 632 0
      src/pages/financial/AccountsReceivablePage.vue
  91. 91 0
      src/pages/financial/AsaasIntegrationPage.vue
  92. 518 0
      src/pages/financial/ChartOfAccountsPage.vue
  93. 112 0
      src/pages/financial/FinancialPage.vue
  94. 45 0
      src/pages/financial/InvoiceIssuancePage.vue
  95. 246 0
      src/pages/financial/TreasuryPage.vue
  96. 187 0
      src/pages/financial/components/AddEditTreasuryAccountDialog.vue
  97. 212 0
      src/pages/financial/components/AddTreasuryLaunchDialog.vue
  98. 2 2
      src/pages/forgot-password/EmailDataStep.vue
  99. 6 3
      src/pages/forgot-password/NewPasswordStep.vue
  100. 295 0
      src/pages/kanban/KanbanPage.vue

+ 20 - 1
package-lock.json

@@ -23,7 +23,8 @@
         "vue-chartjs": "^5.3.3",
         "vue-currency-input": "^3.2.1",
         "vue-i18n": "^11.1.4",
-        "vue-router": "^4.6.4"
+        "vue-router": "^4.6.4",
+        "vuedraggable": "^4.1.0"
       },
       "devDependencies": {
         "@bufbuild/buf": "^1.61.0",
@@ -9827,6 +9828,12 @@
         "node": ">=10.0.0"
       }
     },
+    "node_modules/sortablejs": {
+      "version": "1.14.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
+      "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
+      "license": "MIT"
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -10798,6 +10805,18 @@
       "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
       "license": "MIT"
     },
+    "node_modules/vuedraggable": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
+      "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
+      "license": "MIT",
+      "dependencies": {
+        "sortablejs": "1.14.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.0.1"
+      }
+    },
     "node_modules/webpack-merge": {
       "version": "6.0.1",
       "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz",

+ 4 - 3
package.json

@@ -3,7 +3,7 @@
   "version": "0.0.1",
   "description": "Ginastica Franchisee",
   "productName": "Ginastica Cerebro Franchisee",
-  "author": "Denis <denis.gnl@gmail.com>",
+  "author": "Gabriel <alvesantos.gh@gmail.com>",
   "type": "module",
   "private": true,
   "scripts": {
@@ -30,14 +30,15 @@
     "vue-chartjs": "^5.3.3",
     "vue-currency-input": "^3.2.1",
     "vue-i18n": "^11.1.4",
-    "vue-router": "^4.6.4"
+    "vue-router": "^4.6.4",
+    "vuedraggable": "^4.1.0"
   },
   "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",

+ 3 - 18
src/api/city.js

@@ -8,31 +8,16 @@ export const getCity = async (id) => {
 };
 
 export const getCities = async () => {
-  const { data } = await api.get("/city");
+  const { data } = await api.get("/selects/cities");
   return data.payload;
 };
 
 export const getCityByState = async (stateId) => {
-  const { data } = await api.get(`/city/state/${stateId}`);
+  const { data } = await api.get("/selects/cities", { params: { state_id: stateId } });
   return data.payload;
 };
 
 export const getCityByCountry = async (countryId) => {
-  const { data } = await api.get(`/city/country/${countryId}`);
-  return data.payload;
-};
-
-export const createCity = async (city) => {
-  const { data } = await api.post("/city", city);
-  return data.payload;
-};
-
-export const updateCity = async (city, id) => {
-  const { data } = await api.put(`/city/${id}`, city);
-  return data.payload;
-};
-
-export const deleteCity = async (id) => {
-  const { data } = await api.del(`/city/${id}`);
+  const { data } = await api.get("/selects/cities", { params: { country_id: countryId } });
   return data.payload;
 };

+ 38 - 0
src/api/class.js

@@ -0,0 +1,38 @@
+import api from "src/api";
+
+export const getClasses = async (params = {}) => {
+  const { data } = await api.get("/franchisee/classes", { params });
+  return data.payload;
+};
+
+export const getClass = async (id) => {
+  const { data } = await api.get(`/franchisee/classes/${id}`);
+  return data.payload;
+};
+
+export const createClass = async (payload) => {
+  const { data } = await api.post("/franchisee/classes", payload);
+  return data.payload;
+};
+
+export const updateClass = async (payload, id) => {
+  const { data } = await api.put(`/franchisee/classes/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteClass = async (id) => {
+  const { data } = await api.delete(`/franchisee/classes/${id}`);
+  return data.payload;
+};
+
+// Alunos com contrato ativo no pacote da aula + presença registrada
+export const getClassStudents = async (id) => {
+  const { data } = await api.get(`/franchisee/classes/${id}/students`);
+  return data.payload;
+};
+
+// Salva a lista de presença da aula em lote
+export const saveClassAttendance = async (id, attendances) => {
+  const { data } = await api.post(`/franchisee/classes/${id}/attendances`, { attendances });
+  return data.payload;
+};

+ 1 - 16
src/api/country.js

@@ -8,21 +8,6 @@ export const getCountry = async (id) => {
 };
 
 export const getCountries = async () => {
-  const { data } = await api.get("/country");
-  return data.payload;
-};
-
-export const createCountry = async (country) => {
-  const data = await api.post("/country", country);
-  return data.payload;
-};
-
-export const updateCountry = async (country, id) => {
-  const data = await api.put(`/country/${id}`, country);
-  return data.payload;
-};
-
-export const deleteCountry = async (id) => {
-  const data = await api.del(`/country/${id}`);
+  const { data } = await api.get("/selects/countries");
   return data.payload;
 };

+ 26 - 0
src/api/financial_plan_account.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getPlanAccountsMe = async () => {
+  const { data } = await api.get("/franchisee/financial/plan-accounts");
+  return data.payload;
+};
+
+export const getPlanAccountsForSelect = async () => {
+  const { data } = await api.get("/selects/financial-plan-accounts");
+  return data.payload;
+};
+
+export const createPlanAccountMe = async (payload) => {
+  const { data } = await api.post("/franchisee/financial/plan-accounts", payload);
+  return data.payload;
+};
+
+export const updatePlanAccountMe = async (id, payload) => {
+  const { data } = await api.put(`/franchisee/financial/plan-accounts/${id}`, payload);
+  return data.payload;
+};
+
+export const deletePlanAccountMe = async (id) => {
+  const { data } = await api.delete(`/franchisee/financial/plan-accounts/${id}`);
+  return data;
+};

+ 2 - 2
src/api/franchisee_contract.js

@@ -1,11 +1,11 @@
 import api from "src/api";
 
 export const getFranchiseeContractsByUnit = async () => {
-  const { data } = await api.get("/franchisee-contract/me");
+  const { data } = await api.get("/franchisee/unit/contracts");
   return data.payload;
 };
 
 export const getFranchiseeContractTaxHistory = async (id) => {
-  const { data } = await api.get(`/franchisee-contract/me/${id}/tax-history`);
+  const { data } = await api.get(`/franchisee/unit/contracts/${id}/tax-history`);
   return data.payload;
 };

+ 6 - 0
src/api/franchisee_dashboard.js

@@ -0,0 +1,6 @@
+import api from "src/api";
+
+export const getDashboardSummary = async () => {
+  const { data } = await api.get("/franchisee/dashboard/summary");
+  return data.payload;
+};

+ 4 - 4
src/api/holiday.js

@@ -1,21 +1,21 @@
 import api from "src/api";
 
 export const getHolidays = async () => {
-  const { data } = await api.get("/holiday");
+  const { data } = await api.get("/franchisee/dashboard/holidays");
   return data.payload;
 };
 
 export const createHoliday = async (holiday) => {
-  const { data } = await api.post("/holiday", holiday);
+  const { data } = await api.post("/franchisee/dashboard/holidays", holiday);
   return data.payload;
 };
 
 export const updateHoliday = async (holiday, id) => {
-  const { data } = await api.put(`/holiday/${id}`, holiday);
+  const { data } = await api.put(`/franchisee/dashboard/holidays/${id}`, holiday);
   return data.payload;
 };
 
 export const deleteHoliday = async (id) => {
-  const { data } = await api.delete(`/holiday/${id}`);
+  const { data } = await api.delete(`/franchisee/dashboard/holidays/${id}`);
   return data.payload;
 };

+ 1 - 1
src/api/inhabitant_classification.js

@@ -1,6 +1,6 @@
 import api from "src/api";
 
 export const getInhabitantClassificationsForSelect = async () => {
-  const { data } = await api.get("/inhabitant-classification/all/select");
+  const { data } = await api.get("/selects/inhabitant-classifications");
   return data.payload;
 };

+ 30 - 0
src/api/kanban.js

@@ -0,0 +1,30 @@
+import api from "src/api";
+
+export const getKanbans = async () => {
+  const { data } = await api.get("/franchisee/activities");
+  return data.payload;
+};
+
+export const createKanban = async (payload) => {
+  const { data } = await api.post("/franchisee/activities", payload);
+  return data.payload;
+};
+
+export const updateKanban = async (id, payload) => {
+  const { data } = await api.put(`/franchisee/activities/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteKanban = async (id) => {
+  const { data } = await api.delete(`/franchisee/activities/${id}`);
+  return data;
+};
+
+/**
+ * Persists drag-and-drop reorder.
+ * @param {Array<{id: number, phase: string, order: number}>} items
+ */
+export const reorderKanbans = async (items) => {
+  const { data } = await api.post("/franchisee/activities/reorder", { items });
+  return data;
+};

+ 20 - 0
src/api/kanban_media.js

@@ -0,0 +1,20 @@
+import api from "src/api";
+
+export const getKanbanMedias = async (kanbanId) => {
+  const { data } = await api.get(`/franchisee/activities/${kanbanId}/medias`);
+  return data.payload;
+};
+
+export const uploadKanbanMedia = async (kanbanId, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/franchisee/activities/${kanbanId}/medias`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteKanbanMedia = async (kanbanId, id) => {
+  const { data } = await api.delete(`/franchisee/activities/${kanbanId}/medias/${id}`);
+  return data;
+};

+ 21 - 0
src/api/kanban_reply.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getKanbanReplies = async (kanbanId) => {
+  const { data } = await api.get(`/franchisee/activities/${kanbanId}/replies`);
+  return data.payload;
+};
+
+export const createKanbanReply = async (kanbanId, payload) => {
+  const { data } = await api.post(`/franchisee/activities/${kanbanId}/replies`, payload);
+  return data.payload;
+};
+
+export const updateKanbanReply = async (kanbanId, id, payload) => {
+  const { data } = await api.put(`/franchisee/activities/${kanbanId}/replies/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteKanbanReply = async (kanbanId, id) => {
+  const { data } = await api.delete(`/franchisee/activities/${kanbanId}/replies/${id}`);
+  return data;
+};

+ 21 - 0
src/api/notification.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getMyNotifications = async () => {
+  const { data } = await api.get("/notification/me");
+  return data.payload;
+};
+
+export const getUnreadCount = async () => {
+  const { data } = await api.get("/notification/me/unread-count");
+  return data.payload?.count ?? 0;
+};
+
+export const markNotificationRead = async (id) => {
+  const { data } = await api.post(`/notification/${id}/read`);
+  return data.payload;
+};
+
+export const markAllNotificationsRead = async () => {
+  const { data } = await api.post("/notification/read-all");
+  return data.payload;
+};

+ 12 - 7
src/api/package.js

@@ -1,35 +1,40 @@
 import api from "src/api";
 
 export const getUnitPackages = async () => {
-  const { data } = await api.get("/class-package-unit/visible");
+  const { data } = await api.get("/franchisee/packages");
   return data.payload;
 };
 
 export const getAllUnitPackages = async () => {
-  const { data } = await api.get("/class-package-unit");
+  const { data } = await api.get("/selects/franchisee/packages");
+  return data.payload;
+};
+
+export const getUnitPackagesForSelect = async () => {
+  const { data } = await api.get("/selects/franchisee/packages");
   return data.payload;
 };
 
 export const getUnitPackage = async (id) => {
-  const { data } = await api.get(`/class-package-unit/${id}`);
+  const { data } = await api.get(`/franchisee/packages/${id}`);
   return data.payload;
 };
 
 export const createUnitPackage = async (payload) => {
-  const { data } = await api.post("/class-package-unit", payload);
+  const { data } = await api.post("/franchisee/packages", payload);
   return data.payload;
 };
 
 export const updateUnitPackage = async (id, payload) => {
-  const { data } = await api.put(`/class-package-unit/${id}`, payload);
+  const { data } = await api.put(`/franchisee/packages/${id}`, payload);
   return data.payload;
 };
 
 export const toggleUnitPackageVisibility = async (id) => {
-  const { data } = await api.patch(`/class-package-unit/${id}/toggle-visibility`);
+  const { data } = await api.patch(`/franchisee/packages/${id}/visibility`);
   return data.payload;
 };
 
 export const deleteUnitPackage = async (id) => {
-  await api.delete(`/class-package-unit/${id}`);
+  await api.delete(`/franchisee/packages/${id}`);
 };

+ 3 - 1
src/api/product.js

@@ -1,6 +1,8 @@
 import api from "src/api";
 
 export const getProductsForSelect = async () => {
-  const { data } = await api.get("/product/all/select");
+  const { data } = await api.get("/selects/products", {
+    params: { visible_franchisee: true },
+  });
   return data.payload;
 };

+ 2 - 17
src/api/state.js

@@ -8,26 +8,11 @@ export const getState = async (id) => {
 }
 
 export const getStates = async () => {
-  const { data } = await api.get("/state");
+  const { data } = await api.get("/selects/states");
   return data.payload;
 }
 
 export const getStateByCountry = async (countryId) => {
-  const { data } = await api.get(`/state/country/${countryId}`);
-  return data.payload;
-}
-
-export const createState = async (state) => {
-  const { data } = await api.post("/state", state);
-  return data.payload;
-}
-
-export const updateState = async (state, id) => {
-  const { data } = await api.put(`/state/${id}`, state);
-  return data.payload;
-}
-
-export const deleteState = async (id) => {
-  const { data } = await api.del(`/state/${id}`);
+  const { data } = await api.get("/selects/states", { params: { country_id: countryId } });
   return data.payload;
 }

+ 25 - 7
src/api/student.js

@@ -1,31 +1,49 @@
 import api from "src/api";
 
-export const getStudents = async () => {
-  const { data } = await api.get("/student");
+export const getStudents = async (params = {}) => {
+  const { data } = await api.get("/franchisee/students", { params });
+  return data.payload;
+};
+
+export const getStudentsForSelect = async () => {
+  const { data } = await api.get("/selects/franchisee/students");
   return data.payload;
 };
 
 export const getStudent = async (id) => {
-  const { data } = await api.get(`/student/${id}`);
+  const { data } = await api.get(`/franchisee/students/${id}`);
   return data.payload;
 };
 
 export const createStudent = async (student) => {
-  const { data } = await api.post("/student", student);
+  const { data } = await api.post("/franchisee/students", student);
+  return data.payload;
+};
+
+export const createStudentRegistrationDraft = async (student) => {
+  const { data } = await api.post(
+    "/franchisee/students/registration-drafts",
+    student,
+  );
+
   return data.payload;
 };
 
 export const updateStudent = async (payload, id) => {
   if (payload instanceof FormData) {
     payload.append('_method', 'PUT');
-    const { data } = await api.post(`/student/${id}`, payload);
+
+    const { data } = await api.post(`/franchisee/students/${id}`, payload);
+
     return data.payload;
   }
-  const { data } = await api.put(`/student/${id}`, payload);
+
+  const { data } = await api.put(`/franchisee/students/${id}`, payload);
+
   return data.payload;
 };
 
 export const deleteStudent = async (id) => {
-  const { data } = await api.delete(`/student/${id}`);
+  const { data } = await api.delete(`/franchisee/students/${id}`);
   return data.payload;
 };

+ 35 - 5
src/api/studentContract.js

@@ -1,27 +1,57 @@
 import api from "src/api";
 
+export const getStudentContractById = async (id) => {
+  const { data } = await api.get(`/franchisee/contracts/${id}`);
+  return data.payload;
+};
+
 export const getStudentContracts = async (studentId) => {
-  const { data } = await api.get("/student-contract", { params: { student_id: studentId } });
+  const { data } = await api.get("/franchisee/contracts", { params: { student_id: studentId } });
+  return data.payload;
+};
+
+export const getAllContracts = async () => {
+  const { data } = await api.get("/franchisee/contracts");
   return data.payload;
 };
 
 export const createStudentContract = async (payload) => {
-  const { data } = await api.post("/student-contract", payload);
+  const { data } = await api.post("/franchisee/contracts", payload);
   return data.payload;
 };
 
 export const updateStudentContract = async (id, payload) => {
-  const { data } = await api.put(`/student-contract/${id}`, payload);
+  const { data } = await api.put(`/franchisee/contracts/${id}`, payload);
   return data.payload;
 };
 
 export const attachContractFile = async (id, formData) => {
-  const { data } = await api.post(`/student-contract/${id}/file`, formData, {
+  const { data } = await api.post(`/franchisee/contracts/${id}/file`, formData, {
     headers: { "Content-Type": "multipart/form-data" },
   });
   return data.payload;
 };
 
+export const getContractInstallments = async (id) => {
+  const { data } = await api.get(`/franchisee/contracts/${id}/installments`);
+  return data.payload;
+};
+
+export const freezeContract = async (id, months) => {
+  const { data } = await api.post(`/franchisee/contracts/${id}/freeze`, { months });
+  return data.payload;
+};
+
+export const cancelContract = async (id) => {
+  const { data } = await api.post(`/franchisee/contracts/${id}/cancel`);
+  return data.payload;
+};
+
+export const reactivateContract = async (id) => {
+  const { data } = await api.post(`/franchisee/contracts/${id}/reactivate`);
+  return data.payload;
+};
+
 export const deleteStudentContract = async (id) => {
-  await api.delete(`/student-contract/${id}`);
+  await api.delete(`/franchisee/contracts/${id}`);
 };

+ 4 - 4
src/api/studentResponsible.js

@@ -1,21 +1,21 @@
 import api from "src/api";
 
 export const getStudentResponsible = async (studentId) => {
-  const { data } = await api.get(`/student/${studentId}/responsible`);
+  const { data } = await api.get(`/franchisee/students/${studentId}/responsible`);
   return data.payload;
 };
 
 export const createStudentResponsible = async (payload) => {
-  const { data } = await api.post("/student-responsible", payload);
+  const { data } = await api.post("/franchisee/students/responsibles", payload);
   return data.payload;
 };
 
 export const updateStudentResponsible = async (id, payload) => {
-  const { data } = await api.put(`/student-responsible/${id}`, payload);
+  const { data } = await api.put(`/franchisee/students/responsibles/${id}`, payload);
   return data.payload;
 };
 
 export const deleteStudentResponsible = async (id) => {
-  const { data } = await api.delete(`/student-responsible/${id}`);
+  const { data } = await api.delete(`/franchisee/students/responsibles/${id}`);
   return data.payload;
 };

+ 21 - 5
src/api/student_media.js

@@ -1,20 +1,36 @@
 import api from "src/api";
 
 export const getStudentMedias = async (studentId) => {
-  const { data } = await api.get("/media", {
-    params: { origem: "student", origem_id: studentId },
-  });
+  const { data } = await api.get("/franchisee/students/medias", { params: { student_id: studentId } });
+  return data.payload;
+};
+
+export const getContractMedias = async (contractId) => {
+  const { data } = await api.get("/franchisee/contracts/medias", { params: { student_contract_id: contractId } });
   return data.payload;
 };
 
+export const deleteContractMedia = async (id) => {
+  const { data } = await api.delete(`/franchisee/contracts/medias/${id}`);
+  return data;
+};
+
 export const createStudentMedia = async (formData) => {
-  const { data } = await api.post("/media", formData, {
+  const { data } = await api.post("/franchisee/students/medias", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const updateStudentMedia = async (id, formData) => {
+  formData.append("_method", "PUT");
+  const { data } = await api.post(`/franchisee/students/medias/${id}`, formData, {
     headers: { "Content-Type": "multipart/form-data" },
   });
   return data.payload;
 };
 
 export const deleteStudentMedia = async (id) => {
-  const { data } = await api.delete(`/media/${id}`);
+  const { data } = await api.delete(`/franchisee/students/medias/${id}`);
   return data;
 };

+ 21 - 0
src/api/support_reply.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getSupportReplies = async (ticketId) => {
+  const { data } = await api.get(`/franchisee/support/tickets/${ticketId}/replies`);
+  return data.payload;
+};
+
+export const createSupportReply = async (ticketId, payload) => {
+  const { data } = await api.post(`/franchisee/support/tickets/${ticketId}/replies`, payload);
+  return data.payload;
+};
+
+export const updateSupportReply = async (ticketId, id, payload) => {
+  const { data } = await api.put(`/franchisee/support/tickets/${ticketId}/replies/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteSupportReply = async (ticketId, id) => {
+  const { data } = await api.delete(`/franchisee/support/tickets/${ticketId}/replies/${id}`);
+  return data;
+};

+ 21 - 0
src/api/support_ticket.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getSupportTickets = async () => {
+  const { data } = await api.get("/franchisee/support/tickets");
+  return data.payload;
+};
+
+export const createSupportTicket = async (payload) => {
+  const { data } = await api.post("/franchisee/support/tickets", payload);
+  return data.payload;
+};
+
+export const updateSupportTicket = async (id, payload) => {
+  const { data } = await api.put(`/franchisee/support/tickets/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteSupportTicket = async (id) => {
+  const { data } = await api.delete(`/franchisee/support/tickets/${id}`);
+  return data;
+};

+ 42 - 0
src/api/treasury.js

@@ -0,0 +1,42 @@
+import api from "src/api";
+
+export const getTreasuryAccountsMe = async () => {
+  const { data } = await api.get("/franchisee/financial/treasury/accounts");
+  return data.payload;
+};
+
+export const createTreasuryAccountMe = async (payload) => {
+  const { data } = await api.post("/franchisee/financial/treasury/accounts", payload);
+  return data.payload;
+};
+
+export const updateTreasuryAccountMe = async (id, payload) => {
+  const { data } = await api.put(`/franchisee/financial/treasury/accounts/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteTreasuryAccountMe = async (id) => {
+  const { data } = await api.delete(`/franchisee/financial/treasury/accounts/${id}`);
+  return data;
+};
+
+export const getTreasuryLaunchesMe = async (accountId) => {
+  const { data } = await api.get("/franchisee/financial/treasury/launches", {
+    params: accountId ? { account_id: accountId } : {},
+  });
+  return data.payload;
+};
+
+export const createTreasuryLaunchMe = async (payload) => {
+  const { data } = await api.post("/franchisee/financial/treasury/launches", payload);
+  return data.payload;
+};
+
+export const updateTreasuryLaunchMe = async (id, payload) => {
+  const { data } = await api.put(`/franchisee/financial/treasury/launches/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteTreasuryLaunchMe = async (id) => {
+  await api.delete(`/franchisee/financial/treasury/launches/${id}`);
+};

+ 2 - 7
src/api/unit.js

@@ -13,14 +13,14 @@ export const getUnit = async (id) => {
 //
 
 export const getUnitMe = async () => {
-  const { data } = await api.get("/unit/me");
+  const { data } = await api.get("/franchisee/unit");
   return data.payload;
 };
 
 export const updateUnitMe = async (formData) => {
   formData.append("_method", "PUT");
 
-  const { data } = await api.post("/unit/me", formData, {
+  const { data } = await api.post("/franchisee/unit", formData, {
     headers: { "Content-Type": "multipart/form-data" },
   });
 
@@ -51,8 +51,3 @@ export const deleteUnit = async (id) => {
   const { data } = await api.delete(`/unit/${id}`);
   return data;
 };
-
-export const getUnitsForSelect = async () => {
-  const { data } = await api.get("/unit/all/select");
-  return data.payload;
-};

+ 1 - 8
src/api/unit_financial.js

@@ -1,13 +1,6 @@
 import api from "src/api";
 
-export const getFinancialByUnit = async (unitId) => {
-  const { data } = await api.get("/unit-financial", {
-    params: { unit_id: unitId },
-  });
-  return data.payload;
-};
-
 export const getFinancialMe = async () => {
-  const { data } = await api.get("/unit-financial/me");
+  const { data } = await api.get("/franchisee/financial/settings");
   return data.payload;
 };

+ 6 - 0
src/api/unit_financial_dashboard.js

@@ -0,0 +1,6 @@
+import api from "src/api";
+
+export const getFinancialOverviewMe = async () => {
+  const { data } = await api.get("/franchisee/financial/overview");
+  return data.payload;
+};

+ 1 - 1
src/api/unit_history.js

@@ -1,6 +1,6 @@
 import api from "src/api";
 
 export const getHistoriesByUnit = async () => {
-  const { data } = await api.get("/unit-history/me");
+  const { data } = await api.get("/franchisee/unit/histories");
   return data.payload;
 };

+ 2 - 2
src/api/unit_media.js

@@ -1,12 +1,12 @@
 import api from "src/api";
 
 export const getMediasByUnit = async () => {
-  const { data } = await api.get("/unit-media/me");
+  const { data } = await api.get("/franchisee/unit/medias");
   return data.payload;
 };
 
 export const createMedia = async (formData) => {
-  const { data } = await api.post("/unit-media/me", formData, {
+  const { data } = await api.post("/franchisee/unit/medias", formData, {
     headers: { "Content-Type": "multipart/form-data" },
   });
 

+ 4 - 4
src/api/unit_partner.js

@@ -1,12 +1,12 @@
 import api from "src/api";
 
 export const getPartnersByUnit = async () => {
-  const { data } = await api.get("/unit-partner/me");
+  const { data } = await api.get("/franchisee/unit/partners");
   return data.payload;
 };
 
 export const createPartner = async (formData) => {
-  const { data } = await api.post("/unit-partner/me", formData, {
+  const { data } = await api.post("/franchisee/unit/partners", formData, {
     headers: { "Content-Type": "multipart/form-data" },
   });
 
@@ -14,7 +14,7 @@ export const createPartner = async (formData) => {
 };
 
 export const updatePartner = async (id, formData) => {
-  const { data } = await api.put(`/unit-partner/me/${id}`, formData, {
+  const { data } = await api.put(`/franchisee/unit/partners/${id}`, formData, {
     headers: { "Content-Type": "multipart/form-data" },
   });
 
@@ -22,6 +22,6 @@ export const updatePartner = async (id, formData) => {
 };
 
 export const deletePartner = async (id) => {
-  const { data } = await api.delete(`/unit-partner/me/${id}`);
+  const { data } = await api.delete(`/franchisee/unit/partners/${id}`);
   return data;
 };

+ 21 - 0
src/api/unit_payable.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getPayablesMe = async (params = {}) => {
+  const { data } = await api.get("/franchisee/financial/payables", { params });
+  return data.payload;
+};
+
+export const createPayableMe = async (payload) => {
+  const { data } = await api.post("/franchisee/financial/payables", payload);
+  return data.payload;
+};
+
+export const settlePayableMe = async (id, payload = {}) => {
+  const { data } = await api.post(`/franchisee/financial/payables/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const reopenPayableMe = async (id) => {
+  const { data } = await api.post(`/franchisee/financial/payables/${id}/reopen`);
+  return data.payload;
+};

+ 11 - 0
src/api/unit_payment_account.js

@@ -0,0 +1,11 @@
+import api from "src/api";
+
+export const getPaymentAccountMe = async () => {
+  const { data } = await api.get("/franchisee/integrations/asaas");
+  return data.payload;
+};
+
+export const savePaymentAccountMe = async (payload) => {
+  const { data } = await api.post("/franchisee/integrations/asaas", payload);
+  return data.payload;
+};

+ 37 - 0
src/api/unit_receivable.js

@@ -0,0 +1,37 @@
+import api from "src/api";
+
+export const getReceivablesMe = async (params = {}) => {
+  const { data } = await api.get("/franchisee/financial/receivables", { params });
+  return data.payload;
+};
+
+export const settleReceivableMe = async (id, payload = {}) => {
+  const { data } = await api.post(`/franchisee/financial/receivables/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const reopenReceivableMe = async (id) => {
+  const { data } = await api.post(`/franchisee/financial/receivables/${id}/reopen`);
+  return data.payload;
+};
+
+export const chargeReceivableMe = async (id) => {
+  const { data } = await api.post(`/franchisee/financial/receivables/${id}/charge`);
+  return data.payload;
+};
+
+// Recebíveis avulsos (manuais)
+export const createManualReceivableMe = async (payload) => {
+  const { data } = await api.post("/franchisee/financial/receivables", payload);
+  return data.payload;
+};
+
+export const settleManualReceivableMe = async (id, payload = {}) => {
+  const { data } = await api.post(`/franchisee/financial/receivables/manual/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const reopenManualReceivableMe = async (id) => {
+  const { data } = await api.post(`/franchisee/financial/receivables/manual/${id}/reopen`);
+  return data.payload;
+};

+ 23 - 6
src/api/user.js

@@ -6,17 +6,32 @@ export const getUser = async () => {
 };
 
 export const getUsers = async () => {
-  const { data } = await api.get("/user");
+  const { data } = await api.get("/franchisee/users");
+  return data.payload;
+};
+
+export const getUserById = async (id) => {
+  const { data } = await api.get(`/franchisee/users/${id}`);
+  return data.payload;
+};
+
+export const getUsersByUnit = async () => {
+  const { data } = await api.get("/selects/franchisee/users");
   return data.payload;
 };
 
 export const createUser = async (user) => {
-  const { data } = await api.post("/user", user);
+  const { data } = await api.post("/franchisee/users", user);
   return data.payload;
 };
 
 export const updateUser = async (user, id) => {
-  const { data } = await api.put(`/user/${id}`, user);
+  if (user instanceof FormData) {
+    user.append("_method", "PUT");
+    const { data } = await api.post(`/franchisee/users/${id}`, user);
+    return data.payload;
+  }
+  const { data } = await api.put(`/franchisee/users/${id}`, user);
   return data.payload;
 };
 
@@ -26,11 +41,13 @@ export const updateUserMe = async (user) => {
 };
 
 export const deleteUser = async (id) => {
-  const { data } = await api.delete(`/user/${id}`);
+  const { data } = await api.delete(`/franchisee/users/${id}`);
   return data.payload;
 };
 
 export const userTypes = async () => {
-  const { data } = await api.get("/user/all/types");
-  return data.payload;
+  const { data } = await api.get("/selects/user-types", {
+    params: { access_scope: "unit" },
+  });
+  return Object.fromEntries(data.payload.map((type) => [type.value, type.label]));
 };

+ 11 - 0
src/api/user_type.js

@@ -0,0 +1,11 @@
+import api from "src/api";
+
+const base = "/franchisee/permissions/groups";
+
+export const getUserTypes = async () => (await api.get(base)).data.payload;
+export const createUserType = async (payload) => (await api.post(base, payload)).data.payload;
+export const updateUserType = async (id, payload) => (await api.put(`${base}/${id}`, payload)).data.payload;
+export const deleteUserType = async (id) => (await api.delete(`${base}/${id}`)).data.payload;
+export const getUserTypePermissions = async (id) => (await api.get(`${base}/${id}/permissions`)).data.payload;
+export const updateUserTypePermissions = async (id, permissions) =>
+  (await api.put(`${base}/${id}/permissions`, { permissions })).data.payload;

+ 7 - 1
src/boot/axios.js

@@ -10,7 +10,8 @@ const api = axios.create({
 
 api.interceptors.request.use(
   async (config) => {
-    const accessToken = userStore().accessToken;
+    const store = userStore();
+    const accessToken = store.accessToken;
     const savedLanguage = Cookies.get("locale");
     const language = savedLanguage || window.navigator.language;
     config.headers["Accept-Language"] = language;
@@ -18,6 +19,11 @@ api.interceptors.request.use(
     if (accessToken) {
       config.headers.Authorization = `Bearer ${accessToken}`;
     }
+    // Envia a unidade ativa para o backend filtrar os dados corretamente
+    const activeUnitId = store.selectedUnit?.id;
+    if (activeUnitId) {
+      config.headers["X-Unit-Id"] = activeUnitId;
+    }
     return config;
   },
   (error) => {

+ 3 - 1
src/boot/defaultPropsComponents.js

@@ -1,5 +1,6 @@
 import { QDialog, QInput, QSelect, QScrollArea, QCard } from "quasar";
 import { defineBoot } from "#q-app/wrappers";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 
 /**
  * Set some default properties on a component
@@ -14,7 +15,8 @@ const SetComponentDefaults = (component, defaults) => {
   });
 };
 
-export default defineBoot(() => {
+export default defineBoot(({ app }) => {
+  app.component("DefaultForm", DefaultForm);
   SetComponentDefaults(QDialog, {
     transitionShow: "slide-up",
     transitionHide: "slide-down",

+ 10 - 34
src/components/charts/AniversariantesCard.vue

@@ -5,30 +5,26 @@
       <q-icon name="mdi-cake-variant-outline" color="secondary" size="sm" />
     </div>
 
-    <div class="header-row text-caption text-grey-6 q-mb-xs">
-      <div class="flex items-center" style="gap: 4px">
-        <q-icon name="mdi-calendar-outline" size="14px" color="secondary" />
-        <span>Nome</span>
-      </div>
-      <span>Ações</span>
+    <div class="text-caption text-grey-6 q-mb-xs flex items-center" style="gap: 4px">
+      <q-icon name="mdi-calendar-outline" size="14px" color="secondary" />
+      <span>Nome</span>
     </div>
     <q-separator />
 
     <div class="list-scroll">
-      <q-list dense>
+      <div v-if="people.length === 0" class="text-caption text-grey-5 text-center q-mt-md">
+        Nenhum aniversariante neste mês.
+      </div>
+      <q-list v-else dense>
         <template v-for="(person, index) in people" :key="index">
           <q-item class="q-px-none person-item">
-            <q-item-section avatar style="min-width: 36px">
-              <div class="day-badge">{{ person.day }}</div>
-            </q-item-section>
             <q-item-section>
               <q-item-label class="text-body2">{{ person.name }}</q-item-label>
             </q-item-section>
             <q-item-section side>
-              <div class="flex items-center no-wrap" style="gap: 4px">
-                <q-btn flat round dense size="sm" icon="mdi-whatsapp" color="dark" />
-                <q-btn flat round dense size="sm" icon="mdi-email-outline" color="dark" />
-              </div>
+              <q-item-label class="text-body2 text-weight-medium text-grey-7">
+                Dia {{ person.day }}
+              </q-item-label>
             </q-item-section>
           </q-item>
           <q-separator v-if="index < people.length - 1" />
@@ -55,12 +51,6 @@ defineProps({
   flex-direction: column;
 }
 
-.header-row {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-}
-
 .list-scroll {
   flex: 1;
   overflow-y: auto;
@@ -71,18 +61,4 @@ defineProps({
   padding-top: 6px;
   padding-bottom: 6px;
 }
-
-.day-badge {
-  width: 30px;
-  height: 30px;
-  border-radius: 50%;
-  background-color: #f97316;
-  color: #fff;
-  font-size: 12px;
-  font-weight: 600;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  flex-shrink: 0;
-}
 </style>

+ 85 - 0
src/components/defaults/CustomFileInput.vue

@@ -0,0 +1,85 @@
+<template>
+  <q-file
+    v-model="model"
+    color="secondary"
+    hide-bottom-space
+    label-color="secondary"
+    lazy-rules="ondemand"
+    v-bind="attrs"
+    :accept
+    :class="{ 'required-field': required }"
+    :clearable
+    :dense
+    :error="!!error"
+    :error-message="normalizedErrorMessage"
+    :label
+    :outlined
+    :rules
+    @update:model-value="error = null"
+  >
+    <template #prepend>
+      <slot name="prepend">
+        <q-icon v-if="prependIcon" color="secondary" :name="prependIcon" />
+      </slot>
+    </template>
+
+    <template v-for="(_, slotName) in $slots" #[slotName]="scope">
+      <slot
+        v-if="slotName !== 'prepend'"
+        v-bind="scope ?? {}"
+        :name="slotName"
+      />
+    </template>
+  </q-file>
+</template>
+
+<script setup>
+import { computed, useAttrs } from "vue";
+
+defineOptions({ name: "CustomFileInput", inheritAttrs: false });
+
+const {
+  accept,
+  clearable,
+  dense,
+  errorMessage,
+  label,
+  outlined,
+  prependIcon,
+  rules,
+} = defineProps({
+  accept: { type: String, default: "" },
+  clearable: { type: Boolean, default: false },
+  dense: { type: Boolean, default: false },
+  errorMessage: { type: String, default: undefined },
+  label: { type: String, default: "Arquivo" },
+  outlined: { type: Boolean, default: true },
+  prependIcon: { type: String, default: "mdi-paperclip" },
+  rules: { type: Array, default: () => [] },
+});
+
+const attrs = useAttrs();
+
+const model = defineModel({ type: [File, Array, null] });
+
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+});
+
+const normalizedErrorMessage = computed(() => {
+  if (errorMessage != null) return errorMessage;
+
+  if (error.value == null || typeof error.value === "boolean") return undefined;
+
+  return String(error.value);
+});
+
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
+</script>
+
+<style scoped>
+:deep(.required-field .q-field__label::after) {
+  color: var(--q-negative);
+  content: " *";
+}
+</style>

+ 9 - 1
src/components/defaults/DefaultCepInput.vue

@@ -1,8 +1,8 @@
 <template>
   <DefaultInput
     v-model="model"
-    v-bind="$attrs"
     debounce="500"
+    v-bind="$attrs"
     :class="disable ? 'no-pointer-events' : ''"
     :disable="disable"
     :label
@@ -13,6 +13,7 @@
 <script setup>
 import { nextTick, ref, watch } from "vue";
 import { useQuasar } from "quasar";
+
 import masks from "src/helpers/masks.js";
 
 import DefaultInput from "./DefaultInput.vue";
@@ -53,16 +54,22 @@ watch(
     if (value < 0) {
       model.value = 0;
     }
+
     if (value && value.length > 8) {
       try {
         loading.value = true;
+
         const response = await fetch(`https://viacep.com.br/ws/${value}/json`);
         const data = await response.json();
+
         emit("cepData", data);
         emit("estado", data.estado);
         emit("uf", data.uf);
+
         // this is a hack to work well with the city and state select
+
         await nextTick();
+
         emit("cidade", data.localidade);
         emit("rua", data.logradouro);
         emit("bairro", data.bairro);
@@ -70,6 +77,7 @@ watch(
         emit("complemento", null);
       } catch (error) {
         console.error(error);
+
         $q.notify({
           message: "CEP inválido",
           color: "negative",

+ 25 - 23
src/components/defaults/DefaultCurrencyInput.vue

@@ -3,9 +3,10 @@
     ref="inputRef"
     v-model="formattedValue"
     v-bind="$attrs"
+    :error-message="errorMessage"
+    :input-class="inputClass"
     :label="label"
     :rules="finalRules"
-    :input-class="inputClass"
   />
 </template>
 
@@ -20,33 +21,33 @@ const { inputRules } = useInputRules();
 
 const model = defineModel({ type: Number });
 
-const { options, label, rules } = defineProps({
+const { options, label, rules, errorMessage } = defineProps({
+  errorMessage: {
+    type: String,
+    default: undefined,
+  },
+  inputClass: {
+    type: String,
+    default: "",
+  },
+  label: {
+    type: String,
+    default: "Valor",
+  },
   options: {
     type: Object,
     default: () => ({
-      locale: "pt-BR",
+      accountingSign: false,
+      autoDecimalDigits: true,
       currency: "BRL",
       currencyDisplay: "symbol",
       hideCurrencySymbolOnFocus: false,
       hideGroupingSeparatorOnFocus: false,
       hideNegligibleDecimalDigitsOnFocus: false,
-      autoDecimalDigits: true,
+      locale: "pt-BR",
       useGrouping: true,
-      accountingSign: false,
     }),
   },
-  label: {
-    type: String,
-    default: "Valor",
-  },
-  errorMessage: {
-    type: String,
-    default: "",
-  },
-  inputClass: {
-    type: String,
-    default: "",
-  },
   rules: {
     type: Array,
     default: () => [],
@@ -56,6 +57,13 @@ const { options, label, rules } = defineProps({
 const { inputRef, formattedValue, numberValue, setValue } =
   useCurrencyInput(options);
 
+const finalRules = computed(() =>
+  inputRules.forValue(
+    [...rules, inputRules.minValue(0)],
+    () => numberValue.value,
+  ),
+);
+
 watch(
   () => model.value,
   (newValue) => {
@@ -69,10 +77,4 @@ watch(
     model.value = newValue;
   },
 );
-
-const minRule = inputRules.minValue(0);
-const finalRules = computed(() => [
-  ...rules,
-  () => minRule(numberValue.value),
-]);
 </script>

+ 15 - 9
src/components/defaults/DefaultDialogHeader.vue

@@ -1,6 +1,7 @@
 <template>
-  <q-bar class="bg-transparent q-px-md" v-bind="$attrs" style="height: 55px">
+  <q-bar class="bg-transparent q-px-md" style="height: 55px" v-bind="$attrs">
     <q-icon v-if="icon" :name="icon" />
+
     <div>{{ title }}</div>
 
     <q-space />
@@ -13,31 +14,36 @@
       @click="onMaximazedClick"
     />
 
-    <q-btn dense flat icon="mdi-close" @click="emit('close')" />
+    <q-btn
+      dense
+      flat
+      icon="mdi-close"
+      @click="emit('close')"
+    />
   </q-bar>
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
+import { onMounted, ref } from "vue";
 
 const emit = defineEmits(["maximized", "close"]);
 
 const { title, fullscreen, maximizable, icon } = defineProps({
-  title: {
-    type: String,
-    default: "Titulo",
-  },
   fullscreen: {
     type: Boolean,
     default: false,
   },
+  icon: {
+    type: String,
+    default: "",
+  },
   maximizable: {
     type: Boolean,
     default: false,
   },
-  icon: {
+  title: {
     type: String,
-    default: "",
+    default: "Titulo",
   },
 });
 

+ 76 - 60
src/components/defaults/DefaultFilePicker.vue

@@ -11,17 +11,20 @@
       <slot name="label">
         <span>{{ label }}</span>
       </slot>
+
       <span v-if="required" class="text-negative q-ml-xs">*</span>
     </div>
+
     <q-field
       v-model="model"
-      v-bind="inputAttrs"
       borderless
+      class="image-preview-container"
       hide-bottom-space
-      :rules="rules"
+      lazy-rules="ondemand"
+      v-bind="inputAttrs"
       :error="error"
       :error-message="errorMessage"
-      class="image-preview-container"
+      :rules="rules"
     >
       <div
         class=""
@@ -39,10 +42,11 @@
                   ? 'mdi-image-plus'
                   : 'mdi-file-plus'
             "
-            size="48px"
-            color="grey-6"
             class="absolute-center"
+            color="grey-6"
+            size="48px"
           />
+
           <div
             class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
           >
@@ -58,13 +62,14 @@
 
         <q-img
           v-else-if="type === 'image'"
-          :src="preview"
-          fit="cover"
           class="full-height"
+          fit="cover"
+          :src="preview"
         />
 
         <div v-else class="position-relative column full-height flex-center">
-          <q-icon name="mdi-file-check" size="48px" color="grey-6" />
+          <q-icon color="grey-6" name="mdi-file-check" size="48px" />
+
           <div
             class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
           >
@@ -74,11 +79,11 @@
 
         <div v-if="preview" class="absolute-top-right q-ma-xs">
           <q-btn
-            flat
-            dense
-            round
             color="negative"
+            dense
+            flat
             icon="mdi-close"
+            round
             @click.stop="clearFile"
           />
         </div>
@@ -96,65 +101,83 @@
 
 <script setup>
 import {
-  ref,
-  watch,
+  computed,
   onUnmounted,
-  useTemplateRef,
+  ref,
   useAttrs,
-  computed,
-  onBeforeMount,
+  useTemplateRef,
+  watch,
 } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
-const { label, rules, accept, type, initialImage } = defineProps({
-  label: {
-    type: String,
-    default: "Select Image",
-  },
-  rules: {
-    type: Array,
-    default: () => [],
-  },
+const {
+  accept,
+  error,
+  errorMessage,
+  initialImage,
+  label,
+  rules,
+  type,
+} = defineProps({
   accept: {
     type: String,
     default: "image/*",
   },
-  type: {
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
     type: String,
-    default: "image",
+    default: "",
   },
   initialImage: {
     type: String,
     default: null,
   },
-  error: {
-    type: Boolean,
-    default: false,
+  label: {
+    type: String,
+    default: "Select Image",
   },
-  errorMessage: {
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  type: {
     type: String,
-    default: "",
+    default: "image",
   },
 });
 
 const attrs = useAttrs();
+
 const fileInputRef = useTemplateRef("fileInputRef");
 
-const model = defineModel({ type: [File, String, null], default: null });
+let objectUrl = null;
+
 const base64File = defineModel("base64File", { type: String, default: null });
 
+const model = defineModel({ type: [File, String, null], default: null });
+
 const isDragging = ref(false);
 const preview = ref(initialImage || null);
-const required = ref(false);
 
-let objectUrl = null;
+const inputAttrs = computed(() => {
+  // eslint-disable-next-line
+  const { class: _, style: __, ...rest } = attrs;
+
+  return rest;
+});
+
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
 
 const cleanupObjectURL = () => {
   if (objectUrl) {
     URL.revokeObjectURL(objectUrl);
+
     objectUrl = null;
   }
 };
@@ -162,30 +185,38 @@ const cleanupObjectURL = () => {
 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 pickFile = () => {
-  fileInputRef.value?.pickFiles();
+  reader.readAsDataURL(file);
 };
 
 const clearFile = () => {
   model.value = null;
 };
 
+const pickFile = () => {
+  fileInputRef.value?.pickFiles();
+};
+
 const handleDragOver = (event) => {
   event.preventDefault();
+
   event.dataTransfer.dropEffect = "copy";
+
   isDragging.value = true;
 };
 
@@ -195,15 +226,18 @@ const handleDragLeave = () => {
 
 const handleDrop = (event) => {
   event.preventDefault();
+
   isDragging.value = false;
 
   const file = event.dataTransfer?.files?.[0];
+
   if (!file) return;
 
   const acceptedMime = accept;
 
   if (acceptedMime.endsWith("/*")) {
     const baseMime = acceptedMime.replace("/*", "");
+
     if (file.type.startsWith(baseMime + "/")) {
       model.value = file;
     }
@@ -219,44 +253,26 @@ const handleDrop = (event) => {
   }
 };
 
-const inputAttrs = computed(() => {
-  // eslint-disable-next-line
-  const { class: _, style: __, ...rest } = attrs;
-  return rest;
-});
-
 watch(model, (newFile) => {
   cleanupObjectURL();
 
   if (newFile && newFile instanceof File) {
     if (type === "image") {
       objectUrl = URL.createObjectURL(newFile);
+
       preview.value = objectUrl;
     } else {
       preview.value = "file_selected";
     }
+
     generateBase64(newFile);
   } else {
     preview.value = initialImage || 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);
-  });
-});
-
 onUnmounted(cleanupObjectURL);
 </script>
 

+ 38 - 0
src/components/defaults/DefaultForm.vue

@@ -0,0 +1,38 @@
+<template>
+  <QForm ref="formRef" v-bind="$attrs" @validation-error="onValidationError">
+    <slot />
+  </QForm>
+</template>
+
+<script setup>
+import { nextTick, useTemplateRef } from "vue";
+import { QForm } from "quasar";
+import { useScroll } from "src/composables/useScroll";
+
+defineOptions({ inheritAttrs: false });
+
+const formRef = useTemplateRef("formRef");
+
+const { scrollToComponent } = useScroll();
+
+const getValidationComponents = (...args) =>
+  formRef.value?.getValidationComponents(...args);
+
+const onValidationError = async (invalidComponent) => {
+  if (!invalidComponent) return;
+
+  await nextTick();
+
+  invalidComponent.focus?.();
+
+  scrollToComponent(invalidComponent);
+};
+
+const resetValidation = (...args) => formRef.value?.resetValidation(...args);
+
+const validate = (...args) => formRef.value?.validate(...args);
+
+const submit = (...args) => formRef.value?.submit(...args);
+
+defineExpose({ validate, resetValidation, getValidationComponents, submit });
+</script>

+ 76 - 0
src/components/defaults/DefaultImagePicker.vue

@@ -0,0 +1,76 @@
+<template>
+  <div class="default-image-picker">
+    <div class="text-caption text-grey-6 q-mb-xs">{{ label }}</div>
+
+    <CustomFileInput
+      v-model="model"
+      :accept="accept"
+      :clearable="clearable"
+      :dense="dense"
+      :error="error"
+      :error-message="errorMessage"
+      :outlined="outlined"
+      @update:model-value="handleFile"
+    >
+      <template #append>
+        <q-icon name="search" />
+      </template>
+    </CustomFileInput>
+
+    <div class="text-caption text-grey-6 q-mt-md q-mb-xs">
+      Pré - Visualização
+    </div>
+
+    <div class="preview-area flex flex-center">
+      <img v-if="previewUrl" class="preview-image" :src="previewUrl" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onBeforeUnmount, ref } from "vue";
+
+import CustomFileInput from "./CustomFileInput.vue";
+
+const { accept, clearable, dense, error, errorMessage, label, outlined } =
+  defineProps({
+    accept: { type: String, default: "image/*" },
+    clearable: { type: Boolean, default: true },
+    dense: { type: Boolean, default: true },
+    error: { type: [String, Object, Array, Boolean], default: null },
+    errorMessage: { type: String, default: undefined },
+    label: { type: String, default: "Personalizar" },
+    outlined: { type: Boolean, default: true },
+  });
+
+const model = defineModel({ type: [File, null] });
+const previewModel = defineModel("previewUrl", { type: String, default: null });
+const previewUrl = ref(null);
+
+const handleFile = (file) => {
+  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
+
+  previewUrl.value = file ? URL.createObjectURL(file) : null;
+
+  previewModel.value = previewUrl.value;
+}
+
+onBeforeUnmount(() => {
+  if (previewUrl.value) URL.revokeObjectURL(previewUrl.value);
+});
+</script>
+
+<style scoped>
+.preview-area {
+  min-height: 180px;
+  border: 1px dashed #c7c7c7;
+  border-radius: 8px;
+  background: #fafafa;
+}
+
+.preview-image {
+  max-width: 100%;
+  max-height: 180px;
+  object-fit: contain;
+}
+</style>

+ 69 - 65
src/components/defaults/DefaultInput.vue

@@ -4,23 +4,24 @@
       <q-input
         ref="inputRef"
         v-model="model"
-        v-bind="inputAttrs"
+        color="secondary"
         hide-bottom-space
         label-color="secondary"
-        color="secondary"
-        :label
-        :error="!!error"
-        :error-message="errorMessage"
-        :rules
-        :outlined
+        lazy-rules="ondemand"
+        v-bind="inputAttrs"
         :bg-color
-        :class="inputClass"
+        :class="[inputClass, { 'required-field': required }]"
+        :error="!!error"
+        :error-message="normalizedErrorMessage"
         :input-class="nativeInputClass"
+        :label
+        :outlined
+        :rules
         @update:model-value="error = null"
       >
         <template #append>
           <slot name="append">
-            <q-icon v-if="icon" :name="icon" size="sm" color="secondary" />
+            <q-icon v-if="icon" color="secondary" size="sm" :name="icon" />
           </slot>
         </template>
       </q-input>
@@ -29,92 +30,90 @@
 </template>
 
 <script setup>
-import {
-  ref,
-  onBeforeMount,
-  useAttrs,
-  computed,
-  watch,
-  useTemplateRef,
-} from "vue";
+import { computed, useAttrs, useTemplateRef } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
-const { label, nativeInputClass, inputClass, rules, icon, bgColor, outlined } =
-  defineProps({
-    label: {
-      type: String,
-      default: "",
-    },
-    icon: {
-      type: String,
-      default: "",
-    },
-    rules: {
-      type: Array,
-      default: () => [],
-    },
-    nativeInputClass: {
-      type: String,
-      default: null,
-    },
-    inputClass: {
-      type: String,
-      default: null,
-    },
-    bgColor: {
-      type: String,
-      default: "white",
-    },
-    outlined: {
-      type: Boolean,
-      default: false,
-    },
-  });
+const {
+  bgColor,
+  errorMessage,
+  icon,
+  inputClass,
+  label,
+  nativeInputClass,
+  outlined,
+  rules,
+} = defineProps({
+  bgColor: {
+    type: String,
+    default: "white",
+  },
+  errorMessage: {
+    type: String,
+    default: undefined,
+  },
+  icon: {
+    type: String,
+    default: "",
+  },
+  inputClass: {
+    type: String,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: "",
+  },
+  nativeInputClass: {
+    type: String,
+    default: null,
+  },
+  outlined: {
+    type: Boolean,
+    default: false,
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
 
 const attrs = useAttrs();
 
 const inputRef = useTemplateRef("inputRef");
 
 const model = defineModel({ type: [String, Object, Array, Boolean, null] });
+
 const error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
 
-const required = ref(false);
-
 const inputAttrs = computed(() => {
   // eslint-disable-next-line
   const { class: _, style: __, ...rest } = attrs;
+
   return rest;
 });
 
-const errorMessage = computed(() => {
+const normalizedErrorMessage = computed(() => {
+  if (errorMessage != null) {
+    return errorMessage;
+  }
+
   if (error.value == null) {
     return void 0;
   }
+
   if (typeof error.value === "boolean") {
     return void 0;
   }
+
   return String(error.value);
 });
 
-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);
-  });
-});
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
 
 defineExpose({
   inputRef,
@@ -125,4 +124,9 @@ defineExpose({
 :deep(.q-field--outlined.q-field--rounded .q-field__control) {
   border-radius: 8px;
 }
+
+:deep(.required-field .q-field__label::after) {
+  color: var(--q-negative);
+  content: " *";
+}
 </style>

+ 40 - 27
src/components/defaults/DefaultInputDatePicker.vue

@@ -2,22 +2,22 @@
   <DefaultInput
     v-model="treatedDate"
     v-model:error="error"
+    clearable
     v-bind="$attrs"
-    :input-ref="inputRef"
     :label="label"
     :mask="inputMask"
-    clearable
   >
     <template #append>
       <q-icon
-        :name="time ? 'mdi-calendar-clock' : 'mdi-calendar'"
         class="cursor-pointer"
+        color="secondary"
+        :name="time ? 'mdi-calendar-clock' : 'mdi-calendar'"
       >
-        <q-popup-proxy cover transition-show="scale" transition-hide="scale">
+        <q-popup-proxy cover transition-hide="scale" transition-show="scale">
           <template v-if="!time">
             <q-date v-model="date" mask="YYYY-MM-DD">
               <div class="row items-center justify-end">
-                <q-btn v-close-popup label="OK" color="primary" flat />
+                <q-btn v-close-popup color="primary" flat label="OK" />
               </div>
             </q-date>
           </template>
@@ -26,11 +26,11 @@
             <q-tab-panels
               v-model="activePanel"
               animated
+              class="bg-white"
               transition-prev="slide-right"
               transition-next="slide-left"
-              class="bg-white"
             >
-              <q-tab-panel name="date" class="q-pa-none">
+              <q-tab-panel class="q-pa-none" name="date">
                 <q-date
                   v-model="date"
                   mask="YYYY-MM-DD HH:mm"
@@ -38,8 +38,8 @@
                 />
               </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-tab-panel class="q-pa-none" name="time">
+                <q-time v-model="date" format24h mask="YYYY-MM-DD HH:mm" />
               </q-tab-panel>
             </q-tab-panels>
           </template>
@@ -50,8 +50,9 @@
 </template>
 
 <script setup>
-import { watch, ref, computed } from "vue";
+import { computed, ref, watch } from "vue";
 import { useI18n } from "vue-i18n";
+
 import masks from "src/helpers/masks";
 
 import DefaultInput from "./DefaultInput.vue";
@@ -69,11 +70,11 @@ const { label, time } = defineProps({
 
 const treatedDate = defineModel({ type: [String, null] });
 const untreatedDate = defineModel("untreatedDate", { type: [String, null] });
+
 const error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
 
-const inputRef = ref(null);
 const activePanel = ref("date");
 const date = ref();
 
@@ -102,11 +103,10 @@ const unformatDate = (value) => {
 };
 
 const inputMask = computed(() => {
-  if (!inputRef.value) return "";
-
   if (time) {
     return masks.Brasil.datetime;
   }
+
   return masks.Brasil.date;
 });
 
@@ -117,20 +117,18 @@ watch(date, (value) => {
   treatedDate.value = formatDate(value);
 });
 
-watch(
-  treatedDate,
-  (value) => {
-    if (!value) {
-      date.value = null;
-      untreatedDate.value = null;
-      treatedDate.value = null;
-      activePanel.value = "date";
-      return;
-    }
-    date.value = unformatDate(value);
-  },
-  { immediate: true },
-);
+watch(treatedDate, (value) => {
+  if (!value) {
+    date.value = null;
+    untreatedDate.value = null;
+    treatedDate.value = null;
+    activePanel.value = "date";
+
+    return;
+  }
+
+  date.value = unformatDate(value);
+});
 
 watch(untreatedDate, (value) => {
   if (!value) {
@@ -138,9 +136,24 @@ watch(untreatedDate, (value) => {
     untreatedDate.value = null;
     treatedDate.value = null;
     activePanel.value = "date";
+
     return;
   }
+
   date.value = value;
   treatedDate.value = formatDate(value);
 });
+
+if (untreatedDate.value) {
+  date.value = untreatedDate.value;
+  treatedDate.value = formatDate(untreatedDate.value);
+} else if (treatedDate.value) {
+  date.value = unformatDate(treatedDate.value);
+}
 </script>
+
+<style scoped>
+:deep(.q-field__focusable-action) {
+  color: var(--q-secondary) !important;
+}
+</style>

+ 2 - 1
src/components/defaults/DefaultPasswordInput.vue

@@ -6,9 +6,9 @@
   >
     <template #append>
       <q-icon
-        :name="seePassword ? 'mdi-lock-open-outline' : 'mdi-lock-outline'"
         class="cursor-pointer q-ml-md"
         color="secondary"
+        :name="seePassword ? 'mdi-lock-open-outline' : 'mdi-lock-outline'"
         @click="seePassword = !seePassword"
       />
     </template>
@@ -18,6 +18,7 @@
 import DefaultInput from "./DefaultInput.vue";
 
 const password = defineModel({ type: String });
+
 const seePassword = defineModel("seePassword", {
   default: false,
   type: Boolean,

+ 151 - 58
src/components/defaults/DefaultSelect.vue

@@ -1,111 +1,169 @@
 <template>
-  <div class="column" :class="attrs.class" :style="attrs.style">
-    <div class="col">
-      <q-select
-        ref="selectRef"
-        v-model="model"
-        :label
-        label-color="secondary"
-        v-bind="selectAttrs"
-        :error="!!error"
-        :error-message="errorMessage"
-        :rules
-        :outlined
-        hide-bottom-space
-        :bg-color
-        :class="inputClass"
-        :popup-content-class="popupContentClass"
-        hide-dropdown-icon
-        @update:model-value="error = null"
+  <div
+    :class="[
+      'default-select',
+      attrs.class,
+    ]"
+    :style="attrs.style"
+  >
+    <q-select
+      ref="selectRef"
+      v-model="model"
+      class="default-select-field"
+      color="secondary"
+      dense
+      hide-bottom-space
+      hide-dropdown-icon
+      input-class="default-select-native"
+      label-color="secondary"
+      lazy-rules="ondemand"
+      v-bind="selectAttrs"
+      :bg-color
+      :class="[
+        inputClass,
+        {
+          'required-field': required,
+        },
+      ]"
+      :error="!!error"
+      :error-message="normalizedErrorMessage"
+      :label
+      :outlined
+      :popup-content-class="popupContentClass"
+      :rules
+      @update:model-value="error = null"
+    >
+      <template
+        v-for="(_, slotName) in $slots"
+        #[slotName]="scope"
       >
-        <template v-for="(_, slotName) in $slots" #[slotName]="scope">
-          <slot :name="slotName" v-bind="scope" />
-        </template>
-
-        <template #append>
-          <q-icon :name="dropdownIcon" color="secondary" />
-        </template>
-      </q-select>
-    </div>
+        <slot
+          :name="slotName"
+          v-bind="scope ?? {}"
+        />
+      </template>
+
+      <template #append>
+        <q-icon
+          color="secondary"
+          :name="dropdownIcon"
+        />
+      </template>
+    </q-select>
   </div>
 </template>
 
 <script setup>
-import { ref, onBeforeMount, useAttrs, computed } from "vue";
+import {
+  computed,
+  ref,
+  useAttrs,
+} from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
 const {
-  label,
+  bgColor,
+  dropdownIcon,
+  errorMessage,
   inputClass,
+  label,
+  outlined,
   popupContentClass,
   rules,
-  bgColor,
-  outlined,
-  dropdownIcon,
 } = defineProps({
-  label: {
+  bgColor: {
     type: String,
-    default: "",
+    default: "white",
   },
-  rules: {
-    type: Array,
-    default: () => [],
+  dropdownIcon: {
+    type: String,
+    default: "mdi-chevron-down",
   },
-  inputClass: {
+  errorMessage: {
     type: String,
-    default: null,
+    default: undefined,
   },
-  popupContentClass: {
+  inputClass: {
     type: String,
     default: null,
   },
-  bgColor: {
+  label: {
     type: String,
-    default: "white",
+    default: "",
   },
   outlined: {
     type: Boolean,
     default: false,
   },
-  dropdownIcon: {
+  popupContentClass: {
     type: String,
-    default: "mdi-chevron-down",
+    default: null,
+  },
+  rules: {
+    type: Array,
+    default: () => [],
   },
 });
 
 const attrs = useAttrs();
 
-const model = defineModel({ type: [String, Object, Array, Number, null] });
+const model = defineModel({
+  type: [
+    String,
+    Object,
+    Array,
+    Number,
+    null,
+  ],
+});
+
 const error = defineModel("error", {
-  type: [String, Object, Array, Boolean, null],
+  type: [
+    String,
+    Object,
+    Array,
+    Boolean,
+    null,
+  ],
 });
 
 const selectRef = ref(null);
-const required = ref(false);
 
-const selectAttrs = computed(() => {
-  // eslint-disable-next-line
-  const { class: _, style: __, ...rest } = attrs;
-  return rest;
-});
+const normalizedErrorMessage = computed(() => {
+  if (errorMessage != null) {
+    return errorMessage;
+  }
 
-const errorMessage = computed(() => {
   if (error.value == null) {
     return void 0;
   }
+
   if (typeof error.value === "boolean") {
     return void 0;
   }
+
   return String(error.value);
 });
 
-onBeforeMount(() => {
-  rules.forEach((r) => {
-    if (r?.$id === "required") return (required.value = true);
-  });
+const required = computed(() =>
+  rules.some(
+    (rule) => rule?.$id === "required",
+  ),
+);
+
+const selectAttrs = computed(() => {
+  const {
+    // eslint-disable-next-line no-unused-vars
+    class: _,
+    // eslint-disable-next-line no-unused-vars
+    style: __,
+    ...rest
+  } = attrs;
+
+  return rest;
 });
 
 defineExpose({
@@ -114,7 +172,42 @@ defineExpose({
 </script>
 
 <style scoped>
+.default-select {
+  min-width: 0;
+}
+
+.default-select[class*="col-"] {
+  flex-grow: 0;
+  flex-shrink: 0;
+}
+
+.default-select-field {
+  min-width: 0;
+  width: 100%;
+}
+
+:deep(.default-select-field .q-field__inner),
+:deep(.default-select-field .q-field__control),
+:deep(.default-select-field .q-field__control-container),
+:deep(.default-select-field .q-field__native),
+:deep(.default-select-field .q-field__input) {
+  min-width: 0;
+  max-width: 100%;
+}
+
+:deep(.default-select-native) {
+  color: var(--q-primary) !important;
+  min-width: 0 !important;
+  max-width: 100% !important;
+  -webkit-text-fill-color: var(--q-primary) !important;
+}
+
 :deep(.q-field--outlined.q-field--rounded .q-field__control) {
   border-radius: 8px;
 }
-</style>
+
+:deep(.required-field .q-field__label::after) {
+  color: var(--q-negative);
+  content: " *";
+}
+</style>

+ 183 - 134
src/components/defaults/DefaultTable.vue

@@ -1,19 +1,19 @@
 <template>
   <q-table
     v-model:fullscreen="fullscreen"
+    class="softpar-table q-pa-sm q-mt-md bg-transparent"
     flat
     row-key="id"
+    :columns
+    :filter
+    :grid="$q.screen.lt.sm"
+    :loading
     :pagination="{ rowsPerPage }"
     :pagination-label="getPaginationLabel"
+    :rows
     :rows-per-page-label="$t('common.ui.table.rows_per_page')"
-    :rows-per-page-options="0"
-    :grid="$q.screen.lt.sm"
+    :rows-per-page-options="[0]"
     :visible-columns
-    :filter
-    :columns
-    :loading
-    :rows
-    class="softpar-table q-pa-sm q-mt-md bg-transparent"
     @row-click="onRowClick"
   >
     <template #top>
@@ -23,9 +23,14 @@
       >
         <div v-if="title" class="column text-h6">
           <span>{{ title }}</span>
-          <span class="text-body2">{{
-            `${rows.length} ${descricao} ${feminino ? "cadastradas" : "cadastrados"}`
-          }}</span>
+
+          <span class="text-body2">
+            {{
+              `${rows.length} ${descricao} ${
+                feminino ? "cadastradas" : "cadastrados"
+              }`
+            }}
+          </span>
         </div>
 
         <slot name="top" :rows="rows" />
@@ -35,43 +40,46 @@
         <q-btn
           v-if="addItem"
           color="primary"
-          style="width: 40px; height: 40px"
           icon="mdi-plus"
+          style="width: 40px; height: 40px"
           :outline="outlineAdd"
           @click="onAddItem"
-        >
-        </q-btn>
+        />
       </div>
 
-      <div class="flex full-width align-center q-mb-md" style="gap: 1rem">
+      <div
+        class="flex full-width align-center q-mb-md"
+        style="gap: 1rem"
+      >
         <DefaultInput
           v-if="showSearchField"
           v-model="filter"
-          debounce="250"
-          label="Busque por Unidade, status ou Responsavel"
-          :placeholder="$t('common.actions.search')"
-          clearable
+          bg-color="transparent"
           class="q-mt-sm full-width search-input"
+          clearable
           color="primary"
-          bg-color="transparent"
+          debounce="250"
           dense
           input-class="q-pl-xs"
+          label="Busque por Unidade, status ou Responsavel"
+          :placeholder="$t('common.actions.search')"
         >
           <template #prepend>
-            <q-icon name="mdi-magnify" color="grey-6" />
+            <q-icon color="grey-6" name="mdi-magnify" />
           </template>
         </DefaultInput>
+
         <DefaultSelect
           v-if="showColumnsSelect"
           v-model="visibleColumns"
+          emit-value
+          map-options
           multiple
           options-outlined
+          options-selected-class="text-bold"
+          style="width: 150px"
           :display-value="$q.lang.table.columns"
-          emit-value
-          map-options
           :options="mapColumns"
-          style="width: 150px"
-          options-selected-class="text-bold"
         />
       </div>
     </template>
@@ -80,12 +88,13 @@
       <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"
-            outline
+            class="q-ml-auto q-mr-sm"
             icon="mdi-trash-can-outline"
+            outline
             style="width: 36px"
-            class="q-ml-auto q-mr-sm"
             @click.prevent.stop="onDelete(row.id)"
           />
         </q-item-section>
@@ -96,8 +105,8 @@
       <div class="q-pa-xs col-xs-12 col-sm-6 col-md-4 col-lg-3">
         <q-card
           bordered
-          flat
           class="q-pa-sm"
+          flat
           @click="onRowClick($event, row, index)"
         >
           <q-list dense>
@@ -107,19 +116,25 @@
             >
               <template v-if="col.name !== 'actions'">
                 <q-item-section>
-                  <q-item-label caption>{{ col.label }}</q-item-label>
-                  <q-item-label>{{ col.value }}</q-item-label>
+                  <q-item-label caption>
+                    {{ col.label }}
+                  </q-item-label>
+
+                  <q-item-label>
+                    {{ col.value }}
+                  </q-item-label>
                 </q-item-section>
               </template>
+
               <template v-else>
                 <slot name="body-cell-actions" :row="row" />
+
                 <q-item-section v-if="deleteFunction">
                   <q-btn
-                    v-if="deleteFunction"
-                    outline
+                    class="q-mr-sm"
                     icon="mdi-trash-can-outline"
+                    outline
                     style="width: 36px"
-                    class="q-mr-sm"
                     @click.prevent.stop="onDelete(row.id)"
                   />
                 </q-item-section>
@@ -131,212 +146,250 @@
     </template>
 
     <template #loading>
-      <q-inner-loading showing color="primary" />
+      <q-inner-loading color="primary" showing />
     </template>
 
     <template v-if="!hideNoDataLabel" #no-data>
-      <div v-if="!loading" class="q-my-md row justify-center full-width">
+      <div
+        v-if="!loading"
+        class="q-my-md row justify-center full-width"
+      >
         <div class="q-pa-md body2">
           {{ $t("http.errors.no_records_found") }}
         </div>
       </div>
     </template>
-    <template v-for="slotName in usableSlots($slots)" #[slotName]="data">
+
+    <template
+      v-for="slotName in usableSlots($slots)"
+      #[slotName]="data"
+    >
       <slot :name="slotName" v-bind="data" />
     </template>
   </q-table>
 </template>
 
 <script setup>
-import { ref, onMounted, toRaw, watch } from "vue";
+import { onMounted, ref, toRaw, watch } from "vue";
 import { useI18n } from "vue-i18n";
-import { useRouter } from "vue-router";
 import { useQuasar } from "quasar";
+import { useRouter } from "vue-router";
 
 import DefaultInput from "./DefaultInput.vue";
 import DefaultSelect from "./DefaultSelect.vue";
 
-const emit = defineEmits(["onRowClick", "onAddItem", "noRows"]);
+const emit = defineEmits([
+  "noRows",
+  "onAddItem",
+  "onRowClick",
+]);
 
 const {
-  columns,
+  addItem,
+  addItemRoute,
   apiCall,
+  columns,
+  deleteFunction,
   descricao,
   feminino,
-  outlineAdd,
+  hideNoDataLabel,
+  noApiCall,
   openItem,
   openItemRoute,
-  addItem,
-  addItemRoute,
+  outlineAdd,
   rowsPerPage,
+  showColumnsSelect,
   showSearchField,
-  noApiCall,
-  hideNoDataLabel,
-  deleteFunction,
+  title,
 } = defineProps({
-  title: {
+  addItem: {
+    default: false,
+    type: Boolean,
+  },
+  addItemRoute: {
+    default: "",
     type: String,
+  },
+  apiCall: {
     default: null,
+    type: Function,
   },
   columns: {
-    type: Array,
     required: true,
+    type: Array,
   },
-  apiCall: {
-    type: Function,
+  deleteFunction: {
     default: null,
+    type: Function,
   },
   descricao: {
-    type: String,
     default: "linhas",
+    type: String,
   },
   feminino: {
-    type: Boolean,
     default: true,
+    type: Boolean,
   },
-  outlineAdd: {
+  hideNoDataLabel: {
+    default: false,
     type: Boolean,
+  },
+  noApiCall: {
     default: false,
+    type: Boolean,
   },
   openItem: {
-    type: Boolean,
     default: false,
+    type: Boolean,
   },
   openItemRoute: {
-    type: String,
     default: "",
+    type: String,
   },
-  addItem: {
-    type: Boolean,
+  outlineAdd: {
     default: false,
-  },
-  addItemRoute: {
-    type: String,
-    default: "",
+    type: Boolean,
   },
   rowsPerPage: {
-    type: Number,
     default: 10,
-  },
-  showSearchField: {
-    type: Boolean,
-    default: true,
+    type: Number,
   },
   showColumnsSelect: {
-    type: Boolean,
     default: false,
-  },
-  noApiCall: {
     type: Boolean,
-    default: false,
   },
-  hideNoDataLabel: {
+  showSearchField: {
+    default: true,
     type: Boolean,
-    default: false,
   },
-  deleteFunction: {
-    type: Function,
+  title: {
     default: null,
+    type: String,
   },
 });
 
-const router = useRouter();
 const { t } = useI18n();
 const $q = useQuasar();
+const router = useRouter();
+
+const rows = defineModel("rows", {
+  default: [],
+  type: Array,
+});
 
-const rows = defineModel("rows", { type: Array, default: [] });
 const filter = ref("");
-const loading = ref(true);
 const fullscreen = ref(false);
+const loading = ref(true);
 
-const mapColumns = columns.reduce((accm, column) => {
+const mapColumns = columns.reduce((columnsList, column) => {
   if (!column.required) {
-    accm.push({
+    columnsList.push({
       label: column.label,
       value: column.name,
     });
   }
-  return accm;
+
+  return columnsList;
 }, []);
 
 const visibleColumns = ref(mapColumns.map((column) => column.value));
 
-const getPaginationLabel = (from, to, last) => {
-  return `${from}-${to} ${t("common.ui.table.of")} ${last}`;
-};
-
-const onRowClick = (evt, row, index) => {
-  const item = toRaw(row);
-  if (openItem) {
-    if (openItemRoute) {
-      router.push({ name: openItemRoute, params: { id: item.id } });
-    } else {
-      emit("onRowClick", { evt, row, index });
-    }
-  }
-};
+const getPaginationLabel = (from, to, last) =>
+  `${from}-${to} ${t("common.ui.table.of")} ${last}`;
 
 const onAddItem = () => {
-  if (addItem) {
-    if (addItemRoute) {
-      router.push({ name: addItemRoute });
-    } else {
-      emit("onAddItem");
-    }
+  if (!addItem) return;
+
+  if (addItemRoute) {
+    router.push({
+      name: addItemRoute,
+    });
+
+    return;
   }
+
+  emit("onAddItem");
 };
 
 const onDelete = async (id) => {
-  if (deleteFunction) {
-    $q.dialog({
-      title: t("common.ui.messages.confirm_action"),
-      message: t("common.ui.messages.are_you_sure_delete"),
-      ok: {
-        color: "negative",
-        label: t("common.actions.delete"),
-      },
-      cancel: {
-        color: "primary",
-        outline: true,
-        label: t("common.actions.cancel"),
-      },
-    }).onOk(async () => {
-      loading.value = true;
-      try {
-        await deleteFunction(id);
-        await onRequest();
-      } catch (error) {
-        console.error(error);
-      } finally {
-        loading.value = false;
-      }
-    });
-  }
+  if (!deleteFunction) return;
+
+  $q.dialog({
+    cancel: {
+      color: "primary",
+      label: t("common.actions.cancel"),
+      outline: true,
+    },
+    message: t("common.ui.messages.are_you_sure_delete"),
+    ok: {
+      color: "negative",
+      label: t("common.actions.delete"),
+    },
+    title: t("common.ui.messages.confirm_action"),
+  }).onOk(async () => {
+    loading.value = true;
+
+    try {
+      await deleteFunction(id);
+      await onRequest();
+    } catch (error) {
+      console.error(error);
+    } finally {
+      loading.value = false;
+    }
+  });
 };
 
 const onRequest = async () => {
   if (noApiCall) {
     loading.value = false;
+
     return;
   }
+
   loading.value = true;
 
   const response = await apiCall();
+
   rows.value = response;
 
-  if (rows.value.length == 0) {
+  if (rows.value.length === 0) {
     emit("noRows");
   }
+
   loading.value = false;
 };
 
+const onRowClick = (event, row, index) => {
+  if (!openItem) return;
+
+  const item = toRaw(row);
+
+  if (openItemRoute) {
+    router.push({
+      name: openItemRoute,
+      params: {
+        id: item.id,
+      },
+    });
+
+    return;
+  }
+
+  emit("onRowClick", {
+    event,
+    index,
+    row,
+  });
+};
+
 const usableSlots = (slots) => {
   const availableSlots = Object.keys(slots);
-  const usableSlots = availableSlots.filter(
+
+  return availableSlots.filter(
     (slot) =>
-      !["body-cell-actions", "top", "loading", "no-data"].includes(slot),
+      !["body-cell-actions", "loading", "no-data", "top"].includes(slot),
   );
-  return usableSlots;
 };
 
 watch(
@@ -346,11 +399,7 @@ watch(
   },
 );
 
-onMounted(async () => {
-  await onRequest({
-    filter: undefined,
-  });
-});
+onMounted(onRequest);
 
 defineExpose({
   refresh: onRequest,
@@ -359,4 +408,4 @@ defineExpose({
 
 <style lang="scss">
 @import "src/css/table.scss";
-</style>
+</style>

+ 167 - 133
src/components/defaults/DefaultTableServerSide.vue

@@ -1,20 +1,19 @@
-# DefaultTableServerSide.vue
 <template>
   <q-table
     v-model:fullscreen="fullscreen"
     v-model:pagination="pagination"
-    row-key="id"
-    flat
     class="softpar-table q-pa-sm"
-    :pagination-label="getPaginationLabel"
-    :rows="rows"
-    :rows-per-page-label="$t('common.ui.table.rows_per_page')"
+    flat
+    row-key="id"
+    v-bind="$attrs"
     :columns="columns"
-    :visible-columns="visibleColumns"
     :filter="pagination.filter"
     :grid="$q.screen.lt.sm"
     :loading="loading"
-    v-bind="$attrs"
+    :pagination-label="getPaginationLabel"
+    :rows="rows"
+    :rows-per-page-label="$t('common.ui.table.rows_per_page')"
+    :visible-columns="visibleColumns"
     @row-click="onRowClick"
   >
     <template #top>
@@ -25,12 +24,12 @@
         <DefaultInput
           v-if="showSearchField"
           v-model="pagination.filter"
-          outlined
-          dense
+          autofocus
+          clearable
           debounce="500"
+          dense
+          outlined
           :placeholder="$t('common.actions.search')"
-          clearable
-          autofocus
         >
           <template #append>
             <q-icon name="mdi-magnify" />
@@ -41,16 +40,16 @@
           v-if="showColumnsSelect"
           v-model="visibleColumns"
           class="q-ml-md"
-          multiple
           dense
-          outlined
-          options-outlined
-          :display-value="$q.lang.table.columns"
           emit-value
           map-options
-          :options="mapColumns"
-          style="min-width: 150px"
+          multiple
+          options-outlined
           options-selected-class="text-bold"
+          outlined
+          style="min-width: 150px"
+          :display-value="$q.lang.table.columns"
+          :options="mapColumns"
         />
 
         <q-space />
@@ -59,8 +58,8 @@
           v-if="addItem"
           color="primary"
           padding="12px 16px"
-          :outline="outlineAdd"
           :label="labelAdd"
+          :outline="outlineAdd"
           @click="onAddItem"
         />
       </div>
@@ -70,12 +69,12 @@
       <q-td v-if="deleteFunction">
         <q-item-section>
           <q-btn
+            class="q-ml-auto q-mr-sm"
             color="negative"
-            flat
             dense
+            flat
             icon="mdi-delete"
             style="width: 45px"
-            class="q-ml-auto q-mr-sm"
             @click.prevent.stop="onDelete(row.id)"
           />
         </q-item-section>
@@ -85,6 +84,7 @@
     <template v-if="!hideNoDataLabel" #no-data>
       <div class="q-my-md row justify-center full-width">
         <q-spinner v-if="loading" color="primary" size="30px" />
+
         <div v-else class="q-pa-md body2">
           {{ $t("http.errors.no_records_found") }}
         </div>
@@ -95,47 +95,53 @@
       <div class="flex full-width justify-end">
         <div class="flex items-center">
           {{ $t("common.ui.table.rows_per_page") }}
+
           <DefaultSelect
             v-model="pagination.rowsPerPage"
+            borderless
             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-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 }}
           {{ $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
+            icon="mdi-chevron-left"
+            round
             :disable="scope.isFirstPage"
             @click="prevPage"
           />
+
           <q-btn
-            icon="mdi-chevron-right"
             color="grey-8"
-            round
             dense
             flat
+            icon="mdi-chevron-right"
+            round
             :disable="scope.isLastPage"
             @click="nextPage"
           />
@@ -144,13 +150,13 @@
     </template>
 
     <template v-for="name in $slots" #[name]="data">
-      <slot :name="name" v-bind="data"></slot>
+      <slot :name="name" v-bind="data" />
     </template>
   </q-table>
 </template>
 
 <script setup>
-import { ref, computed, onMounted, toRaw, watch } from "vue";
+import { computed, onMounted, ref, toRaw, watch } from "vue";
 import { useI18n } from "vue-i18n";
 import { useRouter } from "vue-router";
 
@@ -158,179 +164,178 @@ import DefaultInput from "./DefaultInput.vue";
 import DefaultSelect from "./DefaultSelect.vue";
 
 const emit = defineEmits([
-  "onRowClick",
-  "onAddItem",
   "noRows",
+  "onAddItem",
+  "onRowClick",
   "togglePrincipal",
 ]);
 
 const {
-  columns,
+  addItem,
+  addItemRoute,
   apiCall,
-  outlineAdd,
+  columns,
+  deleteFunction,
+  hideNoDataLabel,
+  labelAdd,
   openItem,
   openItemRoute,
-  addItem,
-  addItemRoute,
+  outlineAdd,
   rowsPerPage,
+  showColumnsSelect,
   showSearchField,
-  hideNoDataLabel,
-  deleteFunction,
 } = defineProps({
-  columns: {
-    type: Array,
-    required: true,
+  addItem: {
+    default: true,
+    type: Boolean,
+  },
+  addItemRoute: {
+    default: "",
+    type: String,
   },
   apiCall: {
+    required: true,
     type: Function,
+  },
+  columns: {
     required: true,
+    type: Array,
+  },
+  deleteFunction: {
+    default: null,
+    type: Function,
+  },
+  hideNoDataLabel: {
+    default: false,
+    type: Boolean,
   },
   labelAdd: {
-    type: String,
     default: "Adicionar",
+    type: String,
   },
-  outlineAdd: {
-    type: Boolean,
+  noApiRoute: {
     default: false,
+    type: Boolean,
   },
   openItem: {
-    type: Boolean,
     default: false,
+    type: Boolean,
   },
   openItemRoute: {
-    type: String,
     default: "",
+    type: String,
   },
-  addItem: {
+  outlineAdd: {
+    default: false,
     type: Boolean,
-    default: true,
-  },
-  addItemRoute: {
-    type: String,
-    default: "",
   },
   rowsPerPage: {
-    type: Number,
     default: 10,
+    type: Number,
   },
   showColumnsSelect: {
-    type: Boolean,
     default: false,
+    type: Boolean,
   },
   showSearchField: {
-    type: Boolean,
     default: true,
-  },
-  noApiRoute: {
     type: Boolean,
-    default: false,
-  },
-  hideNoDataLabel: {
-    type: Boolean,
-    default: false,
-  },
-  deleteFunction: {
-    type: Function,
-    default: null,
   },
 });
 
 const { t } = useI18n();
 const router = useRouter();
-const rows = ref([]);
-const loading = ref(true);
+
 const fullscreen = ref(false);
-const rowsPerPageOptions = [10, 15, 25, 50];
+const loading = ref(true);
+const rows = ref([]);
 
 const pagination = ref({
   filter: undefined,
+  from: 0,
   page: 1,
-  rowsPerPage: rowsPerPage,
   rowsNumber: 0,
-  from: 0,
+  rowsPerPage,
   to: 0,
 });
 
-const mapColumns = computed(() => {
-  return columns.reduce((accm, column) => {
+const mapColumns = computed(() =>
+  columns.reduce((columnOptions, column) => {
     if (!column.required) {
-      accm.push({
+      columnOptions.push({
         label: column.label.toUpperCase(),
         value: column.name,
       });
     }
-    return accm;
-  }, []);
-});
 
-const visibleColumns = ref(mapColumns.value.map((column) => column.value));
+    return columnOptions;
+  }, []),
+);
 
-const onRowClick = (evt, row, index) => {
-  const item = toRaw(row);
-  if (openItem) {
-    if (openItemRoute) {
-      router.push({ name: openItemRoute, params: { id: item.id } });
-    } else {
-      emit("onRowClick", { evt, row, index });
-    }
-  }
+const visibleColumns = ref(
+  mapColumns.value.map((column) => column.value),
+);
+
+const rowsPerPageOptions = [10, 15, 25, 50];
+
+let isFetching = false;
+
+const getPaginationLabel = (from, to, total) =>
+  `${from}-${to} ${t?.("common.ui.table.of") ?? "of"} ${total}`;
+
+const nextPage = () => {
+  pagination.value.page += 1;
+
+  onRequest();
 };
 
 const onAddItem = () => {
-  if (addItem) {
-    if (addItemRoute) {
-      router.push({ name: addItemRoute });
-    } else {
-      emit("onAddItem");
-    }
-  }
-};
+  if (!addItem) return;
 
-const onDelete = async (id) => {
-  if (deleteFunction) {
-    loading.value = true;
-    try {
-      await deleteFunction(id);
-      await onRequest();
-    } catch (error) {
-      console.error(error);
-    } finally {
-      loading.value = false;
-    }
+  if (addItemRoute) {
+    router.push({
+      name: addItemRoute,
+    });
+
+    return;
   }
-};
 
-const prevPage = () => {
-  pagination.value.page--;
-  onRequest();
+  emit("onAddItem");
 };
 
-const nextPage = () => {
-  pagination.value.page++;
-  onRequest();
-};
+const onDelete = async (id) => {
+  if (!deleteFunction) return;
+
+  loading.value = true;
 
-const getPaginationLabel = (from, to, total) => {
-  return `${from}-${to} ${t?.("common.ui.table.of") ?? "of"} ${total}`;
+  try {
+    await deleteFunction(id);
+    await onRequest();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    loading.value = false;
+  }
 };
 
-let isFetching = false;
 const onRequest = async () => {
   if (isFetching) return;
 
   isFetching = true;
+
   loading.value = true;
 
   try {
     const response = await apiCall({
+      filter: pagination.value.filter,
       page: pagination.value.page,
       perPage: pagination.value.rowsPerPage,
-      filter: pagination.value.filter,
     });
 
     rows.value = response.data.result.data;
-    pagination.value.rowsNumber = response.data.result.total;
+
     pagination.value.from = response.data.result.from;
+    pagination.value.rowsNumber = response.data.result.total;
     pagination.value.to = response.data.result.to;
 
     if (rows.value.length === 0) {
@@ -339,25 +344,56 @@ const onRequest = async () => {
   } catch (error) {
     console.error("Error fetching data:", error);
   } finally {
-    loading.value = false;
     isFetching = false;
+    loading.value = false;
+  }
+};
+
+const onRowClick = (event, row, index) => {
+  if (!openItem) return;
+
+  const item = toRaw(row);
+
+  if (openItemRoute) {
+    router.push({
+      name: openItemRoute,
+      params: {
+        id: item.id,
+      },
+    });
+
+    return;
   }
+
+  emit("onRowClick", {
+    event,
+    index,
+    row,
+  });
+};
+
+const prevPage = () => {
+  pagination.value.page -= 1;
+
+  onRequest();
 };
 
 watch(
   () => apiCall,
-  () => onRequest(),
+  () => {
+    onRequest();
+  },
 );
 
 watch(
   pagination,
-  async (newVal, oldVal) => {
-    if (!oldVal || loading.value) return;
+  async (newValue, oldValue) => {
+    if (!oldValue || loading.value) return;
 
     if (
-      newVal.rowsPerPage !== oldVal.rowsPerPage ||
-      newVal.filter !== oldVal.filter ||
-      newVal.page !== oldVal.page
+      newValue.rowsPerPage !== oldValue.rowsPerPage ||
+      newValue.filter !== oldValue.filter ||
+      newValue.page !== oldValue.page
     ) {
       await onRequest();
     }
@@ -365,9 +401,7 @@ watch(
   { deep: true },
 );
 
-onMounted(async () => {
-  await onRequest();
-});
+onMounted(onRequest);
 
 defineExpose({
   refresh: onRequest,
@@ -376,4 +410,4 @@ defineExpose({
 
 <style lang="scss">
 @import "src/css/table.scss";
-</style>
+</style>

+ 15 - 13
src/components/defaults/DefaultTabs.vue

@@ -1,20 +1,20 @@
 <template>
   <q-tabs
     v-model="tab"
+    active-bg-color="white"
+    active-color="primary"
+    align="justify"
     class="button bg-background-2 text-font"
     indicator-color="transparent"
-    active-color="primary"
     v-bind="$attrs"
-    align="justify"
-    active-bg-color="white"
   >
     <q-tab
-      v-for="(q_tab, i) in tabsItems"
-      :key="i"
-      :name="q_tab.name"
-      :label="q_tab.label"
-      :disable="q_tab.disable"
-      :class="{ hidden: q_tab.hide }"
+      v-for="(tabItem, index) in tabsItems"
+      :key="index"
+      :class="{ hidden: tabItem.hide }"
+      :disable="tabItem.disable"
+      :label="tabItem.label"
+      :name="tabItem.name"
     />
   </q-tabs>
 </template>
@@ -22,11 +22,13 @@
 <script setup>
 const { tabsItems } = defineProps({
   tabsItems: {
-    type: Array,
-    required: false,
     default: () => [],
+    required: false,
+    type: Array,
   },
 });
 
-const tab = defineModel({ type: String });
-</script>
+const tab = defineModel({
+  type: String,
+});
+</script>

+ 110 - 0
src/components/financial/FinancialCard.vue

@@ -0,0 +1,110 @@
+<template>
+  <q-card
+    class="financial-card"
+    :class="{
+      'financial-card--selected': selected,
+      'financial-card--clickable': clickable,
+    }"
+    @click="clickable && emit('click')"
+  >
+    <div class="row justify-between items-start no-wrap">
+      <span class="text-subtitle1 text-dark card-title">{{ title }}</span>
+      <q-icon :name="icon" size="22px" color="dark" />
+    </div>
+
+    <div class="bottom-area">
+      <div class="text-h5 text-primary value-text">{{ formattedValue }}</div>
+
+      <template v-if="hasFooter">
+        <q-separator class="q-mt-sm q-mb-xs" />
+        <div class="row items-center justify-between no-wrap">
+          <div class="column" style="gap: 2px">
+            <span v-if="percentage !== null" class="text-caption text-foreground">
+              {{ hideValues ? "••" : percentage }}% vs o mês anterior
+            </span>
+            <span v-if="integer !== null" class="text-caption text-foreground">
+              {{ hideValues ? "•" : integer }} {{ integerLabel }}
+            </span>
+          </div>
+          <span v-if="description" class="text-caption text-foreground">
+            {{ description }}
+          </span>
+        </div>
+      </template>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  title: { type: String, required: true },
+  icon: { type: String, default: "mdi-cash-multiple" },
+  financialValue: { type: Number, default: 0 },
+  legend: { type: String, default: "" },
+  percentage: { type: Number, default: null },
+  integer: { type: Number, default: null },
+  integerLabel: { type: String, default: "pagamentos restantes" },
+  description: { type: String, default: "" },
+  hideValues: { type: Boolean, default: false },
+  clickable: { type: Boolean, default: false },
+  selected: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["click"]);
+
+const formattedValue = computed(() =>
+  props.hideValues
+    ? "R$ ••••"
+    : new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(
+        props.financialValue,
+      ),
+);
+
+const hasFooter = computed(
+  () => props.percentage !== null || props.integer !== null || !!props.description,
+);
+</script>
+
+<style scoped lang="scss">
+@import "src/css/quasar.variables.scss";
+
+.financial-card--clickable {
+  cursor: pointer;
+
+  &:hover {
+    box-shadow: 0 0 0 1.5px $primary !important;
+  }
+}
+
+.financial-card--selected {
+  box-shadow: 0 0 0 2px $primary !important;
+}
+
+.financial-card {
+  flex: 1 1 0;
+  min-width: 220px;
+  border-radius: 12px;
+  box-shadow: 0 0 0 1px #c0c0c0c0 !important;
+  padding: 16px 20px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.card-title {
+  line-height: 1.3;
+}
+
+.bottom-area {
+  display: flex;
+  flex-direction: column;
+}
+
+.value-text {
+  font-weight: 600;
+  line-height: 1.2;
+}
+</style>

+ 83 - 0
src/components/financial/FinancialPlanAccountTreeNode.vue

@@ -0,0 +1,83 @@
+<template>
+  <q-expansion-item
+    v-if="hasChildren"
+    v-model="expanded"
+    dense
+    expand-separator
+    header-class="account-tree-row"
+    :style="{ '--account-depth': depth }"
+  >
+    <template #header>
+      <q-item-section class="account-code">{{ node.code }}</q-item-section>
+      <q-item-section class="account-name text-weight-medium">{{ node.description }}</q-item-section>
+      <q-item-section class="account-type">{{ node.chart_type }}</q-item-section>
+      <q-item-section side class="account-actions">
+        <NodeActions :node="node" :can-edit="canEdit" :can-delete="canDelete" @edit="$emit('edit', node)" @delete="$emit('delete', node.id)" />
+      </q-item-section>
+    </template>
+
+    <FinancialPlanAccountTreeNode
+      v-for="child in node.children"
+      :key="child.id"
+      :node="child"
+      :depth="depth + 1"
+      :force-expanded="forceExpanded"
+      :can-edit="canEdit"
+      :can-delete="canDelete"
+      @edit="$emit('edit', $event)"
+      @delete="$emit('delete', $event)"
+    />
+  </q-expansion-item>
+
+  <q-item v-else dense class="account-tree-row" :style="{ '--account-depth': depth }">
+    <q-item-section class="account-code">{{ node.code }}</q-item-section>
+    <q-item-section class="account-name">{{ node.description }}</q-item-section>
+    <q-item-section class="account-type">{{ node.chart_type }}</q-item-section>
+    <q-item-section side class="account-actions">
+      <NodeActions :node="node" :can-edit="canEdit" :can-delete="canDelete" @edit="$emit('edit', node)" @delete="$emit('delete', node.id)" />
+    </q-item-section>
+  </q-item>
+</template>
+
+<script setup>
+import { computed, defineComponent, h, ref, watch } from "vue";
+import { QBtn, QChip } from "quasar";
+
+defineOptions({ name: "FinancialPlanAccountTreeNode" });
+defineEmits(["edit", "delete"]);
+const props = defineProps({
+  node: { type: Object, required: true },
+  depth: { type: Number, default: 0 },
+  forceExpanded: { type: Boolean, default: false },
+  canEdit: { type: Boolean, default: false },
+  canDelete: { type: Boolean, default: false },
+});
+const hasChildren = computed(() => props.node.children?.length > 0);
+const expanded = ref(props.depth === 0);
+watch(() => props.forceExpanded, (value) => value && (expanded.value = true), { immediate: true });
+
+const NodeActions = defineComponent({
+  props: {
+    node: { type: Object, default: () => ({}) },
+    canEdit: Boolean,
+    canDelete: Boolean,
+  },
+  emits: ["edit", "delete"],
+  setup(actionProps, { emit }) {
+    return () => actionProps.node.unit_id
+      ? h("div", { class: "row no-wrap q-gutter-xs" }, [
+          actionProps.canEdit ? h(QBtn, { flat: true, round: true, dense: true, icon: "mdi-pencil-outline", color: "secondary", onClick: (event) => { event.stopPropagation(); emit("edit"); } }) : null,
+          actionProps.canDelete ? h(QBtn, { flat: true, round: true, dense: true, icon: "mdi-trash-can-outline", color: "negative", onClick: (event) => { event.stopPropagation(); emit("delete"); } }) : null,
+        ])
+      : h(QChip, { dense: true, color: "blue-grey-1", textColor: "blue-grey-7", icon: "mdi-lock-outline", label: "Matriz" });
+  },
+});
+</script>
+
+<style scoped>
+:deep(.account-tree-row) { min-height: 48px; padding-left: calc(16px + (var(--account-depth) * 28px)); border-bottom: 1px solid #dedede; }
+:deep(.account-code) { flex: 0 0 15%; min-width: 75px; }
+:deep(.account-name) { flex: 1 1 auto; min-width: 0; }
+:deep(.account-type) { flex: 0 0 16%; min-width: 85px; }
+:deep(.account-actions) { flex: 0 0 105px; }
+</style>

+ 537 - 34
src/components/layout/DefaultHeaderPage.vue

@@ -3,10 +3,20 @@
     <q-breadcrumbs
       v-if="displayBreadcrumbs.length"
       class="q-mb-xs text-secondary"
+      :class="$q.screen.lt.sm ? '' : 'q-pl-lg'"
     >
+      <template #separator>
+        <q-icon
+          class="breadcrumb-separator"
+          name="mdi-chevron-right"
+          size="18px"
+        />
+      </template>
+
       <q-breadcrumbs-el
         v-for="crumb in displayBreadcrumbs"
         :key="crumb.name || crumb.label"
+        :icon="crumb.icon"
         :label="crumb.title"
         :to="crumb.name ? { name: crumb.name, params: crumb.params } : null"
       />
@@ -20,37 +30,68 @@
         >
           {{ displayTitle }}
         </span>
+
         <div v-else style="width: 280px">
           <q-skeleton type="text" height="40px" />
         </div>
+
         <q-icon
           v-if="showFilterIcon"
+          class="q-ml-sm cursor-pointer"
           name="mdi-filter-outline"
-          color="background"
           size="sm"
-          class="q-ml-sm bg-primary"
-          style="border-radius: 8px; padding: 2px"
+          :class="filterOpen ? 'bg-primary' : ''"
+          :color="filterOpen ? 'background' : 'primary'"
+          :style="
+            filterOpen
+              ? 'border-radius: 8px; padding: 2px'
+              : 'border-radius: 8px; padding: 2px; border: 1.5px solid var(--q-primary)'
+          "
+          @click="$emit('show-filter')"
         />
       </div>
+
       <div
         class="flex items-center q-pr-sm"
-        :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
         style="gap: 8px"
+        :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
       >
         <slot name="after" />
 
         <div class="flex items-center no-wrap" style="gap: 12px">
+          <!-- Seletor de unidade: aparece apenas quando o usuário tem mais de uma -->
+          <template v-if="userUnits.length > 1">
+            <DefaultSelect
+              :model-value="store.selectedUnit"
+              :option-label="getUnitLabel"
+              :options="userUnits"
+              label="Unidade"
+              option-value="id"
+              outlined
+              style="width: 280px; flex-shrink: 0"
+              @update:model-value="store.setSelectedUnit"
+            />
+
+            <q-spinner
+              v-if="isUnitLoading"
+              color="primary"
+              size="18px"
+              style="flex-shrink: 0"
+            />
+          </template>
+
           <template v-if="$q.screen.gt.sm && lastLoginFormatted">
             <div
               class="column"
               style="line-height: 1.2; white-space: nowrap; flex-shrink: 0"
             >
-              <span class="text-caption text-grey-6 text-primary text-center"
-                >Ultimo acesso</span
-              >
-              <span class="text-caption text-primary text-center">{{
-                lastLoginFormatted
-              }}</span>
+              <span class="text-caption text-grey-6 text-primary text-center">
+                Ultimo acesso
+              </span>
+
+              <span class="text-caption text-primary text-center">
+                {{ lastLoginFormatted }}
+              </span>
             </div>
           </template>
 
@@ -58,28 +99,277 @@
             class="flex items-center no-wrap"
             style="gap: 2px; flex-shrink: 0"
           >
-            <q-btn flat round dense icon="mdi-bell-badge" color="secondary" />
-            <q-btn flat round dense icon="mdi-account" color="secondary" />
-            <q-btn flat round dense icon="mdi-cog-outline" color="secondary" />
+            <!-- Notificações -->
+            <q-btn flat round dense icon="mdi-bell-badge" color="secondary">
+              <q-badge
+                v-if="unreadCount > 0"
+                color="negative"
+                floating
+                rounded
+                :label="unreadCount"
+              />
+
+              <q-menu
+                anchor="bottom right"
+                class="header-menu"
+                self="top right"
+                :offset="[0, 8]"
+                @before-show="loadNotifications"
+              >
+                <div class="notifications-panel">
+                  <div class="row items-center justify-between q-px-md q-py-sm">
+                    <span
+                      class="text-subtitle2 text-primary text-weight-medium"
+                    >
+                      Notificações
+                    </span>
+
+                    <q-btn
+                      color="secondary"
+                      dense
+                      flat
+                      label="Marcar tudo como lido"
+                      no-caps
+                      size="sm"
+                      :disable="unreadCount === 0"
+                      @click="markAllAsRead"
+                    />
+                  </div>
+
+                  <q-separator />
+
+                  <q-scroll-area
+                    v-if="notifications.length"
+                    style="height: 360px"
+                    :thumb-style="{ width: '5px', borderRadius: '4px' }"
+                  >
+                    <q-list separator>
+                      <q-item
+                        v-for="n in notifications"
+                        :key="n.id"
+                        v-close-popup
+                        :class="!n.is_read ? 'bg-grey-2' : ''"
+                        clickable
+                        @click="openNotification(n)"
+                      >
+                        <q-item-section avatar top>
+                          <q-icon
+                            :color="n.is_read ? 'grey-5' : 'secondary'"
+                            :name="iconFor(n.notification_type)"
+                            size="24px"
+                          />
+                        </q-item-section>
+
+                        <q-item-section>
+                          <q-item-label
+                            class="text-primary"
+                            :class="!n.is_read ? 'text-weight-medium' : ''"
+                          >
+                            {{ n.title }}
+                          </q-item-label>
+
+                          <q-item-label caption lines="2">
+                            {{ n.message }}
+                          </q-item-label>
+
+                          <q-item-label caption class="text-grey-6 q-mt-xs">
+                            {{ formatNotificationDate(n.created_at) }}
+                          </q-item-label>
+                        </q-item-section>
+
+                        <q-item-section v-if="!n.is_read" side top>
+                          <q-badge
+                            color="negative"
+                            rounded
+                            style="padding: 4px"
+                          />
+                        </q-item-section>
+                      </q-item>
+                    </q-list>
+                  </q-scroll-area>
+
+                  <div
+                    v-else
+                    class="column items-center justify-center q-pa-lg text-grey-6"
+                  >
+                    <q-icon name="mdi-bell-off-outline" size="32px" />
+
+                    <span class="text-caption q-mt-sm">Sem notificações</span>
+                  </div>
+                </div>
+              </q-menu>
+            </q-btn>
+
+            <!-- Perfil do usuário -->
+            <q-btn flat round dense icon="mdi-account" color="secondary">
+              <q-menu
+                anchor="bottom right"
+                class="header-menu"
+                self="top right"
+                :offset="[0, 8]"
+              >
+                <div class="profile-panel q-pa-md">
+                  <div class="row items-center no-wrap q-gutter-x-md">
+                    <q-img
+                      class="avatar-circle"
+                      :src="user?.avatar_url || 'icons/user-icon.jpg'"
+                    />
+
+                    <div class="column" style="min-width: 0">
+                      <span
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
+                        {{ user?.name || "Usuário" }}
+                      </span>
+
+                      <span class="text-caption text-grey-6 ellipsis">
+                        {{ user?.email }}
+                      </span>
+                    </div>
+                  </div>
+
+                  <q-separator class="q-my-md" />
+
+                  <q-btn
+                    v-close-popup
+                    align="left"
+                    class="full-width"
+                    color="primary"
+                    flat
+                    icon="mdi-account-edit-outline"
+                    label="Editar perfil"
+                    no-caps
+                    @click="goToEditProfile"
+                  />
+                </div>
+              </q-menu>
+            </q-btn>
+
+            <!-- Configurações -->
+            <q-btn color="secondary" dense flat icon="mdi-cog-outline" round>
+              <q-menu
+                anchor="bottom right"
+                class="header-menu"
+                self="top right"
+                :offset="[0, 8]"
+                @before-show="loadUnitDetails"
+              >
+                <div class="settings-panel q-pa-md">
+                  <div class="column q-gutter-y-sm">
+                    <div>
+                      <span class="text-caption text-grey-6">
+                        Nome da Unidade
+                      </span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
+                        {{ unitInfo.name || "Não informado" }}
+                      </div>
+                    </div>
+
+                    <div>
+                      <span class="text-caption text-grey-6">
+                        Nome fantasia
+                      </span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
+                        {{ unitInfo.fantasy_name || "Não informado" }}
+                      </div>
+                    </div>
+
+                    <div>
+                      <span class="text-caption text-grey-6">Razão social</span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
+                        {{ unitInfo.social_reason || "Não informado" }}
+                      </div>
+                    </div>
+
+                    <div>
+                      <span class="text-caption text-grey-6">
+                        Franqueado Operador
+                      </span>
+
+                      <div
+                        class="text-body2 text-primary text-weight-medium ellipsis"
+                      >
+                        {{ unitInfo.name_responsible || "Não informado" }}
+                      </div>
+                    </div>
+                  </div>
+
+                  <q-separator class="q-my-md" />
+
+                  <q-btn
+                    v-close-popup
+                    align="left"
+                    class="full-width"
+                    color="primary"
+                    flat
+                    icon="mdi-store-edit-outline"
+                    label="Editar unidade"
+                    no-caps
+                    @click="goToEditUnit"
+                  />
+
+                  <q-btn
+                    v-close-popup
+                    align="left"
+                    class="full-width q-mt-xs"
+                    color="negative"
+                    flat
+                    icon="mdi-logout"
+                    label="Sair"
+                    no-caps
+                    @click="logoutFn"
+                  />
+                </div>
+              </q-menu>
+            </q-btn>
           </div>
         </div>
       </div>
     </div>
+
     <q-separator class="q-my-sm" />
   </div>
 </template>
 
 <script setup>
-import { computed } from "vue";
-import { useRoute } from "vue-router";
+import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
+import { formatUnitName } from "src/helpers/utils";
+
+import {
+  getMyNotifications,
+  getUnreadCount,
+  markNotificationRead,
+  markAllNotificationsRead,
+} from "src/api/notification";
+
+import { getUnitMe } from "src/api/unit";
+import { joinRoom, leaveRoom, socket } from "src/boot/socket.io";
+import { useAuth } from "src/composables/useAuth";
 import { useI18n } from "vue-i18n";
+import { useRoute, useRouter } from "vue-router";
 import { userStore } from "src/stores/user";
 
-const { breadcrumbs, showFilterIcon, title } = defineProps({
+
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { breadcrumbs, filterOpen, showFilterIcon, title } = defineProps({
   breadcrumbs: {
     type: Array,
     default: null,
   },
+  filterOpen: {
+    type: Boolean,
+    default: false,
+  },
   showFilterIcon: {
     type: Boolean,
     default: false,
@@ -90,46 +380,216 @@ const { breadcrumbs, showFilterIcon, title } = defineProps({
   },
 });
 
+defineEmits(["show-filter"]);
+
 const route = useRoute();
+const router = useRouter();
 const { t } = useI18n();
 const store = userStore();
+const { logout } = useAuth();
 
-const lastLoginFormatted = computed(() => {
-  const raw = store.user?.last_login_at;
-  if (!raw) return null;
-  const d = new Date(raw.replace(" ", "T") + "Z");
-  return new Intl.DateTimeFormat("pt-BR", {
-    day: "2-digit",
-    month: "2-digit",
-    year: "numeric",
-    hour: "2-digit",
-    minute: "2-digit",
-    timeZone: "America/Sao_Paulo",
-  }).format(d);
+const isUnitLoading = ref(false);
+
+let loadingTimer = null;
+
+const unitDetails = ref(null);
+const unitInfo = computed(() => unitDetails.value || store.selectedUnit || {});
+
+const user = computed(() => store.user);
+const userUnits = computed(() => store.user?.units ?? []);
+
+const displayBreadcrumbs = computed(() => {
+  const items = breadcrumbs?.length ? breadcrumbs : route.meta?.breadcrumbs;
+
+  return (items || []).map((b) => ({
+    ...b,
+    title: b.translate ? t(b.title) : b.title,
+  }));
 });
 
 const displayTitle = computed(() => {
   if (title) {
     if (typeof title === "string") return title;
+
     if (title.translate) return t(title.value);
+
     return title.value;
   } else if (route.meta?.title) {
     const metaTitle = route.meta.title;
+
     if (typeof metaTitle === "string") return metaTitle;
+
     if (metaTitle.translate) return t(metaTitle.value);
+
     return metaTitle.value;
   }
+
   return null;
 });
 
-const displayBreadcrumbs = computed(() => {
-  const items = breadcrumbs?.length ? breadcrumbs : route.meta?.breadcrumbs;
+const lastLoginFormatted = computed(() => {
+  const raw = store.user?.last_login_at;
 
-  return (items || []).map((b) => ({
-    ...b,
-    title: b.translate ? t(b.title) : b.title,
-  }));
+  if (!raw) return null;
+
+  const d = new Date(raw.replace(" ", "T"));
+
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+    timeZone: "America/Fortaleza",
+  }).format(d);
 });
+
+const userRoom = computed(() =>
+  store.user?.id ? `user.${store.user.id}` : null,
+);
+
+const getUnitLabel = (unit) => formatUnitName(unit);
+
+const goToEditProfile = () => {
+  if (!user.value?.id) return;
+
+  router.push({ name: "UserEditPage", params: { id: user.value.id } });
+};
+
+const goToEditUnit = () => {
+  router.push({ name: "UnitDataPage" });
+};
+
+const loadUnitDetails = async () => {
+  try {
+    unitDetails.value = await getUnitMe();
+  } catch (e) {
+    unitDetails.value = store.selectedUnit;
+
+    console.error("Falha ao carregar dados da unidade:", e);
+  }
+};
+
+const logoutFn = async () => {
+  await logout();
+
+  router.push({ name: "LoginPage" });
+};
+
+// ------------------- Notificações (sino) -------------------
+
+const notifications = ref([]);
+
+const unreadCount = ref(0);
+
+const NOTIFICATION_ICONS = {
+  support_ticket_created: "mdi-lifebuoy",
+  support_ticket_reply: "mdi-message-reply-text-outline",
+  kanban_created: "mdi-clipboard-plus-outline",
+  kanban_reply: "mdi-comment-text-outline",
+  kanban_status_changed: "mdi-swap-horizontal",
+  tbr_month_closed: "mdi-cash-multiple",
+  account_payable_created: "mdi-file-document-plus-outline",
+  payment_credited: "mdi-cash-check",
+};
+
+const formatNotificationDate = (raw) => {
+  if (!raw) return "";
+
+  const d = new Date(String(raw).replace(" ", "T"));
+
+  return new Intl.DateTimeFormat("pt-BR", {
+    day: "2-digit",
+    month: "2-digit",
+    year: "numeric",
+    hour: "2-digit",
+    minute: "2-digit",
+  }).format(d);
+};
+
+const iconFor = (type) => NOTIFICATION_ICONS[type] || "mdi-bell-outline";
+
+const loadNotifications = async () => {
+  try {
+    notifications.value = await getMyNotifications();
+  } catch (e) {
+    console.error("Falha ao carregar notificações:", e);
+  }
+};
+
+const loadUnreadCount = async () => {
+  try {
+    unreadCount.value = await getUnreadCount();
+  } catch (e) {
+    console.error("Falha ao carregar contador de notificações:", e);
+  }
+};
+
+const markAllAsRead = async () => {
+  try {
+    await markAllNotificationsRead();
+
+    notifications.value.forEach((n) => (n.is_read = true));
+
+    unreadCount.value = 0;
+  } catch (e) {
+    console.error("Falha ao marcar todas como lidas:", e);
+  }
+};
+
+const openNotification = async (n) => {
+  if (!n.is_read) {
+    try {
+      await markNotificationRead(n.id);
+
+      n.is_read = true;
+
+      unreadCount.value = Math.max(0, unreadCount.value - 1);
+    } catch (e) {
+      console.error("Falha ao marcar como lida:", e);
+    }
+  }
+  if (n.url) router.push(n.url);
+};
+
+const onRealtimeNotification = () => {
+  loadUnreadCount();
+
+  loadNotifications();
+};
+
+onBeforeUnmount(() => {
+  if (userRoom.value) {
+    socket.off("notification", onRealtimeNotification);
+
+    leaveRoom(userRoom.value);
+  }
+});
+
+onMounted(() => {
+  loadUnreadCount();
+
+  if (userRoom.value) {
+    joinRoom(userRoom.value);
+
+    socket.on("notification", onRealtimeNotification);
+  }
+});
+
+watch(
+  () => store.selectedUnit?.id,
+  (newId, oldId) => {
+    if (newId && oldId !== undefined && newId !== oldId) {
+      isUnitLoading.value = true;
+
+      clearTimeout(loadingTimer);
+
+      loadingTimer = setTimeout(() => {
+        isUnitLoading.value = false;
+      }, 1500);
+    }
+  },
+);
 </script>
 
 <style scoped>
@@ -139,6 +599,7 @@ const displayBreadcrumbs = computed(() => {
 
 .default-header-page :deep(.q-breadcrumbs) {
   display: inline-flex;
+  align-items: center;
   max-width: 100%;
 }
 
@@ -146,9 +607,51 @@ const displayBreadcrumbs = computed(() => {
 .default-header-page :deep(.q-breadcrumbs__separator) {
   flex: 0 0 auto;
   width: auto;
+  display: inline-flex;
+  align-items: center;
+  height: 24px;
+  line-height: 24px;
 }
 
 .default-header-page :deep(.q-breadcrumbs__el) {
   max-width: max-content;
 }
+
+.default-header-page :deep(.q-breadcrumbs__el-icon) {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  width: 24px;
+  height: 24px;
+  margin-top: 0;
+  line-height: 1;
+  vertical-align: middle;
+}
+
+.breadcrumb-separator {
+  align-self: center;
+  line-height: 1;
+}
+
+.header-menu {
+  border-radius: 10px;
+}
+
+.notifications-panel {
+  width: 340px;
+  max-width: 90vw;
+}
+
+.avatar-circle {
+  width: 36px;
+  height: 36px;
+  border-radius: 50%;
+  flex-shrink: 0;
+}
+
+.profile-panel,
+.settings-panel {
+  width: 280px;
+  max-width: 90vw;
+}
 </style>

+ 166 - 114
src/components/layout/LeftMenuLayout.vue

@@ -2,15 +2,15 @@
   <q-drawer
     v-bind="$attrs"
     :model-value="true"
-    show-if-above
-    no-swipe-close
-    no-swipe-open
-    :width="214"
-    :mini-width="60"
+    :behavior="'desktop'"
     :breakpoint="500"
     :mini="miniState"
-    :behavior="'desktop'"
+    :mini-width="60"
+    :width="214"
     class="detached-container"
+    no-swipe-close
+    no-swipe-open
+    show-if-above
   >
     <div class="column full-height no-wrap q-drawer-container">
       <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
@@ -31,9 +31,12 @@
         <div
           v-if="!$q.screen.lt.md"
           class="toggle-button-wrapper absolute"
-          style="top: 500px; right: 8px; z-index: 1"
+          style="top: 4px; right: -32px; z-index: 1; height: 24px"
         >
-          <div @click="miniState = !miniState">
+          <div
+            class="flex items-center justify-center full-height"
+            @click="miniState = !miniState"
+          >
             <q-icon
               size="sm"
               :name="
@@ -55,120 +58,80 @@
             >
           </div>
         </div>
-        <q-list class="column no-wrap">
-          <template v-for="(item, index) in navigation_store.navigationItems">
-            <template v-if="item.permission">
-              <q-item
-                v-if="item.type === 'single'"
-                :key="item.name"
-                v-ripple
-                clickable
-                :exact="item.name == 'HomePage'"
-                exact-active-class="menu-selected"
-                active-class="menu-selected"
-                :to="{ name: item.name }"
-              >
-                <q-item-section avatar>
-                  <q-icon
-                    :name="item.icon"
-                    style="font-size: 20px"
-                    color="secondary"
-                  />
-                </q-item-section>
-                <q-item-section>{{ $t(item.title) }}</q-item-section>
-                <q-tooltip
-                  v-if="miniState"
-                  anchor="center right"
-                  self="center left"
-                  :offset="[10, 10]"
-                  >{{ $t(item.title) }}</q-tooltip
+        <q-scroll-area class="menu-scroll col" bar-color="secondary">
+          <q-list class="column no-wrap">
+            <template v-for="(item, index) in navigation_store.navigationItems">
+              <template v-if="item.permission">
+                <q-item
+                  v-if="item.type === 'single'"
+                  :key="item.name"
+                  v-ripple
+                  clickable
+                  :exact="item.name == 'HomePage'"
+                  exact-active-class="menu-selected"
+                  active-class="menu-selected"
+                  :to="{ name: item.name }"
                 >
-              </q-item>
-              <!-- Expansive Menu with children -->
-              <div v-else :key="item.title">
-                <template v-if="!miniState">
+                  <q-item-section avatar>
+                    <q-icon
+                      :name="item.icon"
+                      style="font-size: 20px"
+                      color="secondary"
+                    />
+                  </q-item-section>
+                  <q-item-section>{{ menuLabel(item.title) }}</q-item-section>
                   <q-tooltip
                     v-if="miniState"
                     anchor="center right"
                     self="center left"
                     :offset="[10, 10]"
-                    >{{ $t(item.title) }}</q-tooltip
-                  >
-                  <q-expansion-item
-                    v-model="isExpasionItemExpanded[index]"
-                    :class="{
-                      'menu-selected':
-                        childrenAreActive(item.childrens) &&
-                        !isExpasionItemExpanded[index],
-                    }"
-                  >
-                    <template #header>
-                      <q-item-section avatar>
-                        <q-icon :name="item.icon" style="font-size: 20px" />
-                      </q-item-section>
-                      <q-item-section>{{ $t(item.title) }}</q-item-section>
-                    </template>
-                    <div v-for="child in item.childrens" :key="child.name">
-                      <q-item
-                        v-ripple
-                        clickable
-                        :to="{ name: child.name }"
-                        exact
-                        exact-active-class="menu-selected"
-                        class="q-pl-lg"
-                      >
-                        <q-item-section avatar>
-                          <q-icon :name="child.icon" style="font-size: 20px" />
-                        </q-item-section>
-                        <q-item-section>{{ $t(child.title) }}</q-item-section>
-                        <q-tooltip
-                          v-if="miniState"
-                          anchor="center right"
-                          self="center left"
-                          :offset="[10, 10]"
-                          >{{ $t(child.title) }}</q-tooltip
-                        >
-                      </q-item>
-                    </div>
-                  </q-expansion-item>
-                </template>
-                <template v-else>
-                  <q-item
-                    v-ripple
-                    clickable
-                    exact
-                    exact-active-class="menu-selected"
-                    :class="{
-                      'menu-selected': childrenAreActive(item.childrens),
-                    }"
+                    >{{ menuLabel(item.title) }}</q-tooltip
                   >
-                    <q-item-section avatar>
-                      <q-icon :name="item.icon" style="font-size: 20px" />
-                    </q-item-section>
-                    <q-item-section>{{ $t(item.title) }}</q-item-section>
+                </q-item>
+                <!-- Expansive Menu with children -->
+                <div v-else :key="item.title">
+                  <template v-if="!miniState">
                     <q-tooltip
                       v-if="miniState"
                       anchor="center right"
                       self="center left"
                       :offset="[10, 10]"
-                      >{{ $t(item.title) }}</q-tooltip
+                      >{{ menuLabel(item.title) }}</q-tooltip
                     >
-                    <q-menu
-                      class="menu-drawer"
-                      anchor="center right"
-                      self="top start"
+                    <q-expansion-item
+                      v-model="isExpasionItemExpanded[index]"
+                      :class="{
+                        'menu-selected':
+                          childrenAreActive(item.childrens) &&
+                          !isExpasionItemExpanded[index],
+                      }"
                     >
-                      <q-list>
+                      <template #header>
+                        <q-item-section avatar>
+                          <q-icon
+                            :name="item.icon"
+                            style="font-size: 20px"
+                            color="secondary"
+                          />
+                        </q-item-section>
+                        <q-item-section>{{
+                          menuLabel(item.title)
+                        }}</q-item-section>
+                      </template>
+                      <div
+                        v-for="child in item.childrens"
+                        :key="child.name"
+                        :class="{
+                          'financial-submenu': item.title === 'Financeiro',
+                        }"
+                      >
                         <q-item
-                          v-for="child in item.childrens"
-                          :key="child.name"
                           v-ripple
-                          v-close-popup
                           clickable
                           :to="{ name: child.name }"
                           exact
                           exact-active-class="menu-selected"
-                          class="menu-drawer"
+                          class="q-pl-lg"
                         >
                           <q-item-section avatar>
                             <q-icon
@@ -176,16 +139,83 @@
                               style="font-size: 20px"
                             />
                           </q-item-section>
-                          <q-item-section>{{ $t(child.title) }}</q-item-section>
+                          <q-item-section>{{
+                            menuLabel(child.title)
+                          }}</q-item-section>
+                          <q-tooltip
+                            v-if="miniState"
+                            anchor="center right"
+                            self="center left"
+                            :offset="[10, 10]"
+                            >{{ menuLabel(child.title) }}</q-tooltip
+                          >
                         </q-item>
-                      </q-list>
-                    </q-menu>
-                  </q-item>
-                </template>
-              </div>
+                      </div>
+                    </q-expansion-item>
+                  </template>
+                  <template v-else>
+                    <q-item
+                      v-ripple
+                      clickable
+                      exact
+                      exact-active-class="menu-selected"
+                      :class="{
+                        'menu-selected': childrenAreActive(item.childrens),
+                      }"
+                    >
+                      <q-item-section avatar>
+                        <q-icon
+                          :name="item.icon"
+                          style="font-size: 20px"
+                          color="secondary"
+                        />
+                      </q-item-section>
+                      <q-item-section>{{
+                        menuLabel(item.title)
+                      }}</q-item-section>
+                      <q-tooltip
+                        v-if="miniState"
+                        anchor="center right"
+                        self="center left"
+                        :offset="[10, 10]"
+                        >{{ menuLabel(item.title) }}</q-tooltip
+                      >
+                      <q-menu
+                        class="menu-drawer"
+                        anchor="center right"
+                        self="top start"
+                      >
+                        <q-list>
+                          <q-item
+                            v-for="child in item.childrens"
+                            :key="child.name"
+                            v-ripple
+                            v-close-popup
+                            clickable
+                            :to="{ name: child.name }"
+                            exact
+                            exact-active-class="menu-selected"
+                            class="menu-drawer"
+                          >
+                            <q-item-section avatar>
+                              <q-icon
+                                :name="child.icon"
+                                style="font-size: 20px"
+                              />
+                            </q-item-section>
+                            <q-item-section>{{
+                              menuLabel(child.title)
+                            }}</q-item-section>
+                          </q-item>
+                        </q-list>
+                      </q-menu>
+                    </q-item>
+                  </template>
+                </div>
+              </template>
             </template>
-          </template>
-        </q-list>
+          </q-list>
+        </q-scroll-area>
         <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
         </q-list>
         <q-list class="q-mt-auto">
@@ -245,11 +275,12 @@
   </q-drawer>
 </template>
 <script setup>
-import { ref, watch, watchEffect, onMounted } from "vue";
-import { useAuth } from "src/composables/useAuth";
-import { useRouter, useRoute } from "vue-router";
 import { navigationStore } from "src/stores/navigation";
+import { onMounted, ref, watch, watchEffect } from "vue";
+import { useAuth } from "src/composables/useAuth";
+import { useI18n } from "vue-i18n";
 import { useQuasar, Cookies } from "quasar";
+import { useRouter, useRoute } from "vue-router";
 import { version } from "src/../package.json";
 
 import Logo from "src/assets/images/logo.svg";
@@ -263,6 +294,8 @@ const router = useRouter();
 const route = useRoute();
 const navigation_store = navigationStore();
 const $q = useQuasar();
+const { t, te } = useI18n();
+const menuLabel = (label) => (te(label) ? t(label) : label);
 
 const miniStateCookies = Cookies.get("miniState");
 const miniState = ref(miniStateCookies === "true" ? true : false);
@@ -319,6 +352,15 @@ onMounted(() => {
   color: $primary;
 }
 
+.financial-submenu :deep(.q-item) {
+  color: $secondary;
+}
+
+.financial-submenu :deep(.q-item.menu-selected) {
+  background-color: rgba($secondary, 0.14);
+  color: $secondary;
+}
+
 .toggle-button-wrapper {
   transition: transform 0.3s ease;
 }
@@ -328,7 +370,17 @@ onMounted(() => {
 }
 
 .q-drawer-container {
-  background-image: url("images/background-opacity.png");
+  background-image: url("/images/background-opacity.png");
   background-position: 15%;
 }
+
+.menu-scroll {
+  min-height: 0;
+}
+
+.menu-scroll :deep(.q-scrollarea__bar--v) {
+  width: 6px;
+  right: 2px;
+  border-radius: 6px;
+}
 </style>

+ 78 - 53
src/components/layout/LeftMenuLayoutMobile.vue

@@ -10,69 +10,75 @@
   >
     <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"
-        >
+        <div class="flex flex-center full-width q-pa-sm" style="height: 50px">
           <q-img :src="Logo" style="max-width: 92px" />
         </div>
       </div>
 
       <div class="column full-height no-wrap">
-        <q-list class="column no-wrap">
-          <template v-for="item in navigation_store.navigationItems">
-            <template v-if="item.permission">
-              <!-- Single Menu -->
-              <q-item
-                v-if="item.type === 'single'"
-                :key="item.name"
-                v-ripple
-                clickable
-                exact-active-class="menu-selected"
-                active-class="menu-selected"
-                :exact="item.name == 'HomePage'"
-                :to="{ name: item.name }"
-              >
-                <q-item-section avatar>
-                  <q-icon :name="item.icon" style="font-size: 20px" />
-                </q-item-section>
-                <q-item-section>{{ $t(item.title) }}</q-item-section>
-              </q-item>
-              <!-- Expansive Menu with children -->
-              <q-expansion-item
-                v-else
-                :key="item.icon"
-                v-model="isExpasionItemExpanded"
-                :class="{
-                  'menu-selected':
-                    childrenAreActive(item.children) && !isExpasionItemExpanded,
-                }"
-              >
-                <template #header>
+        <q-scroll-area class="menu-scroll col" bar-color="secondary">
+          <q-list class="column no-wrap">
+            <template v-for="item in navigation_store.navigationItems">
+              <template v-if="item.permission">
+                <!-- Single Menu -->
+                <q-item
+                  v-if="item.type === 'single'"
+                  :key="item.name"
+                  v-ripple
+                  clickable
+                  exact-active-class="menu-selected"
+                  active-class="menu-selected"
+                  :exact="item.name == 'HomePage'"
+                  :to="{ name: item.name }"
+                >
                   <q-item-section avatar>
                     <q-icon :name="item.icon" style="font-size: 20px" />
                   </q-item-section>
                   <q-item-section>{{ $t(item.title) }}</q-item-section>
-                </template>
-                <div v-for="child in item.childrens" :key="child.name">
-                  <q-item
-                    v-ripple
-                    clickable
-                    :to="{ name: child.name }"
-                    exact
-                    exact-active-class="menu-selected"
-                    class="q-pl-lg"
-                  >
+                </q-item>
+                <!-- Expansive Menu with children -->
+                <q-expansion-item
+                  v-else
+                  :key="item.icon"
+                  v-model="isExpasionItemExpanded"
+                  :class="{
+                    'menu-selected':
+                      childrenAreActive(item.children) &&
+                      !isExpasionItemExpanded,
+                  }"
+                >
+                  <template #header>
                     <q-item-section avatar>
-                      <q-icon :name="child.icon" style="font-size: 20px" />
+                      <q-icon :name="item.icon" style="font-size: 20px" />
                     </q-item-section>
-                    <q-item-section>{{ $t(child.title) }}</q-item-section>
-                  </q-item>
-                </div>
-              </q-expansion-item>
+                    <q-item-section>{{ $t(item.title) }}</q-item-section>
+                  </template>
+                  <div
+                    v-for="child in item.childrens"
+                    :key="child.name"
+                    :class="{
+                      'financial-submenu': item.title === 'Financeiro',
+                    }"
+                  >
+                    <q-item
+                      v-ripple
+                      clickable
+                      :to="{ name: child.name }"
+                      exact
+                      exact-active-class="menu-selected"
+                      class="q-pl-lg"
+                    >
+                      <q-item-section avatar>
+                        <q-icon :name="child.icon" style="font-size: 20px" />
+                      </q-item-section>
+                      <q-item-section>{{ $t(child.title) }}</q-item-section>
+                    </q-item>
+                  </div>
+                </q-expansion-item>
+              </template>
             </template>
-          </template>
-        </q-list>
+          </q-list>
+        </q-scroll-area>
 
         <q-list class="q-mt-auto">
           <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
@@ -98,9 +104,9 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
-import { useRoute, useRouter } from "vue-router";
 import { navigationStore } from "src/stores/navigation";
+import { onMounted, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
 import { version } from "src/../package.json";
 
 import Logo from "src/assets/logo.png";
@@ -149,4 +155,23 @@ onMounted(() => {
   background-color: rgba($primary, 0.1);
   color: $primary;
 }
+
+.financial-submenu :deep(.q-item) {
+  color: $secondary;
+}
+
+.financial-submenu :deep(.q-item.menu-selected) {
+  background-color: rgba($secondary, 0.14);
+  color: $secondary;
+}
+
+.menu-scroll {
+  min-height: 0;
+}
+
+.menu-scroll :deep(.q-scrollarea__bar--v) {
+  width: 6px;
+  right: 2px;
+  border-radius: 6px;
+}
 </style>

+ 164 - 85
src/components/selects/CitySelect.vue

@@ -1,160 +1,239 @@
 <template>
-  <DefaultSelect
-    v-model="selectedCity"
-    v-bind="$attrs"
-    use-input
-    hide-selected
-    fill-input
-    clearable
-    :options="cityOptions"
-    :label
-    :loading
-    :placeholder
-    @filter="filterFn"
+  <div
+    :class="attrs.class"
+    :style="attrs.style"
   >
-    <template #no-option>
-      <q-item>
-        <q-item-section class="text-grey">
-          {{ $t("http.errors.no_records_found") }}
-        </q-item-section>
-      </q-item>
-    </template>
-  </DefaultSelect>
+    <DefaultSelect
+      v-model="selectedCity"
+      v-bind="selectAttrs"
+      class="full-width"
+      clearable
+      fill-input
+      hide-selected
+      stack-label
+      use-input
+      :label="label"
+      :loading="loading"
+      :options="cityOptions"
+      :placeholder="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>
+  </div>
 </template>
 
 <script setup>
+import {
+  computed,
+  onMounted,
+  ref,
+  useAttrs,
+  watch,
+} from "vue";
+
 import { getCities } from "src/api/city";
-import { ref, onMounted, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
+
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const emit = defineEmits(["selectedStateId"]);
+defineOptions({
+  inheritAttrs: false,
+});
+
+const emit = defineEmits([
+  "selectedStateId",
+]);
 
-const { state, label, initialId, country, placeholder } = defineProps({
-  // This country prop is here for future use, maybe
+const {
+  country,
+  initialId,
+  label,
+  placeholder,
+  state,
+} = defineProps({
   country: {
     type: Object,
-    required: false,
-    default: () => {
-      return {
-        label: "Brasil",
-        value: 1,
-      };
-    },
+    default: () => ({
+      label: "Brasil",
+      value: 1,
+    }),
   },
-  state: {
-    type: Object,
-    required: false,
+  initialId: {
+    type: Number,
     default: null,
   },
   label: {
     type: String,
-    default: () => useI18n().t("ui.navigation.city"),
+    default: () =>
+      useI18n().t("ui.navigation.city"),
   },
   placeholder: {
     type: String,
     default: () =>
-      useI18n().t("common.actions.search") +
-      " " +
-      useI18n().t("ui.navigation.city"),
+      `${useI18n().t("common.actions.search")} ${useI18n().t(
+        "ui.navigation.city",
+      )}`,
   },
-  initialId: {
-    type: Number,
-    required: false,
+  state: {
+    type: Object,
     default: null,
   },
 });
 
-const selectedCity = defineModel({ type: Object });
+const attrs = useAttrs();
+
+const selectedCity = defineModel({
+  type: Object,
+});
 
-const loading = ref(true);
 const baseOptions = ref([]);
 const cityOptions = ref([]);
+const loading = ref(true);
 
-const filterFn = async (val, update) => {
-  ensureOnlyPossibleOptions(country?.value, state?.value);
-  const needle = normalizeString(val);
-  cityOptions.value = cityOptions.value.filter((v) => {
-    return (
-      normalizeString(v.label).includes(needle) ||
-      normalizeString(v.code).includes(needle)
-    );
-  });
-  update();
-};
+const selectAttrs = computed(() => {
+  const {
+    // eslint-disable-next-line no-unused-vars
+    class: _,
+    // eslint-disable-next-line no-unused-vars
+    style: __,
+    ...rest
+  } = attrs;
+
+  return rest;
+});
+
+const ensureOnlyPossibleOptions = (
+  countryId,
+  stateId,
+) => {
+  if (!stateId) {
+    cityOptions.value = baseOptions.value;
 
-const selectCityByName = (name) => {
-  if (selectedCity.value?.label === name) {
     return;
   }
-  selectedCity.value = baseOptions.value.find((city) => city.label === name);
+
+  cityOptions.value = baseOptions.value.filter(
+    (city) =>
+      city.state_id === stateId &&
+      (!countryId || city.country_id === countryId),
+  );
+};
+
+const filterFn = (value, update) => {
+  update(() => {
+    ensureOnlyPossibleOptions(
+      country?.value,
+      state?.value,
+    );
+
+    const needle = normalizeString(value);
+
+    cityOptions.value = cityOptions.value.filter(
+      (city) =>
+        normalizeString(city.label).includes(needle) ||
+        normalizeString(city.code ?? "").includes(needle),
+    );
+  });
 };
 
 const selectCityById = (id) => {
   if (selectedCity.value?.value === id) {
     return;
   }
-  selectedCity.value = baseOptions.value.find((city) => city.value === id);
+
+  selectedCity.value =
+    baseOptions.value.find(
+      (city) => city.value === id,
+    ) ?? null;
 };
 
-const ensureOnlyPossibleOptions = (country_id, state_id) => {
-  if (state_id) {
-    cityOptions.value = baseOptions.value.filter((city) => {
-      if (country_id) {
-        return city.country_id === country_id && city.state_id === state_id;
-      }
-      return city.state_id === state_id;
-    });
-  }
-  if (!!state_id && !country_id) {
-    cityOptions.value = baseOptions.value;
+const selectCityByName = (name) => {
+  if (selectedCity.value?.label === name) {
+    return;
   }
+
+  selectedCity.value =
+    baseOptions.value.find(
+      (city) => city.label === name,
+    ) ?? null;
 };
 
 watch(
   () => state,
   (value, oldValue) => {
     if (
-      value?.value != oldValue?.value &&
-      value?.value != selectedCity.value?.state_id
+      value?.value !== oldValue?.value &&
+      value?.value !== selectedCity.value?.state_id
     ) {
       selectedCity.value = null;
     }
-    if (value) {
-      ensureOnlyPossibleOptions(country?.value, value.value);
-    }
+
+    ensureOnlyPossibleOptions(
+      country?.value,
+      value?.value,
+    );
+  },
+  {
+    immediate: true,
   },
-  { immediate: true },
 );
 
-watch(selectedCity, () => {
-  if (selectedCity.value?.state_id) {
-    emit("selectedStateId", selectedCity.value.state_id);
+watch(selectedCity, (city) => {
+  if (city?.state_id) {
+    emit(
+      "selectedStateId",
+      city.state_id,
+    );
   }
 });
 
 onMounted(async () => {
   try {
-    const baseCities = await getCities();
-    baseOptions.value = baseCities.map((city) => ({
+    const cities = await getCities();
+
+    baseOptions.value = cities.map((city) => ({
+      country_id: city.country_id,
       label: city.name,
-      value: city.id,
       state_id: city.state_id,
+      value: city.id,
     }));
-    cityOptions.value = baseOptions.value;
+
+    ensureOnlyPossibleOptions(
+      country?.value,
+      state?.value,
+    );
+
     if (initialId) {
       selectCityById(initialId);
     }
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
     loading.value = false;
   }
 });
 
 defineExpose({
-  selectCityByName,
   selectCityById,
+  selectCityByName,
 });
 </script>
+
+<style scoped>
+div {
+  min-width: 0;
+}
+
+:deep(.q-field) {
+  min-width: 0;
+  width: 100%;
+}
+</style>

+ 159 - 78
src/components/selects/StateSelect.vue

@@ -1,151 +1,232 @@
 <template>
-  <DefaultSelect
-    v-model="selectedState"
-    v-bind="$attrs"
-    use-input
-    hide-selected
-    fill-input
-    clearable
-    :options="stateOptions"
-    :label
-    :loading
-    :placeholder
-    @filter="filterFn"
+  <div
+    :class="attrs.class"
+    :style="attrs.style"
   >
-    <template #no-option>
-      <q-item>
-        <q-item-section class="text-grey">
-          {{ $t("http.errors.no_records_found") }}
-        </q-item-section>
-      </q-item>
-    </template>
-  </DefaultSelect>
+    <DefaultSelect
+      v-model="selectedState"
+      v-bind="selectAttrs"
+      class="full-width"
+      clearable
+      fill-input
+      hide-selected
+      stack-label
+      use-input
+      :label="label"
+      :loading="loading"
+      :options="stateOptions"
+      :placeholder="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>
+  </div>
 </template>
 
 <script setup>
+import {
+  computed,
+  onMounted,
+  ref,
+  useAttrs,
+  watch,
+} from "vue";
+
 import { getStates } from "src/api/state";
-import { ref, onMounted, watch } from "vue";
 import { normalizeString } from "src/helpers/utils";
 import { useI18n } from "vue-i18n";
+
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const emit = defineEmits(["selectedCountryId"]);
+defineOptions({
+  inheritAttrs: false,
+});
+
+const emit = defineEmits([
+  "selectedCountryId",
+]);
 
-const { country, initialId, placeholder } = defineProps({
+const {
+  country,
+  initialId,
+  label,
+  placeholder,
+} = defineProps({
   country: {
     type: Object,
-    required: false,
     default: () => ({
       label: "Brasil",
       value: 1,
     }),
   },
-  placeholder: {
+  initialId: {
+    type: Number,
+    default: null,
+  },
+  label: {
     type: String,
     default: () =>
-      useI18n().t("common.actions.search") +
-      " " +
       useI18n().t("ui.navigation.state"),
   },
-  label: {
+  placeholder: {
     type: String,
-    default: () => useI18n().t("ui.navigation.state"),
-  },
-  initialId: {
-    type: Number,
-    required: false,
-    default: null,
+    default: () =>
+      `${useI18n().t("common.actions.search")} ${useI18n().t(
+        "ui.navigation.state",
+      )}`,
   },
 });
 
-const selectedState = defineModel({ type: Object });
+const attrs = useAttrs();
+
+const selectedState = defineModel({
+  type: Object,
+});
 
-const loading = ref(true);
 const baseOptions = ref([]);
+const loading = ref(true);
 const stateOptions = ref([]);
 
-const filterFn = (val, update) => {
-  ensureOnlyPossibleOptions(country?.value);
-  const needle = normalizeString(val);
-  stateOptions.value = stateOptions.value.filter((v) => {
-    return (
-      normalizeString(v.label).includes(needle) ||
-      normalizeString(v.code).includes(needle)
+const selectAttrs = computed(() => {
+  const {
+    // eslint-disable-next-line no-unused-vars
+    class: _,
+    // eslint-disable-next-line no-unused-vars
+    style: __,
+    ...rest
+  } = attrs;
+
+  return rest;
+});
+
+const ensureOnlyPossibleOptions = (countryId) => {
+  if (!countryId) {
+    stateOptions.value = baseOptions.value;
+
+    return;
+  }
+
+  stateOptions.value = baseOptions.value.filter(
+    (state) => state.country_id === countryId,
+  );
+};
+
+const filterFn = (value, update) => {
+  update(() => {
+    ensureOnlyPossibleOptions(country?.value);
+
+    const needle = normalizeString(value);
+
+    stateOptions.value = stateOptions.value.filter(
+      (state) =>
+        normalizeString(state.label).includes(needle) ||
+        normalizeString(state.code ?? "").includes(needle),
     );
   });
-  update();
 };
 
-const selectStateById = async (id) => {
-  if (selectedState.value?.value === id) return;
-  selectedState.value = baseOptions.value.find((state) => state.value === id);
-};
+const selectStateByCode = (code) => {
+  if (selectedState.value?.code === code) {
+    return;
+  }
 
-const selectStateByName = (name) => {
-  if (selectedState.value?.label === name) return;
-  selectedState.value = baseOptions.value.find((state) => state.label === name);
+  selectedState.value =
+    baseOptions.value.find(
+      (state) => state.code === code,
+    ) ?? null;
 };
 
-const selectStateByCode = (code) => {
-  if (selectedState.value?.code === code) return;
-  selectedState.value = baseOptions.value.find((state) => state.code === code);
+const selectStateById = (id) => {
+  if (selectedState.value?.value === id) {
+    return;
+  }
+
+  selectedState.value =
+    baseOptions.value.find(
+      (state) => state.value === id,
+    ) ?? null;
 };
 
-const ensureOnlyPossibleOptions = (country_id) => {
-  if (country_id) {
-    stateOptions.value = baseOptions.value.filter(
-      (state) => state.country_id === country_id,
-    );
-  } else {
-    stateOptions.value = baseOptions.value;
+const selectStateByName = (name) => {
+  if (selectedState.value?.label === name) {
+    return;
   }
+
+  selectedState.value =
+    baseOptions.value.find(
+      (state) => state.label === name,
+    ) ?? null;
 };
 
 watch(
   () => country,
   (value, oldValue) => {
     if (
-      value?.value != oldValue?.value &&
-      value?.value != selectedState.value?.country_id
+      value?.value !== oldValue?.value &&
+      value?.value !== selectedState.value?.country_id
     ) {
       selectedState.value = null;
     }
-    if (value) {
-      ensureOnlyPossibleOptions(value.value);
-    }
+
+    ensureOnlyPossibleOptions(value?.value);
+  },
+  {
+    immediate: true,
   },
-  { immediate: true },
 );
 
-watch(selectedState, () => {
-  if (selectedState.value?.country_id) {
-    emit("selectedCountryId", selectedState.value.country_id);
+watch(selectedState, (state) => {
+  if (state?.country_id) {
+    emit(
+      "selectedCountryId",
+      state.country_id,
+    );
   }
 });
 
 onMounted(async () => {
   try {
-    const baseStates = await getStates();
-    baseOptions.value = baseStates.map((state) => ({
-      label: state.name,
-      value: state.id,
+    const states = await getStates();
+
+    baseOptions.value = states.map((state) => ({
       code: state.code,
       country_id: state.country_id,
+      label: state.name,
+      value: state.id,
     }));
-    stateOptions.value = baseOptions.value;
+
+    ensureOnlyPossibleOptions(country?.value);
+
     if (initialId) {
       selectStateById(initialId);
     }
-  } catch (e) {
-    console.error(e);
+  } catch (error) {
+    console.error(error);
   } finally {
     loading.value = false;
   }
 });
 
 defineExpose({
+  selectStateByCode,
   selectStateById,
   selectStateByName,
-  selectStateByCode,
 });
 </script>
+
+<style scoped>
+div {
+  min-width: 0;
+}
+
+:deep(.q-field) {
+  min-width: 0;
+  width: 100%;
+}
+</style>

+ 24 - 28
src/components/selects/UserTypeSelect.vue

@@ -1,6 +1,6 @@
 <template>
   <DefaultSelect
-    v-model="selectedUserType"
+    v-model="selectedOption"
     v-bind="$attrs"
     use-input
     hide-selected
@@ -15,12 +15,12 @@
 </template>
 
 <script setup>
-import { onMounted, ref } from "vue";
+import { onMounted, ref, computed } from "vue";
 import { userTypes } from "src/api/user";
 import { useI18n } from "vue-i18n";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
 
-const { placeholder, label, type } = defineProps({
+const props = defineProps({
   placeholder: {
     type: String,
     default: () => useI18n().t("common.ui.misc.type"),
@@ -29,24 +29,31 @@ const { placeholder, label, type } = defineProps({
     type: String,
     default: () => useI18n().t("common.ui.misc.type"),
   },
-  type: {
-    type: String,
-    default: null,
+  excludeTypes: {
+    type: Array,
+    default: () => [],
   },
 });
 
-const selectedUserType = defineModel({
-  type: Object,
-});
+const { placeholder, label } = props;
+
 const userTypeOptions = ref([]);
 const filteredOptions = ref([]);
 const isLoading = ref(true);
 
-const selectUserByValue = (value) => {
-  selectedUserType.value = userTypeOptions.value.find(
-    (option) => option.value === value,
-  );
-};
+const modelValue = defineModel({ type: String, default: null });
+
+const selectedOption = computed({
+  get() {
+    return (
+      userTypeOptions.value.find((opt) => opt.value === modelValue.value) ??
+      null
+    );
+  },
+  set(option) {
+    modelValue.value = option?.value ?? null;
+  },
+});
 
 const filterFn = (val, update) => {
   update(() => {
@@ -64,25 +71,14 @@ const filterFn = (val, update) => {
 onMounted(async () => {
   try {
     const response = await userTypes();
-    userTypeOptions.value = Object.entries(response).map(([key, value]) => ({
-      label: value,
-      value: key,
-    }));
+    userTypeOptions.value = Object.entries(response)
+      .map(([key, value]) => ({ label: value, value: key }))
+      .filter((opt) => !props.excludeTypes.includes(opt.value));
     filteredOptions.value = userTypeOptions.value;
-
-    if (type) {
-      selectedUserType.value = userTypeOptions.value.find(
-        (option) => option.value === type,
-      );
-    }
   } catch (error) {
     console.error("Failed to load user types:", error);
   } finally {
     isLoading.value = false;
   }
 });
-
-defineExpose({
-  selectUserByValue,
-});
 </script>

+ 12 - 35
src/components/shared/ChangeImageDialog.vue

@@ -1,30 +1,17 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="min-width: 400px">
+    <q-card class="q-dialog-plugin dialog-form-card" style="min-width: 400px">
       <DefaultDialogHeader title="Trocar Imagem" @close="onDialogCancel" />
 
-      <q-card-section class="q-pt-none q-pb-sm">
-        <div class="text-caption text-grey-6 q-mb-xs">Personalizar</div>
-        <q-file
-          v-model="selectedFile"
-          accept="image/*"
-          outlined
-          dense
-          placeholder="Buscar no Desktop"
-          @update:model-value="onFileSelected"
-        >
-          <template #append>
-            <q-icon name="search" />
-          </template>
-        </q-file>
-      </q-card-section>
-
-      <q-card-section class="q-pt-none">
-        <div class="text-caption text-grey-6 q-mb-xs">Pré - Visualização</div>
-        <div class="preview-area flex flex-center">
-          <img v-if="previewUrl" :src="previewUrl" class="preview-image" />
-        </div>
-      </q-card-section>
+      <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+        <q-card-section class="q-pt-none q-pb-sm">
+          <div class="text-caption text-grey-6 q-mb-xs">Personalizar</div>
+          <DefaultImagePicker
+            v-model="selectedFile"
+            v-model:preview-url="previewUrl"
+          />
+        </q-card-section>
+      </q-scroll-area>
 
       <q-card-actions align="right">
         <q-btn
@@ -47,7 +34,9 @@
 <script setup>
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultImagePicker from "src/components/defaults/DefaultImagePicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
@@ -57,18 +46,6 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 const selectedFile = ref(null);
 const previewUrl = ref(null);
 
-function onFileSelected(file) {
-  if (!file) {
-    previewUrl.value = null;
-    return;
-  }
-  const reader = new FileReader();
-  reader.onload = (e) => {
-    previewUrl.value = e.target.result;
-  };
-  reader.readAsDataURL(file);
-}
-
 function onSave() {
   onDialogOK({ file: selectedFile.value, previewUrl: previewUrl.value });
 }

+ 11 - 3
src/components/shared/CustomTabComponent.vue

@@ -6,10 +6,13 @@
     <div
       v-for="tab in tabs"
       :key="tab.name"
-      class="col flex items-center justify-center cursor-pointer tab-item"
-      :class="activeTab === tab.name ? 'tab-active' : 'tab-inactive'"
+      class="col flex items-center justify-center tab-item"
+      :class="[
+        activeTab === tab.name ? 'tab-active' : 'tab-inactive',
+        tab.disable ? 'tab-disabled' : 'cursor-pointer',
+      ]"
       style="height: 36px; border-radius: 6px"
-      @click="emit('update:activeTab', tab.name)"
+      @click="!tab.disable && emit('update:activeTab', tab.name)"
     >
       <span class="text-center text-weight-medium" style="font-size: 13px">
         {{ tab.label }}
@@ -57,4 +60,9 @@ const emit = defineEmits(["update:activeTab"]);
 .tab-inactive:hover {
   background-color: rgba(0, 0, 0, 0.06);
 }
+
+.tab-disabled {
+  cursor: not-allowed;
+  opacity: 0.55;
+}
 </style>

+ 14 - 2
src/composables/useAuth.js

@@ -1,15 +1,23 @@
 import api from "src/api";
+
 import { permissionStore } from "src/stores/permission";
 import { userStore } from "src/stores/user";
 
 export const useAuth = () => {
   const setAuthDataFromPayload = async (tokens) => {
     const { access_token, user } = tokens;
-    userStore().user = user;
-    userStore().accessToken = access_token;
+
+    const store = userStore();
+
+    store.setUser(user);
+
+    store.accessToken = access_token;
+
     await permissionStore().fetchScopes();
   };
 
+  //
+
   const login = async (email, password) => {
     try {
       const response = await api.post("/login", {
@@ -21,6 +29,7 @@ export const useAuth = () => {
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);
       }
+
       return response;
     } catch (error) {
       return Promise.reject(error);
@@ -30,6 +39,7 @@ export const useAuth = () => {
   const logout = async () => {
     try {
       const response = await api.post("/logout");
+
       if (response.status === 200) {
         userStore().resetUser();
       }
@@ -41,9 +51,11 @@ export const useAuth = () => {
   const refresh = async () => {
     try {
       const response = await api.post("/refresh");
+
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);
       }
+
       return response;
     } catch (error) {
       return Promise.reject(error);

+ 81 - 84
src/composables/useFormUpdateTracker.js → src/composables/useForm.js

@@ -1,9 +1,12 @@
-import { reactive, computed, toRaw, isReactive, watch } from "vue";
+import { computed, isReactive, reactive, toRaw, watch } from "vue";
+
 import isEqual from "fast-deep-equal";
 
-export const useFormUpdateTracker = (initialFormValue) => {
+export const useForm = (initialFormValue) => {
   const form = reactive(deepClone(initialFormValue));
+
   let originalForm = deepClone(initialFormValue);
+
   const updatedFields = reactive({});
 
   const getUpdatedFields = computed(() => {
@@ -14,35 +17,19 @@ export const useFormUpdateTracker = (initialFormValue) => {
     return Object.keys(updatedFields).length > 0;
   });
 
-  watch(
-    form,
-    (newValue) => {
-      const changes = diff(toRaw(newValue), originalForm);
-      Object.keys(updatedFields).forEach((key) => delete updatedFields[key]);
-      Object.assign(updatedFields, changes);
-    },
-    { deep: true },
-  );
-
-  const resetUpdateForm = () => {
-    const newFormState = deepClone(originalForm);
-    Object.keys(form).forEach((key) => delete form[key]);
-    Object.assign(form, newFormState);
-  };
-
-  const setUpdateFormAsOriginal = () => {
-    originalForm = deepClone(toRaw(form));
-  };
-
   const getFormAsFormData = () => {
     const formData = new FormData();
+
     buildFormData(formData, form);
+
     return formData;
   };
 
   const getUpdatedFieldsAsFormData = (spoofMethod = null) => {
     const formData = new FormData();
+
     buildFormData(formData, updatedFields);
+
     if (spoofMethod) {
       formData.append("_method", spoofMethod.toUpperCase());
     }
@@ -50,76 +37,47 @@ export const useFormUpdateTracker = (initialFormValue) => {
     return formData;
   };
 
+  const resetUpdateForm = () => {
+    const newFormState = deepClone(originalForm);
+
+    Object.keys(form).forEach((key) => delete form[key]);
+    Object.assign(form, newFormState);
+  };
+
+  const setUpdateFormAsOriginal = () => {
+    originalForm = deepClone(toRaw(form));
+  };
+
+  watch(
+    form,
+    (newValue) => {
+      const changes = diff(toRaw(newValue), originalForm);
+
+      Object.keys(updatedFields).forEach((key) => delete updatedFields[key]);
+      Object.assign(updatedFields, changes);
+    },
+    { deep: true },
+  );
+
   return {
     form,
     getUpdatedFields,
     hasUpdatedFields,
-    resetUpdateForm,
-    setUpdateFormAsOriginal,
     getFormAsFormData,
     getUpdatedFieldsAsFormData,
+    resetUpdateForm,
+    setUpdateFormAsOriginal,
   };
 };
 
-/**
- * A recursive function to find the differences between two objects.
- * It returns a new object containing only the keys that have been added,
- * changed, or removed (set to null).
- * @param {object} currentObj The current state of the object.
- * @param {object} baseObj The original object to compare against.
- * @returns {object} An object with only the changed, new, or deleted keys.
- */
-function diff(currentObj, baseObj) {
-  const changes = {};
-  const currentKeys = Object.keys(currentObj);
-  const baseKeys = Object.keys(baseObj);
-
-  for (const key of currentKeys) {
-    const currentValue = currentObj[key];
-    const baseValue = baseObj[key];
-
-    if (!isEqual(currentValue, baseValue)) {
-      if (
-        currentValue &&
-        typeof currentValue === "object" &&
-        !Array.isArray(currentValue) &&
-        baseValue &&
-        typeof baseValue === "object" &&
-        !Array.isArray(baseValue)
-      ) {
-        const nestedChanges = diff(currentValue, baseValue);
-        if (Object.keys(nestedChanges).length > 0) {
-          changes[key] = nestedChanges;
-        }
-      } else {
-        changes[key] = deepClone(currentValue);
-      }
-    }
-  }
-
-  for (const key of baseKeys) {
-    if (!Object.prototype.hasOwnProperty.call(currentObj, key)) {
-      changes[key] = null;
-    }
-  }
-
-  return changes;
-}
-
-/**
- * Recursively builds a FormData object from a nested plain object using bracket notation
- * for keys, which is compatible with PHP/Laravel backends.
- * @param {FormData} formData The FormData instance.
- * @param {object} data The plain object to serialize.
- * @param {string} parentKey The base key for nested properties.
- */
-function buildFormData(formData, data, parentKey = "") {
+const buildFormData = (formData, data, parentKey = "") => {
   if (data === undefined) {
     return;
   }
 
   if (data == null) {
     formData.append(parentKey, null);
+
     return;
   }
 
@@ -127,6 +85,7 @@ function buildFormData(formData, data, parentKey = "") {
     data.forEach((value, index) => {
       buildFormData(formData, value, `${parentKey}[${index}]`);
     });
+
     return;
   }
 
@@ -137,12 +96,15 @@ function buildFormData(formData, data, parentKey = "") {
   ) {
     Object.keys(data).forEach((key) => {
       const propName = parentKey ? `${parentKey}[${key}]` : key;
+
       buildFormData(formData, data[key], propName);
     });
+
     return;
   }
 
   let valueToAppend = data;
+
   if (data instanceof Date) {
     valueToAppend = data.toISOString().slice(0, 19).replace("T", " ");
   }
@@ -150,16 +112,11 @@ function buildFormData(formData, data, parentKey = "") {
   formData.append(parentKey, valueToAppend);
 }
 
-/** * Deep clones an object using structuredClone if available,
- * otherwise falls back to JSON methods.
- * If the object is reactive, it converts it to a raw object first.
- * @param {object} obj The object to clone.
- * @returns {object} A deep clone of the input object.
- */
-function deepClone(obj) {
+const deepClone = (obj) => {
   if (obj && isReactive(obj)) {
     obj = toRaw(obj);
   }
+
   if (typeof structuredClone === "function") {
     try {
       return structuredClone(obj);
@@ -169,5 +126,45 @@ function deepClone(obj) {
       );
     }
   }
+
   return JSON.parse(JSON.stringify(obj));
 }
+
+const diff = (currentObj, baseObj) => {
+  const changes = {};
+  const currentKeys = Object.keys(currentObj);
+  const baseKeys = Object.keys(baseObj);
+
+  for (const key of currentKeys) {
+    const currentValue = currentObj[key];
+
+    const baseValue = baseObj[key];
+
+    if (!isEqual(currentValue, baseValue)) {
+      if (
+        currentValue &&
+        typeof currentValue === "object" &&
+        !Array.isArray(currentValue) &&
+        baseValue &&
+        typeof baseValue === "object" &&
+        !Array.isArray(baseValue)
+      ) {
+        const nestedChanges = diff(currentValue, baseValue);
+
+        if (Object.keys(nestedChanges).length > 0) {
+          changes[key] = nestedChanges;
+        }
+      } else {
+        changes[key] = deepClone(currentValue);
+      }
+    }
+  }
+
+  for (const key of baseKeys) {
+    if (!Object.prototype.hasOwnProperty.call(currentObj, key)) {
+      changes[key] = null;
+    }
+  }
+
+  return changes;
+}

+ 301 - 44
src/composables/useInputRules.js

@@ -1,112 +1,369 @@
+import {
+  addYears,
+  differenceInCalendarDays,
+  format,
+  isValid,
+  parse,
+  startOfDay,
+} from "date-fns";
 import { useI18n } from "vue-i18n";
 
 export const useInputRules = () => {
   const { t } = useI18n();
 
+  const hasValue = (value) => {
+    if (value === null || value === undefined) return false;
+    if (typeof value === "string") return value.trim().length > 0;
+    if (Array.isArray(value)) return value.length > 0;
+
+    return true;
+  };
+
+  const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
+
   const emailPattern =
     /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+
   const passwordPattern = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z]).{8,}$/;
-  const cepPattern = /^[0-9]{5}-[0-9]{3}$/;
 
   const inputRules = {
-    required: (value) => !!value || t("validation.rules.required"),
-    requiredNumber: (value) => !isNaN(value) || t("validation.rules.required"),
-    requiredHideMessage: (value) => !!value,
-    min: (min) => (value) =>
-      value.length >= min ||
-      `${t("validation.rules.min")} ${min} ${t("validation.rules.characters")}`,
-    max: (max) => (value) =>
-      value.length <= max ||
-      `${t("validation.rules.max")} ${max} ${t("validation.rules.characters")}`,
-    minValue: (min) => (value) =>
-      value >= min || `${t("validation.rules.min")} ${min}`,
-    maxValue: (max) => (value) =>
-      value <= max || `${t("validation.rules.max")} ${max}`,
+    afterTime: (getStartTime) => (value) => {
+      const startTime =
+        typeof getStartTime === "function" ? getStartTime() : getStartTime;
+
+      return (
+        !hasValue(startTime) ||
+        !hasValue(value) ||
+        value > startTime ||
+        t("validation.rules.after_time")
+      );
+    },
+
+    cep: (value) => {
+      if (!value) return true;
+
+      return cepPattern.test(value) || t("validation.rules.cep");
+    },
+
+    cnpj: (value) => !value || isValidCNPJ(value) || t("validation.rules.cnpj"),
+
+    cpf: (value) => !value || isValidCPF(value) || t("validation.rules.cpf"),
+
+    date: (value) => {
+      if (!hasValue(value)) return true;
+
+      return parseInputDate(value) || t("validation.rules.date");
+    },
+
+    dateMaxYearsFrom:
+      (getStartDate, maxYears = 1) =>
+      (value) => {
+        if (!hasValue(value)) return true;
+
+        const startValue =
+          typeof getStartDate === "function" ? getStartDate() : getStartDate;
+
+        if (!hasValue(startValue)) return true;
+
+        const startDate = parseInputDate(startValue);
+        const endDate = parseInputDate(value);
+
+        if (!startDate || !endDate) return true;
+
+        return (
+          endDate <= addYears(startDate, maxYears) ||
+          t("validation.rules.date_max_years_from", { maxYears })
+        );
+      },
+
+    dateRangeDays:
+      (getStartDate, maxDays = 365) =>
+      (value) => {
+        if (!hasValue(value)) return true;
+
+        const startValue =
+          typeof getStartDate === "function" ? getStartDate() : getStartDate;
+
+        if (!hasValue(startValue)) return true;
+
+        const startDate = parseInputDate(startValue);
+        const endDate = parseInputDate(value);
+
+        if (!startDate || !endDate) return true;
+
+        if (endDate < startDate) {
+          return t("validation.rules.date_before_start");
+        }
+
+        return (
+          differenceInCalendarDays(endDate, startDate) <= maxDays ||
+          t("validation.rules.date_range_days", { maxDays })
+        );
+      },
+
+    renewalEndDate:
+      (getCurrentEndDate, maxYears = 1) =>
+      (value) => {
+        const currentValue =
+          typeof getCurrentEndDate === "function"
+            ? getCurrentEndDate()
+            : getCurrentEndDate;
+
+        if (hasValue(currentValue) && !hasValue(value)) {
+          return t("validation.rules.renewal_end_date_cannot_decrease");
+        }
+
+        if (!hasValue(value)) return true;
+
+        const currentEndDate = parseInputDate(currentValue);
+        const newEndDate = parseInputDate(value);
+
+        if (!newEndDate) return true;
+
+        if (currentEndDate && newEndDate < currentEndDate) {
+          return t("validation.rules.renewal_end_date_cannot_decrease");
+        }
+
+        if (currentEndDate && newEndDate.getTime() === currentEndDate.getTime()) {
+          return true;
+        }
+
+        const maximumEndDate = addYears(startOfDay(new Date()), maxYears);
+
+        return (
+          newEndDate <= maximumEndDate ||
+          t("validation.rules.renewal_max_end_date", {
+            date: format(maximumEndDate, "dd/MM/yyyy"),
+          })
+        );
+      },
+
+    durationHours: (value) => {
+      const minutes = Number(value) * 60;
+
+      return (
+        (Number.isInteger(minutes) && minutes >= 1 && minutes <= 1440) ||
+        t("validation.rules.duration_hours")
+      );
+    },
+
     email: (value) =>
       !value || emailPattern.test(value) || t("validation.rules.email"),
+
     emails: (value) => {
       if (!value) return true;
+
       const emails = value.split(";").map((email) => email.trim());
+
       return (
         emails.every((email) => inputRules.email(email) === true) ||
         t("validation.rules.email")
       );
     },
-    cpf: (value) => !value || isValidCPF(value) || t("validation.rules.cpf"),
-    cnpj: (value) => !value || isValidCNPJ(value) || t("validation.rules.cnpj"),
-    samePassword: (otherValue) => (value) =>
-      value === otherValue || t("validation.rules.same_password"),
-    password: (value) =>
-      !value || passwordPattern.test(value) || t("validation.rules.password"),
-    cep: (value) => {
-      if (!value) return true;
-      return cepPattern.test(value) || t("validation.rules.cep");
-    },
+
+    forValue: (rules, getValue) =>
+      rules.map((rule) => {
+        const wrapped = (...args) => rule(getValue(), ...args);
+
+        if (rule?.$id) wrapped.$id = rule.$id;
+
+        return wrapped;
+      }),
+
+    integer: (value) =>
+      !hasValue(value) ||
+      Number.isInteger(Number(value)) ||
+      t("validation.rules.integer"),
+
+    integerRange:
+      (min, max = null) =>
+      (value) => {
+        if (!hasValue(value)) return true;
+
+        const number = Number(value);
+        const withinRange = number >= min && (max == null || number <= max);
+
+        if (Number.isInteger(number) && withinRange) return true;
+
+        return max == null
+          ? t("validation.rules.integer_min", { min })
+          : t("validation.rules.integer_range", { min, max });
+      },
+
+    max: (max) => (value) =>
+      !hasValue(value) ||
+      String(value).length <= max ||
+      `${t("validation.rules.max")} ${max} ${t("validation.rules.characters")}`,
+
+    maxValue: (max) => (value) =>
+      value <= max || `${t("validation.rules.max")} ${max}`,
+
+    min: (min) => (value) =>
+      !hasValue(value) ||
+      String(value).length >= min ||
+      `${t("validation.rules.min")} ${min} ${t("validation.rules.characters")}`,
+
+    minValue: (min) => (value) =>
+      value >= min || `${t("validation.rules.min")} ${min}`,
+
     notSameDocument: (allDocuments) => (value) => {
       if (!value) return true;
+
       let found = 0;
+
       for (const doc of allDocuments) {
         if (doc == value) {
           found++;
         }
+
         if (found > 1) {
           return t("validation.rules.not_same_document");
         }
       }
+
       return true;
     },
+
+    oneOf: (allowedValues) => (value) =>
+      !hasValue(value) ||
+      allowedValues.includes(value) ||
+      t("validation.rules.one_of"),
+
+    password: (value) =>
+      !value || passwordPattern.test(value) || t("validation.rules.password"),
+
+    positive: (value) => Number(value) > 0 || t("validation.rules.positive"),
+
+    samePassword: (otherValue) => (value) =>
+      value === otherValue || t("validation.rules.same_password"),
+
+    slug: (value) =>
+      !hasValue(value) ||
+      /^[A-Z0-9_]+$/.test(value) ||
+      t("validation.rules.slug"),
+
+    time: (value) => {
+      if (!hasValue(value)) return true;
+
+      const match = /^(\d{2}):(\d{2})$/.exec(value);
+
+      return (
+        (!!match && Number(match[1]) <= 23 && Number(match[2]) <= 59) ||
+        t("validation.rules.time")
+      );
+    },
+
+    whenValue: (getDependency, rule) => (value) =>
+      !hasValue(getDependency()) || rule(value),
+
+    //
+
+    required: (value) => hasValue(value) || t("validation.rules.required"),
+
+    requiredHideMessage: (value) => hasValue(value),
+
+    requiredNumber: (value) =>
+      (value !== null &&
+        value !== undefined &&
+        value !== "" &&
+        !Number.isNaN(Number(value))) ||
+      t("validation.rules.required"),
   };
 
-  inputRules.required.$id = 'required'
+  inputRules.required.$id = "required";
+  inputRules.requiredHideMessage.$id = "required";
+  inputRules.requiredNumber.$id = "required";
 
   return {
     inputRules,
   };
 };
 
-function isValidCPF(cpf) {
-  if (!cpf) return false;
-  cpf = cpf.replace(/[^\d]+/g, "");
-  if (cpf.length !== 11) return false;
-  if (/^(\d)\1+$/.test(cpf)) return false;
-  let sum = 0;
-  for (let i = 0; i < 9; i++) sum += parseInt(cpf.charAt(i)) * (10 - i);
-  let rev = 11 - (sum % 11);
-  if (rev === 10 || rev === 11) rev = 0;
-  if (rev !== parseInt(cpf.charAt(9))) return false;
-  sum = 0;
-  for (let i = 0; i < 10; i++) sum += parseInt(cpf.charAt(i)) * (11 - i);
-  rev = 11 - (sum % 11);
-  if (rev === 10 || rev === 11) rev = 0;
-  if (rev !== parseInt(cpf.charAt(10))) return false;
-  return true;
-}
+const parseInputDate = (value) => {
+  const formats = ["dd/MM/yyyy", "yyyy-MM-dd"];
 
-function isValidCNPJ(cnpj) {
+  for (const dateFormat of formats) {
+    const parsedDate = parse(value, dateFormat, new Date());
+
+    if (isValid(parsedDate) && format(parsedDate, dateFormat) === value) {
+      return parsedDate;
+    }
+  }
+
+  return null;
+};
+
+const isValidCNPJ = (cnpj) => {
   if (!cnpj) return false;
+
   cnpj = cnpj.replace(/[^\d]+/g, "");
+
   if (cnpj.length !== 14) return false;
   if (/^(\d)\1+$/.test(cnpj)) return false;
+
   let length = cnpj.length - 2;
   let numbers = cnpj.substring(0, length);
   let digits = cnpj.substring(length);
   let sum = 0;
   let pos = length - 7;
+
   for (let i = length; i >= 1; i--) {
     sum += parseInt(numbers.charAt(length - i)) * pos--;
+
     if (pos < 2) pos = 9;
   }
+
   let result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+
   if (result !== parseInt(digits.charAt(0))) return false;
+
   length = length + 1;
   numbers = cnpj.substring(0, length);
   sum = 0;
   pos = length - 7;
+
   for (let i = length; i >= 1; i--) {
     sum += parseInt(numbers.charAt(length - i)) * pos--;
+
     if (pos < 2) pos = 9;
   }
+
   result = sum % 11 < 2 ? 0 : 11 - (sum % 11);
+
   if (result !== parseInt(digits.charAt(1))) return false;
+
   return true;
-}
+};
+
+const isValidCPF = (cpf) => {
+  if (!cpf) return false;
+
+  cpf = cpf.replace(/[^\d]+/g, "");
+
+  if (cpf.length !== 11) return false;
+  if (/^(\d)\1+$/.test(cpf)) return false;
+
+  let sum = 0;
+
+  for (let i = 0; i < 9; i++) {
+    sum += parseInt(cpf.charAt(i)) * (10 - i);
+  }
+
+  let rev = 11 - (sum % 11);
+
+  if (rev === 10 || rev === 11) rev = 0;
+  if (rev !== parseInt(cpf.charAt(9))) return false;
+
+  sum = 0;
+
+  for (let i = 0; i < 10; i++) {
+    sum += parseInt(cpf.charAt(i)) * (11 - i);
+  }
+
+  rev = 11 - (sum % 11);
+
+  if (rev === 10 || rev === 11) rev = 0;
+  if (rev !== parseInt(cpf.charAt(10))) return false;
+
+  return true;
+};

+ 157 - 67
src/composables/useScroll.js

@@ -1,81 +1,171 @@
-import { unref } from "vue";
-
-export const useScroll = () => {
-  /**
-   * Scrolls to a target element within a scroll container.
-   * The function is agnostic to whether the container is a QScrollArea or a native DOM element.
-   *
-   * @param {Ref<Component|HTMLElement>} targetRef - Ref to the target component or DOM element.
-   * @param {Ref<Component|HTMLElement>} containerRef - Ref to the container (QScrollArea or a scrollable DOM element like body).
-   * @param {Object} options - Scroll options.
-   * @param {number} options.offset - Offset from the top in pixels (default: 50).
-   * @param {number} options.duration - Animation duration for QScrollArea (default: 150). Note: duration is not supported for native scrolling.
-   * @returns {boolean} - Whether the scroll was successful.
-   */
-  const scrollToComponent = (targetRef, containerRef, options = {}) => {
-    const { offset = 150, duration = 150 } = options;
-
-    const targetInstance = unref(targetRef);
-    const containerInstance = unref(containerRef);
-
-    const targetElement = targetInstance?.$el ?? targetInstance;
-
-    const containerElement = containerInstance?.$el ?? containerInstance;
-
-    if (!targetElement || !containerElement) {
-      console.warn("useScroll: Target or container element not found");
-      return false;
-    }
-
-    try {
-      let currentElement = targetElement;
-      let offsetTop = 0;
-
-      while (currentElement && currentElement !== containerElement) {
-        offsetTop += currentElement.offsetTop;
-        if (
-          containerElement.contains(currentElement.offsetParent) ||
-          containerElement === document.body
-        ) {
-          currentElement = currentElement.offsetParent;
-        } else {
-          currentElement = null;
-        }
+import { nextTick, unref } from "vue";
+
+  export const useScroll = () => {
+    const resolveElement = (value) => {
+      const instance = unref(value);
+
+      return instance?.$el ?? instance ?? null;
+    };
+
+    const resolveContainer = (targetElement, containerRef) => {
+      const containerInstance = unref(containerRef);
+
+      if (typeof containerInstance?.setScrollPosition === "function") {
+        return {
+          instance: containerInstance,
+          element:
+            containerInstance.getScrollTarget?.() ??
+            containerInstance.$el?.querySelector?.(
+              ".q-scrollarea__container",
+            ) ??
+            containerInstance.$el,
+          isQuasar: true,
+        };
+      }
+
+      const providedElement = resolveElement(containerRef);
+
+      const detectedElement = targetElement?.closest?.(
+        ".q-scrollarea__container, .scroll, .scroll-y",
+      );
+
+      const element =
+        providedElement ??
+        detectedElement ??
+        document.scrollingElement ??
+        document.documentElement;
+
+      return {
+        instance: element,
+        element,
+        isQuasar: false,
+      };
+    };
+
+    //
+
+    const calculatePosition = (targetElement, containerElement, offset) => {
+      const targetRect = targetElement.getBoundingClientRect();
+
+      const isDocumentContainer =
+        containerElement === document.body ||
+        containerElement === document.documentElement ||
+        containerElement === document.scrollingElement;
+
+      if (isDocumentContainer) {
+        return Math.max(
+          0,
+          targetRect.top + window.scrollY - offset,
+        );
+      }
+
+      const containerRect = containerElement.getBoundingClientRect();
+
+      return Math.max(
+        0,
+        targetRect.top -
+          containerRect.top +
+          containerElement.scrollTop -
+          offset,
+      );
+    };
+
+    const scrollToComponent = (targetRef, containerRef, options = {}) => {
+      const { offset = 150, duration = 150, behavior = "smooth" } = options;
+
+      const targetElement = resolveElement(targetRef);
+
+      if (!targetElement) {
+        console.warn("useScroll: Target element not found");
+        return false;
       }
 
+      const container = resolveContainer(targetElement, containerRef);
+
+      const containerElement = container.element;
+
+      if (!containerElement) {
+        console.warn("useScroll: Container element not found");
+
+        return false;
+      }
+
+      const isDocumentContainer =
+        containerElement === document.body ||
+        containerElement === document.documentElement ||
+        containerElement === document.scrollingElement;
+
       if (
-        containerElement !== document.body &&
+        !isDocumentContainer &&
         !containerElement.contains(targetElement)
       ) {
-        console.warn("useScroll: Target is not a child of the container");
+        console.warn("useScroll: Target is not inside the container");
         return false;
       }
 
-      const targetPosition = Math.max(0, offsetTop - offset);
-
-      if (typeof containerInstance.setScrollPosition === "function") {
-        containerInstance.setScrollPosition(
-          "vertical",
-          targetPosition,
-          duration,
+      try {
+        const targetPosition = calculatePosition(
+          targetElement,
+          containerElement,
+          offset,
         );
-      } else {
-        const scrollable =
-          containerElement === document.body ? window : containerElement;
-        scrollable.scrollTo({
-          top: targetPosition,
-          behavior: "smooth",
-        });
+
+        if (container.isQuasar) {
+          container.instance.setScrollPosition(
+            "vertical",
+            targetPosition,
+            duration,
+          );
+        } else if (isDocumentContainer) {
+          window.scrollTo({
+            top: targetPosition,
+            behavior,
+          });
+        } else {
+          containerElement.scrollTo({
+            top: targetPosition,
+            behavior,
+          });
+        }
+
+        return true;
+      } catch (error) {
+        console.error("useScroll: Error while scrolling", error);
+        return false;
+      }
+    };
+
+    const scrollToFirstError = async (
+      formRef,
+      containerRef,
+      options = {},
+    ) => {
+      await nextTick();
+
+      const forms = unref(formRef);
+
+      const formInstances = Array.isArray(forms) ? forms : [forms];
+
+      for (const formInstance of formInstances) {
+        const formElement = resolveElement(formInstance);
+
+        const errorElement =
+          formElement?.matches?.(".q-field--error")
+            ? formElement
+            : formElement?.querySelector?.(".q-field--error");
+
+        if (errorElement) {
+          const scrollFn = options.scrollFn ?? scrollToComponent;
+
+          return scrollFn(errorElement, containerRef, options);
+        }
       }
 
-      return true;
-    } catch (error) {
-      console.error("useScroll: Error while scrolling", error);
       return false;
-    }
-  };
+    };
 
-  return {
-    scrollToComponent,
+    return {
+      scrollToComponent,
+      scrollToFirstError,
+    };
   };
-};

+ 29 - 33
src/composables/useSubmitHandler.js

@@ -1,50 +1,29 @@
-import { ref, nextTick } from "vue";
+import { nextTick, ref } from "vue";
+import { useScroll } from "src/composables/useScroll";
 
-export function useSubmitHandler(options = {}) {
-  const { onSuccess, onError, formRef, scrollFn, containerRef } = options;
+export const useSubmitHandler = (options = {}) => {
+  const { containerRef, formRef, onError, onSuccess, scrollFn } = options;
 
-  const loading = ref(false);
-  const validationErrors = ref({});
-
-  const getFormRefs = () => {
-    const refs = formRef?.value;
-    if (!refs) return [];
-    return Array.isArray(refs) ? refs : [refs];
-  };
-
-  const scrollToFirstError = async () => {
-    if (!formRef?.value || !scrollFn) return;
-    await nextTick();
-
-    const refsToSearch = getFormRefs();
-
-    for (const ref of refsToSearch) {
-      if (!ref) continue;
-      const components = ref.getValidationComponents();
-      if (!components) continue;
+  const { scrollToFirstError } = useScroll();
 
-      const firstErrorComponent = components.find((c) => c.hasError);
+  const loading = ref(false);
 
-      if (firstErrorComponent) {
-        const container = containerRef?.value || containerRef;
-        firstErrorComponent.focus();
-        scrollFn(firstErrorComponent, container);
-        return;
-      }
-    }
-  };
+  const validationErrors = ref({});
 
   const execute = async (apiCallThunk) => {
     loading.value = true;
+
     validationErrors.value = {};
 
     let allValid = true;
+
     const refsToValidate = getFormRefs();
 
     if (refsToValidate.length > 0) {
       for (const ref of refsToValidate) {
         if (ref) {
           const success = await ref.validate(true);
+
           if (!success) {
             allValid = false;
           }
@@ -54,12 +33,15 @@ export function useSubmitHandler(options = {}) {
 
     if (!allValid) {
       loading.value = false;
-      await scrollToFirstError();
+
+      await showFirstError();
+
       throw new Error("Frontend validation failed.");
     }
 
     try {
       const response = await apiCallThunk();
+
       if (typeof onSuccess === "function") {
         await onSuccess(response);
       } else {
@@ -72,17 +54,29 @@ export function useSubmitHandler(options = {}) {
     }
   };
 
+  const getFormRefs = () => {
+    const refs = formRef?.value;
+
+    if (!refs) return [];
+
+    return Array.isArray(refs) ? refs : [refs];
+  };
+
   const handleError = async (error) => {
     if (error?.response?.status === 422) {
       const errors = error.response.data.errors || {};
+
       for (const key in errors) {
         const message = errors[key][0];
+
         validationErrors.value[key] = message;
       }
-      await scrollToFirstError();
+
+      await showFirstError();
     }
 
     await nextTick();
+
     if (typeof onError === "function") {
       await onError(error);
     } else {
@@ -90,6 +84,8 @@ export function useSubmitHandler(options = {}) {
     }
   };
 
+  const showFirstError = () => scrollToFirstError(formRef, containerRef, { scrollFn });
+
   return {
     loading,
     validationErrors,

+ 52 - 4
src/css/app.scss

@@ -1,6 +1,6 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
-@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Rethink+Sans:ital,wght@0,400..800;1,400..800&display=swap');
+@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Rethink+Sans:ital,wght@0,400..800;1,400..800&display=swap");
 
 body {
   font-family: "Inter", sans-serif;
@@ -73,11 +73,61 @@ body.body--light {
   }
 }
 
-
 .q-card__actions .q-btn {
   padding: 10px 16px;
 }
 
+.dialog-form-card {
+  display: flex;
+  flex-direction: column;
+  max-height: calc(100vh - 48px);
+  overflow: hidden;
+}
+
+.dialog-form-card > .q-form {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  min-height: 0;
+  overflow: hidden;
+}
+
+.dialog-form-card > .q-bar,
+.dialog-form-card > .q-separator {
+  flex: 0 0 auto;
+}
+
+.dialog-form-card > .dialog-form-scroll,
+.dialog-form-card > .q-form > .dialog-form-scroll {
+  flex: 1 1 auto;
+  height: min(var(--dialog-form-scroll-height, 65vh), calc(100vh - 160px));
+  min-height: 0;
+  overflow: hidden;
+}
+
+.dialog-form-scroll--xs {
+  --dialog-form-scroll-height: 160px;
+}
+
+.dialog-form-scroll--sm {
+  --dialog-form-scroll-height: 300px;
+}
+
+.dialog-form-scroll--md {
+  --dialog-form-scroll-height: 440px;
+}
+
+.q-dialog .q-card__actions {
+  flex: 0 0 auto;
+  padding: 16px !important;
+  border-top: 0 !important;
+  box-shadow: none !important;
+}
+
+.q-dialog .q-card__section {
+  padding: 16px !important;
+}
+
 input[type="number"]::-webkit-inner-spin-button,
 input[type="number"]::-webkit-outer-spin-button {
   -webkit-appearance: none;
@@ -108,7 +158,6 @@ input[type="number"]::-webkit-outer-spin-button {
   background: #fff !important;
 }
 
-
 .q-field--standout.q-field--rounded .q-field__control {
   border-radius: 8px;
   box-shadow: 0 0 0 1px #c0c0c0c0;
@@ -131,4 +180,3 @@ input[type="number"]::-webkit-outer-spin-button {
 .q-field--standout.q-field--highlighted .q-field__native {
   color: $secondary;
 }
-

+ 54 - 3
src/helpers/utils.js

@@ -68,12 +68,21 @@ const convertDateTime = (dateTimeString) => {
  * // Output: 23/05/2023 10:07:27
  */
 const formatDateYMDtoDMY = (dateTime) => {
-  const [datePart, timePart] = dateTime.split(" ");
+  if (!dateTime || typeof dateTime !== "string") return "-";
+
+  const normalizedDateTime = dateTime.trim().replace("T", " ");
+  if (!normalizedDateTime) return "-";
+
+  const [datePart, timePart] = normalizedDateTime.split(" ");
   const [year, month, day] = datePart.split("-");
+  if (!year || !month || !day) return "-";
+
   const formattedDate = `${day}/${month}/${year}`;
   if (timePart) {
-    const [hours, minutes, seconds] = timePart.split(":");
-    const formattedTime = `${hours}:${minutes}:${seconds}`;
+    const [hours, minutes, seconds = "00"] = timePart
+      .replace("Z", "")
+      .split(":");
+    const formattedTime = `${hours}:${minutes}:${seconds.split(".")[0]}`;
     return `${formattedDate} ${formattedTime}`;
   }
   return formattedDate;
@@ -102,6 +111,46 @@ const normalizeString = (val) =>
     .normalize("NFKD")
     .replace(/[\u0300-\u036f~]/g, "");
 
+const normalizeUnitName = (value) => {
+  const name = String(value ?? "").trim();
+  return name && !["null", "undefined"].includes(name.toLowerCase())
+    ? name
+    : null;
+};
+
+const formatUnitName = (unit, fallback = "Unidade sem nome") =>
+  normalizeUnitName(unit?.name) ||
+  normalizeUnitName(unit?.fantasy_name) ||
+  normalizeUnitName(unit?.social_reason) ||
+  fallback;
+
+const isUnderage = (birthDate, referenceDate = new Date()) => {
+  if (!birthDate || typeof birthDate !== "string") return false;
+
+  const normalizedDate = birthDate.trim().split(" ")[0];
+
+  const parts = normalizedDate.includes("/")
+    ? normalizedDate.split("/").reverse()
+    : normalizedDate.split("-");
+
+  const [year, month, day] = parts.map(Number);
+
+  if (!year || !month || !day) return false;
+
+  const parsedBirthDate = new Date(year, month - 1, day);
+
+  const isValidDate =
+    parsedBirthDate.getFullYear() === year &&
+    parsedBirthDate.getMonth() === month - 1 &&
+    parsedBirthDate.getDate() === day;
+
+  if (!isValidDate) return false;
+
+  const eighteenthBirthday = new Date(year + 18, month - 1, day);
+
+  return eighteenthBirthday > referenceDate;
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -109,4 +158,6 @@ export {
   convertDateTime,
   formatToBRLCurrency,
   normalizeString,
+  formatUnitName,
+  isUnderage,
 };

+ 14 - 0
src/i18n/locales/en.json

@@ -176,6 +176,12 @@
       "required": "This field is required",
       "email": "This field must be a valid email | These fields must be valid emails",
       "date": "This field must be a valid date",
+      "date_before_start": "The end date cannot be before the start date",
+      "date_max_years_from": "The contract duration cannot exceed {maxYears} year from the signature date",
+      "date_range_days": "The contract period cannot exceed {maxDays} days",
+      "renewal_end_date_cannot_decrease": "The end date cannot be decreased or removed",
+      "renewal_max_end_date": "The renewal end date cannot be later than {date}",
+      "duration_hours": "Enter a valid duration between 1 minute and 24 hours",
       "min": "This field must have at least",
       "max": "This field must have at most",
       "characters": "characters",
@@ -185,6 +191,14 @@
       "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",
+      "positive": "Enter a value greater than zero",
+      "integer": "Enter a valid integer",
+      "integer_min": "Enter an integer greater than or equal to {min}",
+      "integer_range": "Enter an integer between {min} and {max}",
+      "time": "Enter a valid time",
+      "after_time": "The end time must be after the start time",
+      "one_of": "Select a valid option",
+      "slug": "Use only uppercase letters, numbers, and underscores",
       "value_smaller_than_zero": "Value cannot be less than zero"
     },
     "permissions": {

+ 14 - 0
src/i18n/locales/es.json

@@ -176,6 +176,12 @@
       "required": "Este campo es obligatorio",
       "email": "Este campo debe ser un correo electrónico válido | Estos campos deben ser correos electrónicos válidos",
       "date": "Este campo debe ser una fecha válida",
+      "date_before_start": "La fecha de finalización no puede ser anterior a la fecha de inicio",
+      "date_max_years_from": "La duración del contrato no puede superar {maxYears} año desde la fecha de firma",
+      "date_range_days": "El período del contrato no puede exceder {maxDays} días",
+      "renewal_end_date_cannot_decrease": "La fecha de finalización no puede reducirse ni eliminarse",
+      "renewal_max_end_date": "La fecha de finalización de la renovación no puede ser posterior al {date}",
+      "duration_hours": "Ingrese una duración válida entre 1 minuto y 24 horas",
       "min": "Este campo debe tener al menos",
       "max": "Este campo debe tener como máximo",
       "characters": "caracteres",
@@ -185,6 +191,14 @@
       "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",
+      "positive": "Ingrese un valor mayor que cero",
+      "integer": "Ingrese un número entero válido",
+      "integer_min": "Ingrese un número entero mayor o igual a {min}",
+      "integer_range": "Ingrese un número entero entre {min} y {max}",
+      "time": "Ingrese una hora válida",
+      "after_time": "La hora de finalización debe ser posterior a la hora de inicio",
+      "one_of": "Seleccione una opción válida",
+      "slug": "Use solo letras mayúsculas, números y guiones bajos",
       "value_smaller_than_zero": "El valor no puede ser menor que cero"
     },
     "permissions": {

+ 15 - 1
src/i18n/locales/pt.json

@@ -176,6 +176,12 @@
       "required": "Este campo é obrigatório",
       "email": "Este campo deve ser um e-mail válido | Estes campos devem ser e-mails válidos",
       "date": "Este campo deve ser uma data válida",
+      "date_before_start": "A data de encerramento não pode ser anterior à data de início",
+      "date_max_years_from": "A duração do contrato não pode ultrapassar {maxYears} ano a partir da assinatura",
+      "date_range_days": "O período do contrato não pode ultrapassar {maxDays} dias",
+      "renewal_end_date_cannot_decrease": "A data de encerramento não pode diminuir nem ser removida",
+      "renewal_max_end_date": "A data de encerramento da renovação não pode ser posterior a {date}",
+      "duration_hours": "Informe uma duração válida entre 1 minuto e 24 horas",
       "min": "Este campo deve ter no mínimo",
       "max": "Este campo deve ter no máximo",
       "characters": "caracteres",
@@ -185,6 +191,14 @@
       "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",
+      "positive": "Informe um valor maior que zero",
+      "integer": "Informe um número inteiro válido",
+      "integer_min": "Informe um número inteiro maior ou igual a {min}",
+      "integer_range": "Informe um número inteiro entre {min} e {max}",
+      "time": "Informe um horário válido",
+      "after_time": "O horário final deve ser após o inicial",
+      "one_of": "Selecione uma opção válida",
+      "slug": "Use apenas letras maiúsculas, números e underline",
       "value_smaller_than_zero": "O valor não pode ser menor que zero"
     },
     "permissions": {
@@ -290,7 +304,7 @@
       "home": "Início",
       "versions": "Versões",
       "expand_menu": "Expandir menu",
-      "dashboard": "Painel",
+      "dashboard": "Dashboard",
       "explore": "Explorar",
       "advertise": "Anunciar",
       "my_advertisements": "Meus Anúncios",

+ 120 - 0
src/pages/apis/ApisPage.vue

@@ -0,0 +1,120 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="APIs" />
+
+    <div class="q-pa-md">
+      <q-card flat bordered class="q-pa-md" style="max-width: 720px">
+        <div class="row items-center no-wrap q-mb-xs">
+          <q-icon name="mdi-cash-multiple" size="sm" color="primary" class="q-mr-sm" />
+          <div class="text-subtitle1 text-weight-medium">Asaas</div>
+          <q-space />
+          <q-badge
+            :color="account.asaas_configured ? 'positive' : 'grey-6'"
+            :label="account.asaas_configured ? 'Configurada' : 'Não configurada'"
+            class="q-px-sm q-py-xs"
+          />
+        </div>
+
+        <div class="text-body2 text-grey-7 q-mb-md">
+          Caso esta unidade possua conta própria no Asaas, informe aqui a chave da API
+          para que as cobranças dos alunos sejam emitidas por ela. Este passo é
+          <span class="text-weight-medium">opcional</span> — sem a chave, nenhuma
+          cobrança é gerada pelo Asaas.
+        </div>
+
+        <DefaultForm ref="formRef">
+          <DefaultPasswordInput
+            v-model="apiKey"
+            :error="!!validationErrors.asaas_api_key"
+            :error-message="validationErrors.asaas_api_key"
+            :label="
+              account.asaas_configured
+                ? 'Nova chave da API (deixe em branco para manter a atual)'
+                : 'Chave da API do Asaas'
+            "
+            :placeholder="account.asaas_api_key_masked || ''"
+            outlined
+            autocomplete="off"
+          />
+
+          <div class="row justify-end items-center q-mt-md" style="gap: 12px">
+            <q-btn
+              v-if="canEdit && account.asaas_configured"
+              flat
+              color="negative"
+              label="Remover chave"
+              :disable="loading"
+              @click="onRemove"
+            />
+            <q-btn
+              v-if="canEdit"
+              color="primary-2"
+              label="Salvar"
+              :disable="!apiKey"
+              :loading="loading"
+              @click="onSave"
+            />
+          </div>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import {
+  getPaymentAccountMe,
+  savePaymentAccountMe,
+} from "src/api/unit_payment_account";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import { permissionStore } from "src/stores/permission";
+
+const permissions = permissionStore();
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_integrations", "edit"),
+);
+
+const formRef = ref(null);
+const apiKey = ref("");
+
+const defaultAccount = () => ({
+  asaas_configured: false,
+  asaas_api_key_masked: null,
+  status: "pending",
+});
+
+const account = ref(defaultAccount());
+
+const { loading, validationErrors, execute } = useSubmitHandler({ formRef });
+
+async function fetchData() {
+  try {
+    const data = await getPaymentAccountMe();
+    account.value = data ?? defaultAccount();
+  } catch (e) {
+    console.error(e);
+  }
+}
+
+async function persist(value) {
+  await execute(async () => {
+    account.value = await savePaymentAccountMe({ asaas_api_key: value });
+    apiKey.value = "";
+  });
+}
+
+function onSave() {
+  if (!apiKey.value) return;
+  persist(apiKey.value);
+}
+
+function onRemove() {
+  persist("");
+}
+
+onMounted(fetchData);
+</script>

+ 0 - 159
src/pages/city/CityPage.vue

@@ -1,159 +0,0 @@
-<template>
-  <div>
-    <DefaultHeaderPage>
-      <template #after>
-        <q-btn
-          color="primary"
-          padding="8px 8px"
-          :label="$t('common.actions.add')"
-          icon="mdi-plus"
-          class="q-mt-md"
-          @click="onAddItem"
-        />
-      </template>
-    </DefaultHeaderPage>
-    <div>
-      <DefaultTable
-        ref="tableRef"
-        :columns="columns"
-        :api-call="getCities"
-        :delete-function="deleteCity"
-        :show-columns-select="false"
-        :title="
-          $t('common.terms.list') +
-          ' ' +
-          $t('common.ui.table.of') +
-          ' ' +
-          $t('ui.navigation.city')
-        "
-      >
-        <template #body-cell-actions="{ row }">
-          <q-btn
-            outline
-            style="width: 36px"
-            class="q-ml-auto q-mr-sm"
-            @click.prevent.stop="onRowClick(row)"
-          >
-            <q-icon name="mdi-file-edit-outline" />
-          </q-btn>
-        </template>
-      </DefaultTable>
-    </div>
-  </div>
-</template>
-<script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
-import { useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
-import { permissionStore } from "src/stores/permission";
-import { getCities, deleteCity } from "src/api/city";
-
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-
-const AddEditCityDialog = defineAsyncComponent(
-  () => import("src/pages/city/components/AddEditCityDialog.vue"),
-);
-
-const permission_store = permissionStore();
-const $q = useQuasar();
-const tableRef = useTemplateRef("tableRef");
-const { t } = useI18n();
-
-const columns = [
-  {
-    name: "id",
-    label: "ID",
-    field: "id",
-    align: "left",
-    required: true,
-    sortable: true,
-  },
-  {
-    name: "nome",
-    label: t("common.terms.name"),
-    field: "name",
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "state",
-    label: t("ui.navigation.state"),
-    field: (row) => row.state.name,
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "country",
-    label: t("ui.navigation.country"),
-    field: (row) => row.country.name,
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "status",
-    label: t("common.terms.status"),
-    field: (row) =>
-      row.status == "ACTIVE"
-        ? t("common.status.active")
-        : t("common.status.inactive"),
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "actions",
-    label: t("common.terms.actions"),
-    align: "left",
-    required: true,
-  },
-];
-
-const onRowClick = (row) => {
-  if (permission_store.getAccess("config.city", "edit") === false) {
-    $q.loading.hide();
-    $q.notify({
-      type: "negative",
-      message: t("validation.permissions.edit"),
-    });
-    return;
-  }
-  $q.dialog({
-    component: AddEditCityDialog,
-    componentProps: {
-      city: row,
-      title: () =>
-        useI18n().t("common.actions.edit") +
-        " " +
-        useI18n().t("ui.navigation.city"),
-    },
-  }).onOk(async (success) => {
-    if (success) {
-      tableRef.value.refresh();
-    }
-  });
-};
-
-const onAddItem = () => {
-  if (permission_store.getAccess("config.city", "add") === false) {
-    $q.loading.hide();
-    $q.notify({
-      type: "negative",
-      message: t("validation.permissions.add"),
-    });
-    return;
-  }
-  $q.dialog({
-    component: AddEditCityDialog,
-    componentProps: {
-      title: () =>
-        useI18n().t("common.actions.add") +
-        " " +
-        useI18n().t("ui.navigation.city"),
-    },
-  }).onOk(async (success) => {
-    if (success) {
-      tableRef.value.refresh();
-    }
-  });
-};
-</script>

+ 0 - 158
src/pages/city/components/AddEditCityDialog.vue

@@ -1,158 +0,0 @@
-<template>
-  <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
-      <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')"
-            :placeholder="'Nome completo da cidade'"
-            class="col-md-6 col-12"
-          />
-          <CountrySelect
-            v-model="selectedCountry"
-            v-model:error="validationErrors.country_id"
-            :placeholder="'Selecione o pais desse estado'"
-            :rules="[inputRules.required]"
-            :initial-id="form.country_id"
-            class="col-md-6 col-12"
-          />
-          <StateSelect
-            v-model="selectedState"
-            v-model:error="validationErrors.state_id"
-            :country="selectedCountry"
-            :initial-id="form.state_id"
-            :placeholder="'Selecione o estado dessa cidade'"
-            :rules="[inputRules.required]"
-            class="col-md-6 col-12"
-            @selected-country-id="countrySelectRef?.selectCountryById($event)"
-          />
-          <DefaultSelect
-            v-model="selectedStatus"
-            v-model:error="validationErrors.status"
-            :options="statusOptions"
-            :rules="[inputRules.required]"
-            :label="$t('common.terms.status')"
-            :placeholder="$t('common.terms.status')"
-            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="city ? $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 { ref, useTemplateRef, onMounted, watch } from "vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { useDialogPluginComponent } from "quasar";
-import { useI18n } from "vue-i18n";
-import { createCity, updateCity } from "src/api/city";
-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 CountrySelect from "src/components/selects/CountrySelect.vue";
-import StateSelect from "src/components/selects/StateSelect.vue";
-import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-
-defineEmits([
-  // REQUIRED; need to specify some events that your
-  // component will emit through useDialogPluginComponent()
-  ...useDialogPluginComponent.emits,
-]);
-
-const { city, title } = defineProps({
-  city: {
-    type: Object,
-    default: null,
-  },
-  title: {
-    type: Function,
-    default: () => useI18n().t("common.terms.title"),
-  },
-});
-
-const { t } = useI18n();
-const { inputRules } = useInputRules();
-
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
-  useDialogPluginComponent();
-
-const formRef = useTemplateRef("formRef");
-const countrySelectRef = useTemplateRef("countrySelectRef");
-
-const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
-  name: city ? city?.name : "",
-  country_id: city ? city?.country_id : null,
-  state_id: city ? city?.state_id : null,
-  status: city ? city?.status : "ACTIVE",
-});
-
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
-  onSuccess: () => onDialogOK(true),
-  formRef: formRef,
-});
-
-const selectedCountry = ref(null);
-const selectedState = ref(null);
-const selectedStatus = ref({
-  label: t("common.status.active"),
-  value: "ACTIVE",
-});
-const statusOptions = ref([
-  { label: t("common.status.active"), value: "ACTIVE" },
-  { label: t("common.status.inactive"), value: "INACTIVE" },
-]);
-
-const onOKClick = async () => {
-  if (city) {
-    await submitForm(() => updateCity(getUpdatedFields.value, city.id));
-  } else {
-    await submitForm(() => createCity({ ...form }));
-  }
-};
-
-watch(selectedStatus, () => {
-  form.status = selectedStatus.value?.value;
-});
-
-watch(selectedCountry, () => {
-  form.country_id = selectedCountry.value?.value;
-});
-
-watch(selectedState, () => {
-  form.state_id = selectedState.value?.value;
-});
-
-onMounted(async () => {
-  if (city) {
-    selectedStatus.value = statusOptions.value.find(
-      (status) => status.value === city.status,
-    );
-  }
-});
-</script>

+ 202 - 0
src/pages/classes/ClassPage.vue

@@ -0,0 +1,202 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Agenda" />
+
+    <div class="q-px-md q-pb-md">
+      <!-- Toolbar: Hoje | ‹ data › centralizada | + Nova Atividade -->
+      <div class="class-toolbar q-mb-md">
+        <!-- Esquerda -->
+        <div class="row items-center no-wrap" style="gap: 8px">
+          <q-btn
+            outline
+            color="primary"
+            label="Hoje"
+            no-caps
+            dense
+            class="q-px-md"
+            @click="goToday"
+          />
+          <q-btn
+            v-if="view === 'day'"
+            flat
+            no-caps
+            dense
+            color="primary"
+            icon="mdi-calendar-month-outline"
+            label="Voltar ao calendário"
+            class="q-px-sm"
+            @click="backToMonth"
+          />
+        </div>
+
+        <!-- Centro: ‹ Junho de 2026 › -->
+        <div class="row items-center no-wrap justify-center" style="gap: 4px">
+          <q-btn flat round dense icon="mdi-chevron-left" color="primary" @click="goPrev" />
+          <span class="text-h6 text-primary text-weight-medium" style="min-width: 220px; text-align: center">
+            {{ headerLabel }}
+          </span>
+          <q-btn flat round dense icon="mdi-chevron-right" color="primary" @click="goNext" />
+        </div>
+
+        <!-- Direita -->
+        <div class="row items-center no-wrap justify-end">
+          <q-btn
+            v-if="canAddClass"
+            color="primary"
+            icon="mdi-plus"
+            label="Nova Atividade"
+            no-caps
+            unelevated
+            @click="onAddClick"
+          />
+        </div>
+      </div>
+
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="primary" size="48px" />
+      </div>
+
+      <template v-else>
+        <MonthGrid
+          v-if="view === 'month'"
+          :current-date="currentDate"
+          :classes="classes"
+          @day-click="openDay"
+          @class-click="onClassClick"
+        />
+
+        <DayTimeline
+          v-else
+          :date="currentDate"
+          :classes="classes"
+          @class-click="onClassClick"
+        />
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch, defineAsyncComponent } from "vue";
+import { useQuasar } from "quasar";
+import {
+  addMonths,
+  subMonths,
+  addDays,
+  subDays,
+  format,
+} from "date-fns";
+import { ptBR } from "date-fns/locale";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import MonthGrid from "./components/MonthGrid.vue";
+import DayTimeline from "./components/DayTimeline.vue";
+import { getClasses } from "src/api/class";
+import { userStore } from "src/stores/user";
+import { permissionStore } from "src/stores/permission";
+
+const ClassAttendanceDialog = defineAsyncComponent(
+  () => import("./components/ClassAttendanceDialog.vue"),
+);
+const AddEditClassDialog = defineAsyncComponent(
+  () => import("./components/AddEditClassDialog.vue"),
+);
+
+const $q = useQuasar();
+const { selectedUnit } = userStore();
+const permissions = permissionStore();
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_classes", "add"),
+);
+const canAddClass = computed(() => canAdd.value);
+
+const view = ref("month"); // 'month' | 'day'
+const currentDate = ref(new Date());
+const classes = ref([]);
+const loading = ref(false);
+
+const headerLabel = computed(() => {
+  if (view.value === "month") {
+    const label = format(currentDate.value, "MMMM 'de' yyyy", { locale: ptBR });
+    return label.charAt(0).toUpperCase() + label.slice(1);
+  }
+  const label = format(currentDate.value, "EEEE, d 'de' MMMM", { locale: ptBR });
+  return label.charAt(0).toUpperCase() + label.slice(1);
+});
+
+const loadClasses = async () => {
+  loading.value = true;
+  try {
+    classes.value = await getClasses();
+  } finally {
+    loading.value = false;
+  }
+};
+
+const goPrev = () => {
+  currentDate.value =
+    view.value === "month"
+      ? subMonths(currentDate.value, 1)
+      : subDays(currentDate.value, 1);
+};
+
+const goNext = () => {
+  currentDate.value =
+    view.value === "month"
+      ? addMonths(currentDate.value, 1)
+      : addDays(currentDate.value, 1);
+};
+
+const goToday = () => {
+  currentDate.value = new Date();
+};
+
+const openDay = (date) => {
+  currentDate.value = date;
+  view.value = "day";
+};
+
+const backToMonth = () => {
+  view.value = "month";
+};
+
+// Clicar numa aula abre o dialog de registrar presença
+const onClassClick = (item) => {
+  $q.dialog({
+    component: ClassAttendanceDialog,
+    componentProps: { classData: item },
+  }).onOk(() => loadClasses());
+};
+
+// Adicionar nova atividade (opcionalmente já com a data do dia clicado)
+const openCreate = (date = null) => {
+  $q.dialog({
+    component: AddEditClassDialog,
+    componentProps: {
+      initialDate: date ? format(date, "yyyy-MM-dd") : null,
+    },
+  }).onOk(() => loadClasses());
+};
+
+// Na visão de dia já pré-seleciona a data atual
+const onAddClick = () => openCreate(view.value === "day" ? currentDate.value : null);
+
+// Recarrega quando o usuário troca de unidade no seletor do topo
+watch(
+  () => selectedUnit?.id,
+  (newId, oldId) => {
+    if (newId !== oldId) loadClasses();
+  },
+);
+
+onMounted(loadClasses);
+</script>
+
+<style scoped>
+.class-toolbar {
+  display: grid;
+  grid-template-columns: 1fr auto 1fr;
+  align-items: center;
+  gap: 8px;
+}
+</style>

+ 388 - 0
src/pages/classes/components/AddEditClassDialog.vue

@@ -0,0 +1,388 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 560px">
+      <q-card class="dialog-form-card">
+        <DefaultDialogHeader
+          :title="classData ? 'Editar Aula' : 'Nova Aula'"
+          @close="onDialogCancel"
+        />
+
+        <DefaultForm ref="formRef" @submit="onSubmit">
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--md"
+          >
+            <q-card-section class="q-pt-sm">
+              <div class="row q-col-gutter-sm">
+                <DefaultInput
+                  v-model="form.title"
+                  v-model:error="validationErrors.title"
+                  class="col-12"
+                  label="Nome da Atividade"
+                  :rules="[inputRules.required, inputRules.max(255)]"
+                />
+
+                <DefaultSelect
+                  v-model="form.class_package_unit_id"
+                  v-model:error="validationErrors.class_package_unit_id"
+                  label="Pacote relacionado"
+                  class="col-12"
+                  emit-value
+                  map-options
+                  option-label="name"
+                  option-value="id"
+                  :loading="loadingPackages"
+                  :options="packageOptions"
+                  :rules="[inputRules.required, inputRules.integer]"
+                />
+
+                <DefaultInput
+                  v-model="form.start_time"
+                  v-model:error="validationErrors.date_time_start"
+                  class="col-6"
+                  label="Hora de Início"
+                  mask="##:##"
+                  placeholder="08:40"
+                  :rules="[inputRules.required, inputRules.time]"
+                >
+                  <template #append>
+                    <q-icon
+                      class="cursor-pointer"
+                      color="secondary"
+                      name="mdi-clock-outline"
+                    >
+                      <q-popup-proxy
+                        transition-hide="scale"
+                        transition-show="scale"
+                      >
+                        <q-time
+                          v-model="form.start_time"
+                          format24h
+                          mask="HH:mm"
+                        >
+                          <div class="row items-center justify-end">
+                            <q-btn
+                              v-close-popup
+                              color="primary"
+                              label="OK"
+                              flat
+                            />
+                          </div>
+                        </q-time>
+                      </q-popup-proxy>
+                    </q-icon>
+                  </template>
+                </DefaultInput>
+
+                <DefaultInput
+                  v-model="form.end_time"
+                  v-model:error="validationErrors.date_time_end"
+                  class="col-6"
+                  label="Hora de Fim"
+                  mask="##:##"
+                  placeholder="09:40"
+                  :rules="[
+                    inputRules.required,
+                    inputRules.time,
+                    inputRules.afterTime(() => form.start_time),
+                  ]"
+                >
+                  <template #append>
+                    <q-icon
+                      class="cursor-pointer"
+                      color="secondary"
+                      name="mdi-clock-outline"
+                    >
+                      <q-popup-proxy
+                        transition-hide="scale"
+                        transition-show="scale"
+                      >
+                        <q-time v-model="form.end_time" format24h mask="HH:mm">
+                          <div class="row items-center justify-end">
+                            <q-btn
+                              v-close-popup
+                              color="primary"
+                              flat
+                              label="OK"
+                            />
+                          </div>
+                        </q-time>
+                      </q-popup-proxy>
+                    </q-icon>
+                  </template>
+                </DefaultInput>
+
+                <DefaultSelect
+                  v-model="form.instructor"
+                  v-model:error="validationErrors.instructor"
+                  class="col-6"
+                  clearable
+                  emit-value
+                  label="Neurotrainer"
+                  map-options
+                  option-label="name"
+                  option-value="name"
+                  :loading="loadingInstructors"
+                  :options="instructorOptions"
+                  :rules="[inputRules.max(255)]"
+                />
+
+                <DefaultInput
+                  v-model="form.room"
+                  v-model:error="validationErrors.room"
+                  class="col-6"
+                  label="Número da Sala"
+                  :rules="[inputRules.max(255)]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="form.date_display"
+                  v-model:error="validationErrors.date_time_start"
+                  v-model:untreated-date="form.date"
+                  class="col-12"
+                  label="Data de Início"
+                  :rules="[inputRules.required, inputRules.date]"
+                />
+              </div>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :loading="saving"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { createClass, updateClass } from "src/api/class";
+import { getUnitPackagesForSelect } from "src/api/package";
+import { getUsersByUnit } from "src/api/user";
+import { onMounted, ref, useTemplateRef, watch } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+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";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  classData: { type: Object, default: null },
+  // Data pré-selecionada ao adicionar a partir de um dia do calendário (YYYY-MM-DD)
+  initialDate: { type: String, default: null },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const loadingPackages = ref(false);
+const packageOptions = ref([]);
+const loadingInstructors = ref(false);
+const instructorOptions = ref([]);
+
+const toDisplay = (isoDate) =>
+  isoDate ? isoDate.split("-").reverse().join("/") : null;
+
+const { form, getUpdatedFields } = useForm({
+  title: props.classData?.title ?? "",
+  class_package_unit_id: props.classData?.class_package_unit_id ?? null,
+  instructor: props.classData?.instructor ?? "",
+  room: props.classData?.room ?? "",
+  start_time: props.classData?.date_time_start?.slice(11, 16) ?? "",
+  end_time: props.classData?.date_time_end?.slice(11, 16) ?? "",
+
+  date: props.classData?.date_time_start?.slice(0, 10) ?? props.initialDate ?? null,
+
+  date_display: toDisplay(
+    props.classData?.date_time_start?.slice(0, 10) ?? props.initialDate,
+  ),
+});
+
+const trimTime = (time) => (time ? time.slice(0, 5) : "");
+
+const applyPackageSchedule = () => {
+  if (props.classData) return;
+
+  const pkg = selectedPackage();
+
+  if (!pkg) return;
+
+  const selectedWeekday = form.date
+    ? new Date(`${form.date}T00:00:00`).getDay()
+    : null;
+
+  const startTime =
+    selectedWeekday === pkg.second_weekday
+      ? pkg.second_start_time
+      : pkg.start_time;
+
+  if (!startTime) return;
+
+  form.start_time = trimTime(startTime);
+
+  form.end_time = calculateEndTime(
+    form.start_time,
+    pkg.class_duration_minutes ?? 120,
+  );
+}
+
+const calculateEndTime = (startTime, durationMinutes = 120) => {
+  if (!/^\d{2}:\d{2}$/.test(startTime ?? "")) return "";
+
+  const [hours, minutes] = startTime.split(":").map(Number);
+
+  if (hours > 23 || minutes > 59) return "";
+
+  const endMinutes = (hours * 60 + minutes + durationMinutes) % (24 * 60);
+
+  return `${String(Math.floor(endMinutes / 60)).padStart(2, "0")}:${String(
+    endMinutes % 60,
+  ).padStart(2, "0")}`;
+}
+
+const selectedPackage = () => {
+  return packageOptions.value.find(
+    (pkg) => pkg.id === form.class_package_unit_id,
+  );
+}
+
+//
+
+const loadInstructors = async () => {
+  loadingInstructors.value = true;
+
+  try {
+    const users = await getUsersByUnit();
+
+    instructorOptions.value = (users ?? []).filter(
+      (u) =>
+        u.user_type_system_key === "NEUROTRAINER" ||
+        u.user_type === "NEUROTRAINER" ||
+        u.user_type_label?.trim().toUpperCase() === "NEUROTRAINER",
+    );
+  } catch (e) {
+    console.error("Erro ao carregar neurotrainers:", e);
+  } finally {
+    loadingInstructors.value = false;
+  }
+};
+
+const loadPackages = async () => {
+  loadingPackages.value = true;
+
+  try {
+    packageOptions.value = await getUnitPackagesForSelect();
+  } finally {
+    loadingPackages.value = false;
+  }
+};
+
+//
+
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
+  onSuccess: () => {
+    $q.notify({ type: "positive", message: "Aula salva com sucesso." });
+    onDialogOK(true);
+  },
+});
+
+const onSubmit = async () => {
+  const payload = {
+    title: form.title,
+    class_package_unit_id: form.class_package_unit_id,
+    instructor: form.instructor || null,
+    room: form.room || null,
+    date_time_start: `${form.date}T${form.start_time}:00`,
+    date_time_end: `${form.date}T${form.end_time}:00`,
+  };
+
+  await execute(() => {
+    if (!props.classData?.id) return createClass(payload);
+
+    const changed = getUpdatedFields.value;
+
+    const updatePayload = {};
+
+    for (const key of [
+      "title",
+      "class_package_unit_id",
+      "instructor",
+      "room",
+    ]) {
+      if (key in changed) updatePayload[key] = payload[key];
+    }
+
+    if ("date" in changed || "start_time" in changed) {
+      updatePayload.date_time_start = payload.date_time_start;
+    }
+
+    if ("date" in changed || "end_time" in changed) {
+      updatePayload.date_time_end = payload.date_time_end;
+    }
+
+    return updateClass(updatePayload, props.classData.id);
+  });
+};
+
+watch(() => form.class_package_unit_id, applyPackageSchedule);
+
+watch(
+  () => form.date,
+  () => {
+    if (!form.start_time) applyPackageSchedule();
+  },
+);
+
+watch(
+  () => form.start_time,
+  (startTime) => {
+    const endTime = calculateEndTime(
+      startTime,
+      selectedPackage()?.class_duration_minutes ?? 120,
+    );
+
+    if (endTime) form.end_time = endTime;
+  },
+);
+
+onMounted(() => {
+  loadPackages();
+  loadInstructors();
+});
+</script>

+ 287 - 0
src/pages/classes/components/ClassAttendanceDialog.vue

@@ -0,0 +1,287 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 560px">
+      <q-card style="display: flex; flex-direction: column; max-height: 88vh">
+        <DefaultDialogHeader
+          :title="`Registrar Presença - ${classData.title}`"
+          @close="onDialogCancel"
+        />
+
+        <q-card-section class="q-pt-none" style="flex: 1; overflow-y: auto">
+          <!-- Cabeçalho da aula -->
+          <div class="attendance-head">
+            <div class="attendance-head__badge">{{ startTime }}</div>
+            <div class="column">
+              <span class="text-subtitle1 text-primary text-weight-medium">
+                {{ classData.title }}
+              </span>
+              <div class="row items-center q-gutter-x-md text-caption text-foreground">
+                <span class="row items-center" style="gap: 4px">
+                  <q-icon name="mdi-map-marker-outline" color="secondary" size="15px" />
+                  {{ classData.room || "—" }}
+                </span>
+                <span class="row items-center" style="gap: 4px">
+                  <q-icon name="mdi-account-outline" color="secondary" size="15px" />
+                  {{ classData.instructor || "—" }}
+                </span>
+                <span class="row items-center" style="gap: 4px">
+                  <q-icon name="mdi-clock-outline" color="secondary" size="15px" />
+                  {{ timeRange }}
+                </span>
+              </div>
+            </div>
+          </div>
+
+          <!-- Lista de alunos -->
+          <div class="q-mt-md row items-center" style="gap: 6px">
+            <q-icon name="mdi-account-group-outline" color="primary" size="18px" />
+            <span class="text-weight-medium text-primary">Alunos ({{ students.length }})</span>
+          </div>
+
+          <q-separator class="q-mt-sm" />
+
+          <div v-if="loading" class="flex flex-center q-pa-lg">
+            <q-spinner color="primary" size="32px" />
+          </div>
+
+          <div v-else-if="!students.length" class="text-center text-grey-6 q-pa-lg">
+            Nenhum aluno com contrato ativo neste pacote.
+          </div>
+
+          <q-markup-table v-else flat dense class="attendance-table">
+            <thead>
+              <tr>
+                <th class="text-left">Aluno</th>
+                <th class="text-center">Aulas</th>
+                <th class="text-center">Presença</th>
+                <th class="text-center">Justificar</th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr v-for="row in students" :key="row.student_id">
+                <td class="text-left">{{ row.name }}</td>
+                <td class="text-center">{{ row.classes_count }}</td>
+
+                <!-- Presença: verde = presente, vermelho = falta -->
+                <td class="text-center">
+                  <div class="row no-wrap items-center justify-center" style="gap: 6px">
+                    <q-btn
+                      :disable="!canEdit"
+                      round
+                      unelevated
+                      size="sm"
+                      icon="mdi-check"
+                      :color="row.in_class ? 'positive' : 'grey-4'"
+                      :text-color="row.in_class ? 'white' : 'grey-7'"
+                      @click="markPresent(row)"
+                    >
+                      <q-tooltip>Presente</q-tooltip>
+                    </q-btn>
+                    <q-btn
+                      :disable="!canEdit"
+                      round
+                      unelevated
+                      size="sm"
+                      icon="mdi-close"
+                      :color="!row.in_class ? 'negative' : 'grey-4'"
+                      :text-color="!row.in_class ? 'white' : 'grey-7'"
+                      @click="markAbsent(row)"
+                    >
+                      <q-tooltip>Falta</q-tooltip>
+                    </q-btn>
+                  </div>
+                </td>
+
+                <!-- Justificar: só habilita para faltas -->
+                <td class="text-center">
+                  <q-btn
+                    :disable="!canEdit || row.in_class"
+                    no-caps
+                    unelevated
+                    size="sm"
+                    :color="row.justified ? 'primary' : 'secondary'"
+                    text-color="white"
+                    :label="row.justified ? 'Justificada' : 'Justificar'"
+                    @click="openJustify(row)"
+                  >
+                    <q-tooltip v-if="row.in_class">
+                      Só é possível justificar faltas
+                    </q-tooltip>
+                  </q-btn>
+                </td>
+              </tr>
+            </tbody>
+          </q-markup-table>
+
+          <!-- Resumo -->
+          <div v-if="students.length" class="attendance-summary q-mt-md">
+            <div class="text-weight-medium text-primary q-mb-xs">Resumo</div>
+            <div class="row justify-between attendance-summary__line">
+              <span>Total de Alunos</span><span>{{ students.length }}</span>
+            </div>
+            <div class="row justify-between attendance-summary__line">
+              <span>Presente</span><span>{{ presentCount }}</span>
+            </div>
+          </div>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn outline color="primary" label="Cancelar" no-caps @click="onDialogCancel" />
+          <q-btn
+            v-if="canEdit"
+            color="primary"
+            label="Salvar"
+            no-caps
+            :loading="saving"
+            :disable="!students.length"
+            @click="onSave"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { format, parseISO } from "date-fns";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import JustifyAttendanceDialog from "./JustifyAttendanceDialog.vue";
+import { getClassStudents, saveClassAttendance } from "src/api/class";
+import { permissionStore } from "src/stores/permission";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  classData: { type: Object, required: true },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+const permissions = permissionStore();
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_classes", "edit"),
+);
+
+const students = ref([]);
+const loading = ref(false);
+const saving = ref(false);
+
+const startTime = computed(() =>
+  props.classData.date_time_start
+    ? format(parseISO(props.classData.date_time_start), "HH:mm")
+    : "--:--",
+);
+
+const timeRange = computed(() => {
+  const { date_time_start, date_time_end } = props.classData;
+  if (!date_time_start || !date_time_end) return "";
+  return `${format(parseISO(date_time_start), "HH:mm")} - ${format(parseISO(date_time_end), "HH:mm")}`;
+});
+
+const presentCount = computed(
+  () => students.value.filter((s) => s.in_class).length,
+);
+
+const loadStudents = async () => {
+  loading.value = true;
+  try {
+    students.value = await getClassStudents(props.classData.id);
+  } finally {
+    loading.value = false;
+  }
+};
+
+// Clicar no verde marca presente automaticamente (e remove qualquer justificativa)
+const markPresent = (row) => {
+  row.in_class = true;
+  row.justified = false;
+  row.notes = null;
+};
+
+const markAbsent = (row) => {
+  row.in_class = false;
+};
+
+const openJustify = (row) => {
+  // Só faz sentido justificar faltas
+  if (row.in_class) return;
+
+  $q.dialog({
+    component: JustifyAttendanceDialog,
+    componentProps: { notes: row.notes ?? "" },
+  }).onOk((notes) => {
+    row.in_class = false;
+    row.justified = true;
+    row.notes = notes;
+  });
+};
+
+const onSave = async () => {
+  saving.value = true;
+  try {
+    const attendances = students.value.map((s) => ({
+      student_id: s.student_id,
+      in_class: s.in_class,
+      justified: s.justified,
+      notes: s.notes ?? null,
+    }));
+    await saveClassAttendance(props.classData.id, attendances);
+    $q.notify({ type: "positive", message: "Presença registrada." });
+    onDialogOK(true);
+  } catch {
+    // erros já notificados pelo interceptor do axios
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(loadStudents);
+</script>
+
+<style scoped>
+.attendance-head {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  background: #eaf8f8;
+  border-radius: 10px;
+  padding: 12px;
+  margin-top: 8px;
+}
+
+.attendance-head__badge {
+  background: #ace4e4;
+  color: #003e29;
+  font-weight: 600;
+  border-radius: 8px;
+  padding: 10px 12px;
+  font-size: 14px;
+}
+
+.attendance-table :deep(th) {
+  font-size: 12px;
+  color: #909090;
+  font-weight: 500;
+}
+
+.attendance-table :deep(td) {
+  font-size: 13px;
+}
+
+.attendance-summary {
+  background: #eaf8f8;
+  border-radius: 8px;
+  padding: 10px 12px;
+  font-size: 13px;
+  color: #003e29;
+}
+
+.attendance-summary__line {
+  padding: 2px 0;
+}
+</style>

+ 108 - 0
src/pages/classes/components/ClassEventCard.vue

@@ -0,0 +1,108 @@
+<template>
+  <div
+    class="class-event-card"
+    :class="{ 'class-event-card--compact': compact }"
+    @click.stop="$emit('click', classItem)"
+  >
+    <div class="class-event-card__title">{{ classItem.title }}</div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-map-marker-outline" size="14px" />
+      <span>{{ classItem.room }}</span>
+    </div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-account-outline" size="14px" />
+      <span>{{ classItem.instructor }}</span>
+    </div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-clock-outline" size="14px" />
+      <span>{{ timeRange }}</span>
+    </div>
+
+    <div class="class-event-card__row">
+      <q-icon name="mdi-account-group-outline" size="14px" />
+      <span>{{ enrollment }}</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { format, parseISO } from "date-fns";
+
+const props = defineProps({
+  classItem: { type: Object, required: true },
+  compact: { type: Boolean, default: false },
+});
+
+defineEmits(["click"]);
+
+const timeRange = computed(() => {
+  const start = format(parseISO(props.classItem.date_time_start), "HH:mm");
+  const end = format(parseISO(props.classItem.date_time_end), "HH:mm");
+  return `${start} - ${end}`;
+});
+
+const enrollment = computed(() => {
+  const pad = (n) => String(n ?? 0).padStart(2, "0");
+  // presentes / total de alunos com contrato ativo no pacote
+  return `${pad(props.classItem.present_count)}/${pad(props.classItem.students_count)}`;
+});
+</script>
+
+<style scoped>
+.class-event-card {
+  background: #ffffff;
+  border-radius: 8px;
+  padding: 8px 10px;
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
+  cursor: pointer;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 3px;
+  transition: box-shadow 0.18s ease, transform 0.18s ease;
+}
+
+.class-event-card:hover {
+  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
+  transform: translateY(-1px);
+}
+
+.class-event-card__title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #003e29;
+  text-align: center;
+  line-height: 1.15;
+  margin-bottom: 2px;
+}
+
+.class-event-card__row {
+  display: flex;
+  align-items: center;
+  gap: 5px;
+  font-size: 11px;
+  color: #505050;
+  align-self: flex-start;
+}
+
+.class-event-card__row .q-icon {
+  color: #ff8340;
+  flex-shrink: 0;
+}
+
+.class-event-card--compact {
+  padding: 6px 8px;
+}
+
+.class-event-card--compact .class-event-card__title {
+  font-size: 11px;
+}
+
+.class-event-card--compact .class-event-card__row {
+  font-size: 10px;
+}
+</style>

+ 211 - 0
src/pages/classes/components/DayTimeline.vue

@@ -0,0 +1,211 @@
+<template>
+  <div class="day-timeline">
+    <div ref="scrollRef" class="day-timeline__scroll">
+    <div class="day-timeline__grid">
+      <!-- Hour rows (labels + lines) -->
+      <div
+        v-for="hour in hours"
+        :key="hour"
+        class="day-timeline__hour-row"
+        :style="{ height: hourHeight + 'px' }"
+      >
+        <div class="day-timeline__hour-label">{{ formatHour(hour) }}</div>
+        <div class="day-timeline__hour-line"></div>
+      </div>
+
+      <!-- Positioned events -->
+      <div class="day-timeline__events">
+        <div
+          v-for="item in positionedClasses"
+          :key="item.id"
+          class="day-timeline__event"
+          :style="item.style"
+          @click="$emit('class-click', item.data)"
+        >
+          <div class="day-timeline__event-title">{{ item.data.title }}</div>
+          <div class="day-timeline__event-meta">
+            <q-icon name="mdi-clock-outline" size="13px" />
+            <span>{{ item.timeRange }}</span>
+          </div>
+          <div class="day-timeline__event-meta">
+            <q-icon name="mdi-map-marker-outline" size="13px" />
+            <span>{{ item.data.room }}</span>
+            <span class="day-timeline__event-sep">·</span>
+            <q-icon name="mdi-account-outline" size="13px" />
+            <span>{{ item.data.instructor }}</span>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div v-if="!positionedClasses.length" class="day-timeline__empty">
+      Nenhuma aula agendada para este dia.
+    </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, onMounted, watch, nextTick } from "vue";
+import { format, parseISO, isSameDay, differenceInMinutes } from "date-fns";
+
+const props = defineProps({
+  date: { type: Date, required: true },
+  classes: { type: Array, default: () => [] },
+});
+
+defineEmits(["class-click"]);
+
+const START_HOUR = 0;
+const END_HOUR = 23;
+const DEFAULT_SCROLL_HOUR = 7; // abre no início do horário comercial
+const hourHeight = 64;
+
+const scrollRef = ref(null);
+
+const hours = computed(() => {
+  const list = [];
+  for (let h = START_HOUR; h <= END_HOUR; h++) list.push(h);
+  return list;
+});
+
+const formatHour = (hour) => `${String(hour).padStart(2, "0")}:00`;
+
+const dayClasses = computed(() =>
+  props.classes.filter((c) => isSameDay(parseISO(c.date_time_start), props.date)),
+);
+
+const positionedClasses = computed(() =>
+  dayClasses.value.map((item) => {
+    const start = parseISO(item.date_time_start);
+    const end = parseISO(item.date_time_end);
+    const minutesFromTop =
+      (start.getHours() - START_HOUR) * 60 + start.getMinutes();
+    const durationMinutes = Math.max(differenceInMinutes(end, start), 30);
+
+    return {
+      id: item.id,
+      data: item,
+      timeRange: `${format(start, "HH:mm")} - ${format(end, "HH:mm")}`,
+      style: {
+        top: (minutesFromTop / 60) * hourHeight + "px",
+        height: (durationMinutes / 60) * hourHeight + "px",
+      },
+    };
+  }),
+);
+
+// Posiciona o scroll na primeira aula do dia (ou no horário comercial)
+const scrollToRelevantHour = async () => {
+  await nextTick();
+  if (!scrollRef.value) return;
+
+  const firstStart = dayClasses.value
+    .map((c) => parseISO(c.date_time_start))
+    .sort((a, b) => a - b)[0];
+
+  const targetHour = firstStart
+    ? Math.max(firstStart.getHours() - 1, START_HOUR)
+    : DEFAULT_SCROLL_HOUR;
+
+  scrollRef.value.scrollTop = (targetHour - START_HOUR) * hourHeight;
+};
+
+onMounted(scrollToRelevantHour);
+watch(() => props.date, scrollToRelevantHour);
+</script>
+
+<style scoped>
+.day-timeline {
+  border: 1px solid #ff8340;
+  border-radius: 10px;
+  background: #ffffff;
+  overflow: hidden;
+}
+
+.day-timeline__scroll {
+  max-height: calc(100vh - 220px);
+  overflow-y: auto;
+}
+
+.day-timeline__grid {
+  position: relative;
+}
+
+.day-timeline__hour-row {
+  position: relative;
+  display: flex;
+}
+
+.day-timeline__hour-label {
+  width: 64px;
+  flex-shrink: 0;
+  text-align: right;
+  padding: 2px 10px 0 0;
+  font-size: 11px;
+  color: #909090;
+  transform: translateY(-7px);
+}
+
+.day-timeline__hour-line {
+  flex: 1;
+  border-top: 1px solid #f0e0d6;
+  border-left: 1px solid #ffd9c4;
+}
+
+.day-timeline__events {
+  position: absolute;
+  top: 0;
+  left: 64px;
+  right: 8px;
+  bottom: 0;
+}
+
+.day-timeline__event {
+  position: absolute;
+  left: 6px;
+  right: 6px;
+  background: #ffe0d0;
+  border-left: 3px solid #ff8340;
+  border-radius: 6px;
+  padding: 4px 8px;
+  overflow: hidden;
+  cursor: pointer;
+  transition: filter 0.15s ease;
+}
+
+.day-timeline__event:hover {
+  filter: brightness(0.97);
+}
+
+.day-timeline__event-title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #003e29;
+  line-height: 1.2;
+}
+
+.day-timeline__event-meta {
+  display: flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 11px;
+  color: #6a4a37;
+  margin-top: 2px;
+}
+
+.day-timeline__event-meta .q-icon {
+  color: #ff8340;
+}
+
+.day-timeline__event-sep {
+  margin: 0 2px;
+}
+
+.day-timeline__empty {
+  padding: 24px;
+  text-align: center;
+  color: #909090;
+  font-size: 13px;
+}
+</style>

+ 84 - 0
src/pages/classes/components/JustifyAttendanceDialog.vue

@@ -0,0 +1,84 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 560px">
+      <q-card class="dialog-form-card">
+        <DefaultDialogHeader
+          title="Justificar Registrar Presença"
+          @close="onDialogCancel"
+        />
+
+        <DefaultForm @submit="onConfirm">
+          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--xs">
+            <q-card-section class="q-pt-sm">
+              <DefaultInput
+                v-model="text"
+                autofocus
+                autogrow
+                outlined
+                bg-color="white"
+                input-style="min-height: 110px"
+                :placeholder="placeholder"
+                type="textarea"
+              >
+                <template #append>
+                  <q-icon
+                    color="secondary"
+                    name="mdi-pencil-outline"
+                  />
+                </template>
+              </DefaultInput>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+
+            <q-btn
+              color="primary"
+              label="Justificar"
+              no-caps
+              type="submit"
+              :disable="!text.trim()"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  notes: { type: String, default: "" },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const placeholder =
+  "Ex: O(A) discente [Nome do Aluno] apresentou atestado médico (CID: [Opcional]) " +
+  "justificando sua ausência na aula do dia [Data]. A falta foi abonada/justificada " +
+  "conforme as normas da instituição, garantindo ao aluno o direito de [realizar " +
+  "prova substitutiva / entrega de trabalho em nova data].";
+
+const text = ref(props.notes ?? "");
+
+const onConfirm = () => {
+  onDialogOK(text.value.trim());
+};
+</script>

+ 195 - 0
src/pages/classes/components/MonthGrid.vue

@@ -0,0 +1,195 @@
+<template>
+  <div class="month-grid">
+    <!-- Weekday header -->
+    <div class="month-grid__weekdays">
+      <div v-for="label in weekdayLabels" :key="label" class="month-grid__weekday">
+        {{ label }}
+      </div>
+    </div>
+
+    <!-- Day cells -->
+    <div class="month-grid__body">
+      <div
+        v-for="day in days"
+        :key="day.key"
+        class="month-grid__cell"
+        :class="{
+          'month-grid__cell--outside': !day.inCurrentMonth,
+          'month-grid__cell--today': day.isToday,
+        }"
+        @click="$emit('day-click', day.date)"
+      >
+        <div class="month-grid__cell-number">{{ day.dayNumber }}</div>
+
+        <div class="month-grid__cell-events">
+          <ClassEventCard
+            v-for="item in day.classes.slice(0, MAX_VISIBLE)"
+            :key="item.id"
+            :class-item="item"
+            compact
+            @click="$emit('class-click', item)"
+          />
+
+          <button
+            v-if="day.classes.length > MAX_VISIBLE"
+            type="button"
+            class="month-grid__more"
+            @click.stop="$emit('day-click', day.date)"
+          >
+            +{{ day.classes.length - MAX_VISIBLE }} ver mais
+          </button>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import {
+  startOfMonth,
+  endOfMonth,
+  startOfWeek,
+  endOfWeek,
+  eachDayOfInterval,
+  isSameMonth,
+  isSameDay,
+  parseISO,
+  format,
+} from "date-fns";
+import { ptBR } from "date-fns/locale";
+import ClassEventCard from "./ClassEventCard.vue";
+
+const props = defineProps({
+  currentDate: { type: Date, required: true },
+  classes: { type: Array, default: () => [] },
+});
+
+defineEmits(["day-click", "class-click"]);
+
+// Máximo de aulas exibidas por dia; o excedente vira "+N ver mais"
+const MAX_VISIBLE = 1;
+
+const weekdayLabels = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+
+const days = computed(() => {
+  const monthStart = startOfMonth(props.currentDate);
+  const monthEnd = endOfMonth(props.currentDate);
+  const gridStart = startOfWeek(monthStart, { weekStartsOn: 0 });
+  const gridEnd = endOfWeek(monthEnd, { weekStartsOn: 0 });
+  const today = new Date();
+
+  return eachDayOfInterval({ start: gridStart, end: gridEnd }).map((date) => ({
+    date,
+    key: format(date, "yyyy-MM-dd"),
+    dayNumber: format(date, "d", { locale: ptBR }),
+    inCurrentMonth: isSameMonth(date, props.currentDate),
+    isToday: isSameDay(date, today),
+    classes: props.classes.filter((c) =>
+      isSameDay(parseISO(c.date_time_start), date),
+    ),
+  }));
+});
+</script>
+
+<style scoped>
+.month-grid {
+  display: flex;
+  flex-direction: column;
+  border: 1px solid #ff8340;
+  border-radius: 10px;
+  overflow: hidden;
+  background: #eaf8f8;
+}
+
+.month-grid__weekdays {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  background: #003e29;
+}
+
+.month-grid__weekday {
+  text-align: center;
+  padding: 8px 4px;
+  font-size: 12px;
+  font-weight: 600;
+  color: #ffffff;
+  text-transform: uppercase;
+  letter-spacing: 0.4px;
+}
+
+.month-grid__body {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+}
+
+.month-grid__cell {
+  position: relative;
+  min-height: 116px;
+  border-right: 1px solid #ff8340;
+  border-bottom: 1px solid #ff8340;
+  padding: 6px;
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+  cursor: pointer;
+  transition: background 0.15s ease;
+}
+
+/* remove the right border on the last column of each row */
+.month-grid__cell:nth-child(7n) {
+  border-right: none;
+}
+
+.month-grid__cell:hover {
+  background: rgba(255, 131, 64, 0.08);
+}
+
+.month-grid__cell--outside {
+  background: rgba(0, 0, 0, 0.02);
+}
+
+.month-grid__cell--outside .month-grid__cell-number {
+  color: #b0b0b0;
+}
+
+.month-grid__cell--today .month-grid__cell-number {
+  background: #ff8340;
+  color: #ffffff;
+}
+
+.month-grid__cell-number {
+  align-self: flex-start;
+  font-size: 12px;
+  font-weight: 600;
+  color: #505050;
+  width: 22px;
+  height: 22px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+}
+
+.month-grid__cell-events {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.month-grid__more {
+  align-self: flex-start;
+  border: none;
+  background: transparent;
+  padding: 2px 4px;
+  font-size: 11px;
+  font-weight: 600;
+  color: #ff8340;
+  cursor: pointer;
+  border-radius: 4px;
+}
+
+.month-grid__more:hover {
+  background: rgba(255, 131, 64, 0.12);
+}
+</style>

+ 397 - 0
src/pages/contracts/ContractsPage.vue

@@ -0,0 +1,397 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Contratos" />
+
+    <div class="q-px-sm">
+      <div class="stat-cards-row q-mb-md">
+        <DashboardStatCard
+          icon="mdi-file-check-outline"
+          title="Contratos Ativos"
+          :value="String(metrics.active)"
+        />
+
+        <DashboardStatCard
+          icon="mdi-snowflake"
+          title="Contratos Trancados"
+          :value="String(metrics.frozen)"
+        />
+
+        <DashboardStatCard
+          icon="mdi-file-remove-outline"
+          title="Contratos Cancelados"
+          :value="String(metrics.cancelled)"
+        />
+
+        <DashboardStatCard
+          icon="mdi-alert-circle-outline"
+          title="Contratos Inadimplentes"
+          value="0"
+        />
+      </div>
+
+      <DefaultTable
+        v-model:rows="rows"
+        descricao="contratos"
+        no-api-call
+        title="Contratos"
+        :columns
+        :feminino="false"
+        :loading="isLoading"
+      >
+        <template #body-cell-contato="{ row }">
+          <q-td>
+            <div>
+              {{ row.student_phone }}
+            </div>
+
+            <div class="text-grey-6 text-caption">
+              {{ row.student_city }}
+
+              <template v-if="row.student_city && row.student_state_code">
+                ,
+              </template>
+
+              {{ row.student_state_code }}
+            </div>
+          </q-td>
+        </template>
+
+        <template #body-cell-status="{ row }">
+          <q-td align="center">
+            <q-badge
+              :color="statusColor(row.status)"
+              :label="statusLabel(row.status)"
+            />
+          </q-td>
+        </template>
+
+        <template #body-cell-actions="{ row }">
+          <q-td align="center">
+            <q-item-section
+              class="no-wrap"
+              style="flex-direction: row; gap: 4px"
+            >
+              <q-btn
+                icon="mdi-eye-outline"
+                outline
+                style="width: 36px"
+                @click.prevent.stop="handleView(row)"
+              >
+                <q-tooltip>
+                  Visualizar contrato
+                </q-tooltip>
+              </q-btn>
+
+              <q-btn
+                v-if="canEdit"
+                icon="mdi-pencil-outline"
+                outline
+                style="width: 36px"
+                @click.prevent.stop="handleEdit(row)"
+              >
+                <q-tooltip>
+                  Editar contrato
+                </q-tooltip>
+              </q-btn>
+
+              <q-btn
+                icon="mdi-history"
+                outline
+                style="width: 36px"
+                @click.prevent.stop="handleHistory(row)"
+              >
+                <q-tooltip>
+                  Ver histórico
+                </q-tooltip>
+              </q-btn>
+
+              <q-btn
+                v-if="canEdit"
+                icon="mdi-dots-vertical"
+                outline
+                style="width: 36px"
+                @click.prevent.stop
+              >
+                <q-menu>
+                  <q-list style="min-width: 170px">
+                    <template
+                      v-if="
+                        row.status === 'frozen' ||
+                        row.status === 'cancelled'
+                      "
+                    >
+                      <q-item
+                        v-close-popup
+                        clickable
+                        @click="handleReactivate(row)"
+                      >
+                        <q-item-section>
+                          Reativar Contrato
+                        </q-item-section>
+                      </q-item>
+                    </template>
+
+                    <template v-else>
+                      <q-item
+                        v-close-popup
+                        clickable
+                        @click="handleFreeze(row)"
+                      >
+                        <q-item-section>
+                          Trancar Contrato
+                        </q-item-section>
+                      </q-item>
+
+                      <q-item
+                        v-close-popup
+                        clickable
+                        @click="handleCancel(row)"
+                      >
+                        <q-item-section>
+                          Cancelar Contrato
+                        </q-item-section>
+                      </q-item>
+                    </template>
+                  </q-list>
+                </q-menu>
+              </q-btn>
+            </q-item-section>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {
+  cancelContract,
+  getAllContracts,
+  reactivateContract,
+} from "src/api/studentContract";
+
+import { computed, onMounted, ref, watch } from "vue";
+import { permissionStore } from "src/stores/permission";
+import { userStore } from "src/stores/user";
+import { useQuasar } from "quasar";
+
+import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
+import ContractActionConfirmDialog from "src/pages/students/components/ContractActionConfirmDialog.vue";
+import ContractHistoryDialog from "src/pages/students/components/ContractHistoryDialog.vue";
+import FreezeContractDialog from "src/pages/students/components/FreezeContractDialog.vue";
+import ViewContractDialog from "src/pages/students/components/ViewContractDialog.vue";
+
+import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+
+const $q = useQuasar();
+const store = userStore();
+
+const permissions = permissionStore();
+
+const isLoading = ref(false);
+const rows = ref([]);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_contracts", "edit"),
+);
+
+const metrics = computed(() => ({
+  active: rows.value.filter((row) => row.status === "active").length,
+  cancelled: rows.value.filter((row) => row.status === "cancelled").length,
+  frozen: rows.value.filter((row) => row.status === "frozen").length,
+}));
+
+const columns = [
+  {
+    align: "left",
+    field: "student_name",
+    label: "Nome",
+    name: "name",
+  },
+  {
+    align: "left",
+    field: null,
+    label: "Contato/Endereço",
+    name: "contato",
+  },
+  {
+    align: "left",
+    field: "package_name",
+    label: "Pacote",
+    name: "package_name",
+  },
+  {
+    align: "center",
+    field: "status",
+    label: "Status Contrato",
+    name: "status",
+  },
+  {
+    align: "center",
+    field: null,
+    label: "Ações",
+    name: "actions",
+  },
+];
+
+const confirmAction = (title, message, apiFunction, contract) => {
+  $q.dialog({
+    component: ContractActionConfirmDialog,
+    componentProps: {
+      message,
+      title,
+    },
+  }).onOk(async () => {
+    try {
+      const updated = await apiFunction(contract.id);
+
+      const index = rows.value.findIndex(
+        (row) => row.id === contract.id,
+      );
+
+      if (index !== -1) {
+        rows.value[index] = {
+          ...rows.value[index],
+          status: updated.status,
+        };
+      }
+    } catch (error) {
+      console.error(error);
+
+      $q.notify({
+        message: "Erro ao atualizar status do contrato.",
+        type: "negative",
+      });
+    }
+  });
+};
+
+const handleCancel = (contract) => {
+  confirmAction(
+    "Cancelar Contrato",
+    "Você tem certeza que deseja CANCELAR este contrato? Isso irá gerar multas e cancelamento da cobrança recorrente.",
+    cancelContract,
+    contract,
+  );
+};
+
+const handleEdit = (contract) => {
+  $q.dialog({
+    component: AddEditContractDialog,
+    componentProps: {
+      contract,
+    },
+  }).onOk(loadContracts);
+};
+
+const handleFreeze = (contract) => {
+  $q.dialog({
+    component: FreezeContractDialog,
+    componentProps: {
+      contract,
+    },
+  }).onOk((updated) => {
+    const index = rows.value.findIndex(
+      (row) => row.id === contract.id,
+    );
+
+    if (index !== -1) {
+      rows.value[index] = {
+        ...rows.value[index],
+        status: updated.status,
+      };
+    }
+  });
+};
+
+const handleHistory = (contract) => {
+  $q.dialog({
+    component: ContractHistoryDialog,
+    componentProps: {
+      contractData: contract,
+      id: contract.id,
+    },
+  });
+};
+
+const handleReactivate = (contract) => {
+  confirmAction(
+    "Reativar Contrato",
+    "Você tem certeza que deseja REATIVAR este contrato?",
+    reactivateContract,
+    contract,
+  );
+};
+
+const handleView = (contract) => {
+  $q.dialog({
+    component: ViewContractDialog,
+    componentProps: {
+      id: contract.id,
+    },
+  });
+};
+
+const loadContracts = async () => {
+  isLoading.value = true;
+
+  try {
+    rows.value = await getAllContracts();
+  } finally {
+    isLoading.value = false;
+  }
+};
+
+const statusColor = (status) => {
+  if (status === "active") return "positive";
+  if (status === "frozen") return "info";
+  if (status === "cancelled") return "negative";
+
+  return "warning";
+};
+
+const statusLabel = (status) => {
+  if (status === "active") return "Ativo";
+  if (status === "frozen") return "Trancado";
+  if (status === "cancelled") return "Cancelado";
+
+  return "Inativo";
+};
+
+watch(
+  () => store.selectedUnit?.id,
+  (newId, oldId) => {
+    if (newId && newId !== oldId) {
+      loadContracts();
+    }
+  },
+);
+
+onMounted(loadContracts);
+</script>
+
+<style scoped lang="scss">
+.stat-cards-row {
+  display: flex;
+  flex-wrap: nowrap;
+  gap: 12px;
+}
+
+.stat-cards-row > * {
+  flex: 1 1 0;
+  min-width: 0;
+}
+
+@media (max-width: 768px) {
+  .stat-cards-row {
+    flex-wrap: wrap;
+  }
+
+  .stat-cards-row > * {
+    flex: 1 1 calc(50% - 8px);
+  }
+}
+</style>

+ 0 - 152
src/pages/country/CountryPage.vue

@@ -1,152 +0,0 @@
-<template>
-  <div>
-    <DefaultHeaderPage>
-      <template #after>
-        <q-btn
-          color="primary"
-          padding="8px 8px"
-          :label="$t('common.actions.add')"
-          icon="mdi-plus"
-          class="q-mt-md"
-          @click="onAddItem"
-        />
-      </template>
-    </DefaultHeaderPage>
-    <div>
-      <DefaultTable
-        ref="tableRef"
-        :columns="columns"
-        :api-call="getCountries"
-        :delete-function="deleteCountry"
-        :show-columns-select="false"
-        :title="
-          $t('common.terms.list') +
-          ' ' +
-          $t('common.ui.table.of') +
-          ' ' +
-          $t('ui.navigation.country')
-        "
-      >
-        <template #body-cell-actions="{ row }">
-          <q-btn
-            outline
-            style="width: 36px"
-            class="q-ml-auto q-mr-sm"
-            @click.prevent.stop="onRowClick(row)"
-          >
-            <q-icon name="mdi-file-edit-outline" />
-          </q-btn>
-        </template>
-      </DefaultTable>
-    </div>
-  </div>
-</template>
-<script setup>
-import { defineAsyncComponent, useTemplateRef } from "vue";
-import { useQuasar } from "quasar";
-import { useI18n } from "vue-i18n";
-import { permissionStore } from "src/stores/permission";
-import { getCountries, deleteCountry } from "src/api/country";
-
-import DefaultTable from "src/components/defaults/DefaultTable.vue";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-
-const AddEditCountryDialog = defineAsyncComponent(
-  () => import("src/pages/country/components/AddEditCountryDialog.vue"),
-);
-
-const permission_store = permissionStore();
-const $q = useQuasar();
-const tableRef = useTemplateRef("tableRef");
-const { t } = useI18n();
-
-const columns = [
-  {
-    name: "id",
-    label: "ID",
-    field: "id",
-    align: "left",
-    required: true,
-    sortable: true,
-  },
-  {
-    name: "nome",
-    label: t("common.terms.name"),
-    field: "name",
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "code",
-    label: t("common.terms.code"),
-    field: "code",
-    align: "center",
-    sortable: true,
-  },
-  {
-    name: "status",
-    label: t("common.terms.status"),
-    field: (row) =>
-      row.status == "ACTIVE"
-        ? t("common.status.active")
-        : t("common.status.inactive"),
-    align: "left",
-    sortable: true,
-  },
-  {
-    name: "actions",
-    label: t("common.terms.actions"),
-    align: "left",
-    required: true,
-  },
-];
-
-const onRowClick = (row) => {
-  if (permission_store.getAccess("config.country", "edit") === false) {
-    $q.loading.hide();
-    $q.notify({
-      type: "negative",
-      message: t("validation.permissions.edit"),
-    });
-    return;
-  }
-  $q.dialog({
-    component: AddEditCountryDialog,
-    componentProps: {
-      country: row,
-      title: () =>
-        useI18n().t("common.actions.edit") +
-        " " +
-        useI18n().t("ui.navigation.country"),
-    },
-  }).onOk(async (success) => {
-    if (success) {
-      tableRef.value.refresh();
-    }
-  });
-};
-
-const onAddItem = () => {
-  if (permission_store.getAccess("config.country", "add") === false) {
-    $q.loading.hide();
-    $q.notify({
-      type: "negative",
-      message: t("validation.permissions.add"),
-    });
-    return;
-  }
-  $q.dialog({
-    component: AddEditCountryDialog,
-    componentProps: {
-      title: () =>
-        useI18n().t("common.actions.add") +
-        " " +
-        useI18n().t("ui.navigation.country"),
-    },
-  }).onOk(async (success) => {
-    if (success) {
-      tableRef.value.refresh();
-    }
-  });
-};
-</script>

+ 0 - 136
src/pages/country/components/AddEditCountryDialog.vue

@@ -1,136 +0,0 @@
-<template>
-  <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="width: 800px">
-      <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"
-            :label="$t('common.terms.name')"
-            :placeholder="'Nome completo do pais'"
-            :rules="[inputRules.required]"
-            class="col-md-6 col-12"
-          />
-          <DefaultInput
-            v-model="form.code"
-            v-model:error="validationErrors.code"
-            :label="$t('common.terms.code')"
-            :placeholder="'Ex. BR, PT...'"
-            :rules="[inputRules.required]"
-            class="col-md-6 col-12"
-          />
-          <DefaultSelect
-            v-model="selectedStatus"
-            v-model:error="validationErrors.status"
-            :options="statusOptions"
-            :rules="[inputRules.required]"
-            :label="$t('common.terms.status')"
-            :placeholder="$t('common.terms.status')"
-            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="
-              country ? $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 { ref, useTemplateRef, onMounted, watch } from "vue";
-import { useInputRules } from "src/composables/useInputRules";
-import { useDialogPluginComponent } from "quasar";
-import { useI18n } from "vue-i18n";
-import { createCountry, updateCountry } from "src/api/country";
-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([
-  // REQUIRED; need to specify some events that your
-  // component will emit through useDialogPluginComponent()
-  ...useDialogPluginComponent.emits,
-]);
-
-const { country, title } = defineProps({
-  country: {
-    type: Object,
-    default: null,
-  },
-  title: {
-    type: Function,
-    default: () => useI18n().t("common.terms.title"),
-  },
-});
-
-const { t } = useI18n();
-const { inputRules } = useInputRules();
-
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
-  useDialogPluginComponent();
-
-const formRef = useTemplateRef("formRef");
-
-const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
-  name: country ? country?.name : "",
-  code: country ? country?.code : "",
-  status: country ? country?.status : "ACTIVE",
-});
-
-const {
-  loading,
-  validationErrors,
-  execute: submitForm,
-} = useSubmitHandler({
-  onSuccess: () => onDialogOK(true),
-  formRef: formRef,
-});
-
-const selectedStatus = ref({
-  label: t("common.status.active"),
-  value: "ACTIVE",
-});
-const statusOptions = ref([
-  { label: t("common.status.active"), value: "ACTIVE" },
-  { label: t("common.status.inactive"), value: "INACTIVE" },
-]);
-
-const onOKClick = async () => {
-  if (country) {
-    await submitForm(() => updateCountry(getUpdatedFields.value, country.id));
-  } else {
-    await submitForm(() => createCountry({ ...form }));
-  }
-};
-
-watch(selectedStatus, () => {
-  form.status = selectedStatus.value.value;
-});
-
-onMounted(() => {
-  if (country) {
-    selectedStatus.value = statusOptions.value.find(
-      (status) => status.value === country.status,
-    );
-  }
-});
-</script>

+ 469 - 211
src/pages/dashboard/DashboardPage.vue

@@ -1,58 +1,80 @@
 <template>
   <div>
-    <DefaultHeaderPage class="q-pa-sm" />
+    <DefaultHeaderPage />
 
     <div class="q-pa-sm">
       <div class="stat-cards-row q-mb-md">
         <DashboardStatCard
-          title="Total alunos (contratos ativos)"
-          icon="mdi-account-multiple-outline"
-          :value="String(totalAlunos)"
           :badge="`${totalAlunos} ativos`"
+          :value="String(totalAlunos)"
+          icon="mdi-account-multiple-outline"
+          title="Total alunos (contratos ativos)"
         />
+
+        <!-- TODO: usar dados reais de receita (zerado por enquanto). -->
+
         <DashboardStatCard
-          title="Receita Total"
           icon="mdi-currency-usd"
-          value="R$ 0,00"
           subtitle="0 pagamentos pendentes"
+          title="Receita Total"
+          value="R$ 0,00"
         />
+
+        <!-- TODO: usar dados reais de ticket médio (zerado por enquanto). -->
+
         <DashboardStatCard
-          title="Ticket Médio"
           icon="mdi-calendar-blank"
-          value="R$ 12,00"
           subtitle="Estável"
+          title="Ticket Médio"
+          value="R$ 0,00"
         />
+
         <DashboardStatCard
-          title="Aniversariantes"
+          :value="String(aniversariantes.length)"
           icon="mdi-emoticon-happy-outline"
-          value="0"
           subtitle="Fortaleça seus relacionamentos"
+          title="Aniversariantes"
         />
       </div>
 
       <div class="row q-col-gutter-md q-mb-md items-stretch">
         <div class="col-12 col-md-5">
-          <DashboardChartCard title="Faturamento Serviço / Materiais" style="height: 100%">
+          <DashboardChartCard
+            style="height: 100%"
+            title="Faturamento Serviço / Materiais"
+          >
             <GroupedBarChart
-              :labels="faturamentoChart.labels"
               :datasets="faturamentoChart.datasets"
-              label-y="R$"
+              :labels="faturamentoChart.labels"
               :tick-formatter="formatCurrencyTick"
               :tooltip-formatter="formatCurrencyTooltip"
               class="full-width full-height"
+              label-y="R$"
             />
           </DashboardChartCard>
         </div>
 
         <div class="col-12 col-md-4">
-          <q-card flat bordered class="full-height">
-            <q-card-section class="row justify-between items-center q-pb-xs">
-              <span class="text-subtitle2 text-weight-medium"
-                >Contratos Ativos</span
-              >
-              <q-icon name="mdi-trending-up" color="grey-5" />
+          <q-card
+            bordered
+            class="full-height"
+            flat
+          >
+            <q-card-section
+              class="row justify-between items-center q-pb-xs"
+            >
+              <span class="text-subtitle2 text-weight-medium">
+                Contratos Ativos
+              </span>
+
+              <q-icon
+                color="grey-5"
+                name="mdi-trending-up"
+              />
             </q-card-section>
+
             <q-separator />
+
             <q-card-section
               class="flex flex-center q-pt-sm"
               style="height: calc(100% - 57px); position: relative"
@@ -64,176 +86,219 @@
                   :plugins="[gaugeNeedlePlugin]"
                 />
               </div>
+
               <div class="gauge-label">
-                <div class="text-h5 text-bold">70</div>
-                <div class="text-caption text-grey-6">Grade</div>
+                <div class="text-h5 text-bold">
+                  {{ activeContracts }}
+                </div>
+
+                <div class="text-caption text-grey-6">
+                  Ativos
+                </div>
               </div>
             </q-card-section>
           </q-card>
         </div>
 
         <div class="col-12 col-md-3">
-          <q-card flat bordered class="full-height">
-            <q-card-section class="row justify-between items-center q-pb-xs">
-              <span class="text-subtitle2 text-weight-medium"
-                >Atalhos rápidos</span
-              >
-              <q-icon name="mdi-apps" color="grey-5" />
+          <q-card
+            bordered
+            class="full-height"
+            flat
+          >
+            <q-card-section
+              class="row justify-between items-center q-pb-xs"
+            >
+              <span class="text-subtitle2 text-weight-medium">
+                Atalhos rápidos
+              </span>
+
+              <q-icon
+                color="grey-5"
+                name="mdi-apps"
+              />
             </q-card-section>
+
             <q-separator />
+
             <q-card-section class="q-pt-md column q-gutter-sm">
               <q-btn
-                unelevated
+                v-if="canAdd"
+                class="full-width"
                 color="primary"
                 label="Criar contrato"
                 no-caps
-                class="full-width"
+                unelevated
+                @click="onCriarContrato"
               />
+
               <q-btn
-                unelevated
+                v-if="canEditClasses"
+                class="full-width"
                 color="primary"
                 label="Registrar presença"
                 no-caps
-                class="full-width"
-              />
-              <q-btn
                 unelevated
-                color="primary"
-                label="Novo pedido"
-                no-caps
-                class="full-width"
+                @click="onRegistrarPresenca"
               />
+
+              <div
+                v-if="canAddOrders"
+                class="full-width cursor-not-allowed"
+              >
+                <q-btn
+                  class="full-width"
+                  color="primary"
+                  disable
+                  label="Novo pedido"
+                  no-caps
+                  unelevated
+                />
+
+                <q-tooltip>
+                  Esta funcionalidade ainda não foi desenvolvida.
+                </q-tooltip>
+              </div>
             </q-card-section>
           </q-card>
         </div>
       </div>
 
-      <!-- Row 3: Bottom -->
       <div class="row q-col-gutter-md items-stretch">
         <div class="col-12 col-md-5">
-          <DashboardChartCard title="Matrículas por Período" style="height: 100%">
-            <GroupedBarChart
-              :labels="matriculasChart.labels"
-              :datasets="matriculasChart.datasets"
-              :bar-radius="50"
-              :show-datalabels="true"
-              :max-bar-thickness="44"
-              :category-percentage="0.6"
-              :bar-percentage="0.85"
-              class="full-width full-height"
-            />
-          </DashboardChartCard>
-        </div>
-
-        <div class="col-12 col-md-4">
-          <AniversariantesCard :people="aniversariantes" style="height: 100%" />
-        </div>
+          <q-card class="card-ring full-height feriados-card" flat>
+            <div class="flex justify-between items-center no-wrap q-mb-sm">
+              <span class="text-subtitle2 text-weight-regular">
+                Feriados do mês
+              </span>
 
-        <div class="col-12 col-md-3">
-          <q-card flat bordered class="full-height">
-            <q-card-section class="row justify-between items-center q-pb-xs">
-              <span class="text-subtitle2 text-weight-medium"
-                >Feriados do mês</span
-              >
               <q-btn
-                flat
-                round
+                color="grey-5"
                 dense
+                flat
                 icon="mdi-calendar-star"
-                color="grey-5"
+                round
                 @click="openFeriadosDialog"
               />
-            </q-card-section>
-            <q-separator />
-            <q-card-section class="q-pt-md">
-              <q-btn
-                unelevated
+            </div>
+
+            <q-btn
+              v-if="canAdd"
+              class="full-width q-mb-md"
+              color="primary"
+              label="Nova data"
+              no-caps
+              unelevated
+              @click="openFeriadosDialog"
+            />
+
+            <div
+              v-if="feriadosLoading"
+              class="flex flex-center q-py-md"
+            >
+              <q-spinner
                 color="primary"
-                label="Nova data"
-                no-caps
-                class="full-width q-mb-md"
-                @click="openFeriadosDialog"
+                size="24px"
               />
-              <div v-if="feriadosLoading" class="flex flex-center q-py-md">
-                <q-spinner color="primary" size="24px" />
-              </div>
-              <div v-else-if="feriadosMes.length === 0" class="text-caption text-grey-5 text-center">
-                Nenhum feriado neste mês.
-              </div>
-              <div v-else class="row q-gutter-sm">
-                <div
-                  v-for="feriado in feriadosMes"
-                  :key="feriado.id"
-                  class="column items-center"
-                  style="min-width: 52px"
+            </div>
+
+            <div
+              v-else-if="feriadosMes.length === 0"
+              class="text-caption text-grey-5 text-center q-mt-sm"
+            >
+              Nenhum feriado neste mês.
+            </div>
+
+            <div
+              v-else
+              class="row q-gutter-sm"
+            >
+              <div
+                v-for="feriado in feriadosMes"
+                :key="feriado.id"
+                class="column items-center cursor-pointer"
+                style="min-width: 52px"
+                @click="openEditFromDashboard(feriado)"
+              >
+                <q-badge
+                  class="text-subtitle1 text-bold q-pa-sm"
+                  color="deep-orange"
+                  style="min-width: 40px; justify-content: center"
                 >
-                  <q-badge
-                    color="deep-orange"
-                    class="text-subtitle1 text-bold q-pa-sm"
-                    style="min-width: 40px; justify-content: center"
-                  >
-                    {{ feriado.dia }}
-                  </q-badge>
-                  <div class="text-caption q-mt-xs text-center">
-                    {{ feriado.nome }}
-                  </div>
+                  {{ feriado.dia }}
+                </q-badge>
+
+                <div class="text-caption q-mt-xs text-center">
+                  {{ feriado.nome }}
                 </div>
               </div>
-            </q-card-section>
+            </div>
           </q-card>
         </div>
+
+        <div class="col-12 col-md-4">
+          <DashboardChartCard
+            style="height: 100%"
+            title="Matrículas por Período"
+          >
+            <GroupedBarChart
+              :bar-percentage="0.85"
+              :bar-radius="50"
+              :category-percentage="0.6"
+              :datasets="matriculasChart.datasets"
+              :labels="matriculasChart.labels"
+              :max-bar-thickness="44"
+              :show-datalabels="true"
+              class="full-width full-height"
+            />
+          </DashboardChartCard>
+        </div>
+
+        <div class="col-12 col-md-3">
+          <AniversariantesCard
+            :people="aniversariantes"
+            style="height: 100%"
+          />
+        </div>
       </div>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { ArcElement, Chart as ChartJS, Legend, Tooltip } from "chart.js";
+import { computed, onMounted, ref, watch } from "vue";
 import { Doughnut } from "vue-chartjs";
-import { Chart as ChartJS, ArcElement, Tooltip, Legend } from "chart.js";
+import { getDashboardSummary } from "src/api/franchisee_dashboard";
+import { getHolidays } from "src/api/holiday";
+import { permissionStore } from "src/stores/permission";
 import { useQuasar } from "quasar";
-import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
+import { useRouter } from "vue-router";
+import { userStore } from "src/stores/user";
+
+import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
 import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
+import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
 import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
-import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import AddEditContractDialog from "src/pages/students/components/AddEditContractDialog.vue";
 import FeriadosDialog from "./components/FeriadosDialog.vue";
-import { getHolidays } from "src/api/holiday";
-import { getStudents } from "src/api/student";
+import FeriadosEditDialog from "./components/FeriadosEditDialog.vue";
 
 ChartJS.register(ArcElement, Tooltip, Legend);
 
 const $q = useQuasar();
+const router = useRouter();
 
-const faturamentoChart = {
-  labels: [
-    "17/02", "18/02", "19/02", "20/02", "21/02",
-    "22/02", "23/02", "24/02", "25/02", "26/02",
-    "27/02", "28/02",
-  ],
-  datasets: [
-    {
-      label: "Serviço",
-      data: [120, 185, 95, 210, 155, 200, 170, 130, 195, 160, 145, 180],
-      color: "#a274f1",
-    },
-    {
-      label: "Materiais",
-      data: [75, 115, 60, 140, 95, 125, 105, 85, 135, 100, 90, 115],
-      color: "#ff9999",
-    },
-  ],
-};
-
-const formatCurrencyTick = (value) => {
-  if (value >= 1000) return `R$ ${(value / 1000).toFixed(0)}k`;
-  return `R$ ${value}`;
-};
+const permissions = permissionStore();
+const store = userStore();
 
-const formatCurrencyTooltip = (context) => {
-  const value = context.parsed.y;
-  return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
-};
+const activeContracts = ref(0);
+const allHolidays = ref([]);
+const aniversariantes = ref([]);
+const feriadosLoading = ref(false);
+const students = ref([]);
+const totalAlunos = ref(0);
 
 const gaugeData = ref({
   datasets: [
@@ -250,134 +315,319 @@ const gaugeData = ref({
         "#D01616",
         "#8A0000",
       ],
-      data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
-      needleValue: 7,
       borderColor: "transparent",
+      data: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+      needleValue: 0,
     },
   ],
 });
 
 const gaugeOptions = ref({
-  rotation: 270,
   circumference: 180,
   cutout: "50%",
-  responsive: true,
   maintainAspectRatio: false,
   plugins: {
-    tooltip: { enabled: false },
-    legend: { display: false },
     datalabels: {
       color: "black",
-      font: { size: 14, weight: "bold" },
-      formatter: (_value, ctx) => ctx.dataIndex,
+      font: {
+        size: 14,
+        weight: "bold",
+      },
+      formatter: (_value, context) => context.dataIndex,
+    },
+    legend: {
+      display: false,
+    },
+    tooltip: {
+      enabled: false,
     },
   },
+  responsive: true,
+  rotation: 270,
+});
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "add"),
+);
+
+const canAddOrders = computed(() =>
+  permissions.getAccess("franchisee_orders", "add"),
+);
+
+const canEditClasses = computed(() =>
+  permissions.getAccess("franchisee_classes", "edit"),
+);
+
+const feriadosMes = computed(() => {
+  const now = new Date();
+  const month = now.getMonth() + 1;
+  const year = now.getFullYear();
+
+  return allHolidays.value
+    .filter((holiday) => {
+      const date = new Date(`${holiday.holiday_date}T00:00:00`);
+
+      return (
+        date.getMonth() + 1 === month &&
+        date.getFullYear() === year
+      );
+    })
+    .sort(
+      (firstHoliday, secondHoliday) =>
+        new Date(firstHoliday.holiday_date) -
+        new Date(secondHoliday.holiday_date),
+    )
+    .map((holiday) => ({
+      base_holiday_id: holiday.base_holiday_id ?? null,
+      description: holiday.description,
+      dia: new Date(`${holiday.holiday_date}T00:00:00`).getDate(),
+      holiday_date: holiday.holiday_date,
+      id: holiday.id,
+      nome: holiday.description,
+      type: holiday.type,
+    }));
+});
+
+const matriculasChart = computed(() => {
+  const now = new Date();
+  const start = new Date(now.getFullYear(), now.getMonth() - 5, 1);
+
+  const counts = {};
+
+  for (const student of students.value) {
+    if (!student.created_at) continue;
+
+    const date = new Date(student.created_at.replace(" ", "T"));
+
+    if (date < start) continue;
+
+    const key = `${date.getFullYear()}-${date.getMonth()}`;
+
+    counts[key] = (counts[key] ?? 0) + 1;
+  }
+
+  const data = [];
+  const labels = [];
+
+  for (let index = 0; index < 6; index++) {
+    const month = new Date(
+      start.getFullYear(),
+      start.getMonth() + index,
+      1,
+    );
+
+    const key = `${month.getFullYear()}-${month.getMonth()}`;
+
+    labels.push(MONTHS_PT[month.getMonth()]);
+    data.push(counts[key] ?? 0);
+  }
+
+  return {
+    datasets: [
+      {
+        color: MATRICULA_COLORS,
+        data,
+        label: "Matrículas",
+      },
+    ],
+    labels,
+  };
 });
 
+// Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda),
+// igual ao Franqueador.
+// TODO: alimentar com dados reais de faturamento de serviço/materiais.
+const faturamentoChart = {
+  datasets: [
+    {
+      color: "#a274f1",
+      data: [],
+      label: "Serviço",
+    },
+    {
+      color: "#ff9999",
+      data: [],
+      label: "Materiais",
+    },
+  ],
+  labels: [],
+};
+
 const gaugeNeedlePlugin = {
   id: "gaugeNeedle",
+
   afterDatasetsDraw(chart) {
     const { ctx, data } = chart;
-    ctx.save();
-    const needleValue = data.datasets[0].needleValue;
+
     const meta = chart.getDatasetMeta(0).data[0];
+    const needleValue = data.datasets[0].needleValue;
+    const outerRadius = meta.outerRadius - 20;
     const xCenter = meta.x;
     const yCenter = meta.y;
-    const outerRadius = meta.outerRadius - 20;
+
     const circumference =
-      (meta.circumference / Math.PI / data.datasets[0].data[0]) * needleValue;
+      (meta.circumference / Math.PI / data.datasets[0].data[0]) *
+      needleValue;
+
     const angle = Math.PI;
+
+    ctx.save();
     ctx.translate(xCenter, yCenter);
     ctx.rotate(angle * (circumference + 1.5));
+
     ctx.beginPath();
-    ctx.strokeStyle = "grey";
     ctx.fillStyle = "grey";
+    ctx.strokeStyle = "grey";
     ctx.moveTo(-3, 0);
     ctx.lineTo(0, -outerRadius);
     ctx.lineTo(3, 0);
     ctx.stroke();
     ctx.fill();
+
     ctx.beginPath();
     ctx.arc(0, 0, 6, 0, 2 * Math.PI);
     ctx.fillStyle = "grey";
     ctx.fill();
+
     ctx.restore();
   },
 };
 
-const matriculasChart = {
-  labels: ["JAN", "FEV", "MAR", "ABR", "MAI", "JUN"],
-  datasets: [
-    {
-      label: "Matrículas",
-      data: [120, 200, 150, 80, 70, 110],
-      color: ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"],
-    },
-  ],
-};
+const MATRICULA_COLORS = [
+  "#3B82F6",
+  "#EF4444",
+  "#A855F7",
+  "#374151",
+  "#EAB308",
+  "#06B6D4",
+];
 
-const aniversariantes = ref([
-  { day: 10, name: "Heloisa Faria" },
-  { day: 11, name: "Juliana Costa" },
-  { day: 16, name: "Juliana Costa" },
-  { day: 23, name: "Fernando Almeida" },
-  { day: 29, name: "Lucas Pereira" },
-  { day: 34, name: "Sofia Martins" },
-]);
+const MONTHS_PT = [
+  "JAN",
+  "FEV",
+  "MAR",
+  "ABR",
+  "MAI",
+  "JUN",
+  "JUL",
+  "AGO",
+  "SET",
+  "OUT",
+  "NOV",
+  "DEZ",
+];
 
-// Alunos
-const totalAlunos = ref(0);
+const fetchHolidays = async () => {
+  feriadosLoading.value = true;
 
-async function fetchAlunos() {
   try {
-    const students = await getStudents();
-    totalAlunos.value = students.length;
+    allHolidays.value = await getHolidays();
   } catch {
-    // silencioso
+    $q.notify({
+      message: "Erro ao carregar feriados.",
+      type: "negative",
+    });
+  } finally {
+    feriadosLoading.value = false;
   }
-}
+};
 
-// Feriados
-const allHolidays = ref([]);
-const feriadosLoading = ref(false);
+const fetchSummary = async () => {
+  try {
+    const summary = await getDashboardSummary();
 
-const feriadosMes = computed(() => {
-  const now = new Date();
-  const month = now.getMonth() + 1;
-  const year = now.getFullYear();
-  return allHolidays.value
-    .filter((h) => {
-      const d = new Date(h.holiday_date + "T00:00:00");
-      return d.getMonth() + 1 === month && d.getFullYear() === year;
-    })
-    .sort((a, b) => new Date(a.holiday_date) - new Date(b.holiday_date))
-    .map((h) => ({
-      id: h.id,
-      dia: new Date(h.holiday_date + "T00:00:00").getDate(),
-      nome: h.description,
-    }));
-});
+    activeContracts.value = summary.contracts.active;
+    aniversariantes.value = summary.birthdays;
+    students.value = summary.enrollments;
+    totalAlunos.value = summary.students_count;
 
-async function fetchHolidays() {
-  feriadosLoading.value = true;
-  try {
-    allHolidays.value = await getHolidays();
+    gaugeData.value.datasets[0].needleValue = Math.min(
+      activeContracts.value,
+      10,
+    );
   } catch {
-    $q.notify({ type: "negative", message: "Erro ao carregar feriados." });
-  } finally {
-    feriadosLoading.value = false;
+    // silencioso
   }
-}
+};
 
-function openFeriadosDialog() {
-  $q.dialog({ component: FeriadosDialog }).onOk(() => {
-    fetchHolidays();
+const formatCurrencyTick = (value) => {
+  if (value >= 1000) {
+    return `R$ ${(value / 1000).toFixed(0)}k`;
+  }
+
+  return `R$ ${value}`;
+};
+
+const formatCurrencyTooltip = (context) => {
+  const value = context.parsed.y;
+
+  return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", {
+    minimumFractionDigits: 2,
+  })}`;
+};
+
+const onCriarContrato = () => {
+  $q.dialog({
+    component: AddEditContractDialog,
+    componentProps: {
+      selectStudent: true,
+    },
+  }).onOk(fetchSummary);
+};
+
+const onRegistrarPresenca = () => {
+  router.push({ name: "ClassPage" });
+};
+
+const openEditFromDashboard = (feriado) => {
+  $q.dialog({
+    component: FeriadosEditDialog,
+    componentProps: {
+      holiday: feriado,
+    },
+  }).onOk(({ action, holiday: updated, id }) => {
+    if (action === "update") {
+      const index = allHolidays.value.findIndex(
+        (holiday) => holiday.id === updated.id,
+      );
+
+      if (index !== -1) {
+        allHolidays.value[index] = {
+          ...allHolidays.value[index],
+          ...updated,
+        };
+      }
+
+      return;
+    }
+
+    if (action === "delete") {
+      allHolidays.value = allHolidays.value.filter(
+        (holiday) => holiday.id !== id,
+      );
+    }
   });
-}
+};
+
+const openFeriadosDialog = () => {
+  $q.dialog({
+    component: FeriadosDialog,
+  }).onOk(fetchHolidays);
+};
+
+watch(
+  () => store.selectedUnit?.id,
+  (newId, oldId) => {
+    if (!newId || newId === oldId) return;
+
+    fetchSummary();
+    fetchHolidays();
+  },
+);
 
 onMounted(() => {
   fetchHolidays();
-  fetchAlunos();
+  fetchSummary();
 });
 </script>
 
@@ -393,21 +643,29 @@ onMounted(() => {
   min-width: 0;
 }
 
+.gauge-label {
+  position: absolute;
+  bottom: 28%;
+  left: 50%;
+  pointer-events: none;
+  text-align: center;
+  transform: translateX(-50%);
+}
+
+.feriados-card {
+  display: flex;
+  flex-direction: column;
+  padding: 20px 24px;
+  border-radius: 12px;
+}
+
 @media (max-width: 599px) {
   .stat-cards-row {
     flex-wrap: wrap;
   }
+
   .stat-cards-row > * {
     flex: 1 1 calc(50% - 8px);
   }
 }
-
-.gauge-label {
-  position: absolute;
-  bottom: 28%;
-  left: 50%;
-  transform: translateX(-50%);
-  text-align: center;
-  pointer-events: none;
-}
-</style>
+</style>

+ 405 - 287
src/pages/dashboard/components/FeriadosDialog.vue

@@ -1,149 +1,184 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin feriados-dialog">
+    <q-card class="q-dialog-plugin dialog-form-card feriados-dialog">
       <template v-if="view === 'calendar'">
-        <DefaultDialogHeader title="Feriados" @close="onClose" />
-
-        <q-card-section class="row q-col-gutter-md q-pt-none">
-          <div class="col-12 col-sm-7">
-            <div class="row items-center justify-between q-mb-md">
-              <q-btn
-                flat
-                round
-                dense
-                icon="mdi-chevron-left"
-                color="grey-7"
-                @click="prevMonth"
-              />
-              <div class="row items-center q-gutter-xs">
-                <q-select
-                  v-model="currentMonth"
-                  :options="monthOptions"
-                  emit-value
-                  map-options
+        <DefaultDialogHeader
+          title="Feriados"
+          @close="onClose"
+        />
+
+        <q-scroll-area class="dialog-form-scroll">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 col-sm-7">
+              <div class="row items-center justify-between q-mb-md">
+                <q-btn
+                  color="grey-7"
                   dense
-                  outlined
-                  style="min-width: 100px"
+                  flat
+                  icon="mdi-chevron-left"
+                  round
+                  @click="prevMonth"
                 />
-                <q-select
-                  v-model="currentYear"
-                  :options="yearOptions"
-                  emit-value
-                  map-options
+
+                <div class="row items-center q-gutter-xs">
+                  <DefaultSelect
+                    v-model="currentMonth"
+                    dense
+                    emit-value
+                    map-options
+                    outlined
+                    style="min-width: 100px"
+                    :options="monthOptions"
+                  />
+
+                  <DefaultSelect
+                    v-model="currentYear"
+                    dense
+                    emit-value
+                    map-options
+                    outlined
+                    style="min-width: 80px"
+                    :options="yearOptions"
+                  />
+                </div>
+
+                <q-btn
+                  color="grey-7"
                   dense
-                  outlined
-                  style="min-width: 80px"
+                  flat
+                  icon="mdi-chevron-right"
+                  round
+                  @click="nextMonth"
                 />
               </div>
-              <q-btn
-                flat
-                round
-                dense
-                icon="mdi-chevron-right"
-                color="grey-7"
-                @click="nextMonth"
-              />
+
+              <div class="calendar-grid q-mb-xs">
+                <div
+                  v-for="day in weekDays"
+                  :key="day"
+                  class="cal-header text-caption text-grey-6 text-center text-weight-medium"
+                >
+                  {{ day }}
+                </div>
+              </div>
+
+              <div class="calendar-grid">
+                <div
+                  v-for="(cell, index) in calendarCells"
+                  :key="index"
+                  :class="{
+                    'cal-cell--empty': !cell.day,
+                    'cal-cell--holiday': cell.isHoliday,
+                  }"
+                  class="cal-cell"
+                  @click="
+                    canAdd &&
+                    cell.day &&
+                    !cell.isHoliday &&
+                    openNewRecord(cell.day)
+                  "
+                >
+                  <span
+                    v-if="cell.day"
+                    class="cal-cell__number"
+                  >
+                    {{ cell.day }}
+                  </span>
+                </div>
+              </div>
             </div>
 
-            <div class="calendar-grid q-mb-xs">
+            <div class="col-12 col-sm-5">
+              <div class="text-subtitle2 q-mb-sm">
+                Resumo
+              </div>
+
+              <q-separator class="q-mb-sm" />
+
               <div
-                v-for="day in weekDays"
-                :key="day"
-                class="cal-header text-caption text-grey-6 text-center text-weight-medium"
+                v-if="loadingHolidays"
+                class="flex flex-center q-py-lg"
               >
-                {{ day }}
+                <q-spinner
+                  color="primary"
+                  size="24px"
+                />
               </div>
-            </div>
 
-            <div class="calendar-grid">
               <div
-                v-for="(cell, index) in calendarCells"
-                :key="index"
-                class="cal-cell"
-                :class="{
-                  'cal-cell--empty': !cell.day,
-                  'cal-cell--holiday': cell.isHoliday,
-                }"
-                @click="cell.day && !cell.isHoliday && openNewRecord(cell.day)"
+                v-else-if="monthHolidays.length === 0"
+                class="text-caption text-grey-5 text-center q-mt-lg q-px-sm"
               >
-                <span v-if="cell.day" class="cal-cell__number">{{
-                  cell.day
-                }}</span>
+                Clique em um dia no calendário para adicionar um registro.
               </div>
-            </div>
-          </div>
-
-          <div class="col-12 col-sm-5">
-            <div class="text-subtitle2 q-mb-sm">Resumo</div>
-            <q-separator class="q-mb-sm" />
-
-            <div v-if="loadingHolidays" class="flex flex-center q-py-lg">
-              <q-spinner color="primary" size="24px" />
-            </div>
 
-            <div
-              v-else-if="monthHolidays.length === 0"
-              class="text-caption text-grey-5 text-center q-mt-lg q-px-sm"
-            >
-              Clique em um dia no calendário para adicionar um registro.
-            </div>
+              <q-list
+                v-else
+                separator
+              >
+                <q-item
+                  v-for="holiday in monthHolidays"
+                  :key="holiday.id"
+                  class="q-pa-sm cursor-pointer"
+                  clickable
+                  @click="openEdit(holiday)"
+                >
+                  <q-item-section avatar>
+                    <q-avatar
+                      class="text-weight-bold"
+                      color="deep-orange"
+                      size="36px"
+                      text-color="white"
+                    >
+                      {{ holiday.day }}
+                    </q-avatar>
+                  </q-item-section>
+
+                  <q-item-section>
+                    <q-item-label class="text-body2">
+                      {{ holiday.description }}
+                    </q-item-label>
+
+                    <q-item-label caption>
+                      {{ typeLabel(holiday.type) }}
+                    </q-item-label>
+                  </q-item-section>
+
+                  <q-item-section side>
+                    <q-icon
+                      :color="
+                        holiday.base_holiday_id ? 'grey-4' : 'grey-5'
+                      "
+                      :name="
+                        holiday.base_holiday_id
+                          ? 'mdi-lock-outline'
+                          : 'mdi-pencil-outline'
+                      "
+                      size="xs"
+                    />
+                  </q-item-section>
+                </q-item>
+              </q-list>
 
-            <q-list v-else separator>
-              <q-item
-                v-for="holiday in monthHolidays"
-                :key="holiday.id"
-                class="q-pa-sm"
+              <div
+                v-if="monthHolidays.length > 0"
+                class="text-caption text-grey-5 text-center q-mt-md q-px-sm"
               >
-                <q-item-section avatar>
-                  <q-avatar
-                    size="36px"
-                    color="deep-orange"
-                    text-color="white"
-                    class="text-weight-bold"
-                  >
-                    {{ holiday.day }}
-                  </q-avatar>
-                </q-item-section>
-                <q-item-section>
-                  <q-item-label class="text-body2">{{
-                    holiday.description
-                  }}</q-item-label>
-                  <q-item-label caption>{{
-                    typeLabel(holiday.type)
-                  }}</q-item-label>
-                </q-item-section>
-                <q-item-section side>
-                  <q-btn
-                    flat
-                    round
-                    dense
-                    icon="mdi-trash-can-outline"
-                    color="grey-5"
-                    size="sm"
-                    :loading="deletingId === holiday.id"
-                    @click="removeHoliday(holiday)"
-                  />
-                </q-item-section>
-              </q-item>
-            </q-list>
-
-            <div
-              v-if="monthHolidays.length > 0"
-              class="text-caption text-grey-5 text-center q-mt-md q-px-sm"
-            >
-              Clique em um dia no calendário para adicionar um registro.
+                Clique em um dia no calendário para adicionar um registro.
+              </div>
             </div>
-          </div>
-        </q-card-section>
-
-        <q-separator />
+          </q-card-section>
+        </q-scroll-area>
 
-        <q-card-actions align="right">
+        <q-card-actions
+          align="right"
+          class="q-px-md q-pb-md"
+        >
           <q-btn
-            outline
             color="primary"
-            label="FECHAR"
+            label="Fechar"
             no-caps
+            outline
             @click="onClose"
           />
         </q-card-actions>
@@ -155,79 +190,175 @@
           @close="cancelNewRecord"
         />
 
-        <q-card-section class="column q-gutter-md q-pt-sm">
-          <DefaultInput
-            v-model="newDescription"
-            label="Nome do Evento"
-            placeholder="Ex: Ponto facultativo, Natal..."
-            icon="mdi-pencil-outline"
-            outlined
-            autofocus
-          />
-
-          <DefaultSelect
-            v-model="newType"
-            label="Selecione o Tipo."
-            :options="typeOptions"
-            emit-value
-            map-options
-            outlined
-          />
-        </q-card-section>
-
-        <q-separator />
+        <DefaultForm @submit="saveNewRecord">
+          <q-scroll-area class="dialog-form-scroll">
+            <q-card-section class="column q-gutter-md q-pt-sm">
+              <DefaultInput
+                v-model="newDescription"
+                autofocus
+                icon="mdi-pencil-outline"
+                label="Nome do Evento"
+                outlined
+                placeholder="Ex: Ponto facultativo, Natal..."
+                :rules="[inputRules.required]"
+              />
 
-        <q-card-actions align="right">
-          <q-btn
-            outline
-            color="primary"
-            label="CANCELAR"
-            no-caps
-            @click="cancelNewRecord"
-          />
-          <q-btn
-            unelevated
-            color="primary"
-            label="SALVAR"
-            no-caps
-            :loading="saving"
-            :disable="!newDescription.trim()"
-            @click="saveNewRecord"
-          />
-        </q-card-actions>
+              <DefaultSelect
+                v-model="newType"
+                emit-value
+                label="Selecione o Tipo."
+                map-options
+                outlined
+                :options="typeOptions"
+              />
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="cancelNewRecord"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :disable="!newDescription.trim()"
+              :loading="saving"
+            />
+          </q-card-actions>
+        </DefaultForm>
       </template>
     </q-card>
   </q-dialog>
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from "vue";
+import { createHoliday, getHolidays } from "src/api/holiday";
+import { computed, onMounted, ref } from "vue";
 import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+import { permissionStore } from "src/stores/permission";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
 import DefaultInput from "src/components/defaults/DefaultInput.vue";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
-import { getHolidays, createHoliday, deleteHoliday } from "src/api/holiday";
+import FeriadosEditDialog from "src/pages/dashboard/components/FeriadosEditDialog.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const { dialogRef, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
 const $q = useQuasar();
+const { inputRules } = useInputRules();
 
-const now = new Date();
-const currentMonth = ref(now.getMonth() + 1);
-const currentYear = ref(now.getFullYear());
-
-const view = ref("calendar");
-const selectedDay = ref(null);
-const newDescription = ref("");
-const newType = ref("feriado");
+const permissions = permissionStore();
 
+const currentMonth = ref(new Date().getMonth() + 1);
+const currentYear = ref(new Date().getFullYear());
 const existingHolidays = ref([]);
 const loadingHolidays = ref(false);
+const newDescription = ref("");
+const newType = ref("feriado");
 const saving = ref(false);
-const deletingId = ref(null);
+const selectedDay = ref(null);
+const view = ref("calendar");
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "add"),
+);
+
+const calendarCells = computed(() => {
+  const daysInMonth = new Date(
+    currentYear.value,
+    currentMonth.value,
+    0,
+  ).getDate();
+
+  const firstDay = new Date(
+    currentYear.value,
+    currentMonth.value - 1,
+    1,
+  ).getDay();
+
+  const holidayDays = new Set(
+    monthHolidays.value.map((holiday) => holiday.day),
+  );
+
+  const cells = [];
+
+  for (let index = 0; index < firstDay; index++) {
+    cells.push({
+      day: null,
+      isHoliday: false,
+    });
+  }
+
+  for (let day = 1; day <= daysInMonth; day++) {
+    cells.push({
+      day,
+      isHoliday: holidayDays.has(day),
+    });
+  }
 
-const weekDays = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+  return cells;
+});
+
+const formattedSelectedDate = computed(() => {
+  if (!selectedDay.value) return "";
+
+  const day = String(selectedDay.value).padStart(2, "0");
+  const month = String(currentMonth.value).padStart(2, "0");
+
+  return `${day}/${month}/${currentYear.value}`;
+});
+
+const monthHolidays = computed(() =>
+  existingHolidays.value
+    .filter((holiday) => {
+      const date = new Date(`${holiday.holiday_date}T00:00:00`);
+
+      return (
+        date.getMonth() + 1 === currentMonth.value &&
+        date.getFullYear() === currentYear.value
+      );
+    })
+    .map((holiday) => {
+      const date = new Date(`${holiday.holiday_date}T00:00:00`);
+
+      return {
+        ...holiday,
+        day: date.getDate(),
+      };
+    })
+    .sort(
+      (firstHoliday, secondHoliday) =>
+        firstHoliday.day - secondHoliday.day,
+    ),
+);
+
+const yearOptions = computed(() => {
+  const baseYear = new Date().getFullYear();
+
+  return Array.from({ length: 7 }, (_, index) => {
+    const year = baseYear - 2 + index;
+
+    return {
+      label: String(year),
+      value: year,
+    };
+  });
+});
 
 const monthOptions = [
   { label: "Jan", value: 1 },
@@ -249,145 +380,132 @@ const typeOptions = [
   { label: "Ponto Facultativo", value: "facultativo" },
 ];
 
-const yearOptions = computed(() => {
-  const base = now.getFullYear();
-  return Array.from({ length: 7 }, (_, i) => {
-    const y = base - 2 + i;
-    return { label: String(y), value: y };
-  });
-});
-
-const monthHolidays = computed(() =>
-  existingHolidays.value
-    .filter((h) => {
-      const d = new Date(h.holiday_date + "T00:00:00");
-      return (
-        d.getMonth() + 1 === currentMonth.value &&
-        d.getFullYear() === currentYear.value
-      );
-    })
-    .map((h) => {
-      const d = new Date(h.holiday_date + "T00:00:00");
-      return { ...h, day: d.getDate() };
-    })
-    .sort((a, b) => a.day - b.day),
-);
-
-const calendarCells = computed(() => {
-  const year = currentYear.value;
-  const month = currentMonth.value;
-  const firstDay = new Date(year, month - 1, 1).getDay();
-  const daysInMonth = new Date(year, month, 0).getDate();
-  const holidayDays = new Set(monthHolidays.value.map((h) => h.day));
-
-  const cells = [];
-  for (let i = 0; i < firstDay; i++)
-    cells.push({ day: null, isHoliday: false });
-  for (let d = 1; d <= daysInMonth; d++)
-    cells.push({ day: d, isHoliday: holidayDays.has(d) });
-  return cells;
-});
+const weekDays = [
+  "Dom",
+  "Seg",
+  "Ter",
+  "Qua",
+  "Qui",
+  "Sex",
+  "Sáb",
+];
 
-const formattedSelectedDate = computed(() => {
-  if (!selectedDay.value) return "";
-  const dd = String(selectedDay.value).padStart(2, "0");
-  const mm = String(currentMonth.value).padStart(2, "0");
-  return `${dd}/${mm}/${currentYear.value}`;
-});
+const cancelNewRecord = () => {
+  selectedDay.value = null;
+  view.value = "calendar";
+};
 
-function typeLabel(type) {
-  return type === "facultativo" ? "Ponto Facultativo" : "Feriado";
-}
+const loadHolidays = async () => {
+  loadingHolidays.value = true;
 
-function prevMonth() {
-  if (currentMonth.value === 1) {
-    currentMonth.value = 12;
-    currentYear.value -= 1;
-  } else {
-    currentMonth.value -= 1;
+  try {
+    existingHolidays.value = await getHolidays();
+  } catch {
+    $q.notify({
+      message: "Erro ao carregar feriados.",
+      type: "negative",
+    });
+  } finally {
+    loadingHolidays.value = false;
   }
-}
+};
 
-function nextMonth() {
+const nextMonth = () => {
   if (currentMonth.value === 12) {
     currentMonth.value = 1;
     currentYear.value += 1;
-  } else {
-    currentMonth.value += 1;
+
+    return;
   }
-}
 
-function openNewRecord(day) {
-  selectedDay.value = day;
+  currentMonth.value += 1;
+};
+
+const onClose = () => {
+  onDialogOK(true);
+};
+
+const openEdit = (holiday) => {
+  $q.dialog({
+    component: FeriadosEditDialog,
+    componentProps: {
+      holiday,
+    },
+  }).onOk(({ action, holiday: updatedHoliday, id }) => {
+    if (action === "update") {
+      const index = existingHolidays.value.findIndex(
+        (item) => item.id === updatedHoliday.id,
+      );
+
+      if (index !== -1) {
+        existingHolidays.value[index] = {
+          ...existingHolidays.value[index],
+          ...updatedHoliday,
+        };
+      }
+
+      return;
+    }
+
+    if (action === "delete") {
+      existingHolidays.value = existingHolidays.value.filter(
+        (item) => item.id !== id,
+      );
+    }
+  });
+};
+
+const openNewRecord = (day) => {
   newDescription.value = "";
   newType.value = "feriado";
+  selectedDay.value = day;
   view.value = "new-record";
-}
+};
 
-function cancelNewRecord() {
-  view.value = "calendar";
-  selectedDay.value = null;
-}
+const prevMonth = () => {
+  if (currentMonth.value === 1) {
+    currentMonth.value = 12;
+    currentYear.value -= 1;
 
-async function saveNewRecord() {
-  const desc = newDescription.value.trim();
-  if (!desc || selectedDay.value === null) return;
+    return;
+  }
 
-  const mm = String(currentMonth.value).padStart(2, "0");
-  const dd = String(selectedDay.value).padStart(2, "0");
-  const dateStr = `${currentYear.value}-${mm}-${dd}`;
+  currentMonth.value -= 1;
+};
+
+const saveNewRecord = async () => {
+  const description = newDescription.value.trim();
+
+  if (!description || selectedDay.value === null) return;
+
+  const day = String(selectedDay.value).padStart(2, "0");
+  const month = String(currentMonth.value).padStart(2, "0");
 
   saving.value = true;
+
   try {
     await createHoliday({
-      holiday_date: dateStr,
-      description: desc,
+      description,
+      holiday_date: `${currentYear.value}-${month}-${day}`,
       type: newType.value,
     });
+
     await loadHolidays();
-    view.value = "calendar";
+
     selectedDay.value = null;
+    view.value = "calendar";
   } catch {
     $q.notify({
-      type: "negative",
       message: "Erro ao salvar feriado. Tente novamente.",
-    });
-  } finally {
-    saving.value = false;
-  }
-}
-
-async function removeHoliday(holiday) {
-  deletingId.value = holiday.id;
-  try {
-    await deleteHoliday(holiday.id);
-    existingHolidays.value = existingHolidays.value.filter(
-      (h) => h.id !== holiday.id,
-    );
-  } catch {
-    $q.notify({
       type: "negative",
-      message: "Erro ao remover feriado. Tente novamente.",
     });
   } finally {
-    deletingId.value = null;
-  }
-}
-
-async function loadHolidays() {
-  loadingHolidays.value = true;
-  try {
-    existingHolidays.value = await getHolidays();
-  } catch {
-    $q.notify({ type: "negative", message: "Erro ao carregar feriados." });
-  } finally {
-    loadingHolidays.value = false;
+    saving.value = false;
   }
-}
+};
 
-function onClose() {
-  onDialogOK(true);
-}
+const typeLabel = (type) =>
+  type === "facultativo" ? "Ponto Facultativo" : "Feriado";
 
 onMounted(loadHolidays);
 </script>
@@ -411,10 +529,10 @@ onMounted(loadHolidays);
 }
 
 .cal-cell {
-  aspect-ratio: 1;
   display: flex;
   align-items: center;
   justify-content: center;
+  aspect-ratio: 1;
   border-radius: 50%;
   cursor: pointer;
   transition: background 0.15s;
@@ -429,6 +547,10 @@ onMounted(loadHolidays);
   pointer-events: none;
 }
 
+.cal-cell--holiday {
+  cursor: default;
+}
+
 .cal-cell__number {
   display: flex;
   align-items: center;
@@ -442,12 +564,8 @@ onMounted(loadHolidays);
 }
 
 .cal-cell--holiday .cal-cell__number {
-  background: #e64a19;
   color: #fff;
+  background: #e64a19;
   font-weight: 700;
 }
-
-.cal-cell--holiday {
-  cursor: default;
-}
-</style>
+</style>

+ 237 - 0
src/pages/dashboard/components/FeriadosEditDialog.vue

@@ -0,0 +1,237 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin dialog-form-card feriados-edit-dialog">
+      <DefaultDialogHeader
+        :title="dialogTitle"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm @submit="save">
+        <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+          <q-card-section class="column q-gutter-md q-pt-sm">
+            <q-banner
+              v-if="isReadOnly"
+              class="bg-grey-2 text-grey-7"
+              dense
+              icon="mdi-lock-outline"
+              rounded
+            >
+              Este feriado foi criado pela franqueadora e não pode ser editado.
+            </q-banner>
+
+            <DefaultInput
+              v-model="description"
+              autofocus
+              icon="mdi-pencil-outline"
+              label="Nome do Evento"
+              outlined
+              placeholder="Ex: Ponto facultativo, Natal..."
+              :disable="isReadOnly || !canEdit"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="type"
+              emit-value
+              label="Selecione o Tipo."
+              map-options
+              outlined
+              :disable="isReadOnly || !canEdit"
+              :options="typeOptions"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <template v-if="isReadOnly || !canEdit">
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Fechar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+          </q-card-actions>
+        </template>
+
+        <template v-else>
+          <q-card-actions
+            align="between"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              v-if="canDelete"
+              color="negative"
+              label="Excluir"
+              no-caps
+              outline
+              :loading="deleting"
+              @click="confirmDelete"
+            />
+
+            <div class="row q-gutter-sm">
+              <q-btn
+                color="primary"
+                label="Cancelar"
+                no-caps
+                outline
+                @click="onDialogCancel"
+              />
+
+              <q-btn
+                color="primary"
+                label="Salvar"
+                no-caps
+                type="submit"
+                :disable="!description.trim()"
+                :loading="saving"
+              />
+            </div>
+          </q-card-actions>
+        </template>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { deleteHoliday, updateHoliday } from "src/api/holiday";
+import { permissionStore } from "src/stores/permission";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  holiday: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+const { inputRules } = useInputRules();
+
+const permissions = permissionStore();
+
+const deleting = ref(false);
+const description = ref(props.holiday.description);
+const saving = ref(false);
+const type = ref(props.holiday.type ?? "feriado");
+
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_dashboard", "edit"),
+);
+
+const dialogTitle = computed(() => {
+  const [year, month, day] = props.holiday.holiday_date.split("-");
+
+  return `${day}/${month}/${year}`;
+});
+
+const isReadOnly = computed(() => !!props.holiday.base_holiday_id);
+
+const typeOptions = [
+  {
+    label: "Feriado",
+    value: "feriado",
+  },
+  {
+    label: "Ponto Facultativo",
+    value: "facultativo",
+  },
+];
+
+const confirmDelete = () => {
+  $q.dialog({
+    cancel: {
+      color: "primary",
+      flat: true,
+      label: "Cancelar",
+    },
+    message: `Deseja excluir "${props.holiday.description}"?`,
+    ok: {
+      color: "negative",
+      label: "Excluir",
+      unelevated: true,
+    },
+    title: "Excluir feriado",
+  }).onOk(async () => {
+    deleting.value = true;
+
+    try {
+      await deleteHoliday(props.holiday.id);
+
+      onDialogOK({
+        action: "delete",
+        id: props.holiday.id,
+      });
+    } catch {
+      $q.notify({
+        message: "Erro ao excluir feriado.",
+        type: "negative",
+      });
+    } finally {
+      deleting.value = false;
+    }
+  });
+};
+
+const save = async () => {
+  const trimmedDescription = description.value.trim();
+
+  if (!trimmedDescription) return;
+
+  saving.value = true;
+
+  try {
+    const updatedHoliday = await updateHoliday(
+      {
+        description: trimmedDescription,
+        holiday_date: props.holiday.holiday_date,
+        type: type.value,
+      },
+      props.holiday.id,
+    );
+
+    onDialogOK({
+      action: "update",
+      holiday: {
+        ...props.holiday,
+        description: updatedHoliday.description,
+        type: updatedHoliday.type,
+      },
+    });
+  } catch {
+    $q.notify({
+      message: "Erro ao salvar feriado.",
+      type: "negative",
+    });
+  } finally {
+    saving.value = false;
+  }
+};
+</script>
+
+<style scoped>
+.feriados-edit-dialog {
+  width: 420px;
+  max-width: 95vw;
+}
+</style>

+ 507 - 0
src/pages/financial/AccountsPayablePage.vue

@@ -0,0 +1,507 @@
+<template>
+  <div>
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Contas a Pagar"
+    />
+
+    <div class="row q-pa-md q-gutter-md">
+      <FinancialCard
+        :financial-value="totals.paid"
+        :integer="totals.paidCount"
+        icon="mdi-check-circle-outline"
+        integer-label="pagamentos"
+        title="Pago"
+      />
+
+      <FinancialCard
+        :financial-value="totals.pending"
+        :integer="totals.pendingCount"
+        icon="mdi-cash-minus"
+        integer-label="pendentes"
+        title="A Pagar"
+      />
+
+      <FinancialCard
+        :financial-value="totals.overdue"
+        :integer="totals.overdueCount"
+        icon="mdi-alert-circle-outline"
+        integer-label="em atraso"
+        title="Vencidas"
+      />
+    </div>
+
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        :add-item="canAdd"
+        :columns="columns"
+        :female="true"
+        description="contas"
+        no-api-call
+        title="Contas a Pagar"
+        @on-add-item="openCreate"
+      >
+        <template #body-cell-origin="{ row }">
+          <q-td class="text-left">
+            <q-chip
+              :color="
+                row.origin === 'tbr'
+                  ? 'deep-purple-5'
+                  : 'blue-grey-5'
+              "
+              :label="row.origin === 'tbr' ? 'TBR' : 'Manual'"
+              dense
+              square
+              text-color="white"
+            />
+          </q-td>
+        </template>
+
+        <template #body-cell-value="{ row }">
+          <q-td class="text-left">
+            {{ formatCurrency(row.value) }}
+          </q-td>
+        </template>
+
+        <template #body-cell-status="{ row }">
+          <q-td class="text-left">
+            <q-btn
+              :color="row.status === 'paid' ? 'positive' : 'secondary'"
+              :disable="!canEdit || row.status === 'paid'"
+              :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
+              dense
+              no-caps
+              unelevated
+              @click.stop="changeStatus(row)"
+            >
+              <q-tooltip v-if="canEdit && row.status !== 'paid'">
+                Dar baixa
+              </q-tooltip>
+            </q-btn>
+          </q-td>
+        </template>
+
+        <template #body-cell-actions="{ row }">
+          <q-td auto-width>
+            <q-btn
+              v-if="row.invoice_url"
+              aria-label="Abrir cobrança"
+              color="teal-7"
+              dense
+              flat
+              icon="mdi-open-in-new"
+              round
+              @click="openInvoice(row)"
+            >
+              <q-tooltip>
+                Abrir cobrança no Asaas
+              </q-tooltip>
+            </q-btn>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+
+    <q-dialog v-model="settleDialog">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          title="Dar baixa"
+          @close="settleDialog = false"
+        />
+
+        <DefaultForm @submit="onSettle">
+          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+            <q-card-section class="q-gutter-sm">
+              <div class="text-body2 text-grey-8">
+                {{ selected?.history }}
+              </div>
+
+              <DefaultInputDatePicker
+                v-model="settleForm.payment_date"
+                v-model:untreated-date="settleForm.payment_date_iso"
+                label="Data do pagamento"
+              />
+
+              <div class="row q-col-gutter-sm">
+                <DefaultCurrencyInput
+                  v-model="settleForm.discount"
+                  class="col-6"
+                  label="Desconto"
+                  outlined
+                />
+
+                <DefaultCurrencyInput
+                  v-model="settleForm.fine"
+                  class="col-6"
+                  label="Multa/Juros"
+                  outlined
+                />
+              </div>
+
+              <div class="text-caption text-grey-7">
+                Valor da conta: {{ formatCurrency(selected?.value) }} ·
+
+                <span class="text-weight-medium">
+                  Pago: {{ formatCurrency(settleNetValue) }}
+                </span>
+              </div>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="settleDialog = false"
+            />
+
+            <q-btn
+              color="primary"
+              label="Confirmar baixa"
+              no-caps
+              type="submit"
+              :loading="settling"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </q-dialog>
+
+    <q-dialog v-model="createDialog">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          title="Nova conta a pagar"
+          @close="createDialog = false"
+        />
+
+        <DefaultForm
+          ref="createFormRef"
+          @submit="onCreate"
+        >
+          <q-scroll-area
+            ref="createScrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--sm"
+          >
+            <q-card-section class="q-gutter-sm">
+              <DefaultInput
+                v-model="createForm.history"
+                v-model:error="createValidationErrors.history"
+                label="Descrição"
+                outlined
+                :rules="[inputRules.required]"
+              />
+
+              <div
+                style="
+                  display: grid;
+                  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+                  gap: 8px;
+                "
+              >
+                <DefaultCurrencyInput
+                  v-model="createForm.value"
+                  v-model:error="createValidationErrors.value"
+                  label="Valor"
+                  outlined
+                  :rules="[inputRules.required, inputRules.positive]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="createForm.due_date"
+                  v-model:error="createValidationErrors.due_date"
+                  v-model:untreated-date="createForm.due_date_iso"
+                  label="Vencimento"
+                  :rules="[inputRules.required]"
+                />
+              </div>
+
+              <DefaultInput
+                v-model="createForm.obs"
+                label="Observação (opcional)"
+                outlined
+                type="textarea"
+              />
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="createDialog = false"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :loading="creating"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </q-dialog>
+  </div>
+</template>
+
+<script setup>
+import {
+  createPayableMe,
+  getPayablesMe,
+  settlePayableMe,
+} from "src/api/unit_payable";
+
+import { computed, onMounted, ref, useTemplateRef } from "vue";
+import { permissionStore } from "src/stores/permission";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+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 DefaultTable from "src/components/defaults/DefaultTable.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const permissions = permissionStore();
+
+const createFormRef = useTemplateRef("createFormRef");
+const createScrollAreaRef = useTemplateRef("createScrollAreaRef");
+
+const createDialog = ref(false);
+const rows = ref([]);
+const selected = ref(null);
+const settleDialog = ref(false);
+
+const settleForm = ref({
+  discount: 0,
+  fine: 0,
+  payment_date: null,
+  payment_date_iso: null,
+});
+
+const settling = ref(false);
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_financial", "add"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_financial", "edit"),
+);
+
+const settleNetValue = computed(() => {
+  const baseValue = Number(selected.value?.value ?? 0);
+  const discount = Number(settleForm.value.discount ?? 0);
+  const fine = Number(settleForm.value.fine ?? 0);
+
+  return baseValue - discount + fine;
+});
+
+const totals = computed(() => {
+  const accumulatedTotals = {
+    overdue: 0,
+    overdueCount: 0,
+    paid: 0,
+    paidCount: 0,
+    pending: 0,
+    pendingCount: 0,
+  };
+
+  for (const row of rows.value) {
+    if (row.status === "paid") {
+      accumulatedTotals.paid += Number(row.paid_value ?? 0);
+      accumulatedTotals.paidCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "overdue") {
+      accumulatedTotals.overdue += Number(row.value ?? 0);
+      accumulatedTotals.overdueCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "pending") {
+      accumulatedTotals.pending += Number(row.value ?? 0);
+      accumulatedTotals.pendingCount += 1;
+    }
+  }
+
+  return accumulatedTotals;
+});
+
+const columns = [
+  {
+    align: "left",
+    field: "origin",
+    label: "Origem",
+    name: "origin",
+  },
+  {
+    align: "left",
+    field: "history",
+    label: "Descrição",
+    name: "history",
+  },
+  {
+    align: "left",
+    field: "value",
+    label: "Valor",
+    name: "value",
+  },
+  {
+    align: "left",
+    field: "due_date",
+    label: "Vencimento",
+    name: "due_date",
+  },
+  {
+    align: "left",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "right",
+    field: "actions",
+    label: "Ações",
+    name: "actions",
+  },
+];
+
+const defaultCreateForm = () => {
+  const today = new Date();
+
+  return {
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: today.toISOString().slice(0, 10),
+    history: "",
+    obs: "",
+    value: 0,
+  };
+};
+
+const { form: createForm } = useForm(defaultCreateForm());
+
+const {
+  loading: creating,
+  validationErrors: createValidationErrors,
+  execute: executeCreate,
+} = useSubmitHandler({
+  containerRef: createScrollAreaRef,
+  formRef: createFormRef,
+  onSuccess: async () => {
+    createDialog.value = false;
+
+    await loadData();
+  },
+  scrollFn: scrollToComponent,
+});
+
+const changeStatus = (row) => {
+  if (row.status === "paid") return;
+
+  openSettle(row);
+};
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
+    currency: "BRL",
+    style: "currency",
+  }).format(Number(value ?? 0));
+
+const loadData = async () => {
+  try {
+    rows.value = await getPayablesMe();
+  } catch (error) {
+    console.error("Erro ao buscar contas a pagar:", error);
+  }
+};
+
+const onCreate = async () => {
+  await executeCreate(() =>
+    createPayableMe({
+      due_date: createForm.due_date_iso,
+      history: createForm.history,
+      obs: createForm.obs || null,
+      value: createForm.value,
+    }),
+  );
+};
+
+const onSettle = async () => {
+  if (!selected.value) return;
+
+  settling.value = true;
+
+  try {
+    await settlePayableMe(selected.value.id, {
+      discount: settleForm.value.discount,
+      fine: settleForm.value.fine,
+      payment_date: settleForm.value.payment_date_iso,
+    });
+
+    settleDialog.value = false;
+
+    await loadData();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    settling.value = false;
+  }
+};
+
+const openCreate = () => {
+  Object.assign(createForm, defaultCreateForm());
+
+  createDialog.value = true;
+};
+
+const openInvoice = (row) => {
+  window.open(row.invoice_url, "_blank");
+};
+
+const openSettle = (row) => {
+  const today = new Date();
+
+  selected.value = row;
+
+  settleForm.value = {
+    discount: Number(row.discount ?? 0),
+    fine: Number(row.fine ?? 0),
+    payment_date: today.toLocaleDateString("pt-BR"),
+    payment_date_iso: today.toISOString().slice(0, 10),
+  };
+
+  settleDialog.value = true;
+};
+
+onMounted(loadData);
+</script>

+ 632 - 0
src/pages/financial/AccountsReceivablePage.vue

@@ -0,0 +1,632 @@
+<template>
+  <div>
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Contas a Receber"
+    />
+
+    <div class="row q-pa-md q-gutter-md">
+      <FinancialCard
+        :financial-value="totals.received"
+        :integer="totals.receivedCount"
+        icon="mdi-check-circle-outline"
+        integer-label="recebimentos"
+        title="Recebido"
+      />
+
+      <FinancialCard
+        :financial-value="totals.pending"
+        :integer="totals.pendingCount"
+        icon="mdi-cash-plus"
+        integer-label="pendentes"
+        title="A Receber"
+      />
+
+      <FinancialCard
+        :financial-value="totals.overdue"
+        :integer="totals.overdueCount"
+        icon="mdi-alert-circle-outline"
+        integer-label="em atraso"
+        title="Vencidas"
+      />
+    </div>
+
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        :add-item="canAdd"
+        :columns="columns"
+        :female="true"
+        description="contas"
+        no-api-call
+        title="Contas a Receber"
+        @on-add-item="openCreate"
+      >
+        <template #body-cell-value="{ row }">
+          <q-td class="text-left">
+            {{ formatCurrency(row.value) }}
+          </q-td>
+        </template>
+
+        <template #body-cell-status="{ row }">
+          <q-td class="text-left">
+            <q-btn
+              :color="row.status === 'paid' ? 'positive' : 'secondary'"
+              :disable="!canEdit || row.status === 'paid'"
+              :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
+              dense
+              no-caps
+              unelevated
+              @click.stop="changeStatus(row)"
+            >
+              <q-tooltip v-if="canEdit && row.status !== 'paid'">
+                Dar baixa
+              </q-tooltip>
+            </q-btn>
+          </q-td>
+        </template>
+
+        <template #body-cell-actions="{ row }">
+          <q-td auto-width>
+            <div
+              class="row no-wrap justify-end items-center"
+              style="gap: 6px"
+            >
+              <q-btn
+                v-if="row.invoice_url"
+                class="q-px-sm"
+                color="teal-7"
+                dense
+                icon="mdi-barcode"
+                label="Ver boleto"
+                no-caps
+                outline
+                @click="openInvoice(row)"
+              />
+
+              <q-btn
+                v-else-if="
+                  canEdit &&
+                  row.source !== 'manual' &&
+                  row.status !== 'paid' &&
+                  row.status !== 'cancelled'
+                "
+                aria-label="Gerar cobrança"
+                color="deep-purple-5"
+                dense
+                icon="mdi-cash-fast"
+                outline
+                round
+                :loading="chargingId === row.id"
+                @click="onCharge(row)"
+              >
+                <q-tooltip>
+                  Gerar cobrança no Asaas
+                </q-tooltip>
+              </q-btn>
+            </div>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+
+    <q-dialog v-model="createDialog">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 380px; max-width: 480px"
+      >
+        <DefaultDialogHeader
+          title="Nova conta a receber"
+          @close="createDialog = false"
+        />
+
+        <DefaultForm
+          ref="createFormRef"
+          @submit="onCreate"
+        >
+          <q-scroll-area
+            ref="createScrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--md"
+          >
+            <q-card-section class="q-gutter-sm">
+              <DefaultInput
+                v-model="createForm.history"
+                v-model:error="createValidationErrors.history"
+                label="Descrição"
+                outlined
+                :rules="[inputRules.required]"
+              />
+
+              <div
+                style="
+                  display: grid;
+                  grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
+                  gap: 8px;
+                "
+              >
+                <DefaultCurrencyInput
+                  v-model="createForm.value"
+                  v-model:error="createValidationErrors.value"
+                  label="Valor"
+                  outlined
+                  :rules="[inputRules.required, inputRules.positive]"
+                />
+
+                <DefaultInputDatePicker
+                  v-model="createForm.due_date"
+                  v-model:error="createValidationErrors.due_date"
+                  v-model:untreated-date="createForm.due_date_iso"
+                  label="Vencimento"
+                  :rules="[inputRules.required]"
+                />
+              </div>
+
+              <DefaultSelect
+                v-if="canViewStudents"
+                v-model="createForm.student_id"
+                clearable
+                emit-value
+                label="Aluno (opcional)"
+                map-options
+                outlined
+                :loading="loadingAux"
+                :options="studentOptions"
+              />
+
+              <DefaultSelect
+                v-model="createForm.financial_plan_account_id"
+                clearable
+                emit-value
+                label="Plano de contas (opcional)"
+                map-options
+                outlined
+                :loading="loadingAux"
+                :options="planAccountOptions"
+              />
+
+              <DefaultInput
+                v-model="createForm.obs"
+                autogrow
+                label="Observação (opcional)"
+                outlined
+                type="textarea"
+              />
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="createDialog = false"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :loading="creating"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </q-dialog>
+
+    <q-dialog v-model="settleDialog">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          title="Dar baixa"
+          @close="settleDialog = false"
+        />
+
+        <DefaultForm @submit="onSettle">
+          <q-scroll-area class="dialog-form-scroll dialog-form-scroll--sm">
+            <q-card-section class="q-gutter-sm">
+              <div class="text-body2 text-grey-8">
+                {{ selected?.student_name }} — {{ selected?.history }}
+                ({{ selected?.order }})
+              </div>
+
+              <DefaultInputDatePicker
+                v-model="settleForm.payment_date"
+                v-model:untreated-date="settleForm.payment_date_iso"
+                label="Data do pagamento"
+              />
+
+              <div class="row q-col-gutter-sm">
+                <DefaultCurrencyInput
+                  v-model="settleForm.discount"
+                  class="col-6"
+                  label="Desconto"
+                  outlined
+                />
+
+                <DefaultCurrencyInput
+                  v-model="settleForm.fine"
+                  class="col-6"
+                  label="Multa/Juros"
+                  outlined
+                />
+              </div>
+
+              <div class="text-caption text-grey-7">
+                Valor da parcela: {{ formatCurrency(selected?.value) }} ·
+
+                <span class="text-weight-medium">
+                  Recebido: {{ formatCurrency(settleNetValue) }}
+                </span>
+              </div>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="settleDialog = false"
+            />
+
+            <q-btn
+              color="primary"
+              label="Confirmar baixa"
+              no-caps
+              type="submit"
+              :loading="settling"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </q-dialog>
+  </div>
+</template>
+
+<script setup>
+import {
+  chargeReceivableMe,
+  createManualReceivableMe,
+  getReceivablesMe,
+  settleManualReceivableMe,
+  settleReceivableMe,
+} from "src/api/unit_receivable";
+
+import { computed, onMounted, ref, useTemplateRef } from "vue";
+import { getPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { getStudentsForSelect } from "src/api/student";
+import { permissionStore } from "src/stores/permission";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+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 DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const permissions = permissionStore();
+
+const createFormRef = useTemplateRef("createFormRef");
+const createScrollAreaRef = useTemplateRef("createScrollAreaRef");
+
+const chargingId = ref(null);
+const createDialog = ref(false);
+const loadingAux = ref(false);
+const planAccountOptions = ref([]);
+const rows = ref([]);
+const selected = ref(null);
+const settleDialog = ref(false);
+
+const settleForm = ref({
+  discount: 0,
+  fine: 0,
+  payment_date: null,
+  payment_date_iso: null,
+});
+
+const settling = ref(false);
+const studentOptions = ref([]);
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_financial", "add"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_financial", "edit"),
+);
+
+const settleNetValue = computed(() => {
+  const baseValue = Number(selected.value?.value ?? 0);
+  const discount = Number(settleForm.value.discount ?? 0);
+  const fine = Number(settleForm.value.fine ?? 0);
+
+  return baseValue - discount + fine;
+});
+
+const totals = computed(() => {
+  const accumulatedTotals = {
+    overdue: 0,
+    overdueCount: 0,
+    pending: 0,
+    pendingCount: 0,
+    received: 0,
+    receivedCount: 0,
+  };
+
+  for (const row of rows.value) {
+    if (row.status === "paid") {
+      accumulatedTotals.received += Number(row.paid_value ?? 0);
+      accumulatedTotals.receivedCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "overdue") {
+      accumulatedTotals.overdue += Number(row.value ?? 0);
+      accumulatedTotals.overdueCount += 1;
+
+      continue;
+    }
+
+    if (row.status === "pending") {
+      accumulatedTotals.pending += Number(row.value ?? 0);
+      accumulatedTotals.pendingCount += 1;
+    }
+  }
+
+  return accumulatedTotals;
+});
+
+const columns = [
+  {
+    align: "left",
+    field: "student_name",
+    label: "Aluno",
+    name: "student_name",
+  },
+  {
+    align: "left",
+    field: "history",
+    label: "Descrição",
+    name: "history",
+  },
+  {
+    align: "left",
+    field: "order",
+    label: "Parcela",
+    name: "order",
+  },
+  {
+    align: "left",
+    field: "value",
+    label: "Valor",
+    name: "value",
+  },
+  {
+    align: "left",
+    field: "due_date",
+    label: "Vencimento",
+    name: "due_date",
+  },
+  {
+    align: "left",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "right",
+    field: "actions",
+    label: "Ações",
+    name: "actions",
+  },
+];
+
+const defaultCreateForm = () => {
+  const today = new Date();
+
+  return {
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: today.toISOString().slice(0, 10),
+    financial_plan_account_id: null,
+    history: "",
+    obs: "",
+    student_id: null,
+    value: 0,
+  };
+};
+
+const { form: createForm } = useForm(defaultCreateForm());
+
+const {
+  loading: creating,
+  validationErrors: createValidationErrors,
+  execute: executeCreate,
+} = useSubmitHandler({
+  containerRef: createScrollAreaRef,
+  formRef: createFormRef,
+  onSuccess: async () => {
+    createDialog.value = false;
+
+    await loadData();
+  },
+  scrollFn: scrollToComponent,
+});
+
+const changeStatus = (row) => {
+  if (row.status === "paid") return;
+
+  openSettle(row);
+};
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
+    currency: "BRL",
+    style: "currency",
+  }).format(Number(value ?? 0));
+
+const loadAux = async () => {
+  if (
+    studentOptions.value.length ||
+    planAccountOptions.value.length
+  ) {
+    return;
+  }
+
+  loadingAux.value = true;
+
+  try {
+    const [students, planAccounts] = await Promise.all([
+      getStudentsForSelect(),
+      getPlanAccountsForSelect(),
+    ]);
+
+    studentOptions.value = (students ?? []).map((student) => ({
+      label: student.name,
+      value: student.id,
+    }));
+
+    planAccountOptions.value = (planAccounts ?? []).map((account) => ({
+      label: `${account.code} — ${account.description}`,
+      value: account.id,
+    }));
+  } catch (error) {
+    console.error("Erro ao carregar dados auxiliares:", error);
+  } finally {
+    loadingAux.value = false;
+  }
+};
+
+const loadData = async () => {
+  try {
+    const data = await getReceivablesMe();
+
+    // chave composta: parcelas e avulsos podem ter o mesmo id numerico.
+    // realId e o id verdadeiro usado nas acoes (baixa/reabrir/cobranca).
+
+    rows.value = (data ?? []).map((row) => ({
+      ...row,
+      id: `${row.source}-${row.id}`,
+      realId: row.id,
+    }));
+  } catch (error) {
+    console.error("Erro ao buscar contas a receber:", error);
+  }
+};
+
+const onCharge = async (row) => {
+  chargingId.value = row.id;
+
+  try {
+    await chargeReceivableMe(row.realId);
+
+    await loadData();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    chargingId.value = null;
+  }
+};
+
+const onCreate = async () => {
+  await executeCreate(() =>
+    createManualReceivableMe({
+      due_date: createForm.due_date_iso,
+
+      financial_plan_account_id:
+        createForm.financial_plan_account_id || null,
+
+      history: createForm.history,
+      obs: createForm.obs || null,
+      student_id: createForm.student_id || null,
+      value: createForm.value,
+    }),
+  );
+};
+
+const onSettle = async () => {
+  if (!selected.value) return;
+
+  const payload = {
+    discount: settleForm.value.discount,
+    fine: settleForm.value.fine,
+    payment_date: settleForm.value.payment_date_iso,
+  };
+
+  settling.value = true;
+
+  try {
+    if (selected.value.source === "manual") {
+      await settleManualReceivableMe(
+        selected.value.realId,
+        payload,
+      );
+    } else {
+      await settleReceivableMe(
+        selected.value.realId,
+        payload,
+      );
+    }
+
+    settleDialog.value = false;
+
+    await loadData();
+  } catch (error) {
+    console.error(error);
+  } finally {
+    settling.value = false;
+  }
+};
+
+const openCreate = () => {
+  Object.assign(createForm, defaultCreateForm());
+
+  createDialog.value = true;
+
+  loadAux();
+};
+
+const openInvoice = (row) => {
+  if (!row.invoice_url) return;
+
+  window.open(row.invoice_url, "_blank");
+};
+
+const openSettle = (row) => {
+  const today = new Date();
+
+  selected.value = row;
+
+  settleForm.value = {
+    discount: Number(row.discount ?? 0),
+    fine: Number(row.fine ?? 0),
+    payment_date: today.toLocaleDateString("pt-BR"),
+    payment_date_iso: today.toISOString().slice(0, 10),
+  };
+
+  settleDialog.value = true;
+};
+
+onMounted(loadData);
+</script>

+ 91 - 0
src/pages/financial/AsaasIntegrationPage.vue

@@ -0,0 +1,91 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Integração Asaas" :show-filter-icon="false" />
+
+    <div class="q-pa-md">
+      <q-card flat bordered class="q-pa-md" style="max-width: 720px">
+        <div class="row items-center no-wrap q-mb-xs">
+          <q-icon name="mdi-cash-multiple" size="sm" color="secondary" class="q-mr-sm" />
+          <div class="text-subtitle1 text-weight-medium">Asaas — Conta da Unidade</div>
+          <q-space />
+          <q-badge
+            :color="account.asaas_configured ? 'positive' : 'grey-6'"
+            :label="account.asaas_configured ? 'Configurada' : 'Não configurada'"
+            class="q-px-sm q-py-xs"
+          />
+        </div>
+
+        <div class="text-body2 text-grey-7 q-mb-md">
+          Informe a chave da API da conta Asaas desta unidade para emitir cobranças
+          dos alunos. Sem uma chave configurada, as cobranças continuam disponíveis
+          para controle e baixa manual.
+        </div>
+
+        <DefaultForm ref="formRef" @submit.prevent="onSave">
+          <DefaultPasswordInput
+            v-model="apiKey"
+            :error="!!validationErrors.asaas_api_key"
+            :error-message="validationErrors.asaas_api_key"
+            :label="account.asaas_configured ? 'Nova chave (deixe em branco para manter)' : 'Chave da API do Asaas'"
+            :placeholder="account.asaas_api_key_masked || ''"
+            autocomplete="off"
+          />
+
+          <div class="row justify-end items-center q-mt-md q-gutter-sm">
+            <q-btn
+              v-if="canEdit && account.asaas_configured"
+              outline
+              color="negative"
+              label="Remover chave"
+              no-caps
+              :disable="loading"
+              @click="onRemove"
+            />
+            <q-btn
+              v-if="canEdit"
+              color="secondary"
+              label="Salvar"
+              type="submit"
+              no-caps
+              unelevated
+              :disable="!apiKey"
+              :loading="loading"
+            />
+          </div>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { getPaymentAccountMe, savePaymentAccountMe } from "src/api/unit_payment_account";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+import { permissionStore } from "src/stores/permission";
+
+const permissions = permissionStore();
+const canEdit = computed(() => permissions.getAccess("franchisee_financial", "edit"));
+const formRef = ref(null);
+const apiKey = ref("");
+const account = ref({ asaas_configured: false, asaas_api_key_masked: null });
+const { loading, validationErrors, execute } = useSubmitHandler({ formRef });
+
+const fetchData = async () => {
+  account.value = (await getPaymentAccountMe()) ?? account.value;
+};
+
+const persist = async (value) => {
+  await execute(async () => {
+    account.value = await savePaymentAccountMe({ asaas_api_key: value });
+    apiKey.value = "";
+  });
+};
+
+const onSave = () => apiKey.value && persist(apiKey.value);
+const onRemove = () => persist("");
+
+onMounted(fetchData);
+</script>

+ 518 - 0
src/pages/financial/ChartOfAccountsPage.vue

@@ -0,0 +1,518 @@
+<template>
+  <div>
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Plano de Contas"
+    />
+
+    <div class="q-px-md">
+      <q-card
+        bordered
+        class="q-pa-md q-mt-md"
+        flat
+      >
+        <div class="row items-center q-gutter-md q-mb-md">
+          <div>
+            <div class="text-h6">
+              Plano de Contas
+            </div>
+
+            <div class="text-body2">
+              {{ rows.length }} contas cadastradas
+            </div>
+          </div>
+
+          <q-space />
+
+          <q-btn
+            v-if="canAdd"
+            color="secondary"
+            label="Nova Conta"
+            no-caps
+            unelevated
+            @click="openCreate"
+          />
+        </div>
+
+        <DefaultInput
+          v-model="search"
+          class="q-mb-md"
+          clearable
+          debounce="250"
+          dense
+          label="Buscar por código ou nome"
+        >
+          <template #prepend>
+            <q-icon
+              color="grey-6"
+              name="mdi-magnify"
+            />
+          </template>
+        </DefaultInput>
+
+        <div class="account-tree-header">
+          <div>Código</div>
+          <div>Nome da Conta</div>
+          <div>Tipo</div>
+          <div>Ações</div>
+        </div>
+
+        <q-list>
+          <FinancialPlanAccountTreeNode
+            v-for="node in filteredTree"
+            :key="node.id"
+            :can-delete="canDelete"
+            :can-edit="canEdit"
+            :force-expanded="!!normalizedSearch"
+            :node="node"
+            @delete="onDelete"
+            @edit="openEdit"
+          />
+        </q-list>
+
+        <div
+          v-if="filteredTree.length === 0"
+          class="text-center q-pa-lg"
+        >
+          Nenhuma conta encontrada.
+        </div>
+
+        <q-inner-loading
+          color="secondary"
+          :showing="loadingParents"
+        />
+      </q-card>
+    </div>
+
+    <q-dialog v-model="dialog">
+      <q-card
+        class="dialog-form-card"
+        style="min-width: 360px; max-width: 460px"
+      >
+        <DefaultDialogHeader
+          :title="editing ? 'Editar conta' : 'Nova conta'"
+          @close="dialog = false"
+        />
+
+        <DefaultForm
+          ref="formRef"
+          @submit="onSave"
+        >
+          <q-scroll-area
+            ref="scrollAreaRef"
+            class="dialog-form-scroll dialog-form-scroll--md"
+          >
+            <q-card-section class="q-gutter-sm">
+              <q-option-group
+                v-model="accountKind"
+                color="primary"
+                dense
+                inline
+                :options="kindOptions"
+              />
+
+              <div
+                style="display: grid; gap: 8px"
+                :style="{
+                  gridTemplateColumns:
+                    accountKind === 'parent'
+                      ? 'minmax(0, 7fr) minmax(0, 5fr)'
+                      : 'minmax(0, 1fr)',
+                }"
+              >
+                <DefaultInput
+                  v-model="form.code"
+                  v-model:error="validationErrors.code"
+                  label="Código (ex.: 1.1.01)"
+                  outlined
+                  :rules="[inputRules.required]"
+                />
+
+                <DefaultSelect
+                  v-if="accountKind === 'parent'"
+                  v-model="form.chart_type"
+                  v-model:error="validationErrors.chart_type"
+                  emit-value
+                  label="Tipo"
+                  map-options
+                  outlined
+                  :options="chartTypeOptions"
+                  :rules="[inputRules.required]"
+                />
+              </div>
+
+              <DefaultSelect
+                v-if="accountKind === 'child'"
+                v-model="form.parent_id"
+                v-model:error="validationErrors.parent_id"
+                emit-value
+                label="Conta Pai"
+                map-options
+                option-label="label"
+                option-value="id"
+                outlined
+                :loading="loadingParents"
+                :options="parentOptions"
+                :rules="[inputRules.required]"
+              />
+
+              <DefaultInput
+                v-model="form.description"
+                v-model:error="validationErrors.description"
+                label="Descrição"
+                outlined
+                :rules="[inputRules.required]"
+              />
+
+              <div
+                v-if="accountKind === 'child' && selectedParent"
+                class="text-caption text-grey-7"
+              >
+                Tipo herdado do pai:
+
+                <b>
+                  {{ chartTypeLabel(selectedParent.chart_type) }}
+                </b>
+              </div>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-card-actions
+            align="right"
+            class="q-px-md q-pb-md"
+          >
+            <q-btn
+              color="primary"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="dialog = false"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              :loading="saving"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </q-dialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref, useTemplateRef } from "vue";
+
+import {
+  createPlanAccountMe,
+  deletePlanAccountMe,
+  getPlanAccountsMe,
+  updatePlanAccountMe,
+} from "src/api/financial_plan_account";
+
+import { permissionStore } from "src/stores/permission";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useQuasar } from "quasar";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import FinancialPlanAccountTreeNode from "src/components/financial/FinancialPlanAccountTreeNode.vue";
+
+const $q = useQuasar();
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const permissions = permissionStore();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const accountKind = ref("parent");
+const dialog = ref(false);
+const editing = ref(false);
+const editingId = ref(null);
+const loadingParents = ref(false);
+const rows = ref([]);
+const search = ref("");
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_financial", "add"),
+);
+
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_financial", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_financial", "edit"),
+);
+
+const accountTree = computed(() => {
+  const nodes = new Map(
+    rows.value.map((account) => [
+      account.id,
+      {
+        ...account,
+        children: [],
+      },
+    ]),
+  );
+
+  const roots = [];
+
+  for (const node of nodes.values()) {
+    const parent = node.parent_id
+      ? nodes.get(node.parent_id)
+      : null;
+
+    if (parent) {
+      parent.children.push(node);
+
+      continue;
+    }
+
+    roots.push(node);
+  }
+
+  const sortAccounts = (accounts) => {
+    accounts.sort((firstAccount, secondAccount) =>
+      String(firstAccount.code).localeCompare(
+        String(secondAccount.code),
+        "pt-BR",
+        {
+          numeric: true,
+        },
+      ),
+    );
+
+    accounts.forEach((account) => {
+      sortAccounts(account.children);
+    });
+
+    return accounts;
+  };
+
+  return sortAccounts(roots);
+});
+
+const filteredTree = computed(() => {
+  if (!normalizedSearch.value) return accountTree.value;
+
+  const filterAccounts = (accounts) =>
+    accounts.reduce((result, account) => {
+      const children = filterAccounts(account.children);
+
+      const matches = `${account.code} ${account.description}`
+        .toLocaleLowerCase("pt-BR")
+        .includes(normalizedSearch.value);
+
+      if (matches || children.length) {
+        result.push({
+          ...account,
+          children,
+        });
+      }
+
+      return result;
+    }, []);
+
+  return filterAccounts(accountTree.value);
+});
+
+const normalizedSearch = computed(
+  () => search.value?.trim().toLocaleLowerCase("pt-BR") ?? "",
+);
+
+const parentOptions = computed(() =>
+  rows.value
+    .filter((account) => account.id !== editingId.value)
+    .map((account) => ({
+      chart_type: account.chart_type,
+      id: account.id,
+      label: `${account.code} — ${account.description}`,
+    })),
+);
+
+const selectedParent = computed(() =>
+  parentOptions.value.find(
+    (option) => option.id === form.parent_id,
+  ),
+);
+
+const chartTypeOptions = [
+  {
+    label: "Receita",
+    value: "receita",
+  },
+  {
+    label: "Despesa",
+    value: "despesa",
+  },
+];
+
+const defaultForm = () => ({
+  chart_type: "despesa",
+  code: "",
+  description: "",
+  parent_id: null,
+});
+
+const kindOptions = [
+  {
+    label: "Conta Pai",
+    value: "parent",
+  },
+  {
+    label: "Conta Filho",
+    value: "child",
+  },
+];
+
+const { form, getUpdatedFields } = useForm(defaultForm());
+
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: async () => {
+    dialog.value = false;
+
+    await loadData();
+  },
+  scrollFn: scrollToComponent,
+});
+
+const chartTypeLabel = (type) =>
+  chartTypeOptions.find((option) => option.value === type)?.label ??
+  type;
+
+const loadData = async () => {
+  loadingParents.value = true;
+
+  try {
+    rows.value = await getPlanAccountsMe();
+  } catch (error) {
+    console.error("Erro ao carregar plano de contas:", error);
+  } finally {
+    loadingParents.value = false;
+  }
+};
+
+const onDelete = (id) => {
+  const account = rows.value.find((row) => row.id === id);
+
+  if (account && !account.unit_id) {
+    $q.notify({
+      message:
+        "O plano de contas da Matriz não pode ser excluído pela unidade.",
+      type: "negative",
+    });
+
+    return;
+  }
+
+  $q.dialog({
+    cancel: true,
+    message: "Tem certeza que deseja excluir esta conta do plano?",
+    persistent: true,
+    title: "Excluir conta",
+  }).onOk(async () => {
+    try {
+      await deletePlanAccountMe(id);
+
+      await loadData();
+    } catch (error) {
+      console.error(error);
+    }
+  });
+};
+
+const onSave = async () => {
+  const payload = {
+    code: form.code,
+    description: form.description,
+  };
+
+  if (accountKind.value === "child") {
+    payload.parent_id = form.parent_id;
+  } else {
+    payload.chart_type = form.chart_type;
+    payload.parent_id = null;
+  }
+
+  await execute(() => {
+    if (!editing.value) {
+      return createPlanAccountMe(payload);
+    }
+
+    const changedFields = getUpdatedFields.value;
+    const updatePayload = {};
+
+    for (const key of [
+      "code",
+      "description",
+      "chart_type",
+      "parent_id",
+    ]) {
+      if (key in changedFields) {
+        updatePayload[key] = payload[key];
+      }
+    }
+
+    return updatePlanAccountMe(
+      editingId.value,
+      updatePayload,
+    );
+  });
+};
+
+const openCreate = () => {
+  accountKind.value = "parent";
+  editing.value = false;
+  editingId.value = null;
+
+  Object.assign(form, defaultForm());
+
+  dialog.value = true;
+};
+
+const openEdit = (account) => {
+  accountKind.value = account.parent_id ? "child" : "parent";
+  editing.value = true;
+  editingId.value = account.id;
+
+  Object.assign(form, {
+    chart_type: account.chart_type,
+    code: account.code,
+    description: account.description,
+    parent_id: account.parent_id ?? null,
+  });
+
+  dialog.value = true;
+};
+
+onMounted(loadData);
+</script>
+
+<style scoped>
+.account-tree-header {
+  display: grid;
+  grid-template-columns: 15% 1fr 16% 105px;
+  gap: 16px;
+  padding: 12px 56px 12px 16px;
+  border-bottom: 1px solid #c7c7c7;
+  font-weight: 600;
+}
+</style>

+ 112 - 0
src/pages/financial/FinancialPage.vue

@@ -0,0 +1,112 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Financeiro" :show-filter-icon="false" />
+
+    <div class="row justify-end q-px-md q-mb-xs">
+      <q-btn
+        flat
+        round
+        :icon="showValues ? 'mdi-eye-outline' : 'mdi-eye-off-outline'"
+        color="primary"
+        @click="showValues = !showValues"
+      >
+        <q-tooltip anchor="center left" self="center right" :offset="[8, 0]">
+          {{ showValues ? 'Ocultar valores' : 'Exibir valores' }}
+        </q-tooltip>
+      </q-btn>
+    </div>
+
+    <div class="row q-pa-md q-gutter-md">
+      <FinancialCard
+        title="Resultado do Mês"
+        icon="mdi-scale-balance"
+        :financial-value="overview.result_month"
+        :description="overview.result_month >= 0 ? 'Positivo' : 'Negativo'"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="Recebido no Mês"
+        icon="mdi-cash-plus"
+        :financial-value="overview.received_month"
+        :integer="overview.received_month_count"
+        integer-label="recebimentos"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="Pago no Mês"
+        icon="mdi-cash-minus"
+        :financial-value="overview.paid_month"
+        :integer="overview.paid_month_count"
+        integer-label="pagamentos"
+        :hide-values="!showValues"
+      />
+    </div>
+
+    <div class="row q-px-md q-gutter-md">
+      <FinancialCard
+        title="Contas a Pagar"
+        icon="mdi-cash-minus"
+        :financial-value="overview.payable_pending"
+        :integer="overview.payable_pending_count"
+        integer-label="pendentes"
+        :hide-values="!showValues"
+        clickable
+        @click="$router.push({ name: 'AccountsPayablePage' })"
+      />
+      <FinancialCard
+        title="Contas a Receber"
+        icon="mdi-cash-plus"
+        :financial-value="overview.receivable_pending"
+        :integer="overview.receivable_pending_count"
+        integer-label="pendentes"
+        :hide-values="!showValues"
+        clickable
+        @click="$router.push({ name: 'AccountsReceivablePage' })"
+      />
+      <FinancialCard
+        title="Vencidas (a receber)"
+        icon="mdi-alert-circle-outline"
+        :financial-value="overview.overdue_receivable"
+        :integer="overview.overdue_receivable_count"
+        integer-label="em atraso"
+        :hide-values="!showValues"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { onMounted, ref, watch } from "vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+import { getFinancialOverviewMe } from "src/api/unit_financial_dashboard";
+
+const STORAGE_KEY = "franchisee_financial_show_values";
+const showValues = ref(localStorage.getItem(STORAGE_KEY) !== "false");
+
+watch(showValues, (val) => localStorage.setItem(STORAGE_KEY, String(val)));
+
+const defaultOverview = () => ({
+  result_month: 0,
+  received_month: 0,
+  received_month_count: 0,
+  paid_month: 0,
+  paid_month_count: 0,
+  receivable_pending: 0,
+  receivable_pending_count: 0,
+  payable_pending: 0,
+  payable_pending_count: 0,
+  overdue_receivable: 0,
+  overdue_receivable_count: 0,
+});
+
+const overview = ref(defaultOverview());
+
+onMounted(async () => {
+  try {
+    overview.value = await getFinancialOverviewMe();
+  } catch (e) {
+    console.error("Erro ao carregar visão geral financeira:", e);
+  }
+});
+</script>

+ 45 - 0
src/pages/financial/InvoiceIssuancePage.vue

@@ -0,0 +1,45 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Emissão de Notas" :show-filter-icon="false" />
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        no-api-call
+        :add-item="canAdd"
+        title="Emissão de Notas"
+        description="notas"
+        :female="true"
+        :columns="columns"
+        @on-add-item="notifyUnavailable"
+      >
+        <template #body-cell-actions="{ row }">
+          <q-td align="center">
+            <q-btn outline icon="mdi-file-outline" style="width: 36px" @click.stop="viewInvoice(row)" />
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useQuasar } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const rows = ref([]);
+const canAdd = permissionStore().getAccess("franchisee_financial", "add");
+const columns = [
+  { name: "nf", label: "NF", field: "nf", align: "left" },
+  { name: "name", label: "Nome", field: "name", align: "left" },
+  { name: "due_date", label: "Data de Vencimento", field: "due_date", align: "left" },
+  { name: "value", label: "Valor", field: "value", align: "left" },
+  { name: "status", label: "Status", field: "status", align: "left" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
+];
+const notifyUnavailable = () => $q.notify({ type: "info", message: "Emissão fiscal ainda não configurada para esta unidade." });
+const viewInvoice = (row) => row.url && window.open(row.url, "_blank");
+</script>

+ 246 - 0
src/pages/financial/TreasuryPage.vue

@@ -0,0 +1,246 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Tesouraria" :show-filter-icon="false" />
+
+    <div class="row items-center justify-between q-px-md q-mb-sm">
+      <div class="text-subtitle1 text-weight-medium">Bancos / Caixa</div>
+      <q-btn
+        v-if="canAdd"
+        color="primary"
+        label="Nova conta"
+        icon="mdi-plus"
+        unelevated
+        no-caps
+        @click="openAccountCreate"
+      />
+    </div>
+
+    <div class="row q-px-md q-col-gutter-md">
+      <div
+        v-for="acc in accounts"
+        :key="acc.id"
+        class="col-12 col-sm-6 col-md-4"
+      >
+        <q-card
+          flat
+          bordered
+          class="q-pa-md cursor-pointer"
+          :class="{ 'selected-card': acc.id === selectedId }"
+          @click="selectAccount(acc)"
+        >
+          <div class="row items-center no-wrap">
+            <q-icon name="mdi-bank-outline" size="sm" color="primary" class="q-mr-sm" />
+            <div class="text-subtitle2 ellipsis">{{ acc.name }}</div>
+            <q-space />
+            <q-btn
+              v-if="canEdit"
+              flat
+              round
+              dense
+              icon="mdi-pencil-outline"
+              size="sm"
+              @click.stop="openAccountEdit(acc)"
+            />
+          </div>
+          <div class="text-h6 q-mt-sm">{{ formatCurrency(acc.balance) }}</div>
+          <div class="text-caption text-grey-7">
+            {{ acc.bank_name || "Conta" }}
+            <span v-if="acc.bank_agency"> · Ag. {{ acc.bank_agency }}</span>
+            <span v-if="acc.bank_account"> · CC {{ acc.bank_account }}</span>
+          </div>
+        </q-card>
+      </div>
+
+      <div v-if="!accounts.length" class="col-12 text-grey-7 q-pa-md">
+        Nenhuma conta cadastrada. Clique em "Nova conta" para começar.
+      </div>
+    </div>
+
+    <!-- Extrato -->
+    <div v-if="selectedId" class="q-px-md q-mt-lg">
+      <div class="row items-center justify-between q-mb-sm">
+        <div class="text-subtitle1 text-weight-medium">
+          Extrato — {{ selectedAccount?.name }}
+        </div>
+      </div>
+
+      <DefaultTable
+        v-model:rows="launches"
+        no-api-call
+        :add-item="canAdd"
+        title="Movimentações"
+        description="movimentações"
+        :female="true"
+        :columns="launchColumns"
+        @on-add-item="openLaunchCreate"
+      >
+        <template #body-cell-transaction_type="{ row }">
+          <q-td class="text-left">
+            <q-chip
+              :color="row.transaction_type === 'entrada' ? 'positive' : 'negative'"
+              text-color="white"
+              dense
+              square
+              :label="row.transaction_type === 'entrada' ? 'Entrada' : 'Saída'"
+            />
+          </q-td>
+        </template>
+        <template #body-cell-amount="{ row }">
+          <q-td
+            class="text-left text-weight-medium"
+            :class="row.transaction_type === 'entrada' ? 'text-positive' : 'text-negative'"
+          >
+            {{ row.transaction_type === "entrada" ? "+" : "-" }}
+            {{ formatCurrency(row.amount) }}
+          </q-td>
+        </template>
+        <template #body-cell-actions="{ row }">
+          <q-td>
+            <div class="row no-wrap q-gutter-xs">
+              <q-btn v-if="canEdit" outline icon="mdi-pencil-outline" style="width: 36px" @click.stop="openLaunchEdit(row)" />
+              <q-btn v-if="canDelete" outline color="negative" icon="mdi-trash-can-outline" style="width: 36px" @click.stop="deleteLaunch(row)" />
+            </div>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, defineAsyncComponent, onMounted, ref } from "vue";
+import { useQuasar } from "quasar";
+import {
+  getTreasuryAccountsMe,
+  getTreasuryLaunchesMe,
+  deleteTreasuryLaunchMe,
+} from "src/api/treasury";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import { permissionStore } from "src/stores/permission";
+
+const AddEditTreasuryAccountDialog = defineAsyncComponent(
+  () => import("src/pages/financial/components/AddEditTreasuryAccountDialog.vue"),
+);
+const AddTreasuryLaunchDialog = defineAsyncComponent(
+  () => import("src/pages/financial/components/AddTreasuryLaunchDialog.vue"),
+);
+
+const accounts = ref([]);
+const launches = ref([]);
+const selectedId = ref(null);
+const $q = useQuasar();
+const permissions = permissionStore();
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_financial", "add"),
+);
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_financial", "edit"),
+);
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_financial", "delete"),
+);
+
+const launchColumns = [
+  { name: "launch_date", label: "Data", field: "launch_date", align: "left" },
+  { name: "description", label: "Descrição", field: "description", align: "left" },
+  { name: "transaction_type", label: "Tipo", field: "transaction_type", align: "left" },
+  { name: "amount", label: "Valor", field: "amount", align: "left" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
+];
+
+const selectedAccount = computed(() =>
+  accounts.value.find((a) => a.id === selectedId.value),
+);
+
+function formatCurrency(value) {
+  return new Intl.NumberFormat("pt-BR", {
+    style: "currency",
+    currency: "BRL",
+  }).format(Number(value ?? 0));
+}
+
+async function loadAccounts() {
+  try {
+    accounts.value = await getTreasuryAccountsMe();
+    if (!selectedId.value && accounts.value.length) {
+      selectAccount(accounts.value[0]);
+    }
+  } catch (e) {
+    console.error("Erro ao carregar contas:", e);
+  }
+}
+
+async function loadLaunches() {
+  if (!selectedId.value) return;
+  try {
+    launches.value = await getTreasuryLaunchesMe(selectedId.value);
+  } catch (e) {
+    console.error("Erro ao carregar extrato:", e);
+  }
+}
+
+function selectAccount(acc) {
+  selectedId.value = acc.id;
+  loadLaunches();
+}
+
+function openAccountCreate() {
+  $q.dialog({
+    component: AddEditTreasuryAccountDialog,
+  }).onOk(loadAccounts);
+}
+
+function openAccountEdit(acc) {
+  $q.dialog({
+    component: AddEditTreasuryAccountDialog,
+    componentProps: {
+      account: acc,
+    },
+  }).onOk(loadAccounts);
+}
+
+function openLaunchCreate() {
+  $q.dialog({
+    component: AddTreasuryLaunchDialog,
+    componentProps: {
+      accountId: selectedId.value,
+    },
+  }).onOk(async () => {
+    await loadAccounts();
+    await loadLaunches();
+  });
+}
+
+function openLaunchEdit(launch) {
+  $q.dialog({
+    component: AddTreasuryLaunchDialog,
+    componentProps: { accountId: selectedId.value, launch },
+  }).onOk(async () => {
+    await Promise.all([loadAccounts(), loadLaunches()]);
+  });
+}
+
+function deleteLaunch(launch) {
+  $q.dialog({
+    title: "Excluir movimentação",
+    message: `Deseja excluir a movimentação "${launch.description}"?`,
+    ok: { color: "negative", label: "Excluir" },
+    cancel: { color: "secondary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    await deleteTreasuryLaunchMe(launch.id);
+    await Promise.all([loadAccounts(), loadLaunches()]);
+  });
+}
+
+onMounted(loadAccounts);
+</script>
+
+<style scoped>
+.selected-card {
+  border-color: var(--q-primary);
+  border-width: 2px;
+}
+
+</style>

+ 187 - 0
src/pages/financial/components/AddEditTreasuryAccountDialog.vue

@@ -0,0 +1,187 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin dialog-form-card treasury-dialog-card">
+      <DefaultDialogHeader
+        :title="title"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
+        <q-scroll-area
+          ref="scrollAreaRef"
+          class="dialog-form-scroll dialog-form-scroll--sm"
+        >
+          <q-card-section class="row q-col-gutter-md q-px-lg q-pt-md q-pb-sm">
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              class="col-12"
+              label="Nome (ex.: Banco do Brasil, Caixa Loja)"
+              outlined
+              :rules="[inputRules.required, inputRules.max(255)]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_name"
+              v-model:error="validationErrors.bank_name"
+              class="col-12 col-sm-6"
+              label="Banco"
+              outlined
+              :rules="[inputRules.max(255)]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_agency"
+              v-model:error="validationErrors.bank_agency"
+              class="col-12 col-sm-6"
+              label="Agência"
+              outlined
+              :rules="[inputRules.max(50)]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_account"
+              v-model:error="validationErrors.bank_account"
+              class="col-12 col-sm-6"
+              label="Conta"
+              outlined
+              :rules="[inputRules.max(50)]"
+            />
+
+            <DefaultSelect
+              v-model="form.bank_type_account"
+              v-model:error="validationErrors.bank_type_account"
+              class="col-12 col-sm-6"
+              emit-value
+              label="Tipo"
+              map-options
+              outlined
+              :options="accountTypeOptions"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-card-actions
+          align="right"
+          class="q-px-lg q-pt-md q-pb-lg q-gutter-sm"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, useTemplateRef } from "vue";
+
+import {
+  createTreasuryAccountMe,
+  updateTreasuryAccountMe,
+} from "src/api/treasury";
+
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  account: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const isEdit = computed(() => !!props.account?.id);
+
+const title = computed(() =>
+  isEdit.value ? "Editar conta" : "Nova conta",
+);
+
+const accountTypeOptions = [
+  {
+    label: "Corrente",
+    value: "corrente",
+  },
+  {
+    label: "Poupança",
+    value: "poupanca",
+  },
+];
+
+const { form, getUpdatedFields } = useForm({
+  bank_account: props.account?.bank_account ?? "",
+  bank_agency: props.account?.bank_agency ?? "",
+  bank_name: props.account?.bank_name ?? "",
+  bank_type_account: props.account?.bank_type_account ?? null,
+  name: props.account?.name ?? "",
+  treasury_type: "bank",
+});
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+  scrollFn: scrollToComponent,
+});
+
+const onOKClick = async () => {
+  await execute(() => {
+    if (isEdit.value) {
+      return updateTreasuryAccountMe(
+        props.account.id,
+        { ...getUpdatedFields.value },
+      );
+    }
+
+    return createTreasuryAccountMe({ ...form });
+  });
+};
+</script>
+
+<style scoped>
+.treasury-dialog-card {
+  width: 520px;
+  max-width: calc(100vw - 32px);
+}
+</style>

+ 212 - 0
src/pages/financial/components/AddTreasuryLaunchDialog.vue

@@ -0,0 +1,212 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin dialog-form-card treasury-dialog-card">
+      <DefaultDialogHeader
+        :title="launch ? 'Editar movimentação' : 'Nova movimentação'"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm
+        ref="formRef"
+        @submit="onOKClick"
+      >
+        <q-scroll-area
+          ref="scrollAreaRef"
+          class="dialog-form-scroll dialog-form-scroll--sm"
+        >
+          <q-card-section class="row q-col-gutter-md q-px-lg q-pt-md q-pb-sm">
+            <DefaultSelect
+              v-model="form.transaction_type"
+              v-model:error="validationErrors.transaction_type"
+              class="col-12"
+              emit-value
+              label="Tipo"
+              map-options
+              outlined
+              :options="transactionTypeOptions"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              class="col-12"
+              label="Descrição"
+              outlined
+              :rules="[inputRules.required, inputRules.max(1000)]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.amount"
+              v-model:error="validationErrors.amount"
+              class="col-12 col-sm-6"
+              label="Valor"
+              outlined
+              :rules="[inputRules.required, inputRules.positive]"
+            />
+
+            <DefaultInputDatePicker
+              v-model="form.launch_date"
+              v-model:error="validationErrors.launch_date"
+              v-model:untreated-date="form.launch_date_iso"
+              class="col-12 col-sm-6"
+              label="Data"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-card-actions
+          align="right"
+          class="q-px-lg q-pt-md q-pb-lg q-gutter-sm"
+        >
+          <q-btn
+            color="primary"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import {
+  createTreasuryLaunchMe,
+  updateTreasuryLaunchMe,
+} from "src/api/treasury";
+
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useScroll } from "src/composables/useScroll";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { useTemplateRef } from "vue";
+
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const props = defineProps({
+  accountId: {
+    type: Number,
+    required: true,
+  },
+  launch: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const formRef = useTemplateRef("formRef");
+const scrollAreaRef = useTemplateRef("scrollAreaRef");
+
+const transactionTypeOptions = [
+  {
+    label: "Entrada",
+    value: "entrada",
+  },
+  {
+    label: "Saída",
+    value: "saida",
+  },
+];
+
+const { form, getUpdatedFields } = useForm({
+  amount: props.launch?.amount ?? 0,
+  description: props.launch?.description ?? "",
+
+  launch_date: props.launch?.launch_date
+    ? props.launch.launch_date.split("-").reverse().join("/")
+    : new Date().toLocaleDateString("pt-BR"),
+
+  launch_date_iso:
+    props.launch?.launch_date ?? new Date().toISOString().slice(0, 10),
+
+  transaction_type: props.launch?.transaction_type ?? "entrada",
+});
+
+const {
+  loading,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: () => {
+    onDialogOK(true);
+  },
+  scrollFn: scrollToComponent,
+});
+
+const onOKClick = async () => {
+  const payload = {
+    account_id: props.accountId,
+    amount: form.amount,
+    description: form.description,
+    launch_date: form.launch_date_iso,
+    transaction_type: form.transaction_type,
+  };
+
+  await execute(() => {
+    if (!props.launch) {
+      return createTreasuryLaunchMe(payload);
+    }
+
+    const changedFields = getUpdatedFields.value;
+    const updatePayload = {};
+
+    if ("transaction_type" in changedFields) {
+      updatePayload.transaction_type = form.transaction_type;
+    }
+
+    if ("description" in changedFields) {
+      updatePayload.description = form.description;
+    }
+
+    if ("amount" in changedFields) {
+      updatePayload.amount = form.amount;
+    }
+
+    if (
+      "launch_date_iso" in changedFields ||
+      "launch_date" in changedFields
+    ) {
+      updatePayload.launch_date = form.launch_date_iso;
+    }
+
+    return updateTreasuryLaunchMe(
+      props.launch.id,
+      updatePayload,
+    );
+  });
+};
+</script>
+
+<style scoped>
+.treasury-dialog-card {
+  width: 480px;
+  max-width: calc(100vw - 32px);
+}
+</style>

+ 2 - 2
src/pages/forgot-password/EmailDataStep.vue

@@ -18,7 +18,7 @@
       Informe o seu e-mail para redefinir sua senha
     </div>
 
-    <q-form class="full-width q-px-lg column q-gutter-y-lg q-mt-xs" @submit.prevent="emit('submit', email)">
+    <DefaultForm class="full-width q-px-lg column q-gutter-y-lg q-mt-xs" @submit.prevent="emit('submit', email)">
       <DefaultInput
         v-model="email"
         type="email"
@@ -45,7 +45,7 @@
           </template>
         </q-btn>
       </div>
-    </q-form>
+    </DefaultForm>
   </div>
 </template>
 

+ 6 - 3
src/pages/forgot-password/NewPasswordStep.vue

@@ -18,7 +18,7 @@
       <div class="bg-primary" style="height: 1px; flex: 1"></div>
     </div>
 
-    <q-form
+    <DefaultForm
       class="full-width q-px-lg column q-gutter-y-md q-mt-md"
       @submit.prevent="emit('submit', { password, passwordConfirmation })"
     >
@@ -33,7 +33,10 @@
         v-model="passwordConfirmation"
         label="Confirmar nova senha"
         lazy-rules
-        :rules="[inputRules.required, (v) => v === password || 'As senhas não conferem']"
+        :rules="[
+          inputRules.required,
+          inputRules.samePassword(password),
+        ]"
       />
 
       <q-btn
@@ -49,7 +52,7 @@
           <q-spinner />
         </template>
       </q-btn>
-    </q-form>
+    </DefaultForm>
   </div>
 </template>
 

+ 295 - 0
src/pages/kanban/KanbanPage.vue

@@ -0,0 +1,295 @@
+<template>
+  <div>
+    <DefaultHeaderPage
+      :show-filter-icon="false"
+      title="Atividades"
+    />
+
+    <div
+      v-if="loading"
+      class="flex flex-center q-pa-xl"
+    >
+      <q-spinner
+        color="primary"
+        size="48px"
+      />
+    </div>
+
+    <div
+      v-else
+      class="kanban-board q-px-md q-pb-md"
+    >
+      <div
+        v-for="column in columns"
+        :key="column.phase"
+        style="
+          min-width: 280px;
+          max-width: 320px;
+          flex: 1;
+          display: flex;
+          flex-direction: column;
+          gap: 8px;
+        "
+      >
+        <div
+          class="row items-center justify-between q-px-md q-py-sm"
+          :style="{
+            backgroundColor: column.color,
+            borderRadius: '8px',
+          }"
+        >
+          <span
+            class="text-weight-bold text-white"
+            style="font-size: 14px"
+          >
+            {{ column.label }}
+          </span>
+
+          <div class="row items-center gap-xs">
+            <q-badge
+              color="white"
+              style="font-size: 11px"
+              :label="columnMap[column.phase].length"
+              :text-color="column.badgeTextColor"
+            />
+
+            <q-btn
+              v-if="canAdd"
+              color="white"
+              dense
+              flat
+              icon="mdi-plus"
+              round
+              size="sm"
+              @click="openDialog(null, column.phase)"
+            />
+          </div>
+        </div>
+
+        <draggable
+          :animation="180"
+          :data-phase="column.phase"
+          :disabled="!canEdit"
+          :list="columnMap[column.phase]"
+          drag-class="drag-active"
+          ghost-class="drag-ghost"
+          group="kanban"
+          item-key="id"
+          style="
+            display: flex;
+            flex-direction: column;
+            gap: 8px;
+            min-height: 48px;
+            flex: 1;
+          "
+          @end="onDragEnd"
+        >
+          <template #item="{ element }">
+            <KanbanCard
+              :can-delete="canDelete"
+              :can-edit="canEdit"
+              :card="element"
+              @delete="removeCard($event, column.phase)"
+              @edit="openDialog(element, column.phase)"
+            />
+          </template>
+        </draggable>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {
+  computed,
+  defineAsyncComponent,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from "vue";
+
+import { getKanbans, reorderKanbans } from "src/api/kanban";
+import { permissionStore } from "src/stores/permission";
+import { useQuasar } from "quasar";
+import { userStore } from "src/stores/user";
+
+import draggable from "vuedraggable";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import KanbanCard from "./components/KanbanCard.vue";
+
+const AddEditKanbanDialog = defineAsyncComponent(
+  () => import("./components/AddEditKanbanDialog.vue"),
+);
+
+const $q = useQuasar();
+
+const permissions = permissionStore();
+const { selectedUnit } = userStore();
+
+const loading = ref(false);
+
+const columnMap = reactive({
+  a_fazer: [],
+  concluido: [],
+  demandas_especiais: [],
+  em_progresso: [],
+  em_revisao: [],
+});
+
+const canAdd = computed(() =>
+  permissions.getAccess("franchisee_activities", "add"),
+);
+
+const canDelete = computed(() =>
+  permissions.getAccess("franchisee_activities", "delete"),
+);
+
+const canEdit = computed(() =>
+  permissions.getAccess("franchisee_activities", "edit"),
+);
+
+const columns = [
+  {
+    badgeTextColor: "grey-9",
+    color: "#757575",
+    label: "A Fazer",
+    phase: "a_fazer",
+  },
+  {
+    badgeTextColor: "blue-9",
+    color: "#1976D2",
+    label: "Em Progresso",
+    phase: "em_progresso",
+  },
+  {
+    badgeTextColor: "orange-9",
+    color: "#F57C00",
+    label: "Em Revisão",
+    phase: "em_revisao",
+  },
+  {
+    badgeTextColor: "green-9",
+    color: "#388E3C",
+    label: "Concluído",
+    phase: "concluido",
+  },
+  {
+    badgeTextColor: "yellow-9",
+    color: "#F9A825",
+    label: "Demandas Especiais",
+    phase: "demandas_especiais",
+  },
+];
+
+const loadCards = async () => {
+  loading.value = true;
+
+  try {
+    const data = await getKanbans();
+
+    Object.keys(columnMap).forEach((phase) => {
+      columnMap[phase] = [];
+    });
+
+    data.forEach((card) => {
+      if (columnMap[card.phase]) {
+        columnMap[card.phase].push(card);
+      }
+    });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const onDragEnd = async (event) => {
+  const sourcePhase = event.from.dataset.phase;
+  const targetPhase = event.to.dataset.phase;
+
+  const phasesToUpdate = new Set(
+    [sourcePhase, targetPhase].filter(Boolean),
+  );
+
+  const items = [];
+
+  phasesToUpdate.forEach((phase) => {
+    columnMap[phase].forEach((card, index) => {
+      card.phase = phase;
+
+      items.push({
+        id: card.id,
+        order: index,
+        phase,
+      });
+    });
+  });
+
+  try {
+    await reorderKanbans(items);
+  } catch {
+    await loadCards();
+  }
+};
+
+const openDialog = (card = null, phase = "a_fazer") => {
+  $q.dialog({
+    component: AddEditKanbanDialog,
+    componentProps: {
+      card,
+      initialPhase: phase,
+    },
+  }).onOk(loadCards);
+};
+
+const removeCard = (cardId, phase) => {
+  const index = columnMap[phase].findIndex(
+    (card) => card.id === cardId,
+  );
+
+  if (index !== -1) {
+    columnMap[phase].splice(index, 1);
+  }
+};
+
+watch(
+  () => selectedUnit?.id,
+  (newId, oldId) => {
+    if (newId === oldId) return;
+
+    loadCards();
+  },
+);
+
+onMounted(loadCards);
+</script>
+
+<style scoped>
+.kanban-board {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+  grid-auto-rows: max-content;
+  align-items: start;
+  align-content: start;
+  gap: 16px;
+  width: 100%;
+  max-width: 100%;
+  min-width: 0;
+  min-height: calc(100vh - 120px);
+  box-sizing: border-box;
+  overflow-x: hidden;
+  padding-top: 12px;
+}
+
+:deep(.drag-ghost) {
+  opacity: 0.4;
+  border: 2px dashed #aaa;
+  border-radius: 10px;
+}
+
+:deep(.drag-active) {
+  opacity: 0.95;
+  box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
+  transform: rotate(1.5deg);
+}
+</style>

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff