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>