189 커밋 14ac451a95 ... 8a8cd24c82

작성자 SHA1 메시지 날짜
  Gustavo Mantovani 8a8cd24c82 feat: add novas regras custom no input rules 1 개월 전
  Gustavo Mantovani 6bef2ae7ef refactor: centraliza rules para nao deixar implementacoes proprias em cada componente 1 개월 전
  Gustavo Mantovani aee19d8e95 refactor: ajuste handlers de submit 1 개월 전
  Gustavo Mantovani 6b52efd74c refactor: modifica composable o useScroll 1 개월 전
  Gustavo Mantovani e5ef50ec4c refactor: passa os uploads e validacoes de erro para dentro das tabs ao inves de jogar para o componente pai 1 개월 전
  Gustavo Mantovani e56010eb86 refactor: add rules de cpf em campos restantes de cpf que ainda nao constavam com essa validacao 1 개월 전
  Gustavo Mantovani b840ae4029 chore: renomeia cpf / cnh para cpf e responsavel para franqueado operador em unidades 1 개월 전
  Gustavo Mantovani 6ee9d5b96c feat: add campos de nome e cnpj da unidade na tela de contrato 1 개월 전
  Gustavo Mantovani 37ffa484c4 refactor: deixa address_number required 1 개월 전
  Gustavo Mantovani b85234cfc5 refactor: deixa somente name e cpf obrigatorios no cadastro de unit_partner 1 개월 전
  Gustavo Mantovani 20a16caff8 refactor: ajuste para mostrar erros dos campos + q-scroll-area nos forms em dialogs 1 개월 전
  Gustavo Mantovani 321ac3f964 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 1 개월 전
  Gustavo Mantovani 8b5b5f175a chore: renomeia e-mail secundario para e-mail administrativo 1 개월 전
  Gustavo Mantovani 26af09e227 feat: add nome da unidade (name) 1 개월 전
  Gustavo Mantovani f38c1cd13c refactor: format fantasy name 1 개월 전
  ebagabee 8be64c56be feat(financial): add Asaas charge actions 1 개월 전
  ebagabee 52f81d6827 fix(package): update author information in package.json 1 개월 전
  ebagabee a780f6bb99 docs(versions): update system changelog 1 개월 전
  ebagabee b9336c304a fix(header): display last login in Brazil timezone 1 개월 전
  ebagabee c94dfa47c3 feat(dashboard): add clear filters button 1 개월 전
  ebagabee 6116c312d6 fix(dashboard): update titles and labels for revenue and receivables in DashboardPage 1 개월 전
  ebagabee 686aa8e836 feat(dashboard): add missing receivables stat card to DashboardPage 1 개월 전
  ebagabee 27d8ffdfd6 fix(financial): update validation rules for value input in AddAccountReceivableDialog and AddCompanyPayableDialog 1 개월 전
  ebagabee 5d5eea0340 feat(financial): add FinancialPlanAccountTreeNode component and integrate into ChartOfAccountsPage 1 개월 전
  ebagabee fcb0fb7dc3 feat(financial): implement dialogs for creating and settling franchisee receivables 1 개월 전
  ebagabee 5b1f5cb5e9 feat(financial): add dialogs for creating and settling company payables 1 개월 전
  ebagabee 8301fdf13b feat(treasury): implement edit and delete functionality for treasury launches in TreasuryPage 1 개월 전
  ebagabee 7f2a553fe0 feat(dashboard): refactor stat cards layout by removing duplicates and ensuring proper display of unit metrics 1 개월 전
  ebagabee 610fe9457a feat(dashboard): enhance attendance and ticket dialogs with dynamic data binding and improved user feedback 1 개월 전
  ebagabee 6d892b4f1e feat(packages): replace q-banner with styled div for class duration hint in AddEditPackageDialog 1 개월 전
  ebagabee 175af661eb feat(packages): add class duration input and weekday selection to AddEditPackageDialog; update PackageCard to display default class duration 1 개월 전
  ebagabee e1a16776fe feat(dashboard): enhance getFranchisorDashboard to accept period parameters; update DashboardPage to handle date selection and display unit metrics 1 개월 전
  ebagabee ca55c00b6b feat(api): update endpoints for cities, countries, states, and financial plan accounts; add new select methods for improved data retrieval 1 개월 전
  ebagabee ccf1fcbbd1 refactor(user): remove multipart/form-data headers from createUser and updateUser functions 1 개월 전
  ebagabee 903ba73384 feat(user): enhance userTypes API to accept unitId; update UserTypeSelect and UserActionPage components 1 개월 전
  ebagabee ff9163ec44 feat(permissions): refactor API endpoints for cities, countries, and states; add new group selection API; update permission scopes for user types and navigation 1 개월 전
  ebagabee 8b277a744f feat(permissions): update user type permissions API to use IDs instead of slugs and refine user type filtering 1 개월 전
  ebagabee 493efe0807 feat(permissions): update permission scopes to franchisor_registrations and enhance access controls across user and group management features 1 개월 전
  ebagabee 2b0834f3f6 feat(permissions): update permission scopes to franchisor_activities and enhance access controls across Kanban features 1 개월 전
  ebagabee 3f395929c4 feat(permissions): update permission scopes to franchisor_support and enhance access controls in support features 1 개월 전
  ebagabee 40d8fbb196 feat(permissions): update permission scopes to franchisor_financial and enhance access controls across financial pages 1 개월 전
  ebagabee d8c1c90543 feat(permissions): update permission scopes for TBR and enhance access controls 1 개월 전
  ebagabee 8d1fe8cab7 feat(products): update permissions for product management and enhance UI controls 1 개월 전
  ebagabee f5c9e6aae0 feat(permissions): update permission scopes and enhance package management features 1 개월 전
  ebagabee 749217a149 fix(permissions): update permission scopes to use franchisor_franchisee 1 개월 전
  ebagabee c5e02705f0 fix(user): add accessScope parameter to userTypes API call 1 개월 전
  ebagabee cf761cc6ea feat(permissions): Menu passa a ser derivado de Visualizar 1 개월 전
  ebagabee 4d969a2a1b fix(auth): setUser era ignorado no login/refresh 1 개월 전
  ebagabee 9b48da97b9 fix(user): isAdmin nunca era true 1 개월 전
  ebagabee db50b57c3e fix(permissions): ações fixas e conteúdo rolável no dialog 1 개월 전
  ebagabee 2d27309a79 refactor(permissions): baseia a matriz nos menus da franqueadora 1 개월 전
  ebagabee 975497b071 feat(permissions): dialog de permissões do grupo (Menu/SubCategorias) 1 개월 전
  ebagabee d6376b680e feat(permissions): menu e tela de Grupos de Permissões 1 개월 전
  ebagabee 84bf269ced feat(financial): adiciona card de valor total do estoque no dashboard 1 개월 전
  ebagabee b3a35002c7 feat(accounts-payable): implement company payables management with create, settle, and reopen functionalities 2 달 전
  ebagabee b12396cbc0 feat(plan-account): conta pai/filho no plano de contas 2 달 전
  ebagabee 452fd9bfc7 feat(notifications): conecta sino aos dados reais e realtime 2 달 전
  ebagabee 3ac71d45f7 feat(notifications): add scroll area for notifications and new notification entries 2 달 전
  ebagabee 32ffd60630 feat(header): add user avatar and notifications panel to header 2 달 전
  ebagabee bfd201d965 feat(tbr): aviso de geração automática de contas na aba Cobranças 2 달 전
  ebagabee f0a0232b02 feat(financial): adiciona Integração Asaas ao menu Financeiro 2 달 전
  ebagabee 25a11f4e4e feat(financial): página para a franqueadora configurar a chave Asaas 2 달 전
  ebagabee 26ce8d95c2 feat(financial): cliente de API da conta Asaas da franqueadora 2 달 전
  ebagabee 2afc7aeb08 feat(dashboard): implement API call for franchisor dashboard data and update metrics display 2 달 전
  ebagabee 362228d6a5 feat(treasury): remove placeholder text from financial plan account selection 2 달 전
  ebagabee 6c216c55eb feat(treasury): add financial plan account selection and display in treasury launch 2 달 전
  ebagabee 44d1df9cea feat(unit-financial): default ROI/FNM toggles to on 2 달 전
  ebagabee d4a6fb8412 feat(chart-of-accounts): list accounts and wire new account button 2 달 전
  ebagabee 59087d0d1b feat(chart-of-accounts): add new account dialog 2 달 전
  ebagabee 1a026802af feat(chart-of-accounts): add plan account api client 2 달 전
  ebagabee 2df3745238 feat(treasury): show account balance on cards 2 달 전
  ebagabee a706799e2d feat(treasury): wire manual movements and bank edit 2 달 전
  ebagabee 173c6aa215 feat(treasury): add optional edit action to financial card 2 달 전
  ebagabee 2b71c12f12 feat(treasury): add manual movement dialog 2 달 전
  ebagabee 454ecbb748 feat(treasury): add treasury launch api client 2 달 전
  ebagabee 953e3d39be feat: remove DevBanner e adiciona botao antes 2 달 전
  ebagabee 406aca7586 refactor(treasury): move add button first and use col-3 cards 2 달 전
  ebagabee b82b28fd73 feat(treasury): render dynamic bank cards with add button 2 달 전
  ebagabee fcdebb18ce feat(treasury): add create/edit bank account dialog 2 달 전
  ebagabee 69f13d8888 feat(treasury): add treasury account api client 2 달 전
  ebagabee 7bc8eea3ce feat: tela Contas a Receber do franchisor com dados reais (TBR) 2 달 전
  ebagabee 1fb0dfcd7a feat: dados reais na visão geral do Financeiro (#CB017) 2 달 전
  ebagabee 252a01c699 feat: gate de reautenticação ao acessar o Financeiro (#CB017) 2 달 전
  ebagabee 3cb7f12f07 Muda ordem de menu lateral 3 달 전
  ebagabee c1a2befd3f feat: adiciona devbanner 3 달 전
  ebagabee 74179d9dff feat: adiciona emissao de notas base 3 달 전
  ebagabee 9c55451eab feat: adiciona tela de plano de contas 3 달 전
  ebagabee 1c4d5ca62f feat: contas a receber 3 달 전
  ebagabee 2b642fe9ea feat: adiciona ultimas movimentacoes 3 달 전
  ebagabee b33b3a91e1 feat: adiciona contas a pagar 3 달 전
  ebagabee 387af0fa50 feat: adiciona interacao em cards 3 달 전
  ebagabee 0f30473c4e feat: adiciona tabela em baixo 3 달 전
  ebagabee 5c466d90e9 feat: adiciona olhinho em parte superior 3 달 전
  ebagabee 402c03dc46 feat: adiciona pagina de Financeiro 3 달 전
  ebagabee 30805b0d5c feat: adiciona card de ultimas movimentacoes 3 달 전
  ebagabee 4a6f4254ca feat: ultimas movimentacoes e desempenho por franquia 3 달 전
  ebagabee 0fa2968acf feat: adiciona tres cards abaixo 3 달 전
  ebagabee 667a3b4d79 feat: adiciona card de minha tesouraria 3 달 전
  ebagabee 3a7b869333 feat: adiciona cards na tela de financas 3 달 전
  ebagabee 37648c583d feat: adiciona pagina para financeiro 3 달 전
  ebagabee 5af1f038c2 feat: adiciona menu laterais referente a financeiro 3 달 전
  ebagabee d661751312 feat: adiciona tab de midias 3 달 전
  ebagabee 58a69e28fb feat: adiciona botao de adicionar na linha 3 달 전
  Gabriel Alves 916181087a Merge branch 'feature/GINC-GAB-fluxo-kanban-franqueadora-franquia' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development 3 달 전
  ebagabee 1d9332261f feat(kanban): dialog/card redesign for franchisor 3 달 전
  ebagabee b8d8c92bbb fix(kanban): fix DnD — use reactive columnMap instead of filtered array 3 달 전
  ebagabee 71caf77152 feat(kanban): drag-and-drop with order + phase persistence (franchisor) 3 달 전
  ebagabee 6a7b7a801d fix(kanban): simplify Para label — Interno or unit name only 3 달 전
  ebagabee 54f1e3f677 feat(kanban): add De/Para and Responsável to franchisor kanban card 3 달 전
  ebagabee 5cf7a3e4e7 feat(kanban): add Atividades to franchisor navigation menu 3 달 전
  ebagabee 092819bef5 feat(kanban): add kanban board page to franchisor 3 달 전
  ebagabee dbe1437cf4 feat: allow associating users with multiple units 3 달 전
  ebagabee e752bd8ecc feat: add UnitMultiSelect component 3 달 전
  ebagabee f9012d8b53 feat: adiciona ticket com # 3 달 전
  ebagabee 268e6b1577 feat: muda layout de produtos 3 달 전
  ebagabee ca4ee62071 feat: muda layout de produtos 3 달 전
  ebagabee 817d89c066 feat: adiciona dashboard correto 3 달 전
  ebagabee f8f6412d54 feat: altera layout do feriado 3 달 전
  ebagabee a19b205fd5 feat: adiciona unidades 3 달 전
  ebagabee 00d467b13d feat: adiciona funcionalidade de criacao de contas a receber do aluno 3 달 전
  ebagabee 5e137fc096 chore(tbr): remove getFranchiseeTbrs apos descontinuacao da tabela 3 달 전
  ebagabee eca25cef17 feat(billings): implementa fluxo em lote de geracao de Contas a Receber 3 달 전
  ebagabee 69bb8089ac feat(contracts): troca select de Faixa de Habitantes para municipality_sizes 3 달 전
  ebagabee 2e5fcb7f1a feat(tbr): adiciona client de tbr-calculation e municipality-size 3 달 전
  ebagabee 9604be8bc2 feat(filter): adiciona filtros de unidades e grupos em dash 3 달 전
  ebagabee f36ac05583 feat(class_package): adiciona grupos 3 달 전
  ebagabee acf1021736 feat: adiciona modulo de grupos 3 달 전
  ebagabee c8201c037e feat: adiciona dialog de grupo 3 달 전
  ebagabee ff58015edb feat: adiciona tela de grupos 3 달 전
  ebagabee 9c96da2155 feat: muda rota para cadastros e adiciona tab de grupos em cadastro 3 달 전
  Gabriel Alves add0fdd9ff Merge branch 'feature/GINC-GAB-suporte' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development 3 달 전
  ebagabee 96334e2caf feat(support): adiciona validacao de suporte 3 달 전
  ebagabee ac2979aac5 feat(support): adiciona ticket para franchisee 3 달 전
  ebagabee e02959e1a7 feat: adiciona botao de editar e excluir 3 달 전
  ebagabee c4e8b6b267 feat: adiciona comentarios em ticket 3 달 전
  ebagabee edf5519920 feat: adiciona comentarios em ticket 3 달 전
  ebagabee a79c2e0185 feat(actions): adiciona gap 3 달 전
  ebagabee 8af04cb9d2 feat(support): validacao de status para adicionar ou nao comentarios 3 달 전
  ebagabee 6c14e46a36 feat(support): adiciona funcionalidade de Alterar o Status, Encerrar o Ticket e Validacao do Status para Acoes 3 달 전
  ebagabee 129af80642 feat(support): adiciona destino como select 3 달 전
  ebagabee 3e6a999426 feat(support): adiciona destino como select 3 달 전
  ebagabee 3285a49ed5 refactor: altera nome de dialog 3 달 전
  ebagabee 53cba3f920 feat(support): adiciona ticket dialog 3 달 전
  ebagabee 3f4ba03703 feat(support): adiciona pagina de suporte 3 달 전
  Gabriel Alves 45088b93b3 Merge branch 'feature/GINC-GAB-CB007-fluxo-produtos' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development 4 달 전
  ebagabee a534bfbd92 feat(products): adiciona importacao de produtos 4 달 전
  ebagabee 3b555ca4e4 feat: adiciona expose para get 4 달 전
  ebagabee 0ddf2249cf feat(products): adiciona estoque 4 달 전
  ebagabee ae9ba28942 feat(products): adiciona quantidade 4 달 전
  ebagabee 04a6dd98b6 feat(products): adiciona descricao 4 달 전
  ebagabee 9fd2a6864b feat(products): adiciona dialgo de produtos 4 달 전
  ebagabee ae7701f43a style: adiciona espacamento em filtro de busca e paginacao 4 달 전
  ebagabee a958002b6b feat(products): adiciona pesquisa e botao de adicionar 4 달 전
  ebagabee 922afa9dcf feat(products): finaliza card expansible 4 달 전
  ebagabee 9356c836e8 feat(product_card): adiciona um card de produtos, com expansible 4 달 전
  ebagabee 05b150d99f feat(products): adiciona tab de produtos, estoque e pedidos 4 달 전
  ebagabee 5a9c42cdd6 feat(products): cria rota e pagina de produtos base 4 달 전
  ebagabee 065ecd6fc3 chore(left_menu): altera posicao dos itens e ajusta imagem 4 달 전
  ebagabee aea3f97516 refactor: traduz ui para portugues 4 달 전
  ebagabee 0a5c8a9eec refactor: traduz titulos, function e arquivos de portugues para ingles. 4 달 전
  ebagabee ed3d72526d feat: adiciona grupos em dialog de pacote 4 달 전
  Gabriel Alves c661505a9a Merge branch 'feature/GINC-GAB-CB002-dashboard-e-feriados' of Softpar/sfp_vue_franchisor_ginastica_cerebro into development 4 달 전
  ebagabee 4f41cb7125 feat: adiciona a funcionalidade de editar e excluir 4 달 전
  ebagabee 3b02f6076c feat: cria integracao com backend para cadastro de feriados 4 달 전
  ebagabee b61d4c1118 feat: adiciona dialog de feriados para franqueadora. 4 달 전
  ebagabee 1c639d1ee2 feat: adiciona botao de + 4 달 전
  ebagabee f30ad08c45 feat: adiciona novo Card para feriados do mes 4 달 전
  ebagabee 6b73df995a chore: remove coluna de acoes em card de aniversariante e adiciona o mes atual 4 달 전
  ebagabee 4af21156f0 feat: adiciona faturamente a esquerda e Matriculas por Periodo a Direita. Aniversariantes para baixo. 4 달 전
  ebagabee c3c0150a9a feat: adiciona novo layout 4 달 전
  ebagabee f83e0838db feat: remove icone de filtero para usuarios 4 달 전
  ebagabee 9a95d01251 feat: ajuste em titulos e correção de porcentagem 4 달 전
  ebagabee e2dd55e6d8 feat: remove tipo de usuarios para usuario 4 달 전
  ebagabee 035640a8d7 chore: corrige taxas 4 달 전
  ebagabee 7222502980 feat: adiciona trycatch para garantir que o dialog abra 4 달 전
  ebagabee 3427207029 chore: no api call 4 달 전
  ebagabee 4aeb01eb9a feat: adiciona billing tab 4 달 전
  ebagabee 1ec7f9d968 feat: adiciona faixa de habitantes em contrato 4 달 전
  ebagabee 7d4be6e80f feat: permite o valor 0 em porcentagem 4 달 전
  ebagabee a6950be53b chore: ajusta % para Porcentagem de Royalties 4 달 전
  ebagabee 9fdae76632 chore: remove botao de visualizar 4 달 전
  ebagabee c4ba70059c feat: adiciona filtro em franchisor: dashboard 4 달 전
  ebagabee 5bc09d63ff feat: adiciona crud de tipo de usuarios e adiciona usuario para franquia 4 달 전
  ebagabee bda0ae60fb feat: ajusta front 4 달 전
  ebagabee e832fa1af2 chore: altera function para string 4 달 전
  ebagabee f02c560dae feat: adiciona default header dialog em edicao de pacote 4 달 전
  ebagabee 36a58872ad Merge branch 'development' of gogs.softpar.inf.br:Softpar/sfp_vue_franchisor_ginastica_cerebro into development 4 달 전
  ebagabee 13aa4e1a5b feat: corrige acronimo, adiciona taxas 4 달 전
  ebagabee ebbdb3dbae feat: correcao no historico 4 달 전
100개의 변경된 파일7310개의 추가작업 그리고 2075개의 파일을 삭제
  1. 20 1
      package-lock.json
  2. 4 3
      package.json
  3. 5 0
      src/api/auth.js
  4. 3 18
      src/api/city.js
  5. 21 0
      src/api/company_payable.js
  6. 11 0
      src/api/company_payment_account.js
  7. 1 16
      src/api/country.js
  8. 12 0
      src/api/dashboard.js
  9. 6 0
      src/api/financial_dashboard.js
  10. 26 0
      src/api/financial_plan_account.js
  11. 26 0
      src/api/franchisee_account_receive.js
  12. 31 0
      src/api/group.js
  13. 21 0
      src/api/holiday.js
  14. 1 1
      src/api/inhabitant_classification.js
  15. 30 0
      src/api/kanban.js
  16. 20 0
      src/api/kanban_media.js
  17. 21 0
      src/api/kanban_reply.js
  18. 21 0
      src/api/notification.js
  19. 35 1
      src/api/product.js
  20. 2 17
      src/api/state.js
  21. 19 0
      src/api/student.js
  22. 27 0
      src/api/student_contract.js
  23. 21 0
      src/api/support_reply.js
  24. 21 0
      src/api/support_ticket.js
  25. 0 5
      src/api/tbr.js
  26. 31 0
      src/api/tbr_calculation.js
  27. 21 0
      src/api/treasury_account.js
  28. 20 0
      src/api/treasury_launch.js
  29. 1 1
      src/api/unit.js
  30. 11 9
      src/api/user.js
  31. 35 0
      src/api/user_type.js
  32. 3 1
      src/boot/defaultPropsComponents.js
  33. 8 8
      src/components/charts/BirthdaysCard.vue
  34. 113 0
      src/components/charts/HolidaysCard.vue
  35. 1 1
      src/components/defaults/DefaultCurrencyInput.vue
  36. 3 4
      src/components/defaults/DefaultDialogHeader.vue
  37. 42 27
      src/components/defaults/DefaultFilePicker.vue
  38. 35 0
      src/components/defaults/DefaultForm.vue
  39. 63 47
      src/components/defaults/DefaultInput.vue
  40. 161 0
      src/components/defaults/DefaultMediaPicker.vue
  41. 36 24
      src/components/defaults/DefaultSelect.vue
  42. 6 2
      src/components/defaults/DefaultTable.vue
  43. 221 0
      src/components/financial/AddAccountReceivableDialog.vue
  44. 178 0
      src/components/financial/AddCompanyPayableDialog.vue
  45. 169 0
      src/components/financial/AddEditTreasuryAccountDialog.vue
  46. 203 0
      src/components/financial/AddFinancialPlanAccountDialog.vue
  47. 207 0
      src/components/financial/AddTreasuryLaunchDialog.vue
  48. 99 0
      src/components/financial/FinancialAuthDialog.vue
  49. 123 0
      src/components/financial/FinancialCard.vue
  50. 100 0
      src/components/financial/FinancialPlanAccountTreeNode.vue
  51. 73 0
      src/components/financial/FranchisePerformanceCard.vue
  52. 98 0
      src/components/financial/LastTransactionsCard.vue
  53. 110 0
      src/components/financial/SettleAccountReceivableDialog.vue
  54. 153 0
      src/components/financial/SettleCompanyPayableDialog.vue
  55. 37 0
      src/components/financial/TreasuryCard.vue
  56. 321 28
      src/components/layout/DefaultHeaderPage.vue
  57. 273 170
      src/components/layout/LeftMenuLayout.vue
  58. 121 72
      src/components/layout/LeftMenuLayoutMobile.vue
  59. 68 0
      src/components/selects/GroupSelect.vue
  60. 88 0
      src/components/selects/UnitMultiSelect.vue
  61. 2 1
      src/components/selects/UnitSelect.vue
  62. 23 35
      src/components/selects/UserTypeSelect.vue
  63. 48 56
      src/components/shared/ChangeImageDialog.vue
  64. 11 1
      src/composables/useAuth.js
  65. 81 84
      src/composables/useForm.js
  66. 279 44
      src/composables/useInputRules.js
  67. 157 67
      src/composables/useScroll.js
  68. 29 33
      src/composables/useSubmitHandler.js
  69. 101 35
      src/css/app.scss
  70. 14 0
      src/helpers/utils.js
  71. 9 0
      src/i18n/locales/en.json
  72. 9 0
      src/i18n/locales/es.json
  73. 9 0
      src/i18n/locales/pt.json
  74. 44 0
      src/pages/cadastros/CadastrosPage.vue
  75. 157 0
      src/pages/cadastros/components/AddEditGroupDialog.vue
  76. 109 0
      src/pages/cadastros/tabs/GruposTab.vue
  77. 115 0
      src/pages/cadastros/tabs/UsuariosTab.vue
  78. 0 159
      src/pages/city/CityPage.vue
  79. 0 158
      src/pages/city/components/AddEditCityDialog.vue
  80. 0 152
      src/pages/country/CountryPage.vue
  81. 0 136
      src/pages/country/components/AddEditCountryDialog.vue
  82. 392 357
      src/pages/dashboard/DashboardPage.vue
  83. 73 0
      src/pages/dashboard/StudentDetailPage.vue
  84. 42 44
      src/pages/dashboard/components/ActiveStudentsDialog.vue
  85. 24 18
      src/pages/dashboard/components/AverageAttendanceDialog.vue
  86. 45 77
      src/pages/dashboard/components/CancelledContractsDialog.vue
  87. 44 76
      src/pages/dashboard/components/FrozenContractsDialog.vue
  88. 145 0
      src/pages/dashboard/components/HolidayEditDialog.vue
  89. 522 0
      src/pages/dashboard/components/HolidaysDialog.vue
  90. 38 53
      src/pages/dashboard/components/OpenTicketsDialog.vue
  91. 15 29
      src/pages/dashboard/components/ProductStockDialog.vue
  92. 152 0
      src/pages/financial/AccountsPayablePage.vue
  93. 201 0
      src/pages/financial/AccountsReceivablePage.vue
  94. 164 0
      src/pages/financial/AsaasIntegrationPage.vue
  95. 157 0
      src/pages/financial/ChartOfAccountsPage.vue
  96. 134 0
      src/pages/financial/FinancialPage.vue
  97. 84 0
      src/pages/financial/InvoiceIssuancePage.vue
  98. 240 0
      src/pages/financial/TreasuryPage.vue
  99. 2 2
      src/pages/forgot-password/EmailDataStep.vue
  100. 2 2
      src/pages/forgot-password/NewPasswordStep.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 Cerebro Franchisor",
   "productName": "Gianstica Cerebro Franchisor",
-  "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",

+ 5 - 0
src/api/auth.js

@@ -10,6 +10,11 @@ export const verifyPasswordCode = async (email, code) => {
   return data;
 };
 
+export const confirmPassword = async (password) => {
+  const { data } = await api.post("/confirm-password", { password });
+  return data;
+};
+
 export const resetPassword = async (email, code, password, passwordConfirmation) => {
   const { data } = await api.post("/reset-password", {
     email,

+ 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;
 };

+ 21 - 0
src/api/company_payable.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getCompanyPayables = async (params = {}) => {
+  const { data } = await api.get("/company-payable", { params });
+  return data.payload;
+};
+
+export const createCompanyPayable = async (payload) => {
+  const { data } = await api.post("/company-payable", payload);
+  return data.payload;
+};
+
+export const settleCompanyPayable = async (id, payload = {}) => {
+  const { data } = await api.post(`/company-payable/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const reopenCompanyPayable = async (id) => {
+  const { data } = await api.post(`/company-payable/${id}/reopen`);
+  return data.payload;
+};

+ 11 - 0
src/api/company_payment_account.js

@@ -0,0 +1,11 @@
+import api from "src/api";
+
+export const getCompanyPaymentAccount = async () => {
+  const { data } = await api.get("/company-payment-account");
+  return data.payload;
+};
+
+export const saveCompanyPaymentAccount = async (payload) => {
+  const { data } = await api.post("/company-payment-account", payload);
+  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;
 };

+ 12 - 0
src/api/dashboard.js

@@ -0,0 +1,12 @@
+import api from "src/api";
+
+export const getFranchisorDashboard = async (unitIds = [], period = {}) => {
+  const { data } = await api.get("/franchisor-dashboard/overview", {
+    params: {
+      ...(unitIds.length ? { unit_ids: unitIds } : {}),
+      ...(period.startDate ? { start_date: period.startDate } : {}),
+      ...(period.endDate ? { end_date: period.endDate } : {}),
+    },
+  });
+  return data.payload;
+};

+ 6 - 0
src/api/financial_dashboard.js

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

+ 26 - 0
src/api/financial_plan_account.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getFinancialPlanAccounts = async (params = {}) => {
+  const { data } = await api.get("/financial-plan-account", { params });
+  return data.payload;
+};
+
+export const getFinancialPlanAccountsForSelect = async () => {
+  const { data } = await api.get("/selects/financial-plan-accounts");
+  return data.payload;
+};
+
+export const createFinancialPlanAccount = async (payload) => {
+  const { data } = await api.post("/financial-plan-account", payload);
+  return data.payload;
+};
+
+export const updateFinancialPlanAccount = async (id, payload) => {
+  const { data } = await api.put(`/financial-plan-account/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteFinancialPlanAccount = async (id) => {
+  const { data } = await api.delete(`/financial-plan-account/${id}`);
+  return data;
+};

+ 26 - 0
src/api/franchisee_account_receive.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getFranchiseeReceivables = async (params = {}) => {
+  const { data } = await api.get("/franchisee-account-receive", { params });
+  return data.payload;
+};
+
+export const createFranchiseeReceivable = async (payload) => {
+  const { data } = await api.post("/franchisee-account-receive", payload);
+  return data.payload;
+};
+
+export const settleFranchiseeReceivable = async (id, payload = {}) => {
+  const { data } = await api.post(`/franchisee-account-receive/${id}/settle`, payload);
+  return data.payload;
+};
+
+export const chargeFranchiseeReceivable = async (id) => {
+  const { data } = await api.post(`/franchisee-account-receive/${id}/charge`);
+  return data.payload;
+};
+
+export const reopenFranchiseeReceivable = async (id) => {
+  const { data } = await api.post(`/franchisee-account-receive/${id}/reopen`);
+  return data.payload;
+};

+ 31 - 0
src/api/group.js

@@ -0,0 +1,31 @@
+import api from "src/api";
+
+export const getGroups = async () => {
+  const { data } = await api.get("/group");
+  return data.payload;
+};
+
+export const getGroupsForSelect = async () => {
+  const { data } = await api.get("/selects/groups");
+  return data.payload;
+};
+
+export const getGroup = async (id) => {
+  const { data } = await api.get(`/group/${id}`);
+  return data.payload;
+};
+
+export const createGroup = async (payload) => {
+  const { data } = await api.post("/group", payload);
+  return data.payload;
+};
+
+export const updateGroup = async (id, payload) => {
+  const { data } = await api.put(`/group/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteGroup = async (id) => {
+  const { data } = await api.delete(`/group/${id}`);
+  return data;
+};

+ 21 - 0
src/api/holiday.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getBaseHolidays = async () => {
+  const { data } = await api.get("/base-holiday");
+  return data.payload;
+};
+
+export const createBaseHoliday = async (payload) => {
+  const { data } = await api.post("/base-holiday", payload);
+  return data.payload;
+};
+
+export const updateBaseHoliday = async (id, payload) => {
+  const { data } = await api.put(`/base-holiday/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteBaseHoliday = async (id) => {
+  const { data } = await api.delete(`/base-holiday/${id}`);
+  return data;
+};

+ 1 - 1
src/api/inhabitant_classification.js

@@ -1,7 +1,7 @@
 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("/kanban");
+  return data.payload;
+};
+
+export const createKanban = async (payload) => {
+  const { data } = await api.post("/kanban", payload);
+  return data.payload;
+};
+
+export const updateKanban = async (id, payload) => {
+  const { data } = await api.put(`/kanban/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteKanban = async (id) => {
+  const { data } = await api.delete(`/kanban/${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("/kanban/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(`/kanban/${kanbanId}/medias`);
+  return data.payload;
+};
+
+export const uploadKanbanMedia = async (kanbanId, file) => {
+  const form = new FormData();
+  form.append("file", file);
+  const { data } = await api.post(`/kanban/${kanbanId}/medias`, form, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
+  return data.payload;
+};
+
+export const deleteKanbanMedia = async (kanbanId, id) => {
+  const { data } = await api.delete(`/kanban/${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(`/kanban/${kanbanId}/replies`);
+  return data.payload;
+};
+
+export const createKanbanReply = async (kanbanId, payload) => {
+  const { data } = await api.post(`/kanban/${kanbanId}/replies`, payload);
+  return data.payload;
+};
+
+export const updateKanbanReply = async (kanbanId, id, payload) => {
+  const { data } = await api.put(`/kanban/${kanbanId}/replies/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteKanbanReply = async (kanbanId, id) => {
+  const { data } = await api.delete(`/kanban/${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;
+};

+ 35 - 1
src/api/product.js

@@ -1,6 +1,40 @@
 import api from "src/api";
 
+export const getProducts = async (params) => {
+  const { data } = await api.get("/product", { params });
+  return data.payload;
+};
+
 export const getProductsForSelect = async () => {
-  const { data } = await api.get("/product/all/select");
+  const { data } = await api.get("/selects/products");
+  return data.payload;
+};
+
+export const createProduct = async (payload) => {
+  const { data } = await api.post("/product", payload);
+  return data.payload;
+};
+
+export const updateProduct = async (id, payload) => {
+  const { data } = await api.put(`/product/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteProduct = async (id) => {
+  const { data } = await api.delete(`/product/${id}`);
+  return data;
+};
+
+export const adjustProductStock = async (id, payload) => {
+  const { data } = await api.patch(`/product/${id}/stock`, payload);
+  return data.payload;
+};
+
+export const importProducts = async (file) => {
+  const formData = new FormData();
+  formData.append("file", file);
+  const { data } = await api.post("/product/import", formData, {
+    headers: { "Content-Type": "multipart/form-data" },
+  });
   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;
 }

+ 19 - 0
src/api/student.js

@@ -4,3 +4,22 @@ export const getStudents = async () => {
   const { data } = await api.get("/student");
   return data.payload;
 };
+
+export const getStudentSummaryFranchisor = async (unitIds = []) => {
+  const { data } = await api.get("/student/franchisor/summary", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
+  return data.payload;
+};
+
+export const getFranchisorActiveStudents = async (unitIds = []) => {
+  const { data } = await api.get("/student/franchisor/active", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
+  return data.payload;
+};
+
+export const getFranchisorStudentDetail = async (id) => {
+  const { data } = await api.get(`/student/franchisor/${id}`);
+  return data.payload;
+};

+ 27 - 0
src/api/student_contract.js

@@ -0,0 +1,27 @@
+import api from "src/api";
+
+export const getContractsByStudent = async (studentId) => {
+  const { data } = await api.get("/student-contract", { params: { student_id: studentId } });
+  return data.payload;
+};
+
+export const getFranchisorContractSummary = async (unitIds = []) => {
+  const { data } = await api.get("/student-contract/franchisor/summary", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
+  return data.payload;
+};
+
+export const getFranchisorFrozenContracts = async (unitIds = []) => {
+  const { data } = await api.get("/student-contract/franchisor/frozen", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
+  return data.payload;
+};
+
+export const getFranchisorCancelledContracts = async (unitIds = []) => {
+  const { data } = await api.get("/student-contract/franchisor/cancelled", {
+    params: unitIds.length ? { unit_ids: unitIds } : {},
+  });
+  return data.payload;
+};

+ 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(`/support-ticket/${ticketId}/replies`);
+  return data.payload;
+};
+
+export const createSupportReply = async (ticketId, payload) => {
+  const { data } = await api.post(`/support-ticket/${ticketId}/replies`, payload);
+  return data.payload;
+};
+
+export const updateSupportReply = async (ticketId, id, payload) => {
+  const { data } = await api.put(`/support-ticket/${ticketId}/replies/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteSupportReply = async (ticketId, id) => {
+  const { data } = await api.delete(`/support-ticket/${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("/support-ticket");
+  return data.payload;
+};
+
+export const createSupportTicket = async (payload) => {
+  const { data } = await api.post("/support-ticket", payload);
+  return data.payload;
+};
+
+export const updateSupportTicket = async (id, payload) => {
+  const { data } = await api.put(`/support-ticket/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteSupportTicket = async (id) => {
+  const { data } = await api.delete(`/support-ticket/${id}`);
+  return data;
+};

+ 0 - 5
src/api/tbr.js

@@ -20,11 +20,6 @@ export const getLatestTbr = async () => {
   return data.payload;
 };
 
-export const getFranchiseeTbrs = async () => {
-  const { data } = await api.get("/franchisee-tbr");
-  return data.payload;
-};
-
 export const getTbrBillingPreview = async () => {
   const { data } = await api.get("/tbr-billing-preview");
   return data.payload;

+ 31 - 0
src/api/tbr_calculation.js

@@ -0,0 +1,31 @@
+import api from "src/api";
+
+export const previewTbrCalculation = async (payload) => {
+  const { data } = await api.post("/tbr-calculation/preview", payload);
+  return data.payload;
+};
+
+export const previewBatchTbrCalculation = async (payload) => {
+  const { data } = await api.post("/tbr-calculation/preview-batch", payload);
+  return data.payload;
+};
+
+export const createTbrCalculation = async (payload) => {
+  const { data } = await api.post("/tbr-calculation", payload);
+  return data.payload;
+};
+
+export const generateReceivable = async (id) => {
+  const { data } = await api.post(`/tbr-calculation/${id}/generate-receivable`);
+  return data.payload;
+};
+
+export const generateBatchReceivables = async (payload) => {
+  const { data } = await api.post("/tbr-calculation/generate-batch", payload);
+  return data;
+};
+
+export const getMunicipalitySizes = async () => {
+  const { data } = await api.get("/selects/municipality-sizes");
+  return data.payload;
+};

+ 21 - 0
src/api/treasury_account.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getTreasuryAccounts = async (params = {}) => {
+  const { data } = await api.get("/treasury-account", { params });
+  return data.payload;
+};
+
+export const createTreasuryAccount = async (payload) => {
+  const { data } = await api.post("/treasury-account", payload);
+  return data.payload;
+};
+
+export const updateTreasuryAccount = async (id, payload) => {
+  const { data } = await api.put(`/treasury-account/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteTreasuryAccount = async (id) => {
+  const { data } = await api.delete(`/treasury-account/${id}`);
+  return data;
+};

+ 20 - 0
src/api/treasury_launch.js

@@ -0,0 +1,20 @@
+import api from "src/api";
+
+export const getTreasuryLaunches = async (params = {}) => {
+  const { data } = await api.get("/treasury-launch", { params });
+  return data.payload;
+};
+
+export const createTreasuryLaunch = async (payload) => {
+  const { data } = await api.post("/treasury-launch", payload);
+  return data.payload;
+};
+
+export const updateTreasuryLaunch = async (id, payload) => {
+  const { data } = await api.put(`/treasury-launch/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteTreasuryLaunch = async (id) => {
+  await api.delete(`/treasury-launch/${id}`);
+};

+ 1 - 1
src/api/unit.js

@@ -32,6 +32,6 @@ export const deleteUnit = async (id) => {
 };
 
 export const getUnitsForSelect = async () => {
-  const { data } = await api.get("/unit/all/select");
+  const { data } = await api.get("/selects/units");
   return data.payload;
 };

+ 11 - 9
src/api/user.js

@@ -16,17 +16,13 @@ export const getUserById = async (id) => {
 };
 
 export const createUser = async (formData) => {
-  const { data } = await api.post("/user", formData, {
-    headers: { "Content-Type": "multipart/form-data" },
-  });
+  const { data } = await api.post("/user", formData);
   return data.payload;
 };
 
 export const updateUser = async (payload, id) => {
   if (payload instanceof FormData) {
-    const { data } = await api.post(`/user/${id}`, payload, {
-      headers: { "Content-Type": "multipart/form-data" },
-    });
+    const { data } = await api.post(`/user/${id}`, payload);
     return data.payload;
   }
   const { data } = await api.put(`/user/${id}`, payload);
@@ -38,7 +34,13 @@ export const deleteUser = async (id) => {
   return data.payload;
 };
 
-export const userTypes = async () => {
-  const { data } = await api.get("/user/all/types");
-  return data.payload;
+export const userTypes = async (accessScope = null, unitId = null) => {
+  const { data } = await api.get("/selects/user-types", {
+    params: {
+      ...(accessScope ? { access_scope: accessScope } : {}),
+      ...(unitId ? { unit_id: unitId } : {}),
+      include_system_admin: accessScope === "unit",
+    },
+  });
+  return Object.fromEntries(data.payload.map((type) => [type.value, type.label]));
 };

+ 35 - 0
src/api/user_type.js

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

+ 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",

+ 8 - 8
src/components/charts/AniversariantesCard.vue → src/components/charts/BirthdaysCard.vue

@@ -1,7 +1,7 @@
 <template>
   <q-card flat class="dashboard-chart-card card-ring">
     <div class="flex justify-between items-center no-wrap q-mb-sm">
-      <span class="text-subtitle2 text-weight-regular">Aniversariantes do Mês</span>
+      <span class="text-subtitle2 text-weight-regular">Aniversariantes do Mês de {{ currentMonth }}</span>
       <q-icon name="mdi-cake-variant-outline" color="secondary" size="sm" />
     </div>
 
@@ -10,7 +10,6 @@
         <q-icon name="mdi-calendar-outline" size="14px" color="secondary" />
         <span>Nome</span>
       </div>
-      <span>Ações</span>
     </div>
     <q-separator />
 
@@ -23,12 +22,6 @@
           <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-section>
         </q-item>
         <q-separator v-if="index < people.length - 1" />
       </template>
@@ -37,6 +30,13 @@
 </template>
 
 <script setup>
+const MONTHS_PT = [
+  'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho',
+  'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro',
+];
+
+const currentMonth = MONTHS_PT[new Date().getMonth()];
+
 defineProps({
   people: {
     type: Array,

+ 113 - 0
src/components/charts/HolidaysCard.vue

@@ -0,0 +1,113 @@
+<template>
+  <q-card flat class="holidays-card card-ring">
+    <div class="flex justify-between items-center no-wrap q-mb-sm">
+      <div class="flex items-center q-gutter-x-sm">
+        <q-icon name="mdi-calendar-star-outline" color="dark" size="sm" />
+        <span class="text-subtitle1">Feriados do Mês</span>
+      </div>
+      <q-btn
+        icon="mdi-plus"
+        color="primary"
+        style="width: 40px; height: 40px"
+        @click="openDialog"
+      />
+    </div>
+
+    <div
+      v-if="currentMonthHolidays.length === 0"
+      class="text-caption text-grey-5 text-center q-mt-sm"
+    >
+      Sem feriados para este mês.
+    </div>
+
+    <div v-else class="row q-gutter-sm q-mt-sm">
+      <div
+        v-for="holiday in currentMonthHolidays"
+        :key="holiday.id"
+        class="column items-center cursor-pointer"
+        style="min-width: 52px"
+        @click="openEdit(holiday)"
+      >
+        <q-badge
+          color="deep-orange"
+          class="text-subtitle1 text-bold q-pa-sm"
+          style="min-width: 40px; justify-content: center"
+        >
+          {{ holiday.day }}
+        </q-badge>
+        <div class="text-caption q-mt-xs text-center">
+          {{ holiday.description }}
+        </div>
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import HolidaysDialog from "src/pages/dashboard/components/HolidaysDialog.vue";
+import HolidayEditDialog from "src/pages/dashboard/components/HolidayEditDialog.vue";
+import { getBaseHolidays } from "src/api/holiday";
+
+const $q = useQuasar();
+
+const now = new Date();
+const holidays = ref([]);
+
+function toFrontend(h) {
+  const [year, month, day] = h.holiday_date.split("-").map(Number);
+  return { id: h.id, day, month, year, description: h.description, type: h.type };
+}
+
+async function loadHolidays() {
+  try {
+    const items = await getBaseHolidays();
+    holidays.value = items.map(toFrontend);
+  } catch {
+    holidays.value = [];
+  }
+}
+
+onMounted(loadHolidays);
+
+const currentMonthHolidays = computed(() =>
+  holidays.value
+    .filter((h) => h.month === now.getMonth() + 1 && h.year === now.getFullYear())
+    .sort((a, b) => a.day - b.day),
+);
+
+function openDialog() {
+  $q.dialog({
+    component: HolidaysDialog,
+    componentProps: { initialHolidays: holidays.value },
+  }).onOk((updated) => {
+    holidays.value = updated;
+  });
+}
+
+function openEdit(holiday) {
+  $q.dialog({
+    component: HolidayEditDialog,
+    componentProps: { holiday },
+  }).onOk(({ action, holiday: updated, id }) => {
+    if (action === "update") {
+      const idx = holidays.value.findIndex((h) => h.id === updated.id);
+      if (idx !== -1) holidays.value[idx] = updated;
+    } else if (action === "delete") {
+      holidays.value = holidays.value.filter((h) => h.id !== id);
+    }
+  });
+}
+</script>
+
+<style scoped>
+.holidays-card {
+  border-radius: 12px;
+  padding: 20px 24px;
+  display: flex;
+  flex-direction: column;
+  max-height: 370px;
+  overflow: hidden;
+}
+</style>

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

@@ -73,7 +73,7 @@ watch(
 
 const minRule = inputRules.minValue(0);
 const finalRules = computed(() => [
-  ...rules,
+  ...inputRules.forValue(rules, () => numberValue.value),
   () => minRule(numberValue.value),
 ]);
 </script>

+ 3 - 4
src/components/defaults/DefaultDialogHeader.vue

@@ -1,7 +1,7 @@
 <template>
   <q-bar class="bg-transparent q-px-md" v-bind="$attrs" style="height: 55px">
     <q-icon v-if="icon" :name="icon" />
-    <div>{{ title() }}</div>
+    <div>{{ title }}</div>
 
     <q-space />
 
@@ -19,14 +19,13 @@
 
 <script setup>
 import { ref, onMounted } from "vue";
-import { useI18n } from "vue-i18n";
 
 const emit = defineEmits(["maximized", "close"]);
 
 const { title, fullscreen, maximizable, icon } = defineProps({
   title: {
-    type: Function,
-    default: () => useI18n().t("common.terms.title"),
+    type: String,
+    default: "",
   },
   fullscreen: {
     type: Boolean,

+ 42 - 27
src/components/defaults/DefaultFilePicker.vue

@@ -11,8 +11,10 @@
       <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"
@@ -21,6 +23,7 @@
       :rules="rules"
       :error="error"
       :error-message="errorMessage"
+      lazy-rules="ondemand"
       class="image-preview-container"
     >
       <div
@@ -43,6 +46,7 @@
             color="grey-6"
             class="absolute-center"
           />
+
           <div
             class="text-caption text-grey-6 text-center absolute-bottom q-pb-sm q-px-md"
           >
@@ -65,6 +69,7 @@
 
         <div v-else class="position-relative column full-height flex-center">
           <q-icon name="mdi-file-check" size="48px" color="grey-6" />
+
           <div
             class="absolute-bottom text-caption text-grey-6 text-center q-mb-sm q-px-md"
           >
@@ -96,47 +101,47 @@
 
 <script setup>
 import {
-  ref,
-  watch,
-  onUnmounted,
-  useTemplateRef,
-  useAttrs,
   computed,
   onBeforeMount,
+  onUnmounted,
+  ref,
+  useAttrs,
+  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, 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",
   },
 });
 
@@ -162,16 +167,21 @@ 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);
 };
 
@@ -195,15 +205,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;
     }
@@ -235,6 +248,7 @@ watch(model, (newFile) => {
     } else {
       preview.value = "file_selected";
     }
+
     generateBase64(newFile);
   } else {
     preview.value = initialImage || null;
@@ -289,6 +303,13 @@ onUnmounted(cleanupObjectURL);
       margin: auto;
     }
   }
+
+  .q-field__append {
+    position: absolute;
+    top: -5px;
+    right: 10px;
+  }
+
   .q-field__control {
     height: 100%;
     min-height: 200px;
@@ -300,11 +321,5 @@ onUnmounted(cleanupObjectURL);
   .q-field__control-container {
     justify-content: center;
   }
-
-  .q-field__append {
-    position: absolute;
-    top: -5px;
-    right: 10px;
-  }
 }
 </style>

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

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

+ 63 - 47
src/components/defaults/DefaultInput.vue

@@ -4,18 +4,19 @@
       <q-input
         ref="inputRef"
         v-model="model"
-        v-bind="inputAttrs"
-        hide-bottom-space
         :bg-color="bgColor"
-        :class="inputClass"
+        :class="[inputClass, { 'required-field': required }]"
         :color
         :error="!!error || !!errorMessageProp"
         :error-message="errorMessage"
         :input-class="nativeInputClass"
         :label
         :label-color
+        lazy-rules="ondemand"
         :rules
         :outlined
+        hide-bottom-space
+        v-bind="inputAttrs"
         @update:model-value="error = null"
       >
         <template v-for="(_, slotName) in $slots" #[slotName]>
@@ -24,7 +25,7 @@
 
         <template #append>
           <slot name="append">
-            <q-icon v-if="icon" :name="icon" size="sm" color="secondary" />
+            <q-icon v-if="icon" :name="icon" color="secondary" size="sm" />
           </slot>
         </template>
       </q-input>
@@ -46,49 +47,58 @@ defineOptions({
   inheritAttrs: false,
 });
 
-const { bgColor, errorMessage: errorMessageProp, icon, inputClass, label, labelColor, nativeInputClass, rules, outlined } =
-  defineProps({
-    bgColor: {
-      type: String,
-      default: "white",
-    },
-    color: {
-      type: String,
-      default: "secondary",
-    },
-    errorMessage: {
-      type: String,
-      default: null,
-    },
-    icon: {
-      type: String,
-      default: "",
-    },
-    inputClass: {
-      type: String,
-      default: null,
-    },
-    label: {
-      type: String,
-      default: "",
-    },
-    labelColor: {
-      type: String,
-      default: "secondary",
-    },
-    nativeInputClass: {
-      type: String,
-      default: null,
-    },
-    outlined: {
-      type: Boolean,
-      default: true,
-    },
-    rules: {
-      type: Array,
-      default: () => [],
-    },
-  });
+const {
+  bgColor,
+  errorMessage: errorMessageProp,
+  icon,
+  inputClass,
+  label,
+  labelColor,
+  nativeInputClass,
+  rules,
+  outlined,
+} = defineProps({
+  bgColor: {
+    type: String,
+    default: "white",
+  },
+  color: {
+    type: String,
+    default: "secondary",
+  },
+  errorMessage: {
+    type: String,
+    default: null,
+  },
+  icon: {
+    type: String,
+    default: "",
+  },
+  inputClass: {
+    type: String,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: "",
+  },
+  labelColor: {
+    type: String,
+    default: "secondary",
+  },
+  nativeInputClass: {
+    type: String,
+    default: null,
+  },
+  outlined: {
+    type: Boolean,
+    default: true,
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+});
 
 const attrs = useAttrs();
 
@@ -105,6 +115,7 @@ const required = ref(false);
 const inputAttrs = computed(() => {
   // eslint-disable-next-line
   const { class: _, style: __, ...rest } = attrs;
+
   return rest;
 });
 
@@ -148,4 +159,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>

+ 161 - 0
src/components/defaults/DefaultMediaPicker.vue

@@ -0,0 +1,161 @@
+<template>
+  <div class="default-media-picker" :class="rootClass">
+    <div v-if="labelOutside && label" class="default-media-picker__label">
+      {{ label }}<span v-if="required" class="text-negative"> *</span>
+    </div>
+
+    <q-file
+      v-model="model"
+      v-bind="$attrs"
+      :accept="accept"
+      :class="{ 'required-field': required }"
+      :dense="dense"
+      :error="!!error"
+      :error-message="errorMessage"
+      :label="labelOutside ? undefined : label"
+      :placeholder="placeholder"
+      :rules="rules"
+      lazy-rules="ondemand"
+      outlined
+      @update:model-value="onFileChange"
+    >
+      <template #prepend>
+        <slot name="prepend">
+          <q-icon name="attach_file" />
+        </slot>
+      </template>
+    </q-file>
+
+    <div
+      v-if="showPreview"
+      class="default-media-picker__preview"
+      :style="{ height: `${previewHeight}px` }"
+    >
+      <img
+        v-if="previewUrl && isImage"
+        alt="Pré-visualização do arquivo"
+        class="default-media-picker__image"
+        :src="previewUrl"
+      />
+
+      <video
+        v-else-if="previewUrl && isVideo"
+        class="default-media-picker__video"
+        controls
+        :src="previewUrl"
+      />
+
+      <q-icon
+        v-else-if="model"
+        color="grey-6"
+        name="mdi-file-check-outline"
+        size="48px"
+      />
+
+      <span v-else class="text-caption text-grey-6">
+        Nenhum arquivo selecionado
+      </span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed, onUnmounted, ref, watch, useAttrs } from "vue";
+
+defineOptions({ inheritAttrs: false });
+
+const {
+  accept,
+  dense,
+  label,
+  labelOutside,
+  placeholder,
+  previewHeight,
+  rules,
+  showPreview,
+} = defineProps({
+  accept: { type: String, default: "image/*,video/*,.pdf" },
+  dense: { type: Boolean, default: false },
+  label: { type: String, default: "Arquivo" },
+  labelOutside: { type: Boolean, default: true },
+  placeholder: { type: String, default: "" },
+  previewHeight: { type: Number, default: 200 },
+  rules: { type: Array, default: () => [] },
+  showPreview: { type: Boolean, default: false },
+});
+
+const attrs = useAttrs();
+
+const model = defineModel({ type: [File, null], default: null });
+
+const error = defineModel("error", {
+  type: [String, Object, Array, Boolean, null],
+  default: null,
+});
+
+const previewUrl = defineModel("previewUrl", { type: String, default: null });
+
+const localObjectUrl = ref(null);
+
+const errorMessage = computed(() =>
+  typeof error.value === "string" ? error.value : "",
+);
+
+const isImage = computed(
+  () => model.value?.type?.startsWith("image/") ?? false,
+);
+
+const isVideo = computed(
+  () => model.value?.type?.startsWith("video/") ?? false,
+);
+
+const required = computed(() => rules.some((rule) => rule?.$id === "required"));
+
+const rootClass = computed(() => attrs.class);
+
+const revokeObjectUrl = () => {
+  if (localObjectUrl.value) {
+    URL.revokeObjectURL(localObjectUrl.value);
+
+    localObjectUrl.value = null;
+  }
+}
+
+const onFileChange = (file) => {
+  revokeObjectUrl();
+
+  if (file instanceof File && (isImage.value || isVideo.value)) {
+    localObjectUrl.value = URL.createObjectURL(file);
+    previewUrl.value = localObjectUrl.value;
+  } else {
+    previewUrl.value = null;
+  }
+}
+
+watch(model, (file) => onFileChange(file));
+
+onUnmounted(() => {
+  revokeObjectUrl();
+});
+</script>
+
+<style scoped>
+.default-media-picker__preview {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  margin-top: 8px;
+  overflow: hidden;
+  border: 1px solid #e0e0e0;
+  border-radius: 4px;
+  background: #fafafa;
+}
+
+.default-media-picker__image,
+.default-media-picker__video {
+  width: 100%;
+  height: 100%;
+  object-fit: contain;
+}
+</style>

+ 36 - 24
src/components/defaults/DefaultSelect.vue

@@ -4,18 +4,19 @@
       <q-select
         ref="selectRef"
         v-model="model"
-        :label
+        hide-bottom-space
+        hide-dropdown-icon
         label-color="secondary"
+        lazy-rules="ondemand"
         v-bind="selectAttrs"
+        :bg-color
+        :class="[inputClass, { 'required-field': required }]"
         :error="!!error"
         :error-message="errorMessage"
-        :rules
+        :label
         :outlined
-        hide-bottom-space
-        :bg-color
-        :class="inputClass"
         :popup-content-class="popupContentClass"
-        hide-dropdown-icon
+        :rules
         @update:model-value="error = null"
       >
         <template v-for="(_, slotName) in $slots" #[slotName]="scope">
@@ -31,54 +32,55 @@
 </template>
 
 <script setup>
-import { ref, onBeforeMount, useAttrs, computed } from "vue";
+import { computed, onBeforeMount, ref, useAttrs } from "vue";
 
 defineOptions({
   inheritAttrs: false,
 });
 
 const {
-  label,
+  bgColor,
+  dropdownIcon,
   inputClass,
+  label,
+  outlined,
   popupContentClass,
   rules,
-  bgColor,
-  outlined,
-  dropdownIcon,
 } = defineProps({
-  label: {
+  bgColor: {
     type: String,
-    default: "",
-  },
-  rules: {
-    type: Array,
-    default: () => [],
+    default: "white",
   },
-  inputClass: {
+  dropdownIcon: {
     type: String,
-    default: null,
+    default: "mdi-chevron-down",
   },
-  popupContentClass: {
+  inputClass: {
     type: String,
     default: null,
   },
-  bgColor: {
+  label: {
     type: String,
-    default: "white",
+    default: "",
   },
   outlined: {
     type: Boolean,
     default: true,
   },
-  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 error = defineModel("error", {
   type: [String, Object, Array, Boolean, null],
 });
@@ -89,6 +91,7 @@ const required = ref(false);
 const selectAttrs = computed(() => {
   // eslint-disable-next-line
   const { class: _, style: __, ...rest } = attrs;
+
   return rest;
 });
 
@@ -96,9 +99,11 @@ const errorMessage = computed(() => {
   if (error.value == null) {
     return void 0;
   }
+
   if (typeof error.value === "boolean") {
     return void 0;
   }
+
   return String(error.value);
 });
 
@@ -112,3 +117,10 @@ defineExpose({
   selectRef,
 });
 </script>
+
+<style scoped>
+:deep(.required-field .q-field__label::after) {
+  color: var(--q-negative);
+  content: " *";
+}
+</style>

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

@@ -34,7 +34,7 @@
         <q-btn
           v-if="addItem"
           color="primary"
-          :style="addItemLabel ? '' : 'width: 40px; height: 40px'"
+          :style="denseAdd ? 'height: 20px' : addItemLabel ? '' : 'width: 40px; height: 40px'"
           :icon="addItemLabel ? undefined : 'mdi-plus'"
           :label="addItemLabel || undefined"
           :outline="outlineAdd"
@@ -48,7 +48,7 @@
           v-if="showSearchField"
           v-model="filter"
           debounce="250"
-          label="Busque por Unidade, status ou Responsavel"
+          label="Busque por Unidade, status ou Franqueado Operador"
           label-color="dark"
           :placeholder="$t('common.actions.search')"
           clearable
@@ -262,6 +262,10 @@ const {
     type: Boolean,
     default: false,
   },
+  denseAdd: {
+    type: Boolean,
+    default: false,
+  },
   deleteFunction: {
     type: Function,
     default: null,

+ 221 - 0
src/components/financial/AddAccountReceivableDialog.vue

@@ -0,0 +1,221 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 520px; max-width: 90vw"
+    >
+      <DefaultDialogHeader
+        title="Nova conta a receber"
+        @close="onDialogCancel"
+      />
+
+      <DefaultForm ref="formRef" @submit.prevent="onSubmit">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <DefaultInput
+              v-model="form.history"
+              v-model:error="validationErrors.history"
+              class="col-12"
+              label="Descrição"
+              :error-message="validationErrors.history"
+              :rules="[inputRules.required, inputRules.max(255)]"
+            />
+
+            <div class="col-12">
+              <div class="text-caption text-grey-8 q-mb-xs">
+                Tipo da conta <span class="text-negative">*</span>
+              </div>
+
+              <q-option-group
+                v-model="form.account_type"
+                color="primary"
+                inline
+                type="radio"
+                :options="accountTypeOptions"
+              />
+            </div>
+
+            <UnitSelect
+              v-if="form.account_type === 'unit'"
+              v-model="form.unit"
+              v-model:error="validationErrors.unit_id"
+              class="col-12"
+              label="Unidade"
+              :error-message="validationErrors.unit_id"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.value"
+              v-model:error="validationErrors.value"
+              class="col-12 col-sm-6"
+              label="Valor"
+              :error-message="validationErrors.value"
+              :rules="[inputRules.requiredNumber, inputRules.minValue(0.01)]"
+            />
+
+            <DefaultInputDatePicker
+              v-model="form.due_date"
+              v-model:untreated-date="form.due_date_iso"
+              v-model:error="validationErrors.due_date"
+              class="col-12 col-sm-6"
+              label="Data de vencimento"
+              :error-message="validationErrors.due_date"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="form.financial_plan_account_id"
+              v-model:error="validationErrors.financial_plan_account_id"
+              class="col-12"
+              clearable
+              emit-value
+              label="Plano de contas"
+              map-options
+              :error-message="validationErrors.financial_plan_account_id"
+              :options="planAccountOptions"
+              :loading="loadingPlanAccounts"
+            />
+
+            <DefaultInput
+              v-model="form.obs"
+              v-model:error="validationErrors.obs"
+              class="col-12"
+              label="Observação"
+              rows="3"
+              type="textarea"
+              :error-message="validationErrors.obs"
+              :rules="[inputRules.max(1000)]"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { createFranchiseeReceivable } from "src/api/franchisee_account_receive";
+import { getFinancialPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { onMounted, ref, watch } from "vue";
+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 DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.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 UnitSelect from "src/components/selects/UnitSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const today = new Date();
+
+const dueDateIso = [
+  today.getFullYear(),
+  String(today.getMonth() + 1).padStart(2, "0"),
+  String(today.getDate()).padStart(2, "0"),
+].join("-");
+
+const accountTypeOptions = [
+  { label: "Conta interna", value: "internal" },
+  { label: "Relacionada a uma unidade", value: "unit" },
+];
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    account_type: "internal",
+    unit: null,
+    history: "",
+    value: 0,
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: dueDateIso,
+    financial_plan_account_id: null,
+    obs: "",
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
+  onSuccess: (response) => onDialogOK(response),
+});
+
+const planAccountOptions = ref([]);
+const loadingPlanAccounts = ref(false);
+
+const onSubmit = async () => {
+  await submitForm(() =>
+    createFranchiseeReceivable({
+      account_type: form.account_type,
+      unit_id: form.account_type === "unit" ? form.unit?.value : null,
+      history: form.history,
+      value: form.value,
+      due_date: form.due_date_iso,
+      financial_plan_account_id: form.financial_plan_account_id || null,
+      obs: form.obs || null,
+    }),
+  );
+};
+
+watch(
+  () => form.account_type,
+  (type) => {
+    if (type === "internal") form.unit = null;
+  },
+);
+
+onMounted(async () => {
+  loadingPlanAccounts.value = true;
+
+  try {
+    const accounts = await getFinancialPlanAccountsForSelect();
+
+    planAccountOptions.value = (accounts ?? []).map((account) => ({
+      label: `${account.code} — ${account.description}`,
+      value: account.id,
+    }));
+  } finally {
+    loadingPlanAccounts.value = false;
+  }
+});
+</script>

+ 178 - 0
src/components/financial/AddCompanyPayableDialog.vue

@@ -0,0 +1,178 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 500px; max-width: 90vw"
+    >
+      <DefaultDialogHeader title="Nova conta a pagar" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit.prevent="onSubmit">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <DefaultInput
+              v-model="form.history"
+              v-model:error="validationErrors.history"
+              class="col-12"
+              label="Descrição"
+              :error-message="validationErrors.history"
+              :rules="[inputRules.required, inputRules.max(255)]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.value"
+              v-model:error="validationErrors.value"
+              class="col-12 col-sm-6"
+              label="Valor"
+              :error-message="validationErrors.value"
+              :rules="[inputRules.requiredNumber, inputRules.minValue(0.01)]"
+            />
+
+            <DefaultInputDatePicker
+              v-model="form.due_date"
+              v-model:untreated-date="form.due_date_iso"
+              v-model:error="validationErrors.due_date"
+              class="col-12 col-sm-6"
+              label="Vencimento"
+              :error-message="validationErrors.due_date"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="form.financial_plan_account_id"
+              v-model:error="validationErrors.financial_plan_account_id"
+              class="col-12"
+              clearable
+              emit-value
+              label="Plano de contas (opcional)"
+              map-options
+              :error-message="validationErrors.financial_plan_account_id"
+              :loading="loadingPlanAccounts"
+              :options="planAccountOptions"
+            />
+
+            <DefaultInput
+              v-model="form.obs"
+              v-model:error="validationErrors.obs"
+              class="col-12"
+              label="Observação (opcional)"
+              rows="3"
+              type="textarea"
+              :error-message="validationErrors.obs"
+              :rules="[inputRules.max(1000)]"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { createCompanyPayable } from "src/api/company_payable";
+import { getFinancialPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { onMounted, ref } from "vue";
+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 DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.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 { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const today = new Date();
+
+const dueDateIso = [
+  today.getFullYear(),
+  String(today.getMonth() + 1).padStart(2, "0"),
+  String(today.getDate()).padStart(2, "0"),
+].join("-");
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    history: "",
+    value: 0,
+    due_date: today.toLocaleDateString("pt-BR"),
+    due_date_iso: dueDateIso,
+    financial_plan_account_id: null,
+    obs: "",
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const planAccountOptions = ref([]);
+const loadingPlanAccounts = ref(false);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
+  onSuccess: (response) => onDialogOK(response),
+});
+
+const onSubmit = async () => {
+  await submitForm(() =>
+    createCompanyPayable({
+      history: form.history,
+      value: form.value,
+      due_date: form.due_date_iso,
+      financial_plan_account_id: form.financial_plan_account_id || null,
+      obs: form.obs || null,
+    }),
+  );
+};
+
+onMounted(async () => {
+  loadingPlanAccounts.value = true;
+
+  try {
+    const planAccounts = await getFinancialPlanAccountsForSelect();
+
+    planAccountOptions.value = (planAccounts ?? []).map((account) => ({
+      label: `${account.code} — ${account.description}`,
+      value: account.id,
+    }));
+  } finally {
+    loadingPlanAccounts.value = false;
+  }
+});
+</script>

+ 169 - 0
src/components/financial/AddEditTreasuryAccountDialog.vue

@@ -0,0 +1,169 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 600px; max-width: 90vw"
+    >
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit="onOKClick">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-sm q-pt-none">
+            <DefaultInput
+              v-model="form.name"
+              v-model:error="validationErrors.name"
+              class="col-12"
+              label="Nome da conta"
+              placeholder="Ex. Conta Principal"
+              :error-message="validationErrors.name"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.bank_name"
+              v-model:error="validationErrors.bank_name"
+              class="col-12 col-md-6"
+              label="Banco"
+              placeholder="Ex. Banco do Brasil"
+              :error-message="validationErrors.bank_name"
+            />
+
+            <DefaultSelect
+              v-model="form.bank_type_account"
+              v-model:error="validationErrors.bank_type_account"
+              class="col-12 col-md-6"
+              emit-value
+              label="Tipo de conta"
+              map-options
+              placeholder="Selecione"
+              :error-message="validationErrors.bank_type_account"
+              :options="accountTypeOptions"
+            />
+
+            <DefaultInput
+              v-model="form.bank_agency"
+              v-model:error="validationErrors.bank_agency"
+              class="col-12 col-md-6"
+              label="Agência"
+              placeholder="Ex. 1234"
+              :error-message="validationErrors.bank_agency"
+            />
+
+            <DefaultInput
+              v-model="form.bank_account"
+              v-model:error="validationErrors.bank_account"
+              class="col-12 col-md-6"
+              label="Conta"
+              placeholder="Ex. 56789-0"
+              :error-message="validationErrors.bank_account"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-card-actions>
+          <q-space />
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            type="submit"
+            :label="isEdit ? 'Salvar' : 'Cadastrar'"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+
+import {
+  createTreasuryAccount,
+  updateTreasuryAccount,
+} from "src/api/treasury_account";
+
+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 DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { account } = defineProps({
+  account: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const { scrollToComponent } = useScroll();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { form, getUpdatedFields } = useForm(
+  {
+    name: account?.name ?? "",
+    bank_name: account?.bank_name ?? "",
+    bank_agency: account?.bank_agency ?? "",
+    bank_account: account?.bank_account ?? "",
+    bank_type_account: account?.bank_type_account ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: (response) => onDialogOK(response),
+  formRef,
+  containerRef: scrollAreaRef,
+  scrollFn: scrollToComponent,
+});
+
+const isEdit = computed(() => !!account);
+const title = computed(() => (isEdit.value ? "Editar Banco" : "Novo Banco"));
+
+const accountTypeOptions = [
+  { label: "Conta Corrente", value: "corrente" },
+  { label: "Conta Poupança", value: "poupanca" },
+];
+
+const onOKClick = async () => {
+  const payload = {
+    name: form.name,
+    treasury_type: "bank",
+    bank_name: form.bank_name || null,
+    bank_agency: form.bank_agency || null,
+    bank_account: form.bank_account || null,
+    bank_type_account: form.bank_type_account || null,
+  };
+
+  if (isEdit.value) {
+    await submitForm(() =>
+      updateTreasuryAccount(account.id, getUpdatedFields.value),
+    );
+  } else {
+    await submitForm(() => createTreasuryAccount(payload));
+  }
+};
+</script>

+ 203 - 0
src/components/financial/AddFinancialPlanAccountDialog.vue

@@ -0,0 +1,203 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 520px; max-width: 90vw"
+    >
+      <DefaultDialogHeader title="Nova Conta" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit="onOKClick">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12">
+              <q-option-group
+                v-model="accountKind"
+                color="primary"
+                inline
+                :options="kindOptions"
+              />
+            </div>
+
+            <DefaultInput
+              v-model="form.code"
+              v-model:error="validationErrors.code"
+              class="col-12 col-md-4"
+              label="Código"
+              :error-message="validationErrors.code"
+              :placeholder="accountKind === 'child' ? '3.1.1' : '3'"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              class="col-12 col-md-8"
+              label="Nome da Conta"
+              placeholder="Ex. Contas a Pagar"
+              :error-message="validationErrors.description"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-if="accountKind === 'parent'"
+              v-model="form.chart_type"
+              v-model:error="validationErrors.chart_type"
+              class="col-12"
+              label="Tipo de Conta"
+              placeholder="Ex. Ativo, Passivo, Receitas, Despesas"
+              :error-message="validationErrors.chart_type"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-else
+              v-model="form.parent_id"
+              v-model:error="validationErrors.parent_id"
+              class="col-12"
+              emit-value
+              label="Conta Pai"
+              map-options
+              option-label="label"
+              option-value="id"
+              :error-message="validationErrors.parent_id"
+              :loading="loadingParents"
+              :options="parentOptions"
+              :rules="[inputRules.required]"
+            />
+
+            <div
+              v-if="accountKind === 'child' && selectedParent"
+              class="col-12 text-caption text-grey-7"
+            >
+              Tipo herdado do pai: <b>{{ selectedParent.chart_type }}</b>
+            </div>
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+
+import {
+  createFinancialPlanAccount,
+  getFinancialPlanAccounts,
+} from "src/api/financial_plan_account";
+
+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 DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
+const { scrollToComponent } = useScroll();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { form } = useForm(
+  {
+    chart_type: "",
+    code: "",
+    description: "",
+    parent_id: null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
+  scrollFn: scrollToComponent,
+});
+
+const accountKind = ref("parent");
+const accounts = ref([]);
+const loadingParents = ref(false);
+
+const parentOptions = computed(() =>
+  accounts.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 kindOptions = [
+  { label: "Conta Pai", value: "parent" },
+  { label: "Conta Filho", value: "child" },
+];
+
+const loadAccounts = async () => {
+  loadingParents.value = true;
+
+  try {
+    accounts.value = await getFinancialPlanAccounts();
+  } catch (error) {
+    console.error("Erro ao carregar contas para vínculo:", error);
+  } finally {
+    loadingParents.value = false;
+  }
+};
+
+const onOKClick = 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;
+  }
+
+  await submitForm(() => createFinancialPlanAccount(payload));
+};
+
+onMounted(loadAccounts);
+</script>

+ 207 - 0
src/components/financial/AddTreasuryLaunchDialog.vue

@@ -0,0 +1,207 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 520px; max-width: 90vw"
+    >
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit="onOKClick">
+        <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <DefaultSelect
+              v-model="form.transaction_type"
+              v-model:error="validationErrors.transaction_type"
+              class="col-12"
+              emit-value
+              label="Tipo"
+              map-options
+              placeholder="Entrada ou Saída"
+              :error-message="validationErrors.transaction_type"
+              :options="typeOptions"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultInput
+              v-model="form.description"
+              v-model:error="validationErrors.description"
+              class="col-12"
+              label="Descrição"
+              placeholder="Descreva a movimentação"
+              :error-message="validationErrors.description"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultSelect
+              v-model="form.financial_plan_account_id"
+              v-model:error="validationErrors.financial_plan_account_id"
+              class="col-12"
+              emit-value
+              input-debounce="0"
+              label="Plano de Contas"
+              map-options
+              use-input
+              :error-message="validationErrors.financial_plan_account_id"
+              :loading="loadingPlanAccounts"
+              :options="planAccountOptions"
+              @filter="filterPlanAccounts"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.amount"
+              v-model:error="validationErrors.amount"
+              class="col-12"
+              label="Valor"
+              :error-message="validationErrors.amount"
+              :rules="[inputRules.requiredNumber, inputRules.minValue(0.01)]"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Salvar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import {
+  createTreasuryLaunch,
+  updateTreasuryLaunch,
+} from "src/api/treasury_launch";
+
+import { getFinancialPlanAccountsForSelect } from "src/api/financial_plan_account";
+import { onMounted, ref } from "vue";
+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 DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { account, launch } = defineProps({
+  account: {
+    required: true,
+    type: Object,
+  },
+  launch: {
+    default: null,
+    type: Object,
+  },
+});
+
+const { inputRules } = useInputRules();
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm(
+  {
+    amount: launch?.amount ?? 0,
+    description: launch?.description ?? "",
+    financial_plan_account_id: launch?.financial_plan_account_id ?? null,
+    transaction_type: launch?.transaction_type ?? null,
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
+  scrollFn: scrollToComponent,
+});
+
+const allPlanAccounts = ref([]);
+const loadingPlanAccounts = ref(false);
+const planAccountOptions = ref([]);
+
+const isEdit = !!launch;
+const title = `${isEdit ? "Editar" : "Nova"} movimentação — ${account.name}`;
+
+const typeOptions = [
+  { label: "Entrada", value: "entrada" },
+  { label: "Saída", value: "saida" },
+];
+
+const filterPlanAccounts = (needle, update) => {
+  update(() => {
+    const term = needle.toLowerCase();
+
+    planAccountOptions.value = term
+      ? allPlanAccounts.value.filter((option) =>
+          option.label.toLowerCase().includes(term),
+        )
+      : allPlanAccounts.value;
+  });
+};
+
+const loadPlanAccounts = async () => {
+  loadingPlanAccounts.value = true;
+
+  try {
+    const accounts = await getFinancialPlanAccountsForSelect();
+
+    allPlanAccounts.value = accounts.map((account) => ({
+      label: `${account.code} - ${account.description}`,
+      value: account.id,
+    }));
+
+    planAccountOptions.value = allPlanAccounts.value;
+  } finally {
+    loadingPlanAccounts.value = false;
+  }
+};
+
+const onOKClick = async () => {
+  const payload = {
+    account_id: account.id,
+    amount: form.amount,
+    description: form.description,
+    financial_plan_account_id: form.financial_plan_account_id,
+    transaction_type: form.transaction_type,
+  };
+
+  await submitForm(() =>
+    isEdit
+      ? updateTreasuryLaunch(launch.id, getUpdatedFields.value)
+      : createTreasuryLaunch(payload),
+  );
+};
+
+onMounted(loadPlanAccounts);
+</script>

+ 99 - 0
src/components/financial/FinancialAuthDialog.vue

@@ -0,0 +1,99 @@
+<template>
+  <q-dialog ref="dialogRef" persistent @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="min-width: 400px"
+    >
+      <DefaultDialogHeader title="Acesso ao Financeiro" @close="onCancel" />
+
+      <DefaultForm @submit.prevent="onConfirm">
+        <q-scroll-area class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-caption text-grey-6">
+              Confirme sua senha para acessar o módulo Financeiro.
+            </div>
+
+            <DefaultPasswordInput
+              ref="passwordInput"
+              v-model="password"
+              autofocus
+              class="col-12"
+              dense
+              label="Senha"
+              outlined
+              :error="hasError"
+              :error-message="errorMessage"
+              @update:model-value="hasError = false"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            :disable="loading"
+            @click="onCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Confirmar"
+            no-caps
+            type="submit"
+            unelevated
+            :disable="!password"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { confirmPassword } from "src/api/auth";
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
+const errorMessage = ref("");
+const hasError = ref(false);
+const loading = ref(false);
+const password = ref("");
+
+const onCancel = () => {
+  onDialogCancel();
+};
+
+const onConfirm = async () => {
+  if (!password.value || loading.value) return;
+
+  loading.value = true;
+  hasError.value = false;
+
+  try {
+    await confirmPassword(password.value);
+
+    onDialogOK();
+  } catch (error) {
+    hasError.value = true;
+
+    errorMessage.value =
+      error?.response?.data?.message || "A senha fornecida está incorreta.";
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

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

@@ -0,0 +1,123 @@
+<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>
+      <div class="row items-center no-wrap" style="gap: 4px">
+        <q-btn
+          v-if="editable"
+          flat
+          dense
+          round
+          size="sm"
+          icon="mdi-pencil-outline"
+          color="dark"
+          @click.stop="emit('edit')"
+        />
+        <q-icon :name="icon" size="22px" color="dark" />
+      </div>
+    </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 },
+  editable: { type: Boolean, default: false },
+});
+
+const emit = defineEmits(["click", "edit"]);
+
+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>

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

@@ -0,0 +1,100 @@
+<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>
+    </template>
+
+    <FinancialPlanAccountTreeNode
+      v-for="child in node.children"
+      :key="child.id"
+      :node="child"
+      :depth="depth + 1"
+      :force-expanded="forceExpanded"
+    />
+  </q-expansion-item>
+
+  <q-item
+    v-else
+    dense
+    class="account-tree-row account-tree-leaf"
+    :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>
+</template>
+
+<script setup>
+import { computed, ref, watch } from "vue";
+
+defineOptions({ name: "FinancialPlanAccountTreeNode" });
+
+const props = defineProps({
+  node: { type: Object, required: true },
+  depth: { type: Number, default: 0 },
+  forceExpanded: { type: Boolean, default: false },
+});
+
+const hasChildren = computed(() => props.node.children?.length > 0);
+const expanded = ref(props.depth === 0);
+
+watch(
+  () => props.forceExpanded,
+  (value) => {
+    if (value) expanded.value = true;
+  },
+  { immediate: true },
+);
+</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-tree-leaf) {
+  padding-right: 56px;
+}
+
+:deep(.account-code) {
+  flex: 0 0 18%;
+  min-width: 80px;
+}
+
+:deep(.account-name) {
+  flex: 1 1 auto;
+  min-width: 0;
+}
+
+:deep(.account-type) {
+  flex: 0 0 18%;
+  min-width: 90px;
+  align-items: flex-start;
+}
+
+@media (max-width: 599px) {
+  :deep(.account-tree-row) {
+    padding-left: calc(8px + (var(--account-depth) * 16px));
+  }
+
+  :deep(.account-code),
+  :deep(.account-type) {
+    flex-basis: 25%;
+    min-width: 64px;
+  }
+}
+</style>

+ 73 - 0
src/components/financial/FranchisePerformanceCard.vue

@@ -0,0 +1,73 @@
+<template>
+  <q-card class="franchise-performance-card">
+    <div class="row justify-between items-center no-wrap">
+      <span class="text-subtitle1 text-dark">Desempenho por Franquia</span>
+      <q-btn color="primary" label="Exportar Relatório" icon="mdi-download" unelevated no-caps />
+    </div>
+
+    <q-table
+      flat
+      dense
+      :rows="rows"
+      :columns="columns"
+      :loading="loading"
+      row-key="id"
+      :pagination="{ rowsPerPage: 0 }"
+      hide-pagination
+      virtual-scroll
+      class="q-mt-md"
+      style="max-height: 280px"
+    >
+      <template #body-cell-revenue="{ row }">
+        <q-td align="left">{{ formatCurrency(row.revenue) }}</q-td>
+      </template>
+
+      <template #body-cell-expense="{ row }">
+        <q-td align="left">{{ formatCurrency(row.expense) }}</q-td>
+      </template>
+
+      <template #body-cell-performance="{ row }">
+        <q-td align="left">
+          <span :class="row.performance >= 0 ? 'text-positive' : 'text-negative'">
+            {{ row.performance }}%
+          </span>
+        </q-td>
+      </template>
+
+      <template #no-data>
+        <div class="full-width text-center text-caption text-foreground q-py-md">
+          Nenhum dado encontrado
+        </div>
+      </template>
+    </q-table>
+  </q-card>
+</template>
+
+<script setup>
+defineProps({
+  rows: { type: Array, default: () => [] },
+  loading: { type: Boolean, default: false },
+});
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(value ?? 0);
+
+const columns = [
+  { name: "franchise", label: "Franquia", field: "franchise", align: "left" },
+  { name: "revenue", label: "Faturamento", field: "revenue", align: "left" },
+  { name: "expense", label: "Despesa", field: "expense", align: "left" },
+  { name: "performance", label: "Desempenho", field: "performance", align: "left" },
+];
+</script>
+
+<style scoped lang="scss">
+.franchise-performance-card {
+  flex: 1 1 0;
+  min-width: 220px;
+  height: 380px;
+  border-radius: 12px;
+  box-shadow: 0 0 0 1px #c0c0c0c0 !important;
+  padding: 16px 20px;
+  overflow: hidden;
+}
+</style>

+ 98 - 0
src/components/financial/LastTransactionsCard.vue

@@ -0,0 +1,98 @@
+<template>
+  <q-card class="last-transactions-card">
+    <div class="row justify-between items-center no-wrap">
+      <span class="text-subtitle1 text-dark">Últimas Movimentações</span>
+      <q-btn color="primary" label="Exportar Relatório" icon="mdi-download" unelevated no-caps />
+    </div>
+
+    <q-table
+      flat
+      dense
+      :rows="rows"
+      :columns="columns"
+      :loading="loading"
+      row-key="id"
+      :pagination="{ rowsPerPage: 0 }"
+      hide-pagination
+      virtual-scroll
+      class="transactions-table q-mt-md"
+      style="max-height: 280px"
+    >
+      <template #body-cell-value="{ row }">
+        <q-td align="left">{{ formatCurrency(row.value) }}</q-td>
+      </template>
+
+      <template #body-cell-status="{ row }">
+        <q-td align="left">
+          <q-badge
+            :color="statusColor(row.status)"
+            :label="row.status"
+            style="padding: 4px 8px"
+          />
+        </q-td>
+      </template>
+
+      <template #no-data>
+        <div class="full-width text-center text-caption text-foreground q-py-md">
+          Nenhuma movimentação encontrada
+        </div>
+      </template>
+    </q-table>
+  </q-card>
+</template>
+
+<script setup>
+defineProps({
+  rows: { type: Array, default: () => [] },
+  loading: { type: Boolean, default: false },
+});
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(value ?? 0);
+
+const columns = [
+  {
+    name: "description",
+    label: "Descrição",
+    field: "description",
+    align: "left",
+  },
+  {
+    name: "value",
+    label: "Valor",
+    field: "value",
+    align: "left",
+  },
+  {
+    name: "status",
+    label: "Status",
+    field: "status",
+    align: "left",
+  },
+];
+
+const statusColor = (status) => {
+  const map = {
+    Pago: "positive",
+    Pendente: "warning",
+    Cancelado: "negative",
+  };
+  return map[status] ?? "grey";
+};
+</script>
+
+<style scoped lang="scss">
+.last-transactions-card {
+  flex: 1 1 0;
+  min-width: 220px;
+  height: 380px;
+  border-radius: 12px;
+  box-shadow: 0 0 0 1px #c0c0c0c0 !important;
+  padding: 16px 20px;
+  overflow: hidden;
+}
+
+.transactions-table {
+  border-radius: 8px;
+}
+</style>

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

@@ -0,0 +1,110 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 420px; max-width: 90vw"
+    >
+      <DefaultDialogHeader title="Marcar como pago" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit.prevent="onSubmit">
+        <q-scroll-area class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-body2">
+              {{ receivable.history }}
+            </div>
+
+            <DefaultInputDatePicker
+              v-model="form.payment_date"
+              v-model:untreated-date="form.payment_date_iso"
+              v-model:error="validationErrors.payment_date"
+              class="col-12"
+              label="Data do pagamento"
+              lazy-rules="ondemand"
+              :error-message="validationErrors.payment_date"
+              :rules="[inputRules.required]"
+            />
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Confirmar"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { settleFranchiseeReceivable } from "src/api/franchisee_account_receive";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { receivable } = defineProps({
+  receivable: {
+    required: true,
+    type: Object,
+  },
+});
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+
+const today = new Date();
+
+const paymentDateIso = [
+  today.getFullYear(),
+  String(today.getMonth() + 1).padStart(2, "0"),
+  String(today.getDate()).padStart(2, "0"),
+].join("-");
+
+const { form } = useForm({
+  payment_date: today.toLocaleDateString("pt-BR"),
+  payment_date_iso: paymentDateIso,
+});
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
+});
+
+const onSubmit = () =>
+  submitForm(() =>
+    settleFranchiseeReceivable(receivable.id, {
+      payment_date: form.payment_date_iso,
+    }),
+  );
+</script>

+ 153 - 0
src/components/financial/SettleCompanyPayableDialog.vue

@@ -0,0 +1,153 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      style="width: 460px; max-width: 90vw"
+    >
+      <DefaultDialogHeader title="Dar baixa" @close="onDialogCancel" />
+
+      <DefaultForm ref="formRef" @submit.prevent="onSubmit">
+        <q-scroll-area class="form-dialog-scroll-area">
+          <q-card-section class="row q-col-gutter-md q-pt-none">
+            <div class="col-12 text-body2 text-grey-8">
+              {{ payable.history }}
+            </div>
+
+            <DefaultInputDatePicker
+              v-model="form.payment_date"
+              v-model:untreated-date="form.payment_date_iso"
+              v-model:error="validationErrors.payment_date"
+              class="col-12"
+              label="Data do pagamento"
+              lazy-rules="ondemand"
+              :error-message="validationErrors.payment_date"
+              :rules="[inputRules.required]"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.discount"
+              v-model:error="validationErrors.discount"
+              class="col-12 col-sm-6"
+              label="Desconto"
+              :error-message="validationErrors.discount"
+            />
+
+            <DefaultCurrencyInput
+              v-model="form.fine"
+              v-model:error="validationErrors.fine"
+              class="col-12 col-sm-6"
+              label="Multa/Juros"
+              :error-message="validationErrors.fine"
+            />
+
+            <div class="col-12 text-caption text-grey-7">
+              Valor da conta: {{ formatCurrency(payable.value) }} ·
+
+              <span class="text-weight-medium">
+                Pago: {{ formatCurrency(netValue) }}
+              </span>
+            </div>
+          </q-card-section>
+        </q-scroll-area>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Cancelar"
+            no-caps
+            outline
+            @click="onDialogCancel"
+          />
+
+          <q-btn
+            color="primary"
+            label="Confirmar baixa"
+            no-caps
+            type="submit"
+            unelevated
+            :loading="loading"
+          />
+        </q-card-actions>
+      </DefaultForm>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { settleCompanyPayable } from "src/api/company_payable";
+import { useDialogPluginComponent } from "quasar";
+import { useForm } from "src/composables/useForm";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+
+import DefaultCurrencyInput from "src/components/defaults/DefaultCurrencyInput.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { payable } = defineProps({
+  payable: {
+    required: true,
+    type: Object,
+  },
+});
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+
+const today = new Date();
+
+const paymentDateIso = [
+  today.getFullYear(),
+  String(today.getMonth() + 1).padStart(2, "0"),
+  String(today.getDate()).padStart(2, "0"),
+].join("-");
+
+const initialForm = {
+  discount: Number(payable.discount ?? 0),
+  fine: Number(payable.fine ?? 0),
+  payment_date: today.toLocaleDateString("pt-BR"),
+  payment_date_iso: paymentDateIso,
+};
+
+const { form, getUpdatedFields } = useForm(initialForm);
+
+const {
+  loading,
+  validationErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  formRef,
+  onSuccess: (response) => onDialogOK(response),
+});
+
+const netValue = computed(
+  () =>
+    Number(payable.value ?? 0) -
+    Number(form.discount ?? 0) +
+    Number(form.fine ?? 0),
+);
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", {
+    currency: "BRL",
+    style: "currency",
+  }).format(Number(value ?? 0));
+
+const onSubmit = async () => {
+  await submitForm(() =>
+    settleCompanyPayable(payable.id, {
+      ...getUpdatedFields.value,
+      payment_date: form.payment_date_iso,
+    }),
+  );
+};
+</script>

+ 37 - 0
src/components/financial/TreasuryCard.vue

@@ -0,0 +1,37 @@
+<template>
+  <q-card class="treasury-card">
+    <div class="row justify-between items-start no-wrap">
+      <span class="text-subtitle1 text-dark">Minha Tesouraria</span>
+      <q-icon name="mdi-briefcase-outline" size="22px" color="dark" />
+    </div>
+
+    <div class="column q-gutter-sm q-mt-md">
+      <q-btn
+        outline
+        color="primary"
+        label="Acessar Contas"
+        :to="{ name: 'TreasuryPage' }"
+        no-caps
+      />
+      <q-btn
+        outline
+        color="primary"
+        label="Emissão Fiscal"
+        :to="{ name: 'InvoiceIssuancePage' }"
+        no-caps
+      />
+    </div>
+  </q-card>
+</template>
+
+<style scoped lang="scss">
+.treasury-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;
+}
+</style>

+ 321 - 28
src/components/layout/DefaultHeaderPage.vue

@@ -33,11 +33,17 @@
           <q-skeleton type="text" height="40px" />
         </div>
         <q-icon
+          v-if="showFilterIcon"
           name="mdi-filter-outline"
-          color="background"
+          :color="filterOpen ? 'background' : 'primary'"
           size="sm"
-          class="q-ml-sm bg-primary"
-          style="border-radius: 8px; padding: 2px"
+          class="q-ml-sm cursor-pointer"
+          :class="filterOpen ? 'bg-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>
@@ -46,26 +52,15 @@
         :class="$q.screen.lt.sm ? '' : 'q-pt-md'"
       >
         <div class="flex items-center no-wrap" style="gap: 12px">
-          <q-select
-            v-if="$q.screen.gt.xs"
-            v-model="selectedUnit"
-            dense
-            :options="[]"
-            label="Unidade"
-            style="width: 250px; flex-shrink: 0"
-            color="secondary"
-            hide-dropdown-icon
-          >
-            <template #append>
-              <q-icon name="mdi-map-marker-outline" color="secondary" />
-            </template>
-          </q-select>
-
           <div
             class="flex items-center no-wrap q-gutter-x-md q-px-sm q-ml-md"
             style="flex-shrink: 0"
           >
-            <q-img src="icons/user-icon.jpg" class="avatar-circle" />
+            <q-img
+              :src="user?.avatar_url || 'icons/user-icon.jpg'"
+              class="avatar-circle"
+            />
+
 
             <div
               v-if="$q.screen.gt.xs"
@@ -100,9 +95,162 @@
             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"
+                self="top right"
+                :offset="[0, 8]"
+                class="header-menu"
+                @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
+                      flat
+                      dense
+                      no-caps
+                      size="sm"
+                      color="secondary"
+                      label="Marcar tudo como lido"
+                      :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
+                      clickable
+                      :class="!n.is_read ? 'bg-grey-2' : ''"
+                      @click="openNotification(n)"
+                    >
+                      <q-item-section avatar top>
+                        <q-icon
+                          :name="iconFor(n.notification_type)"
+                          :color="n.is_read ? 'grey-5' : 'secondary'"
+                          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"
+                self="top right"
+                :offset="[0, 8]"
+                class="header-menu"
+              >
+                <div class="profile-panel q-pa-md">
+                  <div class="row items-center no-wrap q-gutter-x-md">
+                    <q-img
+                      :src="user?.avatar_url || 'icons/user-icon.jpg'"
+                      class="avatar-circle"
+                    />
+                    <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
+                    flat
+                    no-caps
+                    align="left"
+                    class="full-width"
+                    color="primary"
+                    icon="mdi-account-edit-outline"
+                    label="Editar"
+                    @click="goToEditProfile"
+                  />
+                </div>
+              </q-menu>
+            </q-btn>
+
+            <!-- Configurações -->
+            <q-btn flat round dense icon="mdi-cog-outline" color="secondary">
+              <q-menu
+                anchor="bottom right"
+                self="top right"
+                :offset="[0, 8]"
+                class="header-menu"
+              >
+                <div class="settings-panel q-pa-md">
+                  <div class="column q-mb-md">
+                    <span class="text-caption text-grey-6">Unidade</span>
+                    <span class="text-body2 text-primary text-weight-medium">
+                      {{ unitLabel }}
+                    </span>
+                  </div>
+                  <q-separator class="q-mb-md" />
+                  <q-btn
+                    v-close-popup
+                    flat
+                    no-caps
+                    align="left"
+                    class="full-width"
+                    color="negative"
+                    icon="mdi-logout"
+                    label="Sair"
+                    @click="logoutFn"
+                  />
+                </div>
+              </q-menu>
+            </q-btn>
           </div>
         </div>
         <slot name="after" />
@@ -113,12 +261,21 @@
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
-import { useRoute } from "vue-router";
+import { computed, ref, onMounted, onBeforeUnmount } from "vue";
+import { useRoute, useRouter } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { userStore } from "src/stores/user";
+import { useAuth } from "src/composables/useAuth";
+import { formatUnitName } from "src/helpers/utils";
+import {
+  getMyNotifications,
+  getUnreadCount,
+  markNotificationRead,
+  markAllNotificationsRead,
+} from "src/api/notification";
+import { socket, joinRoom, leaveRoom } from "src/boot/socket.io";
 
-const { title, breadcrumbs } = defineProps({
+const { title, breadcrumbs, filterOpen, showFilterIcon } = defineProps({
   title: {
     type: String,
     default: null,
@@ -127,25 +284,146 @@ const { title, breadcrumbs } = defineProps({
     type: Object,
     default: null,
   },
+  filterOpen: {
+    type: Boolean,
+    default: false,
+  },
+  showFilterIcon: {
+    type: Boolean,
+    default: true,
+  },
 });
 
 defineEmits(["show-filter"]);
 
 const store = userStore();
+const router = useRouter();
+const { logout } = useAuth();
 const user = computed(() => store.user);
-const selectedUnit = ref(null);
+
+// Unidade exibida em Configurações. Sem unidade vinculada = matriz.
+const unitLabel = computed(() => {
+  const units = store.user?.units ?? [];
+  if (!units.length) return "Franqueadora / Matriz";
+  return units.map((unit) => formatUnitName(unit)).join(", ");
+});
+
+// ------------------- Notificações (sino) -------------------
+const notifications = ref([]);
+const unreadCount = ref(0);
+
+// Ícone por tipo de notificação (fallback genérico).
+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 iconFor = (type) => NOTIFICATION_ICONS[type] || "mdi-bell-outline";
+
+const loadUnreadCount = async () => {
+  try {
+    unreadCount.value = await getUnreadCount();
+  } catch (e) {
+    console.error("Falha ao carregar contador de notificações:", e);
+  }
+};
+
+const loadNotifications = async () => {
+  try {
+    notifications.value = await getMyNotifications();
+  } catch (e) {
+    console.error("Falha ao carregar 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 formatNotificationDate = (raw) => {
+  if (!raw) return "";
+  // Backend envia "Y-m-d H:i:s" (horário local do servidor / America/Sao_Paulo).
+  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);
+};
+
+// Realtime: entra na sala do usuário e atualiza ao receber evento (best-effort).
+const userRoom = computed(() =>
+  user.value?.id ? `user.${user.value.id}` : null,
+);
+
+const onRealtimeNotification = () => {
+  loadUnreadCount();
+  loadNotifications();
+};
+
+onMounted(() => {
+  loadUnreadCount();
+  if (userRoom.value) {
+    joinRoom(userRoom.value);
+    socket.on("notification", onRealtimeNotification);
+  }
+});
+
+onBeforeUnmount(() => {
+  if (userRoom.value) {
+    socket.off("notification", onRealtimeNotification);
+    leaveRoom(userRoom.value);
+  }
+});
+
+const goToEditProfile = () => {
+  if (!user.value?.id) return;
+  router.push({ name: "UserEditPage", params: { id: user.value.id } });
+};
+
+const logoutFn = async () => {
+  await logout();
+  router.push({ name: "LoginPage" });
+};
 
 const lastLoginFormatted = computed(() => {
   const raw = store.user?.last_login_at;
   if (!raw) return null;
-  const d = new Date(raw.replace(" ", "T") + "Z");
+  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/Sao_Paulo",
+    timeZone: "America/Fortaleza",
   }).format(d);
 });
 
@@ -192,4 +470,19 @@ const displayBreadcrumbs = computed(() => {
   border-radius: 50%;
   flex-shrink: 0;
 }
+
+.header-menu {
+  border-radius: 10px;
+}
+
+.notifications-panel {
+  width: 340px;
+  max-width: 90vw;
+}
+
+.profile-panel,
+.settings-panel {
+  width: 260px;
+  max-width: 90vw;
+}
 </style>

+ 273 - 170
src/components/layout/LeftMenuLayout.vue

@@ -1,19 +1,22 @@
 <template>
   <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"
+    :model-value="true"
+    :width="214"
     class="detached-container"
+    no-swipe-close
+    no-swipe-open
+    show-if-above
+    v-bind="$attrs"
   >
     <div class="column full-height no-wrap q-drawer-container">
-      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
+      <div
+        class="overflow-hidden"
+        style="border-radius: 8px 8px 0px 0px"
+      >
         <div
           class="flex flex-center full-width q-pa-sm bg-terciary"
           style="height: 60px"
@@ -23,11 +26,16 @@
             :src="Logo"
             style="width: 138px; height: 39px"
           />
-          <q-img v-else :src="MiniLogo" style="width: 59px; height: 50px" />
+
+          <q-img
+            v-else
+            :src="MiniLogo"
+            style="width: 59px; height: 50px"
+          />
         </div>
       </div>
 
-      <div class="column full-height no-wrap">
+      <div class="column no-wrap menu-content">
         <div
           v-if="!$q.screen.lt.md"
           class="toggle-button-wrapper absolute"
@@ -35,167 +43,229 @@
         >
           <div @click="miniState = !miniState">
             <q-icon
+              color="secondary"
               size="sm"
               :name="
                 miniState
                   ? 'mdi-page-layout-sidebar-right'
                   : 'mdi-page-layout-sidebar-left'
               "
-              color="secondary"
             />
+
             <q-tooltip
               anchor="center right"
               self="center left"
               :offset="[10, 10]"
-              >{{
+            >
+              {{
                 miniState
                   ? $t("ui.navigation.expand_menu")
                   : $t("ui.navigation.collapse_menu")
-              }}</q-tooltip
-            >
+              }}
+            </q-tooltip>
           </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="dark"
-                  />
-                </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-area">
+          <q-list class="column no-wrap">
+            <template
+              v-for="(item, index) in navigation_store.navigationItems"
+              :key="item.name ?? item.title"
+            >
+              <template v-if="item.permission">
+                <q-item
+                  v-if="item.type === 'single'"
+                  v-ripple
+                  active-class="menu-selected"
+                  clickable
+                  exact-active-class="menu-selected"
+                  :exact="item.name == 'HomePage'"
+                  :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
+                      color="dark"
+                      style="font-size: 20px"
+                      :name="item.icon"
+                    />
+                  </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-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"
-                      >
+                    {{ $t(item.title) }}
+                  </q-tooltip>
+                </q-item>
+
+                <div v-else>
+                  <template v-if="!miniState">
+                    <q-expansion-item
+                      v-model="isExpasionItemExpanded[index]"
+                      active-class=""
+                      exact-active-class=""
+                      :class="{
+                        'menu-selected':
+                          childrenAreActive(item.childrens, item.name) &&
+                          !isExpasionItemExpanded[index],
+                      }"
+                      :to="item.name ? { name: item.name } : undefined"
+                    >
+                      <template #header>
                         <q-item-section avatar>
-                          <q-icon :name="child.icon" style="font-size: 20px" />
+                          <q-icon
+                            color="dark"
+                            style="font-size: 20px"
+                            :name="item.icon"
+                          />
                         </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),
-                    }"
-                  >
-                    <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-tooltip
-                      v-if="miniState"
-                      anchor="center right"
-                      self="center left"
-                      :offset="[10, 10]"
-                      >{{ $t(item.title) }}</q-tooltip
-                    >
-                    <q-menu
-                      class="menu-drawer"
-                      anchor="center right"
-                      self="top start"
-                    >
-                      <q-list>
+
+                        <q-item-section>
+                          {{ $t(item.title) }}
+                        </q-item-section>
+                      </template>
+
+                      <div
+                        v-for="child in item.childrens"
+                        :key="child.name"
+                      >
                         <q-item
-                          v-for="child in item.childrens"
-                          :key="child.name"
                           v-ripple
-                          v-close-popup
+                          class="q-pl-lg"
                           clickable
-                          :to="{ name: child.name }"
                           exact
                           exact-active-class="menu-selected"
-                          class="menu-drawer"
+                          :to="{ name: child.name }"
                         >
                           <q-item-section avatar>
                             <q-icon
-                              :name="child.icon"
+                              color="dark"
                               style="font-size: 20px"
+                              :name="child.icon"
                             />
                           </q-item-section>
-                          <q-item-section>{{ $t(child.title) }}</q-item-section>
+
+                          <q-item-section>
+                            {{ $t(child.title) }}
+                          </q-item-section>
                         </q-item>
-                      </q-list>
-                    </q-menu>
-                  </q-item>
-                </template>
-              </div>
+                      </div>
+                    </q-expansion-item>
+                  </template>
+
+                  <template v-else>
+                    <q-item
+                      v-ripple
+                      clickable
+                      :class="{
+                        'menu-selected': childrenAreActive(
+                          item.childrens,
+                          item.name,
+                        ),
+                      }"
+                    >
+                      <q-item-section avatar>
+                        <q-icon
+                          color="dark"
+                          style="font-size: 20px"
+                          :name="item.icon"
+                        />
+                      </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-menu
+                        anchor="center right"
+                        class="menu-drawer"
+                        self="top start"
+                      >
+                        <q-list>
+                          <template v-if="item.name">
+                            <q-item
+                              v-ripple
+                              v-close-popup
+                              class="menu-drawer"
+                              clickable
+                              exact
+                              exact-active-class="menu-selected"
+                              :to="{ name: item.name }"
+                            >
+                              <q-item-section avatar>
+                                <q-icon
+                                  color="dark"
+                                  style="font-size: 20px"
+                                  :name="item.icon"
+                                />
+                              </q-item-section>
+
+                              <q-item-section>
+                                {{ $t(item.title) }}
+                              </q-item-section>
+                            </q-item>
+
+                            <q-separator />
+                          </template>
+
+                          <q-item
+                            v-for="child in item.childrens"
+                            :key="child.name"
+                            v-ripple
+                            v-close-popup
+                            class="menu-drawer"
+                            clickable
+                            exact
+                            exact-active-class="menu-selected"
+                            :to="{ name: child.name }"
+                          >
+                            <q-item-section avatar>
+                              <q-icon
+                                color="dark"
+                                style="font-size: 20px"
+                                :name="child.icon"
+                              />
+                            </q-item-section>
+
+                            <q-item-section>
+                              {{ $t(child.title) }}
+                            </q-item-section>
+                          </q-item>
+                        </q-list>
+                      </q-menu>
+                    </q-item>
+                  </template>
+                </div>
+              </template>
             </template>
-          </template>
-        </q-list>
-        <q-list class="column q-mb-md no-wrap" style="border-radius: 6px">
-        </q-list>
+          </q-list>
+        </q-scroll-area>
+
+        <q-list class="column q-mb-md no-wrap" style="border-radius: 6px" />
+
         <q-list class="q-mt-auto">
           <q-item v-ripple clickable @click="logoutFn">
             <div class="flex q-mx-auto">
               <q-item-section avatar>
                 <div class="flex q-mx-auto q-gutter-x-xs">
                   <q-icon
-                    name="logout"
                     color="negative"
+                    name="logout"
                     style="font-size: 20px"
                   />
 
@@ -203,17 +273,25 @@
                 </div>
               </q-item-section>
             </div>
+
             <q-tooltip
               v-if="miniState"
               anchor="center right"
               self="center left"
               :offset="[10, 10]"
-              >{{ $t("auth.logout") }}</q-tooltip
             >
+              {{ $t("auth.logout") }}
+            </q-tooltip>
           </q-item>
-          <q-item v-ripple clickable @click="openUrl('https://softpar.inf.br')">
+
+          <q-item
+            v-ripple
+            clickable
+            @click="openUrl('https://softpar.inf.br')"
+          >
             <div class="flex full-width justify-center">
               <q-img
+                style="width: 100%; height: 30px"
                 :src="
                   miniState || $q.screen.lt.md
                     ? LogoSoftparMini
@@ -221,7 +299,6 @@
                       ? LogoSoftparLight
                       : LogoSoftparDark
                 "
-                style="width: 100%; height: 30px"
                 :style="
                   miniState || $q.screen.lt.md
                     ? 'max-width: 48px'
@@ -231,62 +308,69 @@
             </div>
           </q-item>
         </q-list>
+
         <div
           class="full-width text-center q-pb-xs cursor-pointer text-dark"
-          @click="gotoVersionPage()"
+          @click="gotoVersionPage"
         >
-          <span v-if="!(miniState || $q.screen.lt.md)" class="text-caption">{{
-            $t("common.terms.version") + " " + version
-          }}</span>
-          <span v-else class="text-caption">{{ version }}</span>
+          <span
+            v-if="!(miniState || $q.screen.lt.md)"
+            class="text-caption"
+          >
+            {{ $t("common.terms.version") + " " + version }}
+          </span>
+
+          <span v-else class="text-caption">
+            {{ version }}
+          </span>
         </div>
       </div>
     </div>
   </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 { Cookies, useQuasar } from "quasar";
 import { navigationStore } from "src/stores/navigation";
-import { useQuasar, Cookies } from "quasar";
+import { onMounted, ref, watch, watchEffect } from "vue";
+import { useAuth } from "src/composables/useAuth";
+
+import { useRoute, useRouter } from "vue-router";
+
 import { version } from "src/../package.json";
 
 import Logo from "src/assets/images/logo.svg";
-import MiniLogo from "src/assets/images/mini-logo.svg";
-import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
+import LogoSoftparLight from "src/assets/softpar_logo_light.svg";
 import LogoSoftparMini from "src/assets/softpar_logo_mini.svg";
+import MiniLogo from "src/assets/images/mini-logo.svg";
 
 const { logout } = useAuth();
-const router = useRouter();
+const $q = useQuasar();
 const route = useRoute();
+const router = useRouter();
+
 const navigation_store = navigationStore();
-const $q = useQuasar();
+
+const isExpasionItemExpanded = ref([]);
 
 const miniStateCookies = Cookies.get("miniState");
-const miniState = ref(miniStateCookies === "true" ? true : false);
+const miniState = ref(miniStateCookies === "true");
+
+const childrenAreActive = (children, parentName = null) => {
+  if (parentName && route?.name === parentName) return true;
+  if (!Array.isArray(children) || children.length === 0) return false;
 
-const childrenAreActive = (children) => {
-  if (!Array.isArray(children) || children.length === 0) {
-    return false;
-  }
   return children.some((child) => route?.name === child?.name);
 };
 
-const isExpasionItemExpanded = ref([]);
-
-watchEffect(() => {
-  if ($q.screen.lt.md) {
-    miniState.value = true;
-    isExpasionItemExpanded.value.forEach((expansion, index) => {
-      isExpasionItemExpanded.value[index] = false;
-    });
-  }
-});
+const gotoVersionPage = () => {
+  router.push({ name: "SystemVersionsPage" });
+};
 
 const logoutFn = async () => {
   await logout();
+
   router.push({ name: "LoginPage" });
 };
 
@@ -294,17 +378,26 @@ const openUrl = (url) => {
   window.open(url, "_blank");
 };
 
-const gotoVersionPage = () => {
-  router.push({ name: "SystemVersionsPage" });
-};
-
 watch(miniState, () => {
-  Cookies.set("miniState", miniState.value, { path: "/", sameSite: "Lax" });
+  Cookies.set("miniState", miniState.value, {
+    path: "/",
+    sameSite: "Lax",
+  });
+});
+
+watchEffect(() => {
+  if ($q.screen.lt.md) {
+    miniState.value = true;
+
+    isExpasionItemExpanded.value.forEach((expansion, index) => {
+      isExpasionItemExpanded.value[index] = false;
+    });
+  }
 });
 
 onMounted(() => {
   navigation_store.navigationItems.forEach((item, index) => {
-    if (childrenAreActive(item.childrens)) {
+    if (childrenAreActive(item.childrens, item.name)) {
       isExpasionItemExpanded.value[index] = true;
     }
   });
@@ -314,11 +407,26 @@ onMounted(() => {
 <style lang="scss" scoped>
 @import "/src/css/quasar.variables.scss";
 
+.menu-content {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
 .menu-selected {
   background-color: $secondary;
   color: $dark;
 }
 
+.menu-scroll-area {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+.q-drawer-container {
+  background-image: url("/images/background-opacity.png");
+  background-position: 15%;
+}
+
 .toggle-button-wrapper {
   transition: transform 0.3s ease;
 }
@@ -326,9 +434,4 @@ onMounted(() => {
 .toggle-button-wrapper:hover {
   transform: scale(1.1);
 }
-
-.q-drawer-container {
-  background-image: url("images/background-opacity.png");
-  background-position: 15%;
-}
-</style>
+</style>

+ 121 - 72
src/components/layout/LeftMenuLayoutMobile.vue

@@ -3,79 +3,111 @@
   <q-drawer
     v-bind="$attrs"
     v-model="leftDrawerOpen"
-    :width="214"
-    :breakpoint="500"
     behavior="mobile"
     class="detached-container"
+    :breakpoint="500"
+    :width="214"
   >
     <div class="column full-height no-wrap">
-      <div class="overflow-hidden" style="border-radius: 8px 8px 0px 0px">
+      <div
+        class="overflow-hidden"
+        style="border-radius: 8px 8px 0px 0px"
+      >
         <div
           class="flex flex-center full-width q-pa-sm"
           style="height: 50px"
         >
-          <q-img :src="Logo" style="max-width: 92px" />
+          <q-img style="max-width: 92px" :src="Logo" />
         </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>
+      <div class="column no-wrap menu-content">
+        <q-scroll-area class="menu-scroll-area">
+          <q-list class="column no-wrap">
+            <template
+              v-for="item in navigation_store.navigationItems"
+              :key="item.name ?? item.icon"
+            >
+              <template v-if="item.permission">
+                <q-item
+                  v-if="item.type === 'single'"
+                  v-ripple
+                  active-class="menu-selected"
+                  clickable
+                  exact-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-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>
+                    {{ $t(item.title) }}
+                  </q-item-section>
+                </q-item>
+
+                <q-expansion-item
+                  v-else
+                  v-model="isExpasionItemExpanded"
+                  :class="{
+                    'menu-selected':
+                      childrenAreActive(item.childrens) &&
+                      !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"
+                  >
+                    <q-item
+                      v-ripple
+                      class="q-pl-lg"
+                      clickable
+                      exact
+                      exact-active-class="menu-selected"
+                      :to="{ name: child.name }"
+                    >
+                      <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')">
+          <q-item
+            v-ripple
+            clickable
+            @click="openUrl('https://softpar.inf.br')"
+          >
             <div class="flex full-width justify-center">
               <q-img
                 :src="LogoSoftparDark"
@@ -84,13 +116,14 @@
             </div>
           </q-item>
         </q-list>
+
         <div
           class="full-width text-center text-subtitle3 cursor-pointer"
-          @click="gotoVersionPage()"
+          @click="gotoVersionPage"
         >
-          <span class="text-caption text-weight-light">{{
-            $t("common.terms.version") + " " + version
-          }}</span>
+          <span class="text-caption text-weight-light">
+            {{ $t("common.terms.version") + " " + version }}
+          </span>
         </div>
       </div>
     </div>
@@ -98,36 +131,41 @@
 </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";
 import LogoSoftparDark from "src/assets/softpar_logo_dark.svg";
 
-const router = useRouter();
-const route = useRoute();
+const leftDrawerOpen = defineModel({
+  type: Boolean,
+});
+
 const navigation_store = navigationStore();
 
-const leftDrawerOpen = defineModel({ type: Boolean });
+const route = useRoute();
+const router = useRouter();
+
 const isExpasionItemExpanded = ref([]);
 
 const childrenAreActive = (children) => {
   if (!Array.isArray(children) || children.length === 0) {
     return false;
   }
-  return children.some((child) => route?.name === child?.name);
-};
 
-const openUrl = (url) => {
-  window.open(url, "_blank");
+  return children.some((child) => route?.name === child?.name);
 };
 
 const gotoVersionPage = () => {
   router.push({ name: "SystemVersionsPage" });
 };
 
+const openUrl = (url) => {
+  window.open(url, "_blank");
+};
+
 onMounted(() => {
   navigation_store.navigationItems.forEach((item, index) => {
     if (childrenAreActive(item.childrens)) {
@@ -139,13 +177,24 @@ onMounted(() => {
 
 <style lang="scss" scoped>
 @import "/src/css/quasar.variables.scss";
-.text-subtitle3 {
-  font-size: 1.1rem !important;
-  font-weight: 400 !important;
+
+.menu-content {
+  flex: 1 1 auto;
+  min-height: 0;
 }
 
 .menu-selected {
   background-color: rgba($primary, 0.1);
   color: $primary;
 }
-</style>
+
+.menu-scroll-area {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+.text-subtitle3 {
+  font-size: 1.1rem !important;
+  font-weight: 400 !important;
+}
+</style>

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

@@ -0,0 +1,68 @@
+<template>
+  <DefaultSelect
+    v-model="selectedGroup"
+    v-bind="$attrs"
+    use-input
+    hide-selected
+    fill-input
+    clearable
+    :options="filteredOptions"
+    :loading="isLoading"
+    :label
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          Nenhum grupo encontrado
+        </q-item-section>
+      </q-item>
+    </template>
+  </DefaultSelect>
+</template>
+
+<script setup>
+import { onMounted, ref } from "vue";
+import { getGroupsForSelect } from "src/api/group";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+
+const { label } = defineProps({
+  label: { type: String, default: "Grupo" },
+});
+
+// model value: { label, value, unit_ids } | null
+const selectedGroup = defineModel({ type: Object });
+
+const groupOptions = ref([]);
+const filteredOptions = ref([]);
+const isLoading = ref(true);
+
+const filterFn = (val, update) => {
+  update(() => {
+    if (val === "") {
+      filteredOptions.value = groupOptions.value;
+    } else {
+      const needle = val.toLowerCase();
+      filteredOptions.value = groupOptions.value.filter((v) =>
+        v.label.toLowerCase().includes(needle),
+      );
+    }
+  });
+};
+
+onMounted(async () => {
+  try {
+    const response = await getGroupsForSelect();
+    groupOptions.value = response.map((g) => ({
+      label: g.name,
+      value: g.id,
+      unit_ids: g.unit_ids ?? [],
+    }));
+    filteredOptions.value = groupOptions.value;
+  } catch (error) {
+    console.error("Failed to load groups:", error);
+  } finally {
+    isLoading.value = false;
+  }
+});
+</script>

+ 88 - 0
src/components/selects/UnitMultiSelect.vue

@@ -0,0 +1,88 @@
+<template>
+  <q-select
+    v-model="selectedUnits"
+    v-bind="$attrs"
+    multiple
+    use-chips
+    use-input
+    fill-input
+    clearable
+    :options="filteredOptions"
+    :loading="isLoading"
+    :label="label"
+    :bg-color="bgColor"
+    option-value="value"
+    option-label="label"
+    @filter="filterFn"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          Nenhum registro encontrado
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { onMounted, ref, watch } from "vue";
+import { getUnitsForSelect } from "src/api/unit";
+import { formatUnitName } from "src/helpers/utils";
+
+const { label, initialIds, bgColor } = defineProps({
+  label: { type: String, default: "Unidades" },
+  initialIds: { type: Array, default: () => [] },
+  bgColor: { type: String, default: "white" },
+});
+
+const selectedUnits = defineModel({ type: Array, default: () => [] });
+const unitOptions = ref([]);
+const filteredOptions = ref([]);
+const isLoading = ref(true);
+
+const selectUnitsByIds = (ids) => {
+  if (!ids?.length) return;
+  selectedUnits.value = unitOptions.value.filter((o) => ids.includes(o.value));
+};
+
+const filterFn = (val, update) => {
+  update(() => {
+    if (val === "") {
+      filteredOptions.value = unitOptions.value;
+    } else {
+      const needle = val.toLowerCase();
+      filteredOptions.value = unitOptions.value.filter((v) =>
+        v.label.toLowerCase().includes(needle),
+      );
+    }
+  });
+};
+
+onMounted(async () => {
+  try {
+    const response = await getUnitsForSelect();
+    unitOptions.value = response.map((unit) => ({
+      label: formatUnitName(unit),
+      value: unit.id,
+    }));
+    filteredOptions.value = unitOptions.value;
+    if (initialIds?.length > 0) selectUnitsByIds(initialIds);
+  } catch (error) {
+    console.error("Failed to load units:", error);
+  } finally {
+    isLoading.value = false;
+  }
+});
+
+watch(
+  () => initialIds,
+  (newIds) => {
+    if (newIds?.length > 0 && unitOptions.value.length > 0) {
+      selectUnitsByIds(newIds);
+    }
+  },
+);
+
+defineExpose({ selectUnitsByIds });
+</script>

+ 2 - 1
src/components/selects/UnitSelect.vue

@@ -26,6 +26,7 @@
 import { onMounted, ref, watch } from "vue";
 import { getUnitsForSelect } from "src/api/unit";
 import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import { formatUnitName } from "src/helpers/utils";
 
 const { placeholder, label, initialId } = defineProps({
   placeholder: { type: String, default: "Buscar unidade" },
@@ -59,7 +60,7 @@ onMounted(async () => {
   try {
     const response = await getUnitsForSelect();
     unitOptions.value = response.map((unit) => ({
-      label: unit.fantasy_name,
+      label: formatUnitName(unit),
       value: unit.id,
     }));
     filteredOptions.value = unitOptions.value;

+ 23 - 35
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, watch } from "vue";
+import { onMounted, ref, computed, watch } 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,26 @@ const { placeholder, label, type } = defineProps({
     type: String,
     default: () => useI18n().t("common.ui.misc.type"),
   },
-  type: {
-    type: String,
-    default: null,
-  },
+  accessScope: { type: String, default: null },
+  unitId: { type: Number, default: null },
 });
 
-const selectedUserType = defineModel({
-  type: Object,
-});
+const { placeholder, label } = props;
+
+const modelValue = defineModel({ type: String, default: null });
+
 const userTypeOptions = ref([]);
 const filteredOptions = ref([]);
 const isLoading = ref(true);
 
-const selectUserByValue = (value) => {
-  selectedUserType.value = userTypeOptions.value.find(
-    (option) => option.value === value,
-  );
-};
+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(() => {
@@ -61,37 +63,23 @@ const filterFn = (val, update) => {
   });
 };
 
-onMounted(async () => {
+const loadOptions = async () => {
+  isLoading.value = true;
   try {
-    const response = await userTypes();
+    const response = await userTypes(props.accessScope, props.unitId);
     userTypeOptions.value = Object.entries(response).map(([key, value]) => ({
       label: value,
       value: key,
     }));
     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;
   }
-});
+};
 
-watch(
-  () => type,
-  (newType) => {
-    if (newType && userTypeOptions.value.length > 0) {
-      selectUserByValue(newType);
-    }
-  },
-);
+onMounted(loadOptions);
 
-defineExpose({
-  selectUserByValue,
-});
+watch(() => [props.accessScope, props.unitId], loadOptions);
 </script>

+ 48 - 56
src/components/shared/ChangeImageDialog.vue

@@ -1,34 +1,49 @@
 <template>
   <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card class="q-dialog-plugin overflow-hidden" style="min-width: 400px">
-      <DefaultDialogHeader :title="() => 'Trocar Imagem'" @close="onDialogCancel" />
+    <q-card
+      class="q-dialog-plugin overflow-hidden"
+      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
+      <q-card-section class="row q-col-gutter-md q-pt-none">
+        <div class="col-12 text-caption text-grey-6">
+          Personalizar
+        </div>
+
+        <DefaultMediaPicker
           v-model="selectedFile"
+          v-model:preview-url="previewUrl"
           accept="image/*"
-          outlined
+          class="col-12"
           dense
+          label=""
           placeholder="Buscar no Desktop"
-          @update:model-value="onFileSelected"
-        >
-          <template #append>
-            <q-icon name="search" />
-          </template>
-        </q-file>
+          preview-height="200"
+          show-preview
+          :label-outside="false"
+        />
       </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-separator />
+
+      <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+        <q-btn
+          color="negative"
+          label="Cancelar"
+          no-caps
+          outline
+          @click="onDialogCancel"
+        />
 
-      <q-card-actions align="right">
-        <q-btn outline color="primary" label="Cancelar" @click="onDialogCancel" />
-        <q-btn color="primary" label="Salvar" :disable="!selectedFile" @click="onSave" />
+        <q-btn
+          color="primary"
+          label="Salvar"
+          no-caps
+          unelevated
+          :disable="!selectedFile"
+          @click="onSave"
+        />
       </q-card-actions>
     </q-card>
   </q-dialog>
@@ -37,45 +52,22 @@
 <script setup>
 import { ref } from "vue";
 import { useDialogPluginComponent } from "quasar";
+
 import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultMediaPicker from "src/components/defaults/DefaultMediaPicker.vue";
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
 
-const selectedFile = ref(null);
 const previewUrl = ref(null);
+const selectedFile = 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 });
-}
-</script>
-
-<style scoped>
-.preview-area {
-  width: 100%;
-  height: 200px;
-  border: 1px solid #e0e0e0;
-  border-radius: 4px;
-  background-color: #fafafa;
-  overflow: hidden;
-}
-
-.preview-image {
-  max-width: 100%;
-  max-height: 100%;
-  object-fit: contain;
-}
-</style>
+const onSave = () => {
+  onDialogOK({
+    file: selectedFile.value,
+    previewUrl: previewUrl.value,
+  });
+};
+</script>

+ 11 - 1
src/composables/useAuth.js

@@ -1,12 +1,18 @@
 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;
+
+    // setUser (e não user =) para que isAdmin seja derivado do perfil.
+
+    userStore().setUser(user);
+
     userStore().accessToken = access_token;
+
     await permissionStore().fetchScopes();
   };
 
@@ -21,6 +27,7 @@ export const useAuth = () => {
       if (response.status === 200) {
         await setAuthDataFromPayload(response.data.payload);
       }
+
       return response;
     } catch (error) {
       return Promise.reject(error);
@@ -30,6 +37,7 @@ export const useAuth = () => {
   const logout = async () => {
     try {
       const response = await api.post("/logout");
+
       if (response.status === 200) {
         userStore().resetUser();
       }
@@ -41,9 +49,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;
+}

+ 279 - 44
src/composables/useInputRules.js

@@ -1,112 +1,347 @@
+import {
+  differenceInCalendarDays,
+  format,
+  isValid,
+  parse,
+} 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");
+    },
+
+    dateMaxDaysFromToday:
+      (maxDays = 365) =>
+      (value) => {
+        if (!hasValue(value)) return true;
+
+        const date = parseInputDate(value);
+
+        if (!date) return true;
+
+        return (
+          differenceInCalendarDays(date, new Date()) <= maxDays ||
+          t("validation.rules.date_max_days_from_today", { maxDays })
+        );
+      },
+
+    dateNotBefore: (getMinimumDate) => (value) => {
+      if (!hasValue(value)) return true;
+
+      const minimumValue =
+        typeof getMinimumDate === "function"
+          ? getMinimumDate()
+          : getMinimumDate;
+
+      if (!hasValue(minimumValue)) return true;
+
+      const date = parseInputDate(value);
+      const minimumDate = parseInputDate(minimumValue);
+
+      if (!date || !minimumDate) return true;
+
+      return (
+        date >= minimumDate ||
+        t("validation.rules.date_not_before", {
+          minimumDate: minimumValue,
+        })
+      );
+    },
+
+    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 })
+        );
+      },
+
+    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"];
+
+  for (const dateFormat of formats) {
+    const parsedDate = parse(value, dateFormat, new Date());
+
+    if (isValid(parsedDate) && format(parsedDate, dateFormat) === value) {
+      return parsedDate;
+    }
+  }
 
-function isValidCNPJ(cnpj) {
+  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,

+ 101 - 35
src/css/app.scss

@@ -1,6 +1,11 @@
 @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");
+
+.required-field .q-field__label::after {
+  color: var(--q-negative);
+  content: " *";
+}
 
 body {
   font-family: "Inter", sans-serif;
@@ -12,12 +17,31 @@ body {
   flex-basis: 0;
 }
 
-.round {
-  border-radius: 50%;
+// Full-height pages with fixed tabs/toolbars and a scrollable content region.
+.tab-page-layout {
+  display: flex;
+  flex-direction: column;
+  height: calc(100dvh - env(safe-area-inset-top));
+  min-height: 0;
 }
 
-.self-center {
-  align-self: center;
+.tab-page-body,
+.tab-page-flex-content {
+  display: flex;
+  flex: 1 1 auto;
+  flex-direction: column;
+  min-height: 0;
+}
+
+.tab-page-scroll-area {
+  flex: 1 1 auto;
+  min-height: 0;
+}
+
+@media (max-width: 599px) {
+  .tab-page-layout {
+    height: calc(100dvh - 68px - env(safe-area-inset-top));
+  }
 }
 
 .input-disable {
@@ -31,13 +55,6 @@ body {
   min-width: 0;
 }
 
-.q-toolbar {
-  position: relative;
-  padding: 0 12px;
-  min-height: 50px;
-  width: auto;
-}
-
 .q-drawer:has(.detached-container) {
   margin: 16px !important;
   margin-bottom: 16px !important;
@@ -47,7 +64,28 @@ body {
   color: #8b8b8b;
 }
 
+.q-toolbar {
+  position: relative;
+  padding: 0 12px;
+  min-height: 50px;
+  width: auto;
+}
+
+.round {
+  border-radius: 50%;
+}
+
+.self-center {
+  align-self: center;
+}
+
 body.body--light {
+  .q-dialog {
+    .q-card {
+      background: #{map.get($colors, "surface-dark")};
+    }
+  }
+
   .q-drawer:has(.detached-container) {
     background: #{map.get($colors, "surface")} !important;
   }
@@ -56,12 +94,6 @@ body.body--light {
     background: #{map.get($colors, "surface")};
   }
 
-  .q-dialog {
-    .q-card {
-      background: #{map.get($colors, "surface-dark")};
-    }
-  }
-
   background: #{map.get($colors, "background")} !important;
 
   .card-ring {
@@ -73,11 +105,49 @@ body.body--light {
   }
 }
 
-
 .q-card__actions .q-btn {
   padding: 10px 16px;
 }
 
+// Keep dialog headers and actions visible while only the form content scrolls.
+.q-dialog .q-card:has(.q-form) {
+  display: flex;
+  flex-direction: column;
+  max-height: calc(100vh - 48px);
+  overflow: hidden;
+
+  > .q-bar,
+  > .q-card__actions {
+    flex: 0 0 auto;
+  }
+
+  > .q-form {
+    display: flex;
+    flex: 1 1 auto;
+    flex-direction: column;
+    min-height: 0;
+    overflow: hidden;
+
+    > .form-dialog-scroll-area {
+      flex: 1 1 auto;
+      height: min(70vh, 640px);
+      min-height: 0;
+    }
+
+    > .q-card__actions,
+    > .q-separator {
+      flex: 0 0 auto;
+    }
+  }
+
+  // Supports dialogs whose form is wrapped by the scroll area.
+  > .form-dialog-scroll-area {
+    flex: 1 1 auto;
+    height: min(70vh, 640px);
+    min-height: 0;
+  }
+}
+
 input[type="number"]::-webkit-inner-spin-button,
 input[type="number"]::-webkit-outer-spin-button {
   -webkit-appearance: none;
@@ -86,17 +156,17 @@ input[type="number"]::-webkit-outer-spin-button {
   margin: 0;
 }
 
-.q-separator {
-  padding-top: 0px;
-  padding-left: 0;
-}
-
 .q-scrollarea__content {
   display: flex;
   flex-direction: column;
   flex: 1 1 auto !important;
 }
 
+.q-separator {
+  padding-top: 0px;
+  padding-left: 0;
+}
+
 .remove-header-expansion-item {
   .q-list--dense > .q-item,
   .q-item--dense {
@@ -104,15 +174,11 @@ input[type="number"]::-webkit-outer-spin-button {
   }
 }
 
-
-
-
-.q-field--standout.q-field--rounded .q-field__control {
+.q-btn--rectangle {
   border-radius: 8px;
-  
 }
 
-.q-btn--rectangle {
+.q-field--standout.q-field--rounded .q-field__control {
   border-radius: 8px;
 }
 
@@ -126,11 +192,11 @@ input[type="number"]::-webkit-outer-spin-button {
   }
 }
 
-.q-field--standout.q-field--highlighted .q-field__native {
-  color: $secondary;
-}
-
 .q-field--standout.q-field--highlighted .q-field__control {
   box-shadow: none;
   border-radius: 8px;
-}
+}
+
+.q-field--standout.q-field--highlighted .q-field__native {
+  color: $secondary;
+}

+ 14 - 0
src/helpers/utils.js

@@ -107,6 +107,19 @@ const formatPercentage = (value) => {
   return `${(value * 100).toFixed(0)}%`;
 };
 
+const normalizeUnitName = (value) => {
+  const name = String(value ?? "").trim();
+  return name && !["null", "undefined"].includes(name.toLowerCase())
+    ? name
+    : null;
+};
+
+const formatUnitName = (unit, fallback = "—") =>
+  normalizeUnitName(unit?.name) ||
+  normalizeUnitName(unit?.fantasy_name) ||
+  normalizeUnitName(unit?.social_reason) ||
+  fallback;
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -115,4 +128,5 @@ export {
   formatToBRLCurrency,
   normalizeString,
   formatPercentage,
+  formatUnitName,
 };

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

@@ -173,14 +173,23 @@
   },
   "validation": {
     "rules": {
+      "after_time": "The end time must be later than the start time",
       "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",
+      "duration_hours": "The duration must be between 1 minute and 24 hours",
+      "integer": "The value must be an integer",
+      "integer_min": "The value must be an integer greater than or equal to {min}",
+      "integer_range": "The value must be an integer between {min} and {max}",
       "min": "This field must have at least",
       "max": "This field must have at most",
       "characters": "characters",
+      "one_of": "Select a valid option",
       "password": "Password must have at least 6 characters, one uppercase letter, one lowercase letter and one number",
+      "positive": "The value must be greater than zero",
       "same_password": "Passwords must match",
+      "slug": "Use only uppercase letters, numbers, and underscores",
+      "time": "This field must be a valid time",
       "not_same_document": "The document must be unique for each participant",
       "cpf": "This field must be a valid CPF",
       "cnpj": "This field must be a valid CNPJ",

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

@@ -173,14 +173,23 @@
   },
   "validation": {
     "rules": {
+      "after_time": "La hora de finalización debe ser posterior a la hora de inicio",
       "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",
+      "duration_hours": "La duración debe estar entre 1 minuto y 24 horas",
+      "integer": "El valor debe ser un número entero",
+      "integer_min": "El valor debe ser un número entero mayor o igual a {min}",
+      "integer_range": "El valor debe ser un número entero entre {min} y {max}",
       "min": "Este campo debe tener al menos",
       "max": "Este campo debe tener como máximo",
       "characters": "caracteres",
+      "one_of": "Seleccione una opción válida",
       "password": "La contraseña debe tener al menos 6 caracteres, una letra mayúscula, una letra minúscula y un número",
+      "positive": "El valor debe ser mayor que cero",
       "same_password": "Las contraseñas deben coincidir",
+      "slug": "Use solo letras mayúsculas, números y guiones bajos",
+      "time": "Este campo debe ser una hora válida",
       "not_same_document": "El documento debe ser único para cada participante",
       "cpf": "Este campo debe ser un CPF válido",
       "cnpj": "Este campo debe ser un CNPJ válido",

+ 9 - 0
src/i18n/locales/pt.json

@@ -173,14 +173,23 @@
   },
   "validation": {
     "rules": {
+      "after_time": "O horário final deve ser posterior ao horário inicial",
       "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",
+      "duration_hours": "A duração deve estar entre 1 minuto e 24 horas",
+      "integer": "O valor deve ser um número inteiro",
+      "integer_min": "O valor deve ser um número inteiro maior ou igual a {min}",
+      "integer_range": "O valor deve ser um número inteiro entre {min} e {max}",
       "min": "Este campo deve ter no mínimo",
       "max": "Este campo deve ter no máximo",
       "characters": "caracteres",
+      "one_of": "Selecione uma opção válida",
       "password": "A senha deve ter pelo menos 6 caracteres, uma letra maiúscula, uma letra minúscula e um número",
+      "positive": "O valor deve ser maior que zero",
       "same_password": "As senhas devem ser iguais",
+      "slug": "Use apenas letras maiúsculas, números e sublinhado",
+      "time": "Este campo deve ser um horário válido",
       "not_same_document": "O documento deve ser único para cada participante",
       "cpf": "Este campo deve ser um CPF válido",
       "cnpj": "Este campo deve ser um CNPJ válido",

+ 44 - 0
src/pages/cadastros/CadastrosPage.vue

@@ -0,0 +1,44 @@
+<template>
+  <div class="tab-page-layout">
+    <DefaultHeaderPage title="Cadastros" :show-filter-icon="false" />
+
+    <div class="tab-page-body q-px-sm">
+      <CustomTabComponent
+        v-model:active-tab="currentTab"
+        class="q-mb-sm"
+        :tabs="tabs"
+      />
+
+      <q-scroll-area class="tab-page-scroll-area">
+        <div v-show="currentTab === 'usuarios'">
+          <UsuariosTab />
+        </div>
+
+        <div v-show="currentTab === 'grupos'">
+          <GruposTab />
+        </div>
+      </q-scroll-area>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { defineAsyncComponent, ref } from "vue";
+
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const UsuariosTab = defineAsyncComponent(
+  () => import("./tabs/UsuariosTab.vue"),
+);
+
+const GruposTab = defineAsyncComponent(() => import("./tabs/GruposTab.vue"));
+
+const currentTab = ref("usuarios");
+
+const tabs = [
+  { name: "usuarios", label: "Usuários" },
+  { name: "grupos", label: "Grupos" },
+];
+</script>

+ 157 - 0
src/pages/cadastros/components/AddEditGroupDialog.vue

@@ -0,0 +1,157 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 520px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader
+          :title="isEdit ? 'Editar Grupo' : 'Adicionar Novo Grupo'"
+          @close="onDialogCancel"
+        />
+
+        <DefaultForm ref="formRef" @submit="onOKClick">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
+              <DefaultInput
+                v-model="form.name"
+                v-model:error="validationErrors.name"
+                class="col-12"
+                label="Nome do Grupo"
+                outlined
+                :error-message="validationErrors.name"
+                :rules="[inputRules.required]"
+              />
+
+              <q-select
+                v-model="form.unit_ids"
+                class="col-12"
+                emit-value
+                hide-dropdown-icon
+                label="Unidades"
+                label-color="secondary"
+                map-options
+                multiple
+                outlined
+                use-chips
+                :loading="loadingUnits"
+                :options="unitOptions"
+              >
+                <template #append>
+                  <q-icon color="secondary" name="mdi-chevron-down" />
+                </template>
+              </q-select>
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+            <q-btn
+              color="negative"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="onDialogCancel"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              unelevated
+              :loading="loading"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+
+import {
+  createGroup,
+  updateGroup,
+} from "src/api/group";
+
+import { formatUnitName } from "src/helpers/utils";
+import { getUnitsForSelect } from "src/api/unit";
+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 DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+const props = defineProps({
+  group: {
+    default: null,
+    type: Object,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogCancel, onDialogHide, onDialogOK } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+
+const { scrollToComponent } = useScroll();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { form, getUpdatedFields } = useForm(
+  {
+    name: props.group?.name ?? "",
+    unit_ids: props.group?.unit_ids ?? [],
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const loadingUnits = ref(true);
+const unitOptions = ref([]);
+
+const isEdit = computed(() => !!props.group);
+
+const { loading, validationErrors, execute } = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onSuccess: (result) => onDialogOK(result),
+  scrollFn: scrollToComponent,
+});
+
+const loadUnits = async () => {
+  try {
+    const units = await getUnitsForSelect();
+
+    unitOptions.value = units.map((unit) => ({
+      label: formatUnitName(unit),
+      value: unit.id,
+    }));
+  } catch (error) {
+    console.error("Failed to load units:", error);
+  } finally {
+    loadingUnits.value = false;
+  }
+};
+
+const onOKClick = async () => {
+  const payload = {
+    name: form.name,
+    unit_ids: form.unit_ids,
+  };
+
+  await execute(() =>
+    isEdit.value
+      ? updateGroup(props.group.id, getUpdatedFields.value)
+      : createGroup(payload),
+  );
+};
+
+onMounted(loadUnits);
+</script>

+ 109 - 0
src/pages/cadastros/tabs/GruposTab.vue

@@ -0,0 +1,109 @@
+<template>
+  <div>
+    <DefaultTable
+      v-model:rows="rows"
+      title="Lista de Grupos"
+      :columns
+      description="Grupos"
+      :female="false"
+      :loading="loading"
+      no-api-call
+      :add-item="canAdd"
+      @on-add-item="handleAdd"
+    >
+      <template #body-cell-status="{ row }">
+        <q-td align="center">
+          <q-badge
+            :color="row.status === 'ACTIVE' ? 'positive' : 'warning'"
+            :label="row.status === 'ACTIVE' ? 'Ativo' : 'Inativo'"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-actions="{ row }">
+        <q-td auto-width>
+          <q-item-section class="no-wrap" style="flex-direction: row">
+            <q-btn
+              v-if="canEdit"
+              outline
+              icon="mdi-pencil-outline"
+              style="width: 36px"
+              class="q-mr-sm"
+              @click.prevent.stop="handleEdit(row)"
+            />
+            <q-btn
+              v-if="canDelete"
+              outline
+              icon="mdi-trash-can-outline"
+              style="width: 36px"
+              class="q-mr-sm"
+              @click.prevent.stop="handleDelete(row)"
+            />
+          </q-item-section>
+        </q-td>
+      </template>
+    </DefaultTable>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import AddEditGroupDialog from "../components/AddEditGroupDialog.vue";
+import { getGroups, deleteGroup } from "src/api/group";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const permissions = permissionStore();
+const canAdd = permissions.getAccess("franchisor_registrations", "add");
+const canEdit = permissions.getAccess("franchisor_registrations", "edit");
+const canDelete = permissions.getAccess("franchisor_registrations", "delete");
+
+const rows = ref([]);
+const loading = ref(false);
+
+const columns = ref([
+  { name: "name", label: "Nome", field: "name", align: "left" },
+  { name: "active_units", label: "Unidades Ativas", field: "active_units", align: "center" },
+  { name: "status", label: "Status", field: "status", align: "center" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
+]);
+
+async function fetchGroups() {
+  loading.value = true;
+  try {
+    rows.value = await getGroups();
+  } catch (error) {
+    console.error("Failed to load groups:", error);
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleAdd() {
+  $q.dialog({ component: AddEditGroupDialog }).onOk(() => fetchGroups());
+}
+
+function handleEdit(row) {
+  $q.dialog({ component: AddEditGroupDialog, componentProps: { group: row } }).onOk(() => fetchGroups());
+}
+
+function handleDelete(row) {
+  $q.dialog({
+    title: "Confirmar exclusão",
+    message: `Deseja excluir o grupo "${row.name}"?`,
+    ok: { color: "negative", label: "Excluir" },
+    cancel: { color: "primary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    try {
+      await deleteGroup(row.id);
+      await fetchGroups();
+    } catch (error) {
+      console.error("Failed to delete group:", error);
+    }
+  });
+}
+
+onMounted(fetchGroups);
+</script>

+ 115 - 0
src/pages/cadastros/tabs/UsuariosTab.vue

@@ -0,0 +1,115 @@
+<template>
+  <div>
+    <DefaultTable
+      v-model:rows="filteredRows"
+      title="Lista de Usuários"
+      :columns
+      description="Usuários"
+      no-api-call
+      :add-item="canAdd"
+      add-item-route="UserAddPage"
+    >
+      <template #body-cell-user_type="{ row }">
+        <q-td align="left">{{ userTypeLabel(row.user_type) }}</q-td>
+      </template>
+
+      <template #body-cell-status="{ row }">
+        <q-td align="center">
+          <q-badge
+            :color="row.status === 'ACTIVE' ? 'positive' : 'warning'"
+            :label="row.status === 'ACTIVE' ? 'Ativo' : 'Inativo'"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-actions="{ row }">
+        <q-td auto-width>
+          <q-item-section class="no-wrap" style="flex-direction: row">
+            <q-btn
+              v-if="canEdit"
+              outline
+              icon="mdi-account-edit-outline"
+              style="width: 36px"
+              class="q-mr-sm"
+              @click.prevent.stop="onEdit(row)"
+            />
+            <q-btn
+              v-if="canDelete"
+              outline
+              icon="mdi-trash-can-outline"
+              style="width: 36px"
+              class="q-mr-sm"
+              @click.prevent.stop="onDelete(row)"
+            />
+          </q-item-section>
+        </q-td>
+      </template>
+    </DefaultTable>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useRouter } from "vue-router";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import { deleteUser, getUsers } from "src/api/user";
+import { useQuasar } from "quasar";
+import { permissionStore } from "src/stores/permission";
+
+const router = useRouter();
+const $q = useQuasar();
+const permissions = permissionStore();
+const canAdd = permissions.getAccess("franchisor_registrations", "add");
+const canEdit = permissions.getAccess("franchisor_registrations", "edit");
+const canDelete = permissions.getAccess("franchisor_registrations", "delete");
+
+const funcaoSelected = ref(null);
+const allUsers = ref([]);
+
+const USER_TYPE_LABELS = {
+  ADMIN: "Administrador (Franqueadora)",
+  ADMIN_FRANCHISEE: "Administrador (Franqueada)",
+};
+
+const userTypeLabel = (type) => USER_TYPE_LABELS[type] ?? type;
+
+const filteredRows = computed(() => {
+  return allUsers.value.filter((row) => {
+    if (funcaoSelected.value && row.user_type !== funcaoSelected.value.value)
+      return false;
+    return true;
+  });
+});
+
+const columns = ref([
+  { name: "name", label: "Nome", field: "name", align: "left" },
+  { name: "email", label: "E-mail", field: "email", align: "left" },
+  { name: "user_type", label: "Função", field: "user_type", align: "left" },
+  { name: "status", label: "Status", field: "status", align: "center" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
+]);
+
+function onEdit(row) {
+  router.push({ name: "UserEditPage", params: { id: row.id } });
+}
+
+function onDelete(row) {
+  $q.dialog({
+    title: "Excluir usuário",
+    message: `Deseja excluir o usuário "${row.name}"?`,
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    await deleteUser(row.id);
+    allUsers.value = allUsers.value.filter((user) => user.id !== row.id);
+  });
+}
+
+onMounted(async () => {
+  try {
+    allUsers.value = await getUsers();
+  } catch (error) {
+    console.error("Failed to load users:", error);
+  }
+});
+</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>

+ 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>

+ 392 - 357
src/pages/dashboard/DashboardPage.vue

@@ -1,126 +1,181 @@
 <template>
   <div>
-    <DefaultHeaderPage show-filter-icon title="Dashboard" />
+    <DefaultHeaderPage
+      title="Dashboard"
+      :filter-open="showFilter"
+      @show-filter="showFilter = !showFilter"
+    />
 
     <div class="q-pa-sm">
       <div class="filter-row">
-        <q-select
-          v-model="selectedPeriod"
-          :options="periodOptions"
-          option-value="value"
-          option-label="label"
-          emit-value
-          map-options
-          dense
-          label="Selecione o Período"
-          color="secondary"
-          class="filter-item"
-          hide-dropdown-icon
-        >
-          <template #append>
-            <q-icon name="mdi-chevron-down" color="secondary" />
-          </template>
-        </q-select>
-
-        <template v-if="selectedPeriod === 'custom'">
-          <q-input
-            v-model="startDate"
+        <template v-if="showFilter">
+          <!-- Unidade: ao selecionar, limpa o grupo -->
+          <UnitSelect
+            v-model="selectedUnit"
             dense
-            label="Data Inicial"
-            mask="##/##/####"
-            placeholder="DD/MM/AAAA"
+            label="Unidade"
+            class="filter-item"
             color="secondary"
+            @update:model-value="onUnitSelected"
+          />
+
+          <!-- Grupo: ao selecionar, limpa a unidade -->
+          <GroupSelect
+            v-model="selectedGroup"
+            dense
+            label="Grupo"
             class="filter-item"
-          >
-            <template #append>
-              <q-icon
-                name="mdi-calendar"
-                color="secondary"
-                class="cursor-pointer"
-              >
-                <q-popup-proxy
-                  cover
-                  transition-show="scale"
-                  transition-hide="scale"
-                >
-                  <q-date
-                    v-model="startDate"
-                    mask="DD/MM/YYYY"
-                    color="secondary"
-                  >
-                    <div class="row items-center justify-end">
-                      <q-btn v-close-popup label="OK" color="secondary" flat />
-                    </div>
-                  </q-date>
-                </q-popup-proxy>
-              </q-icon>
-            </template>
-          </q-input>
-
-          <q-input
-            v-model="endDate"
+            color="secondary"
+            @update:model-value="onGroupSelected"
+          />
+
+          <DefaultSelect
+            v-model="selectedPeriod"
+            :options="periodOptions"
+            option-value="value"
+            option-label="label"
+            emit-value
+            map-options
             dense
-            label="Data Final"
-            mask="##/##/####"
-            placeholder="DD/MM/AAAA"
+            label="Selecione o Período"
             color="secondary"
             class="filter-item"
-          >
-            <template #append>
-              <q-icon
-                name="mdi-calendar"
-                color="secondary"
-                class="cursor-pointer"
-              >
-                <q-popup-proxy
-                  cover
-                  transition-show="scale"
-                  transition-hide="scale"
-                >
-                  <q-date v-model="endDate" mask="DD/MM/YYYY" color="secondary">
-                    <div class="row items-center justify-end">
-                      <q-btn v-close-popup label="OK" color="secondary" flat />
-                    </div>
-                  </q-date>
-                </q-popup-proxy>
-              </q-icon>
-            </template>
-          </q-input>
+          />
+
+          <template v-if="selectedPeriod === 'custom'">
+            <DefaultInputDatePicker
+              v-model="startDate"
+              v-model:untreated-date="startDateIso"
+              dense
+              label="Data Inicial"
+              color="secondary"
+              class="filter-item"
+            />
+
+            <DefaultInputDatePicker
+              v-model="endDate"
+              v-model:untreated-date="endDateIso"
+              dense
+              label="Data Final"
+              color="secondary"
+              class="filter-item"
+            />
+          </template>
+
+          <q-btn
+            outline
+            color="secondary"
+            icon="mdi-filter-remove-outline"
+            label="Limpar filtros"
+            class="clear-filters-button"
+            @click="clearFilters"
+          />
         </template>
       </div>
+
+      <!-- Chip de contexto ativo -->
+      <div v-if="showFilter && filterLabel" class="q-mt-xs q-ml-xs">
+        <q-chip
+          dense
+          color="secondary"
+          text-color="white"
+          icon="mdi-filter"
+          :label="filterLabel"
+          removable
+          @remove="clearUnitFilters"
+        />
+      </div>
     </div>
 
     <div v-if="!isLoading" class="column gap q-pa-sm">
+      <div class="stat-cards-row">
+        <DashboardStatCard
+          title="Total de Unidades"
+          icon="mdi-store-outline"
+          :value="unitsSummary.total"
+          subtitle="Unidades cadastradas"
+        />
+        <DashboardStatCard
+          title="Faturamento Recebido pelas Unidades"
+          icon="mdi-cash-multiple"
+          :value="formatToBRLCurrency(unitsSummary.revenue)"
+          :subtitle="revenuePeriodLabel"
+        />
+        <DashboardStatCard
+          title="Faturamento a Receber pelas Unidades"
+          icon="mdi-cash-clock"
+          :value="formatToBRLCurrency(unitsSummary.missing_receivables)"
+          :subtitle="receivablesPeriodLabel"
+        />
+        <DashboardStatCard
+          title="Contratos das Unidades"
+          icon="mdi-file-document-check-outline"
+          :value="`${unitsSummary.active_contracts} vigentes`"
+          :subtitle="`${unitsSummary.without_active_contract} sem contrato vigente`"
+        />
+      </div>
+
       <div class="stat-cards-row">
         <DashboardStatCard
           title="Total alunos (contratos ativos)"
           icon="mdi-account-multiple-outline"
-          value="4.527"
-          badge="3.200 ativos"
+          :value="totalStudents"
+          :badge="`${activeContracts} ativos`"
           clickable
-          @click="openAlunosDialog"
+          @click="openActiveStudentsDialog"
         />
         <DashboardStatCard
           title="Contratos Congelados"
           icon="mdi-snowflake"
-          value="57"
+          :value="frozenContracts"
           subtitle="É hora de incentivar nossos alunos"
           clickable
-          @click="openContratosCongeladosDialog"
+          @click="openFrozenContractsDialog"
         />
         <DashboardStatCard
           title="Contratos Cancelados"
           icon="mdi-cancel"
-          value="57"
+          :value="cancelledContracts"
           subtitle="É hora de incentivar nossos alunos"
           clickable
-          @click="openContratosCanceladosDialog"
+          @click="openCancelledContractsDialog"
         />
         <DashboardStatCard
           title="Receita Geral"
           icon="mdi-currency-usd"
-          value="R$ 51.548,80"
-          subtitle="0 pagamentos pendentes"
+          :value="formatToBRLCurrency(generalRevenue.value)"
+          :subtitle="`${generalRevenue.pending_count} pagamentos pendentes`"
+        />
+      </div>
+
+      <div class="stat-cards-row">
+        <DashboardStatCard
+          title="Frequência Média"
+          icon="mdi-account-multiple-outline"
+          :value="`${averageAttendance}%`"
+          clickable
+          @click="openAverageAttendanceDialog"
+        />
+        <DashboardStatCard
+          title="Estoque Geral de Produtos"
+          icon="mdi-currency-usd"
+          :value="productStock"
+          clickable
+          @click="openProductStockDialog"
+        />
+        <DashboardStatCard
+          title="Tarefas Pendentes"
+          icon="mdi-draw"
+          :value="pendingTasks"
+          subtitle="Não deixe para amanhã"
+        />
+        <DashboardStatCard
+          title="Tickets Abertos"
+          icon="mdi-calendar-outline"
+          :value="openTickets"
+          :subtitle="`${openTickets} aguardando atendimento`"
+          clickable
+          @click="openTicketsDialog"
         />
       </div>
 
@@ -149,41 +204,8 @@
           />
         </DashboardChartCard>
 
-        <AniversariantesCard :people="aniversariantes" />
-      </div>
-
-      <div class="stat-cards-row">
-        <DashboardStatCard
-          title="Frequência Média"
-          icon="mdi-account-multiple-outline"
-          value="87%"
-          badge="Alta"
-          badge-color="approved"
-          custom-style="padding: 6px 24px"
-          clickable
-          @click="openFrequenciaMediaDialog"
-        />
-        <DashboardStatCard
-          title="Estoque Geral de Produtos"
-          icon="mdi-currency-usd"
-          value="56"
-          clickable
-          @click="openEstoqueProdutosDialog"
-        />
-        <DashboardStatCard
-          title="Tarefas Pendentes"
-          icon="mdi-draw"
-          value="4"
-          subtitle="Não deixe para amanhã"
-        />
-        <DashboardStatCard
-          title="Tickets Abertos"
-          icon="mdi-calendar-outline"
-          value="2"
-          subtitle="Estável"
-          clickable
-          @click="openTicketsAbertoDialog"
-        />
+        <HolidaysCard />
+        <BirthdaysCard :people="birthdays" />
       </div>
     </div>
 
@@ -194,111 +216,265 @@
 </template>
 
 <script setup>
-import { onMounted, ref, watch } from "vue";
+import { onMounted, ref, computed, watch } from "vue";
 import { useQuasar } from "quasar";
 import { useI18n } from "vue-i18n";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import DefaultInputDatePicker from "src/components/defaults/DefaultInputDatePicker.vue";
+import UnitSelect from "src/components/selects/UnitSelect.vue";
+import GroupSelect from "src/components/selects/GroupSelect.vue";
 import DashboardStatCard from "src/components/charts/DashboardStatCard.vue";
 import DashboardChartCard from "src/components/charts/DashboardChartCard.vue";
 import GroupedBarChart from "src/components/charts/normal/GroupedBarChart.vue";
-import AniversariantesCard from "src/components/charts/AniversariantesCard.vue";
-import AlunosAtivosDialog from "src/pages/dashboard/components/AlunosAtivosDialog.vue";
-import ContratosCongeladosDialog from "src/pages/dashboard/components/ContratosCongeladosDialog.vue";
-import ContratosCanceladosDialog from "src/pages/dashboard/components/ContratosCanceladosDialog.vue";
-import FrequenciaMediaDialog from "src/pages/dashboard/components/FrequenciaMediaDialog.vue";
-import EstoqueProdutosDialog from "src/pages/dashboard/components/EstoqueProdutosDialog.vue";
-import TicketsAbertoDialog from "src/pages/dashboard/components/TicketsAbertoDialog.vue";
-
-const { t } = useI18n();
-
+import BirthdaysCard from "src/components/charts/BirthdaysCard.vue";
+import HolidaysCard from "src/components/charts/HolidaysCard.vue";
+import ActiveStudentsDialog from "src/pages/dashboard/components/ActiveStudentsDialog.vue";
+import FrozenContractsDialog from "src/pages/dashboard/components/FrozenContractsDialog.vue";
+import CancelledContractsDialog from "src/pages/dashboard/components/CancelledContractsDialog.vue";
+import AverageAttendanceDialog from "src/pages/dashboard/components/AverageAttendanceDialog.vue";
+import ProductStockDialog from "src/pages/dashboard/components/ProductStockDialog.vue";
+import OpenTicketsDialog from "src/pages/dashboard/components/OpenTicketsDialog.vue";
+import { getStudentSummaryFranchisor } from "src/api/student";
+import { getFranchisorContractSummary } from "src/api/student_contract";
+import { getFranchisorDashboard } from "src/api/dashboard";
+import { formatToBRLCurrency } from "src/helpers/utils";
+
+useI18n(); // i18n instance kept for future use
 const $q = useQuasar();
 
+// ─── Loading state ───────────────────────────────────────────
 const isLoading = ref(true);
 
-const openAlunosDialog = () => {
-  $q.dialog({ component: AlunosAtivosDialog });
+// ─── Metric counters ─────────────────────────────────────────
+const totalStudents    = ref(0);
+const activeContracts  = ref(0);
+const frozenContracts  = ref(0);
+const cancelledContracts = ref(0);
+const openTickets      = ref(0);
+const pendingTasks     = ref(0);
+const productStock     = ref(0);
+const averageAttendance = ref(0);
+const attendanceByUnit = ref([]);
+const products         = ref([]);
+const tickets          = ref([]);
+const generalRevenue   = ref({ value: 0, pending_count: 0 });
+const unitsSummary     = ref({
+  total: 0,
+  revenue: 0,
+  missing_receivables: 0,
+  active_contracts: 0,
+  without_active_contract: 0,
+});
+const birthdays        = ref([]);
+
+// ─── Filter state ────────────────────────────────────────────
+const showFilter    = ref(false);
+const selectedUnit  = ref(null);   // { label, value } | null
+const selectedGroup = ref(null);   // { label, value, unit_ids } | null
+const selectedPeriod = ref("month");
+const startDate     = ref("");
+const endDate       = ref("");
+const startDateIso  = ref("");
+const endDateIso    = ref("");
+
+const periodOptions = [
+  { label: "Hoje",         value: "today"  },
+  { label: "Esta semana",  value: "week"   },
+  { label: "Este mês",     value: "month"  },
+  { label: "Este ano",     value: "year"   },
+  { label: "Personalizado", value: "custom" },
+];
+
+const toIsoDate = (date) => {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, "0");
+  const day = String(date.getDate()).padStart(2, "0");
+  return `${year}-${month}-${day}`;
 };
 
-const openContratosCongeladosDialog = () => {
-  $q.dialog({ component: ContratosCongeladosDialog });
+const revenuePeriod = computed(() => {
+  const now = new Date();
+  let start;
+  let end = now;
+
+  if (selectedPeriod.value === "today") {
+    start = now;
+  } else if (selectedPeriod.value === "week") {
+    start = new Date(now);
+    const day = start.getDay() || 7;
+    start.setDate(start.getDate() - day + 1);
+  } else if (selectedPeriod.value === "year") {
+    start = new Date(now.getFullYear(), 0, 1);
+  } else if (selectedPeriod.value === "custom") {
+    if (!startDateIso.value || !endDateIso.value) return {};
+    return { startDate: startDateIso.value, endDate: endDateIso.value };
+  } else {
+    start = new Date(now.getFullYear(), now.getMonth(), 1);
+  }
+
+  return { startDate: toIsoDate(start), endDate: toIsoDate(end) };
+});
+
+const revenuePeriodLabel = computed(() => {
+  if (!revenuePeriod.value.startDate) return "Mês atual";
+  const format = (value) => value.split("-").reverse().join("/");
+  if (revenuePeriod.value.startDate === revenuePeriod.value.endDate) {
+    return `Recebido em ${format(revenuePeriod.value.startDate)}`;
+  }
+  return `Recebido de ${format(revenuePeriod.value.startDate)} a ${format(revenuePeriod.value.endDate)}`;
+});
+
+const receivablesPeriodLabel = computed(() => {
+  if (!revenuePeriod.value.startDate) return "Mês atual";
+  const format = (value) => value.split("-").reverse().join("/");
+  if (revenuePeriod.value.startDate === revenuePeriod.value.endDate) {
+    return `Vencimento em ${format(revenuePeriod.value.startDate)}`;
+  }
+  return `Vencimentos de ${format(revenuePeriod.value.startDate)} a ${format(revenuePeriod.value.endDate)}`;
+});
+
+/**
+ * Array of unit IDs to use as filter.
+ * - Group selected  → all unit IDs in that group
+ * - Unit selected   → just that unit ID
+ * - Nothing selected → [] (no filter = all units)
+ */
+const activeUnitIds = computed(() => {
+  if (selectedGroup.value?.unit_ids?.length) {
+    return selectedGroup.value.unit_ids;
+  }
+  if (selectedUnit.value?.value) {
+    return [selectedUnit.value.value];
+  }
+  return [];
+});
+
+/** Human-readable label shown in the active-filter chip */
+const filterLabel = computed(() => {
+  if (selectedGroup.value) return `Grupo: ${selectedGroup.value.label}`;
+  if (selectedUnit.value)  return `Unidade: ${selectedUnit.value.label}`;
+  return "";
+});
+
+// Mutual exclusion handlers
+const onUnitSelected = (val) => {
+  if (val) selectedGroup.value = null;
 };
 
-const openContratosCanceladosDialog = () => {
-  $q.dialog({ component: ContratosCanceladosDialog });
+const onGroupSelected = (val) => {
+  if (val) selectedUnit.value = null;
 };
 
-const openFrequenciaMediaDialog = () => {
-  $q.dialog({ component: FrequenciaMediaDialog });
+const clearUnitFilters = () => {
+  selectedUnit.value  = null;
+  selectedGroup.value = null;
 };
 
-const openEstoqueProdutosDialog = () => {
-  $q.dialog({ component: EstoqueProdutosDialog });
+const clearFilters = () => {
+  clearUnitFilters();
+  selectedPeriod.value = "month";
+  startDate.value = "";
+  endDate.value = "";
+  startDateIso.value = "";
+  endDateIso.value = "";
 };
 
-const openTicketsAbertoDialog = () => {
-  $q.dialog({ component: TicketsAbertoDialog });
+// ─── Dialog openers ──────────────────────────────────────────
+const openActiveStudentsDialog = () => {
+  $q.dialog({ component: ActiveStudentsDialog, componentProps: { unitIds: activeUnitIds.value } });
 };
-const defaultPeriod = ref("month");
-const defaultEventId = ref(1);
 
-const selectedPeriod = ref("custom");
-const startDate = ref("");
-const endDate = ref("");
+const openFrozenContractsDialog = () => {
+  $q.dialog({ component: FrozenContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
+};
 
-const periodOptions = [
-  { label: "Hoje", value: "today" },
-  { label: "Esta semana", value: "week" },
-  { label: "Este mês", value: "month" },
-  { label: "Este ano", value: "year" },
-  { label: "Personalizado", value: "custom" },
-];
+const openCancelledContractsDialog = () => {
+  $q.dialog({ component: CancelledContractsDialog, componentProps: { unitIds: activeUnitIds.value } });
+};
 
-const aniversariantes = [
-  { 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 openAverageAttendanceDialog = () => {
+  $q.dialog({
+    component: AverageAttendanceDialog,
+    componentProps: { units: attendanceByUnit.value },
+  });
+};
 
-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 openProductStockDialog = () => {
+  $q.dialog({
+    component: ProductStockDialog,
+    componentProps: { products: products.value },
+  });
+};
+
+const openTicketsDialog = () => {
+  $q.dialog({
+    component: OpenTicketsDialog,
+    componentProps: { tickets: tickets.value },
+  });
+};
+
+// Matrículas por Período — preenchido pela API (últimos 6 meses).
+const MATRICULA_COLORS = ["#3B82F6", "#EF4444", "#A855F7", "#374151", "#EAB308", "#06B6D4"];
+const matriculasChart = ref({
+  labels: [],
+  datasets: [{ label: "Matrículas", data: [], color: MATRICULA_COLORS }],
+});
+
+// ─── Data fetching ───────────────────────────────────────────
+const fetchMetrics = async () => {
+  const ids = activeUnitIds.value;
+  const [studentSummary, contractSummary, dashboard] = await Promise.all([
+    getStudentSummaryFranchisor(ids),
+    getFranchisorContractSummary(ids),
+    getFranchisorDashboard(ids, revenuePeriod.value),
+  ]);
+
+  totalStudents.value      = studentSummary.total;
+  activeContracts.value    = contractSummary.active ?? 0;
+  frozenContracts.value    = contractSummary.frozen;
+  cancelledContracts.value = contractSummary.cancelled;
+
+  openTickets.value    = dashboard.open_tickets ?? 0;
+  pendingTasks.value   = dashboard.pending_tasks ?? 0;
+  productStock.value   = dashboard.product_stock ?? 0;
+  averageAttendance.value = dashboard.attendance?.average ?? 0;
+  attendanceByUnit.value = dashboard.attendance?.units ?? [];
+  products.value       = dashboard.products ?? [];
+  tickets.value        = dashboard.tickets ?? [];
+  generalRevenue.value = dashboard.general_revenue ?? { value: 0, pending_count: 0 };
+  unitsSummary.value   = dashboard.units_summary ?? unitsSummary.value;
+  birthdays.value      = dashboard.birthdays ?? [];
+
+  matriculasChart.value = {
+    labels: dashboard.enrollments?.labels ?? [],
+    datasets: [
+      {
+        label: "Matrículas",
+        data: dashboard.enrollments?.data ?? [],
+        color: MATRICULA_COLORS,
+      },
+    ],
+  };
 };
 
+// Re-fetch whenever unit/group filter changes
+watch([activeUnitIds, selectedPeriod, startDateIso, endDateIso], async () => {
+  if (selectedPeriod.value === "custom" && (!startDateIso.value || !endDateIso.value)) return;
+  isLoading.value = true;
+  try {
+    await fetchMetrics();
+  } finally {
+    isLoading.value = false;
+  }
+});
+
+// Faturamento Serviço / Materiais — zerado (sem fluxo de dados ainda).
 const faturamentoChart = {
-  labels: [
-    "17/02",
-    "18/02",
-    "19/02",
-    "20/02",
-    "21/02",
-    "22/02",
-    "23/02",
-    "24/02",
-    "25/02",
-    "26/02",
-  ],
+  labels: [],
   datasets: [
-    {
-      label: "Serviço",
-      data: [
-        18500, 21000, 16400, 22300, 19800, 17200, 15800, 24100, 20500, 27600,
-      ],
-      color: "#a274f1",
-    },
-    {
-      label: "Materiais",
-      data: [9200, 10500, 8100, 11400, 9800, 8400, 8700, 12200, 10100, 13100],
-      color: "#ff9999",
-    },
+    { label: "Serviço", data: [], color: "#a274f1" },
+    { label: "Materiais", data: [], color: "#ff9999" },
   ],
 };
 
@@ -312,164 +488,14 @@ const formatCurrencyTooltip = (context) => {
   return ` ${context.dataset.label}: R$ ${value.toLocaleString("pt-BR", { minimumFractionDigits: 2 })}`;
 };
 
-const ordersChart = ref({});
-const participantsChart = ref({});
-const paymentsChart = ref({});
-const ticketsSoldChart = ref({});
-const eventTicketsByTypeChart = ref({});
-const eventParticipantsByCNPJAndCPF = ref({});
-const salesOverTimeLineChart = ref({});
-const eventSourcePieChart = ref({});
-
-const generateMockData = () => {
-  const createMiniChartData = (currentTotal, percentage) => ({
-    current_total: currentTotal,
-    percentage_change: percentage,
-    trend_data: Array.from({ length: 10 }, () =>
-      Math.floor(Math.random() * 100),
-    ),
-  });
-
-  const barChartDataRaw = [
-    {
-      label: t("dashboard.charts.tickets_by_type.labels.vip"),
-      value: Math.floor(Math.random() * 300),
-    },
-    {
-      label: t("dashboard.charts.tickets_by_type.labels.track"),
-      value: Math.floor(Math.random() * 800),
-    },
-    {
-      label: t("dashboard.charts.tickets_by_type.labels.box"),
-      value: Math.floor(Math.random() * 400),
-    },
-    {
-      label: t("dashboard.charts.tickets_by_type.labels.courtesy"),
-      value: Math.floor(Math.random() * 50),
-    },
-  ];
-
-  const doughnutDataRaw = [
-    {
-      label: t("common.terms.cpf"),
-      value: Math.floor(Math.random() * 900 + 100),
-    },
-    {
-      label: t("common.terms.cnpj"),
-      value: Math.floor(Math.random() * 100 + 10),
-    },
-  ];
-  const doughnutTotal = doughnutDataRaw.reduce(
-    (sum, item) => sum + item.value,
-    0,
-  );
-
-  const lineChartDataRaw = [
-    {
-      label: t("common.months.january"),
-      value: Math.floor(1200 + Math.random() * 500),
-    },
-    {
-      label: t("common.months.february"),
-      value: Math.floor(1900 + Math.random() * 500),
-    },
-    {
-      label: t("common.months.march"),
-      value: Math.floor(3000 + Math.random() * 500),
-    },
-    {
-      label: t("common.months.april"),
-      value: Math.floor(5000 + Math.random() * 500),
-    },
-    {
-      label: t("common.months.may"),
-      value: Math.floor(2300 + Math.random() * 500),
-    },
-    {
-      label: t("common.months.june"),
-      value: Math.floor(3200 + Math.random() * 500),
-    },
-  ];
-
-  const pieDataRaw = [
-    {
-      label: t("dashboard.charts.registration_source.sources.instagram"),
-      value: Math.floor(450 + Math.random() * 50),
-    },
-    {
-      label: t("dashboard.charts.registration_source.sources.facebook"),
-      value: Math.floor(250 + Math.random() * 50),
-    },
-    {
-      label: t("dashboard.charts.registration_source.sources.google"),
-      value: Math.floor(180 + Math.random() * 50),
-    },
-    {
-      label: t("dashboard.charts.registration_source.sources.referral"),
-      value: Math.floor(120 + Math.random() * 50),
-    },
-  ];
-  const pieTotal = pieDataRaw.reduce((sum, item) => sum + item.value, 0);
-
-  return {
-    payments: createMiniChartData(
-      (Math.random() * 20000 + 5000).toFixed(2),
-      (Math.random() * 20 - 5).toFixed(2),
-    ),
-    orders: createMiniChartData(
-      Math.floor(Math.random() * 500 + 50),
-      (Math.random() * 15 - 5).toFixed(2),
-    ),
-    tickets_sold: createMiniChartData(
-      Math.floor(Math.random() * 1500 + 200),
-      (Math.random() * 25 - 5).toFixed(2),
-    ),
-    participants: createMiniChartData(
-      Math.floor(Math.random() * 1000 + 100),
-      (Math.random() * 10 - 5).toFixed(2),
-    ),
-    barData: {
-      chart_data: barChartDataRaw,
-    },
-    doughnutData: {
-      chart_data: doughnutDataRaw,
-      current_total: doughnutTotal,
-    },
-    lineData: {
-      chart_data: lineChartDataRaw,
-    },
-    pieData: {
-      chart_data: pieDataRaw,
-      current_total: pieTotal,
-    },
-  };
-};
-
-const updateDashboardData = async () => {
+// ─── Init ─────────────────────────────────────────────────────
+onMounted(async () => {
   isLoading.value = true;
-  setTimeout(() => {
-    const mockData = generateMockData();
-
-    ordersChart.value = mockData.orders;
-    participantsChart.value = mockData.participants;
-    paymentsChart.value = mockData.payments;
-    ticketsSoldChart.value = mockData.tickets_sold;
-
-    eventTicketsByTypeChart.value = mockData.barData;
-    eventParticipantsByCNPJAndCPF.value = mockData.doughnutData;
-    salesOverTimeLineChart.value = mockData.lineData;
-    eventSourcePieChart.value = mockData.pieData;
-
+  try {
+    await fetchMetrics();
+  } finally {
     isLoading.value = false;
-  }, 500);
-};
-
-watch([defaultPeriod, defaultEventId], async () => {
-  await updateDashboardData();
-});
-
-onMounted(async () => {
-  await updateDashboardData();
+  }
 });
 </script>
 
@@ -505,17 +531,18 @@ onMounted(async () => {
 }
 
 .charts-row > *:nth-child(1) {
-  flex: 0 0 calc(41.6667% - 11px);
+  flex: 0 0 calc(50% - 8px);
   min-width: 0;
 }
 
 .charts-row > *:nth-child(2) {
-  flex: 0 0 calc(33.3333% - 11px);
+  flex: 0 0 calc(50% - 8px);
   min-width: 0;
 }
 
-.charts-row > *:nth-child(3) {
-  flex: 0 0 calc(25% - 11px);
+.charts-row > *:nth-child(3),
+.charts-row > *:nth-child(4) {
+  flex: 1 1 calc(50% - 8px);
   min-width: 0;
 }
 
@@ -538,10 +565,18 @@ onMounted(async () => {
   max-width: 300px;
 }
 
+.clear-filters-button {
+  margin-left: auto;
+}
+
 @media (max-width: 599px) {
   .filter-item {
     flex: 1 1 100%;
     max-width: 100%;
   }
+
+  .clear-filters-button {
+    width: 100%;
+  }
 }
 </style>

+ 73 - 0
src/pages/dashboard/StudentDetailPage.vue

@@ -0,0 +1,73 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Detalhe do Aluno" />
+
+    <div class="q-pa-md">
+      <div v-if="loading" class="flex flex-center q-pa-xl">
+        <q-spinner color="primary" size="50px" />
+      </div>
+
+      <div v-else-if="student" class="column q-gutter-md" style="max-width: 600px">
+        <q-card flat bordered style="border-radius: 12px">
+          <q-card-section>
+            <div class="text-subtitle2 text-grey-6 q-mb-xs">Nome do Aluno</div>
+            <div class="text-h6 text-dark">{{ student.name }}</div>
+          </q-card-section>
+
+          <q-separator inset />
+
+          <q-card-section>
+            <div class="text-subtitle2 text-grey-6 q-mb-xs">Contato / Unidade</div>
+            <div class="text-body1 text-dark">{{ student.phone ?? "—" }}</div>
+            <div class="text-caption text-grey-6">
+              {{ formatUnitName(student.unit) }}
+            </div>
+          </q-card-section>
+
+          <q-separator inset />
+
+          <q-card-section>
+            <div class="text-subtitle2 text-grey-6 q-mb-xs">Protocolo do Contrato</div>
+            <div class="text-body1 text-dark">
+              {{ student.protocol ?? "—" }}
+            </div>
+          </q-card-section>
+        </q-card>
+
+        <div>
+          <q-btn
+            flat
+            color="primary"
+            icon="mdi-arrow-left"
+            label="Voltar"
+            @click="$router.back()"
+          />
+        </div>
+      </div>
+
+      <div v-else class="text-grey-6 q-pa-md">Aluno não encontrado.</div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { useRoute } from "vue-router";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import { getFranchisorStudentDetail } from "src/api/student";
+import { formatUnitName } from "src/helpers/utils";
+
+const route = useRoute();
+const loading = ref(true);
+const student = ref(null);
+
+onMounted(async () => {
+  try {
+    student.value = await getFranchisorStudentDetail(route.params.id);
+  } catch {
+    // silent
+  } finally {
+    loading.value = false;
+  }
+});
+</script>

+ 42 - 44
src/pages/dashboard/components/AlunosAtivosDialog.vue → src/pages/dashboard/components/ActiveStudentsDialog.vue

@@ -2,9 +2,7 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card style="width: 800px; max-width: 95vw; border-radius: 12px">
       <q-bar class="bg-transparent q-px-md" style="height: 55px">
-        <span class="text-h6 text-dark" style="font-weight: 600"
-          >Alunos Ativos</span
-        >
+        <span class="text-h6 text-dark" style="font-weight: 600">Alunos Ativos</span>
         <q-space />
         <q-btn dense flat icon="mdi-close" @click="onDialogCancel" />
       </q-bar>
@@ -12,11 +10,11 @@
       <q-card-section class="q-pt-none q-pb-md q-px-md">
         <q-card flat bordered style="border-radius: 8px">
           <q-card-section class="q-pb-xs">
-            <div class="text-subtitle2 text-dark">Lista de alunos</div>
+            <div class="text-subtitle2 text-dark">Lista de alunos ativos</div>
             <div class="text-caption text-grey-6">
-              {{ alunos.length }} Alunos Cadastrados
+              {{ students.length }} Alunos Ativos
+              <q-spinner v-if="loading" size="16px" color="primary" class="q-ml-sm" />
             </div>
-            <q-spinner v-if="loading" size="16px" color="primary" class="q-ml-sm" />
           </q-card-section>
 
           <q-card-section class="q-pt-xs q-pb-sm">
@@ -24,7 +22,7 @@
               v-model="search"
               dense
               borderless
-              placeholder="Busque por status, nome, tel/whats"
+              placeholder="Busque por nome, tel/whats ou unidade"
             >
               <template #prepend>
                 <q-icon name="mdi-magnify" color="grey-6" />
@@ -43,24 +41,19 @@
           <q-separator />
 
           <div style="max-height: 320px; overflow-y: auto">
-            <template v-for="(aluno, index) in filteredAlunos" :key="aluno.id">
+            <template v-for="(student, index) in filteredStudents" :key="student.id">
               <div
                 class="list-row q-px-md q-py-sm"
-                :class="{ 'row-selected': index === selectedIndex }"
-                @click="selectedIndex = index"
+                @click="goToDetail(student)"
               >
-                <span class="text-body2 text-dark">{{ aluno.nome }}</span>
+                <span class="text-body2 text-dark">{{ student.name }}</span>
                 <div class="column" style="gap: 2px">
-                  <span class="text-caption text-dark">{{
-                    aluno.telefone
-                  }}</span>
-                  <span class="text-caption text-grey-6">{{
-                    aluno.unidade
-                  }}</span>
+                  <span class="text-caption text-dark">{{ student.phone }}</span>
+                  <span class="text-caption text-grey-6">{{ student.unit }}</span>
                 </div>
                 <q-badge
-                  :label="aluno.status"
-                  :color="aluno.status === 'Ativo' ? 'btn-badge' : 'grey'"
+                  label="Ativo"
+                  color="btn-badge"
                   style="
                     border-radius: 8px;
                     font-size: 11px;
@@ -70,7 +63,7 @@
                   "
                 />
               </div>
-              <q-separator v-if="index < filteredAlunos.length - 1" />
+              <q-separator v-if="index < filteredStudents.length - 1" />
             </template>
           </div>
         </q-card>
@@ -91,47 +84,56 @@
 <script setup>
 import { ref, computed, onMounted } from "vue";
 import { useDialogPluginComponent } from "quasar";
-import { getStudents } from "src/api/student";
+import { useRouter } from "vue-router";
+import { getFranchisorActiveStudents } from "src/api/student";
+import { formatUnitName } from "src/helpers/utils";
+
+const props = defineProps({
+  unitIds: { type: Array, default: () => [] },
+});
 
 defineEmits([...useDialogPluginComponent.emits]);
 
-const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+const { dialogRef, onDialogHide, onDialogCancel, onDialogOK } =
+  useDialogPluginComponent();
 
+const router = useRouter();
 const search = ref("");
-const selectedIndex = ref(0);
 const loading = ref(false);
-
-const alunos = ref([]);
+const students = ref([]);
 
 onMounted(async () => {
   loading.value = true;
   try {
-    const students = await getStudents();
-    alunos.value = students.map((s) => ({
+    const data = await getFranchisorActiveStudents(props.unitIds);
+    students.value = data.map((s) => ({
       id: s.id,
-      nome: s.name,
-      telefone: s.phone ?? "—",
-      unidade: s.unit?.fantasy_name ?? "—",
-      status: s.status === "active" ? "Ativo" : "Inativo",
+      name: s.name,
+      phone: s.phone ?? "—",
+      unit: formatUnitName(s.unit),
     }));
   } catch {
-    // silencioso
+    // silent
   } finally {
     loading.value = false;
   }
 });
 
-const filteredAlunos = computed(() => {
-  if (!search.value) return alunos.value;
+const filteredStudents = computed(() => {
+  if (!search.value) return students.value;
   const q = search.value.toLowerCase();
-  return alunos.value.filter(
-    (a) =>
-      a.nome.toLowerCase().includes(q) ||
-      a.telefone.includes(q) ||
-      a.unidade.toLowerCase().includes(q) ||
-      a.status.toLowerCase().includes(q),
+  return students.value.filter(
+    (s) =>
+      s.name.toLowerCase().includes(q) ||
+      s.phone.includes(q) ||
+      s.unit.toLowerCase().includes(q),
   );
 });
+
+const goToDetail = (student) => {
+  onDialogOK();
+  router.push({ name: "StudentDetailPage", params: { id: student.id } });
+};
 </script>
 
 <style scoped>
@@ -153,8 +155,4 @@ const filteredAlunos = computed(() => {
 .list-row:hover {
   background-color: #f5f5f5;
 }
-
-.row-selected {
-  background-color: #b2dfdb !important;
-}
 </style>

+ 24 - 18
src/pages/dashboard/components/FrequenciaMediaDialog.vue → src/pages/dashboard/components/AverageAttendanceDialog.vue

@@ -35,20 +35,20 @@
           <q-separator />
 
           <div>
-            <template
-              v-for="(item, index) in filteredUnidades"
-              :key="item.id"
-            >
+            <div v-if="filteredUnits.length === 0" class="text-grey-6 text-center q-pa-lg">
+              Nenhuma unidade encontrada.
+            </div>
+            <template v-for="(item, index) in filteredUnits" :key="item.id">
               <div
                 class="list-row q-px-md q-py-sm"
                 :class="{ 'row-selected': index === selectedIndex }"
                 @click="selectedIndex = index"
               >
-                <span class="text-body2 text-dark">{{ item.unidade }}</span>
-                <span class="text-body2 text-dark">{{ item.frequencia }}</span>
+                <span class="text-body2 text-dark">{{ item.unit }}</span>
+                <span class="text-body2 text-dark">{{ item.frequency }}%</span>
                 <q-badge
                   :label="item.status"
-                  :color="item.status === 'Alto' ? 'secondary' : 'orange'"
+                  :color="statusColor(item.status)"
                   style="
                     border-radius: 8px;
                     font-size: 11px;
@@ -57,7 +57,7 @@
                   "
                 />
               </div>
-              <q-separator v-if="index < filteredUnidades.length - 1" />
+              <q-separator v-if="index < filteredUnits.length - 1" />
             </template>
           </div>
         </q-card>
@@ -74,22 +74,28 @@ defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 
+const props = defineProps({
+  units: {
+    type: Array,
+    default: () => [],
+  },
+});
+
 const search = ref("");
 const selectedIndex = ref(0);
 
-const unidades = [
-  { id: 1, unidade: "São Paulo", frequencia: "47%", status: "Baixa" },
-  { id: 2, unidade: "Maringá", frequencia: "20 %", status: "Baixa" },
-  { id: 3, unidade: "Curitiba", frequencia: "79 %", status: "Alto" },
-  { id: 4, unidade: "Foz Iguaçu", frequencia: "98%", status: "Alto" },
-];
+const statusColor = (status) => {
+  if (status === "Alto") return "secondary";
+  if (status === "Sem dados") return "grey";
+  return "orange";
+};
 
-const filteredUnidades = computed(() => {
-  if (!search.value) return unidades;
+const filteredUnits = computed(() => {
+  if (!search.value) return props.units;
   const q = search.value.toLowerCase();
-  return unidades.filter(
+  return props.units.filter(
     (u) =>
-      u.unidade.toLowerCase().includes(q) ||
+      u.unit.toLowerCase().includes(q) ||
       u.status.toLowerCase().includes(q),
   );
 });

+ 45 - 77
src/pages/dashboard/components/ContratosCanceladosDialog.vue → src/pages/dashboard/components/CancelledContractsDialog.vue

@@ -2,9 +2,7 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card style="width: 800px; max-width: 95vw; border-radius: 12px">
       <q-bar class="bg-transparent q-px-md" style="height: 55px">
-        <span class="text-h6 text-dark" style="font-weight: 600"
-          >Contratos Cancelados</span
-        >
+        <span class="text-h6 text-dark" style="font-weight: 600">Contratos Cancelados</span>
         <q-space />
         <q-btn dense flat icon="mdi-close" @click="onDialogCancel" />
       </q-bar>
@@ -12,9 +10,10 @@
       <q-card-section class="q-pt-none q-pb-md q-px-md">
         <q-card flat bordered style="border-radius: 8px">
           <q-card-section class="q-pb-xs">
-            <div class="text-subtitle2 text-dark">Lista de alunos</div>
+            <div class="text-subtitle2 text-dark">Lista de contratos cancelados</div>
             <div class="text-caption text-grey-6">
-              {{ contratos.length }} Alunos Cadastrados
+              {{ contracts.length }} Contratos Cancelados
+              <q-spinner v-if="loading" size="16px" color="primary" class="q-ml-sm" />
             </div>
           </q-card-section>
 
@@ -23,7 +22,7 @@
               v-model="search"
               dense
               borderless
-              placeholder="Busque por status, nome, tel/whats"
+              placeholder="Busque por nome, tel/whats ou unidade"
             >
               <template #prepend>
                 <q-icon name="mdi-magnify" color="grey-6" />
@@ -42,26 +41,15 @@
           <q-separator />
 
           <div style="max-height: 320px; overflow-y: auto">
-            <template
-              v-for="(contrato, index) in filteredContratos"
-              :key="contrato.id"
-            >
-              <div
-                class="list-row q-px-md q-py-sm"
-                :class="{ 'row-selected': index === selectedIndex }"
-                @click="selectedIndex = index"
-              >
-                <span class="text-body2 text-dark">{{ contrato.nome }}</span>
+            <template v-for="(contract, index) in filteredContracts" :key="contract.id">
+              <div class="list-row q-px-md q-py-sm">
+                <span class="text-body2 text-dark">{{ contract.studentName }}</span>
                 <div class="column" style="gap: 2px">
-                  <span class="text-caption text-dark">{{
-                    contrato.telefone
-                  }}</span>
-                  <span class="text-caption text-grey-6">{{
-                    contrato.unidade
-                  }}</span>
+                  <span class="text-caption text-dark">{{ contract.studentPhone }}</span>
+                  <span class="text-caption text-grey-6">{{ contract.unitName }}</span>
                 </div>
                 <q-badge
-                  label="Cancelados"
+                  label="Cancelado"
                   color="negative"
                   style="
                     border-radius: 8px;
@@ -72,7 +60,7 @@
                   "
                 />
               </div>
-              <q-separator v-if="index < filteredContratos.length - 1" />
+              <q-separator v-if="index < filteredContracts.length - 1" />
             </template>
           </div>
         </q-card>
@@ -91,63 +79,47 @@
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
+import { ref, computed, onMounted } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { getFranchisorCancelledContracts } from "src/api/student_contract";
+
+const props = defineProps({
+  unitIds: { type: Array, default: () => [] },
+});
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 
 const search = ref("");
-const selectedIndex = ref(0);
-
-const contratos = [
-  {
-    id: 1,
-    nome: "Heloisa Faria",
-    telefone: "(45)99999-9999",
-    unidade: "Unidade franco",
-  },
-  {
-    id: 2,
-    nome: "Carol",
-    telefone: "(45)99999-9999",
-    unidade: "Arapongas-PR",
-  },
-  {
-    id: 3,
-    nome: "Marcelo Souza",
-    telefone: "(45)98888-8888",
-    unidade: "Curitiba-PR",
-  },
-  {
-    id: 4,
-    nome: "Ana Lúcia",
-    telefone: "(45)97777-7777",
-    unidade: "Londrina-PR",
-  },
-  {
-    id: 5,
-    nome: "Ricardo Silva",
-    telefone: "(45)96666-6666",
-    unidade: "Ponta Grossa-PR",
-  },
-  {
-    id: 6,
-    nome: "Juliana Costa",
-    telefone: "(45)95555-5555",
-    unidade: "Maringá-PR",
-  },
-];
-
-const filteredContratos = computed(() => {
-  if (!search.value) return contratos;
+const loading = ref(false);
+const contracts = ref([]);
+
+onMounted(async () => {
+  loading.value = true;
+  try {
+    const data = await getFranchisorCancelledContracts(props.unitIds);
+    contracts.value = data.map((c) => ({
+      id: c.id,
+      studentName: c.student_name ?? "—",
+      studentPhone: c.student_phone ?? "—",
+      unitName: c.unit_name ?? "—",
+    }));
+  } catch {
+    // silent
+  } finally {
+    loading.value = false;
+  }
+});
+
+const filteredContracts = computed(() => {
+  if (!search.value) return contracts.value;
   const q = search.value.toLowerCase();
-  return contratos.filter(
+  return contracts.value.filter(
     (c) =>
-      c.nome.toLowerCase().includes(q) ||
-      c.telefone.includes(q) ||
-      c.unidade.toLowerCase().includes(q),
+      c.studentName.toLowerCase().includes(q) ||
+      c.studentPhone.includes(q) ||
+      c.unitName.toLowerCase().includes(q),
   );
 });
 </script>
@@ -164,15 +136,11 @@ const filteredContratos = computed(() => {
   grid-template-columns: 1fr 1fr 100px;
   align-items: center;
   align-content: center;
-  cursor: pointer;
+  cursor: default;
   transition: background-color 0.15s;
 }
 
 .list-row:hover {
   background-color: #f5f5f5;
 }
-
-.row-selected {
-  background-color: #b2dfdb !important;
-}
 </style>

+ 44 - 76
src/pages/dashboard/components/ContratosCongeladosDialog.vue → src/pages/dashboard/components/FrozenContractsDialog.vue

@@ -2,9 +2,7 @@
   <q-dialog ref="dialogRef" @hide="onDialogHide">
     <q-card style="width: 800px; max-width: 95vw; border-radius: 12px">
       <q-bar class="bg-transparent q-px-md" style="height: 55px">
-        <span class="text-h6 text-dark" style="font-weight: 600"
-          >Contratos Congelados</span
-        >
+        <span class="text-h6 text-dark" style="font-weight: 600">Contratos Congelados</span>
         <q-space />
         <q-btn dense flat icon="mdi-close" @click="onDialogCancel" />
       </q-bar>
@@ -12,9 +10,10 @@
       <q-card-section class="q-pt-none q-pb-md q-px-md">
         <q-card flat bordered style="border-radius: 8px">
           <q-card-section class="q-pb-xs">
-            <div class="text-subtitle2 text-dark">Lista de alunos</div>
+            <div class="text-subtitle2 text-dark">Lista de contratos congelados</div>
             <div class="text-caption text-grey-6">
-              {{ contratos.length }} Alunos Cadastrados
+              {{ contracts.length }} Contratos Congelados
+              <q-spinner v-if="loading" size="16px" color="primary" class="q-ml-sm" />
             </div>
           </q-card-section>
 
@@ -23,7 +22,7 @@
               v-model="search"
               dense
               borderless
-              placeholder="Busque por status, nome, tel/whats"
+              placeholder="Busque por nome, tel/whats ou unidade"
             >
               <template #prepend>
                 <q-icon name="mdi-magnify" color="grey-6" />
@@ -42,23 +41,12 @@
           <q-separator />
 
           <div style="max-height: 320px; overflow-y: auto">
-            <template
-              v-for="(contrato, index) in filteredContratos"
-              :key="contrato.id"
-            >
-              <div
-                class="list-row q-px-md q-py-sm"
-                :class="{ 'row-selected': index === selectedIndex }"
-                @click="selectedIndex = index"
-              >
-                <span class="text-body2 text-dark">{{ contrato.nome }}</span>
+            <template v-for="(contract, index) in filteredContracts" :key="contract.id">
+              <div class="list-row q-px-md q-py-sm">
+                <span class="text-body2 text-dark">{{ contract.studentName }}</span>
                 <div class="column" style="gap: 2px">
-                  <span class="text-caption text-dark">{{
-                    contrato.telefone
-                  }}</span>
-                  <span class="text-caption text-grey-6">{{
-                    contrato.unidade
-                  }}</span>
+                  <span class="text-caption text-dark">{{ contract.studentPhone }}</span>
+                  <span class="text-caption text-grey-6">{{ contract.unitName }}</span>
                 </div>
                 <q-badge
                   label="Congelado"
@@ -72,7 +60,7 @@
                   "
                 />
               </div>
-              <q-separator v-if="index < filteredContratos.length - 1" />
+              <q-separator v-if="index < filteredContracts.length - 1" />
             </template>
           </div>
         </q-card>
@@ -91,63 +79,47 @@
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
+import { ref, computed, onMounted } from "vue";
 import { useDialogPluginComponent } from "quasar";
+import { getFranchisorFrozenContracts } from "src/api/student_contract";
+
+const props = defineProps({
+  unitIds: { type: Array, default: () => [] },
+});
 
 defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 
 const search = ref("");
-const selectedIndex = ref(0);
-
-const contratos = [
-  {
-    id: 1,
-    nome: "Heloisa Faria",
-    telefone: "(45)99999-9999",
-    unidade: "Unidade franco",
-  },
-  {
-    id: 2,
-    nome: "Carol",
-    telefone: "(45)99999-9999",
-    unidade: "Arapongas-PR",
-  },
-  {
-    id: 3,
-    nome: "Marcelo Souza",
-    telefone: "(45)98888-8888",
-    unidade: "Curitiba-PR",
-  },
-  {
-    id: 4,
-    nome: "Ana Lúcia",
-    telefone: "(45)97777-7777",
-    unidade: "Londrina-PR",
-  },
-  {
-    id: 5,
-    nome: "Ricardo Silva",
-    telefone: "(45)96666-6666",
-    unidade: "Ponta Grossa-PR",
-  },
-  {
-    id: 6,
-    nome: "Juliana Costa",
-    telefone: "(45)95555-5555",
-    unidade: "Maringá-PR",
-  },
-];
-
-const filteredContratos = computed(() => {
-  if (!search.value) return contratos;
+const loading = ref(false);
+const contracts = ref([]);
+
+onMounted(async () => {
+  loading.value = true;
+  try {
+    const data = await getFranchisorFrozenContracts(props.unitIds);
+    contracts.value = data.map((c) => ({
+      id: c.id,
+      studentName: c.student_name ?? "—",
+      studentPhone: c.student_phone ?? "—",
+      unitName: c.unit_name ?? "—",
+    }));
+  } catch {
+    // silent
+  } finally {
+    loading.value = false;
+  }
+});
+
+const filteredContracts = computed(() => {
+  if (!search.value) return contracts.value;
   const q = search.value.toLowerCase();
-  return contratos.filter(
+  return contracts.value.filter(
     (c) =>
-      c.nome.toLowerCase().includes(q) ||
-      c.telefone.includes(q) ||
-      c.unidade.toLowerCase().includes(q),
+      c.studentName.toLowerCase().includes(q) ||
+      c.studentPhone.includes(q) ||
+      c.unitName.toLowerCase().includes(q),
   );
 });
 </script>
@@ -164,15 +136,11 @@ const filteredContratos = computed(() => {
   grid-template-columns: 1fr 1fr 100px;
   align-items: center;
   align-content: center;
-  cursor: pointer;
+  cursor: default;
   transition: background-color 0.15s;
 }
 
 .list-row:hover {
   background-color: #f5f5f5;
 }
-
-.row-selected {
-  background-color: #b2dfdb !important;
-}
 </style>

+ 145 - 0
src/pages/dashboard/components/HolidayEditDialog.vue

@@ -0,0 +1,145 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin feriados-edit-dialog">
+      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
+
+      <q-card-section class="column q-gutter-md q-pt-sm">
+        <DefaultInput
+          v-model="description"
+          label="Nome do Evento"
+          placeholder="Ex: Ponto facultativo, Natal..."
+          icon="mdi-pencil-outline"
+          outlined
+          autofocus
+        />
+
+        <DefaultSelect
+          v-model="type"
+          label="Selecione o Tipo."
+          :options="typeOptions"
+          emit-value
+          map-options
+          outlined
+        />
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="between">
+        <q-btn
+          outline
+          color="negative"
+          label="EXCLUIR"
+          no-caps
+          :loading="deleting"
+          @click="confirmDelete"
+        />
+        <div class="row q-gutter-sm">
+          <q-btn outline color="primary" label="CANCELAR" no-caps @click="onDialogCancel" />
+          <q-btn
+            unelevated
+            color="primary"
+            label="SALVAR"
+            no-caps
+            :disable="!description.trim()"
+            :loading="saving"
+            @click="save"
+          />
+        </div>
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+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 { updateBaseHoliday, deleteBaseHoliday } from "src/api/holiday";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+const $q = useQuasar();
+
+const props = defineProps({
+  holiday: {
+    type: Object,
+    required: true,
+  },
+});
+
+const description = ref(props.holiday.description);
+const type = ref(props.holiday.type ?? "feriado");
+const saving = ref(false);
+const deleting = ref(false);
+
+const typeOptions = [
+  { label: "Feriado", value: "feriado" },
+  { label: "Ponto Facultativo", value: "facultativo" },
+];
+
+const dialogTitle = computed(() => {
+  const dd = String(props.holiday.day).padStart(2, "0");
+  const mm = String(props.holiday.month).padStart(2, "0");
+  return `${dd}/${mm}/${props.holiday.year}`;
+});
+
+async function save() {
+  const desc = description.value.trim();
+  if (!desc) return;
+
+  const mm = String(props.holiday.month).padStart(2, "0");
+  const dd = String(props.holiday.day).padStart(2, "0");
+
+  saving.value = true;
+  try {
+    const updated = await updateBaseHoliday(props.holiday.id, {
+      description: desc,
+      holiday_date: `${props.holiday.year}-${mm}-${dd}`,
+      type: type.value,
+    });
+
+    onDialogOK({
+      action: "update",
+      holiday: {
+        ...props.holiday,
+        description: updated.description,
+        type: updated.type,
+      },
+    });
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao salvar feriado." });
+  } finally {
+    saving.value = false;
+  }
+}
+
+function confirmDelete() {
+  $q.dialog({
+    title: "Excluir feriado",
+    message: `Deseja excluir "${props.holiday.description}"?`,
+    cancel: { label: "Cancelar", flat: true, color: "primary" },
+    ok: { label: "Excluir", unelevated: true, color: "negative" },
+  }).onOk(async () => {
+    deleting.value = true;
+    try {
+      await deleteBaseHoliday(props.holiday.id);
+      onDialogOK({ action: "delete", id: props.holiday.id });
+    } catch {
+      $q.notify({ type: "negative", message: "Erro ao excluir feriado." });
+    } finally {
+      deleting.value = false;
+    }
+  });
+}
+</script>
+
+<style scoped>
+.feriados-edit-dialog {
+  width: 420px;
+  max-width: 95vw;
+}
+</style>

+ 522 - 0
src/pages/dashboard/components/HolidaysDialog.vue

@@ -0,0 +1,522 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin 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
+                color="grey-7"
+                dense
+                flat
+                icon="mdi-chevron-left"
+                round
+                @click="prevMonth"
+              />
+
+              <div class="row items-center q-gutter-xs">
+                <q-select
+                  v-model="currentMonth"
+                  dense
+                  emit-value
+                  map-options
+                  outlined
+                  style="min-width: 100px"
+                  :options="monthOptions"
+                />
+
+                <q-select
+                  v-model="currentYear"
+                  dense
+                  emit-value
+                  map-options
+                  outlined
+                  style="min-width: 80px"
+                  :options="yearOptions"
+                />
+              </div>
+
+              <q-btn
+                color="grey-7"
+                dense
+                flat
+                icon="mdi-chevron-right"
+                round
+                @click="nextMonth"
+              />
+            </div>
+
+            <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="cell.day && !cell.isHoliday && openNewRecord(cell.day)"
+              >
+                <span v-if="cell.day" class="cal-cell__number">
+                  {{ cell.day }}
+                </span>
+              </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="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="grey-5"
+                    name="mdi-pencil-outline"
+                    size="xs"
+                  />
+                </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.
+            </div>
+          </div>
+        </q-card-section>
+
+        <q-separator />
+
+        <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+          <q-btn
+            color="negative"
+            label="Fechar"
+            no-caps
+            outline
+            @click="onClose"
+          />
+        </q-card-actions>
+      </template>
+
+      <template v-else>
+        <DefaultDialogHeader
+          :title="`Novo Registro: ${formattedSelectedDate}`"
+          @close="cancelNewRecord"
+        />
+
+        <DefaultForm ref="formRef" @submit="saveNewRecord">
+          <q-scroll-area ref="scrollAreaRef" class="form-dialog-scroll-area">
+            <q-card-section class="row q-col-gutter-md q-pt-none">
+              <DefaultInput
+                v-model="form.description"
+                v-model:error="validationErrors.description"
+                autofocus
+                class="col-12"
+                icon="mdi-pencil-outline"
+                label="Nome do Evento"
+                outlined
+                placeholder="Ex: Ponto facultativo, Natal..."
+                :error-message="validationErrors.description"
+                :rules="[inputRules.required, inputRules.max(255)]"
+              />
+
+              <DefaultSelect
+                v-model="form.type"
+                v-model:error="validationErrors.type"
+                class="col-12"
+                emit-value
+                label="Selecione o Tipo."
+                map-options
+                outlined
+                :error-message="validationErrors.type"
+                :options="typeOptions"
+              />
+            </q-card-section>
+          </q-scroll-area>
+
+          <q-separator />
+
+          <q-card-actions align="right" class="q-pa-md q-gutter-sm">
+            <q-btn
+              color="negative"
+              label="Cancelar"
+              no-caps
+              outline
+              @click="cancelNewRecord"
+            />
+
+            <q-btn
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              unelevated
+              :loading="saving"
+            />
+          </q-card-actions>
+        </DefaultForm>
+      </template>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { createBaseHoliday } from "src/api/holiday";
+
+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 DefaultForm from "src/components/defaults/DefaultForm.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import HolidayEditDialog from "src/pages/dashboard/components/HolidayEditDialog.vue";
+
+const props = defineProps({
+  initialHolidays: {
+    default: () => [],
+    type: Array,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const $q = useQuasar();
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent();
+const { inputRules } = useInputRules();
+
+const formRef = ref(null);
+const scrollAreaRef = ref(null);
+
+const { scrollToComponent } = useScroll();
+
+const { form } = useForm(
+  {
+    description: "",
+    type: "feriado",
+  },
+  { containerRef: scrollAreaRef },
+);
+
+const {
+  loading: saving,
+  validationErrors,
+  execute,
+} = useSubmitHandler({
+  containerRef: scrollAreaRef,
+  formRef,
+  onError: () => {
+    $q.notify({
+      message: "Erro ao salvar feriado.",
+      type: "negative",
+    });
+  },
+  scrollFn: scrollToComponent,
+});
+
+const currentMonth = ref(new Date().getMonth() + 1);
+const currentYear = ref(new Date().getFullYear());
+const holidays = ref([...props.initialHolidays]);
+const selectedDay = ref(null);
+const view = ref("calendar");
+
+const calendarCells = computed(() => {
+  const firstDay = new Date(
+    currentYear.value,
+    currentMonth.value - 1,
+    1,
+  ).getDay();
+
+  const daysInMonth = new Date(
+    currentYear.value,
+    currentMonth.value,
+    0,
+  ).getDate();
+
+  const holidayDays = new Set(
+    monthHolidays.value.map((holiday) => holiday.day),
+  );
+
+  const cells = [];
+
+  for (let day = 0; day < firstDay; day++) {
+    cells.push({
+      day: null,
+      isHoliday: false,
+    });
+  }
+
+  for (let day = 1; day <= daysInMonth; day++) {
+    cells.push({
+      day,
+      isHoliday: holidayDays.has(day),
+    });
+  }
+
+  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(() =>
+  holidays.value
+    .filter(
+      (holiday) =>
+        holiday.month === currentMonth.value &&
+        holiday.year === currentYear.value,
+    )
+    .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 },
+  { label: "Fev", value: 2 },
+  { label: "Mar", value: 3 },
+  { label: "Abr", value: 4 },
+  { label: "Mai", value: 5 },
+  { label: "Jun", value: 6 },
+  { label: "Jul", value: 7 },
+  { label: "Ago", value: 8 },
+  { label: "Set", value: 9 },
+  { label: "Out", value: 10 },
+  { label: "Nov", value: 11 },
+  { label: "Dez", value: 12 },
+];
+
+const typeOptions = [
+  { label: "Feriado", value: "feriado" },
+  { label: "Ponto Facultativo", value: "facultativo" },
+];
+
+const weekDays = ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"];
+
+const cancelNewRecord = () => {
+  selectedDay.value = null;
+  view.value = "calendar";
+};
+
+const nextMonth = () => {
+  if (currentMonth.value === 12) {
+    currentMonth.value = 1;
+    currentYear.value += 1;
+
+    return;
+  }
+
+  currentMonth.value += 1;
+};
+
+const onClose = () => {
+  onDialogOK(holidays.value);
+};
+
+const openEdit = (holiday) => {
+  $q.dialog({
+    component: HolidayEditDialog,
+    componentProps: {
+      holiday,
+    },
+  }).onOk(({ action, holiday: updatedHoliday, id }) => {
+    if (action === "update") {
+      const index = holidays.value.findIndex(
+        (item) => item.id === updatedHoliday.id,
+      );
+
+      if (index !== -1) {
+        holidays.value[index] = updatedHoliday;
+      }
+    } else if (action === "delete") {
+      holidays.value = holidays.value.filter((item) => item.id !== id);
+    }
+  });
+};
+
+const openNewRecord = (day) => {
+  selectedDay.value = day;
+  form.description = "";
+  form.type = "feriado";
+  view.value = "new-record";
+};
+
+const prevMonth = () => {
+  if (currentMonth.value === 1) {
+    currentMonth.value = 12;
+    currentYear.value -= 1;
+
+    return;
+  }
+
+  currentMonth.value -= 1;
+};
+
+const saveNewRecord = async () => {
+  const description = form.description.trim();
+
+  if (!description || selectedDay.value === null) return;
+
+  const day = String(selectedDay.value).padStart(2, "0");
+  const month = String(currentMonth.value).padStart(2, "0");
+
+  await execute(async () => {
+    const created = await createBaseHoliday({
+      description,
+      holiday_date: `${currentYear.value}-${month}-${day}`,
+      type: form.type,
+    });
+
+    holidays.value.push({
+      day: selectedDay.value,
+      description: created.description,
+      id: created.id,
+      month: currentMonth.value,
+      type: created.type,
+      year: currentYear.value,
+    });
+
+    selectedDay.value = null;
+    view.value = "calendar";
+  });
+};
+
+const typeLabel = (type) => type === "facultativo" ? "Ponto Facultativo" : "Feriado";
+</script>
+
+<style scoped>
+.feriados-dialog {
+  width: 900px;
+  max-width: 95vw;
+}
+
+.calendar-grid {
+  display: grid;
+  grid-template-columns: repeat(7, 1fr);
+  gap: 2px;
+}
+
+.cal-header {
+  padding: 6px 0 4px;
+  font-weight: 600;
+  text-align: center;
+}
+
+.cal-cell {
+  aspect-ratio: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  cursor: pointer;
+  transition: background 0.15s;
+}
+
+.cal-cell:hover:not(.cal-cell--empty):not(.cal-cell--holiday) {
+  background: #f5f5f5;
+}
+
+.cal-cell--empty {
+  cursor: default;
+  pointer-events: none;
+}
+
+.cal-cell__number {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 32px;
+  height: 32px;
+  border-radius: 50%;
+  font-size: 13px;
+  font-weight: 500;
+  line-height: 1;
+}
+
+.cal-cell--holiday .cal-cell__number {
+  background: #e64a19;
+  color: #fff;
+  font-weight: 700;
+}
+
+.cal-cell--holiday {
+  cursor: default;
+}
+</style>

+ 38 - 53
src/pages/dashboard/components/TicketsAbertoDialog.vue → src/pages/dashboard/components/OpenTicketsDialog.vue

@@ -32,47 +32,33 @@
             <span class="text-caption text-grey-7">Data</span>
             <span class="text-caption text-grey-7">Setor</span>
             <span class="text-caption text-grey-7">Status</span>
-            <span class="text-caption text-grey-7">Ações</span>
           </div>
 
           <q-separator />
 
           <div style="max-height: 320px; overflow-y: auto">
+            <div v-if="filteredTickets.length === 0" class="text-grey-6 text-center q-pa-lg">
+              Nenhum ticket aberto encontrado.
+            </div>
             <template v-for="(ticket, index) in filteredTickets" :key="ticket.id">
               <div
                 class="list-row q-px-md q-py-sm"
                 :class="{ 'row-selected': index === selectedIndex }"
                 @click="selectedIndex = index"
               >
-                <span class="text-body2 text-dark">{{ ticket.numero }}</span>
+                <span class="text-body2 text-dark">{{ ticket.number }}</span>
                 <q-badge
-                  :label="ticket.prioridade"
-                  :color="prioridadeColor(ticket.prioridade)"
+                  :label="priorityLabel(ticket.priority)"
+                  :color="priorityColor(ticket.priority)"
                   style="border-radius: 8px; font-size: 11px; padding: 4px 8px; width: max-content"
                 />
-                <span class="text-caption text-dark">{{ ticket.data }}</span>
-                <span class="text-caption text-dark">{{ ticket.setor }}</span>
+                <span class="text-caption text-dark">{{ ticket.date }}</span>
+                <span class="text-caption text-dark">{{ ticket.sector }}</span>
                 <q-badge
-                  :label="ticket.status"
+                  :label="statusLabel(ticket.status)"
                   :color="statusColor(ticket.status)"
                   style="border-radius: 8px; font-size: 11px; padding: 4px 8px; width: max-content"
                 />
-                <div class="row" style="gap: 6px">
-                  <q-btn
-                    flat
-                    dense
-                    icon="mdi-clipboard-outline"
-                    color="dark"
-                    style="border: 1px solid #ccc; border-radius: 6px; width: 30px; height: 30px"
-                  />
-                  <q-btn
-                    flat
-                    dense
-                    icon="mdi-delete-outline"
-                    color="dark"
-                    style="border: 1px solid #ccc; border-radius: 6px; width: 30px; height: 30px"
-                  />
-                </div>
               </div>
               <q-separator v-if="index < filteredTickets.length - 1" />
             </template>
@@ -91,48 +77,47 @@ defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 
+const props = defineProps({
+  tickets: {
+    type: Array,
+    default: () => [],
+  },
+});
+
 const search = ref("");
 const selectedIndex = ref(0);
 
-const tickets = [
-  {
-    id: 1,
-    numero: "#12577",
-    prioridade: "Baixa",
-    data: "15/02/2026",
-    setor: "Financeiro",
-    status: "Não Resolvido",
-  },
-  {
-    id: 2,
-    numero: "#12578",
-    prioridade: "Alta",
-    data: "16/02/2026",
-    setor: "Comercial",
-    status: "Em Análise",
-  },
-];
+const priorityLabel = (priority) =>
+  ({ high: "Alta", medium: "Média", low: "Baixa" })[priority] ?? priority;
 
-const prioridadeColor = (p) => {
-  if (p === "Alta") return "negative";
-  if (p === "Média") return "warning";
+const priorityColor = (priority) => {
+  if (priority === "high") return "negative";
+  if (priority === "medium") return "warning";
   return "orange";
 };
 
 const statusColor = (s) => {
-  if (s === "Resolvido") return "positive";
-  if (s === "Em Análise") return "warning";
+  if (s === "resolved") return "positive";
+  if (s === "in_progress") return "warning";
   return "negative";
 };
 
+const statusLabel = (status) =>
+  ({
+    in_progress: "Em andamento",
+    resolved: "Resolvido",
+    unresolved: "Não resolvido",
+  })[status] ?? status;
+
 const filteredTickets = computed(() => {
-  if (!search.value) return tickets;
+  if (!search.value) return props.tickets;
   const q = search.value.toLowerCase();
-  return tickets.filter(
+  return props.tickets.filter(
     (t) =>
-      t.numero.toLowerCase().includes(q) ||
-      t.data.includes(q) ||
-      t.setor.toLowerCase().includes(q) ||
+      t.number.toLowerCase().includes(q) ||
+      t.title.toLowerCase().includes(q) ||
+      t.date.includes(q) ||
+      t.sector.toLowerCase().includes(q) ||
       t.status.toLowerCase().includes(q),
   );
 });
@@ -141,13 +126,13 @@ const filteredTickets = computed(() => {
 <style scoped>
 .list-header {
   display: grid;
-  grid-template-columns: 90px 100px 110px 1fr 140px 90px;
+  grid-template-columns: 90px 100px 110px 1fr 140px;
   align-items: center;
 }
 
 .list-row {
   display: grid;
-  grid-template-columns: 90px 100px 110px 1fr 140px 90px;
+  grid-template-columns: 90px 100px 110px 1fr 140px;
   align-items: center;
   cursor: pointer;
   transition: background-color 0.15s;

+ 15 - 29
src/pages/dashboard/components/EstoqueProdutosDialog.vue → src/pages/dashboard/components/ProductStockDialog.vue

@@ -19,16 +19,18 @@
             <div class="list-header q-px-md q-py-xs">
               <span class="text-caption text-grey-7">Produto</span>
               <span class="text-caption text-grey-7">Estoque</span>
-              <span class="text-caption text-grey-7">Ações</span>
             </div>
 
             <q-separator />
 
-            <template v-for="(item, index) in produtos" :key="item.id">
+            <div v-if="props.products.length === 0" class="text-grey-6 text-center q-pa-lg">
+              Nenhum produto cadastrado.
+            </div>
+            <template v-for="(item, index) in props.products" :key="item.id">
               <div class="list-row q-px-md q-py-sm">
-                <span class="text-body2 text-dark">{{ item.nome }}</span>
+                <span class="text-body2 text-dark">{{ item.name }}</span>
                 <div class="row items-center" style="gap: 12px">
-                  <span class="text-body2 text-dark">{{ item.quantidade }}</span>
+                  <span class="text-body2 text-dark">{{ item.quantity }}</span>
                   <q-badge
                     :label="item.status"
                     :outline="item.status === 'Limitado'"
@@ -36,24 +38,8 @@
                     style="border-radius: 8px; font-size: 11px; padding: 4px 10px"
                   />
                 </div>
-                <div class="row" style="gap: 6px">
-                  <q-btn
-                    flat
-                    dense
-                    icon="mdi-clipboard-outline"
-                    color="dark"
-                    style="border: 1px solid #ccc; border-radius: 6px; width: 34px; height: 34px"
-                  />
-                  <q-btn
-                    flat
-                    dense
-                    icon="mdi-delete-outline"
-                    color="dark"
-                    style="border: 1px solid #ccc; border-radius: 6px; width: 34px; height: 34px"
-                  />
-                </div>
               </div>
-              <q-separator v-if="index < produtos.length - 1" />
+              <q-separator v-if="index < props.products.length - 1" />
             </template>
           </q-card>
         </q-card>
@@ -69,12 +55,12 @@ defineEmits([...useDialogPluginComponent.emits]);
 
 const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
 
-const produtos = [
-  { id: 1, nome: "Box Viver", quantidade: 14, status: "Baixo" },
-  { id: 2, nome: "Brinquedo 1", quantidade: 100, status: "Alto" },
-  { id: 3, nome: "Brinquedo 2", quantidade: 100, status: "Alto" },
-  { id: 4, nome: "Novo Produto", quantidade: 30, status: "Limitado" },
-];
+const props = defineProps({
+  products: {
+    type: Array,
+    default: () => [],
+  },
+});
 
 const statusColor = (status) => {
   if (status === "Alto") return "primary";
@@ -86,13 +72,13 @@ const statusColor = (status) => {
 <style scoped>
 .list-header {
   display: grid;
-  grid-template-columns: 1fr 1fr 90px;
+  grid-template-columns: 1fr 1fr;
   align-items: center;
 }
 
 .list-row {
   display: grid;
-  grid-template-columns: 1fr 1fr 90px;
+  grid-template-columns: 1fr 1fr;
   align-items: center;
 }
 </style>

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

@@ -0,0 +1,152 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Contas a Pagar" :show-filter-icon="false" />
+
+    <div class="row q-pa-md q-gutter-md">
+      <FinancialCard
+        title="Pago"
+        icon="mdi-check-circle-outline"
+        :financial-value="totals.paid"
+        :integer="totals.paidCount"
+        integer-label="pagamentos"
+      />
+      <FinancialCard
+        title="A Pagar"
+        icon="mdi-cash-minus"
+        :financial-value="totals.pending"
+        :integer="totals.pendingCount"
+        integer-label="pendentes"
+      />
+      <FinancialCard
+        title="Vencidas"
+        icon="mdi-alert-circle-outline"
+        :financial-value="totals.overdue"
+        :integer="totals.overdueCount"
+        integer-label="em atraso"
+      />
+    </div>
+
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        no-api-call
+        :add-item="canAdd"
+        title="Contas a Pagar"
+        description="contas"
+        :female="true"
+        :columns="columns"
+        @on-add-item="openCreate"
+      >
+        <template #body-cell-plan_account="{ row }">
+          <q-td class="text-left">
+            {{ row.plan_account ? `${row.plan_account.code} — ${row.plan_account.description}` : "—" }}
+          </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' : 'orange-7'"
+              :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
+              dense
+              no-caps
+              unelevated
+              :disable="!canEdit || row.status === 'paid'"
+              @click.prevent.stop="changeStatus(row)"
+            >
+              <q-tooltip v-if="canEdit && row.status !== 'paid'">Dar baixa</q-tooltip>
+            </q-btn>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+
+  </div>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { useQuasar } from "quasar";
+import { getCompanyPayables } from "src/api/company_payable";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+import AddCompanyPayableDialog from "src/components/financial/AddCompanyPayableDialog.vue";
+import SettleCompanyPayableDialog from "src/components/financial/SettleCompanyPayableDialog.vue";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const rows = ref([]);
+const permissions = permissionStore();
+const canAdd = permissions.getAccess("franchisor_financial", "add");
+const canEdit = permissions.getAccess("franchisor_financial", "edit");
+
+const columns = [
+  { name: "history", label: "Descrição", field: "history", align: "left" },
+  { name: "plan_account", label: "Plano de Contas", field: "plan_account", align: "left" },
+  { name: "value", label: "Valor", field: "value", align: "left" },
+  { name: "due_date", label: "Vencimento", field: "due_date", align: "left" },
+  { name: "status", label: "Status", field: "status", align: "left" },
+];
+
+function formatCurrency(value) {
+  return new Intl.NumberFormat("pt-BR", {
+    style: "currency",
+    currency: "BRL",
+  }).format(Number(value ?? 0));
+}
+
+const totals = computed(() => {
+  const acc = {
+    paid: 0,
+    paidCount: 0,
+    pending: 0,
+    pendingCount: 0,
+    overdue: 0,
+    overdueCount: 0,
+  };
+  for (const r of rows.value) {
+    if (r.status === "paid") {
+      acc.paid += Number(r.paid_value ?? 0);
+      acc.paidCount++;
+    } else if (r.status === "overdue") {
+      acc.overdue += Number(r.value ?? 0);
+      acc.overdueCount++;
+    } else if (r.status === "pending") {
+      acc.pending += Number(r.value ?? 0);
+      acc.pendingCount++;
+    }
+  }
+  return acc;
+});
+
+async function loadData() {
+  try {
+    rows.value = await getCompanyPayables();
+  } catch (e) {
+    console.error("Erro ao buscar contas a pagar:", e);
+  }
+}
+
+function openSettle(row) {
+  $q.dialog({
+    component: SettleCompanyPayableDialog,
+    componentProps: { payable: row },
+  }).onOk(loadData);
+}
+
+function changeStatus(row) {
+  if (row.status !== "paid") openSettle(row);
+}
+
+function openCreate() {
+  $q.dialog({ component: AddCompanyPayableDialog }).onOk(loadData);
+}
+
+onMounted(loadData);
+</script>

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

@@ -0,0 +1,201 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Contas a Receber" :show-filter-icon="false" />
+
+    <div class="row q-pa-md q-gutter-md">
+      <FinancialCard
+        title="Saldo Tesouraria"
+        icon="mdi-bank-outline"
+        :financial-value="0"
+      />
+      <FinancialCard
+        title="Contas Recebidas"
+        icon="mdi-check-circle-outline"
+        :financial-value="receivedTotal"
+        :integer="receivedCount"
+        integer-label="títulos recebidos"
+      />
+      <FinancialCard
+        title="Contas a Receber"
+        icon="mdi-cash-plus"
+        :financial-value="pendingTotal"
+        :integer="pendingCount"
+        integer-label="títulos pendentes"
+      />
+    </div>
+
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        no-api-call
+        :add-item="canAdd"
+        :loading="loading"
+        title="Contas a Receber"
+        description="contas"
+        :female="true"
+        :columns="columns"
+        @on-add-item="openCreate"
+      >
+        <template #body-cell-status="{ row }">
+          <q-td class="text-left">
+            <q-btn
+              :color="row.status === 'paid' ? 'positive' : 'orange-7'"
+              :label="row.status === 'paid' ? 'Pago' : 'Não pago'"
+              dense
+              no-caps
+              unelevated
+              :disable="!canEdit || row.status === 'paid'"
+              @click.prevent.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"
+              round
+              dense
+              flat
+              color="teal-7"
+              icon="mdi-open-in-new"
+              aria-label="Abrir cobrança"
+              @click="openInvoice(row)"
+            >
+              <q-tooltip>Abrir cobrança no Asaas</q-tooltip>
+            </q-btn>
+            <q-btn
+              v-else-if="
+                canEdit &&
+                row.unit_id &&
+                row.status !== 'paid' &&
+                row.status !== 'cancelled'
+              "
+              round
+              dense
+              outline
+              color="deep-purple-5"
+              icon="mdi-cash-fast"
+              aria-label="Gerar cobrança"
+              :loading="chargingId === row.id"
+              @click="onCharge(row)"
+            >
+              <q-tooltip>Gerar cobrança no Asaas</q-tooltip>
+            </q-btn>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { Notify, useQuasar } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+import {
+  getFranchiseeReceivables,
+  chargeFranchiseeReceivable,
+} from "src/api/franchisee_account_receive";
+import AddAccountReceivableDialog from "src/components/financial/AddAccountReceivableDialog.vue";
+import SettleAccountReceivableDialog from "src/components/financial/SettleAccountReceivableDialog.vue";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const permissions = permissionStore();
+const canAdd = permissions.getAccess("franchisor_financial", "add");
+const canEdit = permissions.getAccess("franchisor_financial", "edit");
+const loading = ref(false);
+const items = ref([]);
+const chargingId = ref(null);
+
+const columns = [
+  { name: "unit", label: "Unidade", field: "unit", align: "left" },
+  { name: "description", label: "Descrição", field: "description", align: "left" },
+  { name: "category", label: "Categoria", field: "category", align: "left" },
+  { name: "value", label: "Valor", field: "value", align: "left" },
+  { name: "due_date", label: "Vencimento", field: "due_date", align: "left" },
+  { name: "status", label: "Status", field: "status", align: "left" },
+  { name: "actions", label: "Ações", field: "actions", align: "right" },
+];
+
+const formatCurrency = (value) =>
+  new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(value ?? 0);
+
+const formatDate = (value) => {
+  if (!value) return "—";
+  const [y, m, d] = value.split("-");
+  return `${d}/${m}/${y}`;
+};
+
+const rows = computed(() =>
+  items.value.map((i) => ({
+    ...i,
+    unit: i.unit_id ? (i.unit_name ?? `Unidade ${i.unit_id}`) : "Conta interna",
+    description: i.history,
+    category: i.plan_account
+      ? `${i.plan_account.code} — ${i.plan_account.description}`
+      : i.origin === "tbr" ? "TBR" : "—",
+    value: formatCurrency(Number(i.value)),
+    due_date: formatDate(i.due_date),
+  })),
+);
+
+const isSettled = (status) => status === "paid";
+
+const receivedTotal = computed(() =>
+  items.value
+    .filter((i) => isSettled(i.status))
+    .reduce((acc, i) => acc + Number(i.paid_value || 0), 0),
+);
+const receivedCount = computed(() => items.value.filter((i) => isSettled(i.status)).length);
+
+const pendingTotal = computed(() =>
+  items.value
+    .filter((i) => !isSettled(i.status) && i.status !== "cancelled")
+    .reduce((acc, i) => acc + (Number(i.value || 0) - Number(i.paid_value || 0)), 0),
+);
+const pendingCount = computed(
+  () => items.value.filter((i) => !isSettled(i.status) && i.status !== "cancelled").length,
+);
+
+const fetchReceivables = async () => {
+  loading.value = true;
+  try {
+    items.value = await getFranchiseeReceivables();
+  } catch {
+    Notify.create({ message: "Não foi possível carregar as contas a receber.", type: "negative" });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const openCreate = () => {
+  $q.dialog({ component: AddAccountReceivableDialog }).onOk(fetchReceivables);
+};
+
+const changeStatus = (row) => {
+  if (row.status === "paid") return;
+  $q.dialog({
+    component: SettleAccountReceivableDialog,
+    componentProps: { receivable: row },
+  }).onOk(fetchReceivables);
+};
+
+const onCharge = async (row) => {
+  chargingId.value = row.id;
+  try {
+    await chargeFranchiseeReceivable(row.id);
+    await fetchReceivables();
+  } finally {
+    chargingId.value = null;
+  }
+};
+
+const openInvoice = (row) => window.open(row.invoice_url, "_blank");
+
+onMounted(fetchReceivables);
+</script>

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

@@ -0,0 +1,164 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Integração Asaas" />
+
+    <div class="q-pa-md">
+      <q-card
+        bordered
+        class="q-pa-md"
+        flat
+        style="max-width: 720px"
+      >
+        <div class="row items-center no-wrap q-mb-xs">
+          <q-icon
+            class="q-mr-sm"
+            color="primary"
+            name="mdi-cash-multiple"
+            size="sm"
+          />
+
+          <div class="text-subtitle1 text-weight-medium">
+            Asaas — Conta da Franqueadora
+          </div>
+
+          <q-space />
+
+          <q-badge
+            class="q-px-sm q-py-xs"
+            :color="account.asaas_configured ? 'positive' : 'grey-6'"
+            :label="
+              account.asaas_configured ? 'Configurada' : 'Não configurada'
+            "
+          />
+        </div>
+
+        <div class="text-body2 text-grey-7 q-mb-md">
+          Informe aqui a chave da API da conta Asaas da franqueadora. Ela é
+          usada para emitir as cobranças do TBR das unidades. Você pode trocá-la
+          a qualquer momento — sem depender do suporte. Enquanto não houver
+          chave, o sistema usa a configuração padrão do ambiente.
+        </div>
+
+        <DefaultForm ref="formRef" @submit="onSave">
+          <DefaultPasswordInput
+            v-model="form.asaas_api_key"
+            v-model:error="validationErrors.asaas_api_key"
+            autocomplete="off"
+            outlined
+            :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 || ''"
+            :rules="[inputRules.max(500)]"
+          />
+
+          <div
+            class="row justify-end items-center q-mt-md"
+            style="gap: 12px"
+          >
+            <q-btn
+              v-if="canEdit && account.asaas_configured"
+              color="negative"
+              flat
+              label="Remover chave"
+              no-caps
+              :disable="loading"
+              @click="onRemove"
+            />
+
+            <q-btn
+              v-if="canEdit"
+              color="primary"
+              label="Salvar"
+              no-caps
+              type="submit"
+              unelevated
+              :disable="!form.asaas_api_key"
+              :loading="loading"
+            />
+          </div>
+        </DefaultForm>
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import {
+  getCompanyPaymentAccount,
+  saveCompanyPaymentAccount,
+} from "src/api/company_payment_account";
+
+import { onMounted, ref } 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 DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultPasswordInput from "src/components/defaults/DefaultPasswordInput.vue";
+
+const { inputRules } = useInputRules();
+const { scrollToComponent } = useScroll();
+
+const { form, getUpdatedFields } = useForm({
+  asaas_api_key: "",
+});
+
+const formRef = ref(null);
+
+const { loading, validationErrors, execute } = useSubmitHandler({
+  formRef,
+  scrollFn: scrollToComponent,
+});
+
+const account = ref({
+  asaas_api_key_masked: null,
+  asaas_configured: false,
+  status: "pending",
+});
+
+const canEdit = permissionStore().getAccess("franchisor_financial", "edit");
+
+const defaultAccount = () => ({
+  asaas_api_key_masked: null,
+  asaas_configured: false,
+  status: "pending",
+});
+
+const fetchData = async () => {
+  try {
+    const data = await getCompanyPaymentAccount();
+
+    account.value = data ?? defaultAccount();
+  } catch (error) {
+    console.error(error);
+  }
+};
+
+const onRemove = () => {
+  persist("");
+};
+
+const onSave = () => {
+  if (!form.asaas_api_key) return;
+
+  persist(form.asaas_api_key);
+};
+
+const persist = async (value) => {
+  await execute(async () => {
+    account.value = await saveCompanyPaymentAccount(
+      value === "" ? { asaas_api_key: "" } : getUpdatedFields.value,
+    );
+
+    form.asaas_api_key = "";
+  });
+};
+
+onMounted(fetchData);
+</script>

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

@@ -0,0 +1,157 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Plano de Contas" :show-filter-icon="false" />
+
+    <div class="q-px-md">
+      <q-card flat bordered class="q-pa-md q-mt-md">
+        <div class="row items-center q-gutter-md q-mb-md">
+          <div>
+            <div class="text-h6">Plano de Contas</div>
+            <div class="text-body2">{{ accounts.length }} contas cadastradas</div>
+          </div>
+          <q-space />
+          <q-btn
+            v-if="canAdd"
+            color="primary"
+            label="Nova Conta"
+            no-caps
+            unelevated
+            @click="handleAddItem"
+          />
+        </div>
+
+        <DefaultInput
+          v-model="search"
+          dense
+          clearable
+          debounce="250"
+          label="Buscar por código ou nome da conta"
+          class="q-mb-md"
+        >
+          <template #prepend>
+            <q-icon name="mdi-magnify" color="grey-6" />
+          </template>
+        </DefaultInput>
+
+        <div class="account-tree-header">
+          <div>Código</div>
+          <div>Nome da Conta</div>
+          <div>Tipo</div>
+        </div>
+
+        <q-list>
+          <FinancialPlanAccountTreeNode
+            v-for="node in filteredTree"
+            :key="node.id"
+            :node="node"
+            :force-expanded="!!normalizedSearch"
+          />
+        </q-list>
+
+        <div v-if="!loading && filteredTree.length === 0" class="text-center q-pa-lg">
+          Nenhuma conta encontrada.
+        </div>
+
+        <q-inner-loading :showing="loading" color="primary" />
+      </q-card>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useQuasar } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import AddFinancialPlanAccountDialog from "src/components/financial/AddFinancialPlanAccountDialog.vue";
+import FinancialPlanAccountTreeNode from "src/components/financial/FinancialPlanAccountTreeNode.vue";
+import { getFinancialPlanAccounts } from "src/api/financial_plan_account";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const canAdd = permissionStore().getAccess("franchisor_financial", "add");
+
+const accounts = ref([]);
+const loading = ref(false);
+const search = ref("");
+
+const accountTree = computed(() => {
+  const nodes = new Map(
+    accounts.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);
+    else roots.push(node);
+  }
+
+  const sortNodes = (items) => {
+    items.sort((a, b) =>
+      String(a.code).localeCompare(String(b.code), "pt-BR", { numeric: true }),
+    );
+    items.forEach((item) => sortNodes(item.children));
+    return items;
+  };
+
+  return sortNodes(roots);
+});
+
+const normalizedSearch = computed(() => search.value?.trim().toLocaleLowerCase("pt-BR") ?? "");
+
+const filteredTree = computed(() => {
+  if (!normalizedSearch.value) return accountTree.value;
+
+  const filterNodes = (nodes) =>
+    nodes.reduce((result, node) => {
+      const children = filterNodes(node.children);
+      const matches = `${node.code} ${node.description}`
+        .toLocaleLowerCase("pt-BR")
+        .includes(normalizedSearch.value);
+
+      if (matches || children.length) result.push({ ...node, children });
+      return result;
+    }, []);
+
+  return filterNodes(accountTree.value);
+});
+
+const fetchAccounts = async () => {
+  loading.value = true;
+  try {
+    accounts.value = await getFinancialPlanAccounts();
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao carregar o plano de contas." });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleAddItem = () => {
+  $q.dialog({ component: AddFinancialPlanAccountDialog }).onOk(() => {
+    $q.notify({ type: "positive", message: "Conta cadastrada com sucesso." });
+    fetchAccounts();
+  });
+};
+
+onMounted(fetchAccounts);
+</script>
+
+<style scoped>
+.account-tree-header {
+  display: grid;
+  grid-template-columns: 18% 1fr 18%;
+  gap: 16px;
+  padding: 12px 56px 12px 16px;
+  border-bottom: 1px solid #c7c7c7;
+  font-weight: 600;
+}
+
+@media (max-width: 599px) {
+  .account-tree-header {
+    grid-template-columns: 25% 1fr 25%;
+    padding-left: 8px;
+  }
+}
+</style>

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

@@ -0,0 +1,134 @@
+<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="Fundo de Marketing"
+        icon="mdi-cash-multiple"
+        :financial-value="overview.marketing_fund.value"
+        :percentage="overview.marketing_fund.percentage"
+        :description="trend(overview.marketing_fund.percentage)"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="Receita de Mensalidade"
+        icon="mdi-cash-multiple"
+        :financial-value="overview.monthly_revenue.value"
+        :percentage="overview.monthly_revenue.percentage"
+        :description="trend(overview.monthly_revenue.percentage)"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="TBR Total"
+        icon="mdi-cash-multiple"
+        :financial-value="overview.tbr_total.value"
+        :percentage="overview.tbr_total.percentage"
+        :description="trend(overview.tbr_total.percentage)"
+        :hide-values="!showValues"
+      />
+      <TreasuryCard />
+    </div>
+
+    <div class="row q-px-md q-gutter-md">
+      <FinancialCard
+        title="Contas a Pagar"
+        icon="mdi-cash-minus"
+        :financial-value="overview.accounts_payable.value"
+        :integer="overview.accounts_payable.count"
+        integer-label="pagamentos pendentes"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="Contas a Receber"
+        icon="mdi-cash-plus"
+        :financial-value="overview.accounts_receivable.value"
+        :integer="overview.accounts_receivable.count"
+        integer-label="pagamentos pendentes"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="Saldo Bancário Total"
+        icon="mdi-bank-outline"
+        :financial-value="overview.bank_balance_total"
+        :hide-values="!showValues"
+      />
+      <FinancialCard
+        title="Valor Total do Estoque"
+        icon="mdi-package-variant-closed"
+        :financial-value="overview.stock_value"
+        :hide-values="!showValues"
+      />
+    </div>
+
+    <div class="row q-px-md q-gutter-md q-mt-none">
+      <LastTransactionsCard :rows="overview.last_transactions" :loading="loading" />
+      <FranchisePerformanceCard :rows="overview.franchise_performance" :loading="loading" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, watch, onMounted } from "vue";
+import { Notify } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+import TreasuryCard from "src/components/financial/TreasuryCard.vue";
+import LastTransactionsCard from "src/components/financial/LastTransactionsCard.vue";
+import FranchisePerformanceCard from "src/components/financial/FranchisePerformanceCard.vue";
+import { getFinancialOverview } from "src/api/financial_dashboard";
+
+const STORAGE_KEY = "financial_show_values";
+const showValues = ref(localStorage.getItem(STORAGE_KEY) !== "false");
+
+watch(showValues, (val) => localStorage.setItem(STORAGE_KEY, String(val)));
+
+const loading = ref(false);
+const overview = ref(emptyOverview());
+
+function emptyOverview() {
+  return {
+    marketing_fund: { value: 0, percentage: 0 },
+    monthly_revenue: { value: 0, percentage: 0 },
+    tbr_total: { value: 0, percentage: 0 },
+    accounts_payable: { value: 0, count: 0 },
+    accounts_receivable: { value: 0, count: 0 },
+    bank_balance_total: 0,
+    stock_value: 0,
+    last_transactions: [],
+    franchise_performance: [],
+  };
+}
+
+const trend = (percentage) => {
+  if (percentage > 0) return "Em alta";
+  if (percentage < 0) return "Em queda";
+  return "Estável";
+};
+
+const fetchOverview = async () => {
+  loading.value = true;
+  try {
+    overview.value = await getFinancialOverview();
+  } catch {
+    Notify.create({ message: "Não foi possível carregar os dados financeiros.", type: "negative" });
+  } finally {
+    loading.value = false;
+  }
+};
+
+onMounted(fetchOverview);
+</script>

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

@@ -0,0 +1,84 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Emissão de Notas" :show-filter-icon="false" />
+
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        :add-item="canAdd"
+        :columns="columns"
+        :female="true"
+        description="notas"
+        no-api-call
+        title="Emissão de Notas"
+        @on-add-item="handleAddItem"
+      >
+        <template #body-cell-actions="{ row }">
+          <q-td align="center">
+            <q-btn
+              icon="mdi-file-outline"
+              outline
+              style="width: 36px"
+              @click.prevent.stop="handleView(row)"
+            />
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { permissionStore } from "src/stores/permission";
+import { ref } from "vue";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+
+const rows = ref([]);
+
+const canAdd = permissionStore().getAccess("franchisor_financial", "add");
+
+const columns = [
+  {
+    align: "left",
+    field: "nf",
+    label: "NF",
+    name: "nf",
+  },
+  {
+    align: "left",
+    field: "name",
+    label: "Nome",
+    name: "name",
+  },
+  {
+    align: "left",
+    field: "due_date",
+    label: "Data de Vencimento",
+    name: "due_date",
+  },
+  {
+    align: "left",
+    field: "value",
+    label: "Valor",
+    name: "value",
+  },
+  {
+    align: "left",
+    field: "status",
+    label: "Status",
+    name: "status",
+  },
+  {
+    align: "center",
+    field: "actions",
+    label: "Ações",
+    name: "actions",
+  },
+];
+
+const handleAddItem = () => {};
+
+const handleView = () => {};
+</script>

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

@@ -0,0 +1,240 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Tesouraria" :show-filter-icon="false" />
+
+    <div class="flex justify-end">
+      <q-btn v-if="!loading && canAdd" color="primary" style="height: 40px; width: 40px;" icon="mdi-plus" @click="openCreateDialog"/>
+    </div>
+    <div class="row q-pa-md q-col-gutter-md items-stretch">
+      <div v-for="bank in cards" :key="bank.key" class="col-3">
+        <FinancialCard
+          :title="bank.label"
+          :icon="bank.icon"
+          :financial-value="bank.value"
+          :percentage="bank.percentage"
+          clickable
+          :editable="canEdit && !!bank.account"
+          :selected="selectedBank.key === bank.key"
+          @click="selectedBank = bank"
+          @edit="openEditDialog(bank.account)"
+        />
+      </div>
+    </div>
+
+    <div class="q-px-md">
+      <DefaultTable
+        v-model:rows="rows"
+        no-api-call
+        :add-item="canAdd"
+        :title="`Extrato ${selectedBank.label}`"
+        description="registros"
+        :female="false"
+        :columns="columns"
+        @on-add-item="handleAddItem"
+      >
+        <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"
+                aria-label="Editar movimentação"
+                @click.prevent.stop="handleEditLaunch(row)"
+              >
+                <q-tooltip>Editar</q-tooltip>
+              </q-btn>
+              <q-btn
+                v-if="canDelete"
+                outline
+                color="negative"
+                icon="mdi-trash-can-outline"
+                style="width: 36px"
+                aria-label="Excluir movimentação"
+                :loading="deletingId === row.id"
+                @click.prevent.stop="handleDeleteLaunch(row)"
+              >
+                <q-tooltip>Excluir</q-tooltip>
+              </q-btn>
+            </div>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, watch } from "vue";
+import { useQuasar } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import FinancialCard from "src/components/financial/FinancialCard.vue";
+import AddEditTreasuryAccountDialog from "src/components/financial/AddEditTreasuryAccountDialog.vue";
+import AddTreasuryLaunchDialog from "src/components/financial/AddTreasuryLaunchDialog.vue";
+import { getTreasuryAccounts } from "src/api/treasury_account";
+import { getTreasuryLaunches, deleteTreasuryLaunch } from "src/api/treasury_launch";
+import { formatToBRLCurrency, formatDateYMDtoDMY } from "src/helpers/utils";
+import { permissionStore } from "src/stores/permission";
+
+const $q = useQuasar();
+const permissions = permissionStore();
+const canAdd = permissions.getAccess("franchisor_financial", "add");
+const canEdit = permissions.getAccess("franchisor_financial", "edit");
+const canDelete = permissions.getAccess("franchisor_financial", "delete");
+
+const accounts = ref([]);
+const launches = ref([]);
+const loading = ref(false);
+const deletingId = ref(null);
+
+// Variação % do saldo vs. o fim do mês anterior.
+const monthPercentage = (current, previous) => {
+  if (previous === 0) return current > 0 ? 100 : 0;
+  return Math.round(((current - previous) / Math.abs(previous)) * 1000) / 10;
+};
+
+const totalCard = computed(() => {
+  const value = accounts.value.reduce((sum, account) => sum + (account.balance ?? 0), 0);
+  const previous = accounts.value.reduce((sum, account) => sum + (account.balance_previous ?? 0), 0);
+  return {
+    key: "total",
+    label: "Saldo Total",
+    icon: "mdi-bank",
+    value,
+    percentage: monthPercentage(value, previous),
+  };
+});
+
+const selectedBank = ref({ key: "total", label: "Saldo Total" });
+
+const cards = computed(() => [
+  totalCard.value,
+  ...accounts.value.map((account) => ({
+    key: `account-${account.id}`,
+    label: account.name,
+    icon: "mdi-bank-outline",
+    value: account.balance ?? 0,
+    percentage: monthPercentage(account.balance ?? 0, account.balance_previous ?? 0),
+    account,
+  })),
+]);
+
+const rows = computed(() =>
+  launches.value.map((launch) => {
+    const isIncome = launch.transaction_type === "entrada";
+    return {
+      ...launch,
+      plan_account: launch.financial_plan_account?.label ?? "—",
+      value: `${isIncome ? "+ " : "- "}${formatToBRLCurrency(launch.amount)}`,
+      updated_at: formatDateYMDtoDMY(launch.updated_at),
+    };
+  }),
+);
+
+const columns = [
+  { name: "description", label: "Descrição", field: "description", align: "left" },
+  { name: "plan_account", label: "Plano de Contas", field: "plan_account", align: "left" },
+  { name: "value", label: "Valor", field: "value", align: "left" },
+  { name: "updated_at", label: "Atualização", field: "updated_at", align: "left" },
+  { name: "actions", label: "Ações", field: null, align: "center" },
+];
+
+const fetchAccounts = async () => {
+  loading.value = true;
+  try {
+    accounts.value = await getTreasuryAccounts();
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao carregar os bancos." });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const fetchLaunches = async () => {
+  try {
+    const params = selectedBank.value.account
+      ? { account_id: selectedBank.value.account.id }
+      : {};
+    launches.value = await getTreasuryLaunches(params);
+  } catch {
+    $q.notify({ type: "negative", message: "Erro ao carregar o extrato." });
+  }
+};
+
+const openCreateDialog = () => {
+  $q.dialog({ component: AddEditTreasuryAccountDialog }).onOk(() => {
+    $q.notify({ type: "positive", message: "Banco cadastrado com sucesso." });
+    fetchAccounts();
+  });
+};
+
+const openEditDialog = (account) => {
+  $q.dialog({ component: AddEditTreasuryAccountDialog, componentProps: { account } }).onOk(() => {
+    $q.notify({ type: "positive", message: "Banco atualizado com sucesso." });
+    fetchAccounts();
+  });
+};
+
+const handleAddItem = () => {
+  if (!selectedBank.value.account) {
+    $q.notify({ type: "warning", message: "Selecione um banco para lançar uma movimentação." });
+    return;
+  }
+
+  $q.dialog({
+    component: AddTreasuryLaunchDialog,
+    componentProps: { account: selectedBank.value.account },
+  }).onOk(() => {
+    $q.notify({ type: "positive", message: "Movimentação registrada com sucesso." });
+    fetchLaunches();
+    fetchAccounts();
+  });
+};
+
+const handleEditLaunch = (launch) => {
+  const account = accounts.value.find((item) => item.id === launch.account_id);
+
+  if (!account) {
+    $q.notify({ type: "negative", message: "Banco da movimentação não encontrado." });
+    return;
+  }
+
+  $q.dialog({
+    component: AddTreasuryLaunchDialog,
+    componentProps: { account, launch },
+  }).onOk(() => {
+    $q.notify({ type: "positive", message: "Movimentação atualizada com sucesso." });
+    fetchLaunches();
+    fetchAccounts();
+  });
+};
+
+const handleDeleteLaunch = (launch) => {
+  $q.dialog({
+    title: "Excluir movimentação",
+    message: `Deseja excluir a movimentação "${launch.description}"?`,
+    ok: { color: "negative", label: "Excluir" },
+    cancel: { color: "primary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    deletingId.value = launch.id;
+    try {
+      await deleteTreasuryLaunch(launch.id);
+      $q.notify({ type: "positive", message: "Movimentação excluída com sucesso." });
+      await Promise.all([fetchLaunches(), fetchAccounts()]);
+    } catch {
+      $q.notify({ type: "negative", message: "Erro ao excluir a movimentação." });
+    } finally {
+      deletingId.value = null;
+    }
+  });
+};
+
+watch(selectedBank, fetchLaunches);
+
+onMounted(async () => {
+  await fetchAccounts();
+  await fetchLaunches();
+});
+</script>

+ 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>
 

+ 2 - 2
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 })"
     >
@@ -49,7 +49,7 @@
           <q-spinner />
         </template>
       </q-btn>
-    </q-form>
+    </DefaultForm>
   </div>
 </template>
 

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.