192 lines
4.8 KiB
Vue
192 lines
4.8 KiB
Vue
<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>
|