Explorar o código

feat: :sparkles: feat (login) bloqueio de logins + provider aprovacao

foi realizado o bloqueio de logins nos diferentes sistemas, permitindo apenas clients logarem no app do cliente, apenas providers logarem no app do cliente e apenas users tipo admin e user logar no backoffice + provider precisa ser aprovado para conseguir logar

fase:dev | origin:escopo
Gustavo Zanatta hai 1 mes
pai
achega
3e13106ef8

+ 16 - 0
src/api/provider.js

@@ -10,6 +10,12 @@ export const getProviders = async () => {
   return data.payload;
 };
 
+export const getPendingProviders = async ({ page = 1, perPage = 5 } = {}) => {
+  console.log(perPage);
+  const response = await api.get("/provider/pending", { params: { page, per_page: perPage } });
+  return { data: { result: response.data.payload } };
+};
+
 export const createProvider = async (provider) => {
   const { data } = await api.post("/provider", provider);
   return data.payload;
@@ -24,3 +30,13 @@ export const deleteProvider = async (id) => {
   const { data } = await api.delete(`/provider/${id}`);
   return data.payload;
 };
+
+export const approveProvider = async (id) => {
+  const { data } = await api.patch(`/provider/${id}/approve`);
+  return data.payload;
+};
+
+export const rejectProvider = async (id) => {
+  const { data } = await api.patch(`/provider/${id}/reject`);
+  return data.payload;
+};

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

@@ -5,7 +5,7 @@
     v-model:pagination="pagination"
     row-key="id"
     flat
-    class="softpar-table q-pa-sm"
+    class="softpar-table"
     :pagination-label="getPaginationLabel"
     :rows="rows"
     :rows-per-page-label="$t('common.ui.table.rows_per_page')"
@@ -19,6 +19,7 @@
   >
     <template #top>
       <div
+        v-if="showSearchField || showColumnsSelect || addItem"
         class="flex full-width justify-between items-center q-mb-md q-pl-sm"
         style="gap: 1rem"
       >
@@ -205,7 +206,7 @@ const {
   },
   rowsPerPage: {
     type: Number,
-    default: 10,
+    default: 5,
   },
   showColumnsSelect: {
     type: Boolean,
@@ -234,7 +235,7 @@ const router = useRouter();
 const rows = ref([]);
 const loading = ref(true);
 const fullscreen = ref(false);
-const rowsPerPageOptions = [10, 15, 25, 50];
+const rowsPerPageOptions = [5];
 
 const pagination = ref({
   filter: undefined,

+ 2 - 2
src/css/table.scss

@@ -1,8 +1,8 @@
 @use "sass:map";
 @use "src/css/quasar.variables.scss";
 .softpar-table {
-  padding-left: 16px !important;
-  padding-right: 16px !important;
+  // padding-left: 16px !important;
+  // padding-right: 16px !important;
 
   .body--dark & {
     --table-bg-color: #{map.get($colors-dark, "surface")}; // Using our dark background

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

@@ -325,16 +325,31 @@
       "birth_date": "Birth Date",
       "selfie_verified": "Selfie Verified",
       "document_verified": "Document Verified",
-      "is_approved": "Approved",
       "average_rating": "Average Rating",
       "total_services": "Total Services",
       "daily_price_8h": "Daily Price 8h",
       "daily_price_6h": "Daily Price 6h",
       "daily_price_4h": "Daily Price 4h",
-      "daily_price_2h": "Daily Price 2h"
+      "daily_price_2h": "Daily Price 2h",
+      "approval_status": "Approval Status"
     },
     "hints": {
       "daily_price": "Value between R$ 100.00 and R$ 500.00"
+    },
+    "approval_status": {
+      "pending": "Pending",
+      "accepted": "Approved",
+      "rejected": "Rejected"
+    },
+    "approval": {
+      "dialog_title": "Provider Review",
+      "section_personal": "Personal Data",
+      "section_pricing": "Pricing",
+      "btn_approve": "Approve",
+      "btn_reject": "Reject",
+      "approved_success": "Provider approved successfully.",
+      "rejected_success": "Provider rejected.",
+      "pending_table_title": "Providers Awaiting Approval"
     }
   },
   "provider_specialities": {
@@ -751,4 +766,4 @@
       }
     }
   }
-}
+}

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

@@ -325,16 +325,31 @@
       "birth_date": "Fecha de Nacimiento",
       "selfie_verified": "Selfie Verificada",
       "document_verified": "Documento Verificado",
-      "is_approved": "Aprobado",
       "average_rating": "Calificación Promedio",
       "total_services": "Total de Servicios",
       "daily_price_8h": "Precio Diario 8h",
       "daily_price_6h": "Precio Diario 6h",
       "daily_price_4h": "Precio Diario 4h",
-      "daily_price_2h": "Precio Diario 2h"
+      "daily_price_2h": "Precio Diario 2h",
+      "approval_status": "Estado de Aprobación"
     },
     "hints": {
       "daily_price": "Valor entre R$ 100,00 y R$ 500,00"
+    },
+    "approval_status": {
+      "pending": "Pendiente",
+      "accepted": "Aprobado",
+      "rejected": "Rechazado"
+    },
+    "approval": {
+      "dialog_title": "Revisión de Prestador",
+      "section_personal": "Datos Personales",
+      "section_pricing": "Precios",
+      "btn_approve": "Aprobar",
+      "btn_reject": "Rechazar",
+      "approved_success": "Prestador aprobado exitosamente.",
+      "rejected_success": "Prestador rechazado.",
+      "pending_table_title": "Prestadores en Espera de Aprobación"
     }
   },
   "provider_specialities": {
@@ -751,4 +766,4 @@
       }
     }
   }
-}
+}

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

@@ -325,16 +325,31 @@
       "birth_date": "Data de Nascimento",
       "selfie_verified": "Selfie Verificada",
       "document_verified": "Documento Verificado",
-      "is_approved": "Aprovado",
       "average_rating": "Avaliação Média",
       "total_services": "Total de Serviços",
       "daily_price_8h": "Preço Diária 8h",
       "daily_price_6h": "Preço Diária 6h",
       "daily_price_4h": "Preço Diária 4h",
-      "daily_price_2h": "Preço Diária 2h"
+      "daily_price_2h": "Preço Diária 2h",
+      "approval_status": "Status de Aprovação"
     },
     "hints": {
       "daily_price": "Valor entre R$ 100,00 e R$ 500,00"
+    },
+    "approval_status": {
+      "pending": "Pendente",
+      "accepted": "Aprovado",
+      "rejected": "Recusado"
+    },
+    "approval": {
+      "dialog_title": "Revisão de Prestador",
+      "section_personal": "Dados Pessoais",
+      "section_pricing": "Preços",
+      "btn_approve": "Aprovar",
+      "btn_reject": "Recusar",
+      "approved_success": "Prestador aprovado com sucesso.",
+      "rejected_success": "Prestador recusado.",
+      "pending_table_title": "Prestadores Aguardando Aprovação"
     }
   },
   "provider_specialities": {
@@ -751,4 +766,4 @@
       }
     }
   }
-}
+}

+ 3 - 0
src/pages/dashboard/DashboardPage.vue

@@ -3,6 +3,8 @@
     <DefaultHeaderPage />
 
     <div v-if="!isLoading" class="q-pa-md">
+      <PendingProvidersTable />
+
       <!-- Tabs Principais: Agendamentos / Oportunidades -->
       <q-tabs
         v-model="scheduleType"
@@ -489,6 +491,7 @@ 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 PendingProvidersTable from 'src/pages/dashboard/components/PendingProvidersTable.vue'
 import ViewScheduleDialog from 'src/pages/schedule/components/ViewScheduleDialog.vue'
 import ViewCustomScheduleDialog from 'src/pages/opportunity/components/ViewCustomScheduleDialog.vue'
 import { getSchedulesGroupedByClient, getSchedulesGroupedByClientCustom, updateScheduleStatus } from 'src/api/schedule'

+ 88 - 0
src/pages/dashboard/components/PendingProvidersTable.vue

@@ -0,0 +1,88 @@
+<template>
+  <div class="q-mb-lg">
+    <div class="text-h6 text-weight-bold q-mb-sm">
+      {{ $t('provider.approval.pending_table_title') }}
+    </div>
+
+    <DefaultTableServerSide
+      ref="tableRef"
+      class="bg-surface-light"
+      :columns="columns"
+      :api-call="getPendingProviders"
+      :add-item="false"
+      :show-search-field="false"
+      :open-item="true"
+      @on-row-click="onRowClick"
+    >
+      <template #body-cell-name="slotProps">
+        <q-td :props="slotProps">
+          {{ slotProps.row.user?.name || '—' }}
+        </q-td>
+      </template>
+
+      <template #body-cell-approval_status="slotProps">
+        <q-td :props="slotProps">
+          <q-badge color="warning" :label="$t(`provider.approval_status.${slotProps.row.approval_status}`)" />
+        </q-td>
+      </template>
+
+      <template #body-cell-created_at="slotProps">
+        <q-td :props="slotProps">
+          {{ formatDate(slotProps.row.created_at) }}
+        </q-td>
+      </template>
+
+      <template #body-cell-actions="slotProps">
+        <q-td :props="slotProps" class="text-right">
+          <q-btn
+            flat
+            dense
+            round
+            icon="mdi-eye-outline"
+            color="primary"
+            @click.stop="onRowClick({ row: slotProps.row })"
+          >
+            <q-tooltip>{{ $t('common.actions.view') }}</q-tooltip>
+          </q-btn>
+        </q-td>
+      </template>
+    </DefaultTableServerSide>
+  </div>
+</template>
+
+<script setup>
+import { defineAsyncComponent, ref } from 'vue';
+import { useQuasar } from 'quasar';
+import { useI18n } from 'vue-i18n';
+import { getPendingProviders } from 'src/api/provider';
+import DefaultTableServerSide from 'src/components/defaults/DefaultTableServerSide.vue';
+import { format, parseISO } from 'date-fns';
+
+const ProviderApprovalDialog = defineAsyncComponent(
+  () => import('src/pages/dashboard/components/ProviderApprovalDialog.vue'),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const tableRef = ref(null);
+
+const columns = [
+  { name: 'name',            label: t('common.terms.name'),              field: (row) => row.user?.name || '—', align: 'left',  sortable: false, style: 'width: 25%' },
+  { name: 'document',        label: t('provider.fields.document'),        field: 'document',                     align: 'left',  sortable: false, style: 'width: 25%' },
+  { name: 'created_at',      label: t('common.terms.created_at'),         field: 'created_at',                   align: 'left',  sortable: false, style: 'width: 25%' },
+  { name: 'approval_status', label: t('provider.fields.approval_status'), field: 'approval_status',              align: 'left',  sortable: false, style: 'width: 25%' },
+  { name: 'actions',         label: '',                                    field: 'actions',                      align: 'right', sortable: false, style: 'width: 25%' },
+];
+
+const formatDate = (value) => {
+  if (!value) return '—';
+  try { return format(parseISO(value), 'dd/MM/yyyy HH:mm'); } catch { return value; }
+};
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: ProviderApprovalDialog,
+    componentProps: { provider: row },
+  }).onOk(() => tableRef.value?.refresh());
+};
+</script>

+ 163 - 0
src/pages/dashboard/components/ProviderApprovalDialog.vue

@@ -0,0 +1,163 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin column" style="width: 700px; max-width: 95vw; max-height: 90vh;">
+      <DefaultDialogHeader :title="() => $t('provider.approval.dialog_title')" @close="onDialogCancel" />
+
+      <div class="col scroll q-pa-md">
+        <div class="row items-center q-mb-md">
+          <q-badge
+            :color="statusColor"
+            :label="$t(`provider.approval_status.${provider.approval_status}`)"
+            class="text-body2 q-pa-sm"
+          />
+        </div>
+
+        <div class="text-subtitle1 text-weight-bold q-mb-sm">{{ $t('provider.approval.section_personal') }}</div>
+        <q-list bordered separator class="rounded-borders q-mb-md">
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('common.terms.name') }}</q-item-label>
+              <q-item-label>{{ provider.user?.name || '—' }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('common.terms.email') }}</q-item-label>
+              <q-item-label>{{ provider.user?.email || '—' }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.document') }}</q-item-label>
+              <q-item-label>{{ provider.document || '—' }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.rg') }}</q-item-label>
+              <q-item-label>{{ provider.rg || '—' }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.birth_date') }}</q-item-label>
+              <q-item-label>{{ provider.birth_date || '—' }}</q-item-label>
+            </q-item-section>
+          </q-item>
+        </q-list>
+
+        <div class="text-subtitle1 text-weight-bold q-mb-sm">{{ $t('provider.approval.section_pricing') }}</div>
+        <q-list bordered separator class="rounded-borders q-mb-md">
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.daily_price_8h') }}</q-item-label>
+              <q-item-label>{{ formatCurrency(provider.daily_price_8h) }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.daily_price_6h') }}</q-item-label>
+              <q-item-label>{{ formatCurrency(provider.daily_price_6h) }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.daily_price_4h') }}</q-item-label>
+              <q-item-label>{{ formatCurrency(provider.daily_price_4h) }}</q-item-label>
+            </q-item-section>
+          </q-item>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t('provider.fields.daily_price_2h') }}</q-item-label>
+              <q-item-label>{{ formatCurrency(provider.daily_price_2h) }}</q-item-label>
+            </q-item-section>
+          </q-item>
+        </q-list>
+
+        <div class="text-caption text-grey-6">
+          {{ $t('common.terms.created_at') + ': ' + (provider.created_at || '—') }}
+        </div>
+      </div>
+
+      <q-card-actions align="right" class="q-pa-md">
+        <q-btn
+          flat
+          :label="$t('common.actions.cancel')"
+          color="grey-7"
+          @click="onDialogCancel"
+        />
+        <q-btn
+          v-if="provider.approval_status !== 'rejected'"
+          unelevated
+          :label="$t('provider.approval.btn_reject')"
+          color="negative"
+          :loading="loadingReject"
+          @click="onReject"
+        />
+        <q-btn
+          v-if="provider.approval_status !== 'accepted'"
+          unelevated
+          :label="$t('provider.approval.btn_approve')"
+          color="positive"
+          :loading="loadingApprove"
+          @click="onApprove"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { approveProvider, rejectProvider } from "src/api/provider";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+const props = defineProps({
+  provider: { type: Object, required: true },
+});
+
+defineEmits([...useDialogPluginComponent.emits]);
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const $q = useQuasar();
+const { t } = useI18n();
+
+const loadingApprove = ref(false);
+const loadingReject  = ref(false);
+
+const statusColor = computed(() => {
+  const map = { pending: "warning", accepted: "positive", rejected: "negative" };
+  return map[props.provider.approval_status] ?? "grey";
+});
+
+const formatCurrency = (value) => {
+  if (!value) return "—";
+  return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(value);
+};
+
+const onApprove = async () => {
+  loadingApprove.value = true;
+  try {
+    await approveProvider(props.provider.id);
+    onDialogOK({ action: "approved" });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed"), position: "top" });
+  } finally {
+    loadingApprove.value = false;
+  }
+};
+
+const onReject = async () => {
+  loadingReject.value = true;
+  try {
+    await rejectProvider(props.provider.id);
+    onDialogOK({ action: "rejected" });
+  } catch {
+    $q.notify({ type: "negative", message: t("http.errors.failed"), position: "top" });
+  } finally {
+    loadingReject.value = false;
+  }
+};
+</script>

+ 13 - 4
src/pages/provider/ProviderPage.vue

@@ -14,7 +14,16 @@
         add-item
         @on-row-click="onRowClick"
         @on-add-item="onAddItem"
-      />
+      >
+        <template #body-cell-approval_status="slotProps">
+          <q-td :props="slotProps">
+            <q-badge
+              :color="{ pending: 'warning', accepted: 'positive', rejected: 'negative' }[slotProps.row.approval_status] ?? 'grey'"
+              :label="$t(`provider.approval_status.${slotProps.row.approval_status}`)"
+            />
+          </q-td>
+        </template>
+      </DefaultTable>
     </div>
   </div>
 </template>
@@ -60,9 +69,9 @@ const columns = [
     sortable: true,
   },
   {
-    name: "is_approved",
-    label: t("provider.fields.is_approved"),
-    field: (row) => (row.is_approved ? t("common.status.yes") : t("common.status.no")),
+    name: "approval_status",
+    label: t("provider.fields.approval_status"),
+    field: "approval_status",
     align: "left",
     sortable: true,
   },

+ 18 - 4
src/pages/provider/components/AddEditProviderDialog.vue

@@ -97,9 +97,17 @@
                 />
 
                 <div class="col-12">
-                  <q-checkbox
-                    v-model="form.is_approved"
-                    :label="$t('provider.fields.is_approved')"
+                  <q-select
+                    v-model="form.approval_status"
+                    :options="approvalStatusOptions"
+                    :label="$t('provider.fields.approval_status')"
+                    emit-value
+                    map-options
+                    outlined
+                    dense
+                    :error="!!serverErrors?.approval_status"
+                    :error-message="serverErrors?.approval_status"
+                    class="col-12"
                   />
                 </div>
 
@@ -246,6 +254,12 @@ const { provider, title } = defineProps({
 const { t } = useI18n();
 const { inputRules } = useInputRules();
 
+const approvalStatusOptions = computed(() => [
+  { label: t("provider.approval_status.pending"),  value: "pending" },
+  { label: t("provider.approval_status.accepted"), value: "accepted" },
+  { label: t("provider.approval_status.rejected"), value: "rejected" },
+]);
+
 const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
   useDialogPluginComponent();
 
@@ -257,7 +271,7 @@ const { form, getUpdatedFields, hasUpdatedFields } = useFormUpdateTracker({
   document: provider ? provider?.document : "",
   rg: provider ? provider?.rg : "",
   birth_date: provider ? provider?.birth_date : null,
-  is_approved: provider ? provider?.is_approved : false,
+  approval_status: provider ? provider?.approval_status : "pending",
   daily_price_8h: provider ? Number(provider?.daily_price_8h) : null,
   daily_price_6h: provider ? Number(provider?.daily_price_6h) : null,
   daily_price_4h: provider ? Number(provider?.daily_price_4h) : null,