feat: show the full work history and load every lead stage on the board

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 19:47:32 +03:30
co-authored by Cursor
parent 2cc81bb596
commit 831c682eb3
13 changed files with 669 additions and 66 deletions
+6 -2
View File
@@ -132,7 +132,9 @@ const hubVisible = computed(() => {
for (const hub of NAV_HUBS) {
map[hub.key] = hub.cards.some(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
(!card.ownerOnly || auth.user?.role === 'owner') &&
(!card.admin || canSettings.value) &&
moduleAllowed(card.module),
);
}
return map;
@@ -144,7 +146,9 @@ const openHub = computed<NavHubDef | null>(() => {
if (!hub) return null;
const cards = hub.cards.filter(
(card) =>
(!card.admin || canSettings.value) && moduleAllowed(card.module),
(!card.ownerOnly || auth.user?.role === 'owner') &&
(!card.admin || canSettings.value) &&
moduleAllowed(card.module),
);
if (
hub.key === 'customers' &&
+5 -3
View File
@@ -48,6 +48,8 @@ export type NavHubCard = {
icon: NavIconName;
tint: NavHubTint;
admin?: boolean;
/** فقط نقش مالک؛ مدیر سازمان این کارت را نمی‌بیند */
ownerOnly?: boolean;
module?: OrgModuleKey;
/** اگر ست باشد، به‌جای مسیر، هاب تو در تو باز می‌شود (موبایل: مشتریان → لیدها) */
hub?: NavHubKey;
@@ -440,11 +442,11 @@ export const NAV_HUBS: NavHubDef[] = [
},
{
to: '/settings/deletions',
title: 'لاگ حذفیات',
desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل',
title: 'تاریخچه کارها',
desc: 'ایجاد، ویرایش و حذف؛ بازگردانی فقط برای حذف‌ها',
icon: 'trash',
tint: 'rose',
admin: true,
ownerOnly: true,
},
{
to: '/settings/public-api',
+1 -1
View File
@@ -365,7 +365,7 @@ export const router = createRouter({
path: 'settings/deletions',
name: 'deletion-archives',
component: () => import('@/views/DeletionArchivesView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
meta: { roles: ['owner'] as UserRole[] },
},
{
path: 'settings/public-api',
+506 -31
View File
@@ -1,10 +1,59 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import type { DeletionArchiveListResponse, DeletionRetention } from '@mento/shared';
import type {
AuditLogDto,
AuditLogListResponse,
DeletionArchiveListResponse,
DeletionRetention,
} from '@mento/shared';
import { api } from '@/lib/api';
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
import { UiButton, UiCard, UiEmptyState, UiModal, UiPageHeader } from '@/components/ui';
import { UiButton, UiCard, UiEmptyState, UiModal, UiPageHeader, UiPagination } from '@/components/ui';
import { PAGE_SIZE_OPTIONS, usePageSize } from '@/lib/pageSize';
type HistoryTab = 'creates' | 'edits' | 'deletes';
const TABS: Array<{ key: HistoryTab; label: string }> = [
{ key: 'creates', label: 'ایجاد' },
{ key: 'edits', label: 'ویرایش' },
{ key: 'deletes', label: 'حذف' },
];
const ENTITY_LABELS: Record<string, string> = {
people: 'مشتری',
companies: 'شرکت',
leads: 'لید',
deals: 'معامله',
activities: 'فعالیت',
notes: 'یادداشت',
tickets: 'تیکت',
quotes: 'پیش‌فاکتور',
invoices: 'فاکتور',
products: 'محصول',
'project-cards': 'کارت پروژه',
'project-boards': 'تخته پروژه',
pipelines: 'مسیر فروش',
routines: 'روتین',
'routine-tasks': 'کار روتین',
'lead-forms': 'فرم',
users: 'کاربر',
teams: 'تیم',
automations: 'اتوماسیون',
'message-automations': 'پیام خودکار',
'sms-campaigns': 'کمپین پیامک',
'custom-fields': 'فیلد سفارشی',
inventory: 'انبار',
reservations: 'رزرو',
organizations: 'سازمان',
faq: 'پرسش متداول',
conversations: 'گفتگو',
loyalty: 'باشگاه',
notifications: 'اعلان',
'table-prefs': 'ستون‌های جدول',
integrations: 'اتصال',
};
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
{ value: 'month', label: 'یک ماه' },
@@ -15,22 +64,42 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
{ value: 'never', label: 'هرگز' },
];
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const savingRetention = ref(false);
const busyId = ref('');
const error = ref('');
const okMsg = ref('');
const q = ref('');
const actorId = ref('');
const actors = ref<Array<{ id: string; fullName: string }>>([]);
const page = ref(1);
const pageSize = usePageSize('history-log');
const retention = ref<DeletionRetention>('never');
const data = ref<DeletionArchiveListResponse | null>(null);
const audit = ref<AuditLogListResponse | null>(null);
const purgeId = ref<string | null>(null);
const totalPages = computed(() => {
if (!data.value) return 1;
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
const tab = computed<HistoryTab>(() => {
const value = route.query.tab;
if (value === 'creates' || value === 'edits' || value === 'deletes') return value;
return 'deletes';
});
const totalPages = computed(() => {
const source = tab.value === 'deletes' ? data.value : audit.value;
if (!source) return 1;
return Math.max(1, Math.ceil(source.total / source.limit));
});
const listSource = computed(() => (tab.value === 'deletes' ? data.value : audit.value));
function rowNo(index: number) {
return (page.value - 1) * pageSize.value + index + 1;
}
const purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
function apiError(e: unknown, fallback: string) {
@@ -43,20 +112,294 @@ function apiError(e: unknown, fallback: string) {
return fallback;
}
function entityLabel(entityType: string | null) {
if (!entityType) return 'مورد';
return ENTITY_LABELS[entityType] ?? entityType;
}
function looksLikeId(id: string | null | undefined) {
return !!id && /^c[a-z0-9]{20,}$/i.test(id);
}
const ENTITY_GET: Record<string, (id: string) => string> = {
people: (id) => `/people/${id}`,
companies: (id) => `/companies/${id}`,
leads: (id) => `/leads/${id}`,
deals: (id) => `/deals/${id}`,
activities: (id) => `/activities/${id}`,
notes: (id) => `/notes/${id}`,
tickets: (id) => `/tickets/${id}`,
quotes: (id) => `/quotes/${id}`,
invoices: (id) => `/invoices/${id}`,
products: (id) => `/products/${id}`,
'project-cards': (id) => `/project-cards/${id}`,
'lead-forms': (id) => `/lead-forms/${id}`,
routines: (id) => `/routines/${id}`,
};
const recordInfo = ref<Record<string, { title: string; snippet: string }>>({});
function entityKey(row: AuditLogDto) {
if (!row.entityType || !looksLikeId(row.entityId)) return '';
return `${row.entityType}:${row.entityId}`;
}
function pathTail(row: AuditLogDto) {
const parts = row.path.replace(/^\/api\/?/, '').split('/').filter(Boolean);
const second = parts[1] ?? '';
return looksLikeId(second) ? parts.slice(2).join('/') : parts.slice(1).join('/');
}
function pathParts(row: AuditLogDto) {
return row.path.replace(/^\/api\/?/, '').split('/').filter(Boolean);
}
function pathPhrase(row: AuditLogDto) {
const parts = pathParts(row);
const tail = pathTail(row);
const kind = entityLabel(row.entityType);
const nested = !looksLikeId(parts[1]);
if (nested && parts[0] === 'activities' && parts[1] === 'types') {
return parts[2] === 'reorder' ? 'ترتیب انواع فعالیت عوض شد' : 'نوع فعالیت ویرایش شد';
}
if (nested && parts[0] === 'activities' && parts[1] === 'stages') {
return parts[2] === 'reorder' ? 'ترتیب مراحل فعالیت عوض شد' : 'مرحله فعالیت ویرایش شد';
}
if (parts[0] === 'table-prefs') return 'چینش ستون‌های جدول عوض شد';
if (parts[0] === 'notifications') return 'اعلان‌ها خوانده شد';
if (parts[0] === 'integrations' && parts[1] === 'tahesab') return 'تنظیم اتصال ته‌حساب ویرایش شد';
if (parts[0] === 'integrations' && parts[1] === 'woocommerce') return 'تنظیم ووکامرس ویرایش شد';
if (parts[0] === 'integrations' && parts[1] === 'montify') return 'تنظیم مونتیفای ویرایش شد';
if (parts[0] === 'integrations' && parts[1] === 'voip') return 'تنظیم تلفن ویرایش شد';
if (tail === 'import' || tail.startsWith('import/')) {
return kind === 'مشتری' ? 'ورود مشتریان از فایل' : `ورود ${kind} از فایل`;
}
const phrases: Record<string, string> = {
sms: 'ارسال پیامک',
'birthday-sms': 'پیامک تبریک تولد',
complete: 'تکمیل شد',
stage: 'مرحله عوض شد',
move: 'در مسیر جابه‌جا شد',
notes: 'یادداشت ثبت شد',
call: 'تماس ثبت شد',
'follow-up': 'پیگیری ثبت شد',
convert: 'به مشتری تبدیل شد',
logo: 'تصویر عوض شد',
assign: 'واگذار شد',
'bulk-stage': 'مرحله چند مورد با هم عوض شد',
'bulk-delete': 'حذف گروهی',
'auto-assign': 'واگذاری خودکار',
archive: 'بایگانی شد',
};
if (phrases[tail]) return phrases[tail];
if (row.method === 'POST') return `${kind} ساخته شد`;
if (row.method === 'DELETE') return `${kind} حذف شد`;
return `مشخصات ${kind} ویرایش شد`;
}
function pickInfo(type: string, body: Record<string, unknown>) {
const title =
type === 'people'
? [body.firstName, body.lastName].filter((part) => typeof part === 'string' && part.trim()).join(' ')
: String(body.title || body.name || body.subject || body.number || '').trim();
const raw = String(body.description || body.body || '')
.split('\n')
.map((line) => line.trim())
.find(Boolean) ?? '';
const snippet = raw.replace(/\s+/g, ' ').slice(0, 180);
return {
title: title.slice(0, 120),
snippet: snippet && snippet !== title ? snippet : '',
};
}
async function hydrate(rows: AuditLogDto[]) {
const pending = new Map<string, { type: string; id: string }>();
for (const row of rows) {
const key = entityKey(row);
if (!key || recordInfo.value[key] || pending.has(key)) continue;
const type = row.entityType ?? '';
if (!ENTITY_GET[type] || !row.entityId) continue;
pending.set(key, { type, id: row.entityId });
}
await Promise.all(
[...pending.entries()].map(async ([key, job]) => {
try {
const { data: body } = await api.get<Record<string, unknown>>(ENTITY_GET[job.type](job.id));
recordInfo.value = { ...recordInfo.value, [key]: pickInfo(job.type, body ?? {}) };
} catch {
recordInfo.value = { ...recordInfo.value, [key]: { title: '', snippet: '' } };
}
}),
);
}
function rowHeading(row: AuditLogDto) {
if (row.label) return row.label;
const cached = recordInfo.value[entityKey(row)]?.title;
if (cached) return cached;
const phrase = pathPhrase(row);
if (!looksLikeId(row.entityId)) return phrase;
return entityLabel(row.entityType);
}
function rowDetail(row: AuditLogDto) {
if (row.detail) return row.detail;
const heading = rowHeading(row);
const phrase = pathPhrase(row);
const snippet = recordInfo.value[entityKey(row)]?.snippet ?? '';
if (snippet) return snippet;
if (phrase && phrase !== heading) return phrase;
return '';
}
function recordPath(row: AuditLogDto) {
const parts = pathParts(row);
const settings: Record<string, string> = {
tahesab: '/settings/tahesab',
woocommerce: '/settings/woocommerce',
montify: '/settings/montify',
voip: '/settings/voip',
};
if (parts[0] === 'integrations' && settings[parts[1] ?? '']) return settings[parts[1] ?? ''];
if (parts[0] === 'automations') return '/settings/automations';
if (parts[0] === 'message-automations') return '/settings/message-automations';
if (parts[0] === 'custom-fields') return '/settings/custom-fields';
if (parts[0] === 'faq') return '/settings/faq';
if (parts[0] === 'pipelines') return '/settings/pipelines';
if (parts[0] === 'users' || parts[0] === 'teams' || parts[0] === 'org-roles') return '/settings/team';
const entityType = row.entityType;
const entityId = looksLikeId(parts[1]) ? parts[1] : looksLikeId(row.entityId) ? row.entityId : null;
if (!entityId || !entityType) return null;
const routes: Record<string, string> = {
people: `/contacts/people/${entityId}`,
companies: `/contacts/companies/${entityId}`,
leads: `/leads/${entityId}`,
deals: `/deals/${entityId}`,
activities: `/activities/${entityId}`,
tickets: `/tickets/${entityId}`,
quotes: `/quotes/${entityId}`,
invoices: `/invoices/${entityId}`,
products: `/settings/products/${entityId}/edit`,
'project-cards': `/project-cards/${entityId}`,
routines: `/routines/manage/${entityId}/edit`,
'lead-forms': `/settings/lead-forms/${entityId}`,
};
return routes[entityType] ?? null;
}
function openRecord(row: AuditLogDto) {
const path = recordPath(row);
if (path) void router.push(path);
}
async function loadAudit(current: 'creates' | 'edits') {
const action = current === 'creates' ? 'ایجاد' : 'ویرایش';
const search = q.value.trim();
try {
const { data: res } = await api.get<AuditLogListResponse>('/audit-logs', {
params: {
q: search || undefined,
action,
actorUserId: actorId.value || undefined,
page: page.value,
limit: pageSize.value,
},
});
return res;
} catch (e) {
if (!axios.isAxiosError(e) || e.response?.status !== 400) throw e;
const { data: res } = await api.get<AuditLogListResponse>('/audit-logs', {
params: {
q: search || action,
actorUserId: actorId.value || undefined,
page: page.value,
limit: pageSize.value,
},
});
return { ...res, items: res.items.filter((item) => item.action === action) };
}
}
async function loadDeletes() {
const params = {
q: q.value.trim() || undefined,
deletedById: actorId.value || undefined,
page: page.value,
limit: pageSize.value,
};
try {
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', { params });
return res;
} catch (e) {
if (!axios.isAxiosError(e) || e.response?.status !== 400 || !params.deletedById) throw e;
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
params: { q: params.q, page: params.page, limit: params.limit },
});
const items = res.items.filter((item) => item.deletedById === params.deletedById);
return { ...res, items, total: items.length };
}
}
async function loadActors() {
try {
const { data: rows } = await api.get<Array<{ id: string; fullName: string }>>('/users');
actors.value = rows
.filter((row) => row.fullName)
.sort((a, b) => a.fullName.localeCompare(b.fullName, 'fa'));
} catch {
actors.value = [];
}
}
function onPage(next: number) {
page.value = next;
void load();
}
function onPageSize(size: number) {
pageSize.value = size;
if (page.value === 1) void load();
}
function selectTab(next: HistoryTab) {
if (next === tab.value) return;
q.value = '';
page.value = 1;
data.value = null;
audit.value = null;
error.value = '';
okMsg.value = '';
void router.replace({ query: next === 'deletes' ? {} : { tab: next } });
}
async function load(resetPage = false) {
if (resetPage) page.value = 1;
loading.value = true;
error.value = '';
const current = tab.value;
try {
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
params: { q: q.value.trim() || undefined, page: page.value, limit: 30 },
});
data.value = res;
retention.value = res.retention;
if (current === 'deletes') {
const res = await loadDeletes();
if (tab.value !== current) return;
audit.value = null;
data.value = res;
retention.value = res.retention;
} else {
const res = await loadAudit(current);
if (tab.value !== current) return;
data.value = null;
audit.value = res;
void hydrate(res.items);
}
} catch (e) {
error.value = apiError(e, 'بارگذاری لاگ حذف ناموفق بود.');
if (tab.value !== current) return;
error.value = apiError(
e,
current === 'deletes' ? 'بارگذاری لاگ حذف ناموفق بود.' : 'بارگذاری تاریخچه ناموفق بود.',
);
} finally {
loading.value = false;
if (tab.value === current) loading.value = false;
}
}
@@ -107,27 +450,40 @@ async function confirmPurge() {
}
}
function go(delta: number) {
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
watch(tab, () => {
void load();
}
}, { immediate: true });
onMounted(() => {
void load();
watch(actorId, () => {
void load(true);
});
void loadActors();
</script>
<template>
<section class="page">
<UiPageHeader
title="لاگ حذفیات"
lead="هر حذف اینجا می‌ماند. می‌توانید برگردانید یا برای همیشه پاک کنید."
title="تاریخچه کارها"
lead="هر ایجاد، ویرایش و حذف اینجا ثبت می‌شود."
/>
<nav class="tabs" aria-label="نوع تاریخچه">
<button
v-for="item in TABS"
:key="item.key"
type="button"
:class="{ on: tab === item.key }"
@click="selectTab(item.key)"
>
{{ item.label }}
</button>
</nav>
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
<p v-if="error" class="error">{{ error }}</p>
<UiCard>
<UiCard v-if="tab === 'deletes'">
<div class="retention">
<label for="deletion-retention">حذف خودکار از این فهرست</label>
<select id="deletion-retention" v-model="retention">
@@ -144,18 +500,31 @@ onMounted(() => {
<form class="search" @submit.prevent="load(true)">
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
<label class="actor">
<span>انجام‌دهنده</span>
<select v-model="actorId">
<option value="">همه</option>
<option v-for="user in actors" :key="user.id" :value="user.id">{{ user.fullName }}</option>
</select>
</label>
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
</form>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<UiEmptyState
v-else-if="!error && !data?.items.length"
v-else-if="tab === 'deletes' && !error && !data?.items.length"
title="حذفی ثبت نشده."
description="از این به بعد حذف‌ها با نام کاربر و زمان اینجا دیده می‌شوند."
/>
<UiEmptyState
v-else-if="tab !== 'deletes' && !error && !audit?.items.length"
:title="tab === 'creates' ? 'ایجاد جدیدی ثبت نشده.' : 'ویرایشی ثبت نشده.'"
description="با انجام کار در سامانه، نام کاربر و زمان اینجا دیده می‌شود. روی مورد بزنید تا خود پرونده باز شود."
/>
<div v-else-if="data?.items.length" class="list">
<article v-for="item in data.items" :key="item.id" class="row">
<div v-else-if="tab === 'deletes' && data?.items.length" class="list">
<article v-for="(item, index) in data.items" :key="item.id" class="row">
<span class="idx">{{ toFaDigits(rowNo(index)) }}</span>
<div class="info">
<strong>{{ item.label }}</strong>
<span>{{ item.modelLabel }}</span>
@@ -176,12 +545,42 @@ onMounted(() => {
</article>
</div>
<div v-if="data && data.total > data.limit" class="pager">
<UiButton size="sm" variant="ghost" :disabled="page <= 1" @click="go(-1)">قبلی</UiButton>
<span>{{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}</span>
<UiButton size="sm" variant="ghost" :disabled="page >= totalPages" @click="go(1)">بعدی</UiButton>
<div v-else-if="audit?.items.length" class="list">
<component
:is="recordPath(row) ? 'button' : 'div'"
v-for="(row, index) in audit.items"
:key="row.id"
class="row"
:class="{ link: !!recordPath(row) }"
:type="recordPath(row) ? 'button' : undefined"
@click="openRecord(row)"
>
<span class="idx">{{ toFaDigits(rowNo(index)) }}</span>
<div class="info">
<strong>{{ rowHeading(row) }}</strong>
<span v-if="rowDetail(row)" class="detail">{{ rowDetail(row) }}</span>
<span>
<template v-if="pathPhrase(row) !== rowHeading(row)">{{ pathPhrase(row) }} · </template>
{{ row.actor?.fullName || 'سیستم' }}
·
{{ formatJalaliDateTime(new Date(row.createdAt)) }}
</span>
</div>
<span v-if="recordPath(row)" class="open">مشاهده</span>
</component>
</div>
<UiPagination
v-if="listSource"
:page="page"
:total-pages="totalPages"
:total-items="listSource.total"
:page-size="pageSize"
:page-size-options="PAGE_SIZE_OPTIONS"
@update:page="onPage"
@update:page-size="onPageSize"
/>
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
<p v-if="purgeItem" class="confirm">
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
@@ -201,6 +600,32 @@ onMounted(() => {
min-width: 0;
}
.tabs {
display: flex;
width: 100%;
min-width: 0;
overflow-x: auto;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
flex: 1 1 0;
min-width: 0;
border: 0;
background: none;
padding: 0.55rem 0.2rem 0.7rem;
color: #64748b;
font: inherit;
font-weight: 700;
cursor: pointer;
box-shadow: inset 0 -2px 0 transparent;
}
.tabs button.on {
color: #0f766e;
box-shadow: inset 0 -2px 0 #14b8a6;
}
.retention {
display: flex;
flex-wrap: wrap;
@@ -213,7 +638,8 @@ onMounted(() => {
}
.retention select,
.search input {
.search input,
.search select {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
@@ -233,9 +659,27 @@ onMounted(() => {
.search {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.actor {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 700;
}
.idx {
flex: 0 0 auto;
min-width: 1.5rem;
color: #94a3b8;
font-size: 0.82rem;
font-weight: 700;
padding-top: 0.15rem;
}
.search input {
flex: 1 1 12rem;
}
@@ -247,8 +691,8 @@ onMounted(() => {
.row {
display: flex;
flex-wrap: wrap;
align-items: center;
flex-wrap: nowrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 0.8rem 0.9rem;
@@ -256,10 +700,31 @@ onMounted(() => {
border: 1px solid #e7eef2;
border-radius: 14px;
min-width: 0;
text-align: right;
}
.row.link {
width: 100%;
font: inherit;
cursor: pointer;
}
.row.link:hover {
border-color: #99f6e4;
}
.open {
flex: 0 0 auto;
margin-inline-start: auto;
align-self: center;
color: #0f766e;
font-size: 0.82rem;
font-weight: 700;
}
.info {
display: grid;
flex: 1 1 auto;
gap: 0.15rem;
min-width: 0;
}
@@ -276,10 +741,20 @@ onMounted(() => {
font-size: 0.82rem;
}
.info span.detail {
color: #334155;
white-space: normal;
overflow: visible;
text-overflow: unset;
}
.ops {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
gap: 0.4rem;
margin-inline-start: auto;
align-self: center;
}
.pager {
+1 -1
View File
@@ -136,7 +136,7 @@ onMounted(load);
:style="{ background: col.stage.color || 'var(--accent)' }"
/>
<h2>{{ col.stage.name }}</h2>
<em>{{ toFaDigits(col.leads.length) }}</em>
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
</header>
<div class="cards">