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