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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user