feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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}`;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import MentoLogo from '@/components/brand/MentoLogo.vue';
|
||||
import QuickCreate from '@/components/QuickCreate.vue';
|
||||
import OrgSwitcher from '@/components/OrgSwitcher.vue';
|
||||
import NavHubPanel from '@/components/nav/NavHubPanel.vue';
|
||||
import GamificationToaster from '@/components/gamification/GamificationToaster.vue';
|
||||
import { NAV_HUBS, type NavHubDef, type NavHubKey } from '@/lib/nav-hubs';
|
||||
import type {
|
||||
ActivitySummaryDto,
|
||||
@@ -49,9 +50,8 @@ function pathIn(prefixes: string[]) {
|
||||
const dashboardActive = computed(() => route.path === '/');
|
||||
const loyaltyActive = computed(() => pathIn(['/loyalty']));
|
||||
const reportsActive = computed(() => pathIn(['/reports']));
|
||||
const leadsActive = computed(() =>
|
||||
pathIn(['/leads', '/settings/lead-stages', '/settings/lead-forms']),
|
||||
);
|
||||
const clubActive = computed(() => pathIn(['/club']));
|
||||
const leadsActive = computed(() => pathIn(['/leads', '/settings/lead-stages']));
|
||||
const customersActive = computed(
|
||||
() => pathIn(['/contacts']) || (isMobileNav.value && leadsActive.value),
|
||||
);
|
||||
@@ -85,6 +85,7 @@ const toolsActive = computed(() =>
|
||||
'/cheques',
|
||||
'/attachments',
|
||||
'/reservations',
|
||||
'/settings/lead-forms',
|
||||
]),
|
||||
);
|
||||
const settingsActive = computed(() =>
|
||||
@@ -104,6 +105,7 @@ const settingsActive = computed(() =>
|
||||
const moreSectionActive = computed(
|
||||
() =>
|
||||
routinesActive.value ||
|
||||
clubActive.value ||
|
||||
notesActive.value ||
|
||||
conversationsActive.value ||
|
||||
supportActive.value ||
|
||||
@@ -539,6 +541,18 @@ onUnmounted(() => {
|
||||
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('gamification')"
|
||||
to="/club"
|
||||
class="nav-item"
|
||||
title="باشگاه امتیازات"
|
||||
:class="{ 'router-link-active': clubActive }"
|
||||
@click="closeHub(); closeMenu()"
|
||||
>
|
||||
<UiIcon name="trophy" :size="collapsed ? 20 : 18" />
|
||||
<span>باشگاه امتیازات</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('conversations')"
|
||||
to="/conversations"
|
||||
@@ -796,6 +810,7 @@ onUnmounted(() => {
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
<GamificationToaster v-if="moduleAllowed('gamification')" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -899,6 +914,16 @@ onUnmounted(() => {
|
||||
<span>کارهای روتین</span>
|
||||
<span v-if="routineBadge" class="nav-badge">{{ toFaDigits(routineBadge) }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="moduleAllowed('gamification')"
|
||||
to="/club"
|
||||
class="nav-item"
|
||||
:class="{ 'router-link-active': clubActive }"
|
||||
@click="closeHub(); closeMore()"
|
||||
>
|
||||
<UiIcon name="trophy" :size="18" />
|
||||
<span>باشگاه امتیازات</span>
|
||||
</RouterLink>
|
||||
<RouterLink to="/notes" class="nav-item" @click="closeHub(); closeMore()">
|
||||
<UiIcon name="document" :size="18" />
|
||||
<span>یادداشتها</span>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { GamificationEvent, GamificationTxDto } from '@mento/shared';
|
||||
import { GAMIFICATION_EVENTS } from '@mento/shared';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
export { apiError } from '@/lib/routines';
|
||||
|
||||
type IconName =
|
||||
| 'trophy'
|
||||
| 'user-plus'
|
||||
| 'funnel'
|
||||
| 'briefcase'
|
||||
| 'phone'
|
||||
| 'calendar'
|
||||
| 'document'
|
||||
| 'check'
|
||||
| 'refresh'
|
||||
| 'dollar'
|
||||
| 'chat'
|
||||
| 'star'
|
||||
| 'medal'
|
||||
| 'gift'
|
||||
| 'sliders'
|
||||
| 'building'
|
||||
| 'trend';
|
||||
|
||||
const EVENT_ICONS: Record<GamificationEvent, IconName> = {
|
||||
LEAD_CREATED: 'funnel',
|
||||
LEAD_STAGE_CHANGED: 'trend',
|
||||
LEAD_CONVERTED: 'user-plus',
|
||||
CUSTOMER_CREATED: 'user-plus',
|
||||
COMPANY_CREATED: 'building',
|
||||
DEAL_CREATED: 'briefcase',
|
||||
DEAL_STAGE_CHANGED: 'trend',
|
||||
DEAL_WON: 'trophy',
|
||||
DEAL_LOST: 'briefcase',
|
||||
CALL_LOGGED: 'phone',
|
||||
MEETING_CREATED: 'calendar',
|
||||
FOLLOW_UP_CREATED: 'calendar',
|
||||
NOTE_CREATED: 'document',
|
||||
ACTIVITY_CREATED: 'check',
|
||||
TASK_COMPLETED: 'check',
|
||||
ROUTINE_COMPLETED: 'refresh',
|
||||
QUOTE_CREATED: 'document',
|
||||
INVOICE_ISSUED: 'dollar',
|
||||
PAYMENT_REGISTERED: 'dollar',
|
||||
TICKET_CREATED: 'chat',
|
||||
TICKET_RESOLVED: 'chat',
|
||||
};
|
||||
|
||||
export function txIcon(tx: Pick<GamificationTxDto, 'kind' | 'event'>): IconName {
|
||||
if (tx.kind === 'bonus') return 'star';
|
||||
if (tx.kind === 'achievement') return 'medal';
|
||||
if (tx.kind === 'redeem' || tx.kind === 'refund') return 'gift';
|
||||
if (tx.kind === 'adjust') return 'sliders';
|
||||
return tx.event ? EVENT_ICONS[tx.event] : 'star';
|
||||
}
|
||||
|
||||
export function eventLabel(event: GamificationEvent | null | undefined): string {
|
||||
return event ? GAMIFICATION_EVENTS[event]?.label ?? event : '';
|
||||
}
|
||||
|
||||
/** عدد فارسی با جداکننده هزارگان */
|
||||
export function faNum(value: number | null | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—';
|
||||
return toFaDigits(Math.round(value).toLocaleString('en-US'));
|
||||
}
|
||||
|
||||
export function signedFa(value: number): string {
|
||||
if (!value) return toFaDigits(0);
|
||||
return `${value > 0 ? '+' : '−'}${faNum(Math.abs(value))}`;
|
||||
}
|
||||
|
||||
/** «۵ دقیقه پیش»، «دیروز ۱۴:۲۰» … */
|
||||
export function timeAgo(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const diff = Date.now() - d.getTime();
|
||||
const min = Math.floor(diff / 60_000);
|
||||
if (min < 1) return 'همین الان';
|
||||
if (min < 60) return `${toFaDigits(min)} دقیقه پیش`;
|
||||
const hours = Math.floor(min / 60);
|
||||
if (hours < 24) return `${toFaDigits(hours)} ساعت پیش`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 7) return `${toFaDigits(days)} روز پیش`;
|
||||
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium' }).format(d);
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string): string {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export const REWARD_ICONS = ['gift', 'star', 'trophy', 'medal', 'crown', 'calendar-sun', 'bag', 'target', 'fire'] as const;
|
||||
export const BADGE_ICONS = ['medal', 'trophy', 'crown', 'star', 'fire', 'phone', 'users', 'funnel', 'target', 'check'] as const;
|
||||
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
owner: 'مالک',
|
||||
admin: 'مدیر',
|
||||
sales: 'فروش',
|
||||
support: 'پشتیبانی',
|
||||
};
|
||||
@@ -136,15 +136,6 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
admin: true,
|
||||
module: 'leads',
|
||||
},
|
||||
{
|
||||
to: '/settings/lead-forms',
|
||||
title: 'فرمساز سرنخ',
|
||||
desc: 'لینک عمومی، iframe و QR برای جذب سرنخ',
|
||||
icon: 'document',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'lead_forms',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -367,6 +358,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'amber',
|
||||
module: 'attachments',
|
||||
},
|
||||
{
|
||||
to: '/settings/lead-forms',
|
||||
title: 'فرمساز سرنخ',
|
||||
desc: 'لینک عمومی، iframe و QR برای جذب سرنخ',
|
||||
icon: 'document',
|
||||
tint: 'mint',
|
||||
admin: true,
|
||||
module: 'lead_forms',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -278,6 +278,16 @@ export const router = createRouter({
|
||||
name: 'loyalty',
|
||||
component: () => import('@/views/LoyaltyClubView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'club',
|
||||
name: 'gamification',
|
||||
component: () => import('@/views/gamification/GamificationView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'club/admin',
|
||||
name: 'gamification-admin',
|
||||
component: () => import('@/views/gamification/GamificationAdminView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'deals/new',
|
||||
name: 'deal-new',
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { GamificationReportDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { api } from '@/lib/api';
|
||||
import AdminRules from '@/components/gamification/admin/AdminRules.vue';
|
||||
import AdminApprovals from '@/components/gamification/admin/AdminApprovals.vue';
|
||||
import AdminRewards from '@/components/gamification/admin/AdminRewards.vue';
|
||||
import AdminAchievements from '@/components/gamification/admin/AdminAchievements.vue';
|
||||
import AdminAdjust from '@/components/gamification/admin/AdminAdjust.vue';
|
||||
import AdminSettings from '@/components/gamification/admin/AdminSettings.vue';
|
||||
import AdminReports from '@/components/gamification/admin/AdminReports.vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
|
||||
type Tab = 'rules' | 'redemptions' | 'rewards' | 'badges' | 'adjust' | 'settings' | 'reports';
|
||||
const TABS: Array<{
|
||||
value: Tab;
|
||||
label: string;
|
||||
icon: 'sliders' | 'check' | 'gift' | 'medal' | 'edit' | 'settings' | 'chart';
|
||||
}> = [
|
||||
{ value: 'rules', label: 'قوانین امتیاز', icon: 'sliders' },
|
||||
{ value: 'redemptions', label: 'تأییدها', icon: 'check' },
|
||||
{ value: 'rewards', label: 'فروشگاه پاداش', icon: 'gift' },
|
||||
{ value: 'badges', label: 'نشانها و بونوسها', icon: 'medal' },
|
||||
{ value: 'adjust', label: 'تنظیم دستی', icon: 'edit' },
|
||||
{ value: 'reports', label: 'گزارش و ROI', icon: 'chart' },
|
||||
{ value: 'settings', label: 'تنظیمات', icon: 'settings' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role === 'owner' || auth.user?.role === 'admin');
|
||||
const pendingCount = ref(0);
|
||||
|
||||
const tab = computed<Tab>(() => {
|
||||
const t = route.query.tab;
|
||||
return TABS.some((x) => x.value === t) ? (t as Tab) : 'rules';
|
||||
});
|
||||
|
||||
function setTab(next: Tab) {
|
||||
void router.replace({ query: { ...route.query, tab: next === 'rules' ? undefined : next } });
|
||||
}
|
||||
|
||||
async function loadPending() {
|
||||
try {
|
||||
const { data } = await api.get<GamificationReportDto>('/admin/gamification/reports', {
|
||||
params: { period: 'today' },
|
||||
});
|
||||
pendingCount.value = data.totals.pendingApprovals + data.totals.pendingRedemptions;
|
||||
} catch {
|
||||
pendingCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function onChanged() {
|
||||
void loadPending();
|
||||
window.dispatchEvent(new CustomEvent('gamification:updated'));
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (isAdmin.value) void loadPending();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page gadmin">
|
||||
<header class="head">
|
||||
<RouterLink to="/club" class="back"><UiIcon name="chevron-right" :size="16" /> باشگاه امتیازات</RouterLink>
|
||||
<h1>مدیریت باشگاه امتیازات</h1>
|
||||
<p>قوانین امتیازدهی، پاداشها، نشانها و درخواستها را از اینجا مدیریت کنید.</p>
|
||||
</header>
|
||||
|
||||
<p v-if="!isAdmin" class="denied">این بخش فقط برای مالک و مدیران سازمان در دسترس است.</p>
|
||||
|
||||
<template v-else>
|
||||
<nav class="tabs" aria-label="بخشهای مدیریت باشگاه">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="tab"
|
||||
:class="{ on: tab === t.value }"
|
||||
@click="setTab(t.value)"
|
||||
>
|
||||
<UiIcon :name="t.icon" :size="15" />
|
||||
<span>{{ t.label }}</span>
|
||||
<em v-if="t.value === 'redemptions' && pendingCount">{{ toFaDigits(pendingCount) }}</em>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<AdminRules v-if="tab === 'rules'" />
|
||||
<AdminApprovals v-else-if="tab === 'redemptions'" @changed="onChanged" />
|
||||
<AdminRewards v-else-if="tab === 'rewards'" />
|
||||
<AdminAchievements v-else-if="tab === 'badges'" />
|
||||
<AdminAdjust v-else-if="tab === 'adjust'" @changed="onChanged" />
|
||||
<AdminReports v-else-if="tab === 'reports'" />
|
||||
<AdminSettings v-else-if="tab === 'settings'" @changed="onChanged" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gadmin {
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.back {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--accent-deep);
|
||||
font-size: var(--fs-sm);
|
||||
text-decoration: none;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.head h1 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-xl);
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.denied {
|
||||
padding: var(--sp-4);
|
||||
border-radius: var(--radius);
|
||||
background: var(--warning-soft);
|
||||
color: var(--warning-deep);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab.on {
|
||||
color: var(--accent-deep);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab em {
|
||||
font-style: normal;
|
||||
font-size: 0.68rem;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,653 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import type { GamificationLeaderboardDto, GamificationMeDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { apiError, faNum } from '@/lib/gamification';
|
||||
import ClubHome from '@/components/gamification/ClubHome.vue';
|
||||
import ClubHistory from '@/components/gamification/ClubHistory.vue';
|
||||
import ClubStore from '@/components/gamification/ClubStore.vue';
|
||||
import ClubBadges from '@/components/gamification/ClubBadges.vue';
|
||||
import ClubTeams from '@/components/gamification/ClubTeams.vue';
|
||||
import ClubRulesModal from '@/components/gamification/ClubRulesModal.vue';
|
||||
import { CLUB_CTX } from '@/components/gamification/club-context';
|
||||
|
||||
type Tab = 'home' | 'history' | 'store' | 'badges' | 'teams';
|
||||
const TABS: Array<{ value: Tab; label: string; icon: 'home' | 'clock' | 'gift' | 'medal' | 'users' }> = [
|
||||
{ value: 'home', label: 'باشگاه', icon: 'home' },
|
||||
{ value: 'history', label: 'تاریخچه امتیازات', icon: 'clock' },
|
||||
{ value: 'store', label: 'فروشگاه پاداش', icon: 'gift' },
|
||||
{ value: 'badges', label: 'نشانها', icon: 'medal' },
|
||||
{ value: 'teams', label: 'رقابت تیمی', icon: 'users' },
|
||||
];
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const me = ref<GamificationMeDto | null>(null);
|
||||
const podium = ref<GamificationLeaderboardDto | null>(null);
|
||||
const error = ref('');
|
||||
const loading = ref(true);
|
||||
const rulesOpen = ref(false);
|
||||
|
||||
const tab = computed<Tab>(() => {
|
||||
const t = route.query.tab;
|
||||
return TABS.some((x) => x.value === t) ? (t as Tab) : 'home';
|
||||
});
|
||||
|
||||
function setTab(next: Tab) {
|
||||
void router.replace({ query: { ...route.query, tab: next === 'home' ? undefined : next } });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const [m, p] = await Promise.all([
|
||||
api.get<GamificationMeDto>('/gamification/me', { params: { period: 'month' } }),
|
||||
api.get<GamificationLeaderboardDto>('/gamification/leaderboard', { params: { period: 'month', limit: 3 } }),
|
||||
]);
|
||||
me.value = m.data;
|
||||
podium.value = p.data;
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری باشگاه امتیازات ممکن نشد.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
provide(CLUB_CTX, { me, reload: load });
|
||||
|
||||
/** سکوی سه نفر اول به ترتیب نمایش: دوم، اول، سوم */
|
||||
const podiumOrder = computed(() => {
|
||||
const items = podium.value?.items ?? [];
|
||||
return [items[1], items[0], items[2]].filter((x): x is NonNullable<typeof x> => !!x && x.score > 0);
|
||||
});
|
||||
|
||||
const gapText = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m || m.rank == null) return '';
|
||||
if (m.rank === 1 && m.periodScore > 0) return 'تو صدرنشین این ماهی! همینطور ادامه بده.';
|
||||
if (m.gapToFirst != null && m.gapToFirst > 0) {
|
||||
return `تا رتبه اول فقط ${faNum(m.gapToFirst)} امتیاز فاصله داری!`;
|
||||
}
|
||||
return 'با اولین امتیاز این ماه وارد رقابت شو!';
|
||||
});
|
||||
|
||||
const gapProgress = computed(() => {
|
||||
const m = me.value;
|
||||
if (!m) return 0;
|
||||
if (m.rank === 1) return 100;
|
||||
const top = m.periodScore + (m.gapToFirst ?? 0);
|
||||
return top > 0 ? Math.round((m.periodScore / top) * 100) : 0;
|
||||
});
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void load();
|
||||
}, 60_000);
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
window.addEventListener('gamification:updated', load);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (timer) clearInterval(timer);
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
window.removeEventListener('gamification:updated', load);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.query.rules,
|
||||
(v) => {
|
||||
if (v === '1') rulesOpen.value = true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="crm-page club">
|
||||
<header class="hero">
|
||||
<div class="hero-top">
|
||||
<div class="hero-title">
|
||||
<span class="star"><UiIcon name="star" :size="20" /></span>
|
||||
<div>
|
||||
<h1>باشگاه امتیازات</h1>
|
||||
<p>هر کار خوب، امتیاز و سکه دارد؛ رقابت کن، پیشرفت کن و پاداش بگیر.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<button type="button" class="hero-btn" @click="rulesOpen = true">
|
||||
<UiIcon name="lightbulb" :size="16" />
|
||||
<span>قوانین و نحوه امتیازدهی</span>
|
||||
</button>
|
||||
<button v-if="me?.isManager" type="button" class="hero-btn solid" @click="router.push('/club/admin')">
|
||||
<UiIcon name="sliders" :size="16" />
|
||||
<span>مدیریت باشگاه</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="hero-error">{{ error }}</p>
|
||||
|
||||
<div v-else class="hero-body" :class="{ loading }">
|
||||
<div class="podium" aria-label="سه نفر برتر این ماه">
|
||||
<div
|
||||
v-for="p in podiumOrder"
|
||||
:key="p.userId"
|
||||
class="pod"
|
||||
:class="[`r${p.rank}`, { me: p.isMe }]"
|
||||
>
|
||||
<span v-if="p.rank === 1" class="crown"><UiIcon name="crown" :size="18" /></span>
|
||||
<UserAvatar :user-id="p.userId" :has-avatar="p.hasAvatar" :name="p.fullName" :size="p.rank === 1 ? 54 : 44" />
|
||||
<strong class="pod-name">{{ p.isMe ? 'شما' : p.fullName }}</strong>
|
||||
<span class="pod-score">{{ faNum(p.score) }}</span>
|
||||
<span class="pod-step">{{ toFaDigits(p.rank) }}</span>
|
||||
</div>
|
||||
<p v-if="!podiumOrder.length && !loading" class="pod-empty">
|
||||
هنوز امتیازی در این ماه ثبت نشده — اولین نفر باش!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="hero-stats">
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<span class="stat-label">رتبه شما</span>
|
||||
<strong>{{ me?.rank ? `#${toFaDigits(me.rank)}` : '—' }}</strong>
|
||||
<small v-if="me">از {{ toFaDigits(me.totalUsers) }} نفر</small>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">امتیاز کل</span>
|
||||
<strong>{{ faNum(me?.score ?? 0) }}</strong>
|
||||
<small v-if="me">این ماه {{ faNum(me.periodScore) }}</small>
|
||||
</div>
|
||||
<div class="stat coin">
|
||||
<span class="stat-label">موجودی سکه</span>
|
||||
<strong>{{ faNum(me?.coins ?? 0) }}</strong>
|
||||
<small>قابل خرج در فروشگاه</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gap">
|
||||
<div class="gap-text">
|
||||
<UiIcon name="target" :size="15" />
|
||||
<span>{{ gapText }}</span>
|
||||
</div>
|
||||
<div class="bar"><i :style="{ width: `${gapProgress}%` }" /></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-side">
|
||||
<div class="goal">
|
||||
<div class="goal-head">
|
||||
<span>هدف بعدی</span>
|
||||
<strong>{{ toFaDigits(me?.goal.progress ?? 0) }}٪</strong>
|
||||
</div>
|
||||
<p>رسیدن به {{ faNum(me?.goal.target ?? 0) }} امتیاز در این ماه</p>
|
||||
<div class="bar warm"><i :style="{ width: `${me?.goal.progress ?? 0}%` }" /></div>
|
||||
</div>
|
||||
<button
|
||||
v-if="me?.nextReward"
|
||||
type="button"
|
||||
class="next-reward"
|
||||
@click="setTab('store')"
|
||||
>
|
||||
<UiIcon name="gift" :size="18" />
|
||||
<span>
|
||||
<small>پاداش بعدی</small>
|
||||
<b>{{ me.nextReward.name }}</b>
|
||||
</span>
|
||||
<em>{{ faNum(me.nextReward.remaining) }} سکه مانده</em>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<nav class="c-tabs" aria-label="بخشهای باشگاه امتیازات">
|
||||
<button
|
||||
v-for="t in TABS"
|
||||
:key="t.value"
|
||||
type="button"
|
||||
class="c-tab"
|
||||
:class="{ on: tab === t.value }"
|
||||
@click="setTab(t.value)"
|
||||
>
|
||||
<UiIcon :name="t.icon" :size="15" />
|
||||
<span>{{ t.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<ClubHome v-if="tab === 'home'" @go="setTab" />
|
||||
<ClubHistory v-else-if="tab === 'history'" />
|
||||
<ClubStore v-else-if="tab === 'store'" />
|
||||
<ClubBadges v-else-if="tab === 'badges'" />
|
||||
<ClubTeams v-else-if="tab === 'teams'" />
|
||||
|
||||
<ClubRulesModal :open="rulesOpen" @close="rulesOpen = false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.club {
|
||||
gap: var(--sp-4);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: calc(var(--radius) + 6px);
|
||||
padding: var(--sp-5);
|
||||
color: #ecfeff;
|
||||
background:
|
||||
radial-gradient(circle at 12% 20%, rgba(45, 212, 191, 0.28), transparent 42%),
|
||||
radial-gradient(circle at 85% 90%, rgba(250, 204, 21, 0.14), transparent 40%),
|
||||
linear-gradient(135deg, #0b3b3c 0%, #0f5154 55%, #115e59 100%);
|
||||
box-shadow: 0 18px 40px -24px rgba(15, 81, 84, 0.8);
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.star {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: linear-gradient(145deg, #fde68a, #f59e0b);
|
||||
color: #78350f;
|
||||
flex: 0 0 auto;
|
||||
box-shadow: 0 6px 16px -6px rgba(245, 158, 11, 0.8);
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-title p {
|
||||
margin: 2px 0 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.95rem;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.hero-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
.hero-btn.solid {
|
||||
background: #fff;
|
||||
color: #0f5154;
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.hero-error {
|
||||
margin: 0;
|
||||
color: #fecaca;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr) minmax(0, 0.9fr);
|
||||
gap: var(--sp-4);
|
||||
align-items: stretch;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.hero-body.loading {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.podium {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.pod {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 92px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pod-name {
|
||||
font-size: var(--fs-xs);
|
||||
color: #fff;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pod-score {
|
||||
font-size: var(--fs-xs);
|
||||
color: #fde68a;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pod-step {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px 10px 4px 4px;
|
||||
font-weight: 800;
|
||||
font-size: 1.1rem;
|
||||
color: #0f5154;
|
||||
background: linear-gradient(180deg, #e2e8f0, #94a3b8);
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.pod.r1 .pod-step {
|
||||
height: 70px;
|
||||
background: linear-gradient(180deg, #fde68a, #f59e0b);
|
||||
color: #78350f;
|
||||
}
|
||||
|
||||
.pod.r3 .pod-step {
|
||||
height: 34px;
|
||||
background: linear-gradient(180deg, #fed7aa, #c2410c);
|
||||
color: #431407;
|
||||
}
|
||||
|
||||
.pod.me .pod-name {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.crown {
|
||||
color: #fbbf24;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.35));
|
||||
animation: bob 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bob {
|
||||
50% {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
.pod-empty {
|
||||
margin: auto 0;
|
||||
color: #a5f3fc;
|
||||
font-size: var(--fs-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--fs-xs);
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.stat.coin strong {
|
||||
color: #fde68a;
|
||||
}
|
||||
|
||||
.stat small {
|
||||
font-size: 0.7rem;
|
||||
color: #99f6e4;
|
||||
}
|
||||
|
||||
.gap {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gap-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.bar {
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #2dd4bf, #a7f3d0);
|
||||
transition: width 0.6s ease;
|
||||
}
|
||||
|
||||
.bar.warm {
|
||||
background: rgba(154, 52, 18, 0.15);
|
||||
}
|
||||
|
||||
.bar.warm i {
|
||||
background: linear-gradient(90deg, #fb923c, #f59e0b);
|
||||
}
|
||||
|
||||
.hero-side {
|
||||
display: grid;
|
||||
gap: var(--sp-2);
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.goal {
|
||||
background: linear-gradient(145deg, #fff7ed, #ffedd5);
|
||||
color: #7c2d12;
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-3);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.goal-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.goal-head strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.goal p {
|
||||
margin: 0;
|
||||
font-size: var(--fs-sm);
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.next-reward {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.next-reward span {
|
||||
display: grid;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.next-reward small {
|
||||
font-size: 0.7rem;
|
||||
color: #a5f3fc;
|
||||
}
|
||||
|
||||
.next-reward b {
|
||||
font-size: var(--fs-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.next-reward em {
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
color: #fde68a;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
display: flex;
|
||||
gap: var(--sp-5);
|
||||
border-bottom: 1px solid var(--line);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.c-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.c-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0.6rem 0.15rem;
|
||||
margin-bottom: -1px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: none;
|
||||
color: var(--muted-light);
|
||||
font: inherit;
|
||||
font-weight: var(--fw-bold);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.c-tab:hover {
|
||||
color: var(--ink-secondary);
|
||||
}
|
||||
|
||||
.c-tab.on {
|
||||
color: var(--accent-hover);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.hero-body {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-column: 1 / -1;
|
||||
order: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.hero {
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.stat {
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.stat strong {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.pod {
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.c-tabs {
|
||||
gap: var(--sp-4);
|
||||
margin-inline: calc(-1 * var(--sp-3));
|
||||
padding-inline: var(--sp-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user