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
+78
View File
@@ -0,0 +1,78 @@
const FIELD_LABELS: Record<string, string> = {
title: 'عنوان',
name: 'نام',
firstName: 'نام',
lastName: 'نام خانوادگی',
fullName: 'نام',
description: 'توضیحات',
body: 'متن',
mobile: 'موبایل',
phone: 'تلفن',
email: 'ایمیل',
status: 'وضعیت',
stageId: 'مرحله',
priority: 'اولویت',
dueAt: 'موعد',
amount: 'مبلغ',
subject: 'موضوع',
type: 'نوع',
city: 'شهر',
address: 'آدرس',
notes: 'یادداشت',
contactName: 'نام مخاطب',
};
const SECRET = /password|token|secret|otp|pin/i;
function textOf(value: unknown) {
if (typeof value === 'string') return value.trim().replace(/\s+/g, ' ');
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
return '';
}
function labelOf(source: unknown) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
const body = source as Record<string, unknown>;
const person = [textOf(body.firstName), textOf(body.lastName)].filter(Boolean).join(' ');
return (
person ||
textOf(body.title) ||
textOf(body.name) ||
textOf(body.subject) ||
textOf(body.fullName)
).slice(0, 120);
}
function changesOf(source: unknown) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return '';
const bits: string[] = [];
for (const [key, value] of Object.entries(source as Record<string, unknown>)) {
if (SECRET.test(key)) continue;
const name = FIELD_LABELS[key];
if (!name) continue;
if (key.endsWith('Id')) {
bits.push(name);
} else {
const text = textOf(value).slice(0, 40);
if (!text) continue;
bits.push(`${name}: ${text}`);
}
if (bits.length >= 4) break;
}
return bits.join('، ');
}
export function auditStory(
method: string,
requestBody: unknown,
responseBody: unknown,
) {
const label = labelOf(responseBody) || labelOf(requestBody);
const detail = method === 'DELETE' ? '' : changesOf(requestBody);
const summary = [label, detail].filter(Boolean).join(' — ').slice(0, 500);
return {
label,
detail: detail.slice(0, 240),
summary,
};
}
@@ -22,6 +22,10 @@ class ListAuditQueryDto {
@IsString()
entityType?: string;
@IsOptional()
@IsString()
action?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -52,6 +56,7 @@ export class AuditLogsController {
q: query.q,
actorUserId: query.actorUserId,
entityType: query.entityType,
action: query.action,
page: query.page,
limit: query.limit,
});
@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import type { Request } from 'express';
import { Observable, tap } from 'rxjs';
import { auditStory } from './audit-detail';
import { AuditLogsService } from './audit-logs.service';
const SKIP_PREFIXES = [
@@ -73,7 +74,6 @@ export class AuditLogsInterceptor implements NestInterceptor {
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
const { entityType, entityId } = inferEntity(path);
const action = actionLabel(method, path);
const summary = `${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
const ip =
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
req.ip ||
@@ -81,7 +81,11 @@ export class AuditLogsInterceptor implements NestInterceptor {
return next.handle().pipe(
tap({
next: () => {
next: (response) => {
const story = auditStory(method, req.body, response);
const summary =
story.summary ||
`${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
void this.audit.write({
organizationId,
actorUserId,
@@ -91,6 +95,7 @@ export class AuditLogsInterceptor implements NestInterceptor {
entityType,
entityId,
summary,
meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null,
ip,
});
},
@@ -51,6 +51,7 @@ export class AuditLogsService {
q?: string;
actorUserId?: string;
entityType?: string;
action?: string;
page?: number;
limit?: number;
},
@@ -61,6 +62,7 @@ export class AuditLogsService {
organizationId,
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
...(opts.entityType ? { entityType: opts.entityType } : {}),
...(opts.action ? { action: opts.action } : {}),
};
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
{ summary: ci(t) },
@@ -97,10 +99,17 @@ export class AuditLogsService {
entityType: string | null;
entityId: string | null;
summary: string;
meta: Prisma.JsonValue | null;
ip: string | null;
createdAt: Date;
actor: { id: string; fullName: string } | null;
}): AuditLogDto {
const meta =
row.meta && typeof row.meta === 'object' && !Array.isArray(row.meta)
? (row.meta as { label?: unknown; detail?: unknown })
: null;
const label = typeof meta?.label === 'string' ? meta.label : null;
const detail = typeof meta?.detail === 'string' ? meta.detail : null;
return {
id: row.id,
action: row.action,
@@ -109,6 +118,8 @@ export class AuditLogsService {
entityType: row.entityType,
entityId: row.entityId,
summary: row.summary,
label,
detail,
actor: row.actor,
ip: row.ip,
createdAt: row.createdAt.toISOString(),
@@ -22,6 +22,10 @@ class ListDeletionsQueryDto {
@IsString()
q?: string;
@IsOptional()
@IsString()
deletedById?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -47,7 +51,7 @@ export class DeletionsController {
constructor(private readonly deletions: DeletionsService) {}
@Get()
@Roles('owner', 'admin')
@Roles('owner')
list(
@CurrentUser() user: { organizationId: string },
@Query() query: ListDeletionsQueryDto,
@@ -57,11 +61,12 @@ export class DeletionsController {
query.page,
query.limit,
query.q,
query.deletedById,
);
}
@Patch('settings')
@Roles('owner', 'admin')
@Roles('owner')
updateSettings(
@CurrentUser() user: { organizationId: string },
@Body() dto: RetentionDto,
@@ -70,7 +75,7 @@ export class DeletionsController {
}
@Post(':id/restore')
@Roles('owner', 'admin')
@Roles('owner')
restore(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@@ -79,7 +84,7 @@ export class DeletionsController {
}
@Delete(':id')
@Roles('owner', 'admin')
@Roles('owner')
purge(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
+9 -1
View File
@@ -30,12 +30,20 @@ const RETENTION_DAYS: Record<DeletionRetention, number | null> = {
export class DeletionsService {
constructor(private readonly prisma: PrismaService) {}
async list(organizationId: string, page = 1, limit = 30, q?: string) {
async list(
organizationId: string,
page = 1,
limit = 30,
q?: string,
deletedById?: string,
) {
const take = Math.min(100, Math.max(1, limit));
const skip = (Math.max(1, page) - 1) * take;
const query = q?.trim();
const actorId = deletedById?.trim();
const where = {
organizationId,
...(actorId ? { deletedById: actorId } : {}),
...(query
? {
OR: [
+25 -21
View File
@@ -182,27 +182,31 @@ export class LeadsService {
async board(actor: Actor): Promise<LeadBoardColumnDto[]> {
const stages = await this.stages.list(actor.organizationId);
const boardStages = stages.filter((s) => s.showOnBoard);
const leads = await this.prisma.lead.findMany({
where: {
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: null,
stageId: { in: boardStages.map((s) => s.id) },
},
include: leadInclude,
orderBy: [{ stageId: 'asc' }, { position: 'asc' }, { updatedAt: 'desc' }],
take: 500,
});
const byStage = new Map<string, LeadDto[]>();
for (const s of boardStages) byStage.set(s.id, []);
for (const row of leads) {
const list = byStage.get(row.stageId);
if (list) list.push(this.toDto(row));
}
return boardStages.map((stage) => ({
stage,
leads: byStage.get(stage.id) ?? [],
}));
const whereBase = {
organizationId: actor.organizationId,
...leadAccessWhere(actor),
archivedAt: null,
};
const perColumn = 120;
return Promise.all(
boardStages.map(async (stage) => {
const where = { ...whereBase, stageId: stage.id };
const [total, rows] = await Promise.all([
this.prisma.lead.count({ where }),
this.prisma.lead.findMany({
where,
include: leadInclude,
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
take: perColumn,
}),
]);
return {
stage,
total,
leads: rows.map((row) => this.toDto(row)),
};
}),
);
}
async get(actor: Actor, id: string): Promise<LeadDto & { events: LeadEventDto[] }> {
+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">
+6
View File
@@ -4001,6 +4001,10 @@ export interface AuditLogDto {
entityType: string | null;
entityId: string | null;
summary: string;
/** نام خوانای پرونده، مثل عنوان فعالیت یا نام مشتری */
label?: string | null;
/** چه چیزی ساخته یا عوض شده */
detail?: string | null;
actor: UserSummaryDto | null;
ip: string | null;
createdAt: string;
@@ -4964,6 +4968,8 @@ export interface LeadStatsDto {
export interface LeadBoardColumnDto {
stage: LeadStageDto;
/** تعداد واقعی لیدهای این مرحله، حتی اگر همهٔ کارت‌ها برنگردد */
total: number;
leads: LeadDto[];
}