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="customer.doc && customer.doc?.name"
13 >
14 <!-- customer detail -->
15 <PageInfo
16 :avatar="{
17 label: customer.doc.customer_name ?? '',
18 image: customer.doc.image,
19 shape: 'square',
20 }"
21 :docInfo="customerInfo"
22 >
23 <template #actions>
24 <div v-if="hasPermission()" class="flex gap-2 items-center">
25 <Button variant="subtle" @click="customerDialog = true">
26 <div class="flex gap-1 items-center">
27 <LucideSquarePen class="h-4 w-4" />
28 <span>{{ __("Edit") }}</span>
29 </div>
30 </Button>
31 <Dropdown :options="dropdownActions" align="end">
32 <Button icon="lucide-more-horizontal" variant="subtle" />
33 </Dropdown>
34 </div>
35 </template>
36 </PageInfo>
37 <div class="overflow-y-auto overscroll-y-contain flex-1 flex flex-col">
38 <TicketStats
39 :dt="'OD Customer'"
40 :dn="customer.doc.name"
41 v-if="!isMobileView"
42 />
43 <!-- Tabs -->
44 <Tabs
45 v-model="activeTab"
46 :tabs="tabs"
47 size="md"
48 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"
49 >
50 <template #tab-label="{ tab }: any">
51 <span class="group flex items-center gap-2">
52 {{ __(tab.label) }}
53 <Badge
54 class="group-hover:bg-surface-gray-10 !bg-surface-gray-2 !text-ink-gray-7"
55 variant="solid"
56 theme="gray"
57 size="sm"
58 >
59 {{ tab.count }}
60 </Badge>
61 </span>
62 </template>
63 <template #tab-panel="{ tab }">
64 <div class="p-5 flex flex-col flex-1 min-h-0">
65 <div v-if="tab.label === __('Tickets')">
66 <!-- Tickets tab content -->
67 <TicketsTab
68 :ticketsListResource="ticketsListResource"
69 :ticketsCountResource="ticketsCountResource"
70 :baseFilter="{ customer: props.id }"
71 :additionalFilter="contactFilter"
72 />
73 </div>
74 <CustomerContactTab v-if="tab.label === __('Contacts')" />
75 </div>
76 </template>
77 </Tabs>
78 </div>
79 </div>
80 </div>
81 <EditCustomerDialog
82 v-if="customerDialog"
83 v-model="customerDialog"
84 @update="customerDialog = false"
85 :id="id"
86 />
87 <DeleteWithTicketsDialog
88 v-model="showDeleteDialog"
89 :name="id"
90 link-field="customer"
91 :title="__('Delete Customer')"
92 :message="
93 __(
94 'Are you sure you want to delete this customer? The reference to this customer will be removed from all the related tickets.'
95 )
96 "
97 :on-delete="handleDelete"
98 />
99</template>
100
101<script setup lang="ts">
102import CustomerContactTab from "@/components/customer/CustomerContactTab.vue";
103import TicketsTab from "@/components/customer/TicketsTab.vue";
104import TicketStats from "@/components/customer/TicketStats.vue";
105import DeleteWithTicketsDialog from "@/components/DeleteWithTicketsDialog.vue";
106import TicketHashIcon from "@/components/icons/TicketHashIcon.vue";
107import LayoutHeader from "@/components/LayoutHeader.vue";
108import PageInfo from "@/components/PageInfo.vue";
109import { useCustomer } from "@/composables/customer";
110import { useScreenSize } from "@/composables/screen";
111import { __ } from "@/translation";
112import { CustomerResourceSymbol } from "@/types";
113import { hasPermission } from "@/utils";
114import {
115 Badge,
116 Breadcrumbs,
117 Button,
118 Dropdown,
119 Tabs,
120 usePageMeta,
121} from "frappe-ui";
122import { computed, h, markRaw, onMounted, provide, ref } from "vue";
123import { useRoute, useRouter } from "vue-router";
124import LucideGlobe from "~icons/lucide/globe";
125import LucideMail from "~icons/lucide/mail";
126import LucideMapPin from "~icons/lucide/map-pin";
127import LucidePhone from "~icons/lucide/phone";
128import LucideSquarePen from "~icons/lucide/square-pen";
129import LucideSquareUser from "~icons/lucide/square-user";
130import LucideTrash2 from "~icons/lucide/trash-2";
131import { getTicketListResource } from "../../stores/docTickets";
132// props with type set at string
133const props = defineProps<{
134 id: string;
135}>();
136const route = useRoute();
137const router = useRouter();
138
139const { isMobileView } = useScreenSize();
140const { ticketsListResource, ticketsCountResource } = getTicketListResource();
141
142const tabs = computed(() => [
143 {
144 label: __("Tickets"),
145 value: "tickets",
146 count: ticketsCountResource.data ?? 0,
147 iconLeft: h(TicketHashIcon, { class: "size-4" }),
148 },
149 {
150 label: __("Contacts"),
151 value: "contacts",
152 count: customer.getContacts.loading
153 ? 0
154 : customer.getContacts.data?.length ?? 0,
155 iconLeft: h(LucideSquareUser, { class: "size-4" }),
156 },
157]);
158const { doc: customer, handleDelete } = useCustomer(props.id);
159provide(CustomerResourceSymbol, customer);
160
161const contactFilter = computed(() => {
162 const contacts = customer.getContacts.data ?? [];
163 if (contacts.length <= 1) return undefined;
164 return {
165 key: "contact",
166 placeholder: __("Contact"),
167 doctype: "Contact",
168 filters: {
169 name: [
170 "in",
171 contacts.map((c: { contact_name: string }) => c.contact_name),
172 ],
173 },
174 };
175});
176
177const activeTab = computed<string>({
178 get() {
179 const hash = route.hash.slice(1);
180 const tab = tabs.value.find((t) => t.value === hash);
181 return tab?.value ?? tabs.value[0].value;
182 },
183 set(value) {
184 router.replace({ hash: value === tabs.value[0].value ? "" : `#${value}` });
185 },
186});
187
188const breadcrumbs = [
189 {
190 label: __("Customers"),
191 route: { name: "CustomerList" },
192 },
193 {
194 label: props.id,
195 },
196];
197
198const customerDialog = ref(false);
199const showDeleteDialog = ref(false);
200
201const dropdownActions = computed(() => [
202 {
203 group: __("Danger"),
204 hideLabel: true,
205 options: [
206 {
207 label: __("Delete"),
208 icon: LucideTrash2,
209 theme: "red",
210 onClick: () => {
211 showDeleteDialog.value = true;
212 },
213 },
214 ],
215 },
216]);
217
218const customerInfo = computed(() => [
219 {
220 icon: markRaw(LucideGlobe),
221 value: customer.doc.domain,
222 condition: !!customer.doc.domain,
223 },
224 {
225 icon: markRaw(LucideMail),
226 value: customer.doc.email_id,
227 condition: !!customer.doc.email_id,
228 },
229 {
230 icon: markRaw(LucidePhone),
231 value: customer.doc.mobile_no,
232 condition: !!customer.doc.mobile_no,
233 },
234 {
235 icon: markRaw(LucideMapPin),
236 value: customer.doc.country,
237 condition: !!customer.doc.country,
238 },
239]);
240
241onMounted(() => {
242 if (hasPermission()) {
243 customer.getPendingInvites.fetch();
244 }
245 customer.getContacts.fetch();
246 ticketsListResource.update({
247 filters: {
248 customer: props.id,
249 },
250 });
251 ticketsListResource.fetch();
252 ticketsCountResource.fetch();
253});
254
255usePageMeta(() => {
256 return {
257 title: `Customer: ${props.id}`,
258 };
259});
260</script>
261
262<style scoped>
263/* frappe-ui's TabsRoot clips with overflow-hidden, which traps the sticky
264 tablist. Let it overflow so the tablist sticks to the page scroll container. */
265.tabs-sticky-header {
266 overflow: visible !important;
267}
268/* Same for the tab panels, so sticky children (e.g. the ticket filter bar)
269 can stick to the page scroll container instead of being clipped. */
270.tabs-sticky-header :deep([role="tabpanel"]) {
271 overflow: visible !important;
272}
273.tabs-sticky-header :deep([role="tablist"]) {
274 position: sticky;
275 top: 0;
276 z-index: 10;
277 background-color: white;
278}
279</style>
280