1<template>
2 <Dialog v-model:open="open" size="md" bare :dismissible="!isDirty">
3 <template #default>
4 <div class="bg-surface-elevation-2 px-4 py-5 sm:px-6">
5 <div class="mb-6 flex items-center justify-between">
6 <div class="flex items-center gap-2">
7 <h3 class="text-2xl-semibold leading-6 text-ink-gray-9">
8 {{ __("Edit Contact") }}
9 </h3>
10 <Badge
11 v-if="isDirty"
12 :label="__('Unsaved')"
13 variant="subtle"
14 theme="amber"
15 />
16 </div>
17 <Button icon="lucide-x" variant="ghost" @click="open = false" />
18 </div>
19
20 <div class="space-y-4">
21 <!-- Avatar -->
22 <ImageAvatar
23 v-model="state.image"
24 :label="__('Photo')"
25 :fallback-label="
26 `${state.firstName} ${state.lastName}`.trim() || __('Contact')
27 "
28 shape="circle"
29 />
30
31 <!-- First + Last name -->
32 <div class="grid grid-cols-2 gap-4">
33 <FormControl
34 :label="__('First Name')"
35 type="text"
36 :required="true"
37 :placeholder="__('John')"
38 v-model="state.firstName"
39 />
40 <FormControl
41 :label="__('Last Name')"
42 type="text"
43 :placeholder="__('Doe')"
44 v-model="state.lastName"
45 />
46 </div>
47 <!-- Email IDs -->
48 <div class="space-y-1.5">
49 <label class="block text-base text-ink-gray-5">
50 {{ __("Email") }}
51 <span class="text-ink-red-6">*</span>
52 </label>
53 <ContactInputRow
54 v-for="(email, index) in state.emails"
55 :key="email.key"
56 v-model="email.email_id"
57 type="email"
58 placeholder="name@example.com"
59 :isPrimary="email.isPrimary"
60 :canRemove="!email.isPrimary"
61 :autofocus="email.key === autofocusKey"
62 @setPrimary="setPrimary('email', index)"
63 @remove="removeRow('email', index)"
64 />
65 <Button
66 key="add-email"
67 size="sm"
68 :label="__('Add Email')"
69 @click="addRow('email')"
70 >
71 <template #prefix>
72 <LucidePlus class="size-3.5" />
73 </template>
74 </Button>
75 </div>
76
77 <!-- Phone numbers -->
78 <div class="space-y-1.5 flex flex-col items-start w-full flex-1">
79 <label class="block text-base text-ink-gray-5">{{
80 __("Phone")
81 }}</label>
82 <ContactInputRow
83 v-for="(phone, index) in state.phones"
84 :key="phone.key"
85 v-model="phone.phone"
86 type="tel"
87 :isPrimary="phone.isPrimary"
88 :canRemove="true"
89 :canTogglePrimary="true"
90 :autofocus="phone.key === autofocusKey"
91 @setPrimary="setPrimary('phone', index)"
92 @remove="removeRow('phone', index)"
93 />
94 <Button
95 key="add-phone"
96 size="sm"
97 :label="__('Add Phone')"
98 @click="addRow('phone')"
99 >
100 <template #prefix>
101 <LucidePlus class="size-3.5" />
102 </template>
103 </Button>
104 </div>
105
106 <!-- Timezone -->
107 <div v-if="doc.doc?.user" class="space-y-1.5">
108 <label class="block text-base text-ink-gray-5">{{
109 __("Timezone")
110 }}</label>
111 <TimezoneControl v-model="state.timezone" />
112 </div>
113
114 <!-- Customer -->
115 <div class="space-y-1.5">
116 <label
117 class="block text-base text-ink-gray-5"
118 v-if="!contactInfoResource.data?.invitation"
119 >
120 {{ __("Customer") }}
121 </label>
122
123 <!-- Already linked: read-only, opens the customer in a new tab -->
124 <div
125 v-for="customer in linkedCustomers"
126 :key="customer.name"
127 class="flex items-center gap-2"
128 >
129 <FormControl
130 class="flex-1"
131 type="text"
132 :modelValue="customer.name"
133 readonly
134 >
135 <template #prefix>
136 <Avatar
137 :label="customer.name"
138 :image="customer.image"
139 size="sm"
140 />
141 </template>
142 </FormControl>
143 <Button
144 variant="ghost"
145 :tooltip="__('Open customer')"
146 @click="goToCustomer(customer.name)"
147 >
148 <template #icon>
149 <LucideExternalLink class="size-4 text-ink-gray-5" />
150 </template>
151 </Button>
152 </div>
153
154 <!-- Link a customer (only while none is linked yet) -->
155 <Link
156 v-if="canLinkCustomer"
157 v-model="state.customer"
158 doctype="OD Customer"
159 :placeholder="__('Select Customer')"
160 />
161 </div>
162
163 <!-- Save -->
164 <div class="flex justify-end">
165 <Button
166 :label="__('Save')"
167 variant="solid"
168 theme="gray"
169 :disabled="!isDirty"
170 :loading="
171 editContactResource.loading || linkCustomerResource.loading
172 "
173 @click="handleSave"
174 />
175 </div>
176 </div>
177 </div>
178 </template>
179 </Dialog>
180</template>
181
182<script setup lang="ts">
183import Link from "@/components/frappe-ui/Link.vue";
184import ImageAvatar from "@/components/ImageAvatar.vue";
185import { nextEntryKey, useContact } from "@/composables/contact";
186import { __ } from "@/translation";
187import type { Error } from "@/types";
188import { getErrorMessage } from "@/utils";
189import { Avatar, Badge, Button, Dialog, FormControl } from "frappe-ui";
190import { computed, ref } from "vue";
191import { useRouter } from "vue-router";
192import LucideExternalLink from "~icons/lucide/external-link";
193import LucidePlus from "~icons/lucide/plus";
194import TimezoneControl from "../TimezoneControl.vue";
195import ContactInputRow from "./ContactInputRow.vue";
196
197const props = defineProps<{ name: string }>();
198const open = defineModel<boolean>({ default: false });
199
200const {
201 state,
202 parseContactData,
203 isDirty,
204 isContactDirty,
205 editContactResource,
206 linkCustomerResource,
207 doc,
208 contactInfoResource,
209} = useContact(props.name);
210
211const router = useRouter();
212const autofocusKey = ref<number | null>(null);
213
214const linkedCustomers = computed<{ name: string; image?: string | null }[]>(
215 () => contactInfoResource.data?.customers ?? []
216);
217const canLinkCustomer = computed(
218 () =>
219 linkedCustomers.value.length === 0 && !contactInfoResource.data?.invitation
220);
221
222function goToCustomer(name: string) {
223 const route = router.resolve({ name: "Customer", params: { id: name } });
224 window.open(route.href, "_blank");
225}
226
227function addRow(type: "email" | "phone") {
228 const key = nextEntryKey();
229 if (type === "email") {
230 state.emails.push({
231 email_id: "",
232 isPrimary: state.emails.length === 0,
233 key,
234 });
235 } else {
236 state.phones.push({
237 phone: "",
238 isPrimary: false,
239 key,
240 });
241 }
242 autofocusKey.value = key;
243}
244
245function removeRow(type: "email" | "phone", index: number) {
246 const list = type === "email" ? state.emails : state.phones;
247 const wasPrimary = list[index]?.isPrimary;
248 list.splice(index, 1);
249 if (type === "email") {
250 const first = list[0];
251 if (wasPrimary && first) {
252 first.isPrimary = true;
253 }
254 }
255}
256
257function setPrimary(type: "email" | "phone", index: number) {
258 if (type === "email") {
259 state.emails.forEach((e, i) => {
260 e.isPrimary = i === index;
261 });
262 } else {
263 const wasPrimary = Boolean(state.phones[index]?.isPrimary);
264 state.phones.forEach((p, i) => {
265 p.isPrimary = i === index ? !wasPrimary : false;
266 });
267 }
268}
269
270async function handleSave() {
271 const tasks: Promise<unknown>[] = [];
272 if (isContactDirty.value) {
273 tasks.push(
274 editContactResource.submit({ name: props.name, doc: parseContactData() })
275 );
276 }
277 if (state.customer) {
278 tasks.push(linkCustomerResource.submit());
279 }
280 if (!tasks.length) return;
281 try {
282 await Promise.all(tasks);
283 state.customer = "";
284 open.value = false;
285 doc.reload();
286 contactInfoResource.reload();
287 } catch (error) {
288 getErrorMessage(error as Error, true);
289 }
290}
291</script>
292
293<style scoped>
294.row-leave-active {
295 position: absolute;
296 width: 100%;
297 transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1),
298 transform 180ms cubic-bezier(0.2, 0, 0, 1);
299}
300.row-leave-to {
301 opacity: 0;
302 transform: translateY(-4px);
303}
304</style>
305