Files
CRM_Montzar/apps/web/src/views/LoyaltyClubView.vue
T
Mahdi 26792f9a37 feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
2026-11-03 16:51:51 +03:30

1342 lines
45 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 { useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canManageSettings } from '@/lib/roles';
import { formatRelativeContact, toFaDigits } from '@/lib/jalali';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import type {
CreateLoyaltyRewardRequest,
CreateLoyaltyTierRequest,
LoyaltyLedgerDto,
LoyaltyMemberDto,
LoyaltyMessageResult,
LoyaltyRewardDto,
LoyaltySettingsDto,
LoyaltyStatsDto,
LoyaltyTierDto,
} from '@mento/shared';
import { brand } from '@mento/shared';
import {
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
List as AList,
ListItem as AListItem,
Menu as AMenu,
MenuItem as AMenuItem,
Modal as AModal,
Modal,
Progress as AProgress,
RadioButton as ARadioButton,
RadioGroup as ARadioGroup,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Switch as ASwitch,
Table as ATable,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType, TableProps } from 'ant-design-vue';
import {
CrownOutlined,
DownloadOutlined,
EditOutlined,
FilterOutlined,
GiftOutlined,
MinusOutlined,
MoreOutlined,
PlusOutlined,
RiseOutlined,
SearchOutlined,
SendOutlined,
SettingOutlined,
StarOutlined,
TrophyOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
import type { Component } from 'vue';
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
Typography;
const AListItemMeta = AList.Item.Meta;
type MobileTab = 'levels' | 'members' | 'rewards';
type ModalKind =
| 'settings'
| 'tier'
| 'adjust'
| 'bulk'
| 'message'
| 'ledger'
| 'reward'
| 'redeem'
| null;
const MEMBER_COL_LABELS = [
'نام',
'شرکت',
'سطح فعلی',
'امتیاز',
'آخرین فعالیت',
'عملیات',
] as const;
registerTableColumns('loyalty.members', [...MEMBER_COL_LABELS]);
const tableCols = useTableColumns(() => 'loyalty.members');
const auth = useAuthStore();
const router = useRouter();
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 activeTab = ref<MobileTab>('members');
const modal = ref<ModalKind>(null);
const selected = ref<string[]>([]);
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 visibleMembers = computed(() => members.value);
const selectedCount = computed(() => selected.value.length);
const activeRewards = computed(() => rewards.value.filter((r) => r.isActive));
const tierFilterOptions = computed(() => [
{ value: '', label: 'همه سطوح' },
...tiers.value.map((t) => ({ value: t.id, label: t.name })),
]);
const rewardOptions = computed(() =>
activeRewards.value.map((r) => ({
value: r.id,
label: `${r.name} (${faNum(r.pointsCost)})`,
})),
);
const memberOptions = computed(() =>
members.value.map((m) => ({
value: m.id,
label: `${m.fullName} — ${faNum(m.loyaltyPoints)}`,
})),
);
const modalTitle = computed(() => {
if (modal.value === 'settings') return 'تنظیمات باشگاه';
if (modal.value === 'tier') return editingTierId.value ? 'ویرایش سطح' : 'سطح جدید';
if (modal.value === 'adjust') return adjustSign.value > 0 ? 'افزودن امتیاز' : 'کسر امتیاز';
if (modal.value === 'bulk') return 'اعمال امتیاز گروهی';
if (modal.value === 'message') return 'ارسال پیام';
if (modal.value === 'ledger') return `تاریخچه ${ledgerName.value}`;
if (modal.value === 'reward') return editingRewardId.value ? 'ویرایش پاداش' : 'پاداش جدید';
if (modal.value === 'redeem') return 'ثبت پاداش';
return '';
});
const kpis = computed(() => {
const s = stats.value;
return [
{
key: 'members',
icon: UserOutlined as Component,
value: s?.memberCount ?? 0,
label: 'عضو باشگاه',
pct: s?.memberDeltaPct ?? null,
},
{
key: 'points',
icon: StarOutlined as Component,
value: s?.pointsIssued ?? 0,
label: 'امتیاز صادر شده',
pct: s?.pointsIssuedDeltaPct ?? null,
},
{
key: 'rewards',
icon: GiftOutlined as Component,
value: s?.rewardsUsed ?? 0,
label: 'پاداش استفاده شده',
pct: s?.rewardsUsedDeltaPct ?? null,
},
{
key: 'retention',
icon: RiseOutlined as Component,
value: s?.retentionRate ?? 0,
suffix: '٪',
label: 'نرخ استمرار اعضا',
pct: s?.retentionDeltaPct ?? null,
},
];
});
const columns = computed<TableColumnsType<LoyaltyMemberDto>>(() => {
const all: TableColumnsType<LoyaltyMemberDto> = [
{ title: 'نام', key: 'نام' },
{ title: 'شرکت', key: 'شرکت' },
{ title: 'سطح فعلی', key: 'سطح فعلی' },
{ title: 'امتیاز', key: 'امتیاز' },
{ title: 'آخرین فعالیت', key: 'آخرین فعالیت' },
{ title: 'عملیات', key: 'عملیات', width: 160 },
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (!MEMBER_COL_LABELS.includes(key as (typeof MEMBER_COL_LABELS)[number])) {
return true;
}
return tableCols.isVisible(key);
});
});
const rowSelection = computed<TableProps['rowSelection']>(() => ({
selectedRowKeys: selected.value,
onChange: (keys) => {
selected.value = keys.map(String);
},
}));
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) || '؟';
}
const sortedTiers = computed(() =>
[...tiers.value].sort(
(a, b) => a.minPoints - b.minPoints || a.position - b.position,
),
);
const tierMemberTotal = computed(() => {
const sum = tiers.value.reduce((acc, t) => acc + (t.memberCount ?? 0), 0);
return sum > 0 ? sum : stats.value?.memberCount ?? 0;
});
function tierSharePct(count: number | undefined) {
const total = tierMemberTotal.value;
if (!total) return 0;
return Math.round(((count ?? 0) / total) * 100);
}
function tierVisual(tier: LoyaltyTierDto) {
const name = tier.name;
if (name.includes('طلا')) {
return {
icon: CrownOutlined,
accent: tier.color || '#d97706',
softBg: '#fef3c7',
tagColor: 'gold',
};
}
if (name.includes('نقره')) {
return {
icon: StarOutlined,
accent: tier.color || '#64748b',
softBg: '#f1f5f9',
tagColor: 'default',
};
}
if (name.includes('برنز')) {
return {
icon: TrophyOutlined,
accent: tier.color || '#c2410c',
softBg: '#ffedd5',
tagColor: 'orange',
};
}
const accent = tier.color || 'var(--ant-color-primary)';
return {
icon: TrophyOutlined,
accent,
softBg: 'var(--ant-color-primary-bg)',
tagColor: 'processing',
};
}
function trendText(pct: number | null) {
if (pct == null) return '';
const abs = faNum(Math.abs(pct));
const dir = pct >= 0 ? '+' : '−';
return `${dir}${abs}٪ نسبت به ماه قبل`;
}
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;
}
const modalOpen = computed({
get: () => modal.value != null,
set: (open: boolean) => {
if (!open) closeModal();
},
});
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;
}
}
function removeTier(id: string) {
if (!canManage.value) return;
Modal.confirm({
title: 'سطح حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
onOk: async () => {
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';
}
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 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) {
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;
}
}
function removeReward(id: string) {
if (!canManage.value) return;
Modal.confirm({
title: 'پاداش حذف یا غیرفعال شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
onOk: async () => {
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';
}
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 goProfile(id: string) {
void router.push(`/contacts/people/${id}`);
}
function onModalOk() {
if (modal.value === 'settings') void saveSettings();
else if (modal.value === 'tier') void saveTier();
else if (modal.value === 'adjust') void submitAdjust();
else if (modal.value === 'bulk') void submitBulk();
else if (modal.value === 'message') void submitMessage();
else if (modal.value === 'reward') void saveReward();
else if (modal.value === 'redeem') void submitRedeem();
}
let searchTimer: number | null = null;
watch(q, () => {
if (searchTimer) window.clearTimeout(searchTimer);
searchTimer = window.setTimeout(() => {
void load();
}, 280);
});
watch(tierFilter, () => {
void load();
});
onMounted(() => {
void load();
});
onBeforeUnmount(() => {
if (searchTimer) window.clearTimeout(searchTimer);
});
</script>
<template>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
<a-flex vertical :gap="4">
<a-typography-title :level="3" :style="{ margin: 0 }">
<CrownOutlined :style="{ color: 'var(--ant-color-warning)' }" />
باشگاه مشتریان
</a-typography-title>
<a-typography-text type="secondary">وفاداری، یک رابطه طولانی‌مدت است.</a-typography-text>
</a-flex>
<a-button @click="openSettings">
<template #icon><SettingOutlined /></template>
تنظیمات باشگاه
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<a-alert
v-if="!settings.enabled"
type="warning"
show-icon
message="باشگاه فعلاً غیرفعال است؛ تنظیمات را بررسی کنید."
/>
<a-spin :spinning="loading">
<a-row :gutter="[16, 16]">
<a-col v-for="k in kpis" :key="k.key" :xs="12" :sm="12" :md="6">
<a-card :bordered="false" :style="{ height: '100%' }">
<a-flex :gap="12" align="flex-start">
<component
:is="k.icon"
:style="{ color: 'var(--ant-color-primary)', fontSize: '20px' }"
/>
<a-flex vertical :gap="0" :style="{ flex: 1, minWidth: 0 }">
<a-typography-text type="secondary">{{ k.label }}</a-typography-text>
<a-typography-title :level="3" :style="{ margin: 0 }">
{{ faNum(k.value) }}{{ k.suffix || '' }}
</a-typography-title>
<a-tag
v-if="k.pct != null"
:color="(k.pct ?? 0) >= 0 ? 'success' : 'error'"
:bordered="false"
>
{{ trendText(k.pct) }}
</a-tag>
</a-flex>
</a-flex>
</a-card>
</a-col>
</a-row>
<a-card :bordered="false" :style="{ marginTop: '16px' }">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="levels" tab="سطوح">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginBottom: '16px' }">
<a-flex vertical :gap="2">
<a-typography-title :level="5" :style="{ margin: 0 }">سطوح باشگاه</a-typography-title>
<a-typography-text type="secondary">
مرتب‌شده از کم‌ترین تا بیشترین آستانه امتیاز
</a-typography-text>
</a-flex>
<a-button v-if="canManage" type="primary" @click="openNewTier">
<template #icon><PlusOutlined /></template>
افزودن سطح
</a-button>
</a-flex>
<a-empty
v-if="!sortedTiers.length"
description="هنوز سطحی تعریف نشده است."
>
<a-button v-if="canManage" type="primary" @click="openNewTier">
افزودن سطح
</a-button>
</a-empty>
<a-row v-else :gutter="[16, 16]">
<a-col
v-for="(t, index) in sortedTiers"
:key="t.id"
:xs="24"
:sm="12"
:lg="8"
>
<a-card
:bordered="true"
hoverable
:style="{ height: '100%' }"
>
<a-flex vertical :gap="16" :style="{ height: '100%' }">
<a-flex justify="space-between" align="flex-start" :gap="12">
<a-flex align="center" :gap="12" :style="{ minWidth: 0, flex: 1 }">
<a-avatar
:size="44"
shape="square"
:style="{
background: tierVisual(t).softBg,
color: tierVisual(t).accent,
borderRadius: '10px',
fontSize: '22px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}"
>
<template #icon>
<component :is="tierVisual(t).icon" />
</template>
</a-avatar>
<a-flex vertical :gap="2" :style="{ minWidth: 0 }">
<a-flex align="center" :gap="6">
<a-typography-title :level="4" :style="{ margin: 0, fontSize: '16px' }">
{{ t.name }}
</a-typography-title>
<a-tag :color="tierVisual(t).tagColor" :bordered="false" :style="{ margin: 0 }">
سطح {{ toFaDigits(index + 1) }}
</a-tag>
</a-flex>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
از {{ faNum(t.minPoints) }} امتیاز
</a-typography-text>
</a-flex>
</a-flex>
<a-button
v-if="canManage"
type="text"
@click="startEditTier(t)"
>
<template #icon><EditOutlined /></template>
</a-button>
</a-flex>
<a-flex justify="space-between" align="baseline">
<a-flex vertical :gap="0">
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
تعداد اعضا
</a-typography-text>
<a-typography-title :level="3" :style="{ margin: 0, lineHeight: 1.2 }">
{{ faNum(t.memberCount ?? 0) }}
<a-typography-text type="secondary" :style="{ fontSize: '13px', fontWeight: 'normal' }">
نفر
</a-typography-text>
</a-typography-title>
</a-flex>
<a-typography-text type="secondary">
{{ toFaDigits(tierSharePct(t.memberCount)) }}٪ از کل اعضا
</a-typography-text>
</a-flex>
<a-progress
:percent="tierSharePct(t.memberCount)"
:stroke-color="tierVisual(t).accent"
:show-info="false"
:stroke-width="6"
:style="{ margin: 0, marginTop: 'auto' }"
/>
</a-flex>
</a-card>
</a-col>
<a-col v-if="canManage" :xs="24" :sm="12" :lg="8">
<a-card
:bordered="true"
hoverable
:style="{
height: '100%',
borderStyle: 'dashed',
}"
@click="openNewTier"
>
<a-flex
vertical
align="center"
justify="center"
:gap="8"
:style="{ minHeight: '140px', height: '100%' }"
>
<a-avatar
:size="44"
shape="square"
:style="{
background: 'var(--ant-color-primary-bg)',
color: 'var(--ant-color-primary)',
borderRadius: '10px',
fontSize: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}"
>
<template #icon><PlusOutlined /></template>
</a-avatar>
<a-typography-text strong>افزودن سطح جدید</a-typography-text>
<a-typography-text type="secondary" :style="{ fontSize: '12px' }">
تعریف سطح با آستانه امتیاز مشخص
</a-typography-text>
</a-flex>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
<a-tab-pane key="members" tab="اعضا">
<a-flex vertical :gap="12">
<a-flex vertical :gap="4">
<a-typography-title :level="5" :style="{ margin: 0 }">اعضای باشگاه</a-typography-title>
<a-typography-text type="secondary">
مدیریت امتیازها و مشاهده وضعیت اعضا
</a-typography-text>
</a-flex>
<a-form layout="vertical">
<a-row :gutter="[16, 0]" align="bottom">
<a-col :xs="24" :md="10">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجو در اعضا..."
style="width: 100%"
@pressEnter="load"
>
<template #prefix><SearchOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="6">
<a-form-item label="سطح">
<a-select
v-model:value="tierFilter"
:options="tierFilterOptions"
style="width: 100%"
>
<template #suffixIcon><FilterOutlined /></template>
</a-select>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label=" ">
<a-space wrap :size="8">
<a-button :disabled="saving" @click="exportExcel">
<template #icon><DownloadOutlined /></template>
خروجی Excel
</a-button>
<a-button @click="openMessage">
<template #icon><SendOutlined /></template>
ارسال پیام
</a-button>
<a-button @click="openBulk">
<template #icon><GiftOutlined /></template>
امتیاز گروهی
</a-button>
</a-space>
</a-form-item>
</a-col>
</a-row>
</a-form>
<a-flex justify="flex-end">
<TableColumnsMenu table-key="loyalty.members" align="end" />
</a-flex>
<a-table
:columns="columns"
:data-source="visibleMembers"
:pagination="false"
:row-selection="rowSelection"
row-key="id"
:scroll="{ x: 860 }"
:locale="{ emptyText: 'عضوی مطابق فیلتر نیست.' }"
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'نام'">
<a-space :size="8">
<a-avatar :style="{ background: 'var(--ant-color-primary-bg)', color: 'var(--ant-color-primary)' }">
{{ initials((record as LoyaltyMemberDto).fullName) }}
</a-avatar>
<a-typography-text strong>
{{ (record as LoyaltyMemberDto).fullName }}
</a-typography-text>
<a-typography-text type="secondary">
{{ toFaDigits((index as number) + 1) }}
</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'شرکت'">
{{ (record as LoyaltyMemberDto).company?.name || '—' }}
</template>
<template v-else-if="column.key === 'سطح فعلی'">
<a-tag
v-if="(record as LoyaltyMemberDto).loyaltyTier"
color="processing"
:bordered="false"
>
<span
:style="{
display: 'inline-block',
width: '8px',
height: '8px',
borderRadius: '50%',
marginInlineEnd: '6px',
background:
(record as LoyaltyMemberDto).loyaltyTier!.color ||
'var(--ant-color-primary)',
}"
aria-hidden="true"
/>
{{ (record as LoyaltyMemberDto).loyaltyTier!.name }}
</a-tag>
<a-typography-text v-else type="secondary">—</a-typography-text>
</template>
<template v-else-if="column.key === 'امتیاز'">
<a-typography-text strong>
{{ faNum((record as LoyaltyMemberDto).loyaltyPoints) }}
</a-typography-text>
</template>
<template v-else-if="column.key === 'آخرین فعالیت'">
{{ formatRelativeContact((record as LoyaltyMemberDto).lastActivityAt) }}
</template>
<template v-else-if="column.key === 'عملیات'">
<a-space :size="4" @click.stop>
<a-button
type="text"
title="افزودن امتیاز"
@click="openAdjust(record as LoyaltyMemberDto, 1)"
>
<template #icon><PlusOutlined /></template>
</a-button>
<a-button
type="text"
danger
title="کسر امتیاز"
@click="openAdjust(record as LoyaltyMemberDto, -1)"
>
<template #icon><MinusOutlined /></template>
</a-button>
<a-dropdown :trigger="['click']">
<a-button type="text">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu>
<a-menu-item
key="profile"
@click="goProfile((record as LoyaltyMemberDto).id)"
>
پروفایل
</a-menu-item>
<a-menu-item
key="ledger"
@click="openLedger(record as LoyaltyMemberDto)"
>
تاریخچه امتیاز
</a-menu-item>
<a-menu-item
key="redeem"
@click="openRedeem(undefined, record as LoyaltyMemberDto)"
>
ثبت پاداش
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
</a-flex>
</a-tab-pane>
<a-tab-pane key="rewards" tab="پاداش‌ها">
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginBottom: '12px' }">
<a-typography-title :level="5" :style="{ margin: 0 }">پاداش‌ها</a-typography-title>
<a-button v-if="canManage" type="primary" @click="openNewReward">
<template #icon><PlusOutlined /></template>
پاداش جدید
</a-button>
</a-flex>
<a-empty v-if="!rewards.length" description="هنوز پاداشی تعریف نشده." />
<a-row v-else :gutter="[16, 16]">
<a-col v-for="r in rewards" :key="r.id" :xs="24" :sm="12" :md="8">
<a-card :bordered="true" :style="{ height: '100%', opacity: r.isActive ? 1 : 0.55 }">
<a-flex vertical :gap="8">
<GiftOutlined :style="{ color: r.color || 'var(--ant-color-primary)', fontSize: '20px' }" />
<a-typography-text strong>{{ r.name }}</a-typography-text>
<a-typography-paragraph type="secondary" :style="{ marginBottom: 0 }">
{{ r.description || '—' }}
</a-typography-paragraph>
<a-typography-text strong>{{ faNum(r.pointsCost) }} امتیاز</a-typography-text>
<a-typography-text type="secondary">
{{ faNum(r.redemptionCount ?? 0) }} بار استفاده
</a-typography-text>
<a-space wrap :size="8">
<a-button @click="openRedeem(r)">ثبت استفاده</a-button>
<a-button v-if="canManage" type="link" @click="startEditReward(r)">
ویرایش
</a-button>
<a-button
v-if="canManage"
type="link"
danger
@click="removeReward(r.id)"
>
حذف
</a-button>
</a-space>
</a-flex>
</a-card>
</a-col>
</a-row>
</a-tab-pane>
</a-tabs>
</a-card>
</a-spin>
<a-modal
v-model:open="modalOpen"
:title="modalTitle"
:confirm-loading="saving"
:ok-text="modal === 'ledger' ? undefined : 'تأیید'"
:footer="modal === 'ledger' ? null : undefined"
destroy-on-close
@ok="onModalOk"
@cancel="closeModal"
>
<template v-if="modal === 'settings'">
<a-form layout="vertical">
<a-form-item>
<a-space :size="8">
<a-switch v-model:checked="settings.enabled" />
<a-typography-text>باشگاه فعال باشد</a-typography-text>
</a-space>
</a-form-item>
<a-form-item label="امتیاز خوش‌آمدگویی عضو جدید">
<a-input-number
v-model:value="settings.welcomePoints"
:min="0"
style="width: 100%"
/>
</a-form-item>
<a-form-item>
<a-space :size="8">
<a-checkbox v-model:checked="settings.notifyOnAdjust" />
<a-typography-text>ارسال پیامک هنگام تغییر امتیاز</a-typography-text>
</a-space>
</a-form-item>
<a-divider v-if="canManage" orientation="right">پاداش‌ها</a-divider>
<a-flex v-if="canManage" justify="space-between" align="center" :style="{ marginBottom: '8px' }">
<a-typography-text strong>پاداش‌ها</a-typography-text>
<a-button type="link" @click="openNewReward">+ پاداش جدید</a-button>
</a-flex>
<a-list v-if="canManage" :data-source="rewards" size="small">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="`${(item as LoyaltyRewardDto).name} · ${faNum((item as LoyaltyRewardDto).pointsCost)}`"
/>
<template #actions>
<a-button type="link" @click="startEditReward(item as LoyaltyRewardDto)">
ویرایش
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-form>
</template>
<template v-else-if="modal === 'tier'">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام سطح" required>
<a-input v-model:value="tierName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="حداقل امتیاز">
<a-input-number v-model:value="tierMin" :min="0" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="رنگ">
<a-input v-model:value="tierColor" type="color" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
<a-button
v-if="editingTierId"
danger
@click="removeTier(editingTierId)"
>
حذف سطح
</a-button>
</a-form>
</template>
<template v-else-if="modal === 'adjust'">
<a-form layout="vertical">
<a-form-item label="مقدار امتیاز" required>
<a-input-number v-model:value="adjustDelta" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل (اختیاری)">
<a-input v-model:value="adjustReason" style="width: 100%" />
</a-form-item>
</a-form>
</template>
<template v-else-if="modal === 'bulk'">
<a-form layout="vertical">
<a-typography-text type="secondary">
{{ faNum(selectedCount) }} عضو انتخاب شده
</a-typography-text>
<a-form-item label="نوع">
<a-radio-group v-model:value="bulkSign">
<a-radio-button :value="1">افزودن</a-radio-button>
<a-radio-button :value="-1">کسر</a-radio-button>
</a-radio-group>
</a-form-item>
<a-form-item label="مقدار" required>
<a-input-number v-model:value="bulkDelta" :min="1" style="width: 100%" />
</a-form-item>
<a-form-item label="دلیل (اختیاری)">
<a-input v-model:value="bulkReason" style="width: 100%" />
</a-form-item>
</a-form>
</template>
<template v-else-if="modal === 'message'">
<a-form layout="vertical">
<a-typography-text type="secondary">
ارسال به {{ faNum(selectedCount) }} عضو انتخاب‌شده (فقط کسانی که موبایل دارند)
</a-typography-text>
<a-form-item label="متن پیام" required>
<a-textarea v-model:value="messageText" :rows="4" style="width: 100%" />
</a-form-item>
</a-form>
</template>
<template v-else-if="modal === 'ledger'">
<a-empty v-if="!ledger.length" description="هنوز تراکنشی ثبت نشده." />
<a-list v-else :data-source="ledger" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="(item as LoyaltyLedgerDto).reason || sourceLabel((item as LoyaltyLedgerDto).source)"
:description="`${formatRelativeContact((item as LoyaltyLedgerDto).createdAt)} · ${sourceLabel((item as LoyaltyLedgerDto).source)}`"
/>
<template #actions>
<a-typography-text
:type="(item as LoyaltyLedgerDto).delta >= 0 ? 'success' : 'danger'"
strong
>
{{ (item as LoyaltyLedgerDto).delta >= 0 ? '+' : '' }}{{ faNum((item as LoyaltyLedgerDto).delta) }}
</a-typography-text>
</template>
</a-list-item>
</template>
</a-list>
</template>
<template v-else-if="modal === 'reward'">
<a-form layout="vertical">
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام پاداش" required>
<a-input v-model:value="rewardName" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="هزینه امتیازی" required>
<a-input-number v-model:value="rewardCost" :min="1" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="توضیح">
<a-input v-model:value="rewardDesc" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="رنگ">
<a-input v-model:value="rewardColor" type="color" style="width: 100%" />
</a-form-item>
</a-col>
</a-row>
</a-form>
</template>
<template v-else-if="modal === 'redeem'">
<a-form layout="vertical">
<a-form-item label="پاداش" required>
<a-select
v-model:value="redeemRewardId"
:options="rewardOptions"
style="width: 100%"
/>
</a-form-item>
<a-form-item label="عضو" required>
<a-select
v-model:value="redeemPersonId"
:options="memberOptions"
show-search
option-filter-prop="label"
style="width: 100%"
/>
</a-form-item>
</a-form>
</template>
</a-modal>
</a-flex>
</template>