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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-10 21:28:49 +03:30
co-authored by Cursor
parent b87ebb22e0
commit 60d797f184
51 changed files with 11298 additions and 806 deletions
@@ -0,0 +1,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>