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