Explorar el Código

refactor(permissions): baseia a matriz nos menus da franqueadora

A lista de permissões passa a ser a árvore de menus (Dashboard, Franqueados,
Pacotes, Produtos, TBR, Financeiro + subs, Suporte, Atividades, Cadastros,
Permissões) em vez dos scopes granulares da API: quem tem o menu tem o fluxo.

- permission_menus.js define os menus e o scope que governa cada fluxo.
- Remove o gating por available_bits (vinha da taxonomia antiga).
- Menu 'Permissões' visível apenas para o administrador geral (adminOnly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee hace 2 semanas
padre
commit
2d27309a79

+ 1 - 1
src/pages/permissions/PermissionsPage.vue

@@ -15,7 +15,7 @@
         @on-add-item="onAdd"
       >
         <template #body-cell-actions="{ row }">
-          <q-td auto-width>
+          <q-td>
             <div class="row no-wrap justify-center" style="gap: 8px">
               <q-btn
                 outline

+ 74 - 33
src/pages/permissions/components/PermissionGroupDialog.vue

@@ -19,21 +19,29 @@
 
             <q-list v-if="!loading" separator class="col scroll">
               <q-item
-                v-for="item in roots"
-                :key="item.id"
+                v-for="item in menus"
+                :key="item.scope"
                 clickable
-                :active="selectedId === item.id"
+                :active="selectedScope === item.scope"
                 active-class="bg-blue-1 text-primary"
-                @click="selectedId = item.id"
+                @click="selectedScope = item.scope"
               >
                 <q-item-section avatar>
-                  <q-icon :name="iconFor(item.scope)" color="dark" />
+                  <q-icon :name="item.icon" color="dark" />
                 </q-item-section>
 
                 <q-item-section>
-                  <q-item-label>{{ item.description }}</q-item-label>
+                  <q-item-label>
+                    {{ item.label }}
+                    <q-icon
+                      v-if="item.children?.length"
+                      name="mdi-chevron-right"
+                      size="16px"
+                      class="q-ml-xs"
+                    />
+                  </q-item-label>
                   <q-item-label caption>
-                    <PermissionLevelBadges :bits="bitsById[item.id]" />
+                    <PermissionLevelBadges :bits="bitsFor(item.scope)" />
                   </q-item-label>
                 </q-item-section>
 
@@ -48,9 +56,8 @@
                   >
                     <q-tooltip>Editar permissões</q-tooltip>
                     <PermissionLevelMenu
-                      :bits="bitsById[item.id]"
-                      :available-bits="item.available_bits"
-                      @update:bits="(value) => (bitsById[item.id] = value)"
+                      :bits="bitsFor(item.scope)"
+                      @update:bits="(value) => setBits(item.scope, value)"
                     />
                   </q-btn>
                 </q-item-section>
@@ -83,16 +90,20 @@
             >
               <div>Nenhuma subcategoria</div>
               <div class="text-caption">
-                O menu "{{ selected.description }}" não possui subcategorias.
+                O menu "{{ selected.label }}" não possui subcategorias.
               </div>
             </div>
 
             <q-list v-else separator class="col scroll">
-              <q-item v-for="child in children" :key="child.id">
+              <q-item v-for="child in children" :key="child.scope">
+                <q-item-section avatar>
+                  <q-icon :name="child.icon" color="dark" />
+                </q-item-section>
+
                 <q-item-section>
-                  <q-item-label>{{ child.description }}</q-item-label>
+                  <q-item-label>{{ child.label }}</q-item-label>
                   <q-item-label caption>
-                    <PermissionLevelBadges :bits="bitsById[child.id]" />
+                    <PermissionLevelBadges :bits="bitsFor(child.scope)" />
                   </q-item-label>
                 </q-item-section>
 
@@ -106,9 +117,8 @@
                   >
                     <q-tooltip>Editar permissões</q-tooltip>
                     <PermissionLevelMenu
-                      :bits="bitsById[child.id]"
-                      :available-bits="child.available_bits"
-                      @update:bits="(value) => (bitsById[child.id] = value)"
+                      :bits="bitsFor(child.scope)"
+                      @update:bits="(value) => setBits(child.scope, value)"
                     />
                   </q-btn>
                 </q-item-section>
@@ -121,7 +131,12 @@
       <q-separator />
 
       <q-card-actions align="right">
-        <q-btn label="Cancelar" color="primary" outline @click="onDialogCancel" />
+        <q-btn
+          label="Cancelar"
+          color="primary"
+          outline
+          @click="onDialogCancel"
+        />
         <q-btn
           label="Salvar"
           color="primary"
@@ -144,7 +159,7 @@ import {
   getUserTypePermissions,
   updateUserTypePermissions,
 } from "src/api/user_type";
-import { permissionScopeIcon } from "src/pages/permissions/permission_scopes";
+import { FRANCHISOR_PERMISSION_MENUS } from "src/pages/permissions/permission_menus";
 
 const { group } = defineProps({
   group: {
@@ -160,32 +175,58 @@ const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
 
 const loading = ref(false);
 const saving = ref(false);
-const permissions = ref([]);
-const bitsById = ref({});
-const selectedId = ref(null);
+const selectedScope = ref(null);
 
-const roots = computed(() =>
-  permissions.value.filter((p) => p.parent_id === null),
+// permission_id e bits atuais do grupo, indexados pelo scope do menu.
+const permissionIdByScope = ref({});
+const bitsByScope = ref({});
+
+const menus = computed(() =>
+  FRANCHISOR_PERMISSION_MENUS.filter(
+    (menu) => permissionIdByScope.value[menu.scope] !== undefined,
+  ),
 );
 
 const selected = computed(
-  () => permissions.value.find((p) => p.id === selectedId.value) ?? null,
+  () => menus.value.find((menu) => menu.scope === selectedScope.value) ?? null,
 );
 
 const children = computed(() =>
-  permissions.value.filter((p) => p.parent_id === selectedId.value),
+  (selected.value?.children ?? []).filter(
+    (child) => permissionIdByScope.value[child.scope] !== undefined,
+  ),
 );
 
-const iconFor = (scope) => permissionScopeIcon(scope);
+const bitsFor = (scope) => bitsByScope.value[scope] ?? 0;
+
+const setBits = (scope, value) => {
+  bitsByScope.value[scope] = value;
+};
+
+const scopesInMenu = () =>
+  FRANCHISOR_PERMISSION_MENUS.flatMap((menu) => [
+    menu.scope,
+    ...(menu.children ?? []).map((child) => child.scope),
+  ]);
 
 const fetchPermissions = async () => {
   loading.value = true;
   try {
     const payload = await getUserTypePermissions(group.slug);
-    permissions.value = payload.permissions;
-    bitsById.value = Object.fromEntries(
-      payload.permissions.map((p) => [p.id, p.bits]),
+    const byScope = Object.fromEntries(
+      payload.permissions.map((p) => [p.scope, p]),
     );
+
+    permissionIdByScope.value = {};
+    bitsByScope.value = {};
+
+    for (const scope of scopesInMenu()) {
+      const permission = byScope[scope];
+      if (!permission) continue;
+
+      permissionIdByScope.value[scope] = permission.id;
+      bitsByScope.value[scope] = permission.bits;
+    }
   } catch {
     Notify.create({
       message: "Não foi possível carregar as permissões do grupo.",
@@ -201,9 +242,9 @@ const onSave = async () => {
   try {
     await updateUserTypePermissions(
       group.slug,
-      permissions.value.map((p) => ({
-        permission_id: p.id,
-        bits: bitsById.value[p.id] ?? 0,
+      Object.entries(permissionIdByScope.value).map(([scope, id]) => ({
+        permission_id: id,
+        bits: bitsByScope.value[scope] ?? 0,
       })),
     );
     Notify.create({ message: "Permissões atualizadas.", type: "positive" });

+ 1 - 1
src/pages/permissions/components/PermissionLevelBadges.vue

@@ -14,7 +14,7 @@
 
 <script setup>
 import { computed } from "vue";
-import { PERMISSION_LEVELS } from "src/pages/permissions/permission_scopes";
+import { PERMISSION_LEVELS } from "src/pages/permissions/permission_menus";
 
 const { bits } = defineProps({
   bits: {

+ 2 - 17
src/pages/permissions/components/PermissionLevelMenu.vue

@@ -8,13 +8,11 @@
         :key="level.key"
         v-ripple
         clickable
-        :disable="!supports(level.bit)"
         @click="toggle(level.bit)"
       >
         <q-item-section side>
           <q-checkbox
             :model-value="has(level.bit)"
-            :disable="!supports(level.bit)"
             dense
             @update:model-value="toggle(level.bit)"
             @click.stop
@@ -24,39 +22,26 @@
         <q-item-section>
           <q-item-label>{{ level.label }}</q-item-label>
         </q-item-section>
-
-        <q-tooltip v-if="!supports(level.bit)">
-          Este módulo não oferece "{{ level.label }}".
-        </q-tooltip>
       </q-item>
     </q-list>
   </q-menu>
 </template>
 
 <script setup>
-import { PERMISSION_LEVELS } from "src/pages/permissions/permission_scopes";
+import { PERMISSION_LEVELS } from "src/pages/permissions/permission_menus";
 
-const { bits, availableBits } = defineProps({
+const { bits } = defineProps({
   bits: {
     type: Number,
     default: 0,
   },
-  availableBits: {
-    type: Number,
-    default: 0,
-  },
 });
 
 const emit = defineEmits(["update:bits"]);
 
 const has = (bit) => (bits & bit) !== 0;
 
-// Cada permissão declara na API quais bits fazem sentido para ela.
-const supports = (bit) => (availableBits & bit) !== 0;
-
 const toggle = (bit) => {
-  if (!supports(bit)) return;
-
   emit("update:bits", has(bit) ? bits & ~bit : bits | bit);
 };
 </script>

+ 75 - 0
src/pages/permissions/permission_menus.js

@@ -0,0 +1,75 @@
+/**
+ * Níveis de permissão expostos na tela, na ordem em que aparecem.
+ * Os bits espelham App\Models\Permission na API.
+ */
+export const PERMISSION_LEVELS = Object.freeze([
+  { key: "menu", label: "Menu", bit: 256 },
+  { key: "view", label: "Visualizar", bit: 1 },
+  { key: "add", label: "Cadastrar", bit: 2 },
+  { key: "edit", label: "Editar", bit: 4 },
+]);
+
+/**
+ * Menus da Franqueadora e o scope de permissão que governa cada fluxo.
+ * A granularidade é o menu: quem tem o menu tem o fluxo inteiro por trás dele.
+ *
+ * Deve espelhar src/stores/navigation.js.
+ */
+export const FRANCHISOR_PERMISSION_MENUS = Object.freeze([
+  { scope: "dashboard", label: "Dashboard", icon: "mdi-poll" },
+  {
+    scope: "franchisee",
+    label: "Franqueados",
+    icon: "mdi-home-variant-outline",
+  },
+  {
+    scope: "class-package",
+    label: "Pacotes",
+    icon: "mdi-package-variant-closed",
+  },
+  { scope: "product", label: "Produtos", icon: "mdi-cart-outline" },
+  { scope: "tbr", label: "TBR", icon: "mdi-database-outline" },
+  {
+    scope: "financial",
+    label: "Financeiro",
+    icon: "mdi-cash-multiple",
+    children: [
+      { scope: "treasury", label: "Tesouraria", icon: "mdi-bank-outline" },
+      {
+        scope: "financial-account-payable",
+        label: "Contas a Pagar",
+        icon: "mdi-cash-minus",
+      },
+      {
+        scope: "financial-account-receive",
+        label: "Contas a Receber",
+        icon: "mdi-cash-plus",
+      },
+      {
+        scope: "financial-plan-account",
+        label: "Plano de Contas",
+        icon: "mdi-format-list-bulleted",
+      },
+      {
+        scope: "financial-invoice",
+        label: "Emissão de Notas",
+        icon: "mdi-file-document-outline",
+      },
+      {
+        scope: "integrations",
+        label: "Integração Asaas",
+        icon: "mdi-api",
+      },
+    ],
+  },
+  { scope: "support-ticket", label: "Suporte", icon: "mdi-headset" },
+  { scope: "kanban", label: "Atividades", icon: "mdi-clipboard-list-outline" },
+  { scope: "config", label: "Cadastros", icon: "mdi-account-multiple-outline" },
+  {
+    scope: "config.user-type",
+    label: "Permissões",
+    icon: "mdi-shield-key-outline",
+    // Só o administrador geral enxerga/configura este menu.
+    adminOnly: true,
+  },
+]);

+ 0 - 41
src/pages/permissions/permission_scopes.js

@@ -1,41 +0,0 @@
-/**
- * Níveis de permissão expostos na tela, na ordem em que aparecem.
- * Os bits espelham App\Models\Permission na API.
- */
-export const PERMISSION_LEVELS = Object.freeze([
-  { key: "menu", label: "Menu", bit: 256 },
-  { key: "view", label: "Visualizar", bit: 1 },
-  { key: "add", label: "Cadastrar", bit: 2 },
-  { key: "edit", label: "Editar", bit: 4 },
-]);
-
-const SCOPE_ICONS = Object.freeze({
-  dashboard: "mdi-poll",
-  franchisee: "mdi-home-variant-outline",
-  "class-package": "mdi-package-variant-closed",
-  "class-package-unit": "mdi-package-variant-closed",
-  "class-package-franchisee": "mdi-package-variant-closed",
-  class: "mdi-school-outline",
-  modality: "mdi-shape-outline",
-  student: "mdi-account-school-outline",
-  "student-contract": "mdi-file-document-outline",
-  media: "mdi-image-outline",
-  kanban: "mdi-clipboard-list-outline",
-  "support-ticket": "mdi-headset",
-  notification: "mdi-bell-outline",
-  product: "mdi-cart-outline",
-  "franchisor-inventory": "mdi-package-variant",
-  "franchisee-inventory": "mdi-package-variant",
-  supplier: "mdi-truck-outline",
-  "payment-method": "mdi-credit-card-outline",
-  financial: "mdi-cash-multiple",
-  treasury: "mdi-bank-outline",
-  integrations: "mdi-api",
-  holiday: "mdi-calendar-outline",
-  tbr: "mdi-database-outline",
-  config: "mdi-cog-outline",
-  "unit-user": "mdi-account-multiple-outline",
-});
-
-export const permissionScopeIcon = (scope) =>
-  SCOPE_ICONS[scope] ?? "mdi-shield-outline";

+ 4 - 0
src/stores/navigation.js

@@ -1,6 +1,7 @@
 import { defineStore } from "pinia";
 import { computed } from "vue";
 import { permissionStore } from "src/stores/permission";
+import { userStore } from "src/stores/user";
 
 export const navigationStore = defineStore("navigation", () => {
   const navigationStructure = Object.freeze([
@@ -131,12 +132,15 @@ export const navigationStore = defineStore("navigation", () => {
       disable: false,
       permission: false,
       permissionScope: "config.user-type",
+      adminOnly: true,
     },
   ]);
 
   const getNavigationAccess = () => {
     const { getAccess } = permissionStore();
+    const { isAdmin } = userStore();
     return navigationStructure
+      .filter((menu) => !menu.adminOnly || isAdmin)
       .map((menu) => {
         if (menu.type === "expansive") {
           if (getAccess(menu.permissionScope, "menu")) menu.permission = true;