IKHSAN
ENID Baca dalam Bahasa Indonesia

opendesk

Public
Branch
main
Latest commit
08d9f3328 October 2026

PendingTickets.vue

Open raw
<template>
  <div class="flex flex-col rounded-5 p-4 grow w-full h-full overflow-hidden">
    <div class="flex gap-4 items-center justify-between">
      <div class="flex items-center gap-2 text-md-semibold text-ink-gray-8">
        {{ currentTitle }}
        <Tooltip :text="tooltipText" side="top">
          <LucideInfo class="size-3" />
        </Tooltip>
      </div>
      <div class="w-max">
        <TabButtons :options="chartTabs" v-model="currentTab" />
      </div>
    </div>
    <div class="flex flex-col mt-5 grow overflow-auto hide-scrollbar">
      <table
        class="w-full table-auto transition-opacity duration-150"
        :class="{
          'opacity-60': getPendingTicketsResource.loading && !showSkeleton,
        }"
      >
        <thead v-if="!showSkeleton && chartConfig?.tickets?.length > 0">
          <tr class="text-sm text-ink-gray-5">
            <th class="p-2 text-start font-normal whitespace-nowrap">
              {{ __("ID") }}
            </th>
            <th class="p-2 text-start font-normal w-full">
              {{ __("Subject") }}
            </th>
            <th class="p-2 text-start font-normal min-w-16 whitespace-nowrap">
              {{ __("Status") }}
            </th>
            <th class="p-2 text-start font-normal min-w-28 whitespace-nowrap">
              {{ __("Priority") }}
            </th>
            <th class="p-2 text-start font-normal min-w-44 whitespace-nowrap">
              {{ __("Team") }}
            </th>
            <th class="p-2 text-start font-normal min-w-40 whitespace-nowrap">
              {{ __("Reason") }}
            </th>
          </tr>
        </thead>
        <tbody
          v-if="!showSkeleton && chartConfig?.tickets?.length > 0"
          class="grow"
        >
          <tr
            v-for="ticket in chartConfig?.tickets"
            :key="ticket.name"
            @click="goToTicket(ticket)"
            class="text-sm cursor-pointer hover:bg-surface-sidebar border-t border-outline-elevation-2"
          >
            <td class="p-2 py-3 whitespace-nowrap">{{ ticket.name }}</td>
            <td class="p-2 py-3 w-full max-w-0 truncate">
              {{ ticket.subject }}
            </td>
            <td class="p-2 py-3 min-w-16 truncate">
              {{ ticket.status }}
            </td>
            <td class="p-2 py-3 min-w-28 truncate">
              <TicketPriority :priority="ticket.priority" />
            </td>
            <td class="p-2 py-3 min-w-44 truncate">
              {{ ticket.agent_group || __("Not Assigned") }}
            </td>
            <td class="p-2 py-3 min-w-40">
              <div
                v-if="ticket.reason"
                class="flex items-center gap-1 text-ink-gray-7 truncate w-full"
                :class="getReasonColorClass(ticket.reason)"
              >
                <TimerIcon
                  v-if="ticket.reason.type === 'upcoming_sla'"
                  class="size-4 flex-shrink-0"
                />
                <TicketPlusIcon
                  v-else-if="ticket.reason.type === 'new_tickets'"
                  class="size-4 flex-shrink-0"
                />
                <CalendarIcon
                  v-else-if="ticket.reason.type === 'pending'"
                  class="size-4 flex-shrink-0"
                />
                <span class="truncate pe-2">{{ ticket.reason.text }}</span>
              </div>
              <span
                v-else
                class="text-ink-gray-4 truncate inline-block w-full align-bottom"
                >{{ __("No reason") }}</span
              >
            </td>
          </tr>
        </tbody>
        <tbody class="relative" v-else>
          <tr
            v-for="i in 8"
            :key="i"
            :class="i > 1 ? 'border-t border-outline-elevation-2' : ''"
          >
            <td class="p-2 py-3 min-w-8">
              <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
            </td>
            <td class="p-2 py-3 w-full max-w-0">
              <div class="h-4 w-full rounded-1 bg-surface-gray-1 max-w-full" />
            </td>
            <td class="p-2 py-3 min-w-14">
              <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
            </td>
            <td class="p-2 py-3 min-w-21">
              <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
            </td>
            <td class="p-2 py-3 min-w-28">
              <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
            </td>
            <td class="p-2 py-3 min-w-40">
              <div class="h-4 w-full rounded-1 bg-surface-gray-1" />
            </td>
          </tr>
          <EmptyState
            class="absolute inset-0 z-10"
            v-if="
              !getPendingTicketsResource.loading &&
              chartConfig?.tickets?.length === 0
            "
            :title="emptyState.title"
            :description="emptyState.description"
            variant="overlay"
            text="md"
          />
        </tbody>
      </table>
      <div
        class="flex justify-between items-center text-sm mt-auto text-ink-gray-5 ps-2 pb-2"
      >
        <div>
          <div
            v-if="chartConfig?.totalPendingTickets > 6"
            class="flex items-center gap-1 cursor-pointer hover:text-ink-gray-7 w-max select-none mt-3"
            @click="redirectToSeeAllTickets"
          >
            {{
              __("See all {0} tickets", chartConfig?.totalPendingTickets + "")
            }}
            <LucideArrowRight class="size-4 rtl:rotate-180" />
          </div>
        </div>
        <div v-if="chartConfig?.tickets?.length > 0" class="mt-3 mb-0.5">
          {{
            __(
              "Showing {0} of {1} {2}",
              chartConfig?.tickets?.length + "",
              chartConfig?.totalPendingTickets + "",
              chartConfig?.tickets?.length > 1 ? __("tickets") : __("ticket")
            )
          }}
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import LucideArrowRight from "~icons/lucide/arrow-right";
import LucideInfo from "~icons/lucide/info";
import EmptyState from "@/components/EmptyState.vue";
import TicketPriority from "@/components/TicketPriority.vue";
import { useView } from "@/composables/useView";
import { __ } from "@/translation";
import { View } from "@/types";
import { createResource, TabButtons } from "frappe-ui";
import { computed, onMounted, ref, watch, type PropType } from "vue";
import { useRouter } from "vue-router";
import CalendarIcon from "~icons/lucide/calendar";
import TicketPlusIcon from "~icons/lucide/ticket-plus";
import TimerIcon from "~icons/lucide/timer";

interface TicketReason {
  type: string;
  text: string;
  seconds_until_due?: number;
}

interface PendingTicket {
  name: string;
  subject: string;
  status: string;
  priority: string;
  agent_group?: string;
  agreement_status?: string;
  creation: string;
  resolution_by?: string;
  response_by?: string;
  reason?: TicketReason;
}

interface PendingTicketsData {
  tickets: PendingTicket[];
  total_pending_tickets: number;
}

const props = defineProps({
  data: {
    type: Object as PropType<PendingTicketsData>,
    required: true,
  },
});

const router = useRouter();
const { views } = useView("OD Ticket");
const currentTab = ref("upcoming_sla");
const chartTabs = [
  {
    label: __("SLA"),
    value: "upcoming_sla",
  },
  {
    label: __("Pending"),
    value: "pending",
  },
  {
    label: __("Recent"),
    value: "new_tickets",
  },
];

const titles: Record<string, string> = {
  upcoming_sla: __("SLA Alerts"),
  new_tickets: __("Recent Tickets"),
  pending: __("Pending Tickets"),
};

const currentTitle = computed(() => {
  return titles[currentTab.value] || __("Pending Tickets");
});

const tooltipTexts: Record<string, string> = {
  upcoming_sla: __("Tickets approaching or breached SLA"),
  new_tickets: __("Tickets assigned to you in the last 7 days"),
  pending: __("Tickets that are awaiting your response"),
};
const tooltipText = computed(() => {
  return tooltipTexts[currentTab.value];
});

const emptyStateLabel: Record<string, { title: string; description: string }> =
  {
    upcoming_sla: {
      title: __("All SLAs on track"),
      description: __("Great! All your tickets are within SLA"),
    },
    new_tickets: {
      title: __("No recently assigned tickets"),
      description: __("No new tickets assigned to you in the last 7 days"),
    },
    pending: {
      title: __("No pending tickets"),
      description: __("All your assigned tickets have been responded to"),
    },
  };

const emptyState = computed(() => {
  return emptyStateLabel[currentTab.value];
});

const chartConfig = computed(() => {
  const _data: PendingTicketsData = getPendingTicketsResource.fetched
    ? getPendingTicketsResource.data
    : props.data;
  const tickets = _data.tickets;
  const totalPendingTickets = _data.total_pending_tickets;

  return {
    tickets,
    totalPendingTickets,
  };
});

const hasLoadedOnce = ref(false);

const getPendingTicketsResource = createResource({
  url: "opendesk.api.agent_home.agent_home.get_pending_tickets",
  onSuccess() {
    hasLoadedOnce.value = true;
  },
});

// Show the skeleton only on the first load. On later tab switches we keep the
// previous rows visible (dimmed) until fresh data arrives, avoiding a flicker.
const showSkeleton = computed(
  () => getPendingTicketsResource.loading && !hasLoadedOnce.value
);

function getReasonColorClass(reason: {
  text: string;
  seconds_until_due?: number;
}) {
  if (reason.text.includes("overdue")) {
    return "text-ink-red-6";
  }

  if (
    reason.seconds_until_due !== undefined &&
    reason.seconds_until_due !== null
  ) {
    const oneHour = 3600;
    const twoHours = 7200;
    if (reason.seconds_until_due <= oneHour) {
      return "text-ink-red-6";
    }
    if (reason.seconds_until_due <= twoHours) {
      return "text-ink-orange-5";
    }
  }
  return "";
}

const goToTicket = (ticket: PendingTicket) => {
  router.push({
    name: "TicketAgent",
    params: { ticketId: String(ticket.name) },
  });
};

const redirectToSeeAllTickets = () => {
  const tabToViewMap: Record<string, string> = {
    pending: "STD-VIEW-PENDING-TICKETS",
    upcoming_sla: "STD-VIEW-SLA-ALERTS",
    new_tickets: "STD-VIEW-RECENTLY-ASSIGNED-TICKETS",
  };

  const viewName = tabToViewMap[currentTab.value];
  const view = views.data?.find((v: View) => v.name === viewName);

  const route = router.resolve({
    name: "TicketsAgent",
    query: {
      view: view?.name,
    },
  });
  window.open(route.href, "_blank");
};

onMounted(() => {
  if (!Array.isArray(props.data.tickets)) {
    getPendingTicketsResource.fetch({ ticket_type: currentTab.value });
  }
});

// Watch for tab changes and fetch tickets
watch(currentTab, (newTab) => {
  getPendingTicketsResource.fetch({ ticket_type: newTab });
});
</script>
bytes
10,921
lines
354
Type
vue
Latest commit
28 October 2026