2026-08-30 09:42:07 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-06 13:50:05 +03:30
|
|
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
2026-10-07 15:23:14 +03:30
|
|
|
|
import { useEscClose } from '@/lib/modal-stack';
|
2026-10-05 11:56:49 +03:30
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
import { useRouter } from 'vue-router';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
|
|
|
|
|
import { canManageSettings } from '@/lib/roles';
|
2026-09-06 13:50:05 +03:30
|
|
|
|
import { formatRelativeContact, toFaDigits } from '@/lib/jalali';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
import type {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
CreateLoyaltyRewardRequest,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
CreateLoyaltyTierRequest,
|
2026-09-06 13:50:05 +03:30
|
|
|
|
LoyaltyLedgerDto,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
LoyaltyMemberDto,
|
2026-09-06 13:50:05 +03:30
|
|
|
|
LoyaltyMessageResult,
|
|
|
|
|
|
LoyaltyRewardDto,
|
|
|
|
|
|
LoyaltySettingsDto,
|
|
|
|
|
|
LoyaltyStatsDto,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
LoyaltyTierDto,
|
|
|
|
|
|
} from '@mento/shared';
|
2026-09-07 11:49:54 +03:30
|
|
|
|
import { brand } from '@mento/shared';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
type MobileTab = 'levels' | 'members' | 'rewards';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
type ModalKind =
|
2026-09-06 13:50:05 +03:30
|
|
|
|
| 'settings'
|
|
|
|
|
|
| 'tier'
|
|
|
|
|
|
| 'adjust'
|
|
|
|
|
|
| 'bulk'
|
|
|
|
|
|
| 'message'
|
|
|
|
|
|
| 'ledger'
|
|
|
|
|
|
| 'reward'
|
|
|
|
|
|
| 'redeem'
|
|
|
|
|
|
| null;
|
2026-11-03 16:51:51 +03:30
|
|
|
|
|
|
|
|
|
|
const MEMBER_COL_LABELS = [
|
|
|
|
|
|
'نام',
|
|
|
|
|
|
'شرکت',
|
|
|
|
|
|
'سطح فعلی',
|
|
|
|
|
|
'امتیاز',
|
|
|
|
|
|
'آخرین فعالیت',
|
|
|
|
|
|
'عملیات',
|
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
|
|
registerTableColumns('loyalty.members', [...MEMBER_COL_LABELS]);
|
|
|
|
|
|
const tableCols = useTableColumns(() => 'loyalty.members');
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const auth = useAuthStore();
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const router = useRouter();
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const canManage = computed(() => canManageSettings(auth.user?.role));
|
|
|
|
|
|
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const ok = ref('');
|
|
|
|
|
|
const q = ref('');
|
2026-09-06 13:50:05 +03:30
|
|
|
|
const tierFilter = ref('');
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const activeTab = ref<MobileTab>('members');
|
|
|
|
|
|
const modal = ref<ModalKind>(null);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
const selected = ref<string[]>([]);
|
|
|
|
|
|
|
|
|
|
|
|
const stats = ref<LoyaltyStatsDto | null>(null);
|
|
|
|
|
|
const settings = ref<LoyaltySettingsDto>({
|
|
|
|
|
|
enabled: true,
|
|
|
|
|
|
welcomePoints: 0,
|
|
|
|
|
|
notifyOnAdjust: false,
|
|
|
|
|
|
});
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const tiers = ref<LoyaltyTierDto[]>([]);
|
|
|
|
|
|
const members = ref<LoyaltyMemberDto[]>([]);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
const rewards = ref<LoyaltyRewardDto[]>([]);
|
|
|
|
|
|
const ledger = ref<LoyaltyLedgerDto[]>([]);
|
|
|
|
|
|
const ledgerName = ref('');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
|
|
|
|
|
const tierName = ref('');
|
|
|
|
|
|
const tierMin = ref(0);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
const tierColor = ref('#d4a017');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
const editingTierId = ref<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const adjustPersonId = ref('');
|
|
|
|
|
|
const adjustDelta = ref(100);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
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);
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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 '';
|
|
|
|
|
|
});
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
|
|
|
|
|
const kpis = computed(() => {
|
|
|
|
|
|
const s = stats.value;
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'members',
|
2026-11-03 16:51:51 +03:30
|
|
|
|
icon: UserOutlined as Component,
|
2026-09-06 13:50:05 +03:30
|
|
|
|
value: s?.memberCount ?? 0,
|
|
|
|
|
|
label: 'عضو باشگاه',
|
|
|
|
|
|
pct: s?.memberDeltaPct ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'points',
|
2026-11-03 16:51:51 +03:30
|
|
|
|
icon: StarOutlined as Component,
|
2026-09-06 13:50:05 +03:30
|
|
|
|
value: s?.pointsIssued ?? 0,
|
2026-11-03 16:51:51 +03:30
|
|
|
|
label: 'امتیاز صادر شده',
|
2026-09-06 13:50:05 +03:30
|
|
|
|
pct: s?.pointsIssuedDeltaPct ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'rewards',
|
2026-11-03 16:51:51 +03:30
|
|
|
|
icon: GiftOutlined as Component,
|
2026-09-06 13:50:05 +03:30
|
|
|
|
value: s?.rewardsUsed ?? 0,
|
2026-11-03 16:51:51 +03:30
|
|
|
|
label: 'پاداش استفاده شده',
|
2026-09-06 13:50:05 +03:30
|
|
|
|
pct: s?.rewardsUsedDeltaPct ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'retention',
|
2026-11-03 16:51:51 +03:30
|
|
|
|
icon: RiseOutlined as Component,
|
2026-09-06 13:50:05 +03:30
|
|
|
|
value: s?.retentionRate ?? 0,
|
|
|
|
|
|
suffix: '٪',
|
|
|
|
|
|
label: 'نرخ استمرار اعضا',
|
|
|
|
|
|
pct: s?.retentionDeltaPct ?? null,
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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);
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
|
|
|
|
|
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) || '؟';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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',
|
|
|
|
|
|
};
|
2026-09-06 13:50:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trendText(pct: number | null) {
|
|
|
|
|
|
if (pct == null) return '';
|
|
|
|
|
|
const abs = faNum(Math.abs(pct));
|
|
|
|
|
|
const dir = pct >= 0 ? '+' : '−';
|
|
|
|
|
|
return `${dir}${abs}٪ نسبت به ماه قبل`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-30 09:42:07 +03:30
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
const [sRes, tRes, mRes, rRes, setRes] = await Promise.all([
|
|
|
|
|
|
api.get<LoyaltyStatsDto>('/loyalty/stats'),
|
2026-08-30 09:42:07 +03:30
|
|
|
|
api.get<LoyaltyTierDto[]>('/loyalty/tiers'),
|
|
|
|
|
|
api.get<LoyaltyMemberDto[]>('/loyalty/members', {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
params: {
|
|
|
|
|
|
q: q.value.trim() || undefined,
|
|
|
|
|
|
tierId: tierFilter.value || undefined,
|
|
|
|
|
|
},
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}),
|
2026-09-06 13:50:05 +03:30
|
|
|
|
api.get<LoyaltyRewardDto[]>('/loyalty/rewards'),
|
|
|
|
|
|
api.get<LoyaltySettingsDto>('/loyalty/settings'),
|
2026-08-30 09:42:07 +03:30
|
|
|
|
]);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
stats.value = sRes.data;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
tiers.value = tRes.data;
|
|
|
|
|
|
members.value = mRes.data;
|
2026-09-06 13:50:05 +03:30
|
|
|
|
rewards.value = rRes.data;
|
|
|
|
|
|
settings.value = setRes.data;
|
|
|
|
|
|
selected.value = selected.value.filter((id) =>
|
|
|
|
|
|
mRes.data.some((m) => m.id === id),
|
|
|
|
|
|
);
|
2026-08-30 09:42:07 +03:30
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (axios.isAxiosError(e) && !e.response) {
|
|
|
|
|
|
error.value = 'اتصال به API برقرار نشد.';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
error.value = 'بارگذاری باشگاه ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
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;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
ok.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
function closeModal() {
|
|
|
|
|
|
modal.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const modalOpen = computed({
|
|
|
|
|
|
get: () => modal.value != null,
|
|
|
|
|
|
set: (open: boolean) => {
|
|
|
|
|
|
if (!open) closeModal();
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-07 15:23:14 +03:30
|
|
|
|
useEscClose(() => modal.value, closeModal);
|
|
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
function openSettings() {
|
|
|
|
|
|
modal.value = 'settings';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openNewTier() {
|
|
|
|
|
|
if (!canManage.value) return;
|
2026-08-30 09:42:07 +03:30
|
|
|
|
editingTierId.value = null;
|
|
|
|
|
|
tierName.value = '';
|
|
|
|
|
|
tierMin.value = 0;
|
2026-09-06 13:50:05 +03:30
|
|
|
|
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';
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
flash('سطح بهروز شد.');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
} else {
|
|
|
|
|
|
await api.post('/loyalty/tiers', body);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
flash('سطح ساخته شد.');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-09-06 13:50:05 +03:30
|
|
|
|
closeModal();
|
2026-08-30 09:42:07 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
fail('ذخیره سطح ناموفق بود.');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function removeTier(id: string) {
|
|
|
|
|
|
if (!canManage.value) return;
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: 'سطح حذف شود؟',
|
|
|
|
|
|
okText: 'حذف',
|
|
|
|
|
|
cancelText: 'انصراف',
|
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
|
onOk: async () => {
|
2026-08-30 09:42:07 +03:30
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/loyalty/tiers/${id}`);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
closeModal();
|
2026-08-30 09:42:07 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
fail('حذف سطح ناموفق بود.');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-08-30 09:42:07 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
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() {
|
2026-08-30 09:42:07 +03:30
|
|
|
|
if (!adjustPersonId.value || !adjustDelta.value) return;
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.post(`/loyalty/members/${adjustPersonId.value}/adjust`, {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
delta: adjustSign.value * Math.abs(Math.trunc(adjustDelta.value)),
|
|
|
|
|
|
reason: adjustReason.value.trim() || undefined,
|
2026-08-30 09:42:07 +03:30
|
|
|
|
});
|
2026-09-06 13:50:05 +03:30
|
|
|
|
flash('امتیاز بهروز شد.');
|
|
|
|
|
|
closeModal();
|
2026-08-30 09:42:07 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
fail('تغییر امتیاز ناموفق بود.');
|
2026-08-30 09:42:07 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
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;
|
2026-09-07 11:49:54 +03:30
|
|
|
|
a.download = `${brand.filePrefix}-loyalty-members.xlsx`;
|
2026-09-06 13:50:05 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function removeReward(id: string) {
|
|
|
|
|
|
if (!canManage.value) return;
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: 'پاداش حذف یا غیرفعال شود؟',
|
|
|
|
|
|
okText: 'حذف',
|
|
|
|
|
|
cancelText: 'انصراف',
|
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
|
onOk: async () => {
|
2026-09-06 13:50:05 +03:30
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/loyalty/rewards/${id}`);
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
fail('حذف پاداش ناموفق بود.');
|
|
|
|
|
|
}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
2026-09-06 13:50:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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 'دستی';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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();
|
2026-09-06 13:50:05 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let searchTimer: number | null = null;
|
|
|
|
|
|
watch(q, () => {
|
|
|
|
|
|
if (searchTimer) window.clearTimeout(searchTimer);
|
|
|
|
|
|
searchTimer = window.setTimeout(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
}, 280);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
watch(tierFilter, () => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-06 13:50:05 +03:30
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
2026-11-03 16:51:51 +03:30
|
|
|
|
if (searchTimer) window.clearTimeout(searchTimer);
|
2026-09-06 13:50:05 +03:30
|
|
|
|
});
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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)' }" />
|
2026-09-06 13:50:05 +03:30
|
|
|
|
باشگاه مشتریان
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</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>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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="باشگاه فعلاً غیرفعال است؛ تنظیمات را بررسی کنید."
|
|
|
|
|
|
/>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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"
|
2026-08-30 09:42:07 +03:30
|
|
|
|
>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
{{ trendText(k.pct) }}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-tag>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</a-col>
|
|
|
|
|
|
</a-row>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-empty
|
|
|
|
|
|
v-if="!sortedTiers.length"
|
|
|
|
|
|
description="هنوز سطحی تعریف نشده است."
|
2026-09-06 13:50:05 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-button v-if="canManage" type="primary" @click="openNewTier">
|
|
|
|
|
|
افزودن سطح
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-empty>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-row v-else :gutter="[16, 16]">
|
|
|
|
|
|
<a-col
|
|
|
|
|
|
v-for="(t, index) in sortedTiers"
|
|
|
|
|
|
:key="t.id"
|
|
|
|
|
|
:xs="24"
|
|
|
|
|
|
:sm="12"
|
|
|
|
|
|
:lg="8"
|
2026-09-22 11:02:08 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-22 11:02:08 +03:30
|
|
|
|
</template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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
|
2026-09-06 13:50:05 +03:30
|
|
|
|
v-if="editingTierId"
|
2026-11-03 16:51:51 +03:30
|
|
|
|
danger
|
2026-09-06 13:50:05 +03:30
|
|
|
|
@click="removeTier(editingTierId)"
|
|
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
حذف سطح
|
|
|
|
|
|
</a-button>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</template>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-06 13:50:05 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-08-30 09:42:07 +03:30
|
|
|
|
</template>
|