IKHSAN
ENID Baca dalam Bahasa Indonesia

opendesk

Public
Branch
main
Latest commit
08d9f3328 October 2026

Contact.vue

Open raw
<template>
  <div
    class="flex h-full flex-col overflow-y-hidden max-w-screen-xl mx-auto w-full"
  >
    <LayoutHeader>
      <template #left-header>
        <Breadcrumbs :items="breadcrumbs" class="-ml-[2px]" />
      </template>
    </LayoutHeader>
    <div
      class="gap-5 flex flex-col flex-1 min-h-0"
      v-if="!contact.loading && contact.doc"
    >
      <!-- ContactInfo -->
      <PageInfo
        :avatar="{
          label: `${contact.doc.full_name}`.trim(),
          image: contact.doc.image ?? undefined,
          shape: 'circle',
        }"
        :doc-info="contactInfo"
        :badge="invitationBadge"
      >
        <template #actions>
          <div class="flex gap-2 items-center">
            <Button
              variant="subtle"
              @click="showEditDialog = true"
              v-if="hasPermission()"
            >
              <div class="flex gap-1 items-center">
                <LucideSquarePen class="h-4 w-4" />
                <span>{{ __("Edit") }}</span>
              </div>
            </Button>
            <Dropdown
              :options="dropdownActions"
              align="end"
              v-if="hasPermission()"
            >
              <Button icon="lucide-more-horizontal" variant="subtle" />
            </Dropdown>
          </div>
        </template>
      </PageInfo>
      <div class="overflow-y-auto overscroll-y-contain flex-1 flex flex-col">
        <TicketStats :dt="'Contact'" :dn="id" v-if="!isMobileView" />
        <Tabs
          v-model="activeTab"
          :tabs="tabs"
          size="md"
          class="tabs-sticky-header flex-1 overflow-hidden [&_[role='tablist']]:!bg-surface-base [&_[role='tablist']]:px-5 [&_[role='tablist']]:py-1.5 max-sm:[&_[role='tablist']]:px-3 [&_[role='tabpanel'][data-state='active']]:flex-1 [&_[role='tabpanel'][data-state='active']]:flex [&_[role='tabpanel'][data-state='active']]:flex-col [&_[role='tabpanel'][data-state='active']]:overflow-auto [&_[role='tabpanel'][data-state='active']]:min-h-0"
        >
          <template #tab-label="{ tab }: any">
            <span class="group flex items-center gap-2">
              {{ __(tab.label) }}
              <Badge
                class="group-hover:bg-surface-gray-10 !bg-surface-gray-2 !text-ink-gray-7"
                variant="solid"
                theme="gray"
                size="sm"
              >
                {{ tab.count }}
              </Badge>
            </span>
          </template>
          <template #tab-panel="{ tab }">
            <div class="p-5 flex flex-col flex-1 min-h-0">
              <TicketsTab
                v-if="tab.label === __('Tickets')"
                :ticketsListResource="ticketsListResource"
                :ticketsCountResource="ticketsCountResource"
                :baseFilter="{ contact: props.id }"
                :additionalFilter="
                (contactInfoResource.data?.customers?.length ?? 0) > 1
                  ? {
                      key: 'customer',
                      placeholder: __('Customer'),
                      doctype: 'OD Customer',
                      filters: {
                        name: ['in', contactInfoResource.data.customers.map((c: { name: string }) => c.name)],
                      },
                    }
                  : undefined
              "
              />
              <div v-if="tab.label === __('Feedback')">
                <!-- Feedback tab content -->
                <ContactFeedback :name="props.id" />
              </div>
            </div>
          </template>
        </Tabs>
      </div>
    </div>
    <DeleteWithTicketsDialog
      v-model="showDeleteDialog"
      :name="id"
      link-field="contact"
      :message="
        __(
          'Are you sure you want to delete this contact? The contact will be permanently deleted and unlinked from any associated customers and tickets.'
        )
      "
      :on-delete="handleDelete"
    />
    <EditContactDialog
      v-if="showEditDialog"
      v-model="showEditDialog"
      :name="id"
    />
  </div>
</template>

<script setup lang="ts">
import ContactCustomers from "@/components/contact/ContactCustomers.vue";
import ContactFeedback from "@/components/contact/ContactFeedback.vue";
import TicketsTab from "@/components/customer/TicketsTab.vue";
import DeleteWithTicketsDialog from "@/components/DeleteWithTicketsDialog.vue";
import ModifiedIcon from "@/components/icons/ModifiedIcon.vue";
import TicketFeedbackIcon from "@/components/icons/TicketFeedbackIcon.vue";
import TicketHashIcon from "@/components/icons/TicketHashIcon.vue";
//@ts-ignore
import EditContactDialog from "@/components/contact/EditContactDialog.vue";
import LayoutHeader from "@/components/LayoutHeader.vue";
import PageInfo from "@/components/PageInfo.vue";
import {
  useContact,
  useContactFeedback,
  useContactInvite,
  useContactResetPassword,
} from "@/composables/contact";
import { useScreenSize } from "@/composables/screen";
import { __ } from "@/translation";
import { hasPermission } from "@/utils";
import {
  Breadcrumbs,
  Button,
  Dropdown,
  Tabs,
  dayjs,
  usePageMeta,
} from "frappe-ui";
import { computed, h, markRaw, onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import LucideMail from "~icons/lucide/mail";
import LucideMapPin from "~icons/lucide/map-pin";
import LucidePhone from "~icons/lucide/phone";
import LucideTrash2 from "~icons/lucide/trash-2";
import { getTicketListResource } from "../../stores/docTickets";

const props = defineProps<{
  id: string;
}>();

const route = useRoute();
const router = useRouter();
const { isMobileView } = useScreenSize();

const {
  doc: contact,
  contactInfoResource,
  handleDelete,
} = useContact(props.id);

const { feedbackCount } = useContactFeedback(props.id);

const { ticketsListResource, ticketsCountResource } = getTicketListResource();

const tabs = computed(() => [
  {
    label: __("Tickets"),
    value: "tickets",
    count: ticketsCountResource.data ?? 0,
    iconLeft: h(TicketHashIcon, { class: "size-4" }),
  },
  {
    label: __("Feedback"),
    value: "feedback",
    count: feedbackCount.data ?? 0,
    iconLeft: h(TicketFeedbackIcon, { class: "size-4" }),
  },
]);

const activeTab = computed<string>({
  get() {
    const hash = route.hash.slice(1);
    const tab = tabs.value.find((t) => t.value === hash);
    return tab?.value ?? tabs.value[0].value;
  },
  set(value) {
    router.replace({ hash: value === tabs.value[0].value ? "" : `#${value}` });
  },
});

const contactInfo = computed(() => {
  if (
    contactInfoResource.loading ||
    !contactInfoResource.data ||
    !contact.doc?.name
  ) {
    return [];
  }
  const info = [
    {
      icon: markRaw(LucideMail),
      value: contact.doc?.email_id,
      condition: !!contact.doc?.email_id,
    },
    {
      icon: markRaw(LucidePhone),
      value: contact.doc?.mobile_no,
      condition: !!contact.doc?.mobile_no,
    },
    {
      icon: markRaw(LucideMapPin),
      value: contactInfoResource.data?.country,
      condition: !!contactInfoResource.data?.country,
    },
    {
      icon: markRaw(ModifiedIcon),
      value: __("Last seen {0}", [
        dayjs(contactInfoResource.data?.last_seen).fromNow(),
      ]),
      condition: !!contactInfoResource.data?.last_seen,
    },
    {
      component: markRaw(
        h(ContactCustomers, {
          customers: contactInfoResource.data?.customers ?? [],
        })
      ),
      condition: (contactInfoResource.data?.customers?.length ?? 0) > 0,
    },
  ];
  return info;
});

const { resendInvite, inviteAsUser } = useContactInvite();
const { resetPassword } = useContactResetPassword(() => contact.doc?.user);

const showEditDialog = ref(false);

const invitation = computed(() => contactInfoResource.data?.invitation);

const invitationBadge = computed(() => {
  const inv = invitation.value;
  if (!inv?.name) return null;
  if (inv.status === "Expired") {
    return {
      label: __("Invitation Expired"),
      theme: "red" as const,
      tooltip: __("Invitation expired. Resend to invite again."),
    };
  }
  return {
    label: __("Invited"),
    theme: "amber" as const,
    tooltip: __("Invite sent. Waiting for the user to accept."),
  };
});

const showDeleteDialog = ref(false);

const dropdownActions = computed(() => {
  const baseActions = [];
  if (!contact.doc?.user && !invitation.value?.name) {
    baseActions.push({
      label: __("Invite as User"),
      icon: "lucide-user-plus",
      onClick: async () => {
        await inviteAsUser(props.id, contact.doc?.email_id);
        contactInfoResource.reload();
      },
    });
  }
  if (invitation.value?.name) {
    baseActions.push({
      label: __("Resend Invite"),
      icon: "lucide-mail",
      onClick: async () => {
        await resendInvite(
          invitation.value!.name,
          invitation.value!.status,
          props.id,
          contact.doc?.email_id
        );
        contactInfoResource.reload();
      },
    });
  }
  if (contact.doc?.user) {
    baseActions.push({
      label: __("Send reset password email"),
      icon: "lucide-mail",
      onClick: () => {
        resetPassword();
      },
    });
  }
  return [
    {
      group: __("Actions"),
      hideLabel: true,
      options: baseActions,
    },
    {
      group: __("Danger"),
      hideLabel: true,
      options: [
        {
          label: __("Delete"),
          icon: LucideTrash2,
          theme: "red",
          onClick: () => {
            showDeleteDialog.value = true;
          },
        },
      ],
    },
  ];
});

const breadcrumbs = [
  {
    label: __("Contacts"),
    route: { name: "ContactList" },
  },
  {
    label: props.id,
  },
];

onMounted(() => {
  ticketsListResource.update({
    filters: {
      contact: props.id,
    },
  });
  ticketsListResource.fetch();
  ticketsCountResource.fetch();
});

usePageMeta(() => {
  return {
    title: `Contact: ${props.id}`,
  };
});
</script>

<style scoped>
/* frappe-ui's TabsRoot clips with overflow-hidden, which traps the sticky
   tablist. Let it overflow so the tablist sticks to the page scroll container. */
.tabs-sticky-header {
  overflow: visible !important;
}
/* Same for the tab panels, so sticky children (e.g. the ticket filter bar)
   can stick to the page scroll container instead of being clipped. */
.tabs-sticky-header :deep([role="tabpanel"]) {
  overflow: visible !important;
}
.tabs-sticky-header :deep([role="tablist"]) {
  position: sticky;
  top: 0;
  z-index: 10;
  background-color: white;
}
</style>
bytes
10,576
lines
369
Type
vue
Latest commit
28 October 2026