1<template>
2 <div
3 class="flex h-full flex-col overflow-y-hidden max-w-screen-xl mx-auto w-full"
4 >
5 <LayoutHeader>
6 <template #left-header>
7 <Breadcrumbs :items="breadcrumbs" class="-ml-[2px]" />
8 </template>
9 </LayoutHeader>
10 <div
11 class="gap-5 flex flex-col flex-1 min-h-0"
12 v-if="!contact.loading && contact.doc"
13 >
14 <!-- ContactInfo -->
15 <PageInfo
16 :avatar="{
17 label: `${contact.doc.full_name}`.trim(),
18 image: contact.doc.image ?? undefined,
19 shape: 'circle',
20 }"
21 :doc-info="contactInfo"
22 :badge="invitationBadge"
23 >
24 <template #actions>
25 <div class="flex gap-2 items-center">
26 <Button
27 variant="subtle"
28 @click="showEditDialog = true"
29 v-if="hasPermission()"
30 >
31 <div class="flex gap-1 items-center">
32 <LucideSquarePen class="h-4 w-4" />
33 <span>{{ __("Edit") }}</span>
34 </div>
35 </Button>
36 <Dropdown
37 :options="dropdownActions"
38 align="end"
39 v-if="hasPermission()"
40 >
41 <Button icon="lucide-more-horizontal" variant="subtle" />
42 </Dropdown>
43 </div>
44 </template>
45 </PageInfo>
46 <div class="overflow-y-auto overscroll-y-contain flex-1 flex flex-col">
47 <TicketStats :dt="'Contact'" :dn="id" v-if="!isMobileView" />
48 <Tabs
49 v-model="activeTab"
50 :tabs="tabs"
51 size="md"
52 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"
53 >
54 <template #tab-label="{ tab }: any">
55 <span class="group flex items-center gap-2">
56 {{ __(tab.label) }}
57 <Badge
58 class="group-hover:bg-surface-gray-10 !bg-surface-gray-2 !text-ink-gray-7"
59 variant="solid"
60 theme="gray"
61 size="sm"
62 >
63 {{ tab.count }}
64 </Badge>
65 </span>
66 </template>
67 <template #tab-panel="{ tab }">
68 <div class="p-5 flex flex-col flex-1 min-h-0">
69 <TicketsTab
70 v-if="tab.label === __('Tickets')"
71 :ticketsListResource="ticketsListResource"
72 :ticketsCountResource="ticketsCountResource"
73 :baseFilter="{ contact: props.id }"
74 :additionalFilter="
75 (contactInfoResource.data?.customers?.length ?? 0) > 1
76 ? {
77 key: 'customer',
78 placeholder: __('Customer'),
79 doctype: 'OD Customer',
80 filters: {
81 name: ['in', contactInfoResource.data.customers.map((c: { name: string }) => c.name)],
82 },
83 }
84 : undefined
85 "
86 />
87 <div v-if="tab.label === __('Feedback')">
88 <!-- Feedback tab content -->
89 <ContactFeedback :name="props.id" />
90 </div>
91 </div>
92 </template>
93 </Tabs>
94 </div>
95 </div>
96 <DeleteWithTicketsDialog
97 v-model="showDeleteDialog"
98 :name="id"
99 link-field="contact"
100 :message="
101 __(
102 'Are you sure you want to delete this contact? The contact will be permanently deleted and unlinked from any associated customers and tickets.'
103 )
104 "
105 :on-delete="handleDelete"
106 />
107 <EditContactDialog
108 v-if="showEditDialog"
109 v-model="showEditDialog"
110 :name="id"
111 />
112 </div>
113</template>
114
115<script setup lang="ts">
116import ContactCustomers from "@/components/contact/ContactCustomers.vue";
117import ContactFeedback from "@/components/contact/ContactFeedback.vue";
118import TicketsTab from "@/components/customer/TicketsTab.vue";
119import DeleteWithTicketsDialog from "@/components/DeleteWithTicketsDialog.vue";
120import ModifiedIcon from "@/components/icons/ModifiedIcon.vue";
121import TicketFeedbackIcon from "@/components/icons/TicketFeedbackIcon.vue";
122import TicketHashIcon from "@/components/icons/TicketHashIcon.vue";
123//@ts-ignore
124import EditContactDialog from "@/components/contact/EditContactDialog.vue";
125import LayoutHeader from "@/components/LayoutHeader.vue";
126import PageInfo from "@/components/PageInfo.vue";
127import {
128 useContact,
129 useContactFeedback,
130 useContactInvite,
131 useContactResetPassword,
132} from "@/composables/contact";
133import { useScreenSize } from "@/composables/screen";
134import { __ } from "@/translation";
135import { hasPermission } from "@/utils";
136import {
137 Breadcrumbs,
138 Button,
139 Dropdown,
140 Tabs,
141 dayjs,
142 usePageMeta,
143} from "frappe-ui";
144import { computed, h, markRaw, onMounted, ref } from "vue";
145import { useRoute, useRouter } from "vue-router";
146import LucideMail from "~icons/lucide/mail";
147import LucideMapPin from "~icons/lucide/map-pin";
148import LucidePhone from "~icons/lucide/phone";
149import LucideTrash2 from "~icons/lucide/trash-2";
150import { getTicketListResource } from "../../stores/docTickets";
151
152const props = defineProps<{
153 id: string;
154}>();
155
156const route = useRoute();
157const router = useRouter();
158const { isMobileView } = useScreenSize();
159
160const {
161 doc: contact,
162 contactInfoResource,
163 handleDelete,
164} = useContact(props.id);
165
166const { feedbackCount } = useContactFeedback(props.id);
167
168const { ticketsListResource, ticketsCountResource } = getTicketListResource();
169
170const tabs = computed(() => [
171 {
172 label: __("Tickets"),
173 value: "tickets",
174 count: ticketsCountResource.data ?? 0,
175 iconLeft: h(TicketHashIcon, { class: "size-4" }),
176 },
177 {
178 label: __("Feedback"),
179 value: "feedback",
180 count: feedbackCount.data ?? 0,
181 iconLeft: h(TicketFeedbackIcon, { class: "size-4" }),
182 },
183]);
184
185const activeTab = computed<string>({
186 get() {
187 const hash = route.hash.slice(1);
188 const tab = tabs.value.find((t) => t.value === hash);
189 return tab?.value ?? tabs.value[0].value;
190 },
191 set(value) {
192 router.replace({ hash: value === tabs.value[0].value ? "" : `#${value}` });
193 },
194});
195
196const contactInfo = computed(() => {
197 if (
198 contactInfoResource.loading ||
199 !contactInfoResource.data ||
200 !contact.doc?.name
201 ) {
202 return [];
203 }
204 const info = [
205 {
206 icon: markRaw(LucideMail),
207 value: contact.doc?.email_id,
208 condition: !!contact.doc?.email_id,
209 },
210 {
211 icon: markRaw(LucidePhone),
212 value: contact.doc?.mobile_no,
213 condition: !!contact.doc?.mobile_no,
214 },
215 {
216 icon: markRaw(LucideMapPin),
217 value: contactInfoResource.data?.country,
218 condition: !!contactInfoResource.data?.country,
219 },
220 {
221 icon: markRaw(ModifiedIcon),
222 value: __("Last seen {0}", [
223 dayjs(contactInfoResource.data?.last_seen).fromNow(),
224 ]),
225 condition: !!contactInfoResource.data?.last_seen,
226 },
227 {
228 component: markRaw(
229 h(ContactCustomers, {
230 customers: contactInfoResource.data?.customers ?? [],
231 })
232 ),
233 condition: (contactInfoResource.data?.customers?.length ?? 0) > 0,
234 },
235 ];
236 return info;
237});
238
239const { resendInvite, inviteAsUser } = useContactInvite();
240const { resetPassword } = useContactResetPassword(() => contact.doc?.user);
241
242const showEditDialog = ref(false);
243
244const invitation = computed(() => contactInfoResource.data?.invitation);
245
246const invitationBadge = computed(() => {
247 const inv = invitation.value;
248 if (!inv?.name) return null;
249 if (inv.status === "Expired") {
250 return {
251 label: __("Invitation Expired"),
252 theme: "red" as const,
253 tooltip: __("Invitation expired. Resend to invite again."),
254 };
255 }
256 return {
257 label: __("Invited"),
258 theme: "amber" as const,
259 tooltip: __("Invite sent. Waiting for the user to accept."),
260 };
261});
262
263const showDeleteDialog = ref(false);
264
265const dropdownActions = computed(() => {
266 const baseActions = [];
267 if (!contact.doc?.user && !invitation.value?.name) {
268 baseActions.push({
269 label: __("Invite as User"),
270 icon: "lucide-user-plus",
271 onClick: async () => {
272 await inviteAsUser(props.id, contact.doc?.email_id);
273 contactInfoResource.reload();
274 },
275 });
276 }
277 if (invitation.value?.name) {
278 baseActions.push({
279 label: __("Resend Invite"),
280 icon: "lucide-mail",
281 onClick: async () => {
282 await resendInvite(
283 invitation.value!.name,
284 invitation.value!.status,
285 props.id,
286 contact.doc?.email_id
287 );
288 contactInfoResource.reload();
289 },
290 });
291 }
292 if (contact.doc?.user) {
293 baseActions.push({
294 label: __("Send reset password email"),
295 icon: "lucide-mail",
296 onClick: () => {
297 resetPassword();
298 },
299 });
300 }
301 return [
302 {
303 group: __("Actions"),
304 hideLabel: true,
305 options: baseActions,
306 },
307 {
308 group: __("Danger"),
309 hideLabel: true,
310 options: [
311 {
312 label: __("Delete"),
313 icon: LucideTrash2,
314 theme: "red",
315 onClick: () => {
316 showDeleteDialog.value = true;
317 },
318 },
319 ],
320 },
321 ];
322});
323
324const breadcrumbs = [
325 {
326 label: __("Contacts"),
327 route: { name: "ContactList" },
328 },
329 {
330 label: props.id,
331 },
332];
333
334onMounted(() => {
335 ticketsListResource.update({
336 filters: {
337 contact: props.id,
338 },
339 });
340 ticketsListResource.fetch();
341 ticketsCountResource.fetch();
342});
343
344usePageMeta(() => {
345 return {
346 title: `Contact: ${props.id}`,
347 };
348});
349</script>
350
351<style scoped>
352/* frappe-ui's TabsRoot clips with overflow-hidden, which traps the sticky
353 tablist. Let it overflow so the tablist sticks to the page scroll container. */
354.tabs-sticky-header {
355 overflow: visible !important;
356}
357/* Same for the tab panels, so sticky children (e.g. the ticket filter bar)
358 can stick to the page scroll container instead of being clipped. */
359.tabs-sticky-header :deep([role="tabpanel"]) {
360 overflow: visible !important;
361}
362.tabs-sticky-header :deep([role="tablist"]) {
363 position: sticky;
364 top: 0;
365 z-index: 10;
366 background-color: white;
367}
368</style>
369