294 lines
6.1 KiB
Vue
294 lines
6.1 KiB
Vue
<script setup lang="ts">
|
|||
|
|
import { onMounted, ref, watch } from 'vue';
|
||
|
|
import { RouterLink } from 'vue-router';
|
||
|
|
import type { GamificationTxDto, PaginatedResponse } from '@mento/shared';
|
||
|
|
import UiIcon from '@/components/UiIcon.vue';
|
||
|
|
import { UiPagination } from '@/components/ui';
|
||
|
|
import { api } from '@/lib/api';
|
||
|
|
import { apiError, formatDateTime, signedFa, txIcon } from '@/lib/gamification';
|
||
|
|
|
||
|
|
const props = withDefaults(defineProps<{ userId?: string | null; showUser?: boolean }>(), {
|
||
|
|
userId: null,
|
||
|
|
showUser: false,
|
||
|
|
});
|
||
|
|
|
||
|
|
const KINDS = [
|
||
|
|
{ value: '', label: 'همه' },
|
||
|
|
{ value: 'rule', label: 'فعالیتها' },
|
||
|
|
{ value: 'bonus', label: 'بونوس' },
|
||
|
|
{ value: 'achievement', label: 'نشان' },
|
||
|
|
{ value: 'adjust', label: 'تنظیم دستی' },
|
||
|
|
{ value: 'redeem', label: 'خرید پاداش' },
|
||
|
|
];
|
||
|
|
|
||
|
|
const STATUS_LABELS: Record<string, string> = {
|
||
|
|
pending: 'در انتظار تأیید',
|
||
|
|
rejected: 'رد شده',
|
||
|
|
};
|
||
|
|
|
||
|
|
const kind = ref('');
|
||
|
|
const page = ref(1);
|
||
|
|
const data = ref<PaginatedResponse<GamificationTxDto> | null>(null);
|
||
|
|
const error = ref('');
|
||
|
|
const loading = ref(false);
|
||
|
|
|
||
|
|
async function load() {
|
||
|
|
loading.value = true;
|
||
|
|
try {
|
||
|
|
const res = await api.get<PaginatedResponse<GamificationTxDto>>('/gamification/transactions', {
|
||
|
|
params: { kind: kind.value || undefined, page: page.value, limit: 30, userId: props.userId || undefined },
|
||
|
|
});
|
||
|
|
data.value = res.data;
|
||
|
|
error.value = '';
|
||
|
|
} catch (e) {
|
||
|
|
error.value = apiError(e, 'بارگذاری تاریخچه ممکن نشد.');
|
||
|
|
} finally {
|
||
|
|
loading.value = false;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
watch(kind, () => {
|
||
|
|
page.value = 1;
|
||
|
|
void load();
|
||
|
|
});
|
||
|
|
watch(() => props.userId, () => {
|
||
|
|
page.value = 1;
|
||
|
|
void load();
|
||
|
|
});
|
||
|
|
onMounted(load);
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<section class="hist">
|
||
|
|
<div class="chips" role="tablist">
|
||
|
|
<button
|
||
|
|
v-for="k in KINDS"
|
||
|
|
:key="k.value"
|
||
|
|
type="button"
|
||
|
|
:class="{ on: kind === k.value }"
|
||
|
|
@click="kind = k.value"
|
||
|
|
>
|
||
|
|
{{ k.label }}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p v-if="error" class="err">{{ error }}</p>
|
||
|
|
|
||
|
|
<ul class="list" :class="{ loading }">
|
||
|
|
<li v-for="tx in data?.items ?? []" :key="tx.id" :class="tx.status">
|
||
|
|
<span class="ic" :class="tx.kind"><UiIcon :name="txIcon(tx)" :size="16" /></span>
|
||
|
|
<span class="body">
|
||
|
|
<b>
|
||
|
|
<template v-if="showUser">{{ tx.userName }} · </template>{{ tx.title }}
|
||
|
|
</b>
|
||
|
|
<small>
|
||
|
|
<RouterLink v-if="tx.link && tx.entityTitle" :to="tx.link">{{ tx.entityTitle }}</RouterLink>
|
||
|
|
<template v-else-if="tx.entityTitle">{{ tx.entityTitle }}</template>
|
||
|
|
<template v-if="tx.entityTitle"> · </template>{{ formatDateTime(tx.createdAt) }}
|
||
|
|
</small>
|
||
|
|
<small v-if="tx.reason" class="reason">
|
||
|
|
دلیل: {{ tx.reason }}<template v-if="tx.createdByName"> — {{ tx.createdByName }}</template>
|
||
|
|
</small>
|
||
|
|
</span>
|
||
|
|
<span v-if="STATUS_LABELS[tx.status]" class="st">{{ STATUS_LABELS[tx.status] }}</span>
|
||
|
|
<span class="pts">
|
||
|
|
<b v-if="tx.score" :class="{ neg: tx.score < 0 }">{{ signedFa(tx.score) }} <small>امتیاز</small></b>
|
||
|
|
<em v-if="tx.coins" :class="{ neg: tx.coins < 0 }">{{ signedFa(tx.coins) }} <small>سکه</small></em>
|
||
|
|
</span>
|
||
|
|
</li>
|
||
|
|
<li v-if="data && !data.items.length" class="empty">موردی ثبت نشده است.</li>
|
||
|
|
</ul>
|
||
|
|
|
||
|
|
<UiPagination
|
||
|
|
v-if="data && data.total > data.limit"
|
||
|
|
v-model:page="page"
|
||
|
|
:total-items="data.total"
|
||
|
|
:page-size="data.limit"
|
||
|
|
@change="load"
|
||
|
|
/>
|
||
|
|
</section>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style scoped>
|
||
|
|
.hist {
|
||
|
|
display: grid;
|
||
|
|
gap: var(--sp-3);
|
||
|
|
min-width: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chips {
|
||
|
|
display: flex;
|
||
|
|
gap: 6px;
|
||
|
|
flex-wrap: wrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chips button {
|
||
|
|
border: 1px solid var(--line);
|
||
|
|
background: var(--surface);
|
||
|
|
border-radius: 999px;
|
||
|
|
padding: 0.3rem 0.8rem;
|
||
|
|
font: inherit;
|
||
|
|
font-size: var(--fs-xs);
|
||
|
|
font-weight: 700;
|
||
|
|
color: var(--ink-secondary);
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.chips button.on {
|
||
|
|
border-color: var(--accent);
|
||
|
|
background: var(--accent-subtle);
|
||
|
|
color: var(--accent-deep);
|
||
|
|
}
|
||
|
|
|
||
|
|
.err {
|
||
|
|
margin: 0;
|
||
|
|
color: var(--danger);
|
||
|
|
}
|
||
|
|
|
||
|
|
.list {
|
||
|
|
list-style: none;
|
||
|
|
margin: 0;
|
||
|
|
padding: 0;
|
||
|
|
background: var(--surface);
|
||
|
|
border: 1px solid var(--line);
|
||
|
|
border-radius: calc(var(--radius) + 4px);
|
||
|
|
overflow: hidden;
|
||
|
|
}
|
||
|
|
|
||
|
|
.list.loading {
|
||
|
|
opacity: 0.6;
|
||
|
|
}
|
||
|
|
|
||
|
|
.list li {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: var(--sp-3);
|
||
|
|
padding: var(--sp-3) var(--sp-4);
|
||
|
|
border-bottom: 1px solid var(--line-light);
|
||
|
|
}
|
||
|
|
|
||
|
|
.list li:last-child {
|
||
|
|
border-bottom: 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
.list li.rejected {
|
||
|
|
opacity: 0.55;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ic {
|
||
|
|
width: 34px;
|
||
|
|
height: 34px;
|
||
|
|
border-radius: 10px;
|
||
|
|
display: grid;
|
||
|
|
place-items: center;
|
||
|
|
flex: 0 0 auto;
|
||
|
|
background: var(--accent-subtle);
|
||
|
|
color: var(--accent-deep);
|
||
|
|
}
|
||
|
|
|
||
|
|
.ic.bonus,
|
||
|
|
.ic.achievement {
|
||
|
|
background: #fef3c7;
|
||
|
|
color: #b45309;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ic.redeem,
|
||
|
|
.ic.refund {
|
||
|
|
background: #ede9fe;
|
||
|
|
color: #6d28d9;
|
||
|
|
}
|
||
|
|
|
||
|
|
.ic.adjust {
|
||
|
|
background: var(--surface-subtle);
|
||
|
|
color: var(--ink-secondary);
|
||
|
|
}
|
||
|
|
|
||
|
|
.body {
|
||
|
|
flex: 1;
|
||
|
|
min-width: 0;
|
||
|
|
display: grid;
|
||
|
|
gap: 1px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.body b {
|
||
|
|
font-size: var(--fs-sm);
|
||
|
|
color: var(--ink);
|
||
|
|
}
|
||
|
|
|
||
|
|
.body small {
|
||
|
|
font-size: var(--fs-xs);
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.body a {
|
||
|
|
color: var(--accent-hover);
|
||
|
|
}
|
||
|
|
|
||
|
|
.reason {
|
||
|
|
color: var(--ink-secondary) !important;
|
||
|
|
}
|
||
|
|
|
||
|
|
.st {
|
||
|
|
font-size: 0.7rem;
|
||
|
|
font-weight: 700;
|
||
|
|
padding: 2px 8px;
|
||
|
|
border-radius: 999px;
|
||
|
|
background: var(--warning-soft);
|
||
|
|
color: var(--warning-deep);
|
||
|
|
flex: 0 0 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.rejected .st {
|
||
|
|
background: var(--danger-soft);
|
||
|
|
color: var(--danger);
|
||
|
|
}
|
||
|
|
|
||
|
|
.pts {
|
||
|
|
display: grid;
|
||
|
|
text-align: end;
|
||
|
|
flex: 0 0 auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pts b {
|
||
|
|
color: var(--success-deep);
|
||
|
|
font-weight: 800;
|
||
|
|
}
|
||
|
|
|
||
|
|
.pts em {
|
||
|
|
font-style: normal;
|
||
|
|
color: #b45309;
|
||
|
|
font-weight: 700;
|
||
|
|
font-size: var(--fs-sm);
|
||
|
|
}
|
||
|
|
|
||
|
|
.pts small {
|
||
|
|
font-size: 0.65rem;
|
||
|
|
font-weight: 600;
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
.pts .neg {
|
||
|
|
color: var(--danger);
|
||
|
|
}
|
||
|
|
|
||
|
|
.empty {
|
||
|
|
justify-content: center;
|
||
|
|
color: var(--muted);
|
||
|
|
}
|
||
|
|
|
||
|
|
@media (max-width: 600px) {
|
||
|
|
.list li {
|
||
|
|
padding: var(--sp-3);
|
||
|
|
flex-wrap: wrap;
|
||
|
|
}
|
||
|
|
|
||
|
|
.body {
|
||
|
|
flex-basis: calc(100% - 50px);
|
||
|
|
}
|
||
|
|
|
||
|
|
.pts {
|
||
|
|
margin-inline-start: auto;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
</style>
|