feat(customers): add optional group assignees with OR visibility for people, companies, and birthdays
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,29 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { CustomerGroupColor, CustomerGroupDto } from '@mento/shared';
|
||||
import type { CustomerGroupColor, CustomerGroupDto, OrgUserDto } from '@mento/shared';
|
||||
import { CUSTOMER_GROUP_COLORS } from '@mento/shared';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { normalizeSearchText } from '@/lib/search';
|
||||
import {
|
||||
CUSTOMER_GROUP_COLOR_LABELS,
|
||||
groupSwatch,
|
||||
useCustomerGroups,
|
||||
} from '@/lib/customer-groups';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { UiButton, UiInput, UiModal } from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import OrgUserPersonPick from '@/components/OrgUserPersonPick.vue';
|
||||
|
||||
const props = defineProps<{ open: boolean }>();
|
||||
const emit = defineEmits<{ close: []; changed: [] }>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const canManage = computed(
|
||||
() => auth.user?.role === 'owner' || auth.user?.role === 'admin',
|
||||
);
|
||||
|
||||
const { groups, load } = useCustomerGroups();
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
const newName = ref('');
|
||||
const newColor = ref<CustomerGroupColor>('teal');
|
||||
const newAssigneeIds = ref<string[]>([]);
|
||||
const editingId = ref<string | null>(null);
|
||||
const editName = ref('');
|
||||
const assigneeDraft = ref<Record<string, string[]>>({});
|
||||
const listQuery = ref('');
|
||||
const busy = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
const q = normalizeSearchText(listQuery.value);
|
||||
if (!q) return groups.value;
|
||||
return groups.value.filter((g) => {
|
||||
const assigneeNames = (g.assignees ?? []).map((a) => a.fullName).join(' ');
|
||||
return (
|
||||
normalizeSearchText(g.name).includes(q) ||
|
||||
normalizeSearchText(g.parentName ?? '').includes(q) ||
|
||||
normalizeSearchText(assigneeNames).includes(q)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function errorText(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] } | undefined)?.message;
|
||||
@@ -33,12 +58,30 @@ function errorText(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const { data } = await api.get<OrgUserDto[]>('/users');
|
||||
users.value = data.filter((u) => u.isActive !== false);
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function syncAssigneeDraft() {
|
||||
const next: Record<string, string[]> = {};
|
||||
for (const g of groups.value) {
|
||||
next[g.id] = (g.assignees ?? []).map((a) => a.id);
|
||||
}
|
||||
assigneeDraft.value = next;
|
||||
}
|
||||
|
||||
async function run(task: () => Promise<unknown>, fallback: string) {
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await task();
|
||||
await load(true);
|
||||
syncAssigneeDraft();
|
||||
emit('changed');
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -53,10 +96,18 @@ async function create() {
|
||||
const name = newName.value.trim();
|
||||
if (!name) return;
|
||||
const ok = await run(
|
||||
() => api.post('/customer-groups', { name, color: newColor.value }),
|
||||
() =>
|
||||
api.post('/customer-groups', {
|
||||
name,
|
||||
color: newColor.value,
|
||||
assigneeIds: newAssigneeIds.value,
|
||||
}),
|
||||
'ساخت گروه ناموفق بود.',
|
||||
);
|
||||
if (ok) newName.value = '';
|
||||
if (ok) {
|
||||
newName.value = '';
|
||||
newAssigneeIds.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(g: CustomerGroupDto) {
|
||||
@@ -82,6 +133,24 @@ async function setColor(g: CustomerGroupDto, color: CustomerGroupColor) {
|
||||
await run(() => api.patch(`/customer-groups/${g.id}`, { color }), 'تغییر رنگ ناموفق بود.');
|
||||
}
|
||||
|
||||
async function onAssigneesChange(g: CustomerGroupDto, ids: string[]) {
|
||||
assigneeDraft.value = { ...assigneeDraft.value, [g.id]: ids };
|
||||
const prev = (g.assignees ?? []).map((a) => a.id).sort().join(',');
|
||||
const next = [...ids].sort().join(',');
|
||||
if (prev === next) return;
|
||||
await run(
|
||||
() => api.patch(`/customer-groups/${g.id}`, { assigneeIds: ids }),
|
||||
'ذخیره مسئولان گروه ناموفق بود.',
|
||||
);
|
||||
}
|
||||
|
||||
function assigneeLabel(g: CustomerGroupDto) {
|
||||
const names = (g.assignees ?? []).map((a) => a.fullName).filter(Boolean);
|
||||
if (!names.length) return 'بدون مسئول';
|
||||
if (names.length === 1) return names[0]!;
|
||||
return `${names[0]} و ${toFaDigits(names.length - 1)} نفر دیگر`;
|
||||
}
|
||||
|
||||
async function remove(g: CustomerGroupDto) {
|
||||
const used = g.personCount + g.companyCount;
|
||||
const note = used ? ` این گروه از ${toFaDigits(used)} مخاطب برداشته میشود (خود مخاطبها حذف نمیشوند).` : '';
|
||||
@@ -95,9 +164,12 @@ watch(
|
||||
if (!open) return;
|
||||
error.value = '';
|
||||
editingId.value = null;
|
||||
void load(true).catch(() => {
|
||||
error.value = 'بارگذاری گروهها ناموفق بود.';
|
||||
});
|
||||
listQuery.value = '';
|
||||
void Promise.all([load(true), loadUsers()])
|
||||
.then(() => syncAssigneeDraft())
|
||||
.catch(() => {
|
||||
error.value = 'بارگذاری گروهها ناموفق بود.';
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
@@ -107,34 +179,60 @@ watch(
|
||||
<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"
|
||||
<form v-if="canManage" class="create-panel" @submit.prevent="create">
|
||||
<header class="create-head">
|
||||
<strong>گروه جدید</strong>
|
||||
<span>نام، رنگ و مسئولان را مشخص کنید و افزودن بزنید.</span>
|
||||
</header>
|
||||
<div class="new-row">
|
||||
<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>
|
||||
</div>
|
||||
<div v-if="users.length" class="assignee-field create-assignees">
|
||||
<span class="assignees-label">مسئولان (اختیاری)</span>
|
||||
<OrgUserPersonPick
|
||||
v-model="newAssigneeIds"
|
||||
:users="users"
|
||||
:disabled="busy"
|
||||
empty-label="انتخاب مسئول"
|
||||
/>
|
||||
</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 v-if="canManage || groups.length" class="list-head">
|
||||
<h3 class="list-title">گروههای موجود</h3>
|
||||
<label v-if="groups.length" class="list-search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
v-model="listQuery"
|
||||
type="search"
|
||||
placeholder="جستجوی گروه یا مسئول…"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<ul v-if="filteredGroups.length" class="list">
|
||||
<li v-for="g in filteredGroups" :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" />
|
||||
@@ -152,9 +250,10 @@ watch(
|
||||
<span class="count">
|
||||
{{ toFaDigits(g.personCount) }} شخص · {{ toFaDigits(g.companyCount) }} شرکت
|
||||
</span>
|
||||
<span v-if="!canManage" class="assignee-summary">{{ assigneeLabel(g) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="editingId !== g.id" class="row-actions">
|
||||
<div v-if="editingId !== g.id && canManage" class="row-actions">
|
||||
<div class="swatches">
|
||||
<button
|
||||
v-for="c in CUSTOMER_GROUP_COLORS"
|
||||
@@ -173,8 +272,20 @@ watch(
|
||||
<UiIcon name="trash" :size="14" />
|
||||
</UiButton>
|
||||
</div>
|
||||
<div v-if="canManage && users.length" class="assignee-field">
|
||||
<span class="assignees-label">مسئولان</span>
|
||||
<OrgUserPersonPick
|
||||
:model-value="assigneeDraft[g.id] ?? []"
|
||||
:users="users"
|
||||
:disabled="busy"
|
||||
empty-label="انتخاب مسئول"
|
||||
@update:model-value="(ids) => (assigneeDraft[g.id] = ids)"
|
||||
@change="(ids) => onAssigneesChange(g, ids)"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else-if="groups.length && listQuery.trim()" class="muted">گروهی با این جستجو پیدا نشد.</p>
|
||||
<p v-else class="muted">هنوز گروهی ساخته نشده است.</p>
|
||||
</div>
|
||||
</UiModal>
|
||||
@@ -200,6 +311,78 @@ watch(
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #0d9488) 28%, #e2e8f0);
|
||||
border-radius: 12px;
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--accent, #0d9488) 8%, #fff) 0%, #fff 72%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.create-head {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.create-head strong {
|
||||
color: var(--accent-deep, #0f766e);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.create-head span {
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.list-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.list-title {
|
||||
margin: 0;
|
||||
color: #475569;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.list-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: min(16rem, 100%);
|
||||
flex: 1 1 12rem;
|
||||
max-width: 18rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.65rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.list-search:focus-within {
|
||||
border-color: #5eead4;
|
||||
}
|
||||
|
||||
.list-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.new-row,
|
||||
.edit {
|
||||
display: flex;
|
||||
@@ -282,14 +465,38 @@ watch(
|
||||
padding: 0.05rem 0.4rem;
|
||||
}
|
||||
|
||||
.count {
|
||||
.count,
|
||||
.assignee-summary {
|
||||
color: #94a3b8;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.assignee-summary {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.assignee-field {
|
||||
flex: 1 1 100%;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding-top: 0.35rem;
|
||||
border-top: 1px dashed #e2e8f0;
|
||||
}
|
||||
|
||||
.create-assignees {
|
||||
border-top: 1px dashed color-mix(in srgb, var(--accent, #0d9488) 22%, #e2e8f0);
|
||||
padding-top: 0.65rem;
|
||||
}
|
||||
|
||||
.assignees-label {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import type { OrgUserDto } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { normalizeSearchText } from '@/lib/search';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
users: Array<Pick<OrgUserDto, 'id' | 'fullName' | 'orgRoleName' | 'hasAvatar'>>;
|
||||
disabled?: boolean;
|
||||
emptyLabel?: string;
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
emptyLabel: 'انتخاب مسئول',
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [ids: string[]];
|
||||
change: [ids: string[]];
|
||||
}>();
|
||||
|
||||
const open = ref(false);
|
||||
const query = ref('');
|
||||
const searchInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const selected = computed(() =>
|
||||
props.modelValue
|
||||
.map((id) => props.users.find((u) => u.id === id))
|
||||
.filter((u): u is NonNullable<typeof u> => Boolean(u)),
|
||||
);
|
||||
|
||||
const label = computed(() => {
|
||||
if (!selected.value.length) return props.emptyLabel;
|
||||
if (selected.value.length === 1) return selected.value[0]!.fullName;
|
||||
return `${selected.value[0]!.fullName} و ${toFaDigits(selected.value.length - 1)} نفر دیگر`;
|
||||
});
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = normalizeSearchText(query.value);
|
||||
if (!q) return props.users;
|
||||
return props.users.filter(
|
||||
(u) =>
|
||||
normalizeSearchText(u.fullName).includes(q) ||
|
||||
normalizeSearchText(u.orgRoleName ?? '').includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
function toggleMenu() {
|
||||
if (props.disabled) return;
|
||||
open.value = !open.value;
|
||||
if (open.value) {
|
||||
void nextTick(() => searchInput.value?.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
open.value = false;
|
||||
}
|
||||
|
||||
function toggle(userId: string) {
|
||||
if (props.disabled) return;
|
||||
const set = new Set(props.modelValue);
|
||||
if (set.has(userId)) set.delete(userId);
|
||||
else set.add(userId);
|
||||
const next = [...set];
|
||||
emit('update:modelValue', next);
|
||||
emit('change', next);
|
||||
}
|
||||
|
||||
watch(open, (isOpen) => {
|
||||
if (!isOpen) query.value = '';
|
||||
});
|
||||
|
||||
defineExpose({ close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wrap" @click.stop>
|
||||
<button
|
||||
class="picker person-pick"
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<div class="assignee-avatars">
|
||||
<UserAvatar
|
||||
v-for="user in selected.slice(0, 3)"
|
||||
:key="user.id"
|
||||
:name="user.fullName"
|
||||
:user-id="user.id"
|
||||
:has-avatar="user.hasAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<UserAvatar v-if="!selected.length" name="مسئول" :size="28" />
|
||||
</div>
|
||||
<span :class="{ ghost: !selected.length }">{{ label }}</span>
|
||||
<UiIcon name="chevron" :size="16" />
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="menu person-menu multi-menu" @click.stop>
|
||||
<label class="person-search">
|
||||
<UiIcon name="search" :size="15" />
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="query"
|
||||
type="search"
|
||||
placeholder="جستجوی نام یا نقش…"
|
||||
@keydown.esc="close"
|
||||
/>
|
||||
</label>
|
||||
<ul class="person-list">
|
||||
<li v-for="user in filtered" :key="user.id">
|
||||
<button
|
||||
type="button"
|
||||
class="multi-option"
|
||||
:class="{ on: modelValue.includes(user.id) }"
|
||||
@click="toggle(user.id)"
|
||||
>
|
||||
<span class="check" :class="{ on: modelValue.includes(user.id) }">
|
||||
<UiIcon v-if="modelValue.includes(user.id)" name="check-plain" :size="12" />
|
||||
</span>
|
||||
<UserAvatar
|
||||
:name="user.fullName"
|
||||
:user-id="user.id"
|
||||
:has-avatar="user.hasAvatar"
|
||||
:size="28"
|
||||
/>
|
||||
<span class="role-name">{{ user.fullName }}</span>
|
||||
<small v-if="user.orgRoleName" class="role-count">{{ user.orgRoleName }}</small>
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="!filtered.length" class="menu-empty">موردی پیدا نشد.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.picker {
|
||||
width: 100%;
|
||||
min-height: var(--control-h, 36px);
|
||||
height: var(--control-h, 36px);
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius, 10px);
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.picker:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.picker .ghost {
|
||||
color: #94a3b8;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.picker :deep(.ico:last-child) {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.assignee-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.assignee-avatars :deep(.avatar),
|
||||
.assignee-avatars > * {
|
||||
margin-inline-end: -0.35rem;
|
||||
box-shadow: 0 0 0 2px #fff;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: calc(100% + 0.25rem);
|
||||
z-index: 40;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
border-radius: var(--radius, 10px);
|
||||
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.14);
|
||||
}
|
||||
|
||||
.person-menu {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 0.45rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.person-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
height: 2.4rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius, 10px);
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.person-search:focus-within {
|
||||
border-color: #5eead4;
|
||||
}
|
||||
|
||||
.person-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.person-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: none;
|
||||
padding: 0.55rem 0.5rem;
|
||||
border-radius: var(--radius, 10px);
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.multi-menu .multi-option.on {
|
||||
background: #f0fdfa;
|
||||
}
|
||||
|
||||
.multi-menu .check {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1.5px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.multi-menu .check.on {
|
||||
background: #14b8a6;
|
||||
border-color: #14b8a6;
|
||||
}
|
||||
|
||||
.multi-menu .role-name {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.multi-menu .role-count {
|
||||
margin-inline-start: auto;
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.menu-empty {
|
||||
padding: 0.6rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
</style>
|
||||
@@ -122,17 +122,27 @@ function apiMessage(e: unknown, fallback: string) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function send() {
|
||||
async function send(force = false) {
|
||||
if (!props.endpoint || busy.value || done.value) return;
|
||||
busy.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(props.endpoint, { text: text.value });
|
||||
await api.post(props.endpoint, { text: text.value, ...(force ? { force: true } : {}) });
|
||||
done.value = 'پیامک ارسال شد.';
|
||||
emit('sent');
|
||||
void loadHistory();
|
||||
} catch (e) {
|
||||
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
|
||||
if (axios.isAxiosError(e) && e.response?.status === 409) {
|
||||
const msg = apiMessage(e, 'قبلاً پیامک تبریک فرستاده شده است. دوباره بفرستیم؟');
|
||||
if (confirm(msg)) {
|
||||
busy.value = false;
|
||||
await send(true);
|
||||
return;
|
||||
}
|
||||
error.value = msg;
|
||||
} else {
|
||||
error.value = apiMessage(e, 'ارسال پیامک ناموفق بود.');
|
||||
}
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { hasSectionPermission } from '@/lib/section-permissions';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
||||
@@ -25,9 +27,13 @@ type BirthdayMonth = {
|
||||
items: BirthdayItem[];
|
||||
};
|
||||
|
||||
const auth = useAuthStore();
|
||||
const data = ref<BirthdayMonth | null>(null);
|
||||
const listOpen = ref(false);
|
||||
const smsTarget = ref<BirthdayItem | null>(null);
|
||||
const canSeeCustomers = computed(() =>
|
||||
hasSectionPermission(auth.user?.role, auth.user?.permissions, 'customers'),
|
||||
);
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<number, BirthdayItem[]>();
|
||||
@@ -44,6 +50,10 @@ function openSms(person: BirthdayItem) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!canSeeCustomers.value) {
|
||||
data.value = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data: body } = await api.get<BirthdayMonth>('/people/birthdays');
|
||||
data.value = body.total > 0 ? body : null;
|
||||
|
||||
Reference in New Issue
Block a user