IKHSAN
EN Read in EnglishID

opendesk

Publik
Branch
main
Commit terakhir
08d9f3328 Oktober 2026

Home.vue

Buka raw
<template>
  <div class="flex flex-col h-full">
    <LayoutHeader>
      <template #left-header>
        <div class="text-lg-medium text-ink-gray-9">{{ __("Home") }}</div>
      </template>
      <template #right-header>
        <div class="flex items-center gap-2">
          <Button
            v-if="layout.length > 0 && !editing"
            :label="__('Refresh')"
            variant="subtle"
            :icon-left="'lucide-refresh-ccw'"
            @click="agentDashboard.reload({ reset_layout: false })"
            :disabled="isLoading"
          />
          <Button
            v-if="editing && isDashboardModified"
            :label="__('Reset')"
            variant="subtle"
            :icon-left="'lucide-rotate-cw'"
            @click="onReset"
            :disabled="isLoading"
          />
          <Button
            v-if="editing"
            :label="__('Save')"
            variant="subtle"
            :icon-left="'lucide-check'"
            @click="onSave"
            :disabled="!isDirty"
            :loading="isLoading"
          />
          <Button
            v-if="layout.length > 0 && !editing"
            :label="__('Edit')"
            variant="subtle"
            :icon-left="'lucide-edit'"
            @click="onEdit"
            :disabled="isLoading"
          />
          <Button
            v-if="editing"
            :label="__('Cancel')"
            variant="subtle"
            @click="onCancel"
            :disabled="isLoading"
          />
          <Dropdown
            v-if="chartsDropdown.length > 0"
            :options="chartsDropdown"
            align="end"
          >
            <Button
              :label="__('New')"
              variant="solid"
              icon-left="lucide-plus"
              :disabled="isLoading"
            />
          </Dropdown>
        </div>
      </template>
    </LayoutHeader>
    <div class="flex-1 overflow-auto">
      <div
        v-if="agentDashboard.loading"
        class="flex items-center justify-center absolute inset-0 z-10"
      >
        <LoadingIndicator class="size-8" />
      </div>
      <div
        class="flex flex-col p-1 pt-4 md:p-4 md:ps-3 mx-auto max-w-screen-xl w-full grow relative h-full"
      >
        <div class="grow pb-12">
          <div
            v-if="!agentDashboard.loading && layout.length > 0"
            class="text-xl-semibold text-ink-gray-8 ps-2"
          >
            {{ __("Hey") }}, {{ userName }}
          </div>
          <div
            v-if="!agentDashboard.loading && layout.length === 0"
            class="absolute top-1/2 start-1/2 transform -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2"
          >
            <div class="flex flex-col items-center justify-center gap-1">
              <LucideLayout class="size-12 text-ink-gray-4" />
              <div class="text-md-medium text-ink-gray-8">
                {{ __("No charts added") }}
              </div>
              <div class="text-p-base text-ink-gray-6">
                {{ __("Add charts to get started") }}
              </div>
            </div>
          </div>
          <div class="mt-3">
            <GridLayout
              v-if="!agentDashboard.loading && layout.length > 0"
              class="h-fit w-full"
              :class="[editing ? 'mb-[20rem] !select-none' : '']"
              :cols="50"
              :rowHeight="14"
              :disabled="!editing"
              :modelValue="layout.map((item) => item.layout)"
              @update:modelValue="onLayoutUpdate"
            >
              <template #item="{ index }">
                <div
                  class="group relative flex h-full w-full p-2 text-ink-gray-8"
                >
                  <div
                    class="flex h-full w-full items-center justify-center"
                    :class="
                      editing
                        ? 'pointer-events-none  [&>div:first-child]:rounded-4 [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
                        : ''
                    "
                  >
                    <ChartItem :item="layout[index]" />
                  </div>
                  <div
                    v-if="editing"
                    class="flex absolute end-0 top-0 bg-surface-gray-9 rounded-4 cursor-pointer opacity-0 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
                  >
                    <div
                      class="rounded-4 p-1 hover:bg-surface-gray-8"
                      @click="layout.splice(index, 1)"
                    >
                      <LucideTrash2 class="size-3 text-ink-base" />
                    </div>
                  </div>
                </div>
              </template>
            </GridLayout>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import LucideLayout from "~icons/lucide/layout";
import LucideTrash2 from "~icons/lucide/trash-2";
import { LayoutHeader } from "@/components";
import { useAuthStore } from "@/stores/auth";
import { capture } from "@/telemetry";
import { __ } from "@/translation";
import {
  Button,
  createResource,
  Dropdown,
  LoadingIndicator,
  toast,
} from "frappe-ui";
import { GridLayout } from "@/components/frappe-ui/GridLayout";
import { storeToRefs } from "pinia";
import { computed, ref } from "vue";
import ChartItem from "./components/ChartItem.vue";

type Layout = {
  x: number;
  y: number;
  w: number;
  h: number;
  i: string;
  minW?: number;
  minH?: number;
  maxW?: number;
  maxH?: number;
};

type LayoutItem = {
  chart: string;
  data: Record<string, unknown>;
  layout: Layout;
};

type DashboardResponse = {
  layout: LayoutItem[];
  default_layout: string;
  dashboard_id?: string;
};

const { userName, userId } = storeToRefs(useAuthStore());
const editing = ref(false);
const layout = ref<LayoutItem[]>([]);
const oldLayout = ref<LayoutItem[]>([]);

const isDirty = computed(() => {
  return JSON.stringify(layout.value) !== JSON.stringify(oldLayout.value);
});
const isLoading = computed(() => {
  return (
    agentDashboard.loading || createDashboard.loading || saveDashboard.loading
  );
});

const agentDashboard = createResource({
  url: "opendesk.api.agent_home.agent_home.get_dashboard",
  auto: true,
  transform(data: DashboardResponse) {
    data.layout = data.layout.map((item) => {
      return {
        ...item,
        layout: {
          ...item.layout,
          i: Math.random().toString(36).substring(2, 9),
        },
      };
    });
    return data;
  },
  onSuccess(data: DashboardResponse) {
    layout.value = data.layout;
  },
});

const cleanUpLayoutData = (layout: LayoutItem[]) => {
  return layout.map((item) => {
    const l = item.layout;
    return {
      chart: item.chart,
      layout: {
        x: l.x,
        y: l.y,
        w: l.w,
        h: l.h,
        minW: l.minW,
        minH: l.minH,
        maxW: l.maxW,
        maxH: l.maxH,
      },
    };
  });
};

const isDashboardModified = computed(() => {
  const _layout = cleanUpLayoutData(layout.value);
  const defaultLayout = cleanUpLayoutData(
    JSON.parse(agentDashboard.data.default_layout)
  );
  return JSON.stringify(_layout) !== JSON.stringify(defaultLayout);
});

const createDashboard = createResource({
  url: "frappe.client.insert",
  makeParams() {
    const layoutData = layout.value.map((item) => {
      return {
        chart: item.chart,
        layout: item.layout,
      };
    }) as LayoutItem[];
    return {
      doc: {
        doctype: "OD Field Layout",
        user: userId.value,
        layout: JSON.stringify(cleanUpLayoutData(layoutData)),
      },
    };
  },
  onSuccess() {
    toast.success(__("Dashboard saved"));
    agentDashboard.reload();
  },
});

const saveDashboard = createResource({
  url: "frappe.client.set_value",
  makeParams() {
    const layoutData = layout.value.map((item) => {
      return {
        chart: item.chart,
        layout: item.layout,
      };
    }) as LayoutItem[];
    return {
      doctype: "OD Field Layout",
      name: agentDashboard.data.dashboard_id,
      fieldname: "layout",
      value: JSON.stringify(cleanUpLayoutData(layoutData)),
    };
  },
  onSuccess() {
    toast.success(__("Dashboard saved"));
  },
});

const chartsDropdown = computed(() => {
  const _charts = [
    {
      label: __("My Tickets"),
      chart: "agent_tickets",
      onClick: () =>
        addChart("agent_tickets", {
          w: 16,
          h: 10,
          minW: 16,
          minH: 10,
          maxH: 11,
        }),
    },
    {
      label: __("Avg. Time Metrics"),
      chart: "avg_time_metrics",
      onClick: () =>
        addChart("avg_time_metrics", {
          w: 50,
          h: 24,
          minW: 18,
          minH: 24,
          maxH: 44,
        }),
    },
    {
      label: __("Avg. First Response Time"),
      chart: "avg_first_response_time",
      onClick: () =>
        addChart("avg_first_response_time", {
          w: 16,
          h: 10,
          minW: 16,
          minH: 10,
          maxH: 11,
        }),
    },
    {
      label: __("Avg. Resolution Time"),
      chart: "avg_resolution_time",
      onClick: () =>
        addChart("avg_resolution_time", {
          w: 16,
          h: 10,
          minW: 16,
          minH: 10,
          maxH: 11,
        }),
    },
    {
      label: __("Reviews"),
      chart: "recent_feedback",
      onClick: () =>
        addChart("recent_feedback", {
          w: 50,
          h: 21,
          minW: 50,
          maxH: 21,
          minH: 21,
        }),
    },
    {
      label: __("Pending Tickets"),
      chart: "pending_tickets",
      onClick: () =>
        addChart("pending_tickets", {
          w: 50,
          h: 32,
          minW: 25,
          minH: 32,
          maxH: 32,
        }),
    },
    {
      label: __("My Recent Activity"),
      chart: "recent_activity",
      onClick: () =>
        addChart("recent_activity", {
          w: 16,
          h: 32,
          minW: 12,
          minH: 20,
          maxH: 44,
        }),
    },
  ].filter((chart) => {
    return !layout.value.some((item) => item.chart === chart.chart);
  });
  return _charts;
});

type ChartSize = {
  w: number;
  h: number;
  minW?: number;
  minH?: number;
  maxW?: number;
  maxH?: number;
};

const addChart = (chart: string, config: ChartSize) => {
  if (!editing.value) {
    onEdit();
  }
  layout.value.unshift({
    chart: chart,
    data: {},
    layout: {
      x: 0,
      y: 0,
      w: config.w,
      h: config.h,
      i: Math.random().toString(),
      minW: config.minW,
      minH: config.minH,
      maxW: config.maxW,
      maxH: config.maxH,
    },
  });
};

const onEdit = () => {
  oldLayout.value = JSON.parse(JSON.stringify(layout.value));
  editing.value = true;
};

const onSave = () => {
  if (agentDashboard.data?.dashboard_id) {
    saveDashboard.submit().then(() => {
      editing.value = false;
    });
  } else {
    createDashboard.submit().then(() => {
      editing.value = false;
    });
  }
  capture("home_page_updated");
};

const onCancel = () => {
  layout.value = oldLayout.value;
  editing.value = false;
};

const onReset = () => {
  agentDashboard.submit({
    reset_layout: true,
  });
  // the flag sticks to the resource, so every later reload would reset again
  agentDashboard.update({ params: {} });
};

const onLayoutUpdate = (newLayout: Layout[]) => {
  layout.value.forEach((item, idx) => {
    item.layout = newLayout[idx];
  });
};
</script>
byte
11.554
baris
448
Jenis
vue
Commit terakhir
28 Oktober 2026