Files
CRM_Montzar/apps/web/src/views/PersonFormView.vue
T

846 lines
24 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import { toFaDigits } from '@/lib/jalali';
import {
filterPhoneContacts,
isContactPickerSupported,
parseVCardFile,
pickPhoneContacts,
sortPhoneContacts,
type PhoneContactDraft,
} from '@/lib/phone-contacts';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
import UiIcon from '@/components/UiIcon.vue';
import {
UiButton,
UiCard,
UiField,
UiInput,
UiModal,
UiPageHeader,
UiSelect,
UiTextarea,
} from '@/components/ui';
const props = withDefaults(
defineProps<{
embedded?: boolean;
entityId?: string;
/** متن جستجو برای پیش‌پر کردن نام یا موبایل شخص جدید */
initialQuery?: string;
}>(),
{ embedded: false },
);
const emit = defineEmits<{ saved: [person?: PersonDto]; cancel: [] }>();
const route = useRoute();
const router = useRouter();
const id = computed(() => {
if (props.embedded) return props.entityId ?? '';
return typeof route.params.id === 'string' ? route.params.id : '';
});
const isNew = computed(() => (props.embedded ? !props.entityId : route.name === 'person-new'));
const pageTitle = computed(() => (isNew.value ? 'شخص جدید' : 'ویرایش شخص'));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const companies = ref<CompanyDto[]>([]);
const firstName = ref('');
const lastName = ref('');
const email = ref('');
const phone = ref('');
const mobile = ref('');
const position = ref('');
const website = ref('');
const instagram = ref('');
const referrer = ref('');
const addresses = ref<string[]>(['']);
const notes = ref('');
const tagsRaw = ref('');
const companyId = ref('');
const mood = ref<PersonMood>('newcomer');
const customFields = ref<Record<string, unknown>>({});
const loyaltyPoints = ref(0);
const loyaltyTierName = ref('');
const contactPickerSupported = isContactPickerSupported();
const pickingContact = ref(false);
const contactFileInput = ref<HTMLInputElement | null>(null);
const sourceOpen = ref(false);
const pickOpen = ref(false);
const pickDrafts = ref<PhoneContactDraft[]>([]);
const pickQuery = ref('');
const visiblePickDrafts = computed(() =>
filterPhoneContacts(pickDrafts.value, pickQuery.value),
);
const backTo = computed(() =>
isNew.value ? { name: 'contacts' as const } : { name: 'person-detail' as const, params: { id: id.value } },
);
/** فقط موبایل را اجباری پر می‌کند؛ نام و بقیه فقط اگر خالی باشند. */
function applyContactDraft(draft: PhoneContactDraft) {
if (draft.mobile) {
mobile.value = draft.mobile;
} else if (draft.phone && !mobile.value.trim()) {
mobile.value = draft.phone;
}
if (!firstName.value.trim() && draft.firstName && draft.firstName !== 'بدون‌نام') {
firstName.value = draft.firstName;
}
if (!lastName.value.trim() && draft.lastName) {
lastName.value = draft.lastName;
}
if (!phone.value.trim() && draft.phone && draft.phone !== draft.mobile) {
phone.value = draft.phone;
}
if (!email.value.trim() && draft.email) {
email.value = draft.email;
}
}
function draftLine(row: PhoneContactDraft) {
return [row.mobile, row.phone, row.email].filter(Boolean).join(' · ') || 'بدون شماره/ایمیل';
}
function closePick() {
pickOpen.value = false;
pickDrafts.value = [];
pickQuery.value = '';
}
function closeSource() {
sourceOpen.value = false;
}
function choosePick(row: PhoneContactDraft) {
applyContactDraft(row);
closePick();
}
async function openPickList(rows: PhoneContactDraft[]) {
pickDrafts.value = sortPhoneContacts(rows);
pickQuery.value = '';
sourceOpen.value = false;
pickOpen.value = false;
await nextTick();
pickOpen.value = true;
}
function fillFromPhoneContact() {
if (pickingContact.value || saving.value) return;
error.value = '';
if (contactPickerSupported) {
sourceOpen.value = true;
return;
}
contactFileInput.value?.click();
}
async function pickFromDevice() {
if (pickingContact.value || saving.value) return;
pickingContact.value = true;
error.value = '';
sourceOpen.value = false;
try {
const rows = await pickPhoneContacts({ multiple: true });
if (!rows.length) {
error.value = 'مخاطبی انتخاب نشد.';
return;
}
await openPickList(rows);
} catch (e) {
const name = e instanceof DOMException ? e.name : '';
const message = e instanceof Error ? e.message : '';
if (name === 'AbortError') return;
if (message === 'CONTACT_PICKER_UNSUPPORTED' || name === 'NotSupportedError') {
contactFileInput.value?.click();
return;
}
error.value = 'انتخاب مخاطب از گوشی ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
function pickFromVcf() {
sourceOpen.value = false;
contactFileInput.value?.click();
}
async function onContactVcfFile(ev: Event) {
const el = ev.target as HTMLInputElement;
const file = el.files?.[0];
el.value = '';
if (!file) return;
pickingContact.value = true;
error.value = '';
try {
const rows = await parseVCardFile(file);
if (!rows.length) {
error.value = 'مخاطبی در فایل پیدا نشد.';
return;
}
await openPickList(rows);
} catch {
error.value = 'خواندن فایل مخاطب (.vcf) ناموفق بود.';
} finally {
pickingContact.value = false;
}
}
function fill(p: PersonDto) {
firstName.value = p.firstName;
lastName.value = p.lastName;
email.value = p.email ?? '';
phone.value = p.phone ?? '';
mobile.value = p.mobile ?? '';
position.value = p.position ?? '';
website.value = p.website ?? '';
instagram.value = p.instagram ?? '';
referrer.value = p.referrer ?? '';
addresses.value = p.addresses?.length ? [...p.addresses] : [''];
notes.value = p.notes ?? '';
tagsRaw.value = (p.tags ?? []).join('، ');
companyId.value = p.company?.id ?? '';
mood.value = p.mood || 'newcomer';
customFields.value = { ...(p.customFields ?? {}) };
loyaltyPoints.value = p.loyaltyPoints ?? 0;
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
}
function addAddress() {
addresses.value.push('');
}
function removeAddress(index: number) {
if (addresses.value.length <= 1) {
addresses.value[0] = '';
return;
}
addresses.value.splice(index, 1);
}
async function load() {
loading.value = true;
error.value = '';
try {
const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', {
params: { limit: 100 },
});
companies.value = companiesRes.data.items;
if (!isNew.value && id.value) {
const { data } = await api.get<PersonDto>(`/people/${id.value}`);
fill(data);
} else if (props.initialQuery?.trim()) {
prefillFromQuery(props.initialQuery.trim());
}
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
} finally {
loading.value = false;
}
}
function prefillFromQuery(text: string) {
const digits = text.replace(/[۰-۹]/g, (d) => String('۰۱۲۳۴۵۶۷۸۹'.indexOf(d))).replace(/[\s-]/g, '');
if (/^\+?\d{5,}$/.test(digits)) {
mobile.value = digits;
return;
}
const [first, ...rest] = text.split(/\s+/);
firstName.value = first ?? '';
lastName.value = rest.join(' ');
}
function parseTags(raw: string): string[] {
return [
...new Set(
raw
.split(/[,،#\n]/)
.map((t) => t.trim())
.filter(Boolean),
),
].slice(0, 40);
}
function payload(): CreatePersonRequest {
return {
firstName: firstName.value,
lastName: lastName.value,
email: email.value || undefined,
phone: phone.value || undefined,
mobile: mobile.value || undefined,
position: position.value || undefined,
website: website.value || undefined,
instagram: instagram.value || undefined,
referrer: referrer.value || undefined,
addresses: addresses.value.map((a) => a.trim()).filter(Boolean),
notes: notes.value || undefined,
tags: parseTags(tagsRaw.value),
companyId: companyId.value || null,
mood: mood.value,
customFields: customFields.value,
};
}
async function submit() {
saving.value = true;
error.value = '';
try {
if (isNew.value) {
const { data } = await api.post<PersonDto>('/people', payload());
if (props.embedded) {
emit('saved', data);
return;
}
await router.replace({ name: 'person-detail', params: { id: data.id } });
} else {
await api.patch(`/people/${id.value}`, payload());
if (props.embedded) {
emit('saved');
return;
}
await router.push({ name: 'person-detail', params: { id: id.value } });
}
} catch (e) {
if (axios.isAxiosError(e)) {
if (!e.response) {
error.value = 'اتصال به سرور برقرار نشد.';
} else {
const msg = (e.response.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
}
} else {
error.value = 'ذخیره ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function remove() {
if (!id.value || !confirm('این شخص حذف شود؟')) return;
try {
await api.delete(`/people/${id.value}`);
await router.push({ name: 'contacts' });
} catch {
error.value = 'حذف ناموفق بود';
}
}
function cancel() {
if (props.embedded) {
emit('cancel');
return;
}
void router.push(backTo.value);
}
onMounted(load);
</script>
<template>
<section class="crm-page" :class="{ embedded, narrow: !embedded }">
<!-- Desktop Header -->
<UiPageHeader
v-if="!embedded"
:title="pageTitle"
:back-to="backTo"
>
<template #lead>
<span class="crumb">
<RouterLink :to="{ name: 'contacts' }">مخاطبین</RouterLink>
<span>›</span>
<strong>{{ pageTitle }}</strong>
</span>
</template>
<template #actions>
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
<UiButton v-if="!isNew" variant="danger" :disabled="saving" @click="remove">
حذف
</UiButton>
</template>
</UiPageHeader>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<form v-else class="crm-form" @submit.prevent="submit">
<UiCard>
<div class="crm-form-grid-2">
<UiField label="نام" for-id="firstName">
<UiInput id="firstName" v-model="firstName" />
</UiField>
<UiField label="نام خانوادگی" for-id="lastName">
<UiInput id="lastName" v-model="lastName" />
</UiField>
<UiField label="موبایل" for-id="mobile">
<template #label-extra>
<button
type="button"
class="from-contact"
:disabled="pickingContact || saving"
@click="fillFromPhoneContact"
>
<UiIcon name="phone" :size="14" />
{{ pickingContact ? '…' : 'از مخاطبین' }}
</button>
</template>
<UiInput id="mobile" v-model="mobile" type="tel" />
</UiField>
<input
ref="contactFileInput"
class="hidden-file"
type="file"
accept=".vcf,text/vcard,text/x-vcard"
@change="onContactVcfFile"
/>
<UiModal nest :open="sourceOpen" title="از مخاطبین" @close="closeSource">
<p class="pick-lead">
چند مخاطب را از دفترچه یا فایل بگیرید؛ بعد در لیست الفبایی جستجو کنید و یکی را برای پر کردن موبایل انتخاب کنید.
</p>
<div class="pick-choose">
<UiButton variant="primary" block :disabled="pickingContact" @click="pickFromDevice">
<UiIcon name="phone" :size="16" />
از دفترچه تلفن
</UiButton>
<UiButton variant="ghost" block :disabled="pickingContact" @click="pickFromVcf">
از فایل .vcf
</UiButton>
</div>
<template #footer>
<UiButton variant="ghost" @click="closeSource">انصراف</UiButton>
</template>
</UiModal>
<UiModal nest :open="pickOpen" title="انتخاب مخاطب" @close="closePick">
<p class="pick-lead">
{{ toFaDigits(pickDrafts.length) }} مخاطب (مرتب‌شده الفبایی).
یکی را انتخاب کنید تا فقط موبایل (و در صورت خالی بودن، نام) پر شود.
</p>
<div class="pick-search">
<UiIcon name="search" :size="15" />
<input
v-model="pickQuery"
type="search"
placeholder="جستجو نام یا موبایل…"
autocomplete="off"
/>
</div>
<ul v-if="visiblePickDrafts.length" class="pick-list">
<li v-for="row in visiblePickDrafts" :key="row.key">
<button type="button" class="pick-row" @click="choosePick(row)">
<span class="pick-avatar" aria-hidden="true">{{ row.fullName.slice(0, 1) }}</span>
<span class="pick-meta">
<strong>{{ row.fullName }}</strong>
<small>{{ draftLine(row) }}</small>
</span>
</button>
</li>
</ul>
<p v-else class="pick-empty">مخاطبی با این جستجو پیدا نشد.</p>
<template #footer>
<UiButton variant="ghost" @click="closePick">انصراف</UiButton>
<UiButton variant="ghost" @click="contactFileInput?.click()">فایل دیگر</UiButton>
</template>
</UiModal>
<UiField label="تلفن" for-id="phone">
<UiInput id="phone" v-model="phone" type="tel" />
</UiField>
<UiField label="ایمیل" for-id="email">
<UiInput id="email" v-model="email" type="email" />
</UiField>
<UiField label="سمت" for-id="position">
<UiInput id="position" v-model="position" />
</UiField>
<UiField label="آدرس وب‌سایت" for-id="website">
<UiInput id="website" v-model="website" dir="ltr" placeholder="https://example.com" />
</UiField>
<UiField label="پیج اینستاگرام" for-id="instagram">
<UiInput id="instagram" v-model="instagram" dir="ltr" placeholder="@username یا لینک" />
</UiField>
<UiField label="معرف" for-id="referrer">
<UiInput id="referrer" v-model="referrer" placeholder="نام معرف" />
</UiField>
<div class="crm-col-span-full addresses">
<div class="addresses-head">
<span>آدرس‌ها</span>
<button type="button" class="add-addr" @click="addAddress">+ افزودن آدرس</button>
</div>
<div v-for="(_, idx) in addresses" :key="idx" class="addr-row">
<UiInput
:id="`address-${idx}`"
v-model="addresses[idx]"
:placeholder="idx === 0 ? 'آدرس اصلی' : `آدرس ${idx + 1}`"
/>
<button type="button" class="rm-addr" aria-label="حذف آدرس" @click="removeAddress(idx)">
×
</button>
</div>
</div>
<div class="crm-col-span-full">
<UiField label="وضعیت مشتری">
<div class="moods">
<button
v-for="item in PERSON_MOODS"
:key="item"
class="mood-btn"
type="button"
:class="{ on: mood === item }"
@click="mood = item"
>
<CustomerMoodFace :mood="item" :size="40" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</button>
</div>
</UiField>
</div>
<div class="crm-col-span-full">
<UiField label="شرکت" for-id="companyId">
<UiSelect id="companyId" v-model="companyId">
<option value="">بدون شرکت</option>
<option v-for="c in companies" :key="c.id" :value="c.id">{{ c.name }}</option>
</UiSelect>
</UiField>
</div>
<div class="crm-col-span-full">
<UiField label="برچسب‌ها" for-id="tags">
<UiInput
id="tags"
v-model="tagsRaw"
placeholder="VIP، معرفی (با کاما جدا کنید)"
/>
</UiField>
</div>
<div class="crm-col-span-full">
<UiField label="یادداشت" for-id="notes">
<UiTextarea id="notes" v-model="notes" :rows="3" />
</UiField>
</div>
<div v-if="!isNew" class="crm-col-span-full loyalty-card">
<div class="loyalty-info">
<span class="muted">باشگاه مشتریان</span>
<strong>{{ loyaltyPoints }} امتیاز<span v-if="loyaltyTierName"> · سطح {{ loyaltyTierName }}</span></strong>
</div>
<RouterLink class="loyalty-link" :to="{ name: 'loyalty' }">مدیریت باشگاه</RouterLink>
</div>
</div>
</UiCard>
<CustomFieldsSection entity-type="person" v-model="customFields" />
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
<EntityTimeline v-if="!isNew && id" entity-type="person" :entity-id="id" />
<p v-if="error" class="crm-field-error">{{ error }}</p>
<div class="mobile-actions mob-only">
<UiButton variant="primary" block type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton v-if="!isNew" variant="danger" block type="button" :disabled="saving" @click="remove">
حذف
</UiButton>
</div>
</form>
<!-- Embedded Modal Actions (Fixed at bottom) -->
<div v-if="embedded" class="embed-actions">
<UiButton variant="primary" :disabled="saving" @click="submit">
<UiIcon name="check" :size="16" />
{{ saving ? 'در حال ذخیره…' : 'ذخیره' }}
</UiButton>
<UiButton variant="ghost" :disabled="saving" @click="cancel">انصراف</UiButton>
</div>
</section>
</template>
<style scoped>
.crumb {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--muted);
font-size: var(--fs-xs);
}
.crumb a {
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.embed-actions {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.addresses {
display: grid;
gap: var(--sp-2);
}
.addresses-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
font-size: var(--fs-sm);
font-weight: var(--fw-semibold);
color: var(--ink);
}
.add-addr {
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
}
.addr-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
}
.rm-addr {
width: 2rem;
height: 2rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
color: var(--muted);
font: inherit;
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.rm-addr:hover {
color: #b91c1c;
border-color: #fecaca;
}
.moods {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.mood-btn {
display: grid;
justify-items: center;
gap: 4px;
border: 1px solid var(--line);
background: var(--surface);
border-radius: var(--radius);
padding: var(--sp-2) var(--sp-3);
cursor: pointer;
font: inherit;
color: var(--muted);
font-size: var(--fs-xs);
transition: all var(--transition-fast);
}
.mood-btn.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: var(--fw-bold);
}
.loyalty-card {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4);
background: var(--surface-subtle);
border: 1px solid var(--line);
border-radius: var(--radius);
}
.loyalty-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.loyalty-link {
color: var(--accent-deep);
font-weight: var(--fw-semibold);
font-size: var(--fs-xs);
}
.mobile-actions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-top: var(--sp-4);
}
.from-contact {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
color: var(--accent-deep);
font: inherit;
font-size: var(--fs-xs);
font-weight: var(--fw-bold);
cursor: pointer;
padding: 0;
line-height: 1;
}
.from-contact:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.hidden-file {
display: none;
}
.pick-lead {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: var(--fs-sm);
line-height: 1.7;
}
.pick-choose {
display: grid;
gap: 0.55rem;
}
.pick-search {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.65rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
}
.pick-search input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
font: inherit;
font-size: var(--fs-sm);
color: var(--ink);
}
.pick-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.45rem;
max-height: min(48vh, 360px);
overflow: auto;
}
.pick-row {
width: 100%;
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.55rem 0.65rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface);
cursor: pointer;
font: inherit;
text-align: start;
color: inherit;
}
.pick-row:hover {
border-color: var(--accent);
background: var(--accent-subtle);
}
.pick-avatar {
width: 34px;
height: 34px;
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
display: grid;
place-items: center;
font-weight: var(--fw-bold);
flex-shrink: 0;
}
.pick-meta {
display: grid;
gap: 0.1rem;
min-width: 0;
}
.pick-meta strong {
font-size: var(--fs-sm);
}
.pick-meta small {
color: var(--muted);
font-size: var(--fs-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pick-empty {
margin: 0.75rem 0 0;
color: var(--muted);
font-size: var(--fs-sm);
text-align: center;
}
@media (min-width: 901px) {
.mob-only {
display: none !important;
}
}
</style>