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:
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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[] }> {
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user