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:
2026-10-21 17:16:31 +03:30
parent ce27df20ca
commit 29d99cbded
27 changed files with 1728 additions and 26 deletions
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { CustomerGroupSummaryDto } from '@mento/shared';
import { groupSwatch } from '@/lib/customer-groups';
defineProps<{
groups: CustomerGroupSummaryDto[] | undefined;
/** متن وقتی گروهی نیست */
empty?: string;
}>();
</script>
<template>
<span v-if="groups?.length" class="group-chips">
<span
v-for="g in groups"
:key="g.id"
class="group-chip"
:style="{ color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg }"
:title="g.source === 'tahesab' ? `${g.name} (از ته‌حساب)` : g.name"
>
{{ g.name }}
</span>
</span>
<span v-else-if="empty" class="group-empty">{{ empty }}</span>
</template>
<style scoped>
.group-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
vertical-align: middle;
}
.group-chip {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.group-empty {
color: #94a3b8;
}
</style>
@@ -0,0 +1,68 @@
<script setup lang="ts">
import { onMounted } from 'vue';
import { groupSwatch, useCustomerGroups } from '@/lib/customer-groups';
const model = defineModel<string[]>({ default: () => [] });
const { groups, loaded, load } = useCustomerGroups();
function toggle(id: string) {
model.value = model.value.includes(id)
? model.value.filter((x) => x !== id)
: [...model.value, id];
}
onMounted(() => {
void load().catch(() => undefined);
});
</script>
<template>
<div class="picker">
<button
v-for="g in groups"
:key="g.id"
type="button"
class="opt"
:class="{ on: model.includes(g.id) }"
:style="
model.includes(g.id)
? { color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg, borderColor: groupSwatch(g.color).fg }
: undefined
"
@click="toggle(g.id)"
>
{{ g.name }}
</button>
<span v-if="loaded && !groups.length" class="hint">
هنوز گروهی تعریف نشده؛ از صفحهٔ مخاطبین «مدیریت گروه‌ها» را بزنید.
</span>
</div>
</template>
<style scoped>
.picker {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.opt {
border: 1px solid #e2e8f0;
background: #fff;
color: #475569;
border-radius: 999px;
padding: 0.25rem 0.75rem;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.opt.on {
font-weight: 600;
}
.hint {
color: #94a3b8;
font-size: 0.82rem;
}
</style>
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import axios from 'axios';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import {
CUSTOMER_GROUP_COLOR_LABELS,
groupSwatch,
useCustomerGroups,
} from '@/lib/customer-groups';
import { UiButton, UiInput, UiModal } from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: []; changed: [] }>();
const { groups, load } = useCustomerGroups();
const newName = ref('');
const newColor = ref<CustomerGroupColor>('teal');
const editingId = ref<string | null>(null);
const editName = ref('');
const busy = ref(false);
const error = ref('');
function errorText(e: unknown, fallback: string) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
if (Array.isArray(msg)) return msg[0] ?? fallback;
if (msg) return msg;
}
return fallback;
}
async function run(task: () => Promise<unknown>, fallback: string) {
busy.value = true;
error.value = '';
try {
await task();
await load(true);
emit('changed');
return true;
} catch (e) {
error.value = errorText(e, fallback);
return false;
} finally {
busy.value = false;
}
}
async function create() {
const name = newName.value.trim();
if (!name) return;
const ok = await run(
() => api.post('/customer-groups', { name, color: newColor.value }),
'ساخت گروه ناموفق بود.',
);
if (ok) newName.value = '';
}
function startEdit(g: CustomerGroupDto) {
editingId.value = g.id;
editName.value = g.name;
}
async function saveName(g: CustomerGroupDto) {
const name = editName.value.trim();
if (!name || name === g.name) {
editingId.value = null;
return;
}
const ok = await run(
() => api.patch(`/customer-groups/${g.id}`, { name }),
'تغییر نام ناموفق بود.',
);
if (ok) editingId.value = null;
}
async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
if (g.color === color) return;
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
}
async function remove(g: CustomerGroupDto) {
const used = g.personCount + g.companyCount;
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته می‌شود (خود مخاطب‌ها حذف نمی‌شوند).` : '';
if (!confirm(`گروه «${g.name}» حذف شود؟${note}`)) return;
await run(() => api.delete(`/customer-groups/${g.id}`), 'حذف گروه ناموفق بود.');
}
watch(
() => props.open,
(open) => {
if (!open) return;
error.value = '';
editingId.value = null;
void load(true).catch(() => {
error.value = 'بارگذاری گروه‌ها ناموفق بود.';
});
},
{ immediate: true },
);
</script>
<template>
<UiModal :open="open" title="گروه‌های مشتری" @close="emit('close')">
<div class="manager">
<p class="lead">
هر شخص یا شرکت می‌تواند چند گروه داشته باشد. گروه‌هایی که از ته‌حساب می‌آیند با برچسب
«ته‌حساب» مشخص شده‌اند.
</p>
<form class="new-row" @submit.prevent="create">
<UiInput v-model="newName" placeholder="نام گروه جدید" :maxlength="80" :disabled="busy" />
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch"
:class="{ on: newColor === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
@click="newColor = c"
/>
</div>
<UiButton type="submit" variant="primary" size="sm" :disabled="busy || !newName.trim()">
<UiIcon name="plus" :size="15" />
افزودن
</UiButton>
</form>
<p v-if="error" class="error">{{ error }}</p>
<ul v-if="groups.length" class="list">
<li v-for="g in groups" :key="g.id" class="row">
<div class="name-cell">
<form v-if="editingId === g.id" class="edit" @submit.prevent="saveName(g)">
<UiInput v-model="editName" :maxlength="80" :disabled="busy" />
<UiButton type="submit" variant="primary" size="sm" :disabled="busy">ذخیره</UiButton>
<UiButton type="button" variant="ghost" size="sm" @click="editingId = null">انصراف</UiButton>
</form>
<template v-else>
<span
class="chip"
:style="{ color: groupSwatch(g.color).fg, background: groupSwatch(g.color).bg }"
>
{{ g.name }}
</span>
<span v-if="g.fromTahesab" class="tag">ته‌حساب</span>
<span class="count">
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
</span>
</template>
</div>
<div v-if="editingId !== g.id" class="row-actions">
<div class="swatches">
<button
v-for="c in CUSTOMER_GROUP_COLORS"
:key="c"
type="button"
class="swatch small"
:class="{ on: g.color === c }"
:style="{ background: groupSwatch(c).fg }"
:title="CUSTOMER_GROUP_COLOR_LABELS[c]"
:disabled="busy"
@click="setColor(g, c)"
/>
</div>
<UiButton variant="ghost" size="sm" :disabled="busy" @click="startEdit(g)">تغییر نام</UiButton>
<UiButton variant="ghost" size="sm" :disabled="busy" @click="remove(g)">
<UiIcon name="trash" :size="14" />
</UiButton>
</div>
</li>
</ul>
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
</div>
</UiModal>
</template>
<style scoped>
.manager {
display: grid;
gap: 0.85rem;
min-width: min(34rem, 100%);
}
.lead,
.muted {
margin: 0;
color: #64748b;
font-size: 0.85rem;
}
.error {
margin: 0;
color: #dc2626;
font-size: 0.85rem;
}
.new-row,
.edit {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.new-row > :first-child,
.edit > :first-child {
flex: 1 1 12rem;
}
.swatches {
display: inline-flex;
gap: 0.3rem;
}
.swatch {
width: 1.3rem;
height: 1.3rem;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 0 1px #e2e8f0;
cursor: pointer;
padding: 0;
}
.swatch.small {
width: 1rem;
height: 1rem;
}
.swatch.on {
box-shadow: 0 0 0 2px #0f172a;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
max-height: 55vh;
overflow-y: auto;
}
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
}
.name-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
min-width: 0;
flex: 1 1 14rem;
}
.chip {
padding: 0.15rem 0.6rem;
border-radius: 999px;
font-weight: 600;
font-size: 0.85rem;
}
.tag {
font-size: 0.7rem;
color: #0369a1;
background: #e0f2fe;
border-radius: 6px;
padding: 0.05rem 0.4rem;
}
.count {
color: #94a3b8;
font-size: 0.78rem;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
</style>
+53
View File
@@ -0,0 +1,53 @@
import { ref } from 'vue';
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
import { api } from '@/lib/api';
export const CUSTOMER_GROUP_COLOR_LABELS: Record<CustomerGroupColor, string> = {
teal: 'سبزآبی',
sky: 'آبی',
violet: 'بنفش',
rose: 'قرمز',
amber: 'نارنجی',
pink: 'صورتی',
slate: 'خاکستری',
};
/** رنگ متن/پس‌زمینهٔ چیپ گروه (پالت داده، نه رنگ‌های قالب) */
export const CUSTOMER_GROUP_SWATCH: Record<CustomerGroupColor, { fg: string; bg: string }> = {
teal: { fg: '#0f766e', bg: '#ccfbf1' },
sky: { fg: '#0369a1', bg: '#e0f2fe' },
violet: { fg: '#6d28d9', bg: '#ede9fe' },
rose: { fg: '#be123c', bg: '#ffe4e6' },
amber: { fg: '#b45309', bg: '#fef3c7' },
pink: { fg: '#be185d', bg: '#fce7f3' },
slate: { fg: '#334155', bg: '#e2e8f0' },
};
export function groupSwatch(color: string | null | undefined) {
return CUSTOMER_GROUP_SWATCH[(color ?? 'teal') as CustomerGroupColor] ?? CUSTOMER_GROUP_SWATCH.teal;
}
const groups = ref<CustomerGroupDto[]>([]);
const loaded = ref(false);
let pending: Promise<CustomerGroupDto[]> | null = null;
/** فهرست گروه‌های مشتری محیط (مشترک بین صفحه‌ها) */
export function useCustomerGroups() {
async function load(force = false): Promise<CustomerGroupDto[]> {
if (loaded.value && !force) return groups.value;
if (pending && !force) return pending;
pending = api
.get<CustomerGroupDto[]>('/customer-groups')
.then(({ data }) => {
groups.value = data;
loaded.value = true;
return data;
})
.finally(() => {
pending = null;
});
return pending;
}
return { groups, loaded, load };
}
+6 -2
View File
@@ -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="تاریخ ثبت شرکت">
+86 -3
View File
@@ -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;
+24
View File
@@ -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
+7 -1
View File
@@ -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">
+167
View File
@@ -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);
}