Explorar o código

feat(permissions): dialog de permissões do grupo (Menu/SubCategorias)

- Novo dialog master-detail: lista de menus à esquerda, subcategorias à
  direita, com estado vazio quando nenhum menu está selecionado.
- Níveis Menu/Visualizar/Cadastrar/Editar por item, respeitando os bits
  que cada permissão declara suportar (available_bits).
- Dialog de grupo agora cria/edita e permite informar o código, derivado
  do nome enquanto não for editado manualmente.
- Ações na tabela: permissões, editar (oculto p/ grupos de sistema) e remover.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ebagabee hai 2 semanas
pai
achega
975497b071

+ 17 - 0
src/api/user_type.js

@@ -10,7 +10,24 @@ export const createUserType = async (payload) => {
   return data.payload;
   return data.payload;
 };
 };
 
 
+export const updateUserType = async (id, payload) => {
+  const { data } = await api.put(`/user-type/${id}`, payload);
+  return data.payload;
+};
+
 export const deleteUserType = async (id) => {
 export const deleteUserType = async (id) => {
   const { data } = await api.delete(`/user-type/${id}`);
   const { data } = await api.delete(`/user-type/${id}`);
   return data.payload;
   return data.payload;
 };
 };
+
+export const getUserTypePermissions = async (slug) => {
+  const { data } = await api.get(`/user-type/${slug}/permissions`);
+  return data.payload;
+};
+
+export const updateUserTypePermissions = async (slug, permissions) => {
+  const { data } = await api.put(`/user-type/${slug}/permissions`, {
+    permissions,
+  });
+  return data.payload;
+};

+ 57 - 13
src/pages/permissions/PermissionsPage.vue

@@ -16,16 +16,39 @@
       >
       >
         <template #body-cell-actions="{ row }">
         <template #body-cell-actions="{ row }">
           <q-td auto-width>
           <q-td auto-width>
-            <q-btn
-              v-if="!row.is_system"
-              outline
-              icon="mdi-trash-can-outline"
-              style="width: 36px"
-              color="negative"
-              @click.prevent.stop="onDelete(row)"
-            >
-              <q-tooltip>Remover grupo</q-tooltip>
-            </q-btn>
+            <div class="row no-wrap justify-center" style="gap: 8px">
+              <q-btn
+                outline
+                icon="mdi-shield-key-outline"
+                style="width: 36px"
+                color="primary"
+                @click.prevent.stop="onEditPermissions(row)"
+              >
+                <q-tooltip>Permissões</q-tooltip>
+              </q-btn>
+
+              <q-btn
+                v-if="!row.is_system"
+                outline
+                icon="mdi-pencil-outline"
+                style="width: 36px"
+                color="primary"
+                @click.prevent.stop="onEdit(row)"
+              >
+                <q-tooltip>Editar grupo</q-tooltip>
+              </q-btn>
+
+              <q-btn
+                v-if="!row.is_system"
+                outline
+                icon="mdi-trash-can-outline"
+                style="width: 36px"
+                color="negative"
+                @click.prevent.stop="onDelete(row)"
+              >
+                <q-tooltip>Remover grupo</q-tooltip>
+              </q-btn>
+            </div>
           </q-td>
           </q-td>
         </template>
         </template>
       </DefaultTable>
       </DefaultTable>
@@ -40,8 +63,13 @@ import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import { getUserTypes, deleteUserType } from "src/api/user_type";
 import { getUserTypes, deleteUserType } from "src/api/user_type";
 
 
-const AddPermissionGroupDialog = defineAsyncComponent(
-  () => import("src/pages/permissions/components/AddPermissionGroupDialog.vue"),
+const AddEditPermissionGroupDialog = defineAsyncComponent(
+  () =>
+    import("src/pages/permissions/components/AddEditPermissionGroupDialog.vue"),
+);
+
+const PermissionGroupDialog = defineAsyncComponent(
+  () => import("src/pages/permissions/components/PermissionGroupDialog.vue"),
 );
 );
 
 
 const $q = useQuasar();
 const $q = useQuasar();
@@ -72,11 +100,27 @@ const columns = [
 ];
 ];
 
 
 const onAdd = () => {
 const onAdd = () => {
-  $q.dialog({ component: AddPermissionGroupDialog }).onOk(() => {
+  $q.dialog({ component: AddEditPermissionGroupDialog }).onOk(() => {
     tableRef.value?.refresh();
     tableRef.value?.refresh();
   });
   });
 };
 };
 
 
+const onEdit = (row) => {
+  $q.dialog({
+    component: AddEditPermissionGroupDialog,
+    componentProps: { group: row },
+  }).onOk(() => {
+    tableRef.value?.refresh();
+  });
+};
+
+const onEditPermissions = (row) => {
+  $q.dialog({
+    component: PermissionGroupDialog,
+    componentProps: { group: row },
+  });
+};
+
 const onDelete = (row) => {
 const onDelete = (row) => {
   $q.dialog({
   $q.dialog({
     title: "Confirmar exclusão",
     title: "Confirmar exclusão",

+ 119 - 0
src/pages/permissions/components/AddEditPermissionGroupDialog.vue

@@ -0,0 +1,119 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card style="min-width: 440px">
+      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
+
+      <q-form ref="formRef" @submit="onSave">
+        <q-card-section class="column q-gutter-md">
+          <DefaultInput
+            v-model="form.label"
+            label="Nome"
+            outlined
+            autofocus
+            :rules="[inputRules.required]"
+            @update:model-value="onLabelChange"
+          />
+
+          <DefaultInput
+            v-model="form.slug"
+            label="Código"
+            outlined
+            hint="Identificador único do grupo. Ex.: FINANCEIRO"
+            :rules="[inputRules.required, slugRule]"
+            @update:model-value="onSlugTouched"
+          />
+        </q-card-section>
+
+        <q-card-actions align="right">
+          <q-btn
+            label="Cancelar"
+            color="primary"
+            outline
+            @click="onDialogCancel"
+          />
+          <q-btn
+            label="Salvar"
+            color="primary"
+            type="submit"
+            :loading="loading"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref, useTemplateRef } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { createUserType, updateUserType } from "src/api/user_type";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+
+const { group } = defineProps({
+  group: {
+    type: Object,
+    default: null,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const { inputRules } = useInputRules();
+const formRef = useTemplateRef("formRef");
+
+const isEdit = computed(() => !!group);
+
+const dialogTitle = computed(() =>
+  isEdit.value ? "Editar Grupo de Permissões" : "Novo Grupo de Permissões",
+);
+
+const form = ref({
+  label: group?.label ?? "",
+  slug: group?.slug ?? "",
+});
+
+// Ao editar, o código já existe e não deve ser re-derivado do nome.
+const slugTouched = ref(isEdit.value);
+
+const toSlug = (value) =>
+  (value ?? "")
+    .normalize("NFD")
+    .replace(/\p{Diacritic}/gu, "")
+    .toUpperCase()
+    .replace(/[^A-Z0-9]+/g, "_")
+    .replace(/^_+|_+$/g, "");
+
+const slugRule = (value) =>
+  /^[A-Z0-9_]+$/.test(value ?? "") ||
+  "Use apenas letras maiúsculas, números e underline.";
+
+const onLabelChange = (value) => {
+  if (!slugTouched.value) {
+    form.value.slug = toSlug(value);
+  }
+};
+
+const onSlugTouched = (value) => {
+  slugTouched.value = true;
+  form.value.slug = toSlug(value);
+};
+
+const { loading, execute } = useSubmitHandler({
+  formRef,
+  onSuccess: () => onDialogOK(),
+});
+
+const onSave = async () => {
+  const payload = { label: form.value.label, slug: form.value.slug };
+
+  await execute(() =>
+    isEdit.value ? updateUserType(group.id, payload) : createUserType(payload),
+  );
+};
+</script>

+ 0 - 66
src/pages/permissions/components/AddPermissionGroupDialog.vue

@@ -1,66 +0,0 @@
-<template>
-  <q-dialog ref="dialogRef" @hide="onDialogHide">
-    <q-card style="min-width: 400px">
-      <DefaultDialogHeader
-        title="Novo Grupo de Permissões"
-        @close="onDialogCancel"
-      />
-
-      <q-form ref="formRef" @submit="onSave">
-        <q-card-section>
-          <DefaultInput
-            v-model="form.label"
-            label="Nome"
-            outlined
-            autofocus
-            :rules="[inputRules.required]"
-          />
-        </q-card-section>
-
-        <q-card-actions align="right">
-          <q-btn
-            label="Cancelar"
-            color="primary"
-            outline
-            @click="onDialogCancel"
-          />
-          <q-btn
-            label="Salvar"
-            color="primary"
-            type="submit"
-            :loading="loading"
-          />
-        </q-card-actions>
-      </q-form>
-    </q-card>
-  </q-dialog>
-</template>
-
-<script setup>
-import { ref, useTemplateRef } from "vue";
-import { useDialogPluginComponent } from "quasar";
-import { useInputRules } from "src/composables/useInputRules";
-import { useSubmitHandler } from "src/composables/useSubmitHandler";
-import { createUserType } from "src/api/user_type";
-import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
-import DefaultInput from "src/components/defaults/DefaultInput.vue";
-
-defineEmits([...useDialogPluginComponent.emits]);
-
-const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
-  useDialogPluginComponent();
-
-const { inputRules } = useInputRules();
-const formRef = useTemplateRef("formRef");
-
-const form = ref({ label: "" });
-
-const { loading, execute } = useSubmitHandler({
-  formRef,
-  onSuccess: () => onDialogOK(),
-});
-
-const onSave = async () => {
-  await execute(() => createUserType({ label: form.value.label }));
-};
-</script>

+ 222 - 0
src/pages/permissions/components/PermissionGroupDialog.vue

@@ -0,0 +1,222 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card style="width: 1100px; max-width: 95vw">
+      <DefaultDialogHeader
+        :title="`Permissões — ${group.label}`"
+        @close="onDialogCancel"
+      />
+
+      <q-card-section class="q-pa-none">
+        <div class="row items-stretch" style="min-height: 440px">
+          <!-- Menu -->
+          <div class="col-12 col-md-6 column">
+            <div class="text-center text-subtitle1 text-weight-medium q-pa-sm">
+              Menu
+            </div>
+            <q-separator />
+
+            <q-inner-loading :showing="loading" />
+
+            <q-list v-if="!loading" separator class="col scroll">
+              <q-item
+                v-for="item in roots"
+                :key="item.id"
+                clickable
+                :active="selectedId === item.id"
+                active-class="bg-blue-1 text-primary"
+                @click="selectedId = item.id"
+              >
+                <q-item-section avatar>
+                  <q-icon :name="iconFor(item.scope)" color="dark" />
+                </q-item-section>
+
+                <q-item-section>
+                  <q-item-label>{{ item.description }}</q-item-label>
+                  <q-item-label caption>
+                    <PermissionLevelBadges :bits="bitsById[item.id]" />
+                  </q-item-label>
+                </q-item-section>
+
+                <q-item-section side>
+                  <q-btn
+                    flat
+                    round
+                    dense
+                    icon="mdi-pencil-outline"
+                    color="dark"
+                    @click.stop
+                  >
+                    <q-tooltip>Editar permissões</q-tooltip>
+                    <PermissionLevelMenu
+                      :bits="bitsById[item.id]"
+                      :available-bits="item.available_bits"
+                      @update:bits="(value) => (bitsById[item.id] = value)"
+                    />
+                  </q-btn>
+                </q-item-section>
+              </q-item>
+            </q-list>
+          </div>
+
+          <q-separator vertical />
+
+          <!-- SubCategorias -->
+          <div class="col column">
+            <div class="text-center text-subtitle1 text-weight-medium q-pa-sm">
+              SubCategorias
+            </div>
+            <q-separator />
+
+            <div
+              v-if="!selected"
+              class="col column flex-center text-center text-foreground q-pa-md"
+            >
+              <div>Nenhum menu selecionado!</div>
+              <div class="text-caption">
+                ← Selecione um menu à esquerda para carregar as subcategorias.
+              </div>
+            </div>
+
+            <div
+              v-else-if="!children.length"
+              class="col column flex-center text-center text-foreground q-pa-md"
+            >
+              <div>Nenhuma subcategoria</div>
+              <div class="text-caption">
+                O menu "{{ selected.description }}" 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-section>
+                  <q-item-label>{{ child.description }}</q-item-label>
+                  <q-item-label caption>
+                    <PermissionLevelBadges :bits="bitsById[child.id]" />
+                  </q-item-label>
+                </q-item-section>
+
+                <q-item-section side>
+                  <q-btn
+                    flat
+                    round
+                    dense
+                    icon="mdi-pencil-outline"
+                    color="dark"
+                  >
+                    <q-tooltip>Editar permissões</q-tooltip>
+                    <PermissionLevelMenu
+                      :bits="bitsById[child.id]"
+                      :available-bits="child.available_bits"
+                      @update:bits="(value) => (bitsById[child.id] = value)"
+                    />
+                  </q-btn>
+                </q-item-section>
+              </q-item>
+            </q-list>
+          </div>
+        </div>
+      </q-card-section>
+
+      <q-separator />
+
+      <q-card-actions align="right">
+        <q-btn label="Cancelar" color="primary" outline @click="onDialogCancel" />
+        <q-btn
+          label="Salvar"
+          color="primary"
+          :loading="saving"
+          :disable="loading"
+          @click="onSave"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, onMounted, ref } from "vue";
+import { Notify, useDialogPluginComponent } from "quasar";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import PermissionLevelBadges from "src/pages/permissions/components/PermissionLevelBadges.vue";
+import PermissionLevelMenu from "src/pages/permissions/components/PermissionLevelMenu.vue";
+import {
+  getUserTypePermissions,
+  updateUserTypePermissions,
+} from "src/api/user_type";
+import { permissionScopeIcon } from "src/pages/permissions/permission_scopes";
+
+const { group } = defineProps({
+  group: {
+    type: Object,
+    required: true,
+  },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const loading = ref(false);
+const saving = ref(false);
+const permissions = ref([]);
+const bitsById = ref({});
+const selectedId = ref(null);
+
+const roots = computed(() =>
+  permissions.value.filter((p) => p.parent_id === null),
+);
+
+const selected = computed(
+  () => permissions.value.find((p) => p.id === selectedId.value) ?? null,
+);
+
+const children = computed(() =>
+  permissions.value.filter((p) => p.parent_id === selectedId.value),
+);
+
+const iconFor = (scope) => permissionScopeIcon(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]),
+    );
+  } catch {
+    Notify.create({
+      message: "Não foi possível carregar as permissões do grupo.",
+      type: "negative",
+    });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const onSave = async () => {
+  saving.value = true;
+  try {
+    await updateUserTypePermissions(
+      group.slug,
+      permissions.value.map((p) => ({
+        permission_id: p.id,
+        bits: bitsById.value[p.id] ?? 0,
+      })),
+    );
+    Notify.create({ message: "Permissões atualizadas.", type: "positive" });
+    onDialogOK();
+  } catch {
+    Notify.create({
+      message: "Não foi possível salvar as permissões.",
+      type: "negative",
+    });
+  } finally {
+    saving.value = false;
+  }
+};
+
+onMounted(fetchPermissions);
+</script>

+ 29 - 0
src/pages/permissions/components/PermissionLevelBadges.vue

@@ -0,0 +1,29 @@
+<template>
+  <span v-if="!active.length" class="text-foreground">Sem acesso</span>
+
+  <span v-else class="row inline items-center" style="gap: 4px">
+    <q-badge
+      v-for="level in active"
+      :key="level.key"
+      color="primary"
+      outline
+      :label="level.label"
+    />
+  </span>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { PERMISSION_LEVELS } from "src/pages/permissions/permission_scopes";
+
+const { bits } = defineProps({
+  bits: {
+    type: Number,
+    default: 0,
+  },
+});
+
+const active = computed(() =>
+  PERMISSION_LEVELS.filter((level) => (bits & level.bit) !== 0),
+);
+</script>

+ 62 - 0
src/pages/permissions/components/PermissionLevelMenu.vue

@@ -0,0 +1,62 @@
+<template>
+  <q-menu anchor="bottom right" self="top right">
+    <q-list style="min-width: 180px">
+      <q-item-label header class="q-pb-xs">Permissões</q-item-label>
+
+      <q-item
+        v-for="level in PERMISSION_LEVELS"
+        :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
+          />
+        </q-item-section>
+
+        <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";
+
+const { bits, availableBits } = 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>

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

@@ -0,0 +1,41 @@
+/**
+ * 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";