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:
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user