Kaynağa Gözat

feat(permissions): implement user type management with CRUD operations and permissions handling

ebagabee 1 hafta önce
ebeveyn
işleme
9a280d7da2

+ 11 - 0
src/api/user_type.js

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

+ 263 - 0
src/pages/permissions/PermissionsPage.vue

@@ -0,0 +1,263 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Grupos de Permissões" :show-filter-icon="false" />
+    <div class="q-px-sm">
+      <DefaultTable
+        ref="tableRef"
+        title="Lista de Grupos de Permissões"
+        :columns="columns"
+        :api-call="getUserTypes"
+        description="grupos"
+        :add-item="canAdd"
+        add-item-label="Novo Grupo"
+        @on-add-item="openGroup()"
+      >
+        <template #body-cell-actions="{ row }">
+          <q-td>
+            <div class="row no-wrap justify-center q-gutter-sm">
+              <q-btn
+                v-if="canEdit"
+                outline
+                icon="mdi-shield-key-outline"
+                color="primary"
+                @click="openPermissions(row)"
+              >
+                <q-tooltip>Permissões</q-tooltip>
+              </q-btn>
+              <q-btn
+                v-if="canEdit"
+                outline
+                icon="mdi-pencil-outline"
+                color="primary"
+                @click="openGroup(row)"
+              >
+                <q-tooltip>Editar grupo</q-tooltip>
+              </q-btn>
+              <q-btn
+                v-if="canDelete"
+                outline
+                icon="mdi-trash-can-outline"
+                color="negative"
+                @click="remove(row)"
+              >
+                <q-tooltip>Remover grupo</q-tooltip>
+              </q-btn>
+            </div>
+          </q-td>
+        </template>
+      </DefaultTable>
+    </div>
+
+    <q-dialog v-model="groupDialog">
+      <q-card style="min-width: 440px">
+        <DefaultDialogHeader
+          :title="editingGroup ? 'Editar Grupo' : 'Novo Grupo'"
+          @close="groupDialog = false"
+        />
+        <q-form @submit="saveGroup">
+          <q-card-section class="column q-gutter-md">
+            <DefaultInput
+              v-model="form.label"
+              label="Nome"
+              outlined
+              :rules="[(value) => !!value || 'Campo obrigatório']"
+            />
+            <DefaultInput
+              v-model="form.slug"
+              label="Código"
+              outlined
+              hint="Ex.: ATENDIMENTO"
+              :rules="[
+                (value) =>
+                  /^[A-Z0-9_]+$/.test(value) ||
+                  'Use letras maiúsculas, números e underline',
+              ]"
+            />
+          </q-card-section>
+          <q-card-actions align="right">
+            <q-btn v-close-popup label="Cancelar" outline color="primary" />
+            <q-btn
+              label="Salvar"
+              type="submit"
+              color="primary"
+              :loading="saving"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </q-dialog>
+
+    <q-dialog v-model="permissionsDialog" full-height>
+      <q-card class="column no-wrap" style="width: 760px; max-width: 95vw">
+        <DefaultDialogHeader
+          :title="`Permissões: ${editingGroup?.label ?? ''}`"
+          @close="permissionsDialog = false"
+        />
+        <q-card-section class="col scroll q-pa-none">
+          <q-inner-loading :showing="loadingPermissions" />
+          <q-list separator>
+            <q-item v-for="menu in visibleMenus" :key="menu.scope">
+              <q-item-section
+                ><q-item-label>{{ menu.label }}</q-item-label></q-item-section
+              >
+              <q-item-section side>
+                <div class="row q-gutter-sm">
+                  <q-checkbox
+                    v-for="level in levelsFor(menu.scope)"
+                    :key="level.key"
+                    :label="level.label"
+                    :model-value="hasBit(menu.scope, level.bit)"
+                    @update:model-value="toggleBit(menu.scope, level.bit)"
+                  />
+                </div>
+              </q-item-section>
+            </q-item>
+          </q-list>
+        </q-card-section>
+        <q-card-actions align="right" class="q-pa-md">
+          <q-btn v-close-popup label="Cancelar" outline color="primary" />
+          <q-btn
+            label="Salvar"
+            color="primary"
+            :loading="saving"
+            @click="savePermissions"
+          />
+        </q-card-actions>
+      </q-card>
+    </q-dialog>
+  </div>
+</template>
+
+<script setup>
+import { computed, ref, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import { permissionStore } from "src/stores/permission";
+import { MENUS, LEVELS, normalizeBits } from "./permission_menus";
+import {
+  getUserTypes,
+  createUserType,
+  updateUserType,
+  deleteUserType,
+  getUserTypePermissions,
+  updateUserTypePermissions,
+} from "src/api/user_type";
+
+const $q = useQuasar();
+const access = permissionStore();
+const tableRef = useTemplateRef("tableRef");
+const canAdd = computed(() =>
+  access.getAccess("franchisee_permissions", "add"),
+);
+const canEdit = computed(() =>
+  access.getAccess("franchisee_permissions", "edit"),
+);
+const canDelete = computed(() =>
+  access.getAccess("franchisee_permissions", "delete"),
+);
+const columns = [
+  {
+    name: "label",
+    label: "Nome",
+    field: "label",
+    align: "left",
+    sortable: true,
+  },
+  {
+    name: "slug",
+    label: "Código",
+    field: "slug",
+    align: "left",
+    sortable: true,
+  },
+  { name: "actions", label: "Ações", align: "center", required: true },
+];
+const groupDialog = ref(false);
+const permissionsDialog = ref(false);
+const editingGroup = ref(null);
+const saving = ref(false);
+const loadingPermissions = ref(false);
+const form = ref({ label: "", slug: "" });
+const permissions = ref({});
+
+const openGroup = (group = null) => {
+  editingGroup.value = group;
+  form.value = { label: group?.label ?? "", slug: group?.slug ?? "" };
+  groupDialog.value = true;
+};
+const saveGroup = async () => {
+  saving.value = true;
+  try {
+    editingGroup.value
+      ? await updateUserType(editingGroup.value.id, form.value)
+      : await createUserType(form.value);
+    groupDialog.value = false;
+    tableRef.value?.refresh();
+  } finally {
+    saving.value = false;
+  }
+};
+
+const remove = (group) =>
+  $q
+    .dialog({
+      title: "Confirmar exclusão",
+      message: `Remover o grupo “${group.label}”?`,
+      cancel: true,
+    })
+    .onOk(async () => {
+      await deleteUserType(group.id);
+      tableRef.value?.refresh();
+    });
+
+const visibleMenus = computed(() =>
+  MENUS.filter((menu) => permissions.value[menu.scope]),
+);
+
+const levelsFor = (scope) =>
+  LEVELS.filter((level) => permissions.value[scope].available_bits & level.bit);
+
+const hasBit = (scope, bit) => !!(permissions.value[scope]?.bits & bit);
+
+const toggleBit = (scope, bit) => {
+  permissions.value[scope].bits = normalizeBits(
+    permissions.value[scope].bits ^ bit,
+  );
+};
+
+const openPermissions = async (group) => {
+  editingGroup.value = group;
+  permissionsDialog.value = true;
+  loadingPermissions.value = true;
+  try {
+    const payload = await getUserTypePermissions(group.id);
+    permissions.value = Object.fromEntries(
+      payload.permissions.map((item) => [
+        item.scope,
+        { ...item, bits: normalizeBits(item.bits) },
+      ]),
+    );
+  } finally {
+    loadingPermissions.value = false;
+  }
+};
+
+const savePermissions = async () => {
+  saving.value = true;
+  try {
+    await updateUserTypePermissions(
+      editingGroup.value.id,
+      Object.values(permissions.value).map((item) => ({
+        permission_id: item.id,
+        bits: item.bits,
+      })),
+    );
+    permissionsDialog.value = false;
+  } finally {
+    saving.value = false;
+  }
+};
+</script>

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

@@ -0,0 +1,24 @@
+export const LEVELS = Object.freeze([
+  { key: "view", label: "Visualizar", bit: 1 },
+  { key: "add", label: "Cadastrar", bit: 2 },
+  { key: "edit", label: "Editar", bit: 4 },
+  { key: "delete", label: "Excluir", bit: 8 },
+]);
+
+export const MENUS = Object.freeze([
+  ["franchisee_dashboard", "Dashboard"],
+  ["franchisee_students", "Alunos"],
+  ["franchisee_contracts", "Contratos"],
+  ["franchisee_classes", "Agenda / Aulas"],
+  ["franchisee_financial", "Financeiro"],
+  ["franchisee_packages", "Pacotes"],
+  ["franchisee_users", "Usuários"],
+  ["franchisee_permissions", "Permissões"],
+  ["franchisee_unit", "Unidade"],
+  ["franchisee_integrations", "APIs"],
+  ["franchisee_support", "Suporte"],
+  ["franchisee_activities", "Atividades"],
+  ["franchisee_orders", "Pedidos"],
+].map(([scope, label]) => ({ scope, label })));
+
+export const normalizeBits = (bits) => (bits & 1 ? bits | 256 : bits & ~256);

+ 13 - 0
src/router/routes/permission.route.js

@@ -0,0 +1,13 @@
+export default [
+  {
+    path: "/permissions",
+    name: "PermissionsPage",
+    component: () => import("pages/permissions/PermissionsPage.vue"),
+    meta: {
+      title: "Permissões",
+      requireAuth: true,
+      requiredPermission: "franchisee_permissions",
+      breadcrumbs: [{ name: "PermissionsPage", title: "Permissões" }],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -113,6 +113,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "franchisee_users",
     },
+    {
+      type: "single",
+      title: "Permissões",
+      name: "PermissionsPage",
+      icon: "mdi-shield-account-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "franchisee_permissions",
+    },
     {
       type: "single",
       title: "Unidade",