Files
CRM_Montzar/apps/web/src/views/LoyaltyClubView.vue
T

1439 lines
49 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useEscClose } from '@/lib/modal-stack';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { formatRelativeContact, toFaDigits } from '@/lib/jalali';
import type {
CreateLoyaltyRewardRequest,
CreateLoyaltyTierRequest,
LoyaltyLedgerDto,
LoyaltyMemberDto,
LoyaltyMessageResult,
LoyaltyRewardDto,
LoyaltySettingsDto,
LoyaltyStatsDto,
LoyaltyTierDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
type MobileTab = 'levels' | 'members' | 'rewards';
type Modal =
| 'settings'
| 'tier'
| 'adjust'
| 'bulk'
| 'message'
| 'ledger'
| 'reward'
| 'redeem'
| null;
type IconName =
| 'users'
| 'star'
| 'gift'
| 'trend'
| 'crown'
| 'medal'
| 'trophy';
const auth = useAuthStore();
const canManage = computed(() => canManageSettings(auth.user?.role));
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const q = ref('');
const tierFilter = ref('');
const mobileTab = ref<MobileTab>('levels');
const modal = ref<Modal>(null);
const menuId = ref<string | null>(null);
const selected = ref<string[]>([]);
const tierSelect = ref<HTMLSelectElement | null>(null);
const stats = ref<LoyaltyStatsDto | null>(null);
const settings = ref<LoyaltySettingsDto>({
enabled: true,
welcomePoints: 0,
notifyOnAdjust: false,
});
const tiers = ref<LoyaltyTierDto[]>([]);
const members = ref<LoyaltyMemberDto[]>([]);
const rewards = ref<LoyaltyRewardDto[]>([]);
const ledger = ref<LoyaltyLedgerDto[]>([]);
const ledgerName = ref('');
const tierName = ref('');
const tierMin = ref(0);
const tierColor = ref('#d4a017');
const editingTierId = ref<string | null>(null);
const adjustPersonId = ref('');
const adjustDelta = ref(100);
const adjustReason = ref('');
const adjustSign = ref<1 | -1>(1);
const bulkDelta = ref(100);
const bulkReason = ref('');
const bulkSign = ref<1 | -1>(1);
const messageText = ref('');
const rewardName = ref('');
const rewardCost = ref(500);
const rewardDesc = ref('');
const rewardColor = ref('#14b8a6');
const editingRewardId = ref<string | null>(null);
const redeemRewardId = ref('');
const redeemPersonId = ref('');
const isMobile = ref(false);
let mq: MediaQueryList | null = null;
const visibleMembers = computed(() => members.value);
const previewMembers = computed(() => visibleMembers.value.slice(0, 5));
const allSelected = computed(
() =>
visibleMembers.value.length > 0 &&
selected.value.length === visibleMembers.value.length,
);
const selectedCount = computed(() => selected.value.length);
const kpis = computed(() => {
const s = stats.value;
return [
{
key: 'members',
tone: 'teal',
icon: 'users' as IconName,
value: s?.memberCount ?? 0,
label: 'عضو باشگاه',
pct: s?.memberDeltaPct ?? null,
mobile: true,
},
{
key: 'points',
tone: 'purple',
icon: 'star' as IconName,
value: s?.pointsIssued ?? 0,
label: isMobile.value ? 'امتیاز صادر شده این ماه' : 'امتیاز صادر شده',
pct: s?.pointsIssuedDeltaPct ?? null,
mobile: true,
},
{
key: 'rewards',
tone: 'gold',
icon: 'gift' as IconName,
value: s?.rewardsUsed ?? 0,
label: isMobile.value ? 'پاداش استفاده شده این ماه' : 'پاداش استفاده شده',
pct: s?.rewardsUsedDeltaPct ?? null,
mobile: true,
},
{
key: 'retention',
tone: 'blue',
icon: 'trend' as IconName,
value: s?.retentionRate ?? 0,
suffix: '٪',
label: 'نرخ استمرار اعضا',
pct: s?.retentionDeltaPct ?? null,
mobile: false,
},
];
});
const mobileKpis = computed(() => kpis.value.filter((k) => k.mobile));
const activeRewards = computed(() => rewards.value.filter((r) => r.isActive));
function faNum(n: number) {
return toFaDigits(Math.round(n).toLocaleString('en-US'));
}
function initials(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length >= 2) return (parts[0]![0] + parts[1]![0]).slice(0, 2);
return name.slice(0, 2) || '؟';
}
function tierTone(name: string) {
if (name.includes('طلا')) return 'gold';
if (name.includes('نقره')) return 'silver';
if (name.includes('برنز')) return 'bronze';
return 'custom';
}
function tierIcon(name: string): IconName {
const tone = tierTone(name);
if (tone === 'gold') return 'crown';
if (tone === 'silver') return 'star';
if (tone === 'bronze') return 'medal';
return 'trophy';
}
function trendText(pct: number | null) {
if (pct == null) return '';
const abs = faNum(Math.abs(pct));
const dir = pct >= 0 ? '+' : '−';
return `${dir}${abs}٪ نسبت به ماه قبل`;
}
function onMq() {
isMobile.value = !!mq?.matches;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [sRes, tRes, mRes, rRes, setRes] = await Promise.all([
api.get<LoyaltyStatsDto>('/loyalty/stats'),
api.get<LoyaltyTierDto[]>('/loyalty/tiers'),
api.get<LoyaltyMemberDto[]>('/loyalty/members', {
params: {
q: q.value.trim() || undefined,
tierId: tierFilter.value || undefined,
},
}),
api.get<LoyaltyRewardDto[]>('/loyalty/rewards'),
api.get<LoyaltySettingsDto>('/loyalty/settings'),
]);
stats.value = sRes.data;
tiers.value = tRes.data;
members.value = mRes.data;
rewards.value = rRes.data;
settings.value = setRes.data;
selected.value = selected.value.filter((id) =>
mRes.data.some((m) => m.id === id),
);
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری باشگاه ناموفق بود.';
}
} finally {
loading.value = false;
}
}
function flash(msg: string) {
ok.value = msg;
error.value = '';
window.setTimeout(() => {
if (ok.value === msg) ok.value = '';
}, 3200);
}
function fail(msg: string) {
error.value = msg;
ok.value = '';
}
function closeModal() {
modal.value = null;
}
useEscClose(() => modal.value, closeModal);
function openSettings() {
modal.value = 'settings';
}
function openNewTier() {
if (!canManage.value) return;
editingTierId.value = null;
tierName.value = '';
tierMin.value = 0;
tierColor.value = '#d4a017';
modal.value = 'tier';
}
function startEditTier(t: LoyaltyTierDto) {
if (!canManage.value) return;
editingTierId.value = t.id;
tierName.value = t.name;
tierMin.value = t.minPoints;
tierColor.value = t.color || '#d4a017';
modal.value = 'tier';
}
async function saveTier() {
if (!canManage.value || tierName.value.trim().length < 2) return;
saving.value = true;
try {
const body: CreateLoyaltyTierRequest = {
name: tierName.value.trim(),
minPoints: Math.max(0, Math.floor(tierMin.value)),
color: tierColor.value.trim() || null,
};
if (editingTierId.value) {
await api.patch(`/loyalty/tiers/${editingTierId.value}`, body);
flash('سطح به‌روز شد.');
} else {
await api.post('/loyalty/tiers', body);
flash('سطح ساخته شد.');
}
closeModal();
await load();
} catch {
fail('ذخیره سطح ناموفق بود.');
} finally {
saving.value = false;
}
}
async function removeTier(id: string) {
if (!canManage.value || !confirm('سطح حذف شود؟')) return;
try {
await api.delete(`/loyalty/tiers/${id}`);
closeModal();
await load();
} catch {
fail('حذف سطح ناموفق بود.');
}
}
function openAdjust(member: LoyaltyMemberDto, sign: 1 | -1) {
adjustPersonId.value = member.id;
adjustSign.value = sign;
adjustDelta.value = 100;
adjustReason.value = '';
modal.value = 'adjust';
menuId.value = null;
}
async function submitAdjust() {
if (!adjustPersonId.value || !adjustDelta.value) return;
saving.value = true;
try {
await api.post(`/loyalty/members/${adjustPersonId.value}/adjust`, {
delta: adjustSign.value * Math.abs(Math.trunc(adjustDelta.value)),
reason: adjustReason.value.trim() || undefined,
});
flash('امتیاز به‌روز شد.');
closeModal();
await load();
} catch {
fail('تغییر امتیاز ناموفق بود.');
} finally {
saving.value = false;
}
}
function openTierFilter() {
const el = tierSelect.value;
if (!el) return;
el.focus();
const picker = (el as HTMLSelectElement & { showPicker?: () => void }).showPicker;
if (typeof picker === 'function') picker.call(el);
else el.click();
}
function toggleAll() {
if (allSelected.value) selected.value = [];
else selected.value = visibleMembers.value.map((m) => m.id);
}
function toggleOne(id: string) {
selected.value = selected.value.includes(id)
? selected.value.filter((x) => x !== id)
: [...selected.value, id];
}
function openBulk() {
if (!selected.value.length) {
fail('ابتدا حداقل یک عضو را انتخاب کنید.');
return;
}
bulkSign.value = 1;
bulkDelta.value = 100;
bulkReason.value = '';
modal.value = 'bulk';
}
async function submitBulk() {
if (!selected.value.length || !bulkDelta.value) return;
saving.value = true;
try {
const { data } = await api.post<{ updated: number }>(
'/loyalty/members/bulk-adjust',
{
personIds: selected.value,
delta: bulkSign.value * Math.abs(Math.trunc(bulkDelta.value)),
reason: bulkReason.value.trim() || undefined,
},
);
flash(`امتیاز ${faNum(data.updated)} عضو به‌روز شد.`);
closeModal();
await load();
} catch {
fail('اعمال امتیاز گروهی ناموفق بود.');
} finally {
saving.value = false;
}
}
function openMessage() {
if (!selected.value.length) {
fail('برای ارسال پیام حداقل یک عضو را انتخاب کنید.');
return;
}
messageText.value = '';
modal.value = 'message';
}
async function submitMessage() {
if (!selected.value.length || messageText.value.trim().length < 2) return;
saving.value = true;
try {
const { data } = await api.post<LoyaltyMessageResult>(
'/loyalty/members/message',
{ personIds: selected.value, text: messageText.value.trim() },
);
flash(
`پیام: ${faNum(data.sent)} ارسال، ${faNum(data.skipped)} بدون موبایل، ${faNum(data.failed)} ناموفق.`,
);
closeModal();
} catch {
fail('ارسال پیام ناموفق بود.');
} finally {
saving.value = false;
}
}
async function exportExcel() {
saving.value = true;
try {
const { data } = await api.get<Blob>('/loyalty/members/export', {
params: {
q: q.value.trim() || undefined,
tierId: tierFilter.value || undefined,
},
responseType: 'blob',
});
const url = URL.createObjectURL(data);
const a = document.createElement('a');
a.href = url;
a.download = `${brand.filePrefix}-loyalty-members.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch {
fail('خروجی اکسل ناموفق بود.');
} finally {
saving.value = false;
}
}
async function saveSettings() {
if (!canManage.value) return;
saving.value = true;
try {
const { data } = await api.patch<LoyaltySettingsDto>('/loyalty/settings', {
enabled: settings.value.enabled,
welcomePoints: Math.max(0, Math.floor(settings.value.welcomePoints)),
notifyOnAdjust: settings.value.notifyOnAdjust,
});
settings.value = data;
flash('تنظیمات باشگاه ذخیره شد.');
closeModal();
} catch {
fail('ذخیره تنظیمات ناموفق بود.');
} finally {
saving.value = false;
}
}
async function openLedger(member: LoyaltyMemberDto) {
menuId.value = null;
ledgerName.value = member.fullName;
ledger.value = [];
modal.value = 'ledger';
try {
const { data } = await api.get<LoyaltyLedgerDto[]>(
`/loyalty/members/${member.id}/ledger`,
);
ledger.value = data;
} catch {
fail('بارگذاری تاریخچه ناموفق بود.');
}
}
function openNewReward() {
if (!canManage.value) return;
editingRewardId.value = null;
rewardName.value = '';
rewardCost.value = 500;
rewardDesc.value = '';
rewardColor.value = '#14b8a6';
modal.value = 'reward';
}
function startEditReward(r: LoyaltyRewardDto) {
if (!canManage.value) return;
editingRewardId.value = r.id;
rewardName.value = r.name;
rewardCost.value = r.pointsCost;
rewardDesc.value = r.description ?? '';
rewardColor.value = r.color || '#14b8a6';
modal.value = 'reward';
}
async function saveReward() {
if (!canManage.value || rewardName.value.trim().length < 2) return;
saving.value = true;
try {
const body: CreateLoyaltyRewardRequest = {
name: rewardName.value.trim(),
pointsCost: Math.max(1, Math.floor(rewardCost.value)),
description: rewardDesc.value.trim() || null,
color: rewardColor.value.trim() || null,
};
if (editingRewardId.value) {
await api.patch(`/loyalty/rewards/${editingRewardId.value}`, body);
flash('پاداش به‌روز شد.');
} else {
await api.post('/loyalty/rewards', body);
flash('پاداش ساخته شد.');
}
closeModal();
await load();
} catch {
fail('ذخیره پاداش ناموفق بود.');
} finally {
saving.value = false;
}
}
async function removeReward(id: string) {
if (!canManage.value || !confirm('پاداش حذف یا غیرفعال شود؟')) return;
try {
await api.delete(`/loyalty/rewards/${id}`);
await load();
} catch {
fail('حذف پاداش ناموفق بود.');
}
}
function openRedeem(reward?: LoyaltyRewardDto, member?: LoyaltyMemberDto) {
redeemRewardId.value = reward?.id ?? activeRewards.value[0]?.id ?? '';
redeemPersonId.value = member?.id ?? selected.value[0] ?? members.value[0]?.id ?? '';
modal.value = 'redeem';
menuId.value = null;
}
async function submitRedeem() {
if (!redeemRewardId.value || !redeemPersonId.value) return;
saving.value = true;
try {
await api.post(`/loyalty/rewards/${redeemRewardId.value}/redeem`, {
personId: redeemPersonId.value,
});
flash('پاداش ثبت شد و امتیاز کسر گردید.');
closeModal();
await load();
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 400) {
fail('امتیاز عضو برای این پاداش کافی نیست.');
} else {
fail('ثبت پاداش ناموفق بود.');
}
} finally {
saving.value = false;
}
}
function sourceLabel(source: string) {
if (source === 'automation') return 'اتوماسیون';
if (source === 'welcome') return 'خوش‌آمدگویی';
if (source === 'reward') return 'پاداش';
if (source === 'bulk') return 'گروهی';
return 'دستی';
}
function onDocClick() {
menuId.value = null;
}
let searchTimer: number | null = null;
watch(q, () => {
if (searchTimer) window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
void load();
}, 280);
});
onMounted(() => {
mq = window.matchMedia('(max-width: 860px)');
onMq();
mq.addEventListener('change', onMq);
document.addEventListener('click', onDocClick);
void load();
});
onBeforeUnmount(() => {
mq?.removeEventListener('change', onMq);
document.removeEventListener('click', onDocClick);
});
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>
باشگاه مشتریان
<span class="crown" aria-hidden="true"><UiIcon name="crown" :size="22" /></span>
</h1>
<p class="lead">وفاداری، یک رابطه طولانی‌مدت است.</p>
</div>
<button class="btn settings" type="button" @click="openSettings">
<UiIcon name="settings" :size="16" />
<span class="settings-label">تنظیمات باشگاه</span>
</button>
</header>
<p v-if="error" class="banner err">{{ error }}</p>
<p v-if="ok" class="banner ok">{{ ok }}</p>
<p v-if="!settings.enabled" class="banner warn">باشگاه فعلاً غیرفعال است؛ تنظیمات را بررسی کنید.</p>
<section class="kpis" :class="{ mobile: isMobile }">
<article
v-for="k in isMobile ? mobileKpis : kpis"
:key="k.key"
class="kpi"
:class="k.tone"
>
<span class="kpi-ico"><UiIcon :name="k.icon" :size="18" /></span>
<strong>{{ faNum(k.value) }}{{ k.suffix || '' }}</strong>
<span>{{ k.label }}</span>
<em
v-if="!isMobile && k.pct != null"
class="trend"
:class="(k.pct ?? 0) >= 0 ? 'up' : 'down'"
>
{{ trendText(k.pct) }}
</em>
</article>
</section>
<nav v-if="isMobile" class="tabs" aria-label="بخش‌های باشگاه">
<button type="button" :class="{ on: mobileTab === 'levels' }" @click="mobileTab = 'levels'">
<UiIcon name="crown" :size="15" /> سطوح
</button>
<button type="button" :class="{ on: mobileTab === 'members' }" @click="mobileTab = 'members'">
<UiIcon name="user" :size="15" /> اعضا
</button>
<button type="button" :class="{ on: mobileTab === 'rewards' }" @click="mobileTab = 'rewards'">
<UiIcon name="gift" :size="15" /> پاداش‌ها
</button>
</nav>
<section v-if="!isMobile || mobileTab === 'levels'" class="panel levels-panel">
<div class="sec-head">
<div>
<h2>سطوح باشگاه</h2>
<button v-if="canManage && !isMobile" class="text-link" type="button" @click="openNewTier">
مدیریت سطوح
</button>
</div>
<button v-if="canManage && !isMobile" class="btn primary" type="button" @click="openNewTier">
+ افزودن سطح جدید
</button>
</div>
<div class="level-grid">
<article
v-for="t in tiers"
:key="t.id"
class="level-card"
:class="tierTone(t.name)"
>
<span class="lvl-ico"><UiIcon :name="tierIcon(t.name)" :size="22" /></span>
<strong>{{ t.name }}</strong>
<span class="from">از {{ faNum(t.minPoints) }} امتیاز</span>
<span class="count">
<UiIcon name="users" :size="14" />
{{ faNum(t.memberCount ?? 0) }} عضو
</span>
<button v-if="canManage && !isMobile" class="text-link" type="button" @click="startEditTier(t)">
ویرایش
</button>
</article>
<button
v-if="canManage && !isMobile"
class="level-card add-card"
type="button"
@click="openNewTier"
>
<span class="plus">+</span>
افزودن سطح جدید
</button>
</div>
<button
v-if="canManage && isMobile"
class="btn add-level-mob"
type="button"
@click="openNewTier"
>
افزودن یا ویرایش سطح +
</button>
</section>
<section v-if="!isMobile || mobileTab === 'members' || mobileTab === 'levels'" class="panel members-panel">
<div class="sec-head">
<div>
<h2>اعضای باشگاه</h2>
<p v-if="!isMobile" class="sub">مدیریت امتیازها و مشاهده وضعیت اعضا</p>
</div>
<button
v-if="isMobile && mobileTab === 'levels'"
class="text-link"
type="button"
@click="mobileTab = 'members'"
>
مشاهده همه
</button>
</div>
<div v-if="!isMobile || mobileTab !== 'rewards'" class="toolbar">
<div class="tools-start">
<label class="search">
<UiIcon name="search" :size="16" />
<input
v-model="q"
type="search"
:placeholder="isMobile ? 'جستجو در نام، شماره تماس یا شرکت...' : 'جستجو در اعضا...'"
@keydown.enter.prevent="load"
/>
</label>
<div class="tier-filter">
<select ref="tierSelect" v-model="tierFilter" aria-label="فیلتر سطح" @change="load">
<option value="">همه سطوح</option>
<option v-for="t in tiers" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
<button
class="icon-btn"
type="button"
title="فیلتر سطح"
:class="{ on: !!tierFilter }"
@click="openTierFilter"
>
<UiIcon name="filter" :size="16" />
</button>
</div>
</div>
<div v-if="!isMobile || mobileTab === 'members'" class="tools-end">
<button class="btn ghost" type="button" :disabled="saving" @click="exportExcel">
<UiIcon name="document" :size="15" />
خروجی Excel
</button>
<button class="btn ghost" type="button" @click="openMessage">
<UiIcon name="send" :size="15" />
ارسال پیام
</button>
<button class="btn ghost" type="button" @click="openBulk">
<UiIcon name="gift" :size="15" />
اعمال امتیاز گروهی
</button>
</div>
</div>
<div class="tc-row desk">
<TableColumnsMenu table-key="loyalty.members" align="end" />
</div>
<div class="table-wrap desk">
<table v-table-cols="'loyalty.members'">
<thead>
<tr>
<th class="check">
<input type="checkbox" :checked="allSelected" @change="toggleAll" />
</th>
<th class="num-col">#</th>
<th>نام</th>
<th>شرکت</th>
<th>سطح فعلی</th>
<th>امتیاز</th>
<th>آخرین فعالیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(m, idx) in visibleMembers" :key="m.id">
<td class="check">
<input type="checkbox" :checked="selected.includes(m.id)" @change="toggleOne(m.id)" />
</td>
<td class="num-col">{{ toFaDigits(idx + 1) }}</td>
<td>
<div class="who">
<span class="avatar">{{ initials(m.fullName) }}</span>
<strong>{{ m.fullName }}</strong>
</div>
</td>
<td>{{ m.company?.name || '—' }}</td>
<td>
<span v-if="m.loyaltyTier" class="badge" :class="tierTone(m.loyaltyTier.name)">
<UiIcon :name="tierIcon(m.loyaltyTier.name)" :size="12" />
{{ m.loyaltyTier.name }}
</span>
<span v-else class="muted">—</span>
</td>
<td class="pts">{{ faNum(m.loyaltyPoints) }}</td>
<td>{{ formatRelativeContact(m.lastActivityAt) }}</td>
<td class="ops" @click.stop>
<button class="act plus" type="button" title="افزودن امتیاز" @click="openAdjust(m, 1)">
<UiIcon name="plus" :size="14" />
</button>
<button class="act minus" type="button" title="کسر امتیاز" @click="openAdjust(m, -1)">
<UiIcon name="minus" :size="14" />
</button>
<UiDropdownMenu
:open="menuId === m.id"
align="end"
@update:open="(v) => (menuId = v ? m.id : null)"
>
<template #trigger="{ toggle }">
<button class="act more" type="button" @click.stop="toggle">
<UiIcon name="dots" :size="14" />
</button>
</template>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</UiDropdownMenu>
</td>
</tr>
</tbody>
</table>
<p v-if="!visibleMembers.length && !loading" class="empty">عضوی مطابق فیلتر نیست.</p>
</div>
<ul class="mob-list">
<li v-for="m in isMobile && mobileTab === 'levels' ? previewMembers : visibleMembers" :key="m.id">
<span class="avatar">{{ initials(m.fullName) }}</span>
<div class="who">
<strong>{{ m.fullName }}</strong>
<small>{{ m.company?.name || 'بدون شرکت' }}</small>
</div>
<span v-if="m.loyaltyTier" class="badge" :class="tierTone(m.loyaltyTier.name)">
<UiIcon :name="tierIcon(m.loyaltyTier.name)" :size="11" />
{{ m.loyaltyTier.name }}
</span>
<span v-else class="badge custom">—</span>
<b class="mob-pts"><span>امتیاز</span><em>{{ faNum(m.loyaltyPoints) }}</em></b>
<div class="mob-side">
<div class="ops">
<UiDropdownMenu
:open="menuId === m.id"
align="end"
@update:open="(v) => (menuId = v ? m.id : null)"
>
<template #trigger="{ toggle }">
<button class="act more" type="button" @click.stop="toggle">
<UiIcon name="dots" :size="14" />
</button>
</template>
<RouterLink :to="`/contacts/people/${m.id}`">پروفایل</RouterLink>
<button type="button" @click="openLedger(m)">تاریخچه امتیاز</button>
<button type="button" @click="openRedeem(undefined, m)">ثبت پاداش</button>
</UiDropdownMenu>
<button class="act plus" type="button" @click="openAdjust(m, 1)">
<UiIcon name="plus" :size="14" />
</button>
<button class="act minus" type="button" @click="openAdjust(m, -1)">
<UiIcon name="minus" :size="14" />
</button>
</div>
</div>
</li>
</ul>
</section>
<section v-if="isMobile && mobileTab === 'rewards'" class="panel rewards-panel">
<div class="sec-head">
<h2>پاداش‌ها</h2>
<button v-if="canManage" class="btn primary" type="button" @click="openNewReward">
+ پاداش جدید
</button>
</div>
<div class="reward-grid">
<article v-for="r in rewards" :key="r.id" class="reward-card" :class="{ off: !r.isActive }">
<span class="kpi-ico gold"><UiIcon name="gift" :size="18" /></span>
<strong>{{ r.name }}</strong>
<p>{{ r.description || '—' }}</p>
<b>{{ faNum(r.pointsCost) }} امتیاز</b>
<small>{{ faNum(r.redemptionCount ?? 0) }} بار استفاده</small>
<div class="reward-ops">
<button class="btn ghost sm" type="button" @click="openRedeem(r)">ثبت استفاده</button>
<button v-if="canManage" class="text-link" type="button" @click="startEditReward(r)">ویرایش</button>
<button v-if="canManage" class="text-link danger" type="button" @click="removeReward(r.id)">حذف</button>
</div>
</article>
</div>
</section>
</section>
<Teleport to="body">
<div v-if="modal" class="overlay">
<div class="sheet" role="dialog" aria-modal="true">
<header>
<h3>
<template v-if="modal === 'settings'">تنظیمات باشگاه</template>
<template v-else-if="modal === 'tier'">{{ editingTierId ? 'ویرایش سطح' : 'سطح جدید' }}</template>
<template v-else-if="modal === 'adjust'">{{ adjustSign > 0 ? 'افزودن امتیاز' : 'کسر امتیاز' }}</template>
<template v-else-if="modal === 'bulk'">اعمال امتیاز گروهی</template>
<template v-else-if="modal === 'message'">ارسال پیام</template>
<template v-else-if="modal === 'ledger'">تاریخچه {{ ledgerName }}</template>
<template v-else-if="modal === 'reward'">{{ editingRewardId ? 'ویرایش پاداش' : 'پاداش جدید' }}</template>
<template v-else>ثبت پاداش</template>
</h3>
<button class="icon-btn" type="button" aria-label="بستن (Esc)" title="بستن (Esc)" @click="closeModal"><UiIcon name="x" :size="16" /></button>
</header>
<form v-if="modal === 'settings'" class="form" @submit.prevent="saveSettings">
<label class="check-row">
<input v-model="settings.enabled" type="checkbox" />
باشگاه فعال باشد
</label>
<label>
امتیاز خوش‌آمدگویی عضو جدید
<input v-model.number="settings.welcomePoints" type="number" min="0" />
</label>
<label class="check-row">
<input v-model="settings.notifyOnAdjust" type="checkbox" />
ارسال پیامک هنگام تغییر امتیاز
</label>
<div v-if="canManage" class="settings-rewards">
<div class="sec-head">
<h4>پاداش‌ها</h4>
<button class="text-link" type="button" @click="openNewReward">+ پاداش جدید</button>
</div>
<ul>
<li v-for="r in rewards" :key="r.id">
<span>{{ r.name }} · {{ faNum(r.pointsCost) }}</span>
<button type="button" class="text-link" @click="startEditReward(r)">ویرایش</button>
</li>
</ul>
</div>
<footer>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving || !canManage">ذخیره</button>
</footer>
</form>
<form v-else-if="modal === 'tier'" class="form" @submit.prevent="saveTier">
<label>نام سطح <input v-model="tierName" required minlength="2" /></label>
<label>حداقل امتیاز <input v-model.number="tierMin" type="number" min="0" /></label>
<label>رنگ <input v-model="tierColor" type="color" /></label>
<footer>
<button
v-if="editingTierId"
class="btn ghost danger"
type="button"
@click="removeTier(editingTierId)"
>
حذف
</button>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving">ذخیره</button>
</footer>
</form>
<form v-else-if="modal === 'adjust'" class="form" @submit.prevent="submitAdjust">
<label>
مقدار امتیاز
<input v-model.number="adjustDelta" type="number" min="1" required />
</label>
<label>دلیل (اختیاری) <input v-model="adjustReason" /></label>
<footer>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving">ثبت</button>
</footer>
</form>
<form v-else-if="modal === 'bulk'" class="form" @submit.prevent="submitBulk">
<p class="muted">{{ faNum(selectedCount) }} عضو انتخاب شده</p>
<div class="signs">
<button type="button" :class="{ on: bulkSign === 1 }" @click="bulkSign = 1">افزودن</button>
<button type="button" :class="{ on: bulkSign === -1 }" @click="bulkSign = -1">کسر</button>
</div>
<label>مقدار <input v-model.number="bulkDelta" type="number" min="1" required /></label>
<label>دلیل (اختیاری) <input v-model="bulkReason" /></label>
<footer>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving">اعمال</button>
</footer>
</form>
<form v-else-if="modal === 'message'" class="form" @submit.prevent="submitMessage">
<p class="muted">ارسال به {{ faNum(selectedCount) }} عضو انتخاب‌شده (فقط کسانی که موبایل دارند)</p>
<label>
متن پیام
<textarea v-model="messageText" rows="4" required minlength="2" />
</label>
<footer>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving">ارسال</button>
</footer>
</form>
<div v-else-if="modal === 'ledger'" class="ledger">
<p v-if="!ledger.length" class="muted">هنوز تراکنشی ثبت نشده.</p>
<ul>
<li v-for="row in ledger" :key="row.id">
<b :class="row.delta >= 0 ? 'up' : 'down'">{{ row.delta >= 0 ? '+' : '' }}{{ faNum(row.delta) }}</b>
<span>{{ row.reason || sourceLabel(row.source) }}</span>
<small>{{ formatRelativeContact(row.createdAt) }} · {{ sourceLabel(row.source) }}</small>
</li>
</ul>
</div>
<form v-else-if="modal === 'reward'" class="form" @submit.prevent="saveReward">
<label>نام پاداش <input v-model="rewardName" required minlength="2" /></label>
<label>هزینه امتیازی <input v-model.number="rewardCost" type="number" min="1" required /></label>
<label>توضیح <input v-model="rewardDesc" /></label>
<label>رنگ <input v-model="rewardColor" type="color" /></label>
<footer>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving">ذخیره</button>
</footer>
</form>
<form v-else class="form" @submit.prevent="submitRedeem">
<label>
پاداش
<select v-model="redeemRewardId" required>
<option disabled value="">انتخاب پاداش</option>
<option v-for="r in activeRewards" :key="r.id" :value="r.id">
{{ r.name }} ({{ faNum(r.pointsCost) }})
</option>
</select>
</label>
<label>
عضو
<select v-model="redeemPersonId" required>
<option disabled value="">انتخاب عضو</option>
<option v-for="m in members" :key="m.id" :value="m.id">
{{ m.fullName }} — {{ faNum(m.loyaltyPoints) }}
</option>
</select>
</label>
<footer>
<button class="btn ghost" type="button" @click="closeModal">انصراف</button>
<button class="btn primary" type="submit" :disabled="saving">ثبت استفاده</button>
</footer>
</form>
</div>
</div>
</Teleport>
</template>
<style scoped>
.page {
--teal: #14b8a6;
--teal-deep: #0f766e;
--ink: #0f172a;
--muted: #64748b;
display: grid;
gap: 1rem;
min-width: 0;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
h1 {
margin: 0;
font-size: clamp(1.45rem, 2.4vw, 1.85rem);
font-weight: 600;
color: var(--ink);
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.crown { color: #eab308; display: inline-flex; }
.crown :deep(.ico) { fill: #eab308; stroke: #ca8a04; }
.lead, .sub, .muted { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.9rem; }
h2 { margin: 0; font-size: 1.08rem; color: var(--ink); }
.banner { margin: 0; padding: 0.65rem 0.85rem; border-radius: var(--radius); font-weight: 600; }
.banner.err { color: #b91c1c; background: #fef2f2; }
.banner.ok { color: #047857; background: #ecfdf5; }
.banner.warn { color: #b45309; background: #fffbeb; }
.kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.kpis.mobile { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.5rem; }
.kpi {
border-radius: var(--radius);
padding: 0.95rem 1rem 0.85rem;
display: grid;
gap: 0.22rem;
min-width: 0;
}
.kpi strong { font-size: 1.55rem; font-weight: 600; color: var(--ink); line-height: 1.1; }
.kpi span { color: var(--muted); font-size: 0.82rem; }
.kpi.teal { background: #e7f7f4; }
.kpi.purple { background: #f3eefc; }
.kpi.gold { background: #fff6df; }
.kpi.blue { background: #e8f3fb; }
.kpi-ico {
width: 34px; height: 34px; border-radius: var(--radius);
display: grid; place-items: center; margin-bottom: 0.15rem;
}
.kpi.teal .kpi-ico { background: #c7efe8; color: #0f766e; }
.kpi.purple .kpi-ico { background: #e4d7fa; color: #7c3aed; }
.kpi.gold .kpi-ico, .kpi-ico.gold { background: #ffe8a3; color: #b45309; }
.kpi.blue .kpi-ico { background: #cfe6f8; color: #0369a1; }
.trend {
margin-top: 0.35rem;
justify-self: start;
padding: 0.12rem 0.45rem;
border-radius: 999px;
font-size: 0.72rem;
font-style: normal;
font-weight: 700;
}
.trend.up { background: #dcfce7; color: #15803d; }
.trend.down { background: #fee2e2; color: #b91c1c; }
.tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.4rem;
background: #fff;
padding: 0.35rem;
border-radius: var(--radius);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.04);
}
.tabs button {
border: 0;
background: transparent;
color: var(--teal-deep);
min-height: var(--control-h);
height: var(--control-h);
border-radius: var(--radius);
font: inherit;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
cursor: pointer;
}
.tabs button.on { background: var(--teal); color: #fff; }
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1.05rem 1.1rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
display: grid;
gap: 0.85rem;
min-width: 0;
}
.sec-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
}
.sec-head > div { display: grid; gap: 0.15rem; }
.btn, .icon-btn, .text-link {
font: inherit;
cursor: pointer;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: var(--control-h);
height: var(--control-h);
padding: 0 0.85rem;
border-radius: var(--radius);
box-sizing: border-box;
font-weight: 600;
font-size: var(--fs-sm);
border: 1px solid transparent;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.btn.primary { background: var(--accent); color: #fff; }
.btn.ghost, .btn.settings {
background: #fff;
color: var(--ink-secondary);
border-color: var(--line);
}
.btn.ghost:hover, .btn.settings:hover {
background: var(--surface-subtle);
border-color: var(--line-strong);
}
.btn.sm { min-height: 30px; height: 30px; padding: 0 0.65rem; font-size: var(--fs-xs); }
.btn:disabled { opacity: 0.55; cursor: not-allowed; }
.icon-btn {
width: var(--control-h); height: var(--control-h); border-radius: var(--radius);
border: 1px solid var(--line); background: #fff; color: var(--muted);
display: grid; place-items: center; box-sizing: border-box;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.icon-btn.on { background: var(--accent-subtle); border-color: var(--accent); color: var(--accent-deep); }
.text-link {
background: none; border: 0; color: var(--teal); font-weight: 700; padding: 0; justify-self: start;
}
.text-link.danger, .danger { color: #b91c1c; }
.level-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.7rem;
}
.level-card {
border: 1px solid #eef2f7;
border-radius: var(--radius);
padding: 1rem 0.9rem;
display: grid;
gap: 0.28rem;
justify-items: start;
min-height: 168px;
background: #fff;
}
.level-card.gold { background: linear-gradient(180deg, #fff8e1, #fff); }
.level-card.silver { background: linear-gradient(180deg, #f3f6fa, #fff); }
.level-card.bronze { background: linear-gradient(180deg, #fff3e6, #fff); }
.lvl-ico {
width: 42px; height: 42px; border-radius: var(--radius);
display: grid; place-items: center; margin-bottom: 0.2rem;
}
.level-card.gold .lvl-ico { background: #ffe58a; color: #ca8a04; }
.level-card.silver .lvl-ico { background: #e2e8f0; color: #64748b; }
.level-card.bronze .lvl-ico { background: #fdba74; color: #c2410c; }
.level-card.custom .lvl-ico { background: #ccfbf1; color: #0f766e; }
.level-card strong { font-size: 1.05rem; }
.from, .count { color: var(--muted); font-size: 0.84rem; }
.count { display: inline-flex; align-items: center; gap: 0.3rem; }
.add-card {
border: 1.5px dashed #5eead4;
background: #f0fdfa;
color: var(--teal-deep);
font-weight: 700;
place-items: center;
cursor: pointer;
}
.add-card .plus { font-size: 2rem; line-height: 1; color: var(--teal); }
.add-level-mob {
width: 100%;
background: #e7f7f4;
color: var(--teal-deep);
border: 1px solid #99f6e4;
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 0.65rem;
flex-wrap: wrap;
}
.tools-start, .tools-end { display: flex; flex-wrap: wrap; gap: 0.45rem; align-items: center; }
.tier-filter {
display: flex;
align-items: center;
gap: 0.45rem;
flex: 1 1 220px;
min-width: 0;
}
.tier-filter select {
flex: 1 1 auto;
width: auto;
min-width: 0;
}
.search {
display: flex; align-items: center; gap: 0.4rem;
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.75rem;
background: #fff; color: var(--muted); min-width: 200px; max-width: 360px;
height: var(--control-h); min-height: var(--control-h); box-sizing: border-box;
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.search input {
border: 0; outline: none; background: none; font: inherit; font-size: var(--fs-sm);
min-width: 0; width: 100%; color: var(--ink); padding: 0;
}
select, textarea, .form input, .form select, .form textarea {
border: 1px solid var(--line); border-radius: var(--radius); padding: 0 0.75rem; background: #fff;
min-height: var(--control-h); height: var(--control-h); font: inherit; font-size: var(--fs-sm);
box-sizing: border-box; color: var(--ink);
}
textarea, .form textarea {
height: auto; min-height: 80px; padding: 0.5rem 0.75rem;
}
.table-wrap { overflow: auto; border: 1px solid #eef2f7; border-radius: var(--radius); }
table { width: 100%; border-collapse: collapse; min-width: 860px; }
th, td { padding: 0.8rem 0.65rem; border-bottom: 1px solid #eef2f7; text-align: right; font-size: 0.9rem; }
th { background: #f8fafc; color: #64748b; font-weight: 700; }
.check { width: 42px; }
.who { display: flex; align-items: center; gap: 0.55rem; }
.avatar {
width: 36px; height: 36px; border-radius: 999px; background: #ccfbf1; color: #0f766e;
display: grid; place-items: center; font-weight: 600; font-size: 0.75rem; flex-shrink: 0;
}
.badge {
display: inline-flex; align-items: center; gap: 0.25rem;
padding: 0.15rem 0.5rem; border-radius: 999px; font-size: 0.75rem; font-weight: 700;
}
.badge.gold { background: #fef3c7; color: #a16207; }
.badge.silver { background: #e2e8f0; color: #475569; }
.badge.bronze { background: #ffedd5; color: #c2410c; }
.badge.custom { background: #ccfbf1; color: #0f766e; }
.pts { font-weight: 600; }
.ops { position: relative; display: inline-flex; gap: 0.3rem; align-items: center; }
.act {
width: 30px; height: 30px; border: 0; border-radius: 999px; display: grid; place-items: center; cursor: pointer;
}
.act.plus { background: #dcfce7; color: #15803d; }
.act.minus { background: #fee2e2; color: #dc2626; }
.act.more { background: #f1f5f9; color: #64748b; }
.mob-list { display: none; list-style: none; margin: 0; padding: 0; gap: 0.65rem; }
.mob-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: clamp(0.32rem, 3.2vw, 1.5rem);
padding: 0.55rem 0.05rem;
border-bottom: 1px solid #f1f5f9;
position: relative;
}
.mob-list .avatar { flex: 0 0 auto; }
.mob-list .who {
display: grid;
gap: 0.08rem;
min-width: 0;
flex: 1 1 6.5rem;
max-width: 38%;
align-content: center;
}
.mob-list .badge,
.mob-pts,
.mob-side { flex: 0 0 auto; }
.mob-list .who strong,
.mob-list .who small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mob-list small { color: var(--muted); }
.mob-list .badge { white-space: nowrap; align-self: center; font-size: 0.68rem; padding: 0.1rem 0.38rem; }
.mob-pts {
display: grid;
justify-items: center;
gap: 0.05rem;
font-size: 0.62rem;
font-weight: 700;
line-height: 1.15;
align-self: center;
color: #64748b;
}
.mob-pts em {
font-style: normal;
font-weight: 600;
font-size: 0.78rem;
color: var(--ink);
}
.mob-side { display: flex; align-items: center; }
.empty { text-align: center; color: var(--muted); padding: 1rem; }
.reward-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.7rem; }
.reward-card {
border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.95rem; display: grid; gap: 0.3rem;
}
.reward-card.off { opacity: 0.55; }
.reward-card p, .reward-card small { margin: 0; color: var(--muted); font-size: 0.84rem; }
.reward-ops { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin-top: 0.35rem; }
.overlay {
position: fixed; inset: 0; z-index: 80;
background: rgba(15, 23, 42, 0.35);
display: grid; place-items: center; padding: 16px;
}
.sheet {
width: min(460px, 100%);
background: #fff; border-radius: var(--radius, 10px); padding: 16px;
display: grid; gap: 16px;
}
.sheet header { display: flex; justify-content: space-between; align-items: center; }
.sheet h3 { margin: 0; font-size: 16px; font-weight: 500; color: var(--ink); line-height: 1.4; }
.form, .ledger { display: grid; gap: 0.7rem; }
.form label { display: grid; gap: 0.3rem; font-size: 0.88rem; font-weight: 700; color: #334155; }
.check-row { display: flex; align-items: center; gap: 0.45rem; font-weight: 700; }
.settings-rewards h4 { margin: 0; font-size: 0.92rem; }
.settings-rewards ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.35rem; }
.settings-rewards li { display: flex; justify-content: space-between; gap: 0.5rem; font-size: 0.86rem; }
.form footer { display: flex; justify-content: flex-end; gap: 0.45rem; }
.signs { display: flex; gap: 0.4rem; }
.signs button {
flex: 1; border: 1px solid #e2e8f0; background: #fff; border-radius: var(--radius);
min-height: 38px; font: inherit; font-weight: 700; cursor: pointer;
}
.signs button.on { background: #ccfbf1; border-color: #99f6e4; color: var(--teal-deep); }
.ledger ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.55rem; }
.ledger li { display: grid; gap: 0.1rem; padding: 0.45rem 0; border-bottom: 1px solid #f1f5f9; }
.ledger .up { color: #15803d; }
.ledger .down { color: #b91c1c; }
@media (max-width: 1100px) {
.kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.level-grid, .reward-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 860px) {
.page-head { align-items: center; }
.settings-label { display: none; }
.btn.settings { width: 40px; padding: 0; }
.kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.kpi { padding: 0.7rem 0.55rem; }
.kpi strong { font-size: 1.15rem; }
.kpi span { font-size: 0.7rem; line-height: 1.35; }
.level-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.level-card { min-height: 0; padding: 0.75rem 0.55rem; }
.level-card .text-link { display: none; }
.desk { display: none !important; }
.tools-end { width: 100%; }
.tools-end .btn { flex: 1; }
.mob-list { display: grid; }
.toolbar { flex-direction: column; }
.tools-start { width: 100%; flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
.search { flex: 0 0 auto; width: 100%; min-width: 0; }
.tier-filter { flex: 0 0 auto; width: 100%; flex-wrap: nowrap; }
.tier-filter select { flex: 1 1 auto; width: auto; }
.mob-list .avatar { width: 28px; height: 28px; font-size: 0.62rem; }
.mob-list .who strong { font-size: 0.82rem; }
.mob-list .who small { font-size: 0.7rem; }
.mob-list .badge { font-size: 0.58rem; padding: 0.06rem 0.28rem; gap: 0.12rem; }
.mob-list .badge :deep(.ico) { width: 9px; height: 9px; }
.mob-pts { font-size: 0.58rem; }
.mob-list li {
gap: clamp(0.32rem, 3.4vw, 1.6rem);
}
.mob-list .who { max-width: 36%; }
.mob-list .act { width: 26px; height: 26px; }
.mob-list .ops { gap: 0.2rem; }
.reward-grid { grid-template-columns: 1fr; }
}
</style>