feat: customer groups for people and companies with tahesab group sync and bulk register
Multiple groups per contact, legacy group text migrated, excel import/export, contacts filter and column, richer create-in-tahesab, match-first bulk export
This commit is contained in:
@@ -8,6 +8,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import RelatedTasksPanel from '@/components/routines/RelatedTasksPanel.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import CustomerGroupPicker from '@/components/CustomerGroupPicker.vue';
|
||||
import { formatLocalDateInput, parseLocalInput } from '@/lib/jalali';
|
||||
import {
|
||||
UiButton,
|
||||
@@ -59,6 +60,7 @@ const city = ref('');
|
||||
const postalCode = ref('');
|
||||
const address = ref('');
|
||||
const groupName = ref('');
|
||||
const groupIds = ref<string[]>([]);
|
||||
const registeredAt = ref('');
|
||||
const notes = ref('');
|
||||
const tagsRaw = ref('');
|
||||
@@ -94,6 +96,7 @@ function fill(c: CompanyDto) {
|
||||
postalCode.value = c.postalCode ?? '';
|
||||
address.value = c.address ?? '';
|
||||
groupName.value = c.groupName ?? '';
|
||||
groupIds.value = (c.groups ?? []).map((g) => g.id);
|
||||
registeredAt.value = c.registeredAt ? formatLocalDateInput(new Date(c.registeredAt)) : '';
|
||||
notes.value = c.notes ?? '';
|
||||
tagsRaw.value = (c.tags ?? []).join('، ');
|
||||
@@ -134,6 +137,7 @@ function payload(): CreateCompanyRequest {
|
||||
postalCode: postalCode.value || null,
|
||||
address: address.value || undefined,
|
||||
groupName: groupName.value || null,
|
||||
groupIds: groupIds.value,
|
||||
registeredAt: parseLocalInput(registeredAt.value)?.toISOString() ?? null,
|
||||
notes: notes.value || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
@@ -254,8 +258,8 @@ onMounted(load);
|
||||
<UiInput id="sourceRef" v-model="sourceRef" placeholder="مثال: 5277" dir="ltr" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="گروه بندی شرکت">
|
||||
<UiInput id="groupName" v-model="groupName" placeholder="مثال: همکار، عمدهفروش" />
|
||||
<UiField label="گروههای شرکت" class="col-span-2">
|
||||
<CustomerGroupPicker v-model="groupIds" />
|
||||
</UiField>
|
||||
|
||||
<UiField label="تاریخ ثبت شرکت">
|
||||
|
||||
@@ -40,6 +40,10 @@ import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||
import { canUseBehavior } from '@/lib/behavior-access';
|
||||
import DiscBadge from '@/components/behavior/DiscBadge.vue';
|
||||
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
|
||||
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
|
||||
import CustomerGroupsManager from '@/components/CustomerGroupsManager.vue';
|
||||
import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups';
|
||||
import { canManageSettings } from '@/lib/roles';
|
||||
|
||||
type Tab = 'people' | 'companies';
|
||||
type SortKey = 'name' | 'newest';
|
||||
@@ -73,6 +77,19 @@ const sourceFilter = computed<PersonImportSource | 'all'>(() => {
|
||||
? (value as PersonImportSource)
|
||||
: 'all';
|
||||
});
|
||||
const groupFilter = computed(() =>
|
||||
typeof route.query.group === 'string' ? route.query.group : '',
|
||||
);
|
||||
const { groups: customerGroups, load: loadCustomerGroups } = useCustomerGroups();
|
||||
const canManageGroups = computed(() => canManageSettings(auth.user?.role));
|
||||
const groupsManagerOpen = ref(false);
|
||||
const visibleGroups = computed(() =>
|
||||
customerGroups.value.filter(
|
||||
(g) =>
|
||||
g.id === groupFilter.value ||
|
||||
(tab.value === 'people' ? g.personCount : g.companyCount) > 0,
|
||||
),
|
||||
);
|
||||
const q = ref(typeof route.query.q === 'string' ? route.query.q : '');
|
||||
const loading = ref(false);
|
||||
const busy = ref(false);
|
||||
@@ -145,6 +162,7 @@ const peopleSavedFilters = computed(() => ({
|
||||
mood: moodFilter.value === 'all' ? undefined : moodFilter.value,
|
||||
source: sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||||
disc: discFilter.value || undefined,
|
||||
group: groupFilter.value || undefined,
|
||||
}));
|
||||
|
||||
function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
||||
@@ -166,6 +184,7 @@ function applyPeopleSavedFilters(filters: Record<string, unknown>) {
|
||||
sort: nextSort === 'name' ? undefined : nextSort,
|
||||
mood: nextMood === 'all' ? undefined : nextMood,
|
||||
source: nextSource === 'all' ? undefined : nextSource,
|
||||
group: typeof filters.group === 'string' && filters.group ? filters.group : undefined,
|
||||
page: undefined,
|
||||
tab: undefined,
|
||||
});
|
||||
@@ -221,6 +240,7 @@ async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
closePopups();
|
||||
void loadCustomerGroups(true).catch(() => undefined);
|
||||
try {
|
||||
if (tab.value === 'people') {
|
||||
const [peopleRes, statsRes] = await Promise.all([
|
||||
@@ -234,6 +254,7 @@ async function load() {
|
||||
importSource:
|
||||
sourceFilter.value === 'all' ? undefined : sourceFilter.value,
|
||||
disc: discFilter.value || undefined,
|
||||
groupId: groupFilter.value || undefined,
|
||||
},
|
||||
}),
|
||||
api.get<PersonImportSourceStatDto[]>('/people/source-stats').catch(() => ({
|
||||
@@ -246,7 +267,12 @@ async function load() {
|
||||
selectedIds.value = selectedIds.value.filter((id) => visible.has(id));
|
||||
} else {
|
||||
const { data } = await api.get<PaginatedResponse<CompanyDto>>('/companies', {
|
||||
params: { q: q.value || undefined, page: page.value, limit: pageSize.value },
|
||||
params: {
|
||||
q: q.value || undefined,
|
||||
page: page.value,
|
||||
limit: pageSize.value,
|
||||
groupId: groupFilter.value || undefined,
|
||||
},
|
||||
});
|
||||
companies.value = data;
|
||||
}
|
||||
@@ -295,6 +321,14 @@ function setDisc(next: string) {
|
||||
patchQuery({ disc: next || undefined, page: undefined });
|
||||
}
|
||||
|
||||
function setGroup(next: string) {
|
||||
patchQuery({ group: next || undefined, page: undefined });
|
||||
}
|
||||
|
||||
async function onGroupsChanged() {
|
||||
await load();
|
||||
}
|
||||
|
||||
function setSource(next: PersonImportSource | 'all') {
|
||||
patchQuery({
|
||||
source: next === 'all' ? undefined : next,
|
||||
@@ -409,7 +443,7 @@ function onImportSettled() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source],
|
||||
() => [route.query.tab, route.query.q, route.query.page, route.query.sort, route.query.mood, route.query.disc, route.query.source, route.query.group],
|
||||
() => {
|
||||
q.value = typeof route.query.q === 'string' ? route.query.q : '';
|
||||
void load();
|
||||
@@ -556,6 +590,33 @@ onMounted(load);
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="visibleGroups.length || canManageGroups" class="source-stats">
|
||||
<button type="button" class="source-chip" :class="{ on: !groupFilter }" @click="setGroup('')">
|
||||
همه گروهها
|
||||
</button>
|
||||
<button
|
||||
v-for="g in visibleGroups"
|
||||
:key="g.id"
|
||||
type="button"
|
||||
class="source-chip"
|
||||
:class="{ on: groupFilter === g.id }"
|
||||
:style="groupFilter === g.id ? { color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg } : undefined"
|
||||
@click="setGroup(g.id)"
|
||||
>
|
||||
{{ g.name }}
|
||||
<em>{{ toFaDigits(tab === 'people' ? g.personCount : g.companyCount) }}</em>
|
||||
</button>
|
||||
<button
|
||||
v-if="canManageGroups"
|
||||
type="button"
|
||||
class="source-chip manage-chip"
|
||||
@click="groupsManagerOpen = true"
|
||||
>
|
||||
<UiIcon name="settings" :size="13" />
|
||||
مدیریت گروهها
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tab === 'people' && canBehavior" class="source-stats">
|
||||
<button type="button" class="source-chip" :class="{ on: !discFilter }" @click="setDisc('')">همه تیپها</button>
|
||||
<button v-for="type in ['D', 'I', 'S', 'C']" :key="type" type="button" class="source-chip" :class="{ on: discFilter === type }" @click="setDisc(type)">
|
||||
@@ -606,6 +667,9 @@ onMounted(load);
|
||||
<span>منبع: {{ sourceLabel(p.importSource || 'manual') }}</span>
|
||||
<span v-if="externalCode(p)"> · کد: {{ externalCodeLabel(p) }}</span>
|
||||
</p>
|
||||
<p v-if="p.groups?.length" class="line">
|
||||
<CustomerGroupChips :groups="p.groups" />
|
||||
</p>
|
||||
<p v-if="p.previousFullName" class="line muted">
|
||||
نام قبلی: {{ p.previousFullName }}
|
||||
</p>
|
||||
@@ -647,6 +711,7 @@ onMounted(load);
|
||||
<th class="num-col">#</th>
|
||||
<th>وضعیت</th>
|
||||
<th>نام شخص</th>
|
||||
<th>گروه</th>
|
||||
<th>منبع</th>
|
||||
<th>کد مبدأ</th>
|
||||
<th>سمت</th>
|
||||
@@ -682,6 +747,7 @@ onMounted(load);
|
||||
قبلی: {{ p.previousFullName }}
|
||||
</div>
|
||||
</td>
|
||||
<td><CustomerGroupChips :groups="p.groups" empty="—" /></td>
|
||||
<td>
|
||||
{{ sourceLabel(p.importSource || 'manual') }}
|
||||
<div v-if="p.previousImportSource" class="muted tiny">
|
||||
@@ -767,6 +833,9 @@ onMounted(load);
|
||||
{{ c.code ? `کد: ${toFaDigits(c.code)}` : '' }}
|
||||
</span>
|
||||
</p>
|
||||
<p v-if="c.groups?.length" class="line">
|
||||
<CustomerGroupChips :groups="c.groups" />
|
||||
</p>
|
||||
<p class="role">
|
||||
{{ [c.city, c.owner?.fullName].filter(Boolean).join(' • ') }}
|
||||
{{ c.city || c.owner ? ' • ' : '' }}افراد: {{ toFaDigits(c.peopleCount) }}
|
||||
@@ -806,7 +875,10 @@ onMounted(load);
|
||||
<td dir="ltr">{{ c.sourceRef ? toFaDigits(c.sourceRef) : '—' }}</td>
|
||||
<td dir="ltr">{{ c.code ? toFaDigits(c.code) : '—' }}</td>
|
||||
<td>{{ sourceLabel(c.importSource || 'manual') }}</td>
|
||||
<td>{{ c.groupName || '—' }}</td>
|
||||
<td>
|
||||
<CustomerGroupChips v-if="c.groups?.length" :groups="c.groups" />
|
||||
<span v-else>{{ c.groupName || '—' }}</span>
|
||||
</td>
|
||||
<td>{{ c.province || '—' }}</td>
|
||||
<td>{{ c.city || '—' }}</td>
|
||||
<td dir="ltr">{{ c.phone || '—' }}</td>
|
||||
@@ -856,6 +928,13 @@ onMounted(load);
|
||||
@cancel="contactModal = null"
|
||||
/>
|
||||
</UiModal>
|
||||
|
||||
<CustomerGroupsManager
|
||||
v-if="canManageGroups"
|
||||
:open="groupsManagerOpen"
|
||||
@close="groupsManagerOpen = false"
|
||||
@changed="onGroupsChanged"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -997,6 +1076,10 @@ h1 {
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
.manage-chip {
|
||||
border-style: dashed;
|
||||
color: #64748b;
|
||||
}
|
||||
.tiny {
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.15rem;
|
||||
|
||||
@@ -38,6 +38,7 @@ import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@me
|
||||
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
|
||||
import { formatLocalDateInput, toFaDigits } from '@/lib/jalali';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { useCustomerGroups } from '@/lib/customer-groups';
|
||||
import {
|
||||
filterPhoneContacts,
|
||||
isContactPickerSupported,
|
||||
@@ -97,7 +98,13 @@ const notes = ref('');
|
||||
const tagsRaw = ref('');
|
||||
const companyId = ref('');
|
||||
const mood = ref<PersonMood>('newcomer');
|
||||
const groupIds = ref<string[]>([]);
|
||||
const customFields = ref<Record<string, unknown>>({});
|
||||
const { groups: customerGroups, loaded: groupsLoaded, load: loadCustomerGroups } =
|
||||
useCustomerGroups();
|
||||
const groupOptions = computed(() =>
|
||||
customerGroups.value.map((g) => ({ value: g.id, label: g.name })),
|
||||
);
|
||||
const loyaltyPoints = ref(0);
|
||||
const loyaltyTierName = ref('');
|
||||
|
||||
@@ -257,6 +264,7 @@ function fill(p: PersonDto) {
|
||||
tagsRaw.value = (p.tags ?? []).join('، ');
|
||||
companyId.value = p.company?.id ?? '';
|
||||
mood.value = p.mood || 'newcomer';
|
||||
groupIds.value = (p.groups ?? []).map((g) => g.id);
|
||||
customFields.value = { ...(p.customFields ?? {}) };
|
||||
loyaltyPoints.value = p.loyaltyPoints ?? 0;
|
||||
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
|
||||
@@ -277,6 +285,7 @@ function removeAddress(index: number) {
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
void loadCustomerGroups().catch(() => undefined);
|
||||
try {
|
||||
const companiesRes = await api.get<{ items: CompanyDto[] }>('/companies', {
|
||||
params: { limit: 100 },
|
||||
@@ -334,6 +343,7 @@ function payload(): CreatePersonRequest {
|
||||
tags: parseTags(tagsRaw.value),
|
||||
companyId: companyId.value || null,
|
||||
mood: mood.value,
|
||||
groupIds: groupIds.value,
|
||||
customFields: customFields.value,
|
||||
};
|
||||
}
|
||||
@@ -558,6 +568,20 @@ onMounted(load);
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="گروهها">
|
||||
<a-select
|
||||
v-model:value="groupIds"
|
||||
mode="multiple"
|
||||
allow-clear
|
||||
option-filter-prop="label"
|
||||
placeholder="بدون گروه"
|
||||
:options="groupOptions"
|
||||
:not-found-content="groupsLoaded ? 'گروهی تعریف نشده' : undefined"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="برچسبها">
|
||||
<a-input
|
||||
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
import { PERSON_MOOD_LABELS } from '@/lib/person-mood';
|
||||
import { canUseBehavior } from '@/lib/behavior-access';
|
||||
import DiscBadge from '@/components/behavior/DiscBadge.vue';
|
||||
import CustomerGroupChips from '@/components/CustomerGroupChips.vue';
|
||||
import DiscPanel from '@/components/behavior/DiscPanel.vue';
|
||||
import SalesCopilotPanel from '@/components/behavior/SalesCopilotPanel.vue';
|
||||
|
||||
@@ -83,7 +84,7 @@ const TABS = computed<Array<{ key: TabKey; label: string }>>(() => {
|
||||
{ key: 'tasks', label: 'کارهای مرتبط' },
|
||||
{ key: 'deals', label: 'معاملات' },
|
||||
{ key: 'cards', label: 'کارتها' },
|
||||
{ key: 'assets', label: 'دارایی' },
|
||||
{ key: 'assets', label: 'تهحساب و دارایی' },
|
||||
{ key: 'info', label: 'اطلاعات' },
|
||||
];
|
||||
if (canBehavior.value) {
|
||||
@@ -759,6 +760,10 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
</div>
|
||||
|
||||
<dl class="facts-grid">
|
||||
<div class="fact">
|
||||
<dt>گروهها</dt>
|
||||
<dd><CustomerGroupChips :groups="person.groups" empty="—" /></dd>
|
||||
</div>
|
||||
<div class="fact">
|
||||
<dt>شرکت</dt>
|
||||
<dd>{{ person.company?.name || '—' }}</dd>
|
||||
@@ -809,6 +814,7 @@ watch(() => route.query.tab, readTab, { immediate: true });
|
||||
</h2>
|
||||
<p v-if="subtitle" class="sub">{{ subtitle }}</p>
|
||||
<DiscBadge v-if="canBehavior && person.discReliable" :label="person.discLabel" />
|
||||
<CustomerGroupChips :groups="person.groups" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="contact-rows">
|
||||
|
||||
@@ -496,6 +496,128 @@ async function importBulk() {
|
||||
}
|
||||
}
|
||||
|
||||
/** همهٔ بازههای کد خوانده میشود و گروه تهحساب روی مشتریهای لینکشده نشانده میشود */
|
||||
async function syncGroups() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
if (!sync.connectionId) {
|
||||
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
syncBusy.value = true;
|
||||
importOverlay.open = true;
|
||||
importOverlay.progress = null;
|
||||
const totals = { rows: 0, linked: 0, updated: 0 };
|
||||
try {
|
||||
let page = 1;
|
||||
let emptyStreak = 0;
|
||||
while (emptyStreak < LIST_ALL_EMPTY_STOP) {
|
||||
importOverlay.message = `در حال همگامسازی گروهها… ${toFaDigits(totals.rows)} مشتری بررسی شد · ${toFaDigits(totals.updated)} تغییر`;
|
||||
const { data } = await api.post<{ rows: number; linked: number; updated: number }>(
|
||||
'/integrations/tahesab/sync/groups',
|
||||
{ connectionId: sync.connectionId, page, pageSize: LIST_ALL_CODE_PAGE },
|
||||
{ timeout: 120_000 },
|
||||
);
|
||||
totals.rows += data.rows;
|
||||
totals.linked += data.linked;
|
||||
totals.updated += data.updated;
|
||||
emptyStreak = data.rows ? 0 : emptyStreak + 1;
|
||||
page += 1;
|
||||
}
|
||||
ok.value = `گروهها همگام شد: ${toFaDigits(totals.linked)} مشتری لینکشده بررسی شد · گروه ${toFaDigits(totals.updated)} نفر بهروز شد.`;
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'همگامسازی گروهها ناموفق بود');
|
||||
} finally {
|
||||
importOverlay.open = false;
|
||||
syncBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
type ExportItem = {
|
||||
personId: string;
|
||||
name: string;
|
||||
status: 'matched' | 'created' | 'ambiguous' | 'skipped' | 'error';
|
||||
moshtariCode?: number;
|
||||
error?: string;
|
||||
};
|
||||
const EXPORT_CHUNK = 10;
|
||||
const exportIssues = ref<ExportItem[]>([]);
|
||||
|
||||
/** شخصهای لینکنشده: اول تطبیق، بعد ساخت در تهحساب (بدون ساخت تکراری) */
|
||||
async function exportUnlinked() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
exportIssues.value = [];
|
||||
if (!sync.connectionId) {
|
||||
error.value = 'ابتدا اتصال را انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
syncBusy.value = true;
|
||||
try {
|
||||
const { data: unlinked } = await api.get<{ ids: string[]; total: number }>(
|
||||
'/integrations/tahesab/sync/unlinked-people',
|
||||
{ params: { connectionId: sync.connectionId } },
|
||||
);
|
||||
if (!unlinked.total) {
|
||||
ok.value = 'همهٔ اشخاص به تهحساب لینک هستند.';
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!confirm(
|
||||
`${toFaDigits(unlinked.total)} شخص به تهحساب لینک نیستند.\n` +
|
||||
'برای هر نفر اول با موبایل/نام در تهحساب جستجو میشود: اگر دقیقاً یک نفر پیدا شد فقط لینک میشود، ' +
|
||||
'اگر چند نفر پیدا شد رد میشود تا دستی بررسی کنید، و اگر پیدا نشد در تهحساب ساخته میشود. ادامه؟',
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
importOverlay.open = true;
|
||||
importOverlay.progress = 0;
|
||||
const totals = { matched: 0, created: 0, ambiguous: 0, skipped: 0, failed: 0 };
|
||||
const ids = unlinked.ids;
|
||||
for (let i = 0; i < ids.length; i += EXPORT_CHUNK) {
|
||||
const chunk = ids.slice(i, i + EXPORT_CHUNK);
|
||||
importOverlay.message = `در حال ثبت در تهحساب… ${toFaDigits(Math.min(i + chunk.length, ids.length))} از ${toFaDigits(ids.length)}`;
|
||||
try {
|
||||
const { data } = await api.post<typeof totals & { items: ExportItem[] }>(
|
||||
'/integrations/tahesab/sync/export-people',
|
||||
{ connectionId: sync.connectionId, personIds: chunk },
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
totals.matched += data.matched;
|
||||
totals.created += data.created;
|
||||
totals.ambiguous += data.ambiguous;
|
||||
totals.skipped += data.skipped;
|
||||
totals.failed += data.failed;
|
||||
exportIssues.value.push(
|
||||
...data.items.filter((it) => it.status === 'ambiguous' || it.status === 'error'),
|
||||
);
|
||||
} catch (e) {
|
||||
totals.failed += chunk.length;
|
||||
exportIssues.value.push(
|
||||
...chunk.map((personId) => ({
|
||||
personId,
|
||||
name: '',
|
||||
status: 'error' as const,
|
||||
error: apiErr(e, 'خطا در ارسال'),
|
||||
})),
|
||||
);
|
||||
}
|
||||
importOverlay.progress = ((i + chunk.length) / ids.length) * 100;
|
||||
}
|
||||
ok.value =
|
||||
`ثبت در تهحساب تمام شد: لینک با مشتری موجود ${toFaDigits(totals.matched)} · ` +
|
||||
`ساختهشده ${toFaDigits(totals.created)} · چند نتیجه (نیاز به بررسی) ${toFaDigits(totals.ambiguous)} · ` +
|
||||
`ناموفق ${toFaDigits(totals.failed)}`;
|
||||
} catch (e) {
|
||||
error.value = apiErr(e, 'ثبت گروهی در تهحساب ناموفق بود');
|
||||
} finally {
|
||||
importOverlay.open = false;
|
||||
importOverlay.progress = null;
|
||||
syncBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function searchProducts() {
|
||||
error.value = '';
|
||||
ok.value = '';
|
||||
@@ -807,6 +929,43 @@ onMounted(load);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sync-sub-section">
|
||||
<h4 class="sync-subhead">۳) گروهها و ثبت مشتریهای منتکت در تهحساب</h4>
|
||||
<p class="sync-sub-desc">
|
||||
«همگامسازی گروهها» گروه هر مشتری لینکشده را از تهحساب میخواند؛ فقط گروهِ آمده از تهحساب عوض
|
||||
میشود و گروههایی که دستی دادهاید میمانند.
|
||||
«ثبت لینکنشدهها» برای هر شخصی که به تهحساب لینک نیست اول تطبیق (موبایل، بعد نام) را امتحان
|
||||
میکند تا مشتری تکراری ساخته نشود؛ اگر پیدا نشد با گروه، آدرس، کد ملی و تاریخ تولد ساخته میشود.
|
||||
</p>
|
||||
<div class="sync-buttons">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="!sync.connectionId || syncBusy"
|
||||
@click="syncGroups"
|
||||
>
|
||||
همگامسازی گروهها از تهحساب
|
||||
</UiButton>
|
||||
<UiButton :disabled="!sync.connectionId || syncBusy" @click="exportUnlinked">
|
||||
ثبت مشتریهای لینکنشده در تهحساب
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="exportIssues.length" class="export-issues">
|
||||
<p class="sync-sub-desc">
|
||||
این اشخاص ثبت نشدند ({{ toFaDigits(exportIssues.length) }}). از پرونده، بخش «تهحساب و دارایی»
|
||||
دستی لینک یا ثبت کنید:
|
||||
</p>
|
||||
<ul>
|
||||
<li v-for="it in exportIssues" :key="it.personId">
|
||||
<RouterLink :to="{ name: 'person-detail', params: { id: it.personId } }">
|
||||
{{ it.name || 'بدون نام' }}
|
||||
</RouterLink>
|
||||
—
|
||||
{{ it.status === 'ambiguous' ? 'چند مشتری مشابه در تهحساب پیدا شد' : it.error || 'خطا' }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="syncResults.length" class="sync-results-box">
|
||||
<h4 class="sync-results-title">
|
||||
نتایج جستجو ({{ syncVisibleResults.length }}<template v-if="syncOnlyNew"> از {{ syncResults.length }}</template>)
|
||||
@@ -1176,6 +1335,14 @@ onMounted(load);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-issues ul {
|
||||
margin: var(--sp-2) 0 0;
|
||||
padding-inline-start: 1.2rem;
|
||||
font-size: var(--fs-xs);
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sync-table-pagination {
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user