feat: باشگاه امتیازات کارکنان (موتور امتیاز، جدول رتبه، فروشگاه پاداش، نشانها، پنل مدیریت و گزارش ROI)
- موتور رویدادمحور با شرط، محدودیت، تأیید مدیر و idempotency - اتصال به لید، مشتری، معامله، فعالیت، روتین، پیشفاکتور، فاکتور و تیکت - صفحه /club و /club/admin، اعلان زنده امتیاز، آیتم منو - انتقال فرمساز سرنخ به ابزارها Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user