Переглянути джерело

feat(support): adiciona ticket para franchisee

ebagabee 4 тижнів тому
батько
коміт
dd907e82f9

+ 21 - 0
src/api/support_reply.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getSupportReplies = async (ticketId) => {
+  const { data } = await api.get(`/support-ticket/${ticketId}/replies`);
+  return data.payload;
+};
+
+export const createSupportReply = async (ticketId, payload) => {
+  const { data } = await api.post(`/support-ticket/${ticketId}/replies`, payload);
+  return data.payload;
+};
+
+export const updateSupportReply = async (ticketId, id, payload) => {
+  const { data } = await api.put(`/support-ticket/${ticketId}/replies/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteSupportReply = async (ticketId, id) => {
+  const { data } = await api.delete(`/support-ticket/${ticketId}/replies/${id}`);
+  return data;
+};

+ 21 - 0
src/api/support_ticket.js

@@ -0,0 +1,21 @@
+import api from "src/api";
+
+export const getSupportTickets = async () => {
+  const { data } = await api.get("/support-ticket");
+  return data.payload;
+};
+
+export const createSupportTicket = async (payload) => {
+  const { data } = await api.post("/support-ticket", payload);
+  return data.payload;
+};
+
+export const updateSupportTicket = async (id, payload) => {
+  const { data } = await api.put(`/support-ticket/${id}`, payload);
+  return data.payload;
+};
+
+export const deleteSupportTicket = async (id) => {
+  const { data } = await api.delete(`/support-ticket/${id}`);
+  return data;
+};

+ 158 - 0
src/pages/support/SupportPage.vue

@@ -0,0 +1,158 @@
+<template>
+  <div>
+    <DefaultHeaderPage title="Suporte" />
+    <DefaultTable
+      ref="tableRef"
+      :columns="columns"
+      :api-call="getSupportTickets"
+      add-item
+      title="Tickets"
+      :female="false"
+      description="tickets"
+      @on-add-item="openAddEditTicketDialog()"
+    >
+      <template #body-cell-severity="{ row }">
+        <q-td align="left">
+          <q-badge
+            :color="severityColor(row.severity)"
+            :label="severityLabel(row.severity)"
+            style="padding: 6px 10px"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-status="{ row }">
+        <q-td align="left">
+          <q-badge
+            :color="statusColor(row.status)"
+            :label="statusLabel(row.status)"
+            style="padding: 6px 10px"
+          />
+        </q-td>
+      </template>
+
+      <template #body-cell-actions="{ row }">
+        <q-td style="display: flex; gap: 4px; align-items: center; justify-content: center">
+          <q-btn
+            v-if="canManage(row)"
+            outline
+            icon="mdi-pencil-outline"
+            style="width: 36px"
+            @click.prevent.stop="handleEdit(row)"
+          />
+
+          <q-btn
+            v-if="canManage(row) && row.status === 'in_progress'"
+            outline
+            icon="mdi-check-circle-outline"
+            style="width: 36px"
+            @click.prevent.stop="handleClose(row)"
+          />
+
+          <q-btn
+            v-if="canManage(row) && row.status === 'in_progress'"
+            outline
+            icon="mdi-trash-can-outline"
+            style="width: 36px"
+            @click.prevent.stop="handleDelete(row)"
+          />
+        </q-td>
+      </template>
+    </DefaultTable>
+  </div>
+</template>
+
+<script setup>
+import { defineAsyncComponent, useTemplateRef } from "vue";
+import { useQuasar } from "quasar";
+
+import DefaultTable from "src/components/defaults/DefaultTable.vue";
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import { getSupportTickets, deleteSupportTicket } from "src/api/support_ticket";
+
+const AddEditTicketDialog = defineAsyncComponent(
+  () => import("src/pages/support/components/AddEditTicketDialog.vue"),
+);
+
+const CloseTicketDialog = defineAsyncComponent(
+  () => import("src/pages/support/components/CloseTicketDialog.vue"),
+);
+
+const $q = useQuasar();
+const tableRef = useTemplateRef("tableRef");
+
+const openAddEditTicketDialog = (ticket = null) => {
+  $q.dialog({
+    component: AddEditTicketDialog,
+    componentProps: { ticket },
+  }).onOk(() => {
+    tableRef.value?.refresh();
+  });
+};
+
+const columns = [
+  { name: "id", label: "Ticket", field: "id", align: "left", style: "width: 5%" },
+  { name: "severity", label: "Prioridade", field: "severity", align: "left", style: "width: 10%" },
+  { name: "created_at", label: "Data", field: "created_at", align: "left", style: "width: 12%" },
+  { name: "sector", label: "Setor", field: "sector", align: "left", style: "width: 18%" },
+  { name: "title", label: "Título", field: "title", align: "left", style: "width: 35%" },
+  { name: "status", label: "Status", field: "status", align: "left", style: "width: 10%" },
+  { name: "actions", label: "Ações", field: "actions", align: "center", style: "width: 10%" },
+];
+
+// Franchisee só pode gerenciar tickets internos que ela mesma criou
+const canManage = (row) => row.origin === "unit" && row.scope === "internal";
+
+const severityLabel = (severity) => {
+  const map = { alta: "Alta", normal: "Normal", baixa: "Baixa" };
+  return map[severity] ?? severity;
+};
+
+const severityColor = (severity) => {
+  const map = { alta: "negative", normal: "warning", baixa: "positive" };
+  return map[severity] ?? "grey";
+};
+
+const statusLabel = (status) => {
+  const map = {
+    in_progress: "Em andamento",
+    resolved: "Resolvido",
+    unresolved: "Não resolvido",
+  };
+  return map[status] ?? status;
+};
+
+const statusColor = (status) => {
+  const map = {
+    in_progress: "warning",
+    resolved: "positive",
+    unresolved: "negative",
+  };
+  return map[status] ?? "grey";
+};
+
+const handleEdit = (row) => {
+  openAddEditTicketDialog(row);
+};
+
+const handleClose = (row) => {
+  $q.dialog({
+    component: CloseTicketDialog,
+    componentProps: { ticket: row },
+  }).onOk(() => {
+    tableRef.value?.refresh();
+  });
+};
+
+const handleDelete = (row) => {
+  $q.dialog({
+    title: "Confirmar exclusão",
+    message: "Tem certeza que deseja excluir este ticket?",
+    ok: { color: "negative", label: "Excluir" },
+    cancel: { color: "primary", outline: true, label: "Cancelar" },
+  }).onOk(async () => {
+    await deleteSupportTicket(row.id);
+    tableRef.value?.refresh();
+  });
+};
+</script>

+ 80 - 0
src/pages/support/components/AddEditReplyDialog.vue

@@ -0,0 +1,80 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 600px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader
+          :title="replyItem ? 'Editar Comentário' : 'Responder Suporte'"
+          @close="onDialogCancel"
+        />
+
+        <q-form ref="formRef" @submit="onOKClick">
+          <q-card-section class="q-pt-sm">
+            <DefaultInput
+              v-model="replyText"
+              label="Descreva a resposta"
+              type="textarea"
+              class="col-12"
+            />
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md">
+            <q-btn
+              outline
+              color="primary"
+              label="Cancelar"
+              @click="onDialogCancel"
+            />
+            <q-btn
+              color="primary"
+              :label="replyItem ? 'Salvar' : 'Responder'"
+              type="submit"
+              :loading="loading"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import { createSupportReply, updateSupportReply } from "src/api/support_reply";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { ticketId, replyItem } = defineProps({
+  ticketId: {
+    type: Number,
+    required: true,
+  },
+  replyItem: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const loading = ref(false);
+const replyText = ref(replyItem?.reply ?? null);
+
+const onOKClick = async () => {
+  loading.value = true;
+  try {
+    if (replyItem) {
+      await updateSupportReply(ticketId, replyItem.id, { reply: replyText.value });
+    } else {
+      await createSupportReply(ticketId, { reply: replyText.value });
+    }
+    onDialogOK(true);
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 291 - 0
src/pages/support/components/AddEditTicketDialog.vue

@@ -0,0 +1,291 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 1100px">
+      <q-card style="height: 500px; display: flex; flex-direction: column; overflow: hidden">
+        <DefaultDialogHeader
+          :title="ticket ? 'Editar Ticket' : 'Novo Ticket'"
+          @close="onDialogCancel"
+        />
+
+        <q-form
+          ref="formRef"
+          style="
+            flex: 1;
+            display: flex;
+            flex-direction: column;
+            overflow: hidden;
+          "
+          @submit="onOKClick"
+        >
+          <q-card-section class="q-pt-sm" style="flex: 1; overflow-y: auto; min-height: 0">
+            <CustomTabComponent
+              v-if="ticket?.id"
+              v-model:active-tab="currentTab"
+              :tabs="tabs"
+              class="q-mb-md"
+            />
+
+            <!-- Tab: Ticket -->
+            <div v-show="currentTab === 'ticket'">
+              <div class="row q-col-gutter-sm">
+                <DefaultInput
+                  v-model="form.title"
+                  label="Título da Tarefa"
+                  class="col-12"
+                />
+
+                <DefaultSelect
+                  v-model="form.severity"
+                  label="Prioridade"
+                  :options="priorityOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                />
+
+                <DefaultInput
+                  :model-value="user?.name"
+                  label="Responsável"
+                  disable
+                  class="col-6"
+                />
+
+                <DefaultSelect
+                  v-model="form.scope"
+                  label="Destino"
+                  :options="unitTargetOptions"
+                  emit-value
+                  map-options
+                  class="col-6"
+                />
+
+                <DefaultInput
+                  v-model="form.sector"
+                  label="Setor"
+                  class="col-6"
+                />
+
+                <DefaultInput
+                  v-model="form.description"
+                  label="Descrição"
+                  type="textarea"
+                  class="col-12"
+                />
+              </div>
+            </div>
+
+            <!-- Tab: Comentários -->
+            <div v-show="currentTab === 'comentarios'">
+              <div
+                v-if="ticket?.status === 'in_progress'"
+                class="flex justify-end q-mb-sm"
+              >
+                <q-btn
+                  color="primary"
+                  icon="mdi-plus"
+                  unelevated
+                  style="width: 40px; height: 40px"
+                  @click="onAddComment"
+                />
+              </div>
+              <div
+                style="
+                  height: 100%;
+                  max-height: 340px;
+                  overflow-y: auto;
+                  display: flex;
+                  flex-direction: column;
+                  gap: 8px;
+                "
+              >
+                <template v-if="replies.length">
+                  <TicketCommentCard
+                    v-for="reply in replies"
+                    :key="reply.id"
+                    :reply="reply.reply"
+                    :created-at="reply.created_at"
+                    :user-name="reply.user_name"
+                    @edit="onEditComment(reply)"
+                    @delete="onDeleteComment(reply)"
+                  />
+                </template>
+                <div
+                  v-else
+                  class="flex flex-center full-height text-grey-5 text-body2"
+                >
+                  Nenhum comentário registrado.
+                </div>
+              </div>
+            </div>
+          </q-card-section>
+
+          <q-card-actions align="right" class="q-px-md q-pb-md" style="flex-shrink: 0">
+            <q-btn
+              outline
+              color="primary"
+              label="Cancelar"
+              @click="onDialogCancel"
+            />
+            <q-btn
+              v-if="canManage && ticket?.id && ticket?.status === 'in_progress'"
+              outline
+              color="negative"
+              label="Encerrar"
+              @click="onCloseTicket"
+            />
+            <q-btn
+              v-if="canManage && (!ticket?.id || ticket?.status === 'in_progress')"
+              color="primary"
+              label="Salvar"
+              type="submit"
+              :loading="loading"
+            />
+          </q-card-actions>
+        </q-form>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref, computed, onMounted } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import CustomTabComponent from "src/components/shared/CustomTabComponent.vue";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultInput from "src/components/defaults/DefaultInput.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import TicketCommentCard from "./TicketCommentCard.vue";
+import { userStore } from "src/stores/user";
+import { useQuasar } from "quasar";
+import {
+  createSupportTicket,
+  updateSupportTicket,
+} from "src/api/support_ticket";
+import CloseTicketDialog from "./CloseTicketDialog.vue";
+import AddEditReplyDialog from "./AddEditReplyDialog.vue";
+import { getSupportReplies, deleteSupportReply } from "src/api/support_reply";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { ticket } = defineProps({
+  ticket: {
+    type: Object,
+    default: null,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const $q = useQuasar();
+const { user } = userStore();
+
+const formRef = ref(null);
+const loading = ref(false);
+const currentTab = ref("ticket");
+
+// Novo ticket (sem id) → pode salvar. Ticket existente → só se for interno próprio.
+const canManage = computed(() =>
+  !ticket?.id || (ticket?.origin === "unit" && ticket?.scope === "internal")
+);
+
+const tabs = computed(() => {
+  const base = [{ name: "ticket", label: "Ticket" }];
+  if (ticket?.id) base.push({ name: "comentarios", label: "Comentários" });
+  return base;
+});
+
+const replies = ref([]);
+
+const loadReplies = async () => {
+  if (!ticket?.id) return;
+  replies.value = await getSupportReplies(ticket.id);
+};
+
+const priorityOptions = [
+  { label: "Alta", value: "alta" },
+  { label: "Normal", value: "normal" },
+  { label: "Baixa", value: "baixa" },
+];
+
+const unitTargetOptions = [
+  { label: "Suporte à Matriz", value: "specific" },
+  { label: "Suporte Interno", value: "internal" },
+];
+
+const form = ref({
+  title: ticket?.title ?? null,
+  severity: ticket?.severity ?? null,
+  scope: ticket?.scope ?? null,
+  sector: ticket?.sector ?? null,
+  description: ticket?.description ?? null,
+});
+
+const buildPayload = () => ({
+  title: form.value.title,
+  severity: form.value.severity,
+  scope: form.value.scope,
+  sector: form.value.sector || null,
+  description: form.value.description || null,
+  target_unit_id: null, // Backend resolve baseado em scope + user.unit_id
+});
+
+const onAddComment = () => {
+  $q.dialog({
+    component: AddEditReplyDialog,
+    componentProps: { ticketId: ticket.id },
+  }).onOk(() => {
+    loadReplies();
+  });
+};
+
+const onEditComment = (reply) => {
+  $q.dialog({
+    component: AddEditReplyDialog,
+    componentProps: { ticketId: ticket.id, replyItem: reply },
+  }).onOk(() => {
+    loadReplies();
+  });
+};
+
+const onDeleteComment = (reply) => {
+  $q.dialog({
+    title: "Excluir Comentário",
+    message: "Tem certeza que deseja excluir este comentário?",
+    cancel: { outline: true, color: "primary", label: "Cancelar" },
+    ok: { color: "negative", label: "Excluir" },
+  }).onOk(async () => {
+    await deleteSupportReply(ticket.id, reply.id);
+    loadReplies();
+  });
+};
+
+onMounted(() => {
+  loadReplies();
+});
+
+const onCloseTicket = () => {
+  $q.dialog({
+    component: CloseTicketDialog,
+    componentProps: { ticket },
+  }).onOk(() => {
+    onDialogOK(true);
+  });
+};
+
+const onOKClick = async () => {
+  loading.value = true;
+  try {
+    const payload = buildPayload();
+    if (ticket?.id) {
+      await updateSupportTicket(ticket.id, payload);
+    } else {
+      await createSupportTicket(payload);
+    }
+    onDialogOK(true);
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 84 - 0
src/pages/support/components/CloseTicketDialog.vue

@@ -0,0 +1,84 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <div style="width: 100%; max-width: 500px">
+      <q-card class="overflow-hidden" style="width: 100%">
+        <DefaultDialogHeader title="Encerrar Ticket" @close="onDialogCancel" />
+
+        <q-card-section class="q-pt-sm column q-gutter-y-md">
+          <DefaultSelect
+            v-model="resolved"
+            label="A solicitação foi resolvida?"
+            :options="resolvedOptions"
+            emit-value
+            map-options
+          />
+
+          <p v-if="resolved !== null" class="text-body2 q-mb-none">
+            <template v-if="resolved">
+              Sua solicitação foi resolvida com sucesso, hora de finalizar este suporte.
+            </template>
+            <template v-else>
+              Sua solicitação não foi resolvida, finalize o suporte.
+            </template>
+          </p>
+        </q-card-section>
+
+        <q-card-actions align="right" class="q-px-md q-pb-md">
+          <q-btn
+            outline
+            color="primary"
+            label="Cancelar"
+            @click="onDialogCancel"
+          />
+          <q-btn
+            v-if="resolved !== null && ticket.status === 'in_progress'"
+            color="primary"
+            label="Encerrar"
+            :loading="loading"
+            @click="onOKClick"
+          />
+        </q-card-actions>
+      </q-card>
+    </div>
+  </q-dialog>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { useDialogPluginComponent } from "quasar";
+
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+import DefaultSelect from "src/components/defaults/DefaultSelect.vue";
+import { updateSupportTicket } from "src/api/support_ticket";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { ticket } = defineProps({
+  ticket: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } =
+  useDialogPluginComponent();
+
+const loading = ref(false);
+const resolved = ref(null);
+
+const resolvedOptions = [
+  { label: "Sim", value: true },
+  { label: "Não", value: false },
+];
+
+const onOKClick = async () => {
+  loading.value = true;
+  try {
+    const status = resolved.value ? "resolved" : "unresolved";
+    await updateSupportTicket(ticket.id, { status });
+    onDialogOK(true);
+  } finally {
+    loading.value = false;
+  }
+};
+</script>

+ 53 - 0
src/pages/support/components/TicketCommentCard.vue

@@ -0,0 +1,53 @@
+<template>
+  <q-card flat bordered class="q-pa-md">
+    <div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 8px">
+      <div style="flex: 1">
+        <div class="text-body2">
+          <span class="text-weight-medium">Resposta:</span> {{ reply }}
+        </div>
+        <div class="text-caption text-grey-6 q-mt-xs">
+          {{ createdAt }} &mdash; {{ userName }}
+        </div>
+      </div>
+      <div style="display: flex; gap: 4px; flex-shrink: 0">
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-pencil-outline"
+          size="sm"
+          color="primary"
+          @click="$emit('edit')"
+        />
+        <q-btn
+          flat
+          dense
+          round
+          icon="mdi-trash-can-outline"
+          size="sm"
+          color="negative"
+          @click="$emit('delete')"
+        />
+      </div>
+    </div>
+  </q-card>
+</template>
+
+<script setup>
+defineEmits(["edit", "delete"]);
+
+defineProps({
+  reply: {
+    type: String,
+    required: true,
+  },
+  createdAt: {
+    type: String,
+    required: true,
+  },
+  userName: {
+    type: String,
+    required: true,
+  },
+});
+</script>

+ 14 - 0
src/router/routes/support.route.js

@@ -0,0 +1,14 @@
+export default [
+  {
+    path: "/support",
+    name: "SupportPage",
+    component: () => import("pages/support/SupportPage.vue"),
+    meta: {
+      title: { value: "Suporte", translate: false },
+      requireAuth: true,
+      breadcrumbs: [
+        { name: "SupportPage", title: "Suporte" },
+      ],
+    },
+  },
+];

+ 8 - 0
src/stores/navigation.js

@@ -52,6 +52,14 @@ export const navigationStore = defineStore("navigation", () => {
       disable: false,
       disable: false,
       permission: true,
       permission: true,
     },
     },
+    {
+      type: "single",
+      title: "Suporte",
+      name: "SupportPage",
+      icon: "mdi-lifebuoy",
+      disable: false,
+      permission: true,
+    },
   ]);
   ]);
 
 
   const getNavigationAccess = () => {
   const getNavigationAccess = () => {