فهرست منبع

feat: :sparkles: crud clientes

crud clientes
Gustavo Zanatta 1 ماه پیش
والد
کامیت
cbec49c927

+ 6 - 6
eslint.config.js

@@ -68,12 +68,12 @@ export default [
       "vue/no-unused-vars": "warn",
       "vue/no-unused-components": "warn",
       "@intlify/vue-i18n/no-dynamic-keys": "off",
-      "@intlify/vue-i18n/no-unused-keys": [
-        "error",
-        {
-          extensions: [".js", ".vue"],
-        },
-      ],
+      // "@intlify/vue-i18n/no-unused-keys": [
+      //   "error",
+      //   {
+      //     extensions: [".js", ".vue"],
+      //   },
+      // ],
       // allow debugger during development only
       "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
     },

+ 26 - 0
src/api/client.js

@@ -0,0 +1,26 @@
+import api from "src/api";
+
+export const getClient = async (id) => {
+  const { data } = await api.get("/client/" + id);
+  return data.payload;
+};
+
+export const getClients = async () => {
+  const { data } = await api.get("/client");
+  return data.payload;
+};
+
+export const createClient = async (client) => {
+  const { data } = await api.post("/client", client);
+  return data.payload;
+};
+
+export const updateClient = async (client, id) => {
+  const { data } = await api.put(`/client/${id}`, client);
+  return data.payload;
+};
+
+export const deleteClient = async (id) => {
+  const { data } = await api.delete(`/client/${id}`);
+  return data.payload;
+};

+ 2 - 4
src/api/provider.js

@@ -1,6 +1,4 @@
-import { createCachedApi } from "./cacheService";
-
-const api = createCachedApi("provider");
+import api from "src/api";
 
 export const getProvider = async (id) => {
   const { data } = await api.get("/provider/" + id);
@@ -23,6 +21,6 @@ export const updateProvider = async (provider, id) => {
 };
 
 export const deleteProvider = async (id) => {
-  const { data } = await api.del(`/provider/${id}`);
+  const { data } = await api.delete(`/provider/${id}`);
   return data.payload;
 };

+ 6 - 0
src/boot/axios.js

@@ -45,6 +45,12 @@ const errorInterceptor = async (error, router) => {
   const user_store = userStore();
 
   if (error.response?.status === 422) {
+    if (error?.response?.data?.message) {
+      Notify.create({
+        message: error?.response?.data?.message,
+        type: "negative",
+      });
+    }
     return Promise.reject(error);
   }
 

+ 134 - 0
src/components/client/ClientSelect.vue

@@ -0,0 +1,134 @@
+<template>
+  <q-select
+    v-model="selectedClient"
+    :options="filteredClients"
+    :label="label"
+    outlined
+    use-input
+    clearable
+    input-debounce="300"
+    option-label="label"
+    option-value="value"
+    :rules="rules"
+    :error="error"
+    :error-message="errorMessage"
+    @filter="filterFn"
+    @update:model-value="updateValue"
+  >
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{ $t('common.status.no_results') }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { ref, onMounted, watch } from 'vue';
+import { getClients } from 'src/api/client';
+import { normalizeString } from 'src/helpers/utils';
+
+const props = defineProps({
+  label: {
+    type: String,
+    default: 'Cliente',
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: '',
+  },
+  initialId: {
+    type: Number,
+    default: null,
+  },
+});
+
+const model = defineModel({
+  type: Object,
+  default: null,
+});
+const emit = defineEmits(['update:modelValue']);
+
+const clients = ref([]);
+const filteredClients = ref([]);
+const selectedClient = ref(null);
+
+const loadClients = async () => {
+  try {
+    const response = await getClients();
+    clients.value = response.map((client) => ({
+      label: `${client.user?.name || ''} - ${client.document}`,
+      value: client.id,
+      document: client.document,
+      user: client.user,
+    }));
+    filteredClients.value = clients.value;
+
+    // Se tiver initialId, seleciona automaticamente
+    if (props.initialId) {
+      selectClientById(props.initialId);
+    }
+  } catch (error) {
+    console.error('Error loading clients:', error);
+  }
+};
+
+const filterFn = (val, update) => {
+  update(() => {
+    const needle = normalizeString(val);
+    filteredClients.value = clients.value.filter((client) => {
+      const clientName = normalizeString(client.user?.name || '');
+      const clientDocument = normalizeString(client.document || '');
+      return clientName.includes(needle) || clientDocument.includes(needle);
+    });
+  });
+};
+
+const updateValue = (value) => {
+  model.value = value;
+  emit('update:modelValue', value);
+};
+
+const selectClientById = (id) => {
+  const client = clients.value.find((c) => c.value === id);
+  if (client) {
+    selectedClient.value = client;
+    updateValue(client);
+  }
+};
+
+const selectClientByName = (name) => {
+  const client = clients.value.find((c) => 
+    normalizeString(c.user?.name || '').includes(normalizeString(name))
+  );
+  if (client) {
+    selectedClient.value = client;
+    updateValue(client);
+  }
+};
+
+watch(() => props.initialId, (newId) => {
+  if (newId) {
+    selectClientById(newId);
+  }
+});
+
+onMounted(() => {
+  loadClients();
+});
+
+defineExpose({
+  selectClientById,
+  selectClientByName,
+});
+</script>

+ 22 - 0
src/helpers/utils.js

@@ -237,6 +237,27 @@ const calculateDailyPrices = (dailyPrice8h) => {
   };
 };
 
+/**
+ * Formata CPF/CNPJ para exibição
+ * @param {string} value - O CPF/CNPJ sem formatação
+ * @returns {string} - CPF/CNPJ formatado
+ */
+const formatDocument = (value) => {
+  if (!value) return '';
+  
+  const cleanValue = value.replace(/\D/g, '');
+  
+  if (cleanValue.length === 11) {
+    // CPF: 000.000.000-00
+    return cleanValue.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4');
+  } else if (cleanValue.length === 14) {
+    // CNPJ: 00.000.000/0000-00
+    return cleanValue.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5');
+  }
+  
+  return value;
+};
+
 export {
   formatDateDMYtoYMD,
   formatDateYMDtoDMY,
@@ -249,4 +270,5 @@ export {
   validateCNPJ,
   validateCpfCnpj,
   calculateDailyPrices,
+  formatDocument,
 };

+ 26 - 3
src/i18n/locales/en.json

@@ -68,7 +68,9 @@
       "version": "Version",
       "user": "User",
       "rating": "Rating",
-      "services": "Services"
+      "services": "Services",
+      "created_at": "Created at",
+      "actions": "Actions"
     },
     "months": {
       "january": "January",
@@ -90,7 +92,8 @@
       "canceled": "Canceled",
       "loading": "Please wait...",
       "yes": "Yes",
-      "no": "No"
+      "no": "No",
+      "no_results": "No results found"
     },
     "ui": {
       "file": {
@@ -127,6 +130,14 @@
       "created_at": "Created at",
       "updated_at": "Updated at",
       "created_by": "Created by"
+    },
+    "messages": {
+      "error_loading": "Error loading data",
+      "no_permission": "You don't have permission to access this resource",
+      "confirm": "Confirm",
+      "confirm_delete": "Are you sure you want to delete this item?",
+      "deleted_successfully": "Deleted successfully",
+      "error_deleting": "Error deleting the item"
     }
   },
   "auth": {
@@ -226,6 +237,15 @@
       "singular": "Preferences"
     }
   },
+  "client": {
+    "singular": "Client",
+    "plural": "Clients",
+    "add": "Add Client",
+    "edit": "Edit Client",
+    "fields": {
+      "document": "CPF/CNPJ"
+    }
+  },
   "provider": {
     "singular": "Provider",
     "plural": "Providers",
@@ -301,10 +321,13 @@
       "wallet": "Wallet",
       "settings": "Settings",
       "city": "City",
+      "client": "Client",
       "state": "State",
       "country": "Country",
       "provider": "Provider",
-      "exit": "Exit"
+      "exit": "Exit",
+      "admin": "Admin",
+      "user": "User"
     }
   },
   "charts": {

+ 26 - 3
src/i18n/locales/es.json

@@ -68,7 +68,9 @@
       "version": "Versión",
       "user": "Usuario",
       "rating": "Calificación",
-      "services": "Servicios"
+      "services": "Servicios",
+      "created_at": "Creado el",
+      "actions": "Acciones"
     },
     "months": {
       "january": "Enero",
@@ -90,7 +92,8 @@
       "canceled": "Cancelado",
       "loading": "Por favor espere...",
       "yes": "Sí",
-      "no": "No"
+      "no": "No",
+      "no_results": "No se encontraron resultados"
     },
     "ui": {
       "file": {
@@ -127,6 +130,14 @@
       "created_at": "Creado el",
       "updated_at": "Actualizado el",
       "created_by": "Creado por"
+    },
+    "messages": {
+      "error_loading": "Error al cargar los datos",
+      "no_permission": "No tienes permiso para acceder a este recurso",
+      "confirm": "Confirmar",
+      "confirm_delete": "¿Estás seguro de que quieres eliminar este elemento?",
+      "deleted_successfully": "Eliminado con éxito",
+      "error_deleting": "Error al eliminar el elemento"
     }
   },
   "auth": {
@@ -226,6 +237,15 @@
       "singular": "Preferencias"
     }
   },
+  "client": {
+    "singular": "Cliente",
+    "plural": "Clientes",
+    "add": "Agregar Cliente",
+    "edit": "Editar Cliente",
+    "fields": {
+      "document": "CPF/CNPJ"
+    }
+  },
   "provider": {
     "singular": "Proveedor",
     "plural": "Proveedores",
@@ -301,10 +321,13 @@
       "wallet": "Billetera",
       "settings": "Configuración",
       "city": "Ciudad",
+      "client": "Cliente",
       "state": "Estado/Provincia",
       "country": "País",
       "provider": "Proveedor",
-      "exit": "Salir"
+      "exit": "Salir",
+      "admin": "Admin",
+      "user": "Usuario"
     }
   },
   "charts": {

+ 26 - 3
src/i18n/locales/pt.json

@@ -68,7 +68,9 @@
       "version": "Versão",
       "user": "Usuário",
       "rating": "Avaliação",
-      "services": "Serviços"
+      "services": "Serviços",
+      "created_at": "Criado em",
+      "actions": "Ações"
     },
     "months": {
       "january": "Janeiro",
@@ -90,7 +92,8 @@
       "canceled": "Cancelado",
       "loading": "Por favor, aguarde...",
       "yes": "Sim",
-      "no": "Não"
+      "no": "Não",
+      "no_results": "Nenhum resultado encontrado"
     },
     "ui": {
       "file": {
@@ -127,6 +130,14 @@
       "created_at": "Criado em",
       "updated_at": "Atualizado em",
       "created_by": "Criado por"
+    },
+    "messages": {
+      "error_loading": "Erro ao carregar os dados",
+      "no_permission": "Você não tem permissão para acessar este recurso",
+      "confirm": "Confirmar",
+      "confirm_delete": "Tem certeza de que deseja excluir este item?",
+      "deleted_successfully": "Excluído com sucesso",
+      "error_deleting": "Erro ao excluir o item"
     }
   },
   "auth": {
@@ -226,6 +237,15 @@
       "singular": "Preferências"
     }
   },
+  "client": {
+    "singular": "Cliente",
+    "plural": "Clientes",
+    "add": "Adicionar Cliente",
+    "edit": "Editar Cliente",
+    "fields": {
+      "document": "CPF/CNPJ"
+    }
+  },
   "provider": {
     "singular": "Prestador",
     "plural": "Prestadores",
@@ -301,10 +321,13 @@
       "wallet": "Carteira",
       "settings": "Configurações",
       "city": "Cidade",
+      "client": "Cliente",
       "state": "Estado",
       "country": "País",
       "provider": "Prestador",
-      "exit": "Sair"
+      "exit": "Sair",
+      "admin": "Admin",
+      "user": "Usuário"
     }
   },
   "charts": {

+ 135 - 0
src/pages/client/ClientPage.vue

@@ -0,0 +1,135 @@
+<template>
+  <q-page class="q-pa-md">
+    <DefaultHeaderPage />
+    <DefaultTable
+        ref="tableRef"
+        :columns="columns"
+        :api-call="getClients"
+        :delete-function="deleteClient"
+        :mostrar-selecao-de-colunas="false"
+        :mostrar-botao-fullscreen="false"
+        :mostrar-toggle-inativos="false"
+        open-item
+        add-item
+        @on-row-click="onRowClick"
+        @on-add-item="onAddItem"
+    />
+  </q-page>
+</template>
+
+<script setup>
+import { ref, computed, onMounted, useTemplateRef } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { useQuasar } from 'quasar';
+import { getClients, deleteClient } from 'src/api/client';
+import { permissionStore } from "src/stores/permission";
+import { formatDocument } from 'src/helpers/utils';
+import DefaultHeaderPage from 'src/components/layout/DefaultHeaderPage.vue';
+import DefaultTable from 'src/components/defaults/DefaultTable.vue';
+import AddEditClientDialog from './components/AddEditClientDialog.vue';
+
+const { t } = useI18n();
+const $q = useQuasar();
+const permission_store = permissionStore();
+
+const clients = ref([]);
+const loading = ref(false);
+const tableRef = useTemplateRef("tableRef");
+
+const columns = computed(() => [
+  {
+    name: 'document',
+    label: t("client.fields.document"),
+    field: 'document',
+    align: 'left',
+    sortable: true,
+    format: (val) => formatDocument(val),
+  },
+  {
+    name: 'user',
+    label: t("common.terms.user"),
+    field: (row) => row.user?.name || '-',
+    align: 'left',
+    sortable: true,
+  },
+  {
+    name: 'created_at',
+    label: t("common.terms.created_at"),
+    field: 'created_at',
+    align: 'left',
+    sortable: true,
+    format: (val) => new Date(val).toLocaleDateString('pt-BR'),
+  },
+  {
+    name: 'actions',
+    label: t("common.terms.actions"),
+    field: 'actions',
+    align: 'center',
+  },
+]);
+
+const loadClients = async () => {
+  loading.value = true;
+  try {
+    clients.value = await getClients();
+  } catch {
+    $q.notify({
+      type: 'negative',
+      message: t('common.messages.error_loading'),
+    });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const onRowClick = ({ row }) => {
+  if (permission_store.getAccess("config.provider", "edit") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.edit"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditClientDialog,
+    componentProps: {
+      client: row,
+      title: () =>
+        useI18n().t("common.actions.edit") +
+        " " +
+        useI18n().t("ui.navigation.client"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+
+const onAddItem = () => {
+  if (permission_store.getAccess("config.client", "add") === false) {
+    $q.loading.hide();
+    $q.notify({
+      type: "negative",
+      message: t("validation.permissions.add"),
+    });
+    return;
+  }
+  $q.dialog({
+    component: AddEditClientDialog,
+    componentProps: {
+      title: () =>
+        useI18n().t("common.actions.add") + " " + useI18n().t("ui.navigation.client"),
+    },
+  }).onOk(async (success) => {
+    if (success) {
+      tableRef.value.refresh();
+    }
+  });
+};
+
+onMounted(() => {
+  loadClients();
+});
+</script>

+ 130 - 0
src/pages/client/components/AddEditClientDialog.vue

@@ -0,0 +1,130 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin" style="width: 700px; max-width: 90vw">
+      <DefaultDialogHeader :title="title" @close="onDialogCancel" />
+      <q-form ref="formRef" @submit="onOKClick">
+        <q-card-section class="row q-col-gutter-sm">
+          <!-- User -->
+          <UserSelect
+            v-model="selectedUser"
+            :label="$t('common.terms.user')"
+            :rules="[inputRules.required]"
+            :error="!!serverErrors?.user_id"
+            :error-message="serverErrors?.user_id"
+            :initial-id="client ? client.user_id : null"
+            class="col-12"
+            @update:model-value="serverErrors.user_id = null"
+          />
+
+          <!-- Document (CPF/CNPJ) -->
+          <q-input
+            v-model="form.document"
+            :mask="documentMask"
+            fill-mask
+            unmasked-value
+            :label="$t('client.fields.document')"
+            :rules="[inputRules.required, validateDocument]"
+            :error="!!serverErrors?.document"
+            :error-message="serverErrors?.document"
+            class="col-12"
+            @update:model-value="serverErrors.document = null"
+          />
+        </q-card-section>
+        <q-card-actions align="center">
+          <q-btn color="primary" label="Cancel" @click="onDialogCancel" />
+          <q-space />
+          <q-btn
+            color="primary"
+            label="OK"
+            :type="'submit'"
+            :loading="loading"
+            :disable="!hasUpdatedFields"
+          />
+        </q-card-actions>
+      </q-form>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, useTemplateRef, watch, computed } from 'vue';
+import { useInputRules } from 'src/composables/useInputRules';
+import { useDialogPluginComponent } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { createClient, updateClient } from 'src/api/client';
+import { useFormUpdateTracker } from 'src/composables/useFormUpdateTracker';
+import { useSubmitHandler } from 'src/composables/useSubmitHandler';
+import { dynamicCpfCnpjMask, validateCpfCnpj } from 'src/helpers/utils';
+
+import DefaultDialogHeader from 'src/components/defaults/DefaultDialogHeader.vue';
+import UserSelect from 'src/components/user/UserSelect.vue';
+
+defineEmits([
+  ...useDialogPluginComponent.emits,
+]);
+
+const { client, title } = defineProps({
+  client: {
+    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({
+  user_id: client ? client?.user_id : null,
+  document: client ? client?.document : '',
+});
+
+const {
+  loading,
+  serverErrors,
+  execute: submitForm,
+} = useSubmitHandler({
+  onSuccess: () => onDialogOK(true),
+  formRef: formRef,
+});
+
+const selectedUser = ref(null);
+
+// Dynamic mask for CPF/CNPJ
+const documentMask = computed(() => {
+  return dynamicCpfCnpjMask(form.document);
+});
+
+// CPF/CNPJ validation
+const validateDocument = (val) => {
+  if (!val) return true;
+  return validateCpfCnpj(val) || t('validation.rules.cpf') + ' / ' + t('validation.rules.cnpj');
+};
+
+const onOKClick = async () => {
+    let response;
+  if (client) {
+    await submitForm(() => {
+      response = updateClient(getUpdatedFields.value, client.id);
+    });
+  } else {
+    await submitForm(() => {
+      response = createClient({ ...form });
+    });
+  }
+  response;
+//   if(response.data.success == false)
+};
+
+watch(selectedUser, () => {
+  form.user_id = selectedUser.value?.value;
+});
+</script>

+ 0 - 2
src/pages/provider/ProviderPage.vue

@@ -27,7 +27,6 @@ import { getProviders, deleteProvider } from "src/api/provider";
 
 import DefaultTable from "src/components/defaults/DefaultTable.vue";
 import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
-// import { formatDateYMDtoDMY } from "src/helpers/utils";
 
 const AddEditProviderDialog = defineAsyncComponent(
   () => import("src/pages/provider/components/AddEditProviderDialog.vue"),
@@ -65,7 +64,6 @@ const columns = [
     name: "birth_date",
     label: t("provider.fields.birth_date"),
     field: "birth_date",
-    // format: (val) => (val ? formatDateYMDtoDMY(val) : "-"),
     align: "left",
     sortable: true,
   },

+ 8 - 0
src/pages/users/UsersPage.vue

@@ -54,6 +54,14 @@ const columns = [
     align: "left",
     sortable: true,
   },
+  {
+    name: "type",
+    label: t("common.ui.misc.type"),
+    field: "type",
+    format: (val) => (val ? t("ui.navigation." + val.toLowerCase())  : "-") ,
+    align: "left",
+    sortable: true,
+  },
   {
     name: "actions",
     required: true,

+ 40 - 0
src/router/routes/client.route.js

@@ -0,0 +1,40 @@
+// const routes = [
+//   {
+//     path: '/client',
+//     name: 'client',
+//     component: () => import('src/pages/client/ClientPage.vue'),
+//     meta: {
+//       requiresAuth: true,
+//       permission: 'config.client',
+//       breadcrumbs: [
+//         { label: 'common.terms.home', to: '/' },
+//         { label: 'client.plural' },
+//       ],
+//     },
+//   },
+// ];
+
+// export default routes;
+
+export default [
+  {
+    path: "/client",
+    name: "ClientPage",
+    component: () => import("pages/client/ClientPage.vue"),
+    meta: {
+      title: "ui.navigation.client",
+      requireAuth: true,
+      requiredPermission: "config.client",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "ProviderPage",
+          title: "ui.navigation.client",
+        },
+      ],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -30,6 +30,15 @@ export const navigationStore = defineStore("navigation", () => {
           permission: false,
           permissionScope: "config.city",
         },
+        {
+          type: "single",
+          title: "ui.navigation.client",
+          name: "ClientPage",
+          icon: "mdi-account-outline",
+          disable: false,
+          permission: false,
+          permissionScope: "config.client",
+        },
         {
           type: "single",
           title: "ui.navigation.state",