feat: keep deletions restorable and confirm a pipeline's deals before it is removed
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "Organization" ADD COLUMN "deletionRetention" TEXT NOT NULL DEFAULT 'never';
|
||||
|
||||
CREATE TABLE "DeletionArchive" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"model" TEXT NOT NULL,
|
||||
"entityId" TEXT NOT NULL,
|
||||
"label" TEXT NOT NULL,
|
||||
"snapshot" JSONB NOT NULL,
|
||||
"deletedById" TEXT,
|
||||
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "DeletionArchive_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX "DeletionArchive_organizationId_deletedAt_idx" ON "DeletionArchive"("organizationId", "deletedAt");
|
||||
CREATE INDEX "DeletionArchive_organizationId_model_idx" ON "DeletionArchive"("organizationId", "model");
|
||||
|
||||
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "DeletionArchive" ADD CONSTRAINT "DeletionArchive_deletedById_fkey" FOREIGN KEY ("deletedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -235,6 +235,9 @@ model Organization {
|
||||
gamificationBadges GamificationBadge[]
|
||||
gamificationUserBadges GamificationUserBadge[]
|
||||
gamificationBonuses GamificationBonus[]
|
||||
/// month | two_months | three_months | six_months | year | never
|
||||
deletionRetention String @default("never")
|
||||
deletionArchives DeletionArchive[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -294,6 +297,7 @@ model User {
|
||||
createdRoutineTasks RoutineTask[] @relation("RoutineTaskCreator")
|
||||
routineTaskEvents RoutineTaskEvent[]
|
||||
routineSettings RoutineUserSettings[]
|
||||
deletionArchives DeletionArchive[] @relation("DeletionActor")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1859,6 +1863,24 @@ model AuditLog {
|
||||
@@index([organizationId, entityType, createdAt])
|
||||
}
|
||||
|
||||
/// نسخهٔ قابل بازگشتِ رکوردهای حذفشده
|
||||
model DeletionArchive {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
/// نام مدل پریزما
|
||||
model String
|
||||
entityId String
|
||||
label String
|
||||
snapshot Json
|
||||
deletedById String?
|
||||
deletedBy User? @relation("DeletionActor", fields: [deletedById], references: [id], onDelete: SetNull)
|
||||
deletedAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, deletedAt])
|
||||
@@index([organizationId, model])
|
||||
}
|
||||
|
||||
/// فیلترهای ذخیرهشده برای لیستها و مسیرهای فروش
|
||||
/// ستونهای مخفی هر جدول برای هر کاربر (بهازای هر سازمان)
|
||||
model UserTablePreference {
|
||||
|
||||
@@ -49,6 +49,7 @@ import { LeadsModule } from './leads/leads.module';
|
||||
import { ConversationsModule } from './conversations/conversations.module';
|
||||
import { RoutinesModule } from './routines/routines.module';
|
||||
import { GamificationModule } from './gamification/gamification.module';
|
||||
import { DeletionsModule } from './deletions/deletions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -105,6 +106,7 @@ import { GamificationModule } from './gamification/gamification.module';
|
||||
ConversationsModule,
|
||||
RoutinesModule,
|
||||
GamificationModule,
|
||||
DeletionsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { deletionActor, skipDeletionArchive } from './deletion-context';
|
||||
|
||||
const SKIP_MODELS = new Set([
|
||||
'DeletionArchive',
|
||||
'AuditLog',
|
||||
'Notification',
|
||||
'PortalOtp',
|
||||
'AutomationCursor',
|
||||
'MessageAutomationJob',
|
||||
'AccountingSyncJob',
|
||||
'LeadImportJob',
|
||||
'WebhookDelivery',
|
||||
]);
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
Person: 'مشتری',
|
||||
Company: 'شرکت',
|
||||
Lead: 'لید',
|
||||
Deal: 'معامله',
|
||||
Pipeline: 'مسیر فروش',
|
||||
PipelineStage: 'مرحله مسیر',
|
||||
Activity: 'فعالیت',
|
||||
Note: 'یادداشت',
|
||||
Quote: 'پیشفاکتور',
|
||||
Invoice: 'فاکتور',
|
||||
Ticket: 'تیکت',
|
||||
ProjectCard: 'کارت پروژه',
|
||||
ProjectBoard: 'تخته پروژه',
|
||||
Product: 'محصول',
|
||||
RoutineTask: 'کار روتین',
|
||||
Routine: 'روتین',
|
||||
LeadForm: 'فرم',
|
||||
User: 'کاربر',
|
||||
Team: 'تیم',
|
||||
};
|
||||
|
||||
type Relink = { model: string; field: string; ids: string[] };
|
||||
type Captured = {
|
||||
row: Record<string, unknown>;
|
||||
children: Array<{ model: string; item: Captured }>;
|
||||
relink: Relink[];
|
||||
};
|
||||
|
||||
type ModelField = {
|
||||
name: string;
|
||||
kind: string;
|
||||
type: string;
|
||||
isList: boolean;
|
||||
relationName?: string;
|
||||
relationFromFields?: string[];
|
||||
relationOnDelete?: string;
|
||||
};
|
||||
|
||||
type ModelMeta = { name: string; fields: ModelField[] };
|
||||
|
||||
function models(): ModelMeta[] {
|
||||
return Prisma.dmmf.datamodel.models as unknown as ModelMeta[];
|
||||
}
|
||||
|
||||
function delegateName(model: string) {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
function delegate(client: PrismaClient, model: string) {
|
||||
return (client as unknown as Record<string, {
|
||||
findMany: (args: unknown) => Promise<Array<Record<string, unknown>>>;
|
||||
findFirst: (args: unknown) => Promise<Record<string, unknown> | null>;
|
||||
create: (args: unknown) => Promise<unknown>;
|
||||
updateMany: (args: unknown) => Promise<unknown>;
|
||||
deleteMany: (args: unknown) => Promise<unknown>;
|
||||
}>)[delegateName(model)];
|
||||
}
|
||||
|
||||
function plain(value: unknown): Record<string, unknown> {
|
||||
return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function modelLabel(model: string) {
|
||||
return MODEL_LABELS[model] ?? model;
|
||||
}
|
||||
|
||||
export function rowLabel(row: Record<string, unknown>) {
|
||||
const person = [row.firstName, row.lastName].filter(Boolean).join(' ').trim();
|
||||
const text =
|
||||
row.title ||
|
||||
row.name ||
|
||||
row.fullName ||
|
||||
row.subject ||
|
||||
person ||
|
||||
row.number ||
|
||||
row.mobile ||
|
||||
row.email ||
|
||||
row.id;
|
||||
return String(text || 'بدون عنوان').slice(0, 180);
|
||||
}
|
||||
|
||||
function cascadeLinks(parentModel: string) {
|
||||
const parent = models().find((item) => item.name === parentModel);
|
||||
if (!parent) return [];
|
||||
const links: Array<{ model: string; fk: string }> = [];
|
||||
for (const field of parent.fields) {
|
||||
if (field.kind !== 'object' || !field.isList) continue;
|
||||
const child = models().find((item) => item.name === field.type);
|
||||
const back = child?.fields.find(
|
||||
(item) =>
|
||||
item.relationName === field.relationName &&
|
||||
(item.relationFromFields?.length ?? 0) > 0,
|
||||
);
|
||||
if (!back || back.relationOnDelete !== 'Cascade') continue;
|
||||
if (SKIP_MODELS.has(field.type)) continue;
|
||||
links.push({ model: field.type, fk: back.relationFromFields![0] });
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
async function setNullRefs(
|
||||
client: PrismaClient,
|
||||
parentModel: string,
|
||||
id: string,
|
||||
): Promise<Relink[]> {
|
||||
const refs: Relink[] = [];
|
||||
for (const model of models()) {
|
||||
if (SKIP_MODELS.has(model.name)) continue;
|
||||
for (const field of model.fields) {
|
||||
if (field.kind !== 'object' || field.type !== parentModel) continue;
|
||||
if (field.relationOnDelete !== 'SetNull') continue;
|
||||
const fk = field.relationFromFields?.[0];
|
||||
if (!fk) continue;
|
||||
const rows = await delegate(client, model.name).findMany({
|
||||
where: { [fk]: id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!rows.length) continue;
|
||||
refs.push({
|
||||
model: model.name,
|
||||
field: fk,
|
||||
ids: rows.map((row) => String(row.id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
}
|
||||
|
||||
async function captureRow(
|
||||
client: PrismaClient,
|
||||
model: string,
|
||||
row: Record<string, unknown>,
|
||||
depth: number,
|
||||
): Promise<Captured> {
|
||||
const id = String(row.id);
|
||||
const relink = depth > 3 ? [] : await setNullRefs(client, model, id);
|
||||
const children: Captured['children'] = [];
|
||||
if (depth < 4) {
|
||||
for (const link of cascadeLinks(model)) {
|
||||
const nested = await delegate(client, link.model).findMany({
|
||||
where: { [link.fk]: id },
|
||||
});
|
||||
for (const child of nested) {
|
||||
children.push({
|
||||
model: link.model,
|
||||
item: await captureRow(client, link.model, child, depth + 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { row: plain(row), children, relink };
|
||||
}
|
||||
|
||||
async function writeArchives(
|
||||
client: PrismaClient,
|
||||
model: string,
|
||||
captured: Captured[],
|
||||
) {
|
||||
const actor = deletionActor();
|
||||
for (const item of captured) {
|
||||
const organizationId =
|
||||
typeof item.row.organizationId === 'string'
|
||||
? item.row.organizationId
|
||||
: actor?.organizationId;
|
||||
if (!organizationId) continue;
|
||||
const deletedById =
|
||||
actor?.userId && actor.organizationId === organizationId ? actor.userId : null;
|
||||
await client.deletionArchive.create({
|
||||
data: {
|
||||
organizationId,
|
||||
model,
|
||||
entityId: String(item.row.id),
|
||||
label: rowLabel(item.row),
|
||||
snapshot: item as unknown as Prisma.InputJsonValue,
|
||||
deletedById,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotDelete(
|
||||
client: PrismaClient,
|
||||
model: string,
|
||||
action: 'delete' | 'deleteMany',
|
||||
where: Record<string, unknown> | undefined,
|
||||
) {
|
||||
if (!where || model === 'Organization' || SKIP_MODELS.has(model) || deletionActor()?.skip) {
|
||||
return [];
|
||||
}
|
||||
const rows =
|
||||
action === 'delete'
|
||||
? [await delegate(client, model).findFirst({ where })].filter(
|
||||
(row): row is Record<string, unknown> => !!row,
|
||||
)
|
||||
: await delegate(client, model).findMany({ where });
|
||||
if (!rows.length) return [];
|
||||
return Promise.all(rows.map((row) => captureRow(client, model, row, 0)));
|
||||
}
|
||||
|
||||
export function withDeletionArchive<T extends PrismaClient>(base: T) {
|
||||
return base.$extends({
|
||||
query: {
|
||||
$allModels: {
|
||||
async delete({ model, args, query }) {
|
||||
const captured = await snapshotDelete(
|
||||
base,
|
||||
model,
|
||||
'delete',
|
||||
args.where as Record<string, unknown> | undefined,
|
||||
).catch((error) => {
|
||||
console.error('deletion archive snapshot failed', error);
|
||||
return [] as Captured[];
|
||||
});
|
||||
const result = await query(args);
|
||||
if (captured.length) {
|
||||
await writeArchives(base, model, captured).catch((error) => {
|
||||
console.error('deletion archive write failed', error);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
async deleteMany({ model, args, query }) {
|
||||
const captured = await snapshotDelete(
|
||||
base,
|
||||
model,
|
||||
'deleteMany',
|
||||
args.where as Record<string, unknown> | undefined,
|
||||
).catch((error) => {
|
||||
console.error('deletion archive snapshot failed', error);
|
||||
return [] as Captured[];
|
||||
});
|
||||
const result = await query(args);
|
||||
if (captured.length) {
|
||||
await writeArchives(base, model, captured).catch((error) => {
|
||||
console.error('deletion archive write failed', error);
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function restoreArchive(
|
||||
client: PrismaClient,
|
||||
snapshot: Captured,
|
||||
model: string,
|
||||
) {
|
||||
await skipDeletionArchive(async () => {
|
||||
await restoreNode(client, model, snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreNode(client: PrismaClient, model: string, node: Captured) {
|
||||
const existing = await delegate(client, model).findFirst({
|
||||
where: { id: node.row.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new Error('این مورد هنوز در سامانه وجود دارد.');
|
||||
}
|
||||
try {
|
||||
await delegate(client, model).create({ data: node.row });
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'P2002') {
|
||||
throw new Error('این مورد یا یک مقدار یکتای آن از قبل وجود دارد.');
|
||||
}
|
||||
if (code === 'P2003') {
|
||||
throw new Error('وابستگی این مورد هنوز وجود ندارد. اول مورد اصلی را برگردانید.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
for (const child of node.children) {
|
||||
await restoreNode(client, child.model, child.item);
|
||||
}
|
||||
for (const link of node.relink) {
|
||||
if (!link.ids.length || SKIP_MODELS.has(link.model)) continue;
|
||||
await delegate(client, link.model).updateMany({
|
||||
where: { id: { in: link.ids } },
|
||||
data: { [link.field]: node.row.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AsyncLocalStorage } from 'async_hooks';
|
||||
|
||||
export type DeletionActor = {
|
||||
userId: string | null;
|
||||
organizationId: string | null;
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
export const deletionContext = new AsyncLocalStorage<DeletionActor>();
|
||||
|
||||
export function deletionActor(): DeletionActor | undefined {
|
||||
return deletionContext.getStore();
|
||||
}
|
||||
|
||||
export function skipDeletionArchive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const current = deletionContext.getStore();
|
||||
return deletionContext.run(
|
||||
{
|
||||
userId: current?.userId ?? null,
|
||||
organizationId: current?.organizationId ?? null,
|
||||
skip: true,
|
||||
},
|
||||
fn,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { DeletionsService } from './deletions.service';
|
||||
|
||||
class ListDeletionsQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class RetentionDto {
|
||||
@IsIn(['month', 'two_months', 'three_months', 'six_months', 'year', 'never'])
|
||||
retention!: string;
|
||||
}
|
||||
|
||||
@Controller('deletions')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
export class DeletionsController {
|
||||
constructor(private readonly deletions: DeletionsService) {}
|
||||
|
||||
@Get()
|
||||
@Roles('owner', 'admin')
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListDeletionsQueryDto,
|
||||
) {
|
||||
return this.deletions.list(
|
||||
user.organizationId,
|
||||
query.page,
|
||||
query.limit,
|
||||
query.q,
|
||||
);
|
||||
}
|
||||
|
||||
@Patch('settings')
|
||||
@Roles('owner', 'admin')
|
||||
updateSettings(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Body() dto: RetentionDto,
|
||||
) {
|
||||
return this.deletions.updateRetention(user.organizationId, dto.retention);
|
||||
}
|
||||
|
||||
@Post(':id/restore')
|
||||
@Roles('owner', 'admin')
|
||||
restore(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.deletions.restore(user.organizationId, id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@Roles('owner', 'admin')
|
||||
purge(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.deletions.purge(user.organizationId, id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { deletionContext } from './deletion-context';
|
||||
|
||||
type Authed = {
|
||||
user?: { userId?: string; organizationId?: string };
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DeletionsInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const req = context.switchToHttp().getRequest<Authed>();
|
||||
const user = req.user;
|
||||
return new Observable((subscriber) => {
|
||||
const subscription = deletionContext.run(
|
||||
{
|
||||
userId: user?.userId ?? null,
|
||||
organizationId: user?.organizationId ?? null,
|
||||
},
|
||||
() => next.handle().subscribe(subscriber),
|
||||
);
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { DeletionsController } from './deletions.controller';
|
||||
import { DeletionsInterceptor } from './deletions.interceptor';
|
||||
import { DeletionsService } from './deletions.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DeletionsController],
|
||||
providers: [
|
||||
DeletionsService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: DeletionsInterceptor,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class DeletionsModule {}
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import type { DeletionRetention } from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { modelLabel, restoreArchive } from './deletion-archive';
|
||||
|
||||
const RETENTIONS = new Set<DeletionRetention>([
|
||||
'month',
|
||||
'two_months',
|
||||
'three_months',
|
||||
'six_months',
|
||||
'year',
|
||||
'never',
|
||||
]);
|
||||
|
||||
const RETENTION_DAYS: Record<DeletionRetention, number | null> = {
|
||||
month: 30,
|
||||
two_months: 60,
|
||||
three_months: 90,
|
||||
six_months: 180,
|
||||
year: 365,
|
||||
never: null,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DeletionsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(organizationId: string, page = 1, limit = 30, q?: string) {
|
||||
const take = Math.min(100, Math.max(1, limit));
|
||||
const skip = (Math.max(1, page) - 1) * take;
|
||||
const query = q?.trim();
|
||||
const where = {
|
||||
organizationId,
|
||||
...(query
|
||||
? {
|
||||
OR: [
|
||||
{ label: { contains: query, mode: 'insensitive' as const } },
|
||||
{ model: { contains: query, mode: 'insensitive' as const } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [rows, total, org] = await Promise.all([
|
||||
this.prisma.deletionArchive.findMany({
|
||||
where,
|
||||
include: { deletedBy: { select: { id: true, fullName: true } } },
|
||||
orderBy: { deletedAt: 'desc' },
|
||||
skip,
|
||||
take,
|
||||
}),
|
||||
this.prisma.deletionArchive.count({ where }),
|
||||
this.prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { deletionRetention: true },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
items: rows.map((row) => ({
|
||||
id: row.id,
|
||||
model: row.model,
|
||||
modelLabel: modelLabel(row.model),
|
||||
entityId: row.entityId,
|
||||
label: row.label,
|
||||
deletedById: row.deletedById,
|
||||
deletedByName: row.deletedBy?.fullName ?? null,
|
||||
deletedAt: row.deletedAt.toISOString(),
|
||||
})),
|
||||
total,
|
||||
page: Math.max(1, page),
|
||||
limit: take,
|
||||
retention: this.retentionOf(org?.deletionRetention),
|
||||
};
|
||||
}
|
||||
|
||||
async updateRetention(organizationId: string, retention: string) {
|
||||
if (!RETENTIONS.has(retention as DeletionRetention)) {
|
||||
throw new BadRequestException('مدت نگهداری معتبر نیست.');
|
||||
}
|
||||
await this.prisma.organization.update({
|
||||
where: { id: organizationId },
|
||||
data: { deletionRetention: retention },
|
||||
});
|
||||
return { retention: retention as DeletionRetention };
|
||||
}
|
||||
|
||||
async restore(organizationId: string, id: string) {
|
||||
const row = await this.owned(organizationId, id);
|
||||
try {
|
||||
await restoreArchive(
|
||||
this.prisma,
|
||||
row.snapshot as never,
|
||||
row.model,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
if (message === 'این مورد هنوز در سامانه وجود دارد.') {
|
||||
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
|
||||
}
|
||||
throw new BadRequestException(message || 'بازگردانی ناموفق بود.');
|
||||
}
|
||||
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async purge(organizationId: string, id: string) {
|
||||
const row = await this.owned(organizationId, id);
|
||||
await this.prisma.deletionArchive.delete({ where: { id: row.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@Cron('15 3 * * *', { timeZone: 'Asia/Tehran' })
|
||||
async purgeExpired() {
|
||||
const orgs = await this.prisma.organization.findMany({
|
||||
select: { id: true, deletionRetention: true },
|
||||
});
|
||||
const now = Date.now();
|
||||
for (const org of orgs) {
|
||||
const days = RETENTION_DAYS[this.retentionOf(org.deletionRetention)];
|
||||
if (!days) continue;
|
||||
const cutoff = new Date(now - days * 24 * 60 * 60 * 1000);
|
||||
await this.prisma.deletionArchive.deleteMany({
|
||||
where: { organizationId: org.id, deletedAt: { lt: cutoff } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private retentionOf(value: string | null | undefined): DeletionRetention {
|
||||
if (value && RETENTIONS.has(value as DeletionRetention)) {
|
||||
return value as DeletionRetention;
|
||||
}
|
||||
return 'never';
|
||||
}
|
||||
|
||||
private async owned(organizationId: string, id: string) {
|
||||
const row = await this.prisma.deletionArchive.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!row) throw new NotFoundException('این حذف در فهرست پیدا نشد.');
|
||||
return row;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,15 @@ export class PipelinesController {
|
||||
return this.pipelinesService.merge(user.organizationId, pipelineId, dto);
|
||||
}
|
||||
|
||||
@Delete(':pipelineId')
|
||||
@Roles('owner', 'admin')
|
||||
remove(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Param('pipelineId') pipelineId: string,
|
||||
) {
|
||||
return this.pipelinesService.remove(user.organizationId, pipelineId);
|
||||
}
|
||||
|
||||
@Post(':pipelineId/stages')
|
||||
@Roles('owner', 'admin')
|
||||
createStage(
|
||||
|
||||
@@ -107,7 +107,18 @@ export class PipelinesService {
|
||||
include: { stages: { orderBy: { position: 'asc' } } },
|
||||
orderBy: [{ isDefault: 'desc' }, { name: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toPipelineDto(row));
|
||||
const counts = await this.prisma.deal.groupBy({
|
||||
by: ['pipelineId'],
|
||||
where: { organizationId },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const dealCountByPipeline = new Map(
|
||||
counts.map((row) => [row.pipelineId, row._count._all]),
|
||||
);
|
||||
return rows.map((row) => ({
|
||||
...this.toPipelineDto(row),
|
||||
dealCount: dealCountByPipeline.get(row.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -192,6 +203,29 @@ export class PipelinesService {
|
||||
return this.getOwnedPipelineDto(organizationId, pipeline.id);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, pipelineId: string): Promise<void> {
|
||||
const pipeline = await this.getOwnedPipeline(organizationId, pipelineId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.deal.deleteMany({
|
||||
where: { organizationId, pipelineId: pipeline.id },
|
||||
});
|
||||
if (pipeline.isDefault) {
|
||||
const next = await tx.pipeline.findFirst({
|
||||
where: { organizationId, id: { not: pipeline.id } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true },
|
||||
});
|
||||
if (next) {
|
||||
await tx.pipeline.update({
|
||||
where: { id: next.id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
await tx.pipeline.delete({ where: { id: pipeline.id } });
|
||||
});
|
||||
}
|
||||
|
||||
async deleteStage(
|
||||
organizationId: string,
|
||||
pipelineId: string,
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { withDeletionArchive } from '../deletions/deletion-archive';
|
||||
|
||||
const base = new PrismaClient();
|
||||
const client = withDeletionArchive(base);
|
||||
|
||||
export interface PrismaService extends PrismaClient {}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
export class PrismaService implements OnModuleInit, OnModuleDestroy {
|
||||
constructor() {
|
||||
return new Proxy(this, {
|
||||
get: (target, prop, receiver) => {
|
||||
if (Reflect.has(target, prop)) return Reflect.get(target, prop, receiver);
|
||||
const value = (client as unknown as Record<PropertyKey, unknown>)[prop];
|
||||
if (typeof value === 'function') {
|
||||
return (value as (...args: unknown[]) => unknown).bind(client);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
await client.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
await client.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
export type PrismaServiceClient = typeof client;
|
||||
|
||||
@@ -36,7 +36,8 @@ export type NavIconName =
|
||||
| 'plus'
|
||||
| 'user-plus'
|
||||
| 'mail'
|
||||
| 'phone';
|
||||
| 'phone'
|
||||
| 'trash';
|
||||
|
||||
export type NavHubTint = 'mint' | 'violet' | 'peach' | 'sky' | 'rose' | 'amber';
|
||||
|
||||
@@ -437,6 +438,14 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
module: 'audit_logs',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/deletions',
|
||||
title: 'لاگ حذفیات',
|
||||
desc: 'چه کسی چه چیزی را حذف کرده، بازگردانی یا حذف کامل',
|
||||
icon: 'trash',
|
||||
tint: 'rose',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/public-api',
|
||||
title: 'API و وبهوک',
|
||||
|
||||
@@ -361,6 +361,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/AuditLogsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/deletions',
|
||||
name: 'deletion-archives',
|
||||
component: () => import('@/views/DeletionArchivesView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/public-api',
|
||||
name: 'public-api',
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import type { 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';
|
||||
|
||||
const RETENTION_OPTIONS: Array<{ value: DeletionRetention; label: string }> = [
|
||||
{ value: 'month', label: 'یک ماه' },
|
||||
{ value: 'two_months', label: 'دو ماه' },
|
||||
{ value: 'three_months', label: 'سه ماه' },
|
||||
{ value: 'six_months', label: 'شش ماه' },
|
||||
{ value: 'year', label: 'یک سال' },
|
||||
{ value: 'never', label: 'هرگز' },
|
||||
];
|
||||
|
||||
const loading = ref(false);
|
||||
const savingRetention = ref(false);
|
||||
const busyId = ref('');
|
||||
const error = ref('');
|
||||
const okMsg = ref('');
|
||||
const q = ref('');
|
||||
const page = ref(1);
|
||||
const retention = ref<DeletionRetention>('never');
|
||||
const data = ref<DeletionArchiveListResponse | 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 purgeItem = computed(() => data.value?.items.find((item) => item.id === purgeId.value) ?? null);
|
||||
|
||||
function apiError(e: unknown, fallback: string) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
if (!e.response || e.response.status === 404) return fallback;
|
||||
if (Array.isArray(msg)) return msg.join('، ');
|
||||
if (msg) return msg;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1;
|
||||
loading.value = true;
|
||||
error.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;
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بارگذاری لاگ حذف ناموفق بود.');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRetention() {
|
||||
savingRetention.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.patch('/deletions/settings', { retention: retention.value });
|
||||
okMsg.value = 'مدت نگهداری ذخیره شد.';
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'ذخیره مدت نگهداری ناموفق بود.');
|
||||
} finally {
|
||||
savingRetention.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(id: string) {
|
||||
busyId.value = id;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.post(`/deletions/${id}/restore`);
|
||||
okMsg.value = 'مورد برگردانده شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'بازگردانی ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPurge() {
|
||||
const id = purgeId.value;
|
||||
if (!id) return;
|
||||
busyId.value = id;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.delete(`/deletions/${id}`);
|
||||
purgeId.value = null;
|
||||
okMsg.value = 'این مورد برای همیشه پاک شد.';
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف کامل ناموفق بود.');
|
||||
} finally {
|
||||
busyId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function go(delta: number) {
|
||||
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
||||
void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page">
|
||||
<UiPageHeader
|
||||
title="لاگ حذفیات"
|
||||
lead="هر حذف اینجا میماند. میتوانید برگردانید یا برای همیشه پاک کنید."
|
||||
/>
|
||||
|
||||
<p v-if="okMsg" class="ok">{{ okMsg }}</p>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<UiCard>
|
||||
<div class="retention">
|
||||
<label for="deletion-retention">حذف خودکار از این فهرست</label>
|
||||
<select id="deletion-retention" v-model="retention">
|
||||
<option v-for="option in RETENTION_OPTIONS" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<UiButton size="sm" :loading="savingRetention" @click="saveRetention">ذخیره</UiButton>
|
||||
</div>
|
||||
<p class="hint">
|
||||
تا پایان این مدت میتوان مورد را برگرداند. «هرگز» یعنی فقط با حذف کامل از بین میرود.
|
||||
</p>
|
||||
</UiCard>
|
||||
|
||||
<form class="search" @submit.prevent="load(true)">
|
||||
<input v-model="q" type="search" placeholder="جستجو در عنوان یا نوع" />
|
||||
<UiButton type="submit" variant="secondary" size="sm">جستجو</UiButton>
|
||||
</form>
|
||||
|
||||
<p v-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<UiEmptyState
|
||||
v-else-if="!error && !data?.items.length"
|
||||
title="حذفی ثبت نشده."
|
||||
description="از این به بعد حذفها با نام کاربر و زمان اینجا دیده میشوند."
|
||||
/>
|
||||
|
||||
<div v-else-if="data?.items.length" class="list">
|
||||
<article v-for="item in data.items" :key="item.id" class="row">
|
||||
<div class="info">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span>{{ item.modelLabel }}</span>
|
||||
<span>
|
||||
{{ item.deletedByName || 'سیستم' }}
|
||||
·
|
||||
{{ formatJalaliDateTime(new Date(item.deletedAt)) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ops">
|
||||
<UiButton size="sm" variant="secondary" :disabled="busyId === item.id" @click="restore(item.id)">
|
||||
بازگردانی
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" :disabled="busyId === item.id" @click="purgeId = item.id">
|
||||
حذف کامل
|
||||
</UiButton>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<UiModal :open="!!purgeItem" title="حذف کامل" @close="purgeId = null">
|
||||
<p v-if="purgeItem" class="confirm">
|
||||
«{{ purgeItem.label }}» برای همیشه پاک شود؟ بعد از این دیگر قابل بازگشت نیست.
|
||||
</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="!!busyId" @click="purgeId = null">انصراف</UiButton>
|
||||
<UiButton variant="danger" :loading="!!busyId" @click="confirmPurge">حذف کامل</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.retention {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.retention label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.retention select,
|
||||
.search input {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
padding: 0.45rem 0.7rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.muted {
|
||||
margin: 0.55rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search input {
|
||||
flex: 1 1 12rem;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
display: grid;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.info strong,
|
||||
.info span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.info span {
|
||||
color: #64748b;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ops {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.ok {
|
||||
margin: 0;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,9 @@ const pipelines = ref<PipelineListItem[]>([]);
|
||||
const name = ref('');
|
||||
const mergeOpen = ref(false);
|
||||
const mergeSourceId = ref<string | null>(null);
|
||||
const deleteTarget = ref<PipelineListItem | null>(null);
|
||||
const deleteCountReady = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
const canMerge = computed(() => pipelines.value.length >= 2);
|
||||
|
||||
@@ -67,6 +70,61 @@ async function createPipeline() {
|
||||
}
|
||||
}
|
||||
|
||||
function deletePrompt(pipeline: PipelineListItem) {
|
||||
if (!deleteCountReady.value) return 'در حال شمارش معاملات این مسیر…';
|
||||
const count = pipeline.dealCount ?? 0;
|
||||
const name = pipeline.name;
|
||||
if (count > 0) {
|
||||
return `مسیر «${name}» شامل ${toFaDigits(count)} معامله است. با حذف، این معاملات هم پاک میشوند. از حذف مطمئن هستید؟`;
|
||||
}
|
||||
return `مسیر «${name}» معاملهای ندارد. از حذف آن مطمئن هستید؟`;
|
||||
}
|
||||
|
||||
async function openDelete(pipeline: PipelineListItem) {
|
||||
deleteTarget.value = pipeline;
|
||||
deleteCountReady.value = pipeline.dealCount != null;
|
||||
if (deleteCountReady.value) return;
|
||||
try {
|
||||
const { data } = await api.get<{ dealCount: number }>(
|
||||
`/pipelines/${pipeline.id}/merge-preview`,
|
||||
);
|
||||
if (deleteTarget.value?.id !== pipeline.id) return;
|
||||
deleteTarget.value = { ...pipeline, dealCount: data.dealCount };
|
||||
} catch (e) {
|
||||
if (deleteTarget.value?.id === pipeline.id) {
|
||||
error.value = apiError(e, 'شمارش معاملات این مسیر ناموفق بود.');
|
||||
deleteTarget.value = null;
|
||||
}
|
||||
return;
|
||||
} finally {
|
||||
if (deleteTarget.value?.id === pipeline.id) deleteCountReady.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeDelete() {
|
||||
if (deleting.value) return;
|
||||
deleteTarget.value = null;
|
||||
deleteCountReady.value = false;
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const pipeline = deleteTarget.value;
|
||||
if (!pipeline || deleting.value) return;
|
||||
deleting.value = true;
|
||||
error.value = '';
|
||||
okMsg.value = '';
|
||||
try {
|
||||
await api.delete(`/pipelines/${pipeline.id}`);
|
||||
okMsg.value = 'کاریز حذف شد.';
|
||||
deleteTarget.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'حذف کاریز ناموفق بود.');
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openMerge(pipeline: PipelineListItem) {
|
||||
if (!canMerge.value) return;
|
||||
mergeSourceId.value = pipeline.id;
|
||||
@@ -142,6 +200,9 @@ onMounted(load);
|
||||
>
|
||||
ادغام
|
||||
</UiButton>
|
||||
<UiButton size="sm" variant="danger" @click="openDelete(pipeline)">
|
||||
حذف
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
@@ -153,6 +214,25 @@ onMounted(load);
|
||||
description="اولین مسیر فروش را اضافه کنید."
|
||||
/>
|
||||
|
||||
<UiModal
|
||||
:open="!!deleteTarget"
|
||||
title="حذف کاریز"
|
||||
@close="closeDelete"
|
||||
>
|
||||
<p v-if="deleteTarget" class="confirm">{{ deletePrompt(deleteTarget) }}</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="deleting" @click="closeDelete">انصراف</UiButton>
|
||||
<UiButton
|
||||
variant="danger"
|
||||
:loading="deleting"
|
||||
:disabled="!deleteCountReady"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
حذف کاریز
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
|
||||
<UiModal :open="mergeOpen" title="ادغام کاریز" @close="closeMerge">
|
||||
<PipelineMergeWizard
|
||||
:open="mergeOpen"
|
||||
@@ -242,6 +322,12 @@ onMounted(load);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
margin: 0;
|
||||
line-height: 1.8;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0f766e;
|
||||
margin: 0;
|
||||
|
||||
@@ -930,6 +930,8 @@ export interface PipelineDto {
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
stages: PipelineStageDto[];
|
||||
/** تعداد معاملات؛ در فهرست کاریزها پر میشود */
|
||||
dealCount?: number;
|
||||
}
|
||||
|
||||
export interface DealDto {
|
||||
@@ -4011,6 +4013,33 @@ export interface AuditLogListResponse {
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export type DeletionRetention =
|
||||
| 'month'
|
||||
| 'two_months'
|
||||
| 'three_months'
|
||||
| 'six_months'
|
||||
| 'year'
|
||||
| 'never';
|
||||
|
||||
export interface DeletionArchiveDto {
|
||||
id: string;
|
||||
model: string;
|
||||
modelLabel: string;
|
||||
entityId: string;
|
||||
label: string;
|
||||
deletedById: string | null;
|
||||
deletedByName: string | null;
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
export interface DeletionArchiveListResponse {
|
||||
items: DeletionArchiveDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
retention: DeletionRetention;
|
||||
}
|
||||
|
||||
/* ——— فیلترهای ذخیرهشده ——— */
|
||||
|
||||
export type SavedViewScope =
|
||||
|
||||
Reference in New Issue
Block a user