feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشان‌ها، پنل مدیریت و گزارش ROI)

- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency
- اتصال به لید، مشتری، معامله، فعالیت، روتین، پیش‌فاکتور، فاکتور و تیکت
- صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو
- انتقال فرم‌ساز سرنخ به ابزارها

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-10 21:28:49 +03:30
co-authored by Cursor
parent b87ebb22e0
commit 60d797f184
51 changed files with 11298 additions and 806 deletions
@@ -0,0 +1,187 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { GamificationReportDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import AdminRules from '@/components/gamification/admin/AdminRules.vue';
import AdminApprovals from '@/components/gamification/admin/AdminApprovals.vue';
import AdminRewards from '@/components/gamification/admin/AdminRewards.vue';
import AdminAchievements from '@/components/gamification/admin/AdminAchievements.vue';
import AdminAdjust from '@/components/gamification/admin/AdminAdjust.vue';
import AdminSettings from '@/components/gamification/admin/AdminSettings.vue';
import AdminReports from '@/components/gamification/admin/AdminReports.vue';
import { toFaDigits } from '@/lib/jalali';
type Tab = 'rules' | 'redemptions' | 'rewards' | 'badges' | 'adjust' | 'settings' | 'reports';
const TABS: Array<{
value: Tab;
label: string;
icon: 'sliders' | 'check' | 'gift' | 'medal' | 'edit' | 'settings' | 'chart';
}> = [
{ value: 'rules', label: 'قوانین امتیاز', icon: 'sliders' },
{ value: 'redemptions', label: 'تأییدها', icon: 'check' },
{ value: 'rewards', label: 'فروشگاه پاداش', icon: 'gift' },
{ value: 'badges', label: 'نشان‌ها و بونوس‌ها', icon: 'medal' },
{ value: 'adjust', label: 'تنظیم دستی', icon: 'edit' },
{ value: 'reports', label: 'گزارش و ROI', icon: 'chart' },
{ value: 'settings', label: 'تنظیمات', icon: 'settings' },
];
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const isAdmin = computed(() => auth.user?.role === 'owner' || auth.user?.role === 'admin');
const pendingCount = ref(0);
const tab = computed<Tab>(() => {
const t = route.query.tab;
return TABS.some((x) => x.value === t) ? (t as Tab) : 'rules';
});
function setTab(next: Tab) {
void router.replace({ query: { ...route.query, tab: next === 'rules' ? undefined : next } });
}
async function loadPending() {
try {
const { data } = await api.get<GamificationReportDto>('/admin/gamification/reports', {
params: { period: 'today' },
});
pendingCount.value = data.totals.pendingApprovals + data.totals.pendingRedemptions;
} catch {
pendingCount.value = 0;
}
}
function onChanged() {
void loadPending();
window.dispatchEvent(new CustomEvent('gamification:updated'));
}
onMounted(() => {
if (isAdmin.value) void loadPending();
});
</script>
<template>
<section class="crm-page gadmin">
<header class="head">
<RouterLink to="/club" class="back"><UiIcon name="chevron-right" :size="16" /> باشگاه امتیازات</RouterLink>
<h1>مدیریت باشگاه امتیازات</h1>
<p>قوانین امتیازدهی، پاداش‌ها، نشان‌ها و درخواست‌ها را از اینجا مدیریت کنید.</p>
</header>
<p v-if="!isAdmin" class="denied">این بخش فقط برای مالک و مدیران سازمان در دسترس است.</p>
<template v-else>
<nav class="tabs" aria-label="بخش‌های مدیریت باشگاه">
<button
v-for="t in TABS"
:key="t.value"
type="button"
class="tab"
:class="{ on: tab === t.value }"
@click="setTab(t.value)"
>
<UiIcon :name="t.icon" :size="15" />
<span>{{ t.label }}</span>
<em v-if="t.value === 'redemptions' && pendingCount">{{ toFaDigits(pendingCount) }}</em>
</button>
</nav>
<AdminRules v-if="tab === 'rules'" />
<AdminApprovals v-else-if="tab === 'redemptions'" @changed="onChanged" />
<AdminRewards v-else-if="tab === 'rewards'" />
<AdminAchievements v-else-if="tab === 'badges'" />
<AdminAdjust v-else-if="tab === 'adjust'" @changed="onChanged" />
<AdminReports v-else-if="tab === 'reports'" />
<AdminSettings v-else-if="tab === 'settings'" @changed="onChanged" />
</template>
</section>
</template>
<style scoped>
.gadmin {
gap: var(--sp-4);
grid-template-columns: minmax(0, 1fr);
}
.head {
display: grid;
gap: 4px;
}
.back {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--accent-deep);
font-size: var(--fs-sm);
text-decoration: none;
width: fit-content;
}
.head h1 {
margin: 0;
font-size: var(--fs-xl);
}
.head p {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.denied {
padding: var(--sp-4);
border-radius: var(--radius);
background: var(--warning-soft);
color: var(--warning-deep);
}
.tabs {
display: flex;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
border-bottom: 1px solid var(--line);
scrollbar-width: thin;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 12px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--muted);
font: inherit;
font-size: var(--fs-sm);
font-weight: 600;
white-space: nowrap;
cursor: pointer;
}
.tab.on {
color: var(--accent-deep);
border-bottom-color: var(--accent);
}
.tab em {
font-style: normal;
font-size: 0.68rem;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
display: grid;
place-items: center;
background: var(--danger);
color: #fff;
}
</style>
@@ -0,0 +1,653 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { GamificationLeaderboardDto, GamificationMeDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import UserAvatar from '@/components/UserAvatar.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification';
import ClubHome from '@/components/gamification/ClubHome.vue';
import ClubHistory from '@/components/gamification/ClubHistory.vue';
import ClubStore from '@/components/gamification/ClubStore.vue';
import ClubBadges from '@/components/gamification/ClubBadges.vue';
import ClubTeams from '@/components/gamification/ClubTeams.vue';
import ClubRulesModal from '@/components/gamification/ClubRulesModal.vue';
import { CLUB_CTX } from '@/components/gamification/club-context';
type Tab = 'home' | 'history' | 'store' | 'badges' | 'teams';
const TABS: Array<{ value: Tab; label: string; icon: 'home' | 'clock' | 'gift' | 'medal' | 'users' }> = [
{ value: 'home', label: 'باشگاه', icon: 'home' },
{ value: 'history', label: 'تاریخچه امتیازات', icon: 'clock' },
{ value: 'store', label: 'فروشگاه پاداش', icon: 'gift' },
{ value: 'badges', label: 'نشان‌ها', icon: 'medal' },
{ value: 'teams', label: 'رقابت تیمی', icon: 'users' },
];
const route = useRoute();
const router = useRouter();
const me = ref<GamificationMeDto | null>(null);
const podium = ref<GamificationLeaderboardDto | null>(null);
const error = ref('');
const loading = ref(true);
const rulesOpen = ref(false);
const tab = computed<Tab>(() => {
const t = route.query.tab;
return TABS.some((x) => x.value === t) ? (t as Tab) : 'home';
});
function setTab(next: Tab) {
void router.replace({ query: { ...route.query, tab: next === 'home' ? undefined : next } });
}
async function load() {
try {
const [m, p] = await Promise.all([
api.get<GamificationMeDto>('/gamification/me', { params: { period: 'month' } }),
api.get<GamificationLeaderboardDto>('/gamification/leaderboard', { params: { period: 'month', limit: 3 } }),
]);
me.value = m.data;
podium.value = p.data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری باشگاه امتیازات ممکن نشد.');
} finally {
loading.value = false;
}
}
provide(CLUB_CTX, { me, reload: load });
/** سکوی سه نفر اول به ترتیب نمایش: دوم، اول، سوم */
const podiumOrder = computed(() => {
const items = podium.value?.items ?? [];
return [items[1], items[0], items[2]].filter((x): x is NonNullable<typeof x> => !!x && x.score > 0);
});
const gapText = computed(() => {
const m = me.value;
if (!m || m.rank == null) return '';
if (m.rank === 1 && m.periodScore > 0) return 'تو صدرنشین این ماهی! همین‌طور ادامه بده.';
if (m.gapToFirst != null && m.gapToFirst > 0) {
return `تا رتبه اول فقط ${faNum(m.gapToFirst)} امتیاز فاصله داری!`;
}
return 'با اولین امتیاز این ماه وارد رقابت شو!';
});
const gapProgress = computed(() => {
const m = me.value;
if (!m) return 0;
if (m.rank === 1) return 100;
const top = m.periodScore + (m.gapToFirst ?? 0);
return top > 0 ? Math.round((m.periodScore / top) * 100) : 0;
});
let timer: ReturnType<typeof setInterval> | null = null;
function onVisible() {
if (document.visibilityState === 'visible') void load();
}
onMounted(() => {
void load();
timer = setInterval(() => {
if (document.visibilityState === 'visible') void load();
}, 60_000);
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('gamification:updated', load);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('gamification:updated', load);
});
watch(
() => route.query.rules,
(v) => {
if (v === '1') rulesOpen.value = true;
},
{ immediate: true },
);
</script>
<template>
<section class="crm-page club">
<header class="hero">
<div class="hero-top">
<div class="hero-title">
<span class="star"><UiIcon name="star" :size="20" /></span>
<div>
<h1>باشگاه امتیازات</h1>
<p>هر کار خوب، امتیاز و سکه دارد؛ رقابت کن، پیشرفت کن و پاداش بگیر.</p>
</div>
</div>
<div class="hero-actions">
<button type="button" class="hero-btn" @click="rulesOpen = true">
<UiIcon name="lightbulb" :size="16" />
<span>قوانین و نحوه امتیازدهی</span>
</button>
<button v-if="me?.isManager" type="button" class="hero-btn solid" @click="router.push('/club/admin')">
<UiIcon name="sliders" :size="16" />
<span>مدیریت باشگاه</span>
</button>
</div>
</div>
<p v-if="error" class="hero-error">{{ error }}</p>
<div v-else class="hero-body" :class="{ loading }">
<div class="podium" aria-label="سه نفر برتر این ماه">
<div
v-for="p in podiumOrder"
:key="p.userId"
class="pod"
:class="[`r${p.rank}`, { me: p.isMe }]"
>
<span v-if="p.rank === 1" class="crown"><UiIcon name="crown" :size="18" /></span>
<UserAvatar :user-id="p.userId" :has-avatar="p.hasAvatar" :name="p.fullName" :size="p.rank === 1 ? 54 : 44" />
<strong class="pod-name">{{ p.isMe ? 'شما' : p.fullName }}</strong>
<span class="pod-score">{{ faNum(p.score) }}</span>
<span class="pod-step">{{ toFaDigits(p.rank) }}</span>
</div>
<p v-if="!podiumOrder.length && !loading" class="pod-empty">
هنوز امتیازی در این ماه ثبت نشده — اولین نفر باش!
</p>
</div>
<div class="hero-stats">
<div class="stat-row">
<div class="stat">
<span class="stat-label">رتبه شما</span>
<strong>{{ me?.rank ? `#${toFaDigits(me.rank)}` : '—' }}</strong>
<small v-if="me">از {{ toFaDigits(me.totalUsers) }} نفر</small>
</div>
<div class="stat">
<span class="stat-label">امتیاز کل</span>
<strong>{{ faNum(me?.score ?? 0) }}</strong>
<small v-if="me">این ماه {{ faNum(me.periodScore) }}</small>
</div>
<div class="stat coin">
<span class="stat-label">موجودی سکه</span>
<strong>{{ faNum(me?.coins ?? 0) }}</strong>
<small>قابل خرج در فروشگاه</small>
</div>
</div>
<div class="gap">
<div class="gap-text">
<UiIcon name="target" :size="15" />
<span>{{ gapText }}</span>
</div>
<div class="bar"><i :style="{ width: `${gapProgress}%` }" /></div>
</div>
</div>
<div class="hero-side">
<div class="goal">
<div class="goal-head">
<span>هدف بعدی</span>
<strong>{{ toFaDigits(me?.goal.progress ?? 0) }}٪</strong>
</div>
<p>رسیدن به {{ faNum(me?.goal.target ?? 0) }} امتیاز در این ماه</p>
<div class="bar warm"><i :style="{ width: `${me?.goal.progress ?? 0}%` }" /></div>
</div>
<button
v-if="me?.nextReward"
type="button"
class="next-reward"
@click="setTab('store')"
>
<UiIcon name="gift" :size="18" />
<span>
<small>پاداش بعدی</small>
<b>{{ me.nextReward.name }}</b>
</span>
<em>{{ faNum(me.nextReward.remaining) }} سکه مانده</em>
</button>
</div>
</div>
</header>
<nav class="c-tabs" aria-label="بخش‌های باشگاه امتیازات">
<button
v-for="t in TABS"
:key="t.value"
type="button"
class="c-tab"
:class="{ on: tab === t.value }"
@click="setTab(t.value)"
>
<UiIcon :name="t.icon" :size="15" />
<span>{{ t.label }}</span>
</button>
</nav>
<ClubHome v-if="tab === 'home'" @go="setTab" />
<ClubHistory v-else-if="tab === 'history'" />
<ClubStore v-else-if="tab === 'store'" />
<ClubBadges v-else-if="tab === 'badges'" />
<ClubTeams v-else-if="tab === 'teams'" />
<ClubRulesModal :open="rulesOpen" @close="rulesOpen = false" />
</section>
</template>
<style scoped>
.club {
gap: var(--sp-4);
grid-template-columns: minmax(0, 1fr);
}
.hero {
position: relative;
overflow: hidden;
border-radius: calc(var(--radius) + 6px);
padding: var(--sp-5);
color: #ecfeff;
background:
radial-gradient(circle at 12% 20%, rgba(45, 212, 191, 0.28), transparent 42%),
radial-gradient(circle at 85% 90%, rgba(250, 204, 21, 0.14), transparent 40%),
linear-gradient(135deg, #0b3b3c 0%, #0f5154 55%, #115e59 100%);
box-shadow: 0 18px 40px -24px rgba(15, 81, 84, 0.8);
display: grid;
gap: var(--sp-4);
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--sp-3);
flex-wrap: wrap;
}
.hero-title {
display: flex;
gap: var(--sp-3);
align-items: center;
min-width: 0;
}
.star {
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
background: linear-gradient(145deg, #fde68a, #f59e0b);
color: #78350f;
flex: 0 0 auto;
box-shadow: 0 6px 16px -6px rgba(245, 158, 11, 0.8);
}
.hero h1 {
margin: 0;
font-size: 1.35rem;
font-weight: 800;
color: #fff;
}
.hero-title p {
margin: 2px 0 0;
font-size: var(--fs-sm);
color: #a5f3fc;
}
.hero-actions {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.hero-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
color: #fff;
border-radius: 999px;
padding: 0.45rem 0.95rem;
font-size: var(--fs-sm);
font-weight: 700;
cursor: pointer;
transition: background 0.15s ease;
}
.hero-btn:hover {
background: rgba(255, 255, 255, 0.16);
}
.hero-btn.solid {
background: #fff;
color: #0f5154;
border-color: #fff;
}
.hero-error {
margin: 0;
color: #fecaca;
}
.hero-body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 0.9fr);
gap: var(--sp-4);
align-items: stretch;
transition: opacity 0.2s ease;
}
.hero-body.loading {
opacity: 0.55;
}
.podium {
display: flex;
align-items: flex-end;
justify-content: center;
gap: var(--sp-2);
min-height: 170px;
}
.pod {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: 92px;
min-width: 0;
}
.pod-name {
font-size: var(--fs-xs);
color: #fff;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pod-score {
font-size: var(--fs-xs);
color: #fde68a;
font-weight: 800;
}
.pod-step {
width: 100%;
display: grid;
place-items: center;
border-radius: 10px 10px 4px 4px;
font-weight: 800;
font-size: 1.1rem;
color: #0f5154;
background: linear-gradient(180deg, #e2e8f0, #94a3b8);
height: 46px;
}
.pod.r1 .pod-step {
height: 70px;
background: linear-gradient(180deg, #fde68a, #f59e0b);
color: #78350f;
}
.pod.r3 .pod-step {
height: 34px;
background: linear-gradient(180deg, #fed7aa, #c2410c);
color: #431407;
}
.pod.me .pod-name {
color: #fde68a;
}
.crown {
color: #fbbf24;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
animation: bob 2.4s ease-in-out infinite;
}
@keyframes bob {
50% {
transform: translateY(-3px);
}
}
.pod-empty {
margin: auto 0;
color: #a5f3fc;
font-size: var(--fs-sm);
text-align: center;
}
.hero-stats {
display: grid;
gap: var(--sp-3);
align-content: center;
}
.stat-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2);
}
.stat {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 12px;
padding: var(--sp-3);
display: grid;
gap: 2px;
min-width: 0;
}
.stat-label {
font-size: var(--fs-xs);
color: #a5f3fc;
}
.stat strong {
font-size: 1.45rem;
font-weight: 800;
color: #fff;
line-height: 1.3;
}
.stat.coin strong {
color: #fde68a;
}
.stat small {
font-size: 0.7rem;
color: #99f6e4;
}
.gap {
display: grid;
gap: 6px;
}
.gap-text {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
font-weight: 700;
color: #fff;
}
.bar {
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #2dd4bf, #a7f3d0);
transition: width 0.6s ease;
}
.bar.warm {
background: rgba(154, 52, 18, 0.15);
}
.bar.warm i {
background: linear-gradient(90deg, #fb923c, #f59e0b);
}
.hero-side {
display: grid;
gap: var(--sp-2);
align-content: center;
}
.goal {
background: linear-gradient(145deg, #fff7ed, #ffedd5);
color: #7c2d12;
border-radius: 12px;
padding: var(--sp-3);
display: grid;
gap: 6px;
}
.goal-head {
display: flex;
justify-content: space-between;
font-size: var(--fs-sm);
font-weight: 700;
}
.goal-head strong {
color: #c2410c;
}
.goal p {
margin: 0;
font-size: var(--fs-sm);
color: #9a3412;
}
.next-reward {
display: flex;
align-items: center;
gap: var(--sp-2);
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: var(--sp-2) var(--sp-3);
color: #fff;
cursor: pointer;
text-align: start;
font: inherit;
}
.next-reward span {
display: grid;
flex: 1;
min-width: 0;
}
.next-reward small {
font-size: 0.7rem;
color: #a5f3fc;
}
.next-reward b {
font-size: var(--fs-sm);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.next-reward em {
font-style: normal;
font-size: 0.72rem;
color: #fde68a;
white-space: nowrap;
}
.c-tabs {
display: flex;
gap: var(--sp-5);
border-bottom: 1px solid var(--line);
overflow-x: auto;
scrollbar-width: none;
}
.c-tabs::-webkit-scrollbar {
display: none;
}
.c-tab {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0.6rem 0.15rem;
margin-bottom: -1px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--muted-light);
font: inherit;
font-weight: var(--fw-bold);
font-size: var(--fs-sm);
white-space: nowrap;
cursor: pointer;
}
.c-tab:hover {
color: var(--ink-secondary);
}
.c-tab.on {
color: var(--accent-hover);
border-bottom-color: var(--accent);
}
@media (max-width: 1100px) {
.hero-body {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.hero-stats {
grid-column: 1 / -1;
order: -1;
}
}
@media (max-width: 700px) {
.hero {
padding: var(--sp-4);
}
.hero-body {
grid-template-columns: minmax(0, 1fr);
}
.stat {
padding: var(--sp-2);
}
.stat strong {
font-size: 1.15rem;
}
.pod {
width: 84px;
}
.c-tabs {
gap: var(--sp-4);
margin-inline: calc(-1 * var(--sp-3));
padding-inline: var(--sp-3);
}
}
</style>