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()
|
@IsString()
|
||||||
entityType?: string;
|
entityType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
action?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -52,6 +56,7 @@ export class AuditLogsController {
|
|||||||
q: query.q,
|
q: query.q,
|
||||||
actorUserId: query.actorUserId,
|
actorUserId: query.actorUserId,
|
||||||
entityType: query.entityType,
|
entityType: query.entityType,
|
||||||
|
action: query.action,
|
||||||
page: query.page,
|
page: query.page,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { Observable, tap } from 'rxjs';
|
import { Observable, tap } from 'rxjs';
|
||||||
|
import { auditStory } from './audit-detail';
|
||||||
import { AuditLogsService } from './audit-logs.service';
|
import { AuditLogsService } from './audit-logs.service';
|
||||||
|
|
||||||
const SKIP_PREFIXES = [
|
const SKIP_PREFIXES = [
|
||||||
@@ -73,7 +74,6 @@ export class AuditLogsInterceptor implements NestInterceptor {
|
|||||||
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
|
const actorUserId = req.user?.userId ?? req.user?.id ?? null;
|
||||||
const { entityType, entityId } = inferEntity(path);
|
const { entityType, entityId } = inferEntity(path);
|
||||||
const action = actionLabel(method, path);
|
const action = actionLabel(method, path);
|
||||||
const summary = `${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
|
|
||||||
const ip =
|
const ip =
|
||||||
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
|
(req.headers['x-forwarded-for'] as string | undefined)?.split(',')[0]?.trim() ||
|
||||||
req.ip ||
|
req.ip ||
|
||||||
@@ -81,7 +81,11 @@ export class AuditLogsInterceptor implements NestInterceptor {
|
|||||||
|
|
||||||
return next.handle().pipe(
|
return next.handle().pipe(
|
||||||
tap({
|
tap({
|
||||||
next: () => {
|
next: (response) => {
|
||||||
|
const story = auditStory(method, req.body, response);
|
||||||
|
const summary =
|
||||||
|
story.summary ||
|
||||||
|
`${action} ${entityType ?? 'منبع'} از مسیر ${path.split('?')[0]}`;
|
||||||
void this.audit.write({
|
void this.audit.write({
|
||||||
organizationId,
|
organizationId,
|
||||||
actorUserId,
|
actorUserId,
|
||||||
@@ -91,6 +95,7 @@ export class AuditLogsInterceptor implements NestInterceptor {
|
|||||||
entityType,
|
entityType,
|
||||||
entityId,
|
entityId,
|
||||||
summary,
|
summary,
|
||||||
|
meta: story.label || story.detail ? { label: story.label, detail: story.detail } : null,
|
||||||
ip,
|
ip,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export class AuditLogsService {
|
|||||||
q?: string;
|
q?: string;
|
||||||
actorUserId?: string;
|
actorUserId?: string;
|
||||||
entityType?: string;
|
entityType?: string;
|
||||||
|
action?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
},
|
},
|
||||||
@@ -61,6 +62,7 @@ export class AuditLogsService {
|
|||||||
organizationId,
|
organizationId,
|
||||||
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
|
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
|
||||||
...(opts.entityType ? { entityType: opts.entityType } : {}),
|
...(opts.entityType ? { entityType: opts.entityType } : {}),
|
||||||
|
...(opts.action ? { action: opts.action } : {}),
|
||||||
};
|
};
|
||||||
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
|
const search = buildSearchWhere<Prisma.AuditLogWhereInput>(opts.q, (t) => [
|
||||||
{ summary: ci(t) },
|
{ summary: ci(t) },
|
||||||
@@ -97,10 +99,17 @@ export class AuditLogsService {
|
|||||||
entityType: string | null;
|
entityType: string | null;
|
||||||
entityId: string | null;
|
entityId: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
meta: Prisma.JsonValue | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
actor: { id: string; fullName: string } | null;
|
actor: { id: string; fullName: string } | null;
|
||||||
}): AuditLogDto {
|
}): 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 {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
action: row.action,
|
action: row.action,
|
||||||
@@ -109,6 +118,8 @@ export class AuditLogsService {
|
|||||||
entityType: row.entityType,
|
entityType: row.entityType,
|
||||||
entityId: row.entityId,
|
entityId: row.entityId,
|
||||||
summary: row.summary,
|
summary: row.summary,
|
||||||
|
label,
|
||||||
|
detail,
|
||||||
actor: row.actor,
|
actor: row.actor,
|
||||||
ip: row.ip,
|
ip: row.ip,
|
||||||
createdAt: row.createdAt.toISOString(),
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ class ListDeletionsQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
q?: string;
|
q?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
deletedById?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -47,7 +51,7 @@ export class DeletionsController {
|
|||||||
constructor(private readonly deletions: DeletionsService) {}
|
constructor(private readonly deletions: DeletionsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner')
|
||||||
list(
|
list(
|
||||||
@CurrentUser() user: { organizationId: string },
|
@CurrentUser() user: { organizationId: string },
|
||||||
@Query() query: ListDeletionsQueryDto,
|
@Query() query: ListDeletionsQueryDto,
|
||||||
@@ -57,11 +61,12 @@ export class DeletionsController {
|
|||||||
query.page,
|
query.page,
|
||||||
query.limit,
|
query.limit,
|
||||||
query.q,
|
query.q,
|
||||||
|
query.deletedById,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('settings')
|
@Patch('settings')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner')
|
||||||
updateSettings(
|
updateSettings(
|
||||||
@CurrentUser() user: { organizationId: string },
|
@CurrentUser() user: { organizationId: string },
|
||||||
@Body() dto: RetentionDto,
|
@Body() dto: RetentionDto,
|
||||||
@@ -70,7 +75,7 @@ export class DeletionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/restore')
|
@Post(':id/restore')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner')
|
||||||
restore(
|
restore(
|
||||||
@CurrentUser() user: { organizationId: string },
|
@CurrentUser() user: { organizationId: string },
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -79,7 +84,7 @@ export class DeletionsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles('owner', 'admin')
|
@Roles('owner')
|
||||||
purge(
|
purge(
|
||||||
@CurrentUser() user: { organizationId: string },
|
@CurrentUser() user: { organizationId: string },
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -30,12 +30,20 @@ const RETENTION_DAYS: Record<DeletionRetention, number | null> = {
|
|||||||
export class DeletionsService {
|
export class DeletionsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
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 take = Math.min(100, Math.max(1, limit));
|
||||||
const skip = (Math.max(1, page) - 1) * take;
|
const skip = (Math.max(1, page) - 1) * take;
|
||||||
const query = q?.trim();
|
const query = q?.trim();
|
||||||
|
const actorId = deletedById?.trim();
|
||||||
const where = {
|
const where = {
|
||||||
organizationId,
|
organizationId,
|
||||||
|
...(actorId ? { deletedById: actorId } : {}),
|
||||||
...(query
|
...(query
|
||||||
? {
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
|
|||||||
@@ -182,27 +182,31 @@ export class LeadsService {
|
|||||||
async board(actor: Actor): Promise<LeadBoardColumnDto[]> {
|
async board(actor: Actor): Promise<LeadBoardColumnDto[]> {
|
||||||
const stages = await this.stages.list(actor.organizationId);
|
const stages = await this.stages.list(actor.organizationId);
|
||||||
const boardStages = stages.filter((s) => s.showOnBoard);
|
const boardStages = stages.filter((s) => s.showOnBoard);
|
||||||
const leads = await this.prisma.lead.findMany({
|
const whereBase = {
|
||||||
where: {
|
organizationId: actor.organizationId,
|
||||||
organizationId: actor.organizationId,
|
...leadAccessWhere(actor),
|
||||||
...leadAccessWhere(actor),
|
archivedAt: null,
|
||||||
archivedAt: null,
|
};
|
||||||
stageId: { in: boardStages.map((s) => s.id) },
|
const perColumn = 120;
|
||||||
},
|
return Promise.all(
|
||||||
include: leadInclude,
|
boardStages.map(async (stage) => {
|
||||||
orderBy: [{ stageId: 'asc' }, { position: 'asc' }, { updatedAt: 'desc' }],
|
const where = { ...whereBase, stageId: stage.id };
|
||||||
take: 500,
|
const [total, rows] = await Promise.all([
|
||||||
});
|
this.prisma.lead.count({ where }),
|
||||||
const byStage = new Map<string, LeadDto[]>();
|
this.prisma.lead.findMany({
|
||||||
for (const s of boardStages) byStage.set(s.id, []);
|
where,
|
||||||
for (const row of leads) {
|
include: leadInclude,
|
||||||
const list = byStage.get(row.stageId);
|
orderBy: [{ position: 'asc' }, { updatedAt: 'desc' }],
|
||||||
if (list) list.push(this.toDto(row));
|
take: perColumn,
|
||||||
}
|
}),
|
||||||
return boardStages.map((stage) => ({
|
]);
|
||||||
stage,
|
return {
|
||||||
leads: byStage.get(stage.id) ?? [],
|
stage,
|
||||||
}));
|
total,
|
||||||
|
leads: rows.map((row) => this.toDto(row)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(actor: Actor, id: string): Promise<LeadDto & { events: LeadEventDto[] }> {
|
async get(actor: Actor, id: string): Promise<LeadDto & { events: LeadEventDto[] }> {
|
||||||
|
|||||||
@@ -132,7 +132,9 @@ const hubVisible = computed(() => {
|
|||||||
for (const hub of NAV_HUBS) {
|
for (const hub of NAV_HUBS) {
|
||||||
map[hub.key] = hub.cards.some(
|
map[hub.key] = hub.cards.some(
|
||||||
(card) =>
|
(card) =>
|
||||||
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
(!card.ownerOnly || auth.user?.role === 'owner') &&
|
||||||
|
(!card.admin || canSettings.value) &&
|
||||||
|
moduleAllowed(card.module),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -144,7 +146,9 @@ const openHub = computed<NavHubDef | null>(() => {
|
|||||||
if (!hub) return null;
|
if (!hub) return null;
|
||||||
const cards = hub.cards.filter(
|
const cards = hub.cards.filter(
|
||||||
(card) =>
|
(card) =>
|
||||||
(!card.admin || canSettings.value) && moduleAllowed(card.module),
|
(!card.ownerOnly || auth.user?.role === 'owner') &&
|
||||||
|
(!card.admin || canSettings.value) &&
|
||||||
|
moduleAllowed(card.module),
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
hub.key === 'customers' &&
|
hub.key === 'customers' &&
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ export type NavHubCard = {
|
|||||||
icon: NavIconName;
|
icon: NavIconName;
|
||||||
tint: NavHubTint;
|
tint: NavHubTint;
|
||||||
admin?: boolean;
|
admin?: boolean;
|
||||||
|
/** فقط نقش مالک؛ مدیر سازمان این کارت را نمیبیند */
|
||||||
|
ownerOnly?: boolean;
|
||||||
module?: OrgModuleKey;
|
module?: OrgModuleKey;
|
||||||
/** اگر ست باشد، بهجای مسیر، هاب تو در تو باز میشود (موبایل: مشتریان → لیدها) */
|
/** اگر ست باشد، بهجای مسیر، هاب تو در تو باز میشود (موبایل: مشتریان → لیدها) */
|
||||||
hub?: NavHubKey;
|
hub?: NavHubKey;
|
||||||
@@ -440,11 +442,11 @@ export const NAV_HUBS: NavHubDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/deletions',
|
to: '/settings/deletions',
|
||||||
title: 'لاگ حذفیات',
|
title: 'تاریخچه کارها',
|
||||||
desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل',
|
desc: 'ایجاد، ویرایش و حذف؛ بازگردانی فقط برای حذفها',
|
||||||
icon: 'trash',
|
icon: 'trash',
|
||||||
tint: 'rose',
|
tint: 'rose',
|
||||||
admin: true,
|
ownerOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/settings/public-api',
|
to: '/settings/public-api',
|
||||||
|
|||||||
@@ -365,7 +365,7 @@ export const router = createRouter({
|
|||||||
path: 'settings/deletions',
|
path: 'settings/deletions',
|
||||||
name: 'deletion-archives',
|
name: 'deletion-archives',
|
||||||
component: () => import('@/views/DeletionArchivesView.vue'),
|
component: () => import('@/views/DeletionArchivesView.vue'),
|
||||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
meta: { roles: ['owner'] as UserRole[] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'settings/public-api',
|
path: 'settings/public-api',
|
||||||
|
|||||||
@@ -1,10 +1,59 @@
|
|||||||
<script setup lang="ts">
|
<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 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 { api } from '@/lib/api';
|
||||||
import { formatJalaliDateTime, toFaDigits } from '@/lib/jalali';
|
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 }> = [
|
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||||
{ value: 'month', label: 'یک ماه' },
|
{ value: 'month', label: 'یک ماه' },
|
||||||
@@ -15,22 +64,42 @@ const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
|||||||
{ value: 'never', label: 'هرگز' },
|
{ value: 'never', label: 'هرگز' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const savingRetention = ref(false);
|
const savingRetention = ref(false);
|
||||||
const busyId = ref('');
|
const busyId = ref('');
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const okMsg = ref('');
|
const okMsg = ref('');
|
||||||
const q = ref('');
|
const q = ref('');
|
||||||
|
const actorId = ref('');
|
||||||
|
const actors = ref<Array<{ id: string; fullName: string }>>([]);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
|
const pageSize = usePageSize('history-log');
|
||||||
const retention = ref<DeletionRetention>('never');
|
const retention = ref<DeletionRetention>('never');
|
||||||
const data = ref<DeletionArchiveListResponse | null>(null);
|
const data = ref<DeletionArchiveListResponse | null>(null);
|
||||||
|
const audit = ref<AuditLogListResponse | null>(null);
|
||||||
const purgeId = ref<string | null>(null);
|
const purgeId = ref<string | null>(null);
|
||||||
|
|
||||||
const totalPages = computed(() => {
|
const tab = computed<HistoryTab>(() => {
|
||||||
if (!data.value) return 1;
|
const value = route.query.tab;
|
||||||
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
|
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);
|
const purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
|
||||||
|
|
||||||
function apiError(e: unknown, fallback: string) {
|
function apiError(e: unknown, fallback: string) {
|
||||||
@@ -43,20 +112,294 @@ function apiError(e: unknown, fallback: string) {
|
|||||||
return fallback;
|
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) {
|
async function load(resetPage = false) {
|
||||||
if (resetPage) page.value = 1;
|
if (resetPage) page.value = 1;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = '';
|
error.value = '';
|
||||||
|
const current = tab.value;
|
||||||
try {
|
try {
|
||||||
const { data: res } = await api.get<DeletionArchiveListResponse>('/deletions', {
|
if (current === 'deletes') {
|
||||||
params: { q: q.value.trim() || undefined, page: page.value, limit: 30 },
|
const res = await loadDeletes();
|
||||||
});
|
if (tab.value !== current) return;
|
||||||
data.value = res;
|
audit.value = null;
|
||||||
retention.value = res.retention;
|
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) {
|
} catch (e) {
|
||||||
error.value = apiError(e, 'بارگذاری لاگ حذف ناموفق بود.');
|
if (tab.value !== current) return;
|
||||||
|
error.value = apiError(
|
||||||
|
e,
|
||||||
|
current === 'deletes' ? 'بارگذاری لاگ حذف ناموفق بود.' : 'بارگذاری تاریخچه ناموفق بود.',
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
if (tab.value === current) loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,27 +450,40 @@ async function confirmPurge() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(delta: number) {
|
watch(tab, () => {
|
||||||
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
|
||||||
void load();
|
void load();
|
||||||
}
|
}, { immediate: true });
|
||||||
|
|
||||||
onMounted(() => {
|
watch(actorId, () => {
|
||||||
void load();
|
void load(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void loadActors();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="page">
|
<section class="page">
|
||||||
<UiPageHeader
|
<UiPageHeader
|
||||||
title="لاگ حذفیات"
|
title="تاریخچه کارها"
|
||||||
lead="هر حذف اینجا میماند. میتوانید برگردانید یا برای همیشه پاک کنید."
|
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="okMsg" class="ok">{{ okMsg }}</p>
|
||||||
<p v-if="error" class="error">{{ error }}</p>
|
<p v-if="error" class="error">{{ error }}</p>
|
||||||
|
|
||||||
<UiCard>
|
<UiCard v-if="tab === 'deletes'">
|
||||||
<div class="retention">
|
<div class="retention">
|
||||||
<label for="deletion-retention">حذف خودکار از این فهرست</label>
|
<label for="deletion-retention">حذف خودکار از این فهرست</label>
|
||||||
<select id="deletion-retention" v-model="retention">
|
<select id="deletion-retention" v-model="retention">
|
||||||
@@ -144,18 +500,31 @@ onMounted(() => {
|
|||||||
|
|
||||||
<form class="search" @submit.prevent="load(true)">
|
<form class="search" @submit.prevent="load(true)">
|
||||||
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
|
<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>
|
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||||
<UiEmptyState
|
<UiEmptyState
|
||||||
v-else-if="!error && !data?.items.length"
|
v-else-if="tab === 'deletes' && !error && !data?.items.length"
|
||||||
title="حذفی ثبت نشده."
|
title="حذفی ثبت نشده."
|
||||||
description="از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
|
description="از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
|
||||||
/>
|
/>
|
||||||
|
<UiEmptyState
|
||||||
|
v-else-if="tab !== 'deletes' && !error && !audit?.items.length"
|
||||||
|
:title="tab === 'creates' ? 'ایجاد جدیدی ثبت نشده.' : 'ویرایشی ثبت نشده.'"
|
||||||
|
description="با انجام کار در سامانه، نام کاربر و زمان اینجا دیده میشود. روی مورد بزنید تا خود پرونده باز شود."
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-else-if="data?.items.length" class="list">
|
<div v-else-if="tab === 'deletes' && data?.items.length" class="list">
|
||||||
<article v-for="item in data.items" :key="item.id" class="row">
|
<article v-for="(item, index) in data.items" :key="item.id" class="row">
|
||||||
|
<span class="idx">{{ toFaDigits(rowNo(index)) }}</span>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<strong>{{ item.label }}</strong>
|
<strong>{{ item.label }}</strong>
|
||||||
<span>{{ item.modelLabel }}</span>
|
<span>{{ item.modelLabel }}</span>
|
||||||
@@ -176,12 +545,42 @@ onMounted(() => {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="data && data.total > data.limit" class="pager">
|
<div v-else-if="audit?.items.length" class="list">
|
||||||
<UiButton size="sm" variant="ghost" :disabled="page <= 1" @click="go(-1)">قبلی</UiButton>
|
<component
|
||||||
<span>{{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}</span>
|
:is="recordPath(row) ? 'button' : 'div'"
|
||||||
<UiButton size="sm" variant="ghost" :disabled="page >= totalPages" @click="go(1)">بعدی</UiButton>
|
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>
|
</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">
|
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||||
<p v-if="purgeItem" class="confirm">
|
<p v-if="purgeItem" class="confirm">
|
||||||
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||||
@@ -201,6 +600,32 @@ onMounted(() => {
|
|||||||
min-width: 0;
|
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 {
|
.retention {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -213,7 +638,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.retention select,
|
.retention select,
|
||||||
.search input {
|
.search input,
|
||||||
|
.search select {
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
@@ -233,9 +659,27 @@ onMounted(() => {
|
|||||||
.search {
|
.search {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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 {
|
.search input {
|
||||||
flex: 1 1 12rem;
|
flex: 1 1 12rem;
|
||||||
}
|
}
|
||||||
@@ -247,8 +691,8 @@ onMounted(() => {
|
|||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.8rem 0.9rem;
|
padding: 0.8rem 0.9rem;
|
||||||
@@ -256,10 +700,31 @@ onMounted(() => {
|
|||||||
border: 1px solid #e7eef2;
|
border: 1px solid #e7eef2;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
min-width: 0;
|
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 {
|
.info {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
flex: 1 1 auto;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -276,10 +741,20 @@ onMounted(() => {
|
|||||||
font-size: 0.82rem;
|
font-size: 0.82rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.info span.detail {
|
||||||
|
color: #334155;
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: unset;
|
||||||
|
}
|
||||||
|
|
||||||
.ops {
|
.ops {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
|
margin-inline-start: auto;
|
||||||
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pager {
|
.pager {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ onMounted(load);
|
|||||||
:style="{ background: col.stage.color || 'var(--accent)' }"
|
:style="{ background: col.stage.color || 'var(--accent)' }"
|
||||||
/>
|
/>
|
||||||
<h2>{{ col.stage.name }}</h2>
|
<h2>{{ col.stage.name }}</h2>
|
||||||
<em>{{ toFaDigits(col.leads.length) }}</em>
|
<em>{{ toFaDigits(col.total ?? col.leads.length) }}</em>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="cards">
|
<div class="cards">
|
||||||
|
|||||||
@@ -4001,6 +4001,10 @@ export interface AuditLogDto {
|
|||||||
entityType: string | null;
|
entityType: string | null;
|
||||||
entityId: string | null;
|
entityId: string | null;
|
||||||
summary: string;
|
summary: string;
|
||||||
|
/** نام خوانای پرونده، مثل عنوان فعالیت یا نام مشتری */
|
||||||
|
label?: string | null;
|
||||||
|
/** چه چیزی ساخته یا عوض شده */
|
||||||
|
detail?: string | null;
|
||||||
actor: UserSummaryDto | null;
|
actor: UserSummaryDto | null;
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -4964,6 +4968,8 @@ export interface LeadStatsDto {
|
|||||||
|
|
||||||
export interface LeadBoardColumnDto {
|
export interface LeadBoardColumnDto {
|
||||||
stage: LeadStageDto;
|
stage: LeadStageDto;
|
||||||
|
/** تعداد واقعی لیدهای این مرحله، حتی اگر همهٔ کارتها برنگردد */
|
||||||
|
total: number;
|
||||||
leads: LeadDto[];
|
leads: LeadDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user