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,164 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import type { GamificationAchievementDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum, formatDateTime } from '@/lib/gamification';
const items = ref<GamificationAchievementDto[]>([]);
const error = ref('');
const earned = computed(() => items.value.filter((b) => b.earned));
const locked = computed(() =>
items.value.filter((b) => !b.earned).sort((a, b) => b.progress - a.progress),
);
onMounted(async () => {
try {
items.value = (await api.get<GamificationAchievementDto[]>('/gamification/achievements')).data;
} catch (e) {
error.value = apiError(e, 'بارگذاری نشان‌ها ممکن نشد.');
}
});
</script>
<template>
<section class="badges">
<p v-if="error" class="err">{{ error }}</p>
<header class="sum">
<UiIcon name="medal" :size="20" />
<span>{{ toFaDigits(earned.length) }} از {{ toFaDigits(items.length) }} نشان را گرفته‌ای</span>
</header>
<div class="grid">
<article
v-for="b in [...earned, ...locked]"
:key="b.id"
class="badge"
:class="{ earned: b.earned }"
:style="{ '--c': b.color }"
>
<span class="ic"><UiIcon :name="b.icon as never" :size="26" /></span>
<h4>{{ b.name }}</h4>
<p v-if="b.description">{{ b.description }}</p>
<template v-if="b.earned">
<small class="when">گرفته‌شده در {{ formatDateTime(b.earnedAt!) }}</small>
</template>
<template v-else>
<div class="bar"><i :style="{ width: `${b.progress}%` }" /></div>
<small class="when">{{ faNum(Math.min(b.current, b.threshold)) }} از {{ faNum(b.threshold) }}</small>
</template>
<em v-if="b.coins">+{{ faNum(b.coins) }} سکه</em>
</article>
<p v-if="!items.length && !error" class="empty">هنوز نشانی تعریف نشده است.</p>
</div>
</section>
</template>
<style scoped>
.badges {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.err {
margin: 0;
color: var(--danger);
}
.sum {
display: flex;
align-items: center;
gap: var(--sp-2);
color: var(--ink-secondary);
font-weight: 700;
}
.sum :deep(svg) {
color: #f59e0b;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-3);
}
.badge {
--c: #f59e0b;
display: grid;
justify-items: center;
text-align: center;
gap: 6px;
padding: var(--sp-4) var(--sp-3);
border-radius: calc(var(--radius) + 4px);
background: var(--surface);
border: 1px solid var(--line);
min-width: 0;
}
.badge .ic {
width: 60px;
height: 60px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--surface-subtle);
color: var(--muted-light);
}
.badge.earned {
border-color: color-mix(in srgb, var(--c) 40%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--c) 10%, var(--surface)), var(--surface));
}
.badge.earned .ic {
background: linear-gradient(145deg, color-mix(in srgb, var(--c) 60%, #fff), var(--c));
color: #fff;
box-shadow: 0 8px 18px -8px var(--c);
}
.badge h4 {
margin: 0;
font-size: var(--fs-sm);
color: var(--ink);
}
.badge p {
margin: 0;
font-size: var(--fs-xs);
color: var(--muted);
}
.bar {
width: 100%;
height: 6px;
border-radius: 999px;
background: var(--line-light);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--c);
}
.when {
font-size: 0.7rem;
color: var(--muted);
}
.badge em {
font-style: normal;
font-size: 0.72rem;
font-weight: 700;
color: #b45309;
}
.empty {
color: var(--muted);
}
</style>
@@ -0,0 +1,293 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import type { GamificationTxDto, PaginatedResponse } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiPagination } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, formatDateTime, signedFa, txIcon } from '@/lib/gamification';
const props = withDefaults(defineProps<{ userId?: string | null; showUser?: boolean }>(), {
userId: null,
showUser: false,
});
const KINDS = [
{ value: '', label: 'همه' },
{ value: 'rule', label: 'فعالیت‌ها' },
{ value: 'bonus', label: 'بونوس' },
{ value: 'achievement', label: 'نشان' },
{ value: 'adjust', label: 'تنظیم دستی' },
{ value: 'redeem', label: 'خرید پاداش' },
];
const STATUS_LABELS: Record<string, string> = {
pending: 'در انتظار تأیید',
rejected: 'رد شده',
};
const kind = ref('');
const page = ref(1);
const data = ref<PaginatedResponse<GamificationTxDto> | null>(null);
const error = ref('');
const loading = ref(false);
async function load() {
loading.value = true;
try {
const res = await api.get<PaginatedResponse<GamificationTxDto>>('/gamification/transactions', {
params: { kind: kind.value || undefined, page: page.value, limit: 30, userId: props.userId || undefined },
});
data.value = res.data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری تاریخچه ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(kind, () => {
page.value = 1;
void load();
});
watch(() => props.userId, () => {
page.value = 1;
void load();
});
onMounted(load);
</script>
<template>
<section class="hist">
<div class="chips" role="tablist">
<button
v-for="k in KINDS"
:key="k.value"
type="button"
:class="{ on: kind === k.value }"
@click="kind = k.value"
>
{{ k.label }}
</button>
</div>
<p v-if="error" class="err">{{ error }}</p>
<ul class="list" :class="{ loading }">
<li v-for="tx in data?.items ?? []" :key="tx.id" :class="tx.status">
<span class="ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="16" /></span>
<span class="body">
<b>
<template v-if="showUser">{{ tx.userName }} · </template>{{ tx.title }}
</b>
<small>
<RouterLink v-if="tx.link && tx.entityTitle" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
<template v-else-if="tx.entityTitle">{{ tx.entityTitle }}</template>
<template v-if="tx.entityTitle"> · </template>{{ formatDateTime(tx.createdAt) }}
</small>
<small v-if="tx.reason" class="reason">
دلیل: {{ tx.reason }}<template v-if="tx.createdByName"> — {{ tx.createdByName }}</template>
</small>
</span>
<span v-if="STATUS_LABELS[tx.status]" class="st">{{ STATUS_LABELS[tx.status] }}</span>
<span class="pts">
<b v-if="tx.score" :class="{ neg: tx.score < 0 }">{{ signedFa(tx.score) }} <small>امتیاز</small></b>
<em v-if="tx.coins" :class="{ neg: tx.coins < 0 }">{{ signedFa(tx.coins) }} <small>سکه</small></em>
</span>
</li>
<li v-if="data && !data.items.length" class="empty">موردی ثبت نشده است.</li>
</ul>
<UiPagination
v-if="data && data.total > data.limit"
v-model:page="page"
:total-items="data.total"
:page-size="data.limit"
@change="load"
/>
</section>
</template>
<style scoped>
.hist {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chips button {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 999px;
padding: 0.3rem 0.8rem;
font: inherit;
font-size: var(--fs-xs);
font-weight: 700;
color: var(--ink-secondary);
cursor: pointer;
}
.chips button.on {
border-color: var(--accent);
background: var(--accent-subtle);
color: var(--accent-deep);
}
.err {
margin: 0;
color: var(--danger);
}
.list {
list-style: none;
margin: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
overflow: hidden;
}
.list.loading {
opacity: 0.6;
}
.list li {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--line-light);
}
.list li:last-child {
border-bottom: 0;
}
.list li.rejected {
opacity: 0.55;
}
.ic {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
flex: 0 0 auto;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.ic.bonus,
.ic.achievement {
background: #fef3c7;
color: #b45309;
}
.ic.redeem,
.ic.refund {
background: #ede9fe;
color: #6d28d9;
}
.ic.adjust {
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.body {
flex: 1;
min-width: 0;
display: grid;
gap: 1px;
}
.body b {
font-size: var(--fs-sm);
color: var(--ink);
}
.body small {
font-size: var(--fs-xs);
color: var(--muted);
}
.body a {
color: var(--accent-hover);
}
.reason {
color: var(--ink-secondary) !important;
}
.st {
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--warning-soft);
color: var(--warning-deep);
flex: 0 0 auto;
}
.rejected .st {
background: var(--danger-soft);
color: var(--danger);
}
.pts {
display: grid;
text-align: end;
flex: 0 0 auto;
}
.pts b {
color: var(--success-deep);
font-weight: 800;
}
.pts em {
font-style: normal;
color: #b45309;
font-weight: 700;
font-size: var(--fs-sm);
}
.pts small {
font-size: 0.65rem;
font-weight: 600;
color: var(--muted);
}
.pts .neg {
color: var(--danger);
}
.empty {
justify-content: center;
color: var(--muted);
}
@media (max-width: 600px) {
.list li {
padding: var(--sp-3);
flex-wrap: wrap;
}
.body {
flex-basis: calc(100% - 50px);
}
.pts {
margin-inline-start: auto;
}
}
</style>
@@ -0,0 +1,528 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import type { GamificationRewardDto, GamificationTxDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { faNum, signedFa, timeAgo, txIcon } from '@/lib/gamification';
import ClubLeaderboard from './ClubLeaderboard.vue';
import { useClub } from './club-context';
const emit = defineEmits<{ go: ['store' | 'history' | 'badges' | 'teams'] }>();
const { me } = useClub();
const rewards = ref<GamificationRewardDto[]>([]);
const feed = ref<GamificationTxDto[]>([]);
onMounted(async () => {
const [r, f] = await Promise.allSettled([
api.get<GamificationRewardDto[]>('/gamification/rewards'),
api.get<GamificationTxDto[]>('/gamification/feed'),
]);
if (r.status === 'fulfilled') rewards.value = r.value.data.slice(0, 3);
if (f.status === 'fulfilled') feed.value = f.value.data.slice(0, 8);
});
const level = computed(() => me.value?.level ?? null);
/** پیام انگیزشی؛ همیشه مثبت و بدون مقایسهٔ تحقیرآمیز */
const motivation = computed(() => {
const m = me.value;
if (!m) return null;
if (m.rank === 1 && m.periodScore > 0) {
return { title: 'عالی!', text: 'تو صدرنشین این ماهی. جایگاهت را حفظ کن!' };
}
if (m.periodScore > 0 && m.periodScore >= m.personalBest) {
return { title: 'رکورد جدید!', text: `${faNum(m.periodScore)} امتیاز — بهترین ماه تو تا امروز.` };
}
if (m.streakDays >= 2) {
return { title: 'عالی!', text: `${toFaDigits(m.streakDays)} روز پیاپی همهٔ کارهای روتین را کامل کردی.` };
}
if (m.gapToNext != null && m.gapToNext > 0 && m.rank) {
return {
title: 'نزدیکی!',
text: `فقط ${faNum(m.gapToNext)} امتیاز تا رتبهٔ ${toFaDigits(m.rank - 1)} فاصله داری.`,
};
}
if (m.todayScore > 0) {
return { title: 'آفرین!', text: `امروز ${faNum(m.todayScore)} امتیاز گرفتی. ادامه بده!` };
}
return { title: 'شروع کن!', text: 'با ثبت تماس، پیگیری یا یک لید جدید اولین امتیاز امروز را بگیر.' };
});
function rewardProgress(r: GamificationRewardDto) {
const coins = me.value?.coins ?? 0;
return Math.min(100, Math.round((coins / Math.max(1, r.cost)) * 100));
}
</script>
<template>
<div class="home">
<aside class="col side">
<section class="card level-card">
<header class="card-h">
<h3>سطح و پیشرفت شما</h3>
<button type="button" class="link" @click="emit('go', 'badges')">نشان‌ها</button>
</header>
<div v-if="level" class="level">
<div class="ring" :style="{ '--p': `${level.progress}` }">
<span>
<small>سطح</small>
<b>{{ toFaDigits(level.level) }}</b>
</span>
</div>
<div class="level-info">
<strong>{{ level.name }}</strong>
<span>{{ toFaDigits(level.progress) }}٪ تا سطح بعد</span>
<small v-if="level.toNext != null">
تا «{{ level.nextName }}»: {{ faNum(level.toNext) }} امتیاز
</small>
<small v-else>بالاترین سطح باشگاه!</small>
</div>
</div>
<div class="mini-stats">
<div>
<b>{{ faNum(me?.personalBest ?? 0) }}</b>
<span>رکورد ماهانه</span>
</div>
<div>
<b>{{ toFaDigits(me?.streakDays ?? 0) }}</b>
<span>روز پیاپی</span>
</div>
<div>
<b>{{ toFaDigits(me?.badgesEarned ?? 0) }}</b>
<span>نشان</span>
</div>
</div>
</section>
<section class="card">
<header class="card-h">
<h3>پاداش‌های قابل دریافت</h3>
<button type="button" class="link" @click="emit('go', 'store')">همه</button>
</header>
<ul class="rewards">
<li v-for="r in rewards" :key="r.id" :style="{ '--c': r.color }">
<span class="r-ic"><UiIcon :name="r.icon as never" :size="18" /></span>
<span class="r-body">
<b>{{ r.name }}</b>
<span class="r-bar"><i :style="{ width: `${rewardProgress(r)}%` }" /></span>
</span>
<em>{{ faNum(r.cost) }}<small>سکه</small></em>
</li>
<li v-if="!rewards.length" class="empty">هنوز پاداشی تعریف نشده است.</li>
</ul>
</section>
<section v-if="motivation" class="card cheer">
<UiIcon name="sparkle" :size="22" />
<div>
<strong>{{ motivation.title }}</strong>
<p>{{ motivation.text }}</p>
</div>
</section>
</aside>
<div class="col center">
<ClubLeaderboard />
</div>
<aside class="col side">
<section class="card">
<header class="card-h">
<h3>آخرین امتیازات شما</h3>
<button type="button" class="link" @click="emit('go', 'history')">تاریخچه</button>
</header>
<ul class="feed">
<li v-for="tx in me?.recent ?? []" :key="tx.id" :class="{ pending: tx.status === 'pending' }">
<span class="f-ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="15" /></span>
<span class="f-body">
<b>{{ tx.title }}</b>
<small>
<RouterLink v-if="tx.link && tx.entityTitle" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
<template v-else-if="tx.entityTitle">{{ tx.entityTitle }}</template>
<template v-if="tx.entityTitle"> · </template>{{ timeAgo(tx.createdAt) }}
<template v-if="tx.status === 'pending'"> · در انتظار تأیید</template>
</small>
</span>
<span class="f-pts">
<b v-if="tx.score" :class="{ neg: tx.score < 0 }">{{ signedFa(tx.score) }}</b>
<small v-if="tx.coins" class="coin" :class="{ neg: tx.coins < 0 }">{{ signedFa(tx.coins) }} سکه</small>
</span>
</li>
<li v-if="!me?.recent.length" class="empty">
هنوز امتیازی نگرفته‌ای. ثبت لید، تماس یا انجام کارهای روتین امتیاز دارد.
</li>
</ul>
</section>
<section class="card">
<header class="card-h">
<h3>فعالیت همکاران</h3>
</header>
<ul class="feed compact">
<li v-for="tx in feed" :key="tx.id">
<span class="f-ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="14" /></span>
<span class="f-body">
<b>{{ tx.userName }}</b>
<small>{{ tx.title }} · {{ timeAgo(tx.createdAt) }}</small>
</span>
<span class="f-pts"><b>{{ signedFa(tx.score) }}</b></span>
</li>
<li v-if="!feed.length" class="empty">هنوز فعالیتی ثبت نشده است.</li>
</ul>
</section>
</aside>
</div>
</template>
<style scoped>
.home {
display: grid;
grid-template-columns: minmax(250px, 300px) minmax(0, 1fr) minmax(250px, 320px);
gap: var(--sp-4);
align-items: start;
}
.col {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
box-shadow: var(--shadow-xs);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.card-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
}
.card-h h3 {
margin: 0;
font-size: var(--fs-md);
font-weight: var(--fw-bold);
color: var(--ink);
}
.link {
border: 0;
background: none;
color: var(--accent-hover);
font: inherit;
font-size: var(--fs-xs);
font-weight: 700;
cursor: pointer;
}
.level {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ring {
--p: 0;
width: 84px;
height: 84px;
border-radius: 50%;
flex: 0 0 auto;
display: grid;
place-items: center;
background: conic-gradient(var(--accent) calc(var(--p) * 1%), var(--accent-soft) 0);
}
.ring span {
width: 66px;
height: 66px;
border-radius: 50%;
background: var(--surface);
display: grid;
place-items: center;
align-content: center;
line-height: 1.1;
}
.ring small {
font-size: 0.68rem;
color: var(--muted);
}
.ring b {
font-size: 1.4rem;
font-weight: 800;
color: var(--accent-deep);
}
.level-info {
display: grid;
gap: 2px;
min-width: 0;
}
.level-info strong {
font-size: 1.05rem;
color: var(--ink);
}
.level-info span {
font-size: var(--fs-sm);
color: var(--accent-deep);
font-weight: 700;
}
.level-info small {
font-size: var(--fs-xs);
color: var(--muted);
}
.mini-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2);
border-top: 1px solid var(--line-light);
padding-top: var(--sp-3);
text-align: center;
}
.mini-stats div {
display: grid;
}
.mini-stats b {
font-size: var(--fs-md);
color: var(--ink);
}
.mini-stats span {
font-size: 0.7rem;
color: var(--muted);
}
.rewards,
.feed {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: var(--sp-2);
}
.rewards li {
--c: var(--accent);
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2);
border-radius: 10px;
border: 1px solid var(--line-light);
background: color-mix(in srgb, var(--c) 6%, var(--surface));
}
.r-ic {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
flex: 0 0 auto;
color: var(--c);
background: color-mix(in srgb, var(--c) 16%, transparent);
}
.r-body {
flex: 1;
min-width: 0;
display: grid;
gap: 5px;
}
.r-body b {
font-size: var(--fs-sm);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.r-bar {
height: 5px;
border-radius: 999px;
background: var(--line-light);
overflow: hidden;
}
.r-bar i {
display: block;
height: 100%;
background: var(--c);
border-radius: inherit;
}
.rewards em {
font-style: normal;
font-weight: 800;
font-size: var(--fs-sm);
color: #b45309;
display: grid;
text-align: center;
line-height: 1.1;
}
.rewards em small {
font-size: 0.65rem;
font-weight: 600;
color: var(--muted);
}
.cheer {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
background: linear-gradient(145deg, #ecfdf5, #ccfbf1);
border-color: #99f6e4;
color: var(--accent-deep);
}
.cheer strong {
font-size: var(--fs-md);
}
.cheer p {
margin: 2px 0 0;
font-size: var(--fs-sm);
color: #115e59;
}
.feed li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) 0;
border-bottom: 1px dashed var(--line-light);
}
.feed li:last-child {
border-bottom: 0;
}
.feed li.pending {
opacity: 0.7;
}
.f-ic {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
flex: 0 0 auto;
background: var(--accent-subtle);
color: var(--accent-deep);
}
.f-ic.bonus,
.f-ic.achievement {
background: #fef3c7;
color: #b45309;
}
.f-ic.redeem,
.f-ic.refund {
background: #ede9fe;
color: #6d28d9;
}
.f-ic.adjust {
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.f-body {
flex: 1;
min-width: 0;
display: grid;
}
.f-body b {
font-size: var(--fs-sm);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.f-body small {
font-size: 0.72rem;
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.f-body a {
color: var(--accent-hover);
}
.f-pts {
display: grid;
text-align: end;
flex: 0 0 auto;
}
.f-pts b {
font-weight: 800;
color: var(--success-deep);
font-size: var(--fs-sm);
}
.f-pts .coin {
font-size: 0.68rem;
color: #b45309;
font-weight: 700;
}
.f-pts .neg {
color: var(--danger);
}
.feed.compact li {
padding: 6px 0;
}
.empty {
color: var(--muted);
font-size: var(--fs-sm);
justify-content: center;
text-align: center;
border: 0 !important;
background: none !important;
}
@media (max-width: 1200px) {
.home {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.center {
grid-column: 1 / -1;
order: -1;
}
}
@media (max-width: 760px) {
.home {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,378 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { GamificationLeaderboardDto, GamificationLeaderEntryDto, GamificationPeriod } from '@mento/shared';
import { GAMIFICATION_PERIOD_LABELS } 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';
const PERIODS: GamificationPeriod[] = ['today', 'week', 'month', 'quarter', 'year', 'all'];
const period = ref<GamificationPeriod>('month');
const board = ref<GamificationLeaderboardDto | null>(null);
const loading = ref(false);
const error = ref('');
async function load() {
loading.value = true;
try {
const { data } = await api.get<GamificationLeaderboardDto>('/gamification/leaderboard', {
params: { period: period.value, limit: 50 },
});
board.value = data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری جدول امتیازات ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(period, load);
onMounted(() => {
void load();
window.addEventListener('gamification:updated', load);
});
onBeforeUnmount(() => window.removeEventListener('gamification:updated', load));
const top3 = computed(() => (board.value?.items ?? []).slice(0, 3).filter((x) => x.score > 0));
const rest = computed(() => {
const items = board.value?.items ?? [];
return top3.value.length ? items.slice(top3.value.length) : items;
});
const meOutside = computed(() => {
const b = board.value;
if (!b?.me) return null;
return b.items.some((i) => i.userId === b.me!.userId) ? null : b.me;
});
function changeLabel(e: GamificationLeaderEntryDto) {
if (!e.change) return '';
return `${toFaDigits(Math.abs(e.change))}`;
}
const MEDALS = ['#f59e0b', '#94a3b8', '#c2410c'];
</script>
<template>
<section class="board">
<header class="b-head">
<div>
<h3><UiIcon name="trophy" :size="18" /> جدول امتیازات</h3>
<small v-if="board">{{ toFaDigits(board.totalUsers) }} همکار در رقابت</small>
</div>
<div class="periods" role="tablist">
<button
v-for="p in PERIODS"
:key="p"
type="button"
role="tab"
:aria-selected="period === p"
:class="{ on: period === p }"
@click="period = p"
>
{{ GAMIFICATION_PERIOD_LABELS[p] }}
</button>
</div>
</header>
<p v-if="error" class="err">{{ error }}</p>
<div v-if="top3.length" class="top3" :class="{ loading }">
<div
v-for="(e, i) in top3"
:key="e.userId"
class="t-card"
:class="{ me: e.isMe }"
:style="{ '--m': MEDALS[i] }"
>
<span class="t-rank">{{ toFaDigits(e.rank) }}</span>
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="46" />
<strong>{{ e.isMe ? 'شما' : e.fullName }}</strong>
<small>{{ e.levelName }}</small>
<b>{{ faNum(e.score) }} <span>امتیاز</span></b>
</div>
</div>
<ol class="rows" :class="{ loading }">
<li v-for="e in rest" :key="e.userId" :class="{ me: e.isMe }">
<span class="rank">{{ toFaDigits(e.rank) }}</span>
<UserAvatar :user-id="e.userId" :has-avatar="e.hasAvatar" :name="e.fullName" :size="32" />
<span class="who">
<b>{{ e.isMe ? 'شما' : e.fullName }}</b>
<small>{{ e.levelName }}</small>
</span>
<span v-if="e.change" class="chg" :class="e.change > 0 ? 'up' : 'down'">
{{ e.change > 0 ? '▲' : '▼' }}{{ changeLabel(e) }}
</span>
<span class="score">{{ faNum(e.score) }}</span>
</li>
<template v-if="meOutside">
<li class="sep" aria-hidden="true">⋯</li>
<li class="me">
<span class="rank">{{ toFaDigits(meOutside.rank) }}</span>
<UserAvatar :user-id="meOutside.userId" :has-avatar="meOutside.hasAvatar" :name="meOutside.fullName" :size="32" />
<span class="who">
<b>شما</b>
<small>{{ meOutside.levelName }}</small>
</span>
<span v-if="meOutside.change" class="chg" :class="meOutside.change > 0 ? 'up' : 'down'">
{{ meOutside.change > 0 ? '▲' : '▼' }}{{ changeLabel(meOutside) }}
</span>
<span class="score">{{ faNum(meOutside.score) }}</span>
</li>
</template>
<li v-if="board && !board.items.length" class="empty">هنوز کسی در این بازه امتیاز نگرفته است.</li>
</ol>
</section>
</template>
<style scoped>
.board {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
box-shadow: var(--shadow-xs);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.b-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
flex-wrap: wrap;
}
.b-head h3 {
margin: 0;
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-md);
color: var(--ink);
}
.b-head h3 :deep(svg) {
color: #f59e0b;
}
.b-head small {
font-size: var(--fs-xs);
color: var(--muted);
}
.periods {
display: flex;
gap: 4px;
background: var(--surface-subtle);
border-radius: 999px;
padding: 3px;
max-width: 100%;
overflow-x: auto;
scrollbar-width: none;
}
.periods::-webkit-scrollbar {
display: none;
}
.periods button {
border: 0;
background: none;
border-radius: 999px;
padding: 0.3rem 0.7rem;
font: inherit;
font-size: var(--fs-xs);
font-weight: 700;
color: var(--muted);
cursor: pointer;
white-space: nowrap;
flex: 0 0 auto;
}
.periods button.on {
background: var(--surface);
color: var(--accent-deep);
box-shadow: var(--shadow-xs);
}
.err {
margin: 0;
color: var(--danger);
font-size: var(--fs-sm);
}
.top3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2);
}
.t-card {
--m: #f59e0b;
position: relative;
display: grid;
justify-items: center;
gap: 3px;
padding: var(--sp-3) var(--sp-2);
border-radius: 12px;
border: 1px solid color-mix(in srgb, var(--m) 35%, transparent);
background: linear-gradient(180deg, color-mix(in srgb, var(--m) 12%, var(--surface)), var(--surface));
text-align: center;
min-width: 0;
}
.t-card.me {
box-shadow: 0 0 0 2px var(--accent);
}
.t-rank {
position: absolute;
top: 8px;
inset-inline-start: 8px;
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--m);
color: #fff;
font-size: 0.75rem;
font-weight: 800;
}
.t-card strong {
font-size: var(--fs-sm);
color: var(--ink);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.t-card small {
font-size: 0.7rem;
color: var(--muted);
}
.t-card b {
font-size: var(--fs-md);
font-weight: 800;
color: var(--ink);
}
.t-card b span {
font-size: 0.68rem;
font-weight: 600;
color: var(--muted);
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.rows li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: 7px var(--sp-2);
border-radius: 10px;
transition: background 0.15s ease;
}
.rows li:hover {
background: var(--surface-hover);
}
.rows li.me {
background: var(--accent-subtle);
box-shadow: inset 0 0 0 1px var(--accent-soft);
}
.rows li.sep {
justify-content: center;
color: var(--muted-light);
padding: 0;
}
.rank {
width: 26px;
text-align: center;
font-weight: 800;
color: var(--muted);
font-size: var(--fs-sm);
flex: 0 0 auto;
}
.who {
flex: 1;
min-width: 0;
display: grid;
}
.who b {
font-size: var(--fs-sm);
color: var(--ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who small {
font-size: 0.68rem;
color: var(--muted);
}
.chg {
font-size: 0.7rem;
font-weight: 800;
flex: 0 0 auto;
}
.chg.up {
color: var(--success-deep);
}
.chg.down {
color: var(--muted-light);
}
.score {
font-weight: 800;
color: var(--ink);
font-size: var(--fs-sm);
min-width: 52px;
text-align: end;
flex: 0 0 auto;
}
.loading {
opacity: 0.55;
}
.empty {
justify-content: center;
color: var(--muted);
font-size: var(--fs-sm);
}
@media (max-width: 520px) {
.top3 {
gap: 6px;
}
.t-card {
padding: var(--sp-2) 4px;
}
}
</style>
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { GamificationRuleDto } from '@mento/shared';
import { GAMIFICATION_EVENT_GROUP_LABELS, GAMIFICATION_EVENTS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiModal } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum, txIcon } from '@/lib/gamification';
import { conditionText } from './rule-text';
const props = defineProps<{ open: boolean }>();
defineEmits<{ close: [] }>();
const rules = ref<GamificationRuleDto[]>([]);
const error = ref('');
watch(
() => props.open,
async (open) => {
if (!open || rules.value.length) return;
try {
rules.value = (await api.get<GamificationRuleDto[]>('/gamification/rules')).data;
} catch (e) {
error.value = apiError(e, 'بارگذاری قوانین ممکن نشد.');
}
},
);
const groups = computed(() => {
const map = new Map<string, GamificationRuleDto[]>();
for (const r of rules.value) {
const g = GAMIFICATION_EVENTS[r.event]?.group ?? 'activities';
map.set(g, [...(map.get(g) ?? []), r]);
}
return [...map.entries()].map(([key, list]) => ({
key,
label: GAMIFICATION_EVENT_GROUP_LABELS[key as keyof typeof GAMIFICATION_EVENT_GROUP_LABELS] ?? key,
list,
}));
});
function limits(r: GamificationRuleDto): string[] {
const out: string[] = [];
if (r.maxPerDay) out.push(`حداکثر ${toFaDigits(r.maxPerDay)} بار در روز`);
if (r.maxCoinsPerDay) out.push(`حداکثر ${faNum(r.maxCoinsPerDay)} سکه در روز`);
if (r.maxPerUser) out.push(`حداکثر ${toFaDigits(r.maxPerUser)} بار در کل`);
if (r.cooldownMinutes) out.push(`فاصله حداقل ${toFaDigits(r.cooldownMinutes)} دقیقه`);
if (r.oncePerEntity) out.push('یک بار برای هر پرونده');
if (r.firstTimeOnly) out.push('فقط بار اول');
if (r.requiresApproval) out.push('نیاز به تأیید مدیر');
return out;
}
</script>
<template>
<UiModal :open="open" title="قوانین و نحوه امتیازدهی" @close="$emit('close')">
<div class="rules">
<p class="intro">
<b>امتیاز</b> رتبه و سطح شما را تعیین می‌کند و هرگز خرج نمی‌شود. <b>سکه</b> را می‌توانی در فروشگاه
پاداش خرج کنی. هر کار فقط یک بار امتیاز می‌گیرد و محدودیت‌ها جلوی ثبت‌های تکراری را می‌گیرند.
</p>
<p v-if="error" class="err">{{ error }}</p>
<section v-for="g in groups" :key="g.key" class="group">
<h4>{{ g.label }}</h4>
<ul>
<li v-for="r in g.list" :key="r.id">
<span class="ic"><UiIcon :name="txIcon({ kind: 'rule', event: r.event })" :size="15" /></span>
<span class="body">
<b>{{ r.name }}</b>
<small v-if="r.conditions.length">{{ r.conditions.map(conditionText).join(' و ') }}</small>
<small v-if="limits(r).length" class="lim">{{ limits(r).join(' · ') }}</small>
</span>
<span class="pts">
<b v-if="r.score">+{{ faNum(r.score) }}</b>
<em v-if="r.coins">+{{ faNum(r.coins) }} سکه</em>
</span>
</li>
</ul>
</section>
</div>
</UiModal>
</template>
<style scoped>
.rules {
display: grid;
gap: var(--sp-3);
max-height: 70vh;
overflow-y: auto;
}
.intro {
margin: 0;
padding: var(--sp-3);
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
font-size: var(--fs-sm);
line-height: 1.9;
}
.err {
margin: 0;
color: var(--danger);
}
.group h4 {
margin: 0 0 var(--sp-1);
font-size: var(--fs-sm);
color: var(--muted);
}
.group ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
.group li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--line-light);
}
.ic {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
flex: 0 0 auto;
}
.body {
flex: 1;
min-width: 0;
display: grid;
}
.body b {
font-size: var(--fs-sm);
}
.body small {
font-size: 0.72rem;
color: var(--muted);
}
.lim {
color: var(--warning-deep) !important;
}
.pts {
display: grid;
text-align: end;
flex: 0 0 auto;
}
.pts b {
color: var(--success-deep);
font-weight: 800;
}
.pts em {
font-style: normal;
font-size: 0.72rem;
color: #b45309;
font-weight: 700;
}
</style>
@@ -0,0 +1,353 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import type { GamificationRedemptionDto, GamificationRewardDto } from '@mento/shared';
import { GAMIFICATION_REDEMPTION_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiModal, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, faNum, formatDateTime } from '@/lib/gamification';
import { useClub } from './club-context';
const { me, reload } = useClub();
const rewards = ref<GamificationRewardDto[]>([]);
const mine = ref<GamificationRedemptionDto[]>([]);
const error = ref('');
const ok = ref('');
const picked = ref<GamificationRewardDto | null>(null);
const note = ref('');
const busy = ref(false);
const coins = computed(() => me.value?.coins ?? 0);
async function load() {
try {
const [r, m] = await Promise.all([
api.get<GamificationRewardDto[]>('/gamification/rewards'),
api.get<GamificationRedemptionDto[]>('/gamification/redemptions'),
]);
rewards.value = r.data;
mine.value = m.data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری فروشگاه ممکن نشد.');
}
}
function open(r: GamificationRewardDto) {
ok.value = '';
error.value = '';
note.value = '';
picked.value = r;
}
async function confirm() {
if (!picked.value) return;
busy.value = true;
try {
const { data } = await api.post<GamificationRedemptionDto>('/gamification/rewards/redeem', {
rewardId: picked.value.id,
note: note.value.trim() || null,
});
ok.value =
data.status === 'pending'
? `درخواست «${data.rewardName}» ثبت شد و پس از تأیید مدیر تحویل می‌شود.`
: `«${data.rewardName}» برای شما ثبت شد.`;
picked.value = null;
await Promise.all([load(), reload()]);
} catch (e) {
error.value = apiError(e, 'ثبت درخواست ممکن نشد.');
picked.value = null;
} finally {
busy.value = false;
}
}
onMounted(load);
</script>
<template>
<section class="store">
<div class="wallet">
<UiIcon name="dollar" :size="20" />
<span>موجودی شما: <b>{{ faNum(coins) }}</b> سکه</span>
<small>سکه را با انجام فعالیت‌ها، نشان‌ها و بونوس‌ها جمع کن.</small>
</div>
<p v-if="ok" class="ok">{{ ok }}</p>
<p v-if="error" class="err">{{ error }}</p>
<div class="grid">
<article
v-for="r in rewards"
:key="r.id"
class="reward"
:style="{ '--c': r.color }"
:class="{ locked: coins < r.cost }"
>
<span class="ic"><UiIcon :name="r.icon as never" :size="26" /></span>
<h4>{{ r.name }}</h4>
<p v-if="r.description">{{ r.description }}</p>
<div class="price">
<b>{{ faNum(r.cost) }}</b>
<small>سکه</small>
</div>
<div class="bar"><i :style="{ width: `${Math.min(100, Math.round((coins / r.cost) * 100))}%` }" /></div>
<small v-if="r.stock != null" class="stock">موجودی: {{ faNum(r.stock) }}</small>
<UiButton
size="sm"
block
:variant="coins >= r.cost ? 'primary' : 'outline'"
:disabled="coins < r.cost || r.stock === 0"
@click="open(r)"
>
{{ coins >= r.cost ? 'دریافت پاداش' : `${faNum(r.cost - coins)} سکه دیگر` }}
</UiButton>
</article>
<p v-if="!rewards.length" class="empty">هنوز پاداشی تعریف نشده است.</p>
</div>
<section v-if="mine.length" class="mine">
<h3>درخواست‌های من</h3>
<ul>
<li v-for="m in mine" :key="m.id">
<UiIcon :name="m.rewardIcon as never" :size="16" />
<span class="m-body">
<b>{{ m.rewardName }}</b>
<small>{{ formatDateTime(m.createdAt) }}<template v-if="m.decisionNote"> · {{ m.decisionNote }}</template></small>
</span>
<span class="cost">{{ faNum(m.cost) }} سکه</span>
<span class="st" :class="m.status">{{ GAMIFICATION_REDEMPTION_LABELS[m.status] }}</span>
</li>
</ul>
</section>
<UiModal :open="!!picked" title="تأیید دریافت پاداش" @close="picked = null">
<div v-if="picked" class="confirm">
<p>
برای دریافت <b>«{{ picked.name }}»</b> مبلغ <b>{{ faNum(picked.cost) }}</b> سکه از موجودی شما کسر می‌شود.
</p>
<p class="muted">
موجودی پس از دریافت: {{ faNum(coins - picked.cost) }} سکه
<template v-if="picked.requiresApproval"> — این درخواست پس از تأیید مدیر نهایی می‌شود و در صورت رد، سکه‌ها برمی‌گردد.</template>
</p>
<UiTextarea v-model="note" placeholder="توضیح (اختیاری) — مثلاً زمان یا مدل دلخواه" :rows="2" />
</div>
<template #footer>
<UiButton variant="ghost" @click="picked = null">انصراف</UiButton>
<UiButton :loading="busy" @click="confirm">تأیید و دریافت</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.store {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.wallet {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
padding: var(--sp-3) var(--sp-4);
border-radius: calc(var(--radius) + 4px);
background: linear-gradient(135deg, #fffbeb, #fef3c7);
border: 1px solid #fde68a;
color: #92400e;
}
.wallet b {
font-size: 1.2rem;
}
.wallet small {
color: #b45309;
margin-inline-start: auto;
}
.ok {
margin: 0;
color: var(--success-deep);
background: var(--success-soft);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
}
.err {
margin: 0;
color: var(--danger);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
gap: var(--sp-3);
}
.reward {
--c: var(--accent);
display: grid;
gap: var(--sp-2);
align-content: start;
padding: var(--sp-4);
border-radius: calc(var(--radius) + 4px);
background: var(--surface);
border: 1px solid var(--line);
border-top: 3px solid var(--c);
box-shadow: var(--shadow-xs);
min-width: 0;
}
.ic {
width: 52px;
height: 52px;
border-radius: 14px;
display: grid;
place-items: center;
color: var(--c);
background: color-mix(in srgb, var(--c) 14%, transparent);
}
.reward h4 {
margin: 0;
font-size: var(--fs-md);
color: var(--ink);
}
.reward p {
margin: 0;
font-size: var(--fs-sm);
color: var(--muted);
}
.price {
display: flex;
align-items: baseline;
gap: 4px;
color: #b45309;
}
.price b {
font-size: 1.3rem;
font-weight: 800;
}
.bar {
height: 6px;
border-radius: 999px;
background: var(--line-light);
overflow: hidden;
}
.bar i {
display: block;
height: 100%;
background: var(--c);
}
.stock {
font-size: var(--fs-xs);
color: var(--muted);
}
.empty {
color: var(--muted);
}
.mine {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
display: grid;
gap: var(--sp-2);
}
.mine h3 {
margin: 0;
font-size: var(--fs-md);
}
.mine ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
.mine li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--line-light);
flex-wrap: wrap;
}
.mine li:last-child {
border-bottom: 0;
}
.m-body {
flex: 1;
min-width: 0;
display: grid;
}
.m-body b {
font-size: var(--fs-sm);
}
.m-body small {
font-size: var(--fs-xs);
color: var(--muted);
}
.cost {
font-size: var(--fs-xs);
color: #b45309;
font-weight: 700;
}
.st {
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-subtle);
color: var(--ink-secondary);
}
.st.pending {
background: var(--warning-soft);
color: var(--warning-deep);
}
.st.approved,
.st.delivered {
background: var(--success-soft);
color: var(--success-deep);
}
.st.rejected {
background: var(--danger-soft);
color: var(--danger);
}
.confirm {
display: grid;
gap: var(--sp-3);
}
.confirm p {
margin: 0;
}
.muted {
color: var(--muted);
font-size: var(--fs-sm);
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import type { GamificationPeriod, GamificationTeamEntryDto } from '@mento/shared';
import { GAMIFICATION_PERIOD_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiSegmented } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification';
const PERIODS: GamificationPeriod[] = ['week', 'month', 'quarter', 'year', 'all'];
const options = PERIODS.map((p) => ({ value: p, label: GAMIFICATION_PERIOD_LABELS[p] }));
const period = ref<GamificationPeriod>('month');
const teams = ref<GamificationTeamEntryDto[]>([]);
const error = ref('');
const loaded = ref(false);
async function load() {
try {
teams.value = (
await api.get<GamificationTeamEntryDto[]>('/gamification/leaderboard/teams', {
params: { period: period.value },
})
).data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری رقابت تیمی ممکن نشد.');
} finally {
loaded.value = true;
}
}
watch(period, load);
onMounted(load);
function share(t: GamificationTeamEntryDto) {
const max = Math.max(1, ...teams.value.map((x) => x.score));
return Math.round((Math.max(0, t.score) / max) * 100);
}
</script>
<template>
<section class="teams">
<div class="bar-top">
<UiSegmented v-model="period" :options="options" />
</div>
<p v-if="error" class="err">{{ error }}</p>
<ol class="list">
<li v-for="t in teams" :key="t.teamId" :class="{ mine: t.isMine }">
<span class="rank" :class="`r${t.rank}`">
<UiIcon v-if="t.rank === 1" name="crown" :size="16" />
<template v-else>{{ toFaDigits(t.rank) }}</template>
</span>
<span class="body">
<b>{{ t.name }} <small v-if="t.isMine">تیم شما</small></b>
<span class="track"><i :style="{ width: `${share(t)}%` }" /></span>
<small>{{ toFaDigits(t.members) }} عضو · میانگین {{ faNum(t.avgScore) }} امتیاز</small>
</span>
<span class="score">{{ faNum(t.score) }}</span>
</li>
<li v-if="loaded && !teams.length" class="empty">
هنوز تیمی تعریف نشده است. تیم‌ها از «تنظیمات › تیم‌ها» ساخته می‌شوند.
</li>
</ol>
</section>
</template>
<style scoped>
.teams {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.err {
margin: 0;
color: var(--danger);
}
.list {
list-style: none;
margin: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
overflow: hidden;
}
.list li {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--line-light);
}
.list li:last-child {
border-bottom: 0;
}
.list li.mine {
background: var(--accent-subtle);
}
.rank {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
font-weight: 800;
background: var(--surface-subtle);
color: var(--muted);
flex: 0 0 auto;
}
.rank.r1 {
background: #fef3c7;
color: #b45309;
}
.body {
flex: 1;
min-width: 0;
display: grid;
gap: 4px;
}
.body b {
font-size: var(--fs-sm);
}
.body b small {
font-size: 0.68rem;
color: var(--accent-deep);
background: var(--accent-soft);
padding: 1px 6px;
border-radius: 999px;
}
.body > small {
font-size: var(--fs-xs);
color: var(--muted);
}
.track {
height: 6px;
border-radius: 999px;
background: var(--line-light);
overflow: hidden;
}
.track i {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), #5eead4);
}
.score {
font-weight: 800;
font-size: var(--fs-md);
flex: 0 0 auto;
}
.empty {
justify-content: center;
color: var(--muted);
text-align: center;
}
</style>
@@ -0,0 +1,228 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import type { GamificationPulseDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { faNum, txIcon } from '@/lib/gamification';
const POLL_MS = 45_000;
const MIN_GAP_MS = 8_000;
const TOAST_MS = 5_500;
interface Toast {
id: number;
icon: ReturnType<typeof txIcon> | 'crown';
title: string;
points: string;
rankUp: boolean;
}
const route = useRoute();
const router = useRouter();
const toasts = ref<Toast[]>([]);
let since: string | null = null;
let rank: number | null = null;
let lastPoll = 0;
let stopped = false;
let busy = false;
let timer: ReturnType<typeof setInterval> | null = null;
let seq = 0;
function push(t: Omit<Toast, 'id'>) {
const id = ++seq;
toasts.value = [...toasts.value.slice(-3), { ...t, id }];
setTimeout(() => dismiss(id), TOAST_MS);
}
function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id);
}
async function poll() {
if (stopped || busy || document.visibilityState !== 'visible') return;
busy = true;
lastPoll = Date.now();
try {
const { data } = await api.get<GamificationPulseDto>('/gamification/pulse', {
params: since ? { since } : {},
});
const first = since == null;
since = data.serverTime;
if (!first) {
for (const tx of data.items) {
const parts = [
tx.score ? `${tx.score > 0 ? '+' : '−'}${faNum(Math.abs(tx.score))} امتیاز` : '',
tx.coins ? `${tx.coins > 0 ? '+' : '−'}${faNum(Math.abs(tx.coins))} سکه` : '',
].filter(Boolean);
push({ icon: txIcon(tx), title: tx.title, points: parts.join(' · '), rankUp: false });
}
if (data.rank != null && rank != null && data.rank < rank) {
push({
icon: 'crown',
title: `رتبه شما در این ماه به #${toFaDigits(data.rank)} رسید!`,
points: `${toFaDigits(rank - data.rank)} پله صعود`,
rankUp: true,
});
}
if (data.items.length) window.dispatchEvent(new CustomEvent('gamification:updated'));
}
rank = data.rank;
} catch (e) {
const status = axios.isAxiosError(e) ? e.response?.status : undefined;
if (status === 401 || status === 403 || status === 404) stopped = true;
} finally {
busy = false;
}
}
function onVisible() {
if (document.visibilityState === 'visible' && Date.now() - lastPoll > MIN_GAP_MS) void poll();
}
watch(
() => route.fullPath,
() => {
if (Date.now() - lastPoll > MIN_GAP_MS) setTimeout(() => void poll(), 1_500);
},
);
onMounted(() => {
void poll();
timer = setInterval(() => void poll(), POLL_MS);
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('gamification:poke', onVisible);
});
onBeforeUnmount(() => {
if (timer) clearInterval(timer);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('gamification:poke', onVisible);
});
function open(t: Toast) {
dismiss(t.id);
void router.push('/club');
}
</script>
<template>
<div class="g-toasts" aria-live="polite">
<TransitionGroup name="g-toast">
<button
v-for="t in toasts"
:key="t.id"
type="button"
class="g-toast"
:class="{ rank: t.rankUp }"
@click="open(t)"
>
<span class="ic"><UiIcon :name="t.icon" :size="18" /></span>
<span class="body">
<b>{{ t.points || 'امتیاز جدید' }}</b>
<small>{{ t.title }}</small>
</span>
<UiIcon name="star" :size="14" class="spark" />
</button>
</TransitionGroup>
</div>
</template>
<style scoped>
.g-toasts {
position: fixed;
inset-inline-start: 16px;
bottom: 16px;
z-index: 1200;
display: grid;
gap: 8px;
pointer-events: none;
max-width: min(340px, calc(100vw - 32px));
}
.g-toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 0;
border-radius: 14px;
color: #ecfeff;
background: linear-gradient(135deg, #0b3b3c, #0f766e);
box-shadow: 0 14px 30px -14px rgba(15, 81, 84, 0.9);
font: inherit;
text-align: start;
cursor: pointer;
}
.g-toast.rank {
background: linear-gradient(135deg, #92400e, #f59e0b);
}
.ic {
width: 34px;
height: 34px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.16);
flex: 0 0 auto;
}
.body {
display: grid;
min-width: 0;
}
.body b {
font-size: var(--fs-sm);
font-weight: 800;
color: #fde68a;
}
.g-toast.rank .body b {
color: #fff;
}
.body small {
font-size: var(--fs-xs);
opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.spark {
color: #fde68a;
margin-inline-start: auto;
}
.g-toast-enter-active,
.g-toast-leave-active {
transition:
transform 0.3s ease,
opacity 0.3s ease;
}
.g-toast-enter-from {
transform: translateY(16px) scale(0.96);
opacity: 0;
}
.g-toast-leave-to {
opacity: 0;
transform: translateX(-16px);
}
@media (max-width: 760px) {
.g-toasts {
bottom: calc(76px + env(safe-area-inset-bottom, 0px));
inset-inline-start: 12px;
inset-inline-end: 12px;
max-width: none;
}
}
</style>
@@ -0,0 +1,531 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import type {
GamificationBadgeDto,
GamificationBadgeMetric,
GamificationBonusDto,
GamificationBonusKind,
GamificationEvent,
SaveGamificationBadgeRequest,
SaveGamificationBonusRequest,
} from '@mento/shared';
import {
GAMIFICATION_BADGE_METRICS,
GAMIFICATION_BONUS_KINDS,
GAMIFICATION_EVENT_KEYS,
GAMIFICATION_EVENTS,
} from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, BADGE_ICONS, eventLabel, faNum } from '@/lib/gamification';
const COLORS = ['#f59e0b', '#0d9488', '#8b5cf6', '#ef4444', '#3b82f6', '#10b981', '#ec4899'];
const PERIOD_LABELS = { day: 'روز', week: 'هفته', month: 'ماه' } as const;
const badges = ref<GamificationBadgeDto[]>([]);
const bonuses = ref<GamificationBonusDto[]>([]);
const error = ref('');
async function load() {
try {
const [b, o] = await Promise.all([
api.get<GamificationBadgeDto[]>('/admin/gamification/badges'),
api.get<GamificationBonusDto[]>('/admin/gamification/bonuses'),
]);
badges.value = b.data;
bonuses.value = o.data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری نشان‌ها و بونوس‌ها ممکن نشد.');
}
}
/* نشان */
const badgeOpen = ref(false);
const badgeId = ref<string | null>(null);
const badgeErr = ref('');
const badgeBusy = ref(false);
const bf = reactive({
name: '',
description: '',
icon: 'medal',
color: COLORS[0],
metric: 'event_count' as string,
event: 'DEAL_WON' as string,
threshold: '10',
coins: '0',
isActive: true,
});
function editBadge(b: GamificationBadgeDto | null) {
badgeId.value = b?.id ?? null;
badgeErr.value = '';
Object.assign(bf, {
name: b?.name ?? '',
description: b?.description ?? '',
icon: b?.icon ?? 'medal',
color: b?.color ?? COLORS[0],
metric: b?.metric ?? 'event_count',
event: b?.event ?? 'DEAL_WON',
threshold: String(b?.threshold ?? 10),
coins: String(b?.coins ?? 0),
isActive: b?.isActive ?? true,
});
badgeOpen.value = true;
}
async function saveBadge() {
const threshold = Math.round(Number(bf.threshold));
if (bf.name.trim().length < 2 || !(threshold > 0)) {
badgeErr.value = 'نام و آستانه معتبر وارد کنید.';
return;
}
const body: SaveGamificationBadgeRequest = {
name: bf.name.trim(),
description: bf.description.trim() || null,
icon: bf.icon,
color: bf.color,
metric: bf.metric as GamificationBadgeMetric,
event: bf.metric === 'event_count' ? (bf.event as GamificationEvent) : null,
threshold,
coins: Math.max(0, Math.round(Number(bf.coins) || 0)),
isActive: bf.isActive,
};
badgeBusy.value = true;
try {
if (badgeId.value) await api.patch(`/admin/gamification/badges/${badgeId.value}`, body);
else await api.post('/admin/gamification/badges', body);
badgeOpen.value = false;
await load();
} catch (e) {
badgeErr.value = apiError(e, 'ذخیره نشان ممکن نشد.');
} finally {
badgeBusy.value = false;
}
}
/* بونوس */
const bonusOpen = ref(false);
const bonusId = ref<string | null>(null);
const bonusErr = ref('');
const bonusBusy = ref(false);
const of = reactive({
name: '',
description: '',
kind: 'event_count_period' as string,
event: 'DEAL_WON' as string,
threshold: '10',
period: 'month' as string,
score: '0',
coins: '100',
isActive: true,
});
function editBonus(b: GamificationBonusDto | null) {
bonusId.value = b?.id ?? null;
bonusErr.value = '';
Object.assign(of, {
name: b?.name ?? '',
description: b?.description ?? '',
kind: b?.kind ?? 'event_count_period',
event: b?.event ?? 'DEAL_WON',
threshold: String(b?.threshold ?? 10),
period: b?.period ?? 'month',
score: String(b?.score ?? 0),
coins: String(b?.coins ?? 100),
isActive: b?.isActive ?? true,
});
bonusOpen.value = true;
}
const thresholdLabel = computed(() => (of.kind === 'routine_streak' ? 'هر چند روز پیاپی' : 'تعداد لازم'));
async function saveBonus() {
const threshold = Math.round(Number(of.threshold));
if (of.name.trim().length < 2 || !(threshold >= 0)) {
bonusErr.value = 'نام و آستانه معتبر وارد کنید.';
return;
}
const body: SaveGamificationBonusRequest = {
name: of.name.trim(),
description: of.description.trim() || null,
kind: of.kind as GamificationBonusKind,
event: of.kind === 'event_count_period' ? (of.event as GamificationEvent) : null,
threshold,
period: of.period as 'day' | 'week' | 'month',
score: Math.round(Number(of.score) || 0),
coins: Math.max(0, Math.round(Number(of.coins) || 0)),
isActive: of.isActive,
};
bonusBusy.value = true;
try {
if (bonusId.value) await api.patch(`/admin/gamification/bonuses/${bonusId.value}`, body);
else await api.post('/admin/gamification/bonuses', body);
bonusOpen.value = false;
await load();
} catch (e) {
bonusErr.value = apiError(e, 'ذخیره بونوس ممکن نشد.');
} finally {
bonusBusy.value = false;
}
}
async function remove(kind: 'badges' | 'bonuses', id: string, name: string) {
if (!window.confirm(`«${name}» حذف شود؟`)) return;
try {
await api.delete(`/admin/gamification/${kind}/${id}`);
await load();
} catch (e) {
error.value = apiError(e, 'حذف ممکن نشد.');
}
}
function bonusText(b: GamificationBonusDto): string {
if (b.kind === 'routine_streak') return `هر ${faNum(b.threshold)} روز پیاپی انجام کامل روتین`;
if (b.kind === 'top_of_month') return 'نفر اول امتیاز در پایان هر ماه';
return `${faNum(b.threshold)} بار «${eventLabel(b.event)}» در یک ${PERIOD_LABELS[b.period]}`;
}
onMounted(load);
</script>
<template>
<section class="ach">
<p v-if="error" class="err">{{ error }}</p>
<article class="card">
<div class="head">
<h3><UiIcon name="medal" :size="17" /> نشان‌ها</h3>
<UiButton size="sm" @click="editBadge(null)"><UiIcon name="plus" :size="14" /> نشان جدید</UiButton>
</div>
<ul class="list">
<li v-for="b in badges" :key="b.id" :class="{ off: !b.isActive }" :style="{ '--c': b.color }">
<span class="ic"><UiIcon :name="b.icon as never" :size="16" /></span>
<span class="body">
<b>{{ b.name }}</b>
<small>
{{ GAMIFICATION_BADGE_METRICS[b.metric] }}<template v-if="b.event"> «{{ eventLabel(b.event) }}»</template>
≥ {{ faNum(b.threshold) }}
<template v-if="b.coins"> · +{{ faNum(b.coins) }} سکه</template>
</small>
</span>
<span class="count">{{ faNum(b.earnedCount) }} نفر</span>
<span class="acts">
<button type="button" title="ویرایش" @click="editBadge(b)"><UiIcon name="edit" :size="15" /></button>
<button type="button" class="del" title="حذف" @click="remove('badges', b.id, b.name)"><UiIcon name="trash" :size="15" /></button>
</span>
</li>
<li v-if="!badges.length" class="empty">نشانی تعریف نشده است.</li>
</ul>
</article>
<article class="card">
<div class="head">
<h3><UiIcon name="star" :size="17" /> بونوس‌ها</h3>
<UiButton size="sm" @click="editBonus(null)"><UiIcon name="plus" :size="14" /> بونوس جدید</UiButton>
</div>
<ul class="list">
<li v-for="b in bonuses" :key="b.id" :class="{ off: !b.isActive }" style="--c: #f59e0b">
<span class="ic"><UiIcon name="star" :size="16" /></span>
<span class="body">
<b>{{ b.name }}</b>
<small>{{ bonusText(b) }}</small>
</span>
<span class="pts">
<b v-if="b.score">+{{ faNum(b.score) }}</b>
<em v-if="b.coins">+{{ faNum(b.coins) }} سکه</em>
</span>
<span class="count">{{ faNum(b.awardedCount) }}×</span>
<span class="acts">
<button type="button" title="ویرایش" @click="editBonus(b)"><UiIcon name="edit" :size="15" /></button>
<button type="button" class="del" title="حذف" @click="remove('bonuses', b.id, b.name)"><UiIcon name="trash" :size="15" /></button>
</span>
</li>
<li v-if="!bonuses.length" class="empty">بونوسی تعریف نشده است.</li>
</ul>
</article>
<UiModal :open="badgeOpen" :title="badgeId ? 'ویرایش نشان' : 'نشان جدید'" @close="badgeOpen = false">
<form class="form" @submit.prevent="saveBadge">
<UiField label="نام" required><UiInput v-model="bf.name" maxlength="120" /></UiField>
<UiField label="شاخص">
<UiSelect v-model="bf.metric">
<option v-for="(label, key) in GAMIFICATION_BADGE_METRICS" :key="key" :value="key">{{ label }}</option>
</UiSelect>
</UiField>
<UiField v-if="bf.metric === 'event_count'" label="رویداد">
<UiSelect v-model="bf.event">
<option v-for="k in GAMIFICATION_EVENT_KEYS" :key="k" :value="k">{{ GAMIFICATION_EVENTS[k].label }}</option>
</UiSelect>
</UiField>
<div class="row">
<UiField label="آستانه" required><UiInput v-model="bf.threshold" type="number" /></UiField>
<UiField label="سکه هدیه"><UiInput v-model="bf.coins" type="number" /></UiField>
</div>
<UiField label="توضیح"><UiTextarea v-model="bf.description" :rows="2" /></UiField>
<div class="pick">
<span>آیکن:</span>
<button v-for="ic in BADGE_ICONS" :key="ic" type="button" :class="{ on: bf.icon === ic }" @click="bf.icon = ic">
<UiIcon :name="ic" :size="16" />
</button>
</div>
<div class="pick">
<span>رنگ:</span>
<button
v-for="c in COLORS"
:key="c"
type="button"
class="dot"
:class="{ on: bf.color === c }"
:style="{ background: c }"
:aria-label="c"
@click="bf.color = c"
/>
</div>
<label class="ck"><input v-model="bf.isActive" type="checkbox" /> فعال</label>
<p v-if="badgeErr" class="err">{{ badgeErr }}</p>
</form>
<template #footer>
<UiButton variant="ghost" @click="badgeOpen = false">انصراف</UiButton>
<UiButton :loading="badgeBusy" @click="saveBadge">ذخیره</UiButton>
</template>
</UiModal>
<UiModal :open="bonusOpen" :title="bonusId ? 'ویرایش بونوس' : 'بونوس جدید'" @close="bonusOpen = false">
<form class="form" @submit.prevent="saveBonus">
<UiField label="نام" required><UiInput v-model="of.name" maxlength="120" /></UiField>
<UiField label="نوع">
<UiSelect v-model="of.kind">
<option v-for="(label, key) in GAMIFICATION_BONUS_KINDS" :key="key" :value="key">{{ label }}</option>
</UiSelect>
</UiField>
<div v-if="of.kind === 'event_count_period'" class="row">
<UiField label="رویداد">
<UiSelect v-model="of.event">
<option v-for="k in GAMIFICATION_EVENT_KEYS" :key="k" :value="k">{{ GAMIFICATION_EVENTS[k].label }}</option>
</UiSelect>
</UiField>
<UiField label="در بازه">
<UiSelect v-model="of.period">
<option v-for="(label, key) in PERIOD_LABELS" :key="key" :value="key">{{ label }}</option>
</UiSelect>
</UiField>
</div>
<div class="row3">
<UiField v-if="of.kind !== 'top_of_month'" :label="thresholdLabel"><UiInput v-model="of.threshold" type="number" /></UiField>
<UiField label="امتیاز"><UiInput v-model="of.score" type="number" /></UiField>
<UiField label="سکه"><UiInput v-model="of.coins" type="number" /></UiField>
</div>
<UiField label="توضیح"><UiTextarea v-model="of.description" :rows="2" /></UiField>
<label class="ck"><input v-model="of.isActive" type="checkbox" /> فعال</label>
<p v-if="bonusErr" class="err">{{ bonusErr }}</p>
</form>
<template #footer>
<UiButton variant="ghost" @click="bonusOpen = false">انصراف</UiButton>
<UiButton :loading="bonusBusy" @click="saveBonus">ذخیره</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.ach {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.err {
margin: 0;
color: var(--danger);
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
display: grid;
gap: var(--sp-2);
min-width: 0;
}
.head {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-2);
}
.head h3 {
margin: 0;
font-size: var(--fs-md);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
.list li {
--c: var(--accent);
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--line-light);
}
.list li:last-child {
border-bottom: 0;
}
.list li.off {
opacity: 0.55;
}
.ic {
width: 32px;
height: 32px;
border-radius: 50%;
display: grid;
place-items: center;
color: var(--c);
background: color-mix(in srgb, var(--c) 15%, transparent);
flex: 0 0 auto;
}
.body {
flex: 1;
min-width: 0;
display: grid;
}
.body b {
font-size: var(--fs-sm);
}
.body small {
font-size: var(--fs-xs);
color: var(--muted);
}
.pts {
display: grid;
text-align: end;
}
.pts b {
color: var(--success-deep);
}
.pts em {
font-style: normal;
font-size: 0.72rem;
color: #b45309;
font-weight: 700;
}
.count {
font-size: var(--fs-xs);
color: var(--muted);
}
.acts {
display: flex;
}
.acts button {
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
}
.acts button:hover {
background: var(--surface-hover);
}
.acts .del:hover {
background: var(--danger-soft);
color: var(--danger);
}
.empty {
color: var(--muted);
font-size: var(--fs-sm);
}
.form {
display: grid;
gap: var(--sp-3);
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
.row3 {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--sp-2);
}
.pick {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
color: var(--muted);
}
.pick button {
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--line);
background: var(--surface);
cursor: pointer;
display: grid;
place-items: center;
color: var(--ink-secondary);
}
.pick button.on {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
color: var(--accent-deep);
}
.pick .dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
}
.pick .dot.on {
border-color: var(--ink);
}
.ck {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
}
</style>
@@ -0,0 +1,160 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import type { OrgUserDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiField, UiInput, UiSegmented, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, faNum } from '@/lib/gamification';
import ClubHistory from '../ClubHistory.vue';
const emit = defineEmits<{ changed: [] }>();
type Target = 'score' | 'coins';
const targetOptions: Array<{ value: Target; label: string }> = [
{ value: 'score', label: 'امتیاز' },
{ value: 'coins', label: 'سکه' },
];
const users = ref<OrgUserDto[]>([]);
const target = ref<Target>('score');
const form = reactive({ userId: '', amount: '', reason: '' });
const error = ref('');
const ok = ref('');
const busy = ref(false);
const historyKey = ref(0);
const activeUsers = computed(() => users.value.filter((u) => u.isActive !== false));
onMounted(async () => {
try {
users.value = (await api.get<OrgUserDto[]>('/users')).data;
} catch (e) {
error.value = apiError(e, 'بارگذاری کاربران ممکن نشد.');
}
});
async function submit() {
error.value = '';
ok.value = '';
const amount = Math.round(Number(form.amount));
if (!form.userId) {
error.value = 'همکار را انتخاب کنید.';
return;
}
if (!amount) {
error.value = 'مقدار باید عددی غیر صفر باشد (برای کسر، منفی وارد کنید).';
return;
}
if (form.reason.trim().length < 2) {
error.value = 'دلیل تغییر الزامی است و در تاریخچه ثبت می‌شود.';
return;
}
busy.value = true;
try {
await api.post(`/admin/gamification/adjust-${target.value}`, {
userId: form.userId,
amount,
reason: form.reason.trim(),
});
const name = users.value.find((u) => u.id === form.userId)?.fullName ?? '';
ok.value = `${amount > 0 ? '+' : ''}${faNum(amount)} ${target.value === 'score' ? 'امتیاز' : 'سکه'} برای ${name} ثبت شد.`;
form.amount = '';
form.reason = '';
historyKey.value++;
emit('changed');
} catch (e) {
error.value = apiError(e, 'ثبت تغییر ممکن نشد.');
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="adj">
<form class="card" @submit.prevent="submit">
<h3><UiIcon name="sliders" :size="17" /> افزودن یا کسر دستی</h3>
<p class="hint">همه تغییرات دستی با نام شما و دلیل در تاریخچه ثبت می‌شوند و به همکار اطلاع داده می‌شود.</p>
<div class="grid">
<UiField label="همکار" required>
<UiSelect v-model="form.userId">
<option value="">انتخاب کنید…</option>
<option v-for="u in activeUsers" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</UiSelect>
</UiField>
<UiField label="نوع">
<UiSegmented v-model="target" :options="targetOptions" />
</UiField>
<UiField label="مقدار" hint="مثبت = افزودن، منفی = کسر" required>
<UiInput v-model="form.amount" type="number" placeholder="مثلاً 50 یا -20" />
</UiField>
</div>
<UiField label="دلیل" required>
<UiTextarea v-model="form.reason" :rows="2" placeholder="مثلاً: جبران امتیاز ثبت‌نشده جلسه با مشتری" :maxlength="500" />
</UiField>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div><UiButton type="submit" :loading="busy">ثبت تغییر</UiButton></div>
</form>
<article class="card">
<h3><UiIcon name="clock" :size="17" /> تاریخچه {{ form.userId ? 'این همکار' : 'همه همکاران' }}</h3>
<ClubHistory :key="historyKey" :user-id="form.userId || null" show-user />
</article>
</section>
</template>
<style scoped>
.adj {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-md);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.hint {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.grid {
display: grid;
grid-template-columns: minmax(0, 2fr) auto minmax(0, 1fr);
gap: var(--sp-3);
align-items: end;
}
.err {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
@media (max-width: 700px) {
.grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import type { GamificationRedemptionDto, GamificationRedemptionStatus, GamificationTxDto } from '@mento/shared';
import { GAMIFICATION_REDEMPTION_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiSegmented } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, faNum, formatDateTime, txIcon } from '@/lib/gamification';
const emit = defineEmits<{ changed: [] }>();
const pending = ref<GamificationTxDto[]>([]);
const redemptions = ref<GamificationRedemptionDto[]>([]);
const status = ref<GamificationRedemptionStatus | ''>('pending');
const error = ref('');
const busyId = ref('');
const statusOptions: Array<{ value: GamificationRedemptionStatus | ''; label: string }> = [
{ value: 'pending', label: 'در انتظار' },
{ value: 'approved', label: 'تأیید شده' },
{ value: 'delivered', label: 'تحویل شده' },
{ value: 'rejected', label: 'رد شده' },
{ value: '', label: 'همه' },
];
async function loadPending() {
pending.value = (await api.get<GamificationTxDto[]>('/admin/gamification/pending')).data;
}
async function loadRedemptions() {
redemptions.value = (
await api.get<GamificationRedemptionDto[]>('/admin/gamification/redemptions', {
params: status.value ? { status: status.value } : {},
})
).data;
}
async function load() {
try {
await Promise.all([loadPending(), loadRedemptions()]);
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری درخواست‌ها ممکن نشد.');
}
}
function askNote(action: string): string | null | false {
const note = window.prompt(`توضیح برای ${action} (اختیاری):`, '');
if (note === null) return false;
return note.trim() || null;
}
async function decideTx(tx: GamificationTxDto, approve: boolean) {
const note = approve ? null : askNote('رد');
if (note === false) return;
busyId.value = tx.id;
try {
await api.post(`/admin/gamification/transactions/${tx.id}/${approve ? 'approve' : 'reject'}`, { note });
pending.value = pending.value.filter((x) => x.id !== tx.id);
emit('changed');
} catch (e) {
error.value = apiError(e, 'ثبت تصمیم ممکن نشد.');
} finally {
busyId.value = '';
}
}
async function decideRedemption(r: GamificationRedemptionDto, action: 'approve' | 'reject' | 'deliver') {
const note = action === 'reject' ? askNote('رد (سکه‌ها برگردانده می‌شود)') : null;
if (note === false) return;
busyId.value = r.id;
try {
await api.post(`/admin/gamification/redemptions/${r.id}/${action}`, { note });
await loadRedemptions();
emit('changed');
} catch (e) {
error.value = apiError(e, 'ثبت تصمیم ممکن نشد.');
} finally {
busyId.value = '';
}
}
async function onStatus(next: GamificationRedemptionStatus | '') {
status.value = next;
try {
await loadRedemptions();
} catch (e) {
error.value = apiError(e, 'بارگذاری درخواست‌ها ممکن نشد.');
}
}
onMounted(load);
</script>
<template>
<section class="appr">
<p v-if="error" class="err">{{ error }}</p>
<article class="card">
<h3><UiIcon name="clock" :size="17" /> امتیازهای منتظر تأیید</h3>
<ul v-if="pending.length" class="list">
<li v-for="tx in pending" :key="tx.id">
<span class="ic"><UiIcon :name="txIcon(tx)" :size="15" /></span>
<span class="body">
<b>{{ tx.userName }} — {{ tx.title }}</b>
<small>
{{ formatDateTime(tx.createdAt) }}
<template v-if="tx.entityTitle">
·
<RouterLink v-if="tx.link" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
<template v-else>{{ tx.entityTitle }}</template>
</template>
</small>
</span>
<span class="pts">
<b>{{ tx.score > 0 ? '+' : '' }}{{ faNum(tx.score) }}</b>
<em v-if="tx.coins">+{{ faNum(tx.coins) }} سکه</em>
</span>
<span class="acts">
<UiButton size="sm" variant="success" :loading="busyId === tx.id" @click="decideTx(tx, true)">تأیید</UiButton>
<UiButton size="sm" variant="outline" :disabled="busyId === tx.id" @click="decideTx(tx, false)">رد</UiButton>
</span>
</li>
</ul>
<p v-else class="empty">امتیازی منتظر تأیید نیست.</p>
</article>
<article class="card">
<div class="head">
<h3><UiIcon name="gift" :size="17" /> درخواست‌های پاداش</h3>
<UiSegmented :model-value="status" :options="statusOptions" @update:model-value="onStatus" />
</div>
<ul v-if="redemptions.length" class="list">
<li v-for="r in redemptions" :key="r.id">
<span class="ic gift"><UiIcon :name="r.rewardIcon as never" :size="15" /></span>
<span class="body">
<b>{{ r.userName }} — {{ r.rewardName }}</b>
<small>
{{ formatDateTime(r.createdAt) }}
<template v-if="r.note"> · «{{ r.note }}»</template>
<template v-if="r.decidedByName"> · {{ r.decidedByName }}</template>
<template v-if="r.decisionNote"> · {{ r.decisionNote }}</template>
</small>
</span>
<span class="cost">{{ faNum(r.cost) }} سکه</span>
<span class="st" :class="r.status">{{ GAMIFICATION_REDEMPTION_LABELS[r.status] }}</span>
<span class="acts">
<template v-if="r.status === 'pending'">
<UiButton size="sm" variant="success" :loading="busyId === r.id" @click="decideRedemption(r, 'approve')">تأیید</UiButton>
<UiButton size="sm" variant="outline" :disabled="busyId === r.id" @click="decideRedemption(r, 'reject')">رد</UiButton>
</template>
<UiButton
v-else-if="r.status === 'approved'"
size="sm"
:loading="busyId === r.id"
@click="decideRedemption(r, 'deliver')"
>
تحویل شد
</UiButton>
</span>
</li>
</ul>
<p v-else class="empty">درخواستی در این وضعیت نیست.</p>
</article>
</section>
</template>
<style scoped>
.appr {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.err {
margin: 0;
color: var(--danger);
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-md);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
flex-wrap: wrap;
}
.head > :last-child {
max-width: 100%;
overflow-x: auto;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
}
.list li {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-2) 0;
border-bottom: 1px solid var(--line-light);
flex-wrap: wrap;
}
.list li:last-child {
border-bottom: 0;
}
.ic {
width: 30px;
height: 30px;
border-radius: 9px;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
flex: 0 0 auto;
}
.ic.gift {
background: #fef3c7;
color: #b45309;
}
.body {
flex: 1;
min-width: 180px;
display: grid;
}
.body b {
font-size: var(--fs-sm);
}
.body small {
font-size: var(--fs-xs);
color: var(--muted);
}
.pts {
display: grid;
text-align: end;
}
.pts b {
color: var(--success-deep);
}
.pts em,
.cost {
font-style: normal;
font-size: 0.72rem;
color: #b45309;
font-weight: 700;
}
.st {
font-size: 0.7rem;
font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
background: var(--surface-subtle);
}
.st.pending {
background: var(--warning-soft);
color: var(--warning-deep);
}
.st.approved,
.st.delivered {
background: var(--success-soft);
color: var(--success-deep);
}
.st.rejected {
background: var(--danger-soft);
color: var(--danger);
}
.acts {
display: flex;
gap: 6px;
}
.empty {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
</style>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import type { GamificationPeriod, GamificationReportDto, GamificationReportUserRow } from '@mento/shared';
import { GAMIFICATION_PERIOD_LABELS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiSegmented } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum } from '@/lib/gamification';
const PERIODS: GamificationPeriod[] = ['today', 'week', 'month', 'quarter', 'year', 'all'];
const options = PERIODS.map((p) => ({ value: p, label: GAMIFICATION_PERIOD_LABELS[p] }));
const period = ref<GamificationPeriod>('month');
const report = ref<GamificationReportDto | null>(null);
const error = ref('');
const loading = ref(false);
async function load() {
loading.value = true;
try {
report.value = (
await api.get<GamificationReportDto>('/admin/gamification/reports', { params: { period: period.value } })
).data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری گزارش ممکن نشد.');
} finally {
loading.value = false;
}
}
watch(period, load);
onMounted(load);
const lists: Array<{ key: keyof GamificationReportDto; title: string; unit: string }> = [
{ key: 'topActive', title: 'فعال‌ترین‌ها', unit: 'امتیاز' },
{ key: 'topGrowth', title: 'بیشترین رشد', unit: 'امتیاز' },
{ key: 'topDeals', title: 'بیشترین معامله برنده', unit: 'معامله' },
{ key: 'topLeads', title: 'بیشترین لید', unit: 'لید' },
{ key: 'topTasks', title: 'بیشترین کار انجام‌شده', unit: 'کار' },
{ key: 'inactive', title: 'کم‌فعال‌ها', unit: 'امتیاز' },
];
function rows(key: keyof GamificationReportDto): GamificationReportUserRow[] {
return (report.value?.[key] as GamificationReportUserRow[] | undefined) ?? [];
}
function fmt(v: number): string {
return Number.isInteger(v) ? faNum(v) : toFaDigits(v.toFixed(1));
}
</script>
<template>
<section class="rep" :class="{ loading }">
<div class="bar">
<UiSegmented v-model="period" :options="options" />
</div>
<p v-if="error" class="err">{{ error }}</p>
<template v-if="report">
<div class="kpis">
<div class="kpi"><small>امتیاز توزیع‌شده</small><b>{{ faNum(report.totals.score) }}</b></div>
<div class="kpi"><small>سکه داده‌شده</small><b>{{ faNum(report.totals.coinsEarned) }}</b></div>
<div class="kpi"><small>سکه خرج‌شده</small><b>{{ faNum(report.totals.coinsSpent) }}</b></div>
<div class="kpi">
<small>همکاران فعال</small>
<b>{{ toFaDigits(report.totals.activeUsers) }} / {{ toFaDigits(report.totals.totalUsers) }}</b>
</div>
<div class="kpi"><small>تراکنش‌ها</small><b>{{ faNum(report.totals.transactions) }}</b></div>
<div class="kpi warn">
<small>منتظر تأیید</small>
<b>{{ toFaDigits(report.totals.pendingApprovals + report.totals.pendingRedemptions) }}</b>
</div>
</div>
<article class="card">
<h3><UiIcon name="trend" :size="17" /> اثر باشگاه بر عملکرد (ROI)</h3>
<p v-if="!report.roi.since" class="hint">تاریخ فعال‌سازی باشگاه ثبت نشده است.</p>
<template v-else>
<p class="hint">
میانگین روزانه {{ toFaDigits(report.roi.windowDays) }} روز قبل از فعال‌سازی، در مقایسه با
{{ toFaDigits(report.roi.windowDays) }} روز بعد از آن.
</p>
<div class="table-wrap">
<table>
<thead>
<tr><th>شاخص</th><th>قبل</th><th>بعد</th><th>تغییر</th></tr>
</thead>
<tbody>
<tr v-for="m in report.roi.metrics" :key="m.key">
<td>{{ m.label }}</td>
<td>{{ fmt(m.before) }}</td>
<td>{{ fmt(m.after) }}</td>
<td>
<span v-if="m.changePct != null" class="chg" :class="{ up: m.changePct > 0, down: m.changePct < 0 }">
{{ m.changePct > 0 ? '▲' : m.changePct < 0 ? '▼' : '' }} {{ toFaDigits(Math.abs(m.changePct)) }}٪
</span>
<span v-else class="muted">—</span>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</article>
<div class="cols">
<article v-for="l in lists" :key="l.key" class="card">
<h3>{{ l.title }}</h3>
<ol v-if="rows(l.key).length" class="rank">
<li v-for="(r, i) in rows(l.key)" :key="r.userId">
<span class="n">{{ toFaDigits(i + 1) }}</span>
<span class="name">{{ r.fullName }}</span>
<b>{{ faNum(r.value) }}</b>
<small>{{ l.unit }}</small>
</li>
</ol>
<p v-else class="hint">داده‌ای نیست.</p>
</article>
</div>
<article class="card">
<h3><UiIcon name="funnel" :size="17" /> امتیاز به تفکیک رویداد</h3>
<div class="table-wrap">
<table>
<thead>
<tr><th>رویداد</th><th>تعداد</th><th>امتیاز</th><th>سکه</th></tr>
</thead>
<tbody>
<tr v-for="e in report.byEvent" :key="e.event">
<td>{{ e.label }}</td>
<td>{{ faNum(e.count) }}</td>
<td>{{ faNum(e.score) }}</td>
<td>{{ faNum(e.coins) }}</td>
</tr>
<tr v-if="!report.byEvent.length"><td colspan="4" class="muted">در این بازه امتیازی ثبت نشده است.</td></tr>
</tbody>
</table>
</div>
</article>
</template>
</section>
</template>
<style scoped>
.rep {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.rep.loading {
opacity: 0.6;
}
.bar {
max-width: 100%;
overflow-x: auto;
}
.err {
margin: 0;
color: var(--danger);
}
.kpis {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: var(--sp-3);
}
.kpi {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 2px);
padding: var(--sp-3);
display: grid;
gap: 4px;
}
.kpi small {
color: var(--muted);
font-size: var(--fs-xs);
}
.kpi b {
font-size: 1.25rem;
font-weight: 800;
}
.kpi.warn b {
color: var(--warning-deep);
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
display: grid;
gap: var(--sp-2);
align-content: start;
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-md);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.hint {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.cols {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--sp-3);
}
.rank {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.rank li {
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
}
.rank .n {
width: 22px;
height: 22px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--surface-subtle);
font-size: 0.7rem;
font-weight: 700;
color: var(--muted);
}
.rank .name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rank small {
color: var(--muted);
font-size: var(--fs-xs);
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: var(--fs-sm);
}
th,
td {
padding: 8px 10px;
text-align: start;
border-bottom: 1px solid var(--line-light);
white-space: nowrap;
}
th {
color: var(--muted);
font-weight: 700;
font-size: var(--fs-xs);
}
.chg {
font-weight: 700;
}
.chg.up {
color: var(--success-deep);
}
.chg.down {
color: var(--danger);
}
.muted {
color: var(--muted);
}
</style>
@@ -0,0 +1,321 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import type { GamificationRewardDto, SaveGamificationRewardRequest } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiField, UiInput, UiModal, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, faNum, REWARD_ICONS } from '@/lib/gamification';
const COLORS = ['#0d9488', '#f59e0b', '#8b5cf6', '#ef4444', '#3b82f6', '#10b981', '#ec4899'];
const items = ref<GamificationRewardDto[]>([]);
const error = ref('');
const modalError = ref('');
const open = ref(false);
const busy = ref(false);
const editingId = ref<string | null>(null);
const form = reactive({
name: '',
description: '',
cost: '500',
icon: 'gift',
color: COLORS[0],
stock: '',
isActive: true,
requiresApproval: true,
});
async function load() {
try {
items.value = (await api.get<GamificationRewardDto[]>('/admin/gamification/rewards')).data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری پاداش‌ها ممکن نشد.');
}
}
function edit(r: GamificationRewardDto | null) {
editingId.value = r?.id ?? null;
modalError.value = '';
Object.assign(form, {
name: r?.name ?? '',
description: r?.description ?? '',
cost: String(r?.cost ?? 500),
icon: r?.icon ?? 'gift',
color: r?.color ?? COLORS[0],
stock: r?.stock == null ? '' : String(r.stock),
isActive: r?.isActive ?? true,
requiresApproval: r?.requiresApproval ?? true,
});
open.value = true;
}
async function save() {
const cost = Math.round(Number(form.cost));
if (form.name.trim().length < 2 || !(cost > 0)) {
modalError.value = 'نام و قیمت (سکه) معتبر وارد کنید.';
return;
}
const body: SaveGamificationRewardRequest = {
name: form.name.trim(),
description: form.description.trim() || null,
cost,
icon: form.icon,
color: form.color,
stock: form.stock.trim() === '' ? null : Math.max(0, Math.round(Number(form.stock))),
isActive: form.isActive,
requiresApproval: form.requiresApproval,
};
busy.value = true;
try {
if (editingId.value) await api.patch(`/admin/gamification/rewards/${editingId.value}`, body);
else await api.post('/admin/gamification/rewards', body);
open.value = false;
await load();
} catch (e) {
modalError.value = apiError(e, 'ذخیره پاداش ممکن نشد.');
} finally {
busy.value = false;
}
}
async function remove(r: GamificationRewardDto) {
if (!window.confirm(`پاداش «${r.name}» حذف شود؟`)) return;
try {
await api.delete(`/admin/gamification/rewards/${r.id}`);
await load();
} catch (e) {
error.value = apiError(e, 'حذف پاداش ممکن نشد.');
}
}
onMounted(load);
</script>
<template>
<section class="rw">
<div class="bar">
<p class="hint">پاداش‌ها با سکه خریداری می‌شوند. امتیاز رتبه‌بندی خرج نمی‌شود.</p>
<UiButton @click="edit(null)"><UiIcon name="plus" :size="15" /> پاداش جدید</UiButton>
</div>
<p v-if="error" class="err">{{ error }}</p>
<div class="grid">
<article v-for="r in items" :key="r.id" class="card" :class="{ off: !r.isActive }" :style="{ '--c': r.color }">
<span class="ic"><UiIcon :name="r.icon as never" :size="22" /></span>
<div class="body">
<b>{{ r.name }}</b>
<small>{{ faNum(r.cost) }} سکه<template v-if="r.stock != null"> · موجودی {{ faNum(r.stock) }}</template></small>
<small v-if="r.requiresApproval" class="ap">نیاز به تأیید</small>
</div>
<span class="acts">
<button type="button" title="ویرایش" @click="edit(r)"><UiIcon name="edit" :size="15" /></button>
<button type="button" class="del" title="حذف" @click="remove(r)"><UiIcon name="trash" :size="15" /></button>
</span>
</article>
</div>
<UiModal :open="open" :title="editingId ? 'ویرایش پاداش' : 'پاداش جدید'" @close="open = false">
<form class="form" @submit.prevent="save">
<UiField label="نام" required><UiInput v-model="form.name" maxlength="120" /></UiField>
<div class="row">
<UiField label="قیمت (سکه)" required><UiInput v-model="form.cost" type="number" /></UiField>
<UiField label="موجودی" hint="خالی = نامحدود"><UiInput v-model="form.stock" type="number" /></UiField>
</div>
<UiField label="توضیح"><UiTextarea v-model="form.description" :rows="2" /></UiField>
<div class="pick">
<span>آیکن:</span>
<button
v-for="ic in REWARD_ICONS"
:key="ic"
type="button"
:class="{ on: form.icon === ic }"
@click="form.icon = ic"
>
<UiIcon :name="ic" :size="16" />
</button>
</div>
<div class="pick">
<span>رنگ:</span>
<button
v-for="c in COLORS"
:key="c"
type="button"
class="dot"
:class="{ on: form.color === c }"
:style="{ background: c }"
:aria-label="c"
@click="form.color = c"
/>
</div>
<label class="ck"><input v-model="form.requiresApproval" type="checkbox" /> نیاز به تأیید مدیر</label>
<label class="ck"><input v-model="form.isActive" type="checkbox" /> نمایش در فروشگاه</label>
<p v-if="modalError" class="err">{{ modalError }}</p>
</form>
<template #footer>
<UiButton variant="ghost" @click="open = false">انصراف</UiButton>
<UiButton :loading="busy" @click="save">ذخیره</UiButton>
</template>
</UiModal>
</section>
</template>
<style scoped>
.rw {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--sp-2);
flex-wrap: wrap;
}
.hint {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.err {
margin: 0;
color: var(--danger);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: var(--sp-3);
}
.card {
--c: var(--accent);
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--surface);
border: 1px solid var(--line);
border-inline-start: 4px solid var(--c);
border-radius: calc(var(--radius) + 2px);
min-width: 0;
}
.card.off {
opacity: 0.55;
}
.ic {
width: 42px;
height: 42px;
border-radius: 12px;
display: grid;
place-items: center;
color: var(--c);
background: color-mix(in srgb, var(--c) 14%, transparent);
flex: 0 0 auto;
}
.body {
flex: 1;
min-width: 0;
display: grid;
}
.body b {
font-size: var(--fs-sm);
}
.body small {
font-size: var(--fs-xs);
color: var(--muted);
}
.body .ap {
color: var(--warning-deep);
}
.acts {
display: flex;
}
.acts button {
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
}
.acts button:hover {
background: var(--surface-hover);
}
.acts .del:hover {
background: var(--danger-soft);
color: var(--danger);
}
.form {
display: grid;
gap: var(--sp-3);
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-2);
}
.pick {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
color: var(--muted);
}
.pick button {
width: 32px;
height: 32px;
border-radius: 9px;
border: 1px solid var(--line);
background: var(--surface);
cursor: pointer;
display: grid;
place-items: center;
color: var(--ink-secondary);
}
.pick button.on {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
color: var(--accent-deep);
}
.pick .dot {
width: 24px;
height: 24px;
border-radius: 50%;
border: 2px solid transparent;
}
.pick .dot.on {
border-color: var(--ink);
}
.ck {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--fs-sm);
}
</style>
@@ -0,0 +1,354 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import type { GamificationRuleDto, TeamDto } from '@mento/shared';
import { GAMIFICATION_EVENTS } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiSearch } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError, faNum, txIcon } from '@/lib/gamification';
import { conditionText } from '../rule-text';
import RuleBuilderModal from './RuleBuilderModal.vue';
const rules = ref<GamificationRuleDto[]>([]);
const teams = ref<TeamDto[]>([]);
const error = ref('');
const q = ref('');
const editing = ref<GamificationRuleDto | null>(null);
const builderOpen = ref(false);
const filtered = computed(() => {
const s = q.value.trim();
if (!s) return rules.value;
return rules.value.filter(
(r) => r.name.includes(s) || (GAMIFICATION_EVENTS[r.event]?.label ?? '').includes(s),
);
});
async function load() {
try {
const [r, t] = await Promise.all([
api.get<GamificationRuleDto[]>('/admin/gamification/rules'),
api.get<TeamDto[]>('/teams').catch(() => ({ data: [] as TeamDto[] })),
]);
rules.value = r.data;
teams.value = t.data;
error.value = '';
} catch (e) {
error.value = apiError(e, 'بارگذاری قوانین ممکن نشد.');
}
}
function openNew() {
editing.value = null;
builderOpen.value = true;
}
function openEdit(r: GamificationRuleDto) {
editing.value = r;
builderOpen.value = true;
}
function onSaved() {
builderOpen.value = false;
void load();
}
async function toggle(r: GamificationRuleDto) {
try {
const { data } = await api.patch<GamificationRuleDto>(`/admin/gamification/rules/${r.id}/toggle`, {
isActive: !r.isActive,
});
Object.assign(r, data);
} catch (e) {
error.value = apiError(e, 'تغییر وضعیت ممکن نشد.');
}
}
async function duplicate(r: GamificationRuleDto) {
try {
await api.post(`/admin/gamification/rules/${r.id}/duplicate`);
await load();
} catch (e) {
error.value = apiError(e, 'کپی قانون ممکن نشد.');
}
}
async function remove(r: GamificationRuleDto) {
if (!window.confirm(`قانون «${r.name}» حذف شود؟ امتیازهای قبلی باقی می‌مانند.`)) return;
try {
await api.delete(`/admin/gamification/rules/${r.id}`);
rules.value = rules.value.filter((x) => x.id !== r.id);
} catch (e) {
error.value = apiError(e, 'حذف قانون ممکن نشد.');
}
}
function limitsText(r: GamificationRuleDto): string {
const out: string[] = [];
if (r.maxPerDay) out.push(`${toFaDigits(r.maxPerDay)}/روز`);
if (r.maxCoinsPerDay) out.push(`سقف ${faNum(r.maxCoinsPerDay)} سکه/روز`);
if (r.maxPerUser) out.push(`${toFaDigits(r.maxPerUser)} بار در کل`);
if (r.cooldownMinutes) out.push(`هر ${toFaDigits(r.cooldownMinutes)} دقیقه`);
if (r.oncePerEntity) out.push('یک بار/پرونده');
if (r.firstTimeOnly) out.push('فقط بار اول');
if (r.requiresApproval) out.push('با تأیید مدیر');
if (r.roles.length || r.teamIds.length) out.push('مخاطب محدود');
return out.join(' · ');
}
onMounted(load);
</script>
<template>
<section class="rules">
<div class="bar">
<UiSearch v-model="q" placeholder="جستجوی قانون یا رویداد…" />
<UiButton @click="openNew"><UiIcon name="plus" :size="15" /> قانون جدید</UiButton>
</div>
<p v-if="error" class="err">{{ error }}</p>
<ul class="list">
<li v-for="r in filtered" :key="r.id" :class="{ off: !r.isActive }">
<span class="ic"><UiIcon :name="txIcon({ kind: 'rule', event: r.event })" :size="16" /></span>
<button type="button" class="body" @click="openEdit(r)">
<b>{{ r.name }}</b>
<small>
{{ GAMIFICATION_EVENTS[r.event]?.label ?? r.event }}
<template v-if="r.conditions.length"> · {{ r.conditions.map(conditionText).join(' و ') }}</template>
</small>
<small v-if="limitsText(r)" class="lim">{{ limitsText(r) }}</small>
</button>
<span class="pts">
<b :class="{ neg: r.score < 0 }">{{ r.score > 0 ? '+' : '' }}{{ faNum(r.score) }}</b>
<em v-if="r.coins">+{{ faNum(r.coins) }} سکه</em>
</span>
<span class="count" title="تعداد دفعات اعطا">{{ faNum(r.awardedCount) }}×</span>
<label class="sw" :title="r.isActive ? 'فعال' : 'غیرفعال'">
<input type="checkbox" :checked="r.isActive" @change="toggle(r)" />
<i />
</label>
<span class="acts">
<button type="button" title="ویرایش" @click="openEdit(r)"><UiIcon name="edit" :size="15" /></button>
<button type="button" title="کپی" @click="duplicate(r)"><UiIcon name="layers" :size="15" /></button>
<button type="button" class="del" title="حذف" @click="remove(r)"><UiIcon name="trash" :size="15" /></button>
</span>
</li>
<li v-if="!filtered.length" class="empty">قانونی پیدا نشد.</li>
</ul>
<RuleBuilderModal
:open="builderOpen"
:rule="editing"
:teams="teams"
@close="builderOpen = false"
@saved="onSaved"
/>
</section>
</template>
<style scoped>
.rules {
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.bar {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
align-items: center;
}
.bar > :first-child {
flex: 1;
min-width: 200px;
}
.err {
margin: 0;
color: var(--danger);
}
.list {
list-style: none;
margin: 0;
padding: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
overflow: hidden;
}
.list li {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-3);
border-bottom: 1px solid var(--line-light);
}
.list li:last-child {
border-bottom: 0;
}
.list li.off {
opacity: 0.55;
}
.ic {
width: 34px;
height: 34px;
border-radius: 10px;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
flex: 0 0 auto;
}
.body {
flex: 1;
min-width: 0;
display: grid;
text-align: start;
background: none;
border: 0;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
.body b {
font-size: var(--fs-sm);
}
.body small {
font-size: var(--fs-xs);
color: var(--muted);
}
.lim {
color: var(--warning-deep) !important;
}
.pts {
display: grid;
text-align: end;
flex: 0 0 auto;
}
.pts b {
color: var(--success-deep);
font-weight: 800;
}
.pts b.neg {
color: var(--danger);
}
.pts em {
font-style: normal;
font-size: 0.72rem;
color: #b45309;
font-weight: 700;
}
.count {
font-size: var(--fs-xs);
color: var(--muted);
min-width: 40px;
text-align: center;
}
.sw {
position: relative;
width: 36px;
height: 20px;
flex: 0 0 auto;
cursor: pointer;
}
.sw input {
position: absolute;
opacity: 0;
inset: 0;
cursor: pointer;
}
.sw i {
position: absolute;
inset: 0;
border-radius: 999px;
background: var(--line);
transition: background 0.15s;
}
.sw i::after {
content: '';
position: absolute;
top: 2px;
inset-inline-start: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
transition: transform 0.15s;
}
.sw input:checked + i {
background: var(--accent);
}
.sw input:checked + i::after {
transform: translateX(-16px);
}
.acts {
display: flex;
gap: 2px;
}
.acts button {
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
}
.acts button:hover {
background: var(--surface-hover);
color: var(--ink);
}
.acts .del:hover {
background: var(--danger-soft);
color: var(--danger);
}
.empty {
justify-content: center;
color: var(--muted);
}
@media (max-width: 700px) {
.list li {
flex-wrap: wrap;
}
.body {
flex-basis: calc(100% - 50px);
}
.count {
margin-inline-start: auto;
}
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import type { GamificationSettingsDto } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiField, UiInput } from '@/components/ui';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import { apiError } from '@/lib/gamification';
const emit = defineEmits<{ changed: [] }>();
const form = reactive({
enabled: true,
monthlyGoal: '1500',
showFullLeaderboard: true,
levels: [] as Array<{ name: string; minScore: string }>,
});
const error = ref('');
const ok = ref('');
const busy = ref(false);
function fill(s: GamificationSettingsDto) {
form.enabled = s.enabled;
form.monthlyGoal = String(s.monthlyGoal);
form.showFullLeaderboard = s.showFullLeaderboard;
form.levels = s.levels.map((l) => ({ name: l.name, minScore: String(l.minScore) }));
}
onMounted(async () => {
try {
fill((await api.get<GamificationSettingsDto>('/admin/gamification/settings')).data);
} catch (e) {
error.value = apiError(e, 'بارگذاری تنظیمات ممکن نشد.');
}
});
function addLevel() {
const last = form.levels[form.levels.length - 1];
form.levels.push({ name: '', minScore: String((Number(last?.minScore) || 0) + 500) });
}
async function save() {
error.value = '';
ok.value = '';
const levels = form.levels
.map((l) => ({ name: l.name.trim(), minScore: Math.max(0, Math.round(Number(l.minScore) || 0)) }))
.filter((l) => l.name);
if (!levels.length) {
error.value = 'حداقل یک سطح لازم است.';
return;
}
levels.sort((a, b) => a.minScore - b.minScore);
levels[0].minScore = 0;
const goal = Math.round(Number(form.monthlyGoal));
if (!(goal >= 1)) {
error.value = 'هدف ماهانه باید عدد مثبت باشد.';
return;
}
busy.value = true;
try {
const { data } = await api.patch<GamificationSettingsDto>('/admin/gamification/settings', {
enabled: form.enabled,
monthlyGoal: goal,
showFullLeaderboard: form.showFullLeaderboard,
levels,
});
fill(data);
ok.value = 'تنظیمات ذخیره شد.';
emit('changed');
} catch (e) {
error.value = apiError(e, 'ذخیره تنظیمات ممکن نشد.');
} finally {
busy.value = false;
}
}
</script>
<template>
<form class="st" @submit.prevent="save">
<article class="card">
<h3><UiIcon name="sliders" :size="17" /> تنظیمات کلی</h3>
<label class="ck">
<input v-model="form.enabled" type="checkbox" />
<span>
<b>امتیازدهی فعال باشد</b>
<small>اگر خاموش شود، رویدادهای جدید امتیاز نمی‌گیرند ولی سوابق حفظ می‌شود.</small>
</span>
</label>
<label class="ck">
<input v-model="form.showFullLeaderboard" type="checkbox" />
<span>
<b>جدول کامل برای همه نمایش داده شود</b>
<small>اگر خاموش باشد، همکاران فقط ۱۰ نفر اول و رتبه خودشان را می‌بینند.</small>
</span>
</label>
<UiField label="هدف ماهانه امتیاز" hint="در کارت «هدف بعدی» نمایش داده می‌شود" class="goal">
<UiInput v-model="form.monthlyGoal" type="number" />
</UiField>
</article>
<article class="card">
<h3><UiIcon name="trend" :size="17" /> سطح‌ها</h3>
<p class="hint">سطح بر اساس امتیاز کل محاسبه می‌شود. سطح اول همیشه از صفر شروع می‌شود.</p>
<div v-for="(l, i) in form.levels" :key="i" class="lvl">
<span class="n">{{ toFaDigits(i + 1) }}</span>
<UiInput v-model="l.name" placeholder="نام سطح" maxlength="40" />
<UiInput v-model="l.minScore" type="number" :disabled="i === 0" placeholder="حداقل امتیاز" />
<button type="button" class="x" :disabled="form.levels.length <= 1" aria-label="حذف سطح" @click="form.levels.splice(i, 1)">
<UiIcon name="x" :size="14" />
</button>
</div>
<div>
<UiButton v-if="form.levels.length < 20" type="button" variant="dashed" size="sm" @click="addLevel">+ افزودن سطح</UiButton>
</div>
</article>
<p v-if="error" class="err">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div><UiButton type="submit" :loading="busy">ذخیره تنظیمات</UiButton></div>
</form>
</template>
<style scoped>
.st {
display: grid;
gap: var(--sp-4);
min-width: 0;
}
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: calc(var(--radius) + 4px);
padding: var(--sp-4);
display: grid;
gap: var(--sp-3);
min-width: 0;
}
.card h3 {
margin: 0;
font-size: var(--fs-md);
display: flex;
align-items: center;
gap: var(--sp-2);
}
.ck {
display: flex;
align-items: flex-start;
gap: var(--sp-2);
cursor: pointer;
}
.ck input {
margin-top: 4px;
}
.ck span {
display: grid;
}
.ck b {
font-size: var(--fs-sm);
}
.ck small {
font-size: var(--fs-xs);
color: var(--muted);
}
.goal {
max-width: 260px;
}
.hint {
margin: 0;
color: var(--muted);
font-size: var(--fs-sm);
}
.lvl {
display: grid;
grid-template-columns: 28px minmax(0, 2fr) minmax(0, 1fr) 28px;
gap: var(--sp-2);
align-items: center;
}
.n {
width: 26px;
height: 26px;
border-radius: 50%;
display: grid;
place-items: center;
background: var(--accent-subtle);
color: var(--accent-deep);
font-weight: 800;
font-size: var(--fs-xs);
}
.x {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
}
.x:disabled {
opacity: 0.3;
cursor: default;
}
.x:not(:disabled):hover {
background: var(--danger-soft);
color: var(--danger);
}
.err {
margin: 0;
color: var(--danger);
}
.ok {
margin: 0;
color: var(--success-deep);
}
</style>
@@ -0,0 +1,534 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import type {
GamificationCondition,
GamificationConditionOp,
GamificationEvent,
GamificationRuleDto,
SaveGamificationRuleRequest,
TeamDto,
} from '@mento/shared';
import {
GAMIFICATION_CONDITION_OPS,
GAMIFICATION_EVENT_GROUP_LABELS,
GAMIFICATION_EVENT_KEYS,
GAMIFICATION_EVENTS,
GAMIFICATION_FIELDS,
} from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiField, UiInput, UiModal, UiSelect, UiTextarea } from '@/components/ui';
import { api } from '@/lib/api';
import { apiError, ROLE_LABELS } from '@/lib/gamification';
import { conditionText } from '../rule-text';
const props = defineProps<{ open: boolean; rule: GamificationRuleDto | null; teams: TeamDto[] }>();
const emit = defineEmits<{ close: []; saved: [GamificationRuleDto] }>();
const NUMBER_OPS: GamificationConditionOp[] = ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'in'];
const STRING_OPS: GamificationConditionOp[] = ['eq', 'neq', 'contains', 'in'];
const BOOL_OPS: GamificationConditionOp[] = ['is_true', 'is_false'];
interface Form {
name: string;
description: string;
event: string;
score: string;
coins: string;
conditions: Array<{ field: string; op: string; value: string }>;
isActive: boolean;
maxPerDay: string;
maxCoinsPerDay: string;
maxPerUser: string;
cooldownMinutes: string;
oncePerEntity: boolean;
firstTimeOnly: boolean;
requiresApproval: boolean;
roles: string[];
teamIds: string[];
}
function blank(): Form {
return {
name: '',
description: '',
event: 'DEAL_WON',
score: '10',
coins: '0',
conditions: [],
isActive: true,
maxPerDay: '',
maxCoinsPerDay: '',
maxPerUser: '',
cooldownMinutes: '',
oncePerEntity: false,
firstTimeOnly: false,
requiresApproval: false,
roles: [],
teamIds: [],
};
}
const form = reactive<Form>(blank());
const error = ref('');
const busy = ref(false);
const str = (v: number | null | undefined) => (v == null ? '' : String(v));
watch(
() => props.open,
(open) => {
if (!open) return;
error.value = '';
const r = props.rule;
Object.assign(
form,
r
? {
name: r.name,
description: r.description ?? '',
event: r.event,
score: String(r.score),
coins: String(r.coins),
conditions: r.conditions.map((c) => ({ field: c.field, op: c.op, value: str(c.value as number) })),
isActive: r.isActive,
maxPerDay: str(r.maxPerDay),
maxCoinsPerDay: str(r.maxCoinsPerDay),
maxPerUser: str(r.maxPerUser),
cooldownMinutes: str(r.cooldownMinutes),
oncePerEntity: r.oncePerEntity,
firstTimeOnly: r.firstTimeOnly,
requiresApproval: r.requiresApproval,
roles: [...r.roles],
teamIds: [...r.teamIds],
}
: blank(),
);
},
{ immediate: true },
);
const eventGroups = computed(() => {
const map = new Map<string, GamificationEvent[]>();
for (const key of GAMIFICATION_EVENT_KEYS) {
const g = GAMIFICATION_EVENTS[key].group;
map.set(g, [...(map.get(g) ?? []), key]);
}
return [...map.entries()].map(([g, keys]) => ({
label: GAMIFICATION_EVENT_GROUP_LABELS[g as keyof typeof GAMIFICATION_EVENT_GROUP_LABELS],
keys,
}));
});
const eventKey = computed(() => form.event as GamificationEvent);
const fields = computed(() => GAMIFICATION_EVENTS[eventKey.value]?.fields ?? []);
const isBool = (op: string) => BOOL_OPS.includes(op as GamificationConditionOp);
watch(
() => form.event,
() => {
form.conditions = form.conditions.filter((c) => fields.value.includes(c.field));
},
);
function opsFor(field: string): GamificationConditionOp[] {
const t = GAMIFICATION_FIELDS[field]?.type;
return t === 'boolean' ? BOOL_OPS : t === 'number' ? NUMBER_OPS : STRING_OPS;
}
function addCondition() {
const field = fields.value[0];
if (!field) return;
form.conditions.push({ field, op: opsFor(field)[0], value: '' });
}
function onFieldChange(i: number) {
const c = form.conditions[i];
if (!opsFor(c.field).includes(c.op as GamificationConditionOp)) c.op = opsFor(c.field)[0];
c.value = '';
}
function toggle(list: string[], value: string) {
const i = list.indexOf(value);
if (i >= 0) list.splice(i, 1);
else list.push(value);
}
const optInt = (v: string) => (v.trim() === '' ? null : Math.max(0, Math.round(Number(v))));
function buildConditions(): GamificationCondition[] {
return form.conditions.map((c) => {
const op = c.op as GamificationConditionOp;
if (isBool(op)) return { field: c.field, op, value: null };
const isNum = GAMIFICATION_FIELDS[c.field]?.type === 'number' && op !== 'in';
return { field: c.field, op, value: isNum ? Number(c.value) : c.value.trim() };
});
}
const preview = computed(() => {
const conds = buildConditions();
const parts = [`وقتی «${GAMIFICATION_EVENTS[eventKey.value]?.label ?? form.event}»`];
if (conds.length) parts.push(`و ${conds.map(conditionText).join(' و ')}`);
const reward = [
Number(form.score) ? `${form.score} امتیاز` : '',
Number(form.coins) ? `${form.coins} سکه` : '',
].filter(Boolean);
parts.push(`← ${reward.join(' + ') || 'بدون پاداش'}`);
return parts.join(' ');
});
async function save() {
error.value = '';
if (form.name.trim().length < 2) {
error.value = 'نام قانون را وارد کنید.';
return;
}
const bad = form.conditions.find(
(c) => !isBool(c.op) && (c.value.trim() === '' || (GAMIFICATION_FIELDS[c.field]?.type === 'number' && c.op !== 'in' && !Number.isFinite(Number(c.value)))),
);
if (bad) {
error.value = `مقدار شرط «${GAMIFICATION_FIELDS[bad.field]?.label ?? bad.field}» معتبر نیست.`;
return;
}
const body: SaveGamificationRuleRequest = {
name: form.name.trim(),
description: form.description.trim() || null,
event: eventKey.value,
score: Math.round(Number(form.score) || 0),
coins: Math.max(0, Math.round(Number(form.coins) || 0)),
conditions: buildConditions(),
isActive: form.isActive,
maxPerDay: optInt(form.maxPerDay),
maxCoinsPerDay: optInt(form.maxCoinsPerDay),
maxPerUser: optInt(form.maxPerUser),
cooldownMinutes: optInt(form.cooldownMinutes),
oncePerEntity: form.oncePerEntity,
firstTimeOnly: form.firstTimeOnly,
requiresApproval: form.requiresApproval,
roles: form.roles,
teamIds: form.teamIds,
};
busy.value = true;
try {
const { data } = props.rule
? await api.patch<GamificationRuleDto>(`/admin/gamification/rules/${props.rule.id}`, body)
: await api.post<GamificationRuleDto>('/admin/gamification/rules', body);
emit('saved', data);
} catch (e) {
error.value = apiError(e, 'ذخیره قانون ممکن نشد.');
} finally {
busy.value = false;
}
}
</script>
<template>
<UiModal :open="open" :title="rule ? 'ویرایش قانون امتیاز' : 'قانون امتیاز جدید'" @close="emit('close')">
<form class="builder" @submit.prevent="save">
<div class="row2">
<UiField label="نام قانون" required>
<UiInput v-model="form.name" placeholder="مثلاً: معامله بزرگ برنده شد" maxlength="120" />
</UiField>
<label class="switch">
<input v-model="form.isActive" type="checkbox" />
<span>فعال</span>
</label>
</div>
<section class="step">
<h4><span class="tag when">WHEN</span> وقتی این اتفاق بیفتد</h4>
<UiSelect v-model="form.event">
<optgroup v-for="g in eventGroups" :key="g.label" :label="g.label">
<option v-for="k in g.keys" :key="k" :value="k">{{ GAMIFICATION_EVENTS[k].label }}</option>
</optgroup>
</UiSelect>
</section>
<section class="step">
<h4><span class="tag if">IF</span> و این شرط‌ها برقرار باشد <small>(اختیاری، همه با هم)</small></h4>
<div v-for="(c, i) in form.conditions" :key="i" class="cond">
<UiSelect v-model="c.field" size="sm" @update:model-value="onFieldChange(i)">
<option v-for="f in fields" :key="f" :value="f">{{ GAMIFICATION_FIELDS[f]?.label ?? f }}</option>
</UiSelect>
<UiSelect v-model="c.op" size="sm">
<option v-for="op in opsFor(c.field)" :key="op" :value="op">{{ GAMIFICATION_CONDITION_OPS[op] }}</option>
</UiSelect>
<template v-if="!isBool(c.op)">
<UiSelect v-if="GAMIFICATION_FIELDS[c.field]?.options && c.op !== 'in'" v-model="c.value" size="sm">
<option value="">انتخاب…</option>
<option v-for="o in GAMIFICATION_FIELDS[c.field].options" :key="o.value" :value="o.value">{{ o.label }}</option>
</UiSelect>
<UiInput
v-else
v-model="c.value"
size="sm"
:type="GAMIFICATION_FIELDS[c.field]?.type === 'number' && c.op !== 'in' ? 'number' : 'text'"
placeholder="مقدار"
/>
</template>
<span v-else />
<button type="button" class="x" aria-label="حذف شرط" @click="form.conditions.splice(i, 1)">
<UiIcon name="x" :size="14" />
</button>
</div>
<UiButton v-if="form.conditions.length < 10" type="button" variant="dashed" size="sm" @click="addCondition">
+ افزودن شرط
</UiButton>
</section>
<section class="step">
<h4><span class="tag then">THEN</span> این پاداش را بده</h4>
<div class="row2">
<UiField label="امتیاز (رتبه‌بندی)" hint="منفی هم مجاز است">
<UiInput v-model="form.score" type="number" />
</UiField>
<UiField label="سکه (فروشگاه)">
<UiInput v-model="form.coins" type="number" />
</UiField>
</div>
</section>
<details class="step adv" :open="!!(form.maxPerDay || form.maxPerUser || form.cooldownMinutes || form.roles.length || form.teamIds.length)">
<summary>محدودیت‌ها و مخاطبان</summary>
<div class="row4">
<UiField label="حداکثر بار در روز"><UiInput v-model="form.maxPerDay" type="number" placeholder="نامحدود" /></UiField>
<UiField label="سقف سکه روزانه"><UiInput v-model="form.maxCoinsPerDay" type="number" placeholder="نامحدود" /></UiField>
<UiField label="حداکثر بار برای هر نفر"><UiInput v-model="form.maxPerUser" type="number" placeholder="نامحدود" /></UiField>
<UiField label="فاصله زمانی (دقیقه)"><UiInput v-model="form.cooldownMinutes" type="number" placeholder="ندارد" /></UiField>
</div>
<div class="checks">
<label><input v-model="form.oncePerEntity" type="checkbox" /> یک بار برای هر پرونده</label>
<label><input v-model="form.firstTimeOnly" type="checkbox" /> فقط اولین بار</label>
<label><input v-model="form.requiresApproval" type="checkbox" /> نیاز به تأیید مدیر</label>
</div>
<div class="chips">
<span class="chips-label">نقش‌ها:</span>
<button
v-for="(label, key) in ROLE_LABELS"
:key="key"
type="button"
class="chip"
:class="{ on: form.roles.includes(key) }"
@click="toggle(form.roles, key)"
>
{{ label }}
</button>
<small>{{ form.roles.length ? '' : 'همه نقش‌ها' }}</small>
</div>
<div v-if="teams.length" class="chips">
<span class="chips-label">تیم‌ها:</span>
<button
v-for="t in teams"
:key="t.id"
type="button"
class="chip"
:class="{ on: form.teamIds.includes(t.id) }"
@click="toggle(form.teamIds, t.id)"
>
{{ t.name }}
</button>
<small>{{ form.teamIds.length ? '' : 'همه تیم‌ها' }}</small>
</div>
</details>
<UiField label="توضیح (اختیاری)">
<UiTextarea v-model="form.description" :rows="2" />
</UiField>
<p class="preview"><UiIcon name="sparkle" :size="14" /> {{ preview }}</p>
<p v-if="error" class="err">{{ error }}</p>
</form>
<template #footer>
<UiButton variant="ghost" @click="emit('close')">انصراف</UiButton>
<UiButton :loading="busy" @click="save">ذخیره قانون</UiButton>
</template>
</UiModal>
</template>
<style scoped>
.builder {
display: grid;
gap: var(--sp-3);
max-height: 70vh;
overflow-y: auto;
padding-inline-end: 2px;
}
.row2 {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-3);
align-items: end;
}
.step .row2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.row4 {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--sp-2);
}
.switch {
display: inline-flex;
align-items: center;
gap: 6px;
padding-bottom: 8px;
font-size: var(--fs-sm);
}
.step {
display: grid;
gap: var(--sp-2);
padding: var(--sp-3);
border-radius: var(--radius);
border: 1px solid var(--line);
background: var(--surface-subtle);
}
.step h4 {
margin: 0;
display: flex;
align-items: center;
gap: var(--sp-2);
font-size: var(--fs-sm);
color: var(--ink);
}
.step h4 small {
color: var(--muted);
font-weight: 400;
}
.tag {
font-size: 0.65rem;
font-weight: 800;
letter-spacing: 0.05em;
padding: 2px 7px;
border-radius: 6px;
color: #fff;
direction: ltr;
}
.tag.when {
background: #0f766e;
}
.tag.if {
background: #7c3aed;
}
.tag.then {
background: #d97706;
}
.cond {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1.2fr) 28px;
gap: var(--sp-2);
align-items: center;
}
.x {
width: 28px;
height: 28px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
}
.x:hover {
background: var(--danger-soft);
color: var(--danger);
}
.adv summary {
cursor: pointer;
font-weight: 700;
font-size: var(--fs-sm);
}
.checks {
display: flex;
flex-wrap: wrap;
gap: var(--sp-3);
font-size: var(--fs-sm);
}
.checks label {
display: inline-flex;
align-items: center;
gap: 6px;
}
.chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.chips-label {
font-size: var(--fs-xs);
color: var(--muted);
font-weight: 700;
}
.chips small {
font-size: var(--fs-xs);
color: var(--muted);
}
.chip {
border: 1px solid var(--line);
background: var(--surface);
border-radius: 999px;
padding: 3px 10px;
font-size: var(--fs-xs);
cursor: pointer;
color: var(--ink-secondary);
}
.chip.on {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.preview {
margin: 0;
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius);
background: var(--accent-subtle);
color: var(--accent-deep);
font-size: var(--fs-sm);
display: flex;
gap: 6px;
align-items: flex-start;
}
.err {
margin: 0;
color: var(--danger);
}
@media (max-width: 640px) {
.row4 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cond {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 28px;
}
.cond > :nth-child(3) {
grid-column: 1 / 3;
grid-row: 2;
}
}
</style>
@@ -0,0 +1,15 @@
import { inject, type InjectionKey, type Ref } from 'vue';
import type { GamificationMeDto } from '@mento/shared';
export type ClubContext = {
me: Ref<GamificationMeDto | null>;
reload: () => Promise<void>;
};
export const CLUB_CTX: InjectionKey<ClubContext> = Symbol('club');
export function useClub(): ClubContext {
const ctx = inject(CLUB_CTX);
if (!ctx) throw new Error('ClubContext not provided');
return ctx;
}
@@ -0,0 +1,15 @@
import type { GamificationCondition } from '@mento/shared';
import { GAMIFICATION_CONDITION_OPS, GAMIFICATION_FIELDS } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
/** متن خوانا برای یک شرط، مثل «مبلغ بیشتر از ۱۰۰٬۰۰۰» */
export function conditionText(c: GamificationCondition): string {
const field = GAMIFICATION_FIELDS[c.field];
const label = field?.label ?? c.field;
if (c.op === 'is_true') return `${label}: بله`;
if (c.op === 'is_false') return `${label}: خیر`;
const raw = c.value ?? '';
const option = field?.options?.find((o) => o.value === String(raw));
const value = option ? option.label : typeof raw === 'number' ? toFaDigits(raw.toLocaleString('en-US')) : String(raw);
return `${label} ${GAMIFICATION_CONDITION_OPS[c.op]} ${value}`;
}