Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
1342 lines
45 KiB
Vue
1342 lines
45 KiB
Vue
<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>
|