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,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>