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
+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;