IKHSAN
ENID Baca dalam Bahasa Indonesia

opendesk

Public
Branch
main
Latest commit
08d9f3328 October 2026

Dashboard.vue

Open raw
<template>
  <div class="flex flex-col">
    <LayoutHeader>
      <template #left-header>
        <div class="text-lg-medium text-ink-gray-9">
          {{ __(dashboardTitle) }}
        </div>
      </template>
      <template #right-header>
        <!-- Segmented pill toggle: only visible to managers -->
        <div v-if="isManager">
          <TabButtons v-model="activeTab" :options="tabButtons" />
        </div>
      </template>
    </LayoutHeader>

    <div class="px-4 py-4 w-full overflow-y-scroll">
      <!-- Filters -->
      <div class="mb-4 flex items-center gap-4 overflow-x-auto px-1 py-1">
        <Dropdown v-if="!showDatePicker" :options="options">
          <template #default>
            <div
              class="flex justify-between !min-w-48 items-center border border-outline-gray-2 rounded-4 text-ink-gray-8 px-2 py-1.5 hover:border-outline-gray-3 hover:shadow-sm focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-0 transition-colors h-7 cursor-pointer"
            >
              <div class="flex items-center">
                <LucideCalendar class="size-4 text-ink-gray-5 me-2" />
                <span class="text-base whitespace-nowrap">{{ preset }}</span>
              </div>
              <LucideChevronDown class="size-4 text-ink-gray-5" />
            </div>
          </template>
        </Dropdown>
        <DateRangePicker
          v-else
          class="!w-48"
          ref="datePickerRef"
          v-model="periodRange"
          variant="outline"
          :placeholder="__('Period')"
          :format="'MMM D'"
          @update:open="onPickerToggle"
        >
          <template #prefix>
            <!-- ms-px: TextInput anchors the prefix to the wrapper, which sits
                 outside the input's 1px border, so it lands 1px left of the
                 preset trigger's icon. -->
            <LucideCalendar class="size-4 text-ink-gray-5 ms-px me-2" />
          </template>
        </DateRangePicker>
        <Link
          v-if="isManager && !viewMyStats"
          class="w-48"
          doctype="OD Team"
          variant="outline"
          :placeholder="__('Team')"
          v-model="filters.team"
        >
          <template #prefix>
            <LucideUsers class="size-4 text-ink-gray-5 me-1" />
          </template>
          <!-- One line per row: the filter only needs the name. -->
          <template #item-label="{ item }">
            <div class="truncate">{{ item.label }}</div>
          </template>
        </Link>
        <Link
          v-if="isManager && !viewMyStats"
          class="w-48"
          doctype="OD Agent"
          variant="outline"
          :placeholder="__('Agent')"
          v-model="filters.agent"
          :filters="agentFilter"
        >
          <template #prefix>
            <LucideUser class="size-4 text-ink-gray-5 me-1" />
          </template>
          <!-- One line per row: the filter only needs the name. -->
          <template #item-label="{ item }">
            <div class="truncate">{{ item.label }}</div>
          </template>
          <!-- An empty list means an empty team, not a failed search. -->
          <template #empty>
            {{
              filters.team && !teamMembers.data?.length
                ? __("No agents in this team")
                : __("No results")
            }}
          </template>
        </Link>
      </div>
      <!-- Charts -->

      <!-- Number Cards -->
      <div
        class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mb-4 px-1"
        v-if="!numberCards.loading"
      >
        <Tooltip
          v-for="({ tooltip, ...card }, index) in numberCards.data"
          :key="index"
          :text="tooltip"
        >
          <NumberCard v-bind="card" />
        </Tooltip>
      </div>
      <div
        v-if="!loading && !isEmpty"
        class="transition-all animate-fade-in duration-300"
      >
        <!-- Trend Charts -->
        <div
          class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 px-1"
          v-if="!trendData.loading"
        >
          <template v-for="(chart, index) in trendData.data" :key="index">
            <!-- has data -->
            <ChartCard v-if="!isChartEmpty(chart)" class="h-80">
              <component :is="getChartType(chart)" />
            </ChartCard>

            <!-- chart with no data -->
            <SkeletonLoader
              v-else
              :variants="['bar-chart', 'empty-state']"
              :bar-chart-count="1"
              :has-applied-filter="hasAppliedFilter"
              :empty-states="chartEmptyState(chart)"
            />
          </template>
        </div>
        <!-- Master Data Charts -->
        <div
          class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-4 px-1"
          v-if="!masterData.loading"
        >
          <template v-for="(chart, index) in masterData.data" :key="index">
            <!-- has data -->
            <ChartCard v-if="!isChartEmpty(chart)" class="h-80">
              <component :is="getChartType(chart)" />
            </ChartCard>

            <!-- chart with no data -->
            <SkeletonLoader
              v-else
              :variants="['bar-chart', 'empty-state']"
              :bar-chart-count="1"
              :has-applied-filter="hasAppliedFilter"
              :empty-states="chartEmptyState(chart)"
            />
          </template>
        </div>

        <!-- Tag Charts: org level insight, an agent cannot act on their own tag mix -->
        <div
          class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-4 px-1"
          v-if="isManager && !tagData.loading"
        >
          <template v-for="(chart, index) in tagData.data" :key="index">
            <!-- has data -->
            <ChartCard v-if="!isChartEmpty(chart)" class="h-80">
              <component :is="getChartType(chart)" />
            </ChartCard>

            <!-- chart with no data -->
            <SkeletonLoader
              v-else
              :variants="['bar-chart', 'empty-state']"
              :bar-chart-count="1"
              :has-applied-filter="hasAppliedFilter"
              :empty-states="chartEmptyState(chart)"
            />
          </template>
        </div>
      </div>

      <!-- Skeleton Loading State -->
      <div class="flex flex-col gap-4">
        <SkeletonLoader
          v-if="numberCards.loading"
          :variants="['number-cards']"
          :number-cards-count="5"
          :loading="true"
        />
        <SkeletonLoader
          v-if="trendData.loading"
          :variants="['bar-chart']"
          :bar-chart-count="4"
          :loading="true"
        />
      </div>

      <!-- complete empty state -->
      <div
        v-if="isEmpty"
        class="transition-all animate-fade-in duration-300 relative"
      >
        <div>
          <SkeletonLoader
            :variants="['bar-chart', 'empty-state']"
            :bar-chart-count="emptyStates.length"
            :empty-states="emptyStates"
            :has-applied-filter="hasAppliedFilter"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useScreenSize } from "@/composables/screen";
import { useAuthStore } from "@/stores/auth";
import { __ } from "@/translation";
import { Link } from "@framework/ui";
import { useStorage } from "@vueuse/core";
import {
  DateRangePicker,
  Dropdown,
  TabButtons,
  Tooltip,
  createResource,
  dayjs,
  usePageMeta,
} from "frappe-ui";
import { BarChart, ChartCard, DonutChart, NumberCard } from "frappe-ui/charts";
import { computed, h, onMounted, reactive, ref, watch } from "vue";
import LucideBuilding2 from "~icons/lucide/building-2";
import LucideUser from "~icons/lucide/user";
const { isMobileView } = useScreenSize();

interface NumberCardData {
  title: string;
  value: number | null;
  delta: number | null;
  deltaSuffix: string;
  suffix?: string;
  negativeIsBetter?: boolean;
  tooltip: string;
}

type Filters = {
  period: string;
  agent: null | string;
  team: null | string;
};

const filters = reactive<Filters>({
  period: getLastXDays(),
  agent: null,
  team: null,
});

interface ChartData {
  data: ChartValues[];
  title: string;
  type: "bar" | "donut";
}

interface ChartValues {
  date: Date;
  Open: number;
  Closed: number;
  "SLA Fulfilled": number;
}

const dashboardTitle = computed(() => {
  if (!isManager) return __("Agent Dashboard");
  return viewMyStats.value ? __("My Dashboard") : __("Organization Dashboard");
});

interface ChartEmptyState {
  // header of the card, shown untranslated only when the chart itself is missing
  chartTitle: string;
  chartSubtitle?: string;
  title: string;
  message: string;
}

// Chart key (stable, untranslated identifier from the dashboard APIs) → empty state copy.
const emptyStateByChart: Record<string, ChartEmptyState> = {
  ticket_trend: {
    chartTitle: "Ticket Trend",
    title: "No ticket activity",
    message: "Ticket trends will appear here once tickets are created.",
  },
  feedback_trend: {
    chartTitle: "Feedback Trend",
    title: "No feedback data",
    message: "Feedback insights will appear once responses are collected.",
  },
  tickets_by_team: {
    chartTitle: "Tickets by Team",
    title: "No team data",
    message: "Tickets will be grouped by team once available.",
  },
  tickets_by_type: {
    chartTitle: "Tickets by Type",
    title: "No ticket type data",
    message: "Tickets will be categorized by type once created.",
  },
  tickets_by_priority: {
    chartTitle: "Tickets by Priority",
    title: "No priority data",
    message: "Ticket priorities will be reflected here once assigned.",
  },
  tickets_by_channel: {
    chartTitle: "Tickets by Channel",
    title: "No channel data",
    message: "Tickets will be grouped by channel once received.",
  },
  top_tags: {
    chartTitle: "Top Tags",
    title: "No tags used yet",
    message: "The most used tags will be ranked here once tickets are tagged.",
  },
  tag_trend: {
    chartTitle: "Tag Trend",
    title: "No tag activity",
    message: "Daily tag volume will appear here once tickets are tagged.",
  },
};

const managerOnlyCharts = ["top_tags", "tag_trend"];

// whole dashboard empty: no chart payload to read titles from, so fall back to ours
const emptyStates = computed(() =>
  Object.entries(emptyStateByChart)
    .filter(([key]) => isManager || !managerOnlyCharts.includes(key))
    .map(([, state]) => state)
);

function chartEmptyState(chart: any) {
  const state = emptyStateByChart[chart?.key] ?? {
    title: `No ${String(chart?.title).toLowerCase()} available`,
  };
  return [
    { ...state, chartTitle: chart?.title, chartSubtitle: chart?.subtitle },
  ];
}

const tabButtons = computed(() => {
  if (isMobileView.value) {
    return [
      {
        value: "organization",
        label: __("My Organization"),
        icon: h(LucideBuilding2, { class: "size-4" }),
      },
      {
        value: "my_stats",
        label: __("My Stats"),
        icon: h(LucideUser, { class: "size-4" }),
      },
    ];
  }
  return [
    {
      value: "organization",
      iconLeft: h(LucideBuilding2, { class: "size-4" }),
      label: __("My Organization"),
    },
    {
      value: "my_stats",
      iconLeft: h(LucideUser, { class: "size-4" }),
      label: __("My Stats"),
    },
  ];
});

const hasAppliedFilter = computed(() =>
  Boolean(
    filters.agent ||
      filters.team ||
      (filters.period && filters.period !== getLastXDays(30))
  )
);

const isEmpty = computed(() => {
  if (!numberCards.data || !trendData.data || !masterData.data) return false;
  return (
    (numberCards.data as NumberCardData[]).every((d) => !d.value) &&
    (trendData.data as ChartData[]).every((d) => !d.data?.length) &&
    (masterData.data as ChartData[]).every((d) => !d.data?.length)
  );
});

const parseFilters = (filters: Filters) => {
  return {
    from_date: filters.period?.split(",")[0] ?? null,
    to_date: filters.period?.split(",")[1] ?? null,
    team: filters.team,
    agent: filters.agent,
  };
};

// No `cache` key here: a static cache key makes frappe-ui return the same
// resource instance across navigations, still bound to the previous mount's
// `filters` closure, so reloads after navigating back send stale params.
const numberCards = createResource({
  url: "opendesk.api.dashboard.get_dashboard_data",
  makeParams: () => ({
    dashboard_type: "number_card",
    filters: parseFilters(filters),
  }),
});

const masterData = createResource({
  url: "opendesk.api.dashboard.get_dashboard_data",
  makeParams: () => ({
    dashboard_type: "master",
    filters: parseFilters(filters),
  }),
});

const trendData = createResource({
  url: "opendesk.api.dashboard.get_dashboard_data",
  makeParams: () => ({
    dashboard_type: "trend",
    filters: parseFilters(filters),
  }),
});

const tagData = createResource({
  url: "opendesk.api.dashboard.get_dashboard_data",
  makeParams: () => ({
    dashboard_type: "tags",
    filters: parseFilters(filters),
  }),
});

const agentFilter = ref(null);
const teamMembers = createResource({
  url: "opendesk.opendesk.doctype.od_team.od_team.get_team_members",
  cache: ["Analytics", "TeamMembers"],
  params: {
    team: filters.team,
  },
  onSuccess: (data) => {
    agentFilter.value = { name: ["in", data] };
  },
});

const { isManager, userId } = useAuthStore();

const viewMyStats = ref(false);
const activeTab = useStorage("dashboard_active_tab", "organization");
function validateView(myStats: boolean) {
  viewMyStats.value = myStats;
  if (myStats) {
    filters.team = null;
    filters.agent = userId;
  } else {
    filters.agent = null;
  }
}

watch(activeTab, (val) => {
  validateView(val === "my_stats");
});

watch(
  () => filters.team,
  (newVal) => {
    filters.agent = null; // Reset agent when team is selected
    if (newVal) {
      teamMembers.update({
        params: {
          team: newVal,
        },
      });
      teamMembers.reload();
    }
    if (!newVal) {
      agentFilter.value = null; // Reset agent filter if no team is selected
    }
  }
);

//check empty for individual charts
function isChartEmpty(chart: any) {
  if (!chart.data?.length) return true;
  return chart.data.every((row: any) =>
    Object.entries(row)
      .filter(([key]) => key !== "date")
      .every(([, val]) => val === null || val === 0)
  );
}

const loading = computed(() => {
  return numberCards.loading || masterData.loading || trendData.loading;
});

// api already sends the frappe-ui props for each chart
function getChartType({ type, key, ...props }: any) {
  return h(type === "donut" ? DonutChart : BarChart, props);
}

// include today, so last 7 days is today and the 6 days before. The site's
// today, not the browser's: tickets are dated in the site's timezone.
function getLastXDays(range: number = 30): string {
  const today = dayjs().tz(window.timezone?.system);
  const from = today.subtract(range - 1, "day");
  return `${from.format("YYYY-MM-DD")},${today.format("YYYY-MM-DD")}`;
}

const showDatePicker = ref(false);
const datePickerRef = ref(null);
const preset = ref(__("Last 30 Days"));

// frappe-ui v1 DateRangePicker models a [from, to] tuple; filters.period and
// the API keep the legacy "from,to" string, so convert at the picker boundary.
const periodRange = computed({
  get: (): string[] => (filters.period ? filters.period.split(",") : []),
  set: (range: string[]) => {
    showDatePicker.value = false;
    filters.period = range?.length ? range.join(",") : "";
    preset.value = formatter(filters.period);
  },
});

// dismissing the calendar without a range would otherwise leave an empty
// field where the preset menu used to be, with no way back to it
function onPickerToggle(open: boolean) {
  if (!open && !filters.period) {
    showDatePicker.value = false;
    preset.value = formatter("");
  }
}

const options = computed(() => [
  {
    group: __("Presets"),
    hideLabel: true,
    options: [
      {
        label: __("Today"),
        onClick: () => {
          preset.value = __("Today");
          filters.period = getLastXDays(1);
        },
      },
      {
        label: __("Last 7 Days"),
        onClick: () => {
          preset.value = __("Last 7 Days");
          filters.period = getLastXDays(7);
        },
      },
      {
        label: __("Last 30 Days"),
        onClick: () => {
          preset.value = __("Last 30 Days");
          filters.period = getLastXDays(30);
        },
      },
      {
        label: __("Last 60 Days"),
        onClick: () => {
          preset.value = __("Last 60 Days");
          filters.period = getLastXDays(60);
        },
      },
      {
        label: __("Last 90 Days"),
        onClick: () => {
          preset.value = __("Last 90 Days");
          filters.period = getLastXDays(90);
        },
      },
    ],
  },
  {
    label: __("Custom Range"),
    onClick: () => {
      showDatePicker.value = true;
      setTimeout(() => {
        datePickerRef.value?.open();
      }, 0);
      preset.value = __("Custom Range");
      filters.period = null; // Reset period to allow custom date selection
    },
  },
]);

function formatter(range: string) {
  if (!range) {
    filters.period = getLastXDays();
    preset.value = __("Last 30 Days");
    return preset.value;
  }
  let [from, to] = range.split(",");
  return `${formatRange(from)} to ${formatRange(to)}`;
}

function formatRange(date: string) {
  const dateObj = new Date(date);
  return dateObj.toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year:
      dateObj.getFullYear() === new Date().getFullYear()
        ? undefined
        : "numeric",
  });
}

watch(
  () => filters,
  () => {
    if (showDatePicker.value && !filters.period) return;
    numberCards.reload();
    masterData.reload();
    trendData.reload();
    if (isManager) tagData.reload();
  },
  { deep: true }
);

onMounted(() => {
  if (!isManager) {
    filters.agent = userId;
  } else {
    validateView(activeTab.value === "my_stats");
  }
  numberCards.reload();
  masterData.reload();
  trendData.reload();
  if (isManager) tagData.reload();
});

usePageMeta(() => {
  return {
    title: __("Dashboard"),
  };
});
</script>

<style scoped>
:deep(.form-control button) {
  @apply text-base rounded-4 h-7 py-1.5 border border-outline-gray-2 bg-surface-base placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-0 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark];
}
:deep(.form-control button > div) {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

:deep(.form-control div) {
  width: 100%;
  display: flex;
}
</style>
bytes
18,817
lines
654
Type
vue
Latest commit
28 October 2026