소스 검색

feat: :sparkles: feat (suporte) adicionada opcao de registrar suporte

adicionada funcao do usuario preencher formulario e enviar para suporte com duvidas/ajudas que precisa. Visualizacao e interacao via backoffice

fase: staging | origin:escopo
Gustavo Zanatta 3 주 전
부모
커밋
ab586bd5d8

+ 18 - 0
src/api/supportRequest.js

@@ -0,0 +1,18 @@
+import api from "src/api";
+
+export const getSupportRequests = async ({ page = 1, perPage = 10, filter, status } = {}) => {
+  const response = await api.get("/support-requests", {
+    params: { page, per_page: perPage, filter: filter || undefined, status: status || undefined },
+  });
+  return { data: { result: response.data.payload } };
+};
+
+export const getSupportRequest = async (id) => {
+  const { data } = await api.get(`/support-request/${id}`);
+  return data.payload;
+};
+
+export const updateSupportRequestStatus = async (id, status) => {
+  const { data } = await api.put(`/support-request/${id}`, { status });
+  return data.payload;
+};

+ 1 - 1
src/components/defaults/DefaultTableServerSide.vue

@@ -235,7 +235,7 @@ const router = useRouter();
 const rows = ref([]);
 const loading = ref(true);
 const fullscreen = ref(false);
-const rowsPerPageOptions = [5];
+const rowsPerPageOptions = [5, 10];
 
 const pagination = ref({
   filter: undefined,

+ 40 - 2
src/i18n/locales/en.json

@@ -717,7 +717,8 @@
       "service_type": "Service Type",
       "speciality": "Speciality",
       "reviews": "Reviews",
-      "payments": "Payments"
+      "payments": "Payments",
+      "support_requests": "Support requests"
     }
   },
   "charts": {
@@ -813,5 +814,42 @@
         }
       }
     }
+  },
+  "support_request": {
+    "singular": "Support request",
+    "plural": "Support requests",
+    "fields": {
+      "title": "Subject",
+      "category": "Category",
+      "name": "Name",
+      "email": "Email",
+      "phone": "Phone",
+      "user_type": "Type",
+      "status": "Status",
+      "message": "Message",
+      "handled_by": "Handled by",
+      "resolved_at": "Resolved at"
+    },
+    "categories": {
+      "payment": "Payment",
+      "schedule": "Schedule",
+      "account": "Account",
+      "other": "Other"
+    },
+    "status": {
+      "pending": "Pending",
+      "in_progress": "In progress",
+      "resolved": "Resolved"
+    },
+    "actions": {
+      "mark_in_progress": "Take request",
+      "mark_resolved": "Mark as resolved"
+    },
+    "user_types": {
+      "client": "Client",
+      "provider": "Provider",
+      "admin": "Administrator",
+      "user": "Operator"
+    }
   }
-}
+}

+ 40 - 2
src/i18n/locales/es.json

@@ -717,7 +717,8 @@
       "service_type": "Tipo de Servicio",
       "speciality": "Especialidad",
       "reviews": "Evaluaciones",
-      "payments": "Pagos"
+      "payments": "Pagos",
+      "support_requests": "Atenciones"
     }
   },
   "charts": {
@@ -813,5 +814,42 @@
         }
       }
     }
+  },
+  "support_request": {
+    "singular": "Atención",
+    "plural": "Atenciones",
+    "fields": {
+      "title": "Asunto",
+      "category": "Categoría",
+      "name": "Nombre",
+      "email": "Correo",
+      "phone": "Teléfono",
+      "user_type": "Tipo",
+      "status": "Estado",
+      "message": "Mensaje",
+      "handled_by": "Atendido por",
+      "resolved_at": "Resuelto el"
+    },
+    "categories": {
+      "payment": "Pago",
+      "schedule": "Agenda",
+      "account": "Cuenta",
+      "other": "Otro"
+    },
+    "status": {
+      "pending": "Pendiente",
+      "in_progress": "En atención",
+      "resolved": "Resuelto"
+    },
+    "actions": {
+      "mark_in_progress": "Tomar atención",
+      "mark_resolved": "Marcar como resuelto"
+    },
+    "user_types": {
+      "client": "Cliente",
+      "provider": "Prestador",
+      "admin": "Administrador",
+      "user": "Operador"
+    }
   }
-}
+}

+ 40 - 2
src/i18n/locales/pt.json

@@ -717,7 +717,8 @@
       "service_type": "Tipo de Serviço",
       "speciality": "Especialidade",
       "reviews": "Avaliações",
-      "payments": "Pagamentos"
+      "payments": "Pagamentos",
+      "support_requests": "Atendimentos"
     }
   },
   "charts": {
@@ -813,5 +814,42 @@
         }
       }
     }
+  },
+  "support_request": {
+    "singular": "Atendimento",
+    "plural": "Atendimentos",
+    "fields": {
+      "title": "Assunto",
+      "category": "Categoria",
+      "name": "Nome",
+      "email": "E-mail",
+      "phone": "Telefone",
+      "user_type": "Tipo",
+      "status": "Status",
+      "message": "Mensagem",
+      "handled_by": "Atendido por",
+      "resolved_at": "Resolvido em"
+    },
+    "categories": {
+      "payment": "Pagamento",
+      "schedule": "Agendamento",
+      "account": "Conta",
+      "other": "Outro"
+    },
+    "status": {
+      "pending": "Pendente",
+      "in_progress": "Em atendimento",
+      "resolved": "Resolvido"
+    },
+    "actions": {
+      "mark_in_progress": "Assumir atendimento",
+      "mark_resolved": "Marcar como resolvido"
+    },
+    "user_types": {
+      "client": "Cliente",
+      "provider": "Prestador",
+      "admin": "Administrador",
+      "user": "Operador"
+    }
   }
-}
+}

+ 129 - 0
src/pages/supportRequest/SupportRequestsPage.vue

@@ -0,0 +1,129 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+    <div>
+      <div class="row q-px-sm q-mb-sm">
+        <q-select
+          v-model="statusFilter"
+          :options="statusOptions"
+          :label="$t('support_request.fields.status')"
+          emit-value
+          map-options
+          dense
+          outlined
+          clearable
+          style="min-width: 220px"
+        />
+      </div>
+      <DefaultTableServerSide
+        :key="statusFilter ?? 'all'"
+        ref="tableRef"
+        :columns="columns"
+        :api-call="apiCall"
+        :add-item="false"
+        :rows-per-page="10"
+        open-item
+        @on-row-click="onRowClick"
+      >
+        <template #body-cell-status="slotProps">
+          <q-td :props="slotProps">
+            <q-badge :color="statusColor(slotProps.row.status)" :label="statusLabel(slotProps.row.status)" />
+          </q-td>
+        </template>
+      </DefaultTableServerSide>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { defineAsyncComponent, useTemplateRef, computed, ref } from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { getSupportRequests } from "src/api/supportRequest";
+
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+
+const SupportRequestDetailDialog = defineAsyncComponent(() =>
+  import("src/pages/supportRequest/components/SupportRequestDetailDialog.vue")
+);
+
+const $q = useQuasar();
+const tableRef = useTemplateRef("tableRef");
+const { t } = useI18n();
+
+const statusFilter = ref(null);
+
+const apiCall = (params) => getSupportRequests({ ...params, status: statusFilter.value });
+
+const statusOptions = computed(() => [
+  { label: t("support_request.status.pending"), value: "pending" },
+  { label: t("support_request.status.in_progress"), value: "in_progress" },
+  { label: t("support_request.status.resolved"), value: "resolved" },
+]);
+
+const categoryLabel = (category) => t(`support_request.categories.${category}`, category);
+const statusLabel = (status) => t(`support_request.status.${status}`, status);
+const userTypeLabel = (type) =>
+  type ? t(`support_request.user_types.${type.toLowerCase()}`, type) : "—";
+const statusColor = (status) =>
+  ({ pending: "orange", in_progress: "blue", resolved: "green" }[status] || "grey");
+
+const columns = computed(() => [
+  {
+    name: "title",
+    label: t("support_request.fields.title"),
+    field: "title",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "category",
+    label: t("support_request.fields.category"),
+    field: (row) => categoryLabel(row.category),
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "name",
+    label: t("support_request.fields.name"),
+    field: "name",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "user_type",
+    label: t("support_request.fields.user_type"),
+    field: (row) => userTypeLabel(row.user_type),
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "status",
+    label: t("support_request.fields.status"),
+    field: (row) => statusLabel(row.status),
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "created_at",
+    label: t("common.terms.created_at"),
+    field: "created_at",
+    align: "left",
+    sortable: false,
+  },
+]);
+
+const onRowClick = ({ row }) => {
+  $q.dialog({
+    component: SupportRequestDetailDialog,
+    componentProps: {
+      supportRequest: row,
+    },
+  }).onOk(async (updated) => {
+    if (updated) {
+      tableRef.value.refresh();
+    }
+  });
+};
+</script>

+ 165 - 0
src/pages/supportRequest/components/SupportRequestDetailDialog.vue

@@ -0,0 +1,165 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 720px; max-width: 92vw">
+      <DefaultDialogHeader :title="title" @close="handleClose" />
+
+      <q-card-section class="q-gutter-y-sm">
+        <div class="row items-center justify-between">
+          <div class="text-h6">{{ form.title }}</div>
+          <q-badge :color="statusColor(form.status)" :label="statusLabel(form.status)" />
+        </div>
+
+        <q-list dense>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t("support_request.fields.category") }}</q-item-label>
+              <q-item-label>{{ categoryLabel(form.category) }}</q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>{{ $t("support_request.fields.user_type") }}</q-item-label>
+              <q-item-label>{{ userTypeLabel(form.user_type) }}</q-item-label>
+            </q-item-section>
+          </q-item>
+
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t("support_request.fields.name") }}</q-item-label>
+              <q-item-label>{{ form.name || emptyLabel }}</q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>{{ $t("support_request.fields.email") }}</q-item-label>
+              <q-item-label>
+                <a v-if="form.email" :href="`mailto:${form.email}`">{{ form.email }}</a>
+                <span v-else>{{ emptyLabel }}</span>
+              </q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>{{ $t("support_request.fields.phone") }}</q-item-label>
+              <q-item-label>
+                <a v-if="form.phone" :href="`tel:${form.phone}`">{{ form.phone }}</a>
+                <span v-else>{{ emptyLabel }}</span>
+              </q-item-label>
+            </q-item-section>
+          </q-item>
+
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>{{ $t("common.terms.created_at") }}</q-item-label>
+              <q-item-label>{{ form.created_at }}</q-item-label>
+            </q-item-section>
+            <q-item-section v-if="form.handler">
+              <q-item-label caption>{{ $t("support_request.fields.handled_by") }}</q-item-label>
+              <q-item-label>{{ form.handler }}</q-item-label>
+            </q-item-section>
+            <q-item-section v-if="form.resolved_at">
+              <q-item-label caption>{{ $t("support_request.fields.resolved_at") }}</q-item-label>
+              <q-item-label>{{ form.resolved_at }}</q-item-label>
+            </q-item-section>
+          </q-item>
+        </q-list>
+
+        <div>
+          <div class="text-caption text-grey-7 q-mb-xs">
+            {{ $t("support_request.fields.message") }}
+          </div>
+          <div class="support-message">{{ form.message }}</div>
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right" class="q-px-md q-pb-md q-gutter-x-sm">
+        <q-btn :label="$t('common.actions.close')" flat color="primary" @click="handleClose" />
+        <q-btn
+          v-if="canEdit && form.status !== 'in_progress' && form.status !== 'resolved'"
+          color="blue"
+          unelevated
+          :label="$t('support_request.actions.mark_in_progress')"
+          :loading="loading"
+          @click="changeStatus('in_progress')"
+        />
+        <q-btn
+          v-if="canEdit && form.status !== 'resolved'"
+          color="green"
+          unelevated
+          :label="$t('support_request.actions.mark_resolved')"
+          :loading="loading"
+          @click="changeStatus('resolved')"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import { useDialogPluginComponent, useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+import { permissionStore } from "src/stores/permission";
+import { updateSupportRequestStatus } from "src/api/supportRequest";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { supportRequest } = defineProps({
+  supportRequest: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { t } = useI18n();
+const $q = useQuasar();
+const permission_store = permissionStore();
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
+
+const form = ref({ ...supportRequest });
+const loading = ref(false);
+const updated = ref(false);
+
+const title = () => t("ui.navigation.support_requests");
+const canEdit = computed(() => permission_store.getAccess("support.request", "edit") !== false);
+
+const emptyLabel = "—";
+const categoryLabel = (category) => t(`support_request.categories.${category}`, category);
+const statusLabel = (status) => t(`support_request.status.${status}`, status);
+const userTypeLabel = (type) =>
+  type ? t(`support_request.user_types.${type.toLowerCase()}`, type) : emptyLabel;
+const statusColor = (status) =>
+  ({ pending: "orange", in_progress: "blue", resolved: "green" }[status] || "grey");
+
+const changeStatus = async (status) => {
+  loading.value = true;
+  try {
+    const result = await updateSupportRequestStatus(form.value.id, status);
+    form.value = { ...form.value, ...result };
+    updated.value = true;
+    $q.notify({ type: "positive", message: t("common.ui.messages.updated") });
+  } catch (error) {
+    console.error(error);
+    $q.notify({ type: "negative", message: t("http.errors.failed") });
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleClose = () => {
+  if (updated.value) {
+    onDialogOK(true);
+  } else {
+    onDialogCancel();
+  }
+};
+</script>
+
+<style scoped lang="scss">
+.support-message {
+  white-space: pre-wrap;
+  word-break: break-word;
+  background: #f5f5f7;
+  border-radius: 12px;
+  padding: 14px;
+  line-height: 1.5;
+  max-height: 320px;
+  overflow-y: auto;
+}
+</style>

+ 22 - 0
src/router/routes/supportRequest.route.js

@@ -0,0 +1,22 @@
+export default [
+  {
+    path: "/support-requests",
+    name: "SupportRequestsPage",
+    component: () => import("pages/supportRequest/SupportRequestsPage.vue"),
+    meta: {
+      title: "ui.navigation.support_requests",
+      requireAuth: true,
+      requiredPermission: "support.request",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "SupportRequestsPage",
+          title: "ui.navigation.support_requests",
+        },
+      ],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -49,6 +49,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: ["payment", "config.payment"],
     },
+    {
+      type: "single",
+      title: "ui.navigation.support_requests",
+      name: "SupportRequestsPage",
+      icon: "mdi-face-agent",
+      disable: false,
+      permission: false,
+      permissionScope: "support.request",
+    },
     {
       type: "expansive",
       title: "ui.navigation.registration",