feat: routines module; super admin subscription badge, 7-day trial and lifetime plan
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import type { RoutineMemberStatDto } from '@mento/shared';
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import { formatMinutes, formatPercent, percentTone } from '@/lib/routines';
|
||||
|
||||
const props = defineProps<{ members: RoutineMemberStatDto[] }>();
|
||||
const emit = defineEmits<{ select: [userId: string] }>();
|
||||
|
||||
type SortKey = 'name' | 'total' | 'completed' | 'overdue' | 'skipped' | 'percent' | 'streak' | 'avg';
|
||||
|
||||
const sortKey = ref<SortKey>('percent');
|
||||
const sortDir = ref<'asc' | 'desc'>('desc');
|
||||
|
||||
const columns: Array<{ key: SortKey; label: string }> = [
|
||||
{ key: 'name', label: 'کاربر' },
|
||||
{ key: 'total', label: 'کل کارها' },
|
||||
{ key: 'completed', label: 'انجام شده' },
|
||||
{ key: 'overdue', label: 'عقبافتاده' },
|
||||
{ key: 'skipped', label: 'رد شده' },
|
||||
{ key: 'percent', label: 'درصد انجام' },
|
||||
{ key: 'streak', label: 'روز پیاپی کامل' },
|
||||
{ key: 'avg', label: 'میانگین زمان انجام' },
|
||||
];
|
||||
|
||||
function value(m: RoutineMemberStatDto, key: SortKey): number | string {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return m.user.fullName;
|
||||
case 'percent':
|
||||
return m.percent ?? -1;
|
||||
case 'avg':
|
||||
return m.avgCompletionMinutes ?? Number.MAX_SAFE_INTEGER;
|
||||
default:
|
||||
return m[key];
|
||||
}
|
||||
}
|
||||
|
||||
const rows = computed(() => {
|
||||
const dir = sortDir.value === 'asc' ? 1 : -1;
|
||||
return [...props.members].sort((a, b) => {
|
||||
const va = value(a, sortKey.value);
|
||||
const vb = value(b, sortKey.value);
|
||||
if (typeof va === 'string' && typeof vb === 'string') return va.localeCompare(vb, 'fa') * dir;
|
||||
return ((va as number) - (vb as number)) * dir;
|
||||
});
|
||||
});
|
||||
|
||||
function sortBy(key: SortKey) {
|
||||
if (sortKey.value === key) sortDir.value = sortDir.value === 'asc' ? 'desc' : 'asc';
|
||||
else {
|
||||
sortKey.value = key;
|
||||
sortDir.value = key === 'name' || key === 'avg' ? 'asc' : 'desc';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="crm-table-container">
|
||||
<table class="crm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
v-for="c in columns"
|
||||
:key="c.key"
|
||||
:aria-sort="sortKey === c.key ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'"
|
||||
>
|
||||
<button type="button" class="sort" :class="{ on: sortKey === c.key }" @click="sortBy(c.key)">
|
||||
{{ c.label }}
|
||||
<span class="arrow">{{ sortKey === c.key ? (sortDir === 'asc' ? '▲' : '▼') : '↕' }}</span>
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="m in rows" :key="m.user.id" class="clickable" @click="emit('select', m.user.id)">
|
||||
<td>
|
||||
<span class="who">
|
||||
<UserAvatar :name="m.user.fullName" :user-id="m.user.id" :size="28" />
|
||||
{{ m.user.fullName }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ toFaDigits(m.total) }}</td>
|
||||
<td class="ok">{{ toFaDigits(m.completed) }}</td>
|
||||
<td :class="{ bad: m.overdue > 0 }">{{ toFaDigits(m.overdue) }}</td>
|
||||
<td>{{ toFaDigits(m.skipped) }}</td>
|
||||
<td>
|
||||
<span class="pct" :class="`tone-${percentTone(m.percent)}`">
|
||||
<strong>{{ formatPercent(m.percent) }}</strong>
|
||||
<i><b :style="{ width: `${m.percent ?? 0}%` }" /></i>
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ toFaDigits(m.streak) }}</td>
|
||||
<td class="nowrap">{{ formatMinutes(m.avgCompletionMinutes) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!rows.length">
|
||||
<td colspan="8" class="empty">دادهای برای این بازه نیست.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sort {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sort.on {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.who {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
font-weight: var(--fw-medium);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--success-deep);
|
||||
}
|
||||
|
||||
.bad {
|
||||
color: var(--danger);
|
||||
font-weight: var(--fw-semibold);
|
||||
}
|
||||
|
||||
.pct {
|
||||
display: grid;
|
||||
grid-template-columns: 44px 80px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pct i {
|
||||
height: 6px;
|
||||
border-radius: 99px;
|
||||
background: var(--surface-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pct b {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--line-strong);
|
||||
}
|
||||
|
||||
.tone-good b {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.tone-mid b {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.tone-low b {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.empty {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: var(--sp-6);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user