Add Didar gap features: lost-reason reports, audit logs, tags, card assignees, and saved-views API.
Ignore Graphify local artifacts and expose new modules in superadmin.
This commit is contained in:
@@ -21,3 +21,8 @@ docs/~$*
|
||||
*.py[cod]
|
||||
apps/stt/.venv/
|
||||
crm-node_modules.tgz
|
||||
|
||||
# Graphify (local architecture graph — do not commit)
|
||||
graphify-out/
|
||||
**/graphify-out/
|
||||
.cursor/rules/graphify.mdc
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Company" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Person" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Activity" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "ProjectCard" ADD COLUMN IF NOT EXISTS "tags" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "AuditLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"actorUserId" TEXT,
|
||||
"action" TEXT NOT NULL,
|
||||
"method" TEXT NOT NULL,
|
||||
"path" TEXT NOT NULL,
|
||||
"entityType" TEXT,
|
||||
"entityId" TEXT,
|
||||
"summary" TEXT NOT NULL,
|
||||
"meta" JSONB,
|
||||
"ip" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE IF NOT EXISTS "SavedView" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"ownerUserId" TEXT,
|
||||
"name" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"filters" JSONB NOT NULL DEFAULT '{}',
|
||||
"isShared" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable (implicit many-to-many ProjectCard <-> User assignees)
|
||||
CREATE TABLE IF NOT EXISTS "_ProjectCardAssignees" (
|
||||
"A" TEXT NOT NULL,
|
||||
"B" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_createdAt_idx" ON "AuditLog"("organizationId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_actorUserId_createdAt_idx" ON "AuditLog"("organizationId", "actorUserId", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "AuditLog_organizationId_entityType_createdAt_idx" ON "AuditLog"("organizationId", "entityType", "createdAt");
|
||||
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_scope_idx" ON "SavedView"("organizationId", "scope");
|
||||
CREATE INDEX IF NOT EXISTS "SavedView_organizationId_ownerUserId_idx" ON "SavedView"("organizationId", "ownerUserId");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "_ProjectCardAssignees_AB_unique" ON "_ProjectCardAssignees"("A", "B");
|
||||
CREATE INDEX IF NOT EXISTS "_ProjectCardAssignees_B_index" ON "_ProjectCardAssignees"("B");
|
||||
|
||||
-- AddForeignKey
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_actorUserId_fkey" FOREIGN KEY ("actorUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_ownerUserId_fkey" FOREIGN KEY ("ownerUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_A_fkey" FOREIGN KEY ("A") REFERENCES "ProjectCard"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "_ProjectCardAssignees" ADD CONSTRAINT "_ProjectCardAssignees_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
@@ -192,6 +192,8 @@ model Organization {
|
||||
partyOpenings PartyOpeningBalance[]
|
||||
commissionEntries CommissionEntry[]
|
||||
cheques Cheque[]
|
||||
auditLogs AuditLog[]
|
||||
savedViews SavedView[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -233,6 +235,9 @@ model User {
|
||||
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
||||
memberships Membership[]
|
||||
billingAccount BillingAccount?
|
||||
auditLogs AuditLog[] @relation("AuditLogActor")
|
||||
savedViews SavedView[] @relation("SavedViewOwner")
|
||||
assignedProjectCards ProjectCard[] @relation("ProjectCardAssignees")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -329,6 +334,7 @@ model Company {
|
||||
website String?
|
||||
address String?
|
||||
notes String?
|
||||
tags String[] @default([])
|
||||
customFields Json @default("{}")
|
||||
people Person[]
|
||||
deals Deal[]
|
||||
@@ -368,6 +374,7 @@ model Person {
|
||||
referrer String?
|
||||
notes String?
|
||||
mood PersonMood @default(newcomer)
|
||||
tags String[] @default([])
|
||||
customFields Json @default("{}")
|
||||
loyaltyPoints Int @default(0)
|
||||
loyaltyTierId String?
|
||||
@@ -493,6 +500,7 @@ model Activity {
|
||||
/// پیامک به موبایل مسئول هنگام رسیدن یادآور
|
||||
reminderNotifySms Boolean @default(false)
|
||||
completedAt DateTime?
|
||||
tags String[] @default([])
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
companyId String?
|
||||
@@ -1067,6 +1075,7 @@ model ProjectCard {
|
||||
stage ProjectBoardStage @relation(fields: [stageId], references: [id], onDelete: Restrict)
|
||||
title String
|
||||
notes String?
|
||||
tags String[] @default([])
|
||||
position Int @default(0)
|
||||
personId String?
|
||||
person Person? @relation(fields: [personId], references: [id], onDelete: SetNull)
|
||||
@@ -1076,6 +1085,7 @@ model ProjectCard {
|
||||
deal Deal? @relation(fields: [dealId], references: [id], onDelete: SetNull)
|
||||
ownerId String?
|
||||
owner User? @relation("ProjectCardOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
assignees User[] @relation("ProjectCardAssignees")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -1546,3 +1556,44 @@ model PartyOpeningBalance {
|
||||
@@unique([organizationId, personId])
|
||||
@@index([personId])
|
||||
}
|
||||
|
||||
/// لاگ فعالیت کاربران روی منابع سازمان
|
||||
model AuditLog {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
actorUserId String?
|
||||
actor User? @relation("AuditLogActor", fields: [actorUserId], references: [id], onDelete: SetNull)
|
||||
action String
|
||||
method String
|
||||
path String
|
||||
entityType String?
|
||||
entityId String?
|
||||
summary String
|
||||
meta Json?
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([organizationId, createdAt])
|
||||
@@index([organizationId, actorUserId, createdAt])
|
||||
@@index([organizationId, entityType, createdAt])
|
||||
}
|
||||
|
||||
/// فیلترهای ذخیرهشده برای لیستها و کاریزها
|
||||
model SavedView {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
ownerUserId String?
|
||||
owner User? @relation("SavedViewOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
/// deals | people | companies | activities | project_cards
|
||||
scope String
|
||||
filters Json @default("{}")
|
||||
isShared Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, scope])
|
||||
@@index([organizationId, ownerUserId])
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { type Actor, assigneeWhere, ownerWhere } from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
type ActivityActor = Pick<Actor, 'userId' | 'role'>;
|
||||
|
||||
@@ -33,6 +34,7 @@ type ActivityRow = {
|
||||
reminderNotifyApp: boolean;
|
||||
reminderNotifySms: boolean;
|
||||
completedAt: Date | null;
|
||||
tags: string[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
person: { id: string; firstName: string; lastName: string } | null;
|
||||
@@ -191,6 +193,7 @@ export class ActivitiesService {
|
||||
reminderAt: dto.reminderAt ? new Date(dto.reminderAt) : null,
|
||||
reminderNotifyApp: dto.reminderNotifyApp ?? true,
|
||||
reminderNotifySms: dto.reminderNotifySms ?? false,
|
||||
tags: normalizeTags(dto.tags),
|
||||
personId: emptyToNull(dto.personId ?? null),
|
||||
companyId: emptyToNull(dto.companyId ?? null),
|
||||
dealId: emptyToNull(dto.dealId ?? null),
|
||||
@@ -247,6 +250,7 @@ export class ActivitiesService {
|
||||
if ('reminderAt' in dto) data.reminderAt = dto.reminderAt ? new Date(dto.reminderAt) : null;
|
||||
if (dto.reminderNotifyApp !== undefined) data.reminderNotifyApp = dto.reminderNotifyApp;
|
||||
if (dto.reminderNotifySms !== undefined) data.reminderNotifySms = dto.reminderNotifySms;
|
||||
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
@@ -477,6 +481,7 @@ export class ActivitiesService {
|
||||
reminderNotifyApp: row.reminderNotifyApp,
|
||||
reminderNotifySms: row.reminderNotifySms,
|
||||
completedAt: row.completedAt?.toISOString() ?? null,
|
||||
tags: row.tags ?? [],
|
||||
person: row.person
|
||||
? {
|
||||
id: row.person.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
@@ -107,6 +108,11 @@ export class CreateActivityDto {
|
||||
@IsBoolean()
|
||||
reminderNotifySms?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -177,6 +183,11 @@ export class UpdateActivityDto {
|
||||
@IsBoolean()
|
||||
reminderNotifySms?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -34,6 +34,8 @@ import { InventoryModule } from './inventory/inventory.module';
|
||||
import { FinanceModule } from './finance/finance.module';
|
||||
import { PlatformModule } from './platform/platform.module';
|
||||
import { BillingModule } from './billing/billing.module';
|
||||
import { AuditLogsModule } from './audit-logs/audit-logs.module';
|
||||
import { SavedViewsModule } from './saved-views/saved-views.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -75,6 +77,8 @@ import { BillingModule } from './billing/billing.module';
|
||||
MessageAutomationsModule,
|
||||
MemoryModule,
|
||||
TahesabModule,
|
||||
AuditLogsModule,
|
||||
SavedViewsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { 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 { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
class ListAuditQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
actorUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
entityType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(100)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
@Controller('audit-logs')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('audit_logs')
|
||||
@Roles('owner', 'admin')
|
||||
export class AuditLogsController {
|
||||
constructor(private readonly auditLogs: AuditLogsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string },
|
||||
@Query() query: ListAuditQueryDto,
|
||||
) {
|
||||
return this.auditLogs.list(user.organizationId, {
|
||||
q: query.q,
|
||||
actorUserId: query.actorUserId,
|
||||
entityType: query.entityType,
|
||||
page: query.page,
|
||||
limit: query.limit,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
const SKIP_PREFIXES = [
|
||||
'/api/health',
|
||||
'/api/auth/login',
|
||||
'/api/auth/register',
|
||||
'/api/auth/otp',
|
||||
'/api/auth/refresh',
|
||||
'/api/portal',
|
||||
'/api/public',
|
||||
'/api/platform',
|
||||
'/api/audit-logs',
|
||||
];
|
||||
|
||||
type AuthedRequest = Request & {
|
||||
user?: {
|
||||
userId?: string;
|
||||
id?: string;
|
||||
organizationId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function inferEntity(path: string): { entityType: string | null; entityId: string | null } {
|
||||
const clean = path.replace(/^\/api\/?/, '').split('?')[0] ?? '';
|
||||
const parts = clean.split('/').filter(Boolean);
|
||||
if (!parts.length) return { entityType: null, entityId: null };
|
||||
const entityType = parts[0] ?? null;
|
||||
const maybeId = parts[1];
|
||||
const entityId =
|
||||
maybeId && !['new', 'me', 'summary', 'dashboard', 'board'].includes(maybeId)
|
||||
? maybeId
|
||||
: null;
|
||||
return { entityType, entityId };
|
||||
}
|
||||
|
||||
function actionLabel(method: string, path: string): string {
|
||||
const m = method.toUpperCase();
|
||||
if (m === 'POST') return 'ایجاد';
|
||||
if (m === 'PATCH' || m === 'PUT') return 'ویرایش';
|
||||
if (m === 'DELETE') return 'حذف';
|
||||
return m;
|
||||
void path;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogsInterceptor implements NestInterceptor {
|
||||
constructor(private readonly audit: AuditLogsService) {}
|
||||
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
const http = context.switchToHttp();
|
||||
const req = http.getRequest<AuthedRequest>();
|
||||
const method = (req.method ?? 'GET').toUpperCase();
|
||||
if (!['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const path = req.originalUrl || req.url || '';
|
||||
if (SKIP_PREFIXES.some((p) => path.startsWith(p))) {
|
||||
return next.handle();
|
||||
}
|
||||
|
||||
const organizationId = req.user?.organizationId;
|
||||
if (!organizationId) return next.handle();
|
||||
|
||||
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 ||
|
||||
null;
|
||||
|
||||
return next.handle().pipe(
|
||||
tap({
|
||||
next: () => {
|
||||
void this.audit.write({
|
||||
organizationId,
|
||||
actorUserId,
|
||||
action,
|
||||
method,
|
||||
path: path.split('?')[0] ?? path,
|
||||
entityType,
|
||||
entityId,
|
||||
summary,
|
||||
ip,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_INTERCEPTOR } from '@nestjs/core';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { AuditLogsController } from './audit-logs.controller';
|
||||
import { AuditLogsInterceptor } from './audit-logs.interceptor';
|
||||
import { AuditLogsService } from './audit-logs.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
controllers: [AuditLogsController],
|
||||
providers: [
|
||||
AuditLogsService,
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: AuditLogsInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [AuditLogsService],
|
||||
})
|
||||
export class AuditLogsModule {}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AuditLogDto, AuditLogListResponse } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export type AuditWriteInput = {
|
||||
organizationId: string;
|
||||
actorUserId?: string | null;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
entityType?: string | null;
|
||||
entityId?: string | null;
|
||||
summary: string;
|
||||
meta?: Record<string, unknown> | null;
|
||||
ip?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AuditLogsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async write(input: AuditWriteInput): Promise<void> {
|
||||
try {
|
||||
await this.prisma.auditLog.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId ?? null,
|
||||
action: input.action,
|
||||
method: input.method.slice(0, 16),
|
||||
path: input.path.slice(0, 400),
|
||||
entityType: input.entityType ?? null,
|
||||
entityId: input.entityId ?? null,
|
||||
summary: input.summary.slice(0, 500),
|
||||
meta:
|
||||
input.meta == null
|
||||
? undefined
|
||||
: (input.meta as Prisma.InputJsonValue),
|
||||
ip: input.ip ?? null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// never break the main request because of audit write failures
|
||||
}
|
||||
}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
opts: {
|
||||
q?: string;
|
||||
actorUserId?: string;
|
||||
entityType?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
): Promise<AuditLogListResponse> {
|
||||
const page = Math.max(1, opts.page ?? 1);
|
||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 30));
|
||||
const where: Prisma.AuditLogWhereInput = {
|
||||
organizationId,
|
||||
...(opts.actorUserId ? { actorUserId: opts.actorUserId } : {}),
|
||||
...(opts.entityType ? { entityType: opts.entityType } : {}),
|
||||
...(opts.q?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ summary: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ path: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
{ action: { contains: opts.q.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
const [total, rows] = await Promise.all([
|
||||
this.prisma.auditLog.count({ where }),
|
||||
this.prisma.auditLog.findMany({
|
||||
where,
|
||||
include: { actor: { select: { id: true, fullName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
};
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
summary: string;
|
||||
ip: string | null;
|
||||
createdAt: Date;
|
||||
actor: { id: string; fullName: string } | null;
|
||||
}): AuditLogDto {
|
||||
return {
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
summary: row.summary,
|
||||
actor: row.actor,
|
||||
ip: row.ip,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Normalize free-form tag lists (max 40 unique non-empty tags). */
|
||||
export function normalizeTags(input?: string[] | null): string[] {
|
||||
if (!input?.length) return [];
|
||||
const cleaned = input
|
||||
.map((t) => String(t ?? '').trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (t.startsWith('#') ? t.slice(1).trim() : t))
|
||||
.filter(Boolean);
|
||||
return [...new Set(cleaned)].slice(0, 40);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
const companyInclude = {
|
||||
_count: { select: { people: true } },
|
||||
@@ -38,6 +39,7 @@ type CompanyRow = {
|
||||
website: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
customFields: unknown;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -108,6 +110,7 @@ export class CompaniesService {
|
||||
website: emptyToNull(dto.website),
|
||||
address: emptyToNull(dto.address),
|
||||
notes: emptyToNull(dto.notes),
|
||||
tags: normalizeTags(dto.tags),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
};
|
||||
@@ -134,6 +137,7 @@ export class CompaniesService {
|
||||
if ('website' in dto) data.website = emptyToNull(dto.website);
|
||||
if ('address' in dto) data.address = emptyToNull(dto.address);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
@@ -228,6 +232,7 @@ export class CompaniesService {
|
||||
website: row.website,
|
||||
address: row.address,
|
||||
notes: row.notes,
|
||||
tags: row.tags ?? [],
|
||||
customFields: asCustomFields(row.customFields),
|
||||
peopleCount: row._count.people,
|
||||
owner: row.owner,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
@@ -77,6 +78,11 @@ export class CreateCompanyDto {
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
@@ -124,6 +130,11 @@ export class UpdateCompanyDto {
|
||||
@MaxLength(2000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
customFields?: Record<string, unknown>;
|
||||
@@ -184,6 +195,11 @@ export class CreatePersonDto {
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -249,6 +265,11 @@ export class UpdatePersonDto {
|
||||
@IsIn([...PERSON_MOODS])
|
||||
mood?: (typeof PERSON_MOODS)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import { LoyaltyService } from '../loyalty/loyalty.service';
|
||||
import { AutomationsService } from '../automations/automations.service';
|
||||
import { MessageAutomationsService } from '../message-automations/message-automations.service';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
const personInclude = {
|
||||
company: { select: { id: true, name: true } },
|
||||
@@ -48,6 +49,7 @@ type PersonRow = {
|
||||
referrer: string | null;
|
||||
notes: string | null;
|
||||
mood: PersonMood;
|
||||
tags: string[];
|
||||
customFields: unknown;
|
||||
loyaltyPoints: number;
|
||||
createdAt: Date;
|
||||
@@ -146,6 +148,7 @@ export class PeopleService {
|
||||
referrer: emptyToNull(dto.referrer),
|
||||
notes: emptyToNull(dto.notes),
|
||||
mood: dto.mood ?? 'newcomer',
|
||||
tags: normalizeTags(dto.tags),
|
||||
companyId,
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
customFields: toJsonCustomFields(dto.customFields),
|
||||
@@ -193,6 +196,7 @@ export class PeopleService {
|
||||
if ('referrer' in dto) data.referrer = emptyToNull(dto.referrer);
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes);
|
||||
if (dto.mood !== undefined) data.mood = dto.mood;
|
||||
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
||||
if ('customFields' in dto) {
|
||||
data.customFields = toJsonCustomFields(dto.customFields);
|
||||
}
|
||||
@@ -278,6 +282,7 @@ export class PeopleService {
|
||||
referrer: row.referrer,
|
||||
notes: row.notes,
|
||||
mood: row.mood,
|
||||
tags: row.tags ?? [],
|
||||
customFields: asCustomFields(row.customFields),
|
||||
loyaltyPoints: row.loyaltyPoints ?? 0,
|
||||
loyaltyTier: row.loyaltyTier,
|
||||
|
||||
@@ -57,6 +57,11 @@ export class CreateProjectCardDto {
|
||||
@MaxLength(4000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -76,6 +81,11 @@ export class CreateProjectCardDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateProjectCardDto {
|
||||
@@ -90,6 +100,11 @@ export class UpdateProjectCardDto {
|
||||
@MaxLength(4000)
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
@@ -109,6 +124,11 @@ export class UpdateProjectCardDto {
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsString()
|
||||
ownerId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export class MoveProjectCardDto {
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ownerWhere,
|
||||
resolveOwnerId,
|
||||
} from '../common/record-scope';
|
||||
import { normalizeTags } from '../common/tags';
|
||||
|
||||
const DEFAULT_STAGES = ['صف', 'در حال انجام', 'بازبینی', 'انجامشده'] as const;
|
||||
|
||||
@@ -27,6 +28,7 @@ const cardInclude = {
|
||||
company: { select: { id: true, name: true } },
|
||||
deal: { select: { id: true, title: true } },
|
||||
owner: { select: { id: true, fullName: true } },
|
||||
assignees: { select: { id: true, fullName: true } },
|
||||
} satisfies Prisma.ProjectCardInclude;
|
||||
|
||||
type CardActor = Pick<Actor, 'userId' | 'role'>;
|
||||
@@ -192,6 +194,7 @@ export class ProjectBoardsService {
|
||||
_max: { position: true },
|
||||
});
|
||||
|
||||
const assigneeIds = [...new Set((dto.assigneeIds ?? []).map((id) => id.trim()).filter(Boolean))];
|
||||
const created = await this.prisma.projectCard.create({
|
||||
data: {
|
||||
organizationId,
|
||||
@@ -199,11 +202,15 @@ export class ProjectBoardsService {
|
||||
stageId: stage.id,
|
||||
title: dto.title.trim(),
|
||||
notes: emptyToNull(dto.notes),
|
||||
tags: normalizeTags(dto.tags),
|
||||
personId: emptyToNull(dto.personId),
|
||||
companyId: emptyToNull(dto.companyId),
|
||||
dealId: emptyToNull(dto.dealId),
|
||||
ownerId: resolveOwnerId(actor, dto.ownerId),
|
||||
position: (maxPos._max.position ?? -1) + 1,
|
||||
...(assigneeIds.length
|
||||
? { assignees: { connect: assigneeIds.map((id) => ({ id })) } }
|
||||
: {}),
|
||||
},
|
||||
include: cardInclude,
|
||||
});
|
||||
@@ -234,6 +241,7 @@ export class ProjectBoardsService {
|
||||
const data: Prisma.ProjectCardUncheckedUpdateInput = {};
|
||||
if (dto.title !== undefined) data.title = dto.title.trim();
|
||||
if ('notes' in dto) data.notes = emptyToNull(dto.notes ?? null);
|
||||
if ('tags' in dto) data.tags = normalizeTags(dto.tags);
|
||||
if ('personId' in dto) data.personId = emptyToNull(dto.personId ?? null);
|
||||
if ('companyId' in dto) data.companyId = emptyToNull(dto.companyId ?? null);
|
||||
if ('dealId' in dto) data.dealId = emptyToNull(dto.dealId ?? null);
|
||||
@@ -246,7 +254,18 @@ export class ProjectBoardsService {
|
||||
|
||||
const row = await this.prisma.projectCard.update({
|
||||
where: { id },
|
||||
data,
|
||||
data: {
|
||||
...data,
|
||||
...(dto.assigneeIds
|
||||
? {
|
||||
assignees: {
|
||||
set: [...new Set(dto.assigneeIds.map((x) => x.trim()).filter(Boolean))].map(
|
||||
(uid) => ({ id: uid }),
|
||||
),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
include: cardInclude,
|
||||
});
|
||||
return this.toCardDto(row);
|
||||
@@ -411,6 +430,7 @@ export class ProjectBoardsService {
|
||||
id: string;
|
||||
title: string;
|
||||
notes: string | null;
|
||||
tags?: string[];
|
||||
position: number;
|
||||
boardId: string;
|
||||
stageId: string;
|
||||
@@ -420,11 +440,13 @@ export class ProjectBoardsService {
|
||||
company: { id: string; name: string } | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
assignees?: Array<{ id: string; fullName: string }>;
|
||||
}): ProjectCardDto {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
notes: row.notes,
|
||||
tags: row.tags ?? [],
|
||||
position: row.position,
|
||||
boardId: row.boardId,
|
||||
stageId: row.stageId,
|
||||
@@ -437,6 +459,7 @@ export class ProjectBoardsService {
|
||||
company: row.company,
|
||||
deal: row.deal,
|
||||
owner: row.owner,
|
||||
assignees: row.assignees ?? [],
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
|
||||
@@ -76,6 +76,7 @@ export class ReportsService {
|
||||
stageId: true,
|
||||
ownerId: true,
|
||||
source: true,
|
||||
lostReason: true,
|
||||
updatedAt: true,
|
||||
createdAt: true,
|
||||
lineItems: {
|
||||
@@ -402,6 +403,27 @@ export class ReportsService {
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
const lostReasonMap = new Map<string, { count: number; amount: number }>();
|
||||
for (const d of lostInPeriod) {
|
||||
const label = normalizeLostReason(d.lostReason);
|
||||
const prev = lostReasonMap.get(label) ?? { count: 0, amount: 0 };
|
||||
prev.count += 1;
|
||||
prev.amount += decimalToBase(d.amount, d.currency, rates);
|
||||
lostReasonMap.set(label, prev);
|
||||
}
|
||||
const lostReasonTotal =
|
||||
[...lostReasonMap.values()].reduce((a, b) => a + b.count, 0) || 1;
|
||||
const lostReasons: ReportsDashboardDto['lostReasons'] = [
|
||||
...lostReasonMap.entries(),
|
||||
]
|
||||
.map(([label, row]) => ({
|
||||
label,
|
||||
count: row.count,
|
||||
amount: Math.round(row.amount),
|
||||
percent: Math.round((row.count / lostReasonTotal) * 1000) / 10,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
type LineAgg = {
|
||||
name: string;
|
||||
category: string;
|
||||
@@ -636,6 +658,11 @@ export class ReportsService {
|
||||
`بیشترین منبع معاملات برنده «${sources[0].label}» بوده است.`,
|
||||
);
|
||||
}
|
||||
if (lostReasons[0] && lostReasons[0].count > 0) {
|
||||
insights.push(
|
||||
`بیشترین دلیل شکست معاملات «${lostReasons[0].label}» بوده است (${lostReasons[0].count} مورد).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -647,6 +674,7 @@ export class ReportsService {
|
||||
monthlyComparison,
|
||||
customerGrowth,
|
||||
sources,
|
||||
lostReasons,
|
||||
salesByCategory,
|
||||
topProducts,
|
||||
customerStatus,
|
||||
@@ -1052,6 +1080,12 @@ function normalizeSource(raw: string | null | undefined): string {
|
||||
return map[value.toLowerCase()] ?? value;
|
||||
}
|
||||
|
||||
function normalizeLostReason(raw: string | null | undefined): string {
|
||||
const value = raw?.trim();
|
||||
if (!value) return 'بدون دلیل ثبتشده';
|
||||
return value;
|
||||
}
|
||||
|
||||
function activityTypeLabel(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { SavedViewsService } from './saved-views.service';
|
||||
|
||||
const SCOPES = [
|
||||
'deals',
|
||||
'people',
|
||||
'companies',
|
||||
'activities',
|
||||
'project_cards',
|
||||
] as const;
|
||||
|
||||
class CreateSavedViewDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsIn(SCOPES)
|
||||
scope!: (typeof SCOPES)[number];
|
||||
|
||||
@IsObject()
|
||||
filters!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
class UpdateSavedViewDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SCOPES)
|
||||
scope?: (typeof SCOPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filters?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
@Controller('saved-views')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, OrgModuleGuard)
|
||||
@RequireOrgModule('saved_views')
|
||||
export class SavedViewsController {
|
||||
constructor(private readonly savedViews: SavedViewsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Query('scope') scope?: string,
|
||||
) {
|
||||
return this.savedViews.list(user.organizationId, user.userId, scope);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Body() dto: CreateSavedViewDto,
|
||||
) {
|
||||
return this.savedViews.create(user.organizationId, user.userId, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser()
|
||||
user: { organizationId: string; userId: string; role: string },
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateSavedViewDto,
|
||||
) {
|
||||
const isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||
return this.savedViews.update(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
dto,
|
||||
isAdmin,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(
|
||||
@CurrentUser()
|
||||
user: { organizationId: string; userId: string; role: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||
await this.savedViews.remove(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
isAdmin,
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { BillingModule } from '../billing/billing.module';
|
||||
import { SavedViewsController } from './saved-views.controller';
|
||||
import { SavedViewsService } from './saved-views.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, BillingModule],
|
||||
controllers: [SavedViewsController],
|
||||
providers: [SavedViewsService],
|
||||
exports: [SavedViewsService],
|
||||
})
|
||||
export class SavedViewsModule {}
|
||||
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateSavedViewRequest,
|
||||
SavedViewDto,
|
||||
SavedViewScope,
|
||||
UpdateSavedViewRequest,
|
||||
} from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const SCOPES: SavedViewScope[] = [
|
||||
'deals',
|
||||
'people',
|
||||
'companies',
|
||||
'activities',
|
||||
'project_cards',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class SavedViewsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
scope?: string,
|
||||
): Promise<SavedViewDto[]> {
|
||||
const rows = await this.prisma.savedView.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
...(scope ? { scope } : {}),
|
||||
OR: [{ isShared: true }, { ownerUserId: userId }],
|
||||
},
|
||||
include: { owner: { select: { id: true, fullName: true } } },
|
||||
orderBy: [{ updatedAt: 'desc' }],
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
dto: CreateSavedViewRequest,
|
||||
): Promise<SavedViewDto> {
|
||||
const scope = this.assertScope(dto.scope);
|
||||
const name = dto.name?.trim();
|
||||
if (!name) throw new BadRequestException('نام فیلتر الزامی است');
|
||||
const row = await this.prisma.savedView.create({
|
||||
data: {
|
||||
organizationId,
|
||||
ownerUserId: userId,
|
||||
name,
|
||||
scope,
|
||||
filters: (dto.filters ?? {}) as Prisma.InputJsonValue,
|
||||
isShared: Boolean(dto.isShared),
|
||||
},
|
||||
include: { owner: { select: { id: true, fullName: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
dto: UpdateSavedViewRequest,
|
||||
isAdmin: boolean,
|
||||
): Promise<SavedViewDto> {
|
||||
const existing = await this.prisma.savedView.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
||||
if (!isAdmin && existing.ownerUserId !== userId) {
|
||||
throw new BadRequestException('فقط مالک فیلتر میتواند آن را ویرایش کند');
|
||||
}
|
||||
|
||||
const data: Prisma.SavedViewUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('نام فیلتر الزامی است');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.scope !== undefined) data.scope = this.assertScope(dto.scope);
|
||||
if (dto.filters !== undefined) {
|
||||
data.filters = dto.filters as Prisma.InputJsonValue;
|
||||
}
|
||||
if (dto.isShared !== undefined) data.isShared = Boolean(dto.isShared);
|
||||
|
||||
const row = await this.prisma.savedView.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: { owner: { select: { id: true, fullName: true } } },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
isAdmin: boolean,
|
||||
): Promise<void> {
|
||||
const existing = await this.prisma.savedView.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
||||
if (!isAdmin && existing.ownerUserId !== userId) {
|
||||
throw new BadRequestException('فقط مالک فیلتر میتواند آن را حذف کند');
|
||||
}
|
||||
await this.prisma.savedView.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private assertScope(scope: string): SavedViewScope {
|
||||
if (!SCOPES.includes(scope as SavedViewScope)) {
|
||||
throw new BadRequestException('محدوده فیلتر معتبر نیست');
|
||||
}
|
||||
return scope as SavedViewScope;
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: string;
|
||||
filters: unknown;
|
||||
isShared: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
owner: { id: string; fullName: string } | null;
|
||||
}): SavedViewDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
scope: row.scope as SavedViewScope,
|
||||
filters:
|
||||
row.filters && typeof row.filters === 'object'
|
||||
? (row.filters as Record<string, unknown>)
|
||||
: {},
|
||||
isShared: row.isShared,
|
||||
owner: row.owner,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { formatLocalDateTimeInput } from '@/lib/jalali';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
|
||||
@@ -93,15 +95,14 @@ function onActivityPersonEdit() {
|
||||
if (draft.value?.activity) draft.value.activity.personId = null;
|
||||
}
|
||||
|
||||
function toLocalInput(iso: string | null) {
|
||||
const dueLocal = computed(() => {
|
||||
const iso = draft.value?.activity?.dueAt;
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
return Number.isNaN(date.getTime()) ? '' : formatLocalDateTimeInput(date);
|
||||
});
|
||||
|
||||
function onDueEdit(value: string) {
|
||||
function onDueLocal(value: string) {
|
||||
if (!draft.value?.activity) return;
|
||||
if (!value) {
|
||||
draft.value.activity.dueAt = null;
|
||||
@@ -111,6 +112,25 @@ function onDueEdit(value: string) {
|
||||
draft.value.activity.dueAt = Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
async function rematchActivityPerson() {
|
||||
const activity = draft.value?.activity;
|
||||
const name = activity?.personName.trim();
|
||||
if (!activity || !name) {
|
||||
if (activity) activity.personId = null;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { data } = await api.get<{ items: Array<{ id: string; fullName: string }> }>('/people', {
|
||||
params: { q: name, limit: 5 },
|
||||
});
|
||||
const exact = data.items.filter((row) => row.fullName.trim() === name);
|
||||
const hit = exact.length === 1 ? exact[0] : data.items.length === 1 ? data.items[0] : null;
|
||||
activity.personId = hit?.id ?? null;
|
||||
} catch {
|
||||
activity.personId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseCommand(text: string, fallbackToAsk: boolean) {
|
||||
voiceBusy.value = true;
|
||||
voiceError.value = '';
|
||||
@@ -312,15 +332,14 @@ watch(
|
||||
</label>
|
||||
<label>
|
||||
زمان
|
||||
<input
|
||||
type="datetime-local"
|
||||
:value="toLocalInput(draft.activity.dueAt)"
|
||||
@input="onDueEdit(($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
|
||||
</label>
|
||||
<label>
|
||||
مشتری
|
||||
<input v-model="draft.activity.personName" @input="onActivityPersonEdit" @change="rematchActivityPerson" />
|
||||
</label>
|
||||
<label>مشتری <input v-model="draft.activity.personName" @input="onActivityPersonEdit" /></label>
|
||||
<p v-if="draft.activity.personName && !draft.activity.personId" class="warn">
|
||||
این نام به پروندهای وصل نیست؛ فعالیت بدون لینک ثبت میشود.
|
||||
در فهرست مشتریان پروندهای با این نام پیدا نشد. فعالیت ذخیره میشود، ولی داخل پروندهٔ آن شخص دیده نمیشود.
|
||||
</p>
|
||||
<label>توضیح <textarea v-model="draft.activity.description" rows="2" /></label>
|
||||
</div>
|
||||
|
||||
@@ -335,6 +335,15 @@ export const NAV_HUBS: NavHubDef[] = [
|
||||
tint: 'violet',
|
||||
module: 'team_settings',
|
||||
},
|
||||
{
|
||||
to: '/settings/audit-logs',
|
||||
title: 'لاگ فعالیت کاربران',
|
||||
desc: 'تاریخچه ایجاد، ویرایش و حذف',
|
||||
icon: 'layers',
|
||||
tint: 'rose',
|
||||
module: 'audit_logs',
|
||||
admin: true,
|
||||
},
|
||||
{
|
||||
to: '/settings/custom-fields',
|
||||
title: 'فیلدهای سفارشی',
|
||||
|
||||
@@ -252,6 +252,12 @@ export const router = createRouter({
|
||||
component: () => import('@/views/TeamSettingsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/audit-logs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('@/views/AuditLogsView.vue'),
|
||||
meta: { roles: ['owner', 'admin'] as UserRole[] },
|
||||
},
|
||||
{
|
||||
path: 'settings/custom-fields',
|
||||
name: 'custom-fields',
|
||||
|
||||
@@ -71,6 +71,7 @@ const title = ref('');
|
||||
const type = ref<ActivityType | ''>('');
|
||||
const priority = ref<ActivityPriority>('medium');
|
||||
const description = ref('');
|
||||
const tagsRaw = ref('');
|
||||
const dueDate = ref('');
|
||||
const dueTime = ref('10:30');
|
||||
const durationMinutes = ref(30);
|
||||
@@ -155,6 +156,7 @@ function resetForm() {
|
||||
type.value = '';
|
||||
priority.value = 'medium';
|
||||
description.value = '';
|
||||
tagsRaw.value = '';
|
||||
defaultDue();
|
||||
durationMinutes.value = 30;
|
||||
personId.value = '';
|
||||
@@ -201,6 +203,7 @@ async function load() {
|
||||
priority.value = data.priority ?? 'medium';
|
||||
description.value = data.description ?? '';
|
||||
needsNote.value = Boolean(data.description?.trim());
|
||||
tagsRaw.value = (data.tags ?? []).join('، ');
|
||||
splitDue(data.dueAt);
|
||||
durationMinutes.value = data.durationMinutes ?? 30;
|
||||
reminderAt.value = data.reminderAt
|
||||
@@ -240,6 +243,17 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[,،#\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 40);
|
||||
}
|
||||
|
||||
function payload(): CreateActivityRequest {
|
||||
const due =
|
||||
dueDate.value && dueTime.value ? new Date(`${dueDate.value}T${dueTime.value}:00`) : null;
|
||||
@@ -249,6 +263,7 @@ function payload(): CreateActivityRequest {
|
||||
type: type.value || 'task',
|
||||
priority: priority.value,
|
||||
description: description.value || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
dueAt: due && !Number.isNaN(due.getTime()) ? due.toISOString() : null,
|
||||
durationMinutes: durationMinutes.value || null,
|
||||
reminderAt: reminder && !Number.isNaN(reminder.getTime()) ? reminder.toISOString() : null,
|
||||
@@ -573,6 +588,12 @@ watch([id, isNew], () => void load(), { immediate: true });
|
||||
:rows="8"
|
||||
placeholder="جزئیات تماس، نتیجه جلسه یا یادداشت مرتبط را بنویسید…"
|
||||
/>
|
||||
<label for="activity-tags" class="tags-label">هشتگ / برچسب</label>
|
||||
<UiInput
|
||||
id="activity-tags"
|
||||
v-model="tagsRaw"
|
||||
placeholder="پیگیری، فوری (با کاما یا #)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { AuditLogListResponse } from '@mento/shared';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiEmptyState,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiPageHeader,
|
||||
} from '@/components/ui';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const q = ref('');
|
||||
const entityType = ref('');
|
||||
const page = ref(1);
|
||||
const data = ref<AuditLogListResponse | null>(null);
|
||||
|
||||
const totalPages = computed(() => {
|
||||
if (!data.value) return 1;
|
||||
return Math.max(1, Math.ceil(data.value.total / data.value.limit));
|
||||
});
|
||||
|
||||
async function load(resetPage = false) {
|
||||
if (resetPage) page.value = 1;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data: res } = await api.get<AuditLogListResponse>('/audit-logs', {
|
||||
params: {
|
||||
q: q.value.trim() || undefined,
|
||||
entityType: entityType.value.trim() || undefined,
|
||||
page: page.value,
|
||||
limit: 30,
|
||||
},
|
||||
});
|
||||
data.value = res;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 403) {
|
||||
error.value = 'دسترسی به لاگ فعالیت برای این محیط فعال نیست.';
|
||||
} else if (axios.isAxiosError(e) && !e.response) {
|
||||
error.value = 'اتصال به API برقرار نشد.';
|
||||
} else {
|
||||
error.value = 'بارگذاری لاگ ناموفق بود.';
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function go(delta: number) {
|
||||
page.value = Math.min(totalPages.value, Math.max(1, page.value + delta));
|
||||
void load();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="audit-page">
|
||||
<UiPageHeader
|
||||
title="لاگ فعالیت کاربران"
|
||||
description="ثبت تغییرات ایجاد، ویرایش و حذف در سامانه"
|
||||
>
|
||||
<template #actions>
|
||||
<UiButton variant="secondary" :disabled="loading" @click="load()">
|
||||
<UiIcon name="refresh" />
|
||||
بروزرسانی
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<UiCard class="filters">
|
||||
<div class="filters-grid">
|
||||
<UiField label="جستجو">
|
||||
<UiInput
|
||||
v-model="q"
|
||||
placeholder="خلاصه، مسیر یا نوع عملیات…"
|
||||
@keydown.enter="load(true)"
|
||||
/>
|
||||
</UiField>
|
||||
<UiField label="نوع منبع">
|
||||
<UiInput
|
||||
v-model="entityType"
|
||||
placeholder="مثلاً deals یا people"
|
||||
@keydown.enter="load(true)"
|
||||
/>
|
||||
</UiField>
|
||||
<div class="filters-actions">
|
||||
<UiButton :disabled="loading" @click="load(true)">اعمال فیلتر</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
</UiCard>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<UiCard v-if="loading && !data" title="در حال بارگذاری…">
|
||||
<p class="muted">لطفاً صبر کنید.</p>
|
||||
</UiCard>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="!loading && data && !data.items.length"
|
||||
title="لاگی ثبت نشده"
|
||||
description="پس از ایجاد یا ویرایش رکوردها، رویدادها اینجا دیده میشوند."
|
||||
/>
|
||||
|
||||
<UiCard v-else-if="data?.items.length" title="رویدادها">
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>زمان</th>
|
||||
<th>کاربر</th>
|
||||
<th>عملیات</th>
|
||||
<th>منبع</th>
|
||||
<th>خلاصه</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in data.items" :key="row.id">
|
||||
<td class="nowrap">{{ formatJalaliNumeric(row.createdAt) }}</td>
|
||||
<td>{{ row.actor?.fullName ?? '—' }}</td>
|
||||
<td>
|
||||
<span class="pill">{{ row.action }}</span>
|
||||
</td>
|
||||
<td class="muted">{{ row.entityType ?? '—' }}</td>
|
||||
<td>{{ row.summary }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="loading || page <= 1"
|
||||
@click="go(-1)"
|
||||
>
|
||||
قبلی
|
||||
</UiButton>
|
||||
<span class="muted">
|
||||
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(totalPages) }}
|
||||
· {{ toFaDigits(data.total) }} مورد
|
||||
</span>
|
||||
<UiButton
|
||||
variant="secondary"
|
||||
:disabled="loading || page >= totalPages"
|
||||
@click="go(1)"
|
||||
>
|
||||
بعدی
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-page {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr auto;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
.filters-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
}
|
||||
.error {
|
||||
color: #b91c1c;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.muted {
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
padding: 0.65rem 0.55rem;
|
||||
border-bottom: 1px solid #eef2f7;
|
||||
vertical-align: top;
|
||||
}
|
||||
th {
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
padding: 0.15rem 0.55rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f0fdfa;
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.85rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -45,8 +45,20 @@ const email = ref('');
|
||||
const website = ref('');
|
||||
const address = ref('');
|
||||
const notes = ref('');
|
||||
const tagsRaw = ref('');
|
||||
const customFields = ref<Record<string, unknown>>({});
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[,،#\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 40);
|
||||
}
|
||||
|
||||
function fill(c: CompanyDto) {
|
||||
name.value = c.name;
|
||||
nationalId.value = c.nationalId ?? '';
|
||||
@@ -55,6 +67,7 @@ function fill(c: CompanyDto) {
|
||||
website.value = c.website ?? '';
|
||||
address.value = c.address ?? '';
|
||||
notes.value = c.notes ?? '';
|
||||
tagsRaw.value = (c.tags ?? []).join('، ');
|
||||
customFields.value = { ...(c.customFields ?? {}) };
|
||||
}
|
||||
|
||||
@@ -81,6 +94,7 @@ function payload(): CreateCompanyRequest {
|
||||
website: website.value || undefined,
|
||||
address: address.value || undefined,
|
||||
notes: notes.value || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
customFields: customFields.value,
|
||||
};
|
||||
}
|
||||
@@ -236,6 +250,14 @@ onMounted(load);
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="برچسبها" class="col-span-2">
|
||||
<UiInput
|
||||
id="company-tags"
|
||||
v-model="tagsRaw"
|
||||
placeholder="شرکتی، VIP (با کاما جدا کنید)"
|
||||
/>
|
||||
</UiField>
|
||||
|
||||
<UiField label="یادداشت و توضیحات تکمیلی" class="col-span-2">
|
||||
<UiTextarea
|
||||
id="notes"
|
||||
|
||||
@@ -62,6 +62,7 @@ const position = ref('');
|
||||
const website = ref('');
|
||||
const referrer = ref('');
|
||||
const notes = ref('');
|
||||
const tagsRaw = ref('');
|
||||
const companyId = ref('');
|
||||
const mood = ref<PersonMood>('newcomer');
|
||||
const customFields = ref<Record<string, unknown>>({});
|
||||
@@ -205,6 +206,7 @@ function fill(p: PersonDto) {
|
||||
website.value = p.website ?? '';
|
||||
referrer.value = p.referrer ?? '';
|
||||
notes.value = p.notes ?? '';
|
||||
tagsRaw.value = (p.tags ?? []).join('، ');
|
||||
companyId.value = p.company?.id ?? '';
|
||||
mood.value = p.mood || 'newcomer';
|
||||
customFields.value = { ...(p.customFields ?? {}) };
|
||||
@@ -231,6 +233,17 @@ async function load() {
|
||||
}
|
||||
}
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[,،#\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 40);
|
||||
}
|
||||
|
||||
function payload(): CreatePersonRequest {
|
||||
return {
|
||||
firstName: firstName.value,
|
||||
@@ -242,6 +255,7 @@ function payload(): CreatePersonRequest {
|
||||
website: website.value || undefined,
|
||||
referrer: referrer.value || undefined,
|
||||
notes: notes.value || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
companyId: companyId.value || null,
|
||||
mood: mood.value,
|
||||
customFields: customFields.value,
|
||||
@@ -463,6 +477,16 @@ onMounted(load);
|
||||
</UiField>
|
||||
</div>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="برچسبها" for-id="tags">
|
||||
<UiInput
|
||||
id="tags"
|
||||
v-model="tagsRaw"
|
||||
placeholder="VIP، معرفی (با کاما جدا کنید)"
|
||||
/>
|
||||
</UiField>
|
||||
</div>
|
||||
|
||||
<div class="crm-col-span-full">
|
||||
<UiField label="یادداشت" for-id="notes">
|
||||
<UiTextarea id="notes" v-model="notes" :rows="3" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { ProjectCardDto } from '@mento/shared';
|
||||
import type { OrgUserDto, ProjectCardDto } from '@mento/shared';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -35,6 +35,29 @@ const saving = ref(false);
|
||||
const error = ref('');
|
||||
const title = ref('');
|
||||
const notes = ref('');
|
||||
const tagsRaw = ref('');
|
||||
const selectedAssigneeIds = ref<string[]>([]);
|
||||
const users = ref<OrgUserDto[]>([]);
|
||||
|
||||
function parseTags(raw: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
raw
|
||||
.split(/[,،#\n]/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 40);
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const { data } = await api.get<OrgUserDto[]>('/users');
|
||||
users.value = data;
|
||||
} catch {
|
||||
users.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (isNew.value) return;
|
||||
@@ -46,6 +69,8 @@ async function load() {
|
||||
const { data } = await api.get<ProjectCardDto>(`/project-cards/${cardId}`);
|
||||
title.value = data.title;
|
||||
notes.value = data.notes ?? '';
|
||||
tagsRaw.value = (data.tags ?? []).join('، ');
|
||||
selectedAssigneeIds.value = (data.assignees ?? []).map((a) => a.id);
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && !e.response) {
|
||||
error.value = 'اتصال به API برقرار نشد.';
|
||||
@@ -71,6 +96,8 @@ async function save() {
|
||||
const { data } = await api.post<ProjectCardDto>('/project-cards', {
|
||||
title: title.value.trim(),
|
||||
notes: notes.value.trim() || undefined,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
assigneeIds: selectedAssigneeIds.value,
|
||||
boardId,
|
||||
stageId: props.embedded ? props.initialStageId : undefined,
|
||||
personId:
|
||||
@@ -85,6 +112,8 @@ async function save() {
|
||||
await api.patch(`/project-cards/${id.value}`, {
|
||||
title: title.value.trim(),
|
||||
notes: notes.value.trim() || null,
|
||||
tags: parseTags(tagsRaw.value),
|
||||
assigneeIds: selectedAssigneeIds.value,
|
||||
});
|
||||
if (props.embedded) {
|
||||
emit('saved');
|
||||
@@ -121,7 +150,10 @@ function cancel() {
|
||||
void router.push({ name: 'projects' });
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
await loadUsers();
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -188,6 +220,22 @@ onMounted(load);
|
||||
<label for="card-title">عنوان</label>
|
||||
<input id="card-title" v-model="title" type="text" required minlength="2" maxlength="200" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-tags">برچسبها</label>
|
||||
<input
|
||||
id="card-tags"
|
||||
v-model="tagsRaw"
|
||||
type="text"
|
||||
placeholder="فوری، پشتیبانی (با کاما جدا کنید)"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-assignees">مسئولان کارت</label>
|
||||
<select id="card-assignees" v-model="selectedAssigneeIds" multiple class="multi">
|
||||
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
|
||||
</select>
|
||||
<small class="hint">با نگه داشتن Ctrl میتوانید چند نفر را انتخاب کنید.</small>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="card-notes">یادداشت</label>
|
||||
<textarea id="card-notes" v-model="notes" rows="5" maxlength="4000" />
|
||||
@@ -313,12 +361,27 @@ h1 {
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
min-height: var(--control-h);
|
||||
padding: 0 0.75rem;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
select.multi {
|
||||
min-height: 110px;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #64748b;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
padding: 0 0.85rem;
|
||||
font: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -160,6 +160,12 @@ function exportReport() {
|
||||
'منبع,تعداد,درصد',
|
||||
...d.sources.map((s) => `${s.label},${s.count},${s.percent}`),
|
||||
'',
|
||||
'دلایل شکست معاملات',
|
||||
'دلیل,تعداد,درصد,مبلغ',
|
||||
...(d.lostReasons ?? []).map(
|
||||
(s) => `${s.label},${s.count},${s.percent},${s.amount ?? ''}`,
|
||||
),
|
||||
'',
|
||||
'پرفروشها',
|
||||
'نام,تعداد,مبلغ',
|
||||
...d.topProducts.map((p) => `${p.name},${p.quantity},${p.amount}`),
|
||||
@@ -267,6 +273,39 @@ const sourcesOption = computed<UiChartOption>(() => {
|
||||
};
|
||||
});
|
||||
|
||||
const lostReasonsOption = computed<UiChartOption>(() => {
|
||||
const rows = data.value?.lostReasons ?? [];
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
formatter: (p: any) => {
|
||||
const row = rows.find((r) => r.label === p.name);
|
||||
const amountPart =
|
||||
row?.amount != null ? `<br/>مبلغ: <strong>${money(row.amount)}</strong>` : '';
|
||||
return `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)${amountPart}`;
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
bottom: '0%',
|
||||
left: 'center',
|
||||
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
|
||||
},
|
||||
color: CHART_PALETTE,
|
||||
series: [
|
||||
{
|
||||
name: 'دلایل شکست',
|
||||
type: 'pie',
|
||||
radius: ['45%', '70%'],
|
||||
center: ['50%', '42%'],
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: rows.map((s) => ({ name: s.label, value: s.count })),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const monthlyOption = computed<UiChartOption>(() => {
|
||||
const rows = data.value?.monthlyComparison ?? [];
|
||||
return {
|
||||
@@ -563,6 +602,16 @@ onMounted(() => {
|
||||
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
|
||||
</UiCard>
|
||||
|
||||
<!-- Lost reasons -->
|
||||
<UiCard class="chart-card" title="دلایل عدم موفقیت فروش">
|
||||
<UiChart
|
||||
v-if="data.lostReasons?.length"
|
||||
:option="lostReasonsOption"
|
||||
height="260px"
|
||||
/>
|
||||
<p v-else class="empty-msg">معامله شکستخوردهای در این بازه نیست.</p>
|
||||
</UiCard>
|
||||
|
||||
<!-- Monthly comparison -->
|
||||
<UiCard class="chart-card" title="مقایسه فروش ماههای اخیر">
|
||||
<UiChart
|
||||
|
||||
@@ -116,7 +116,9 @@ export type OrgModuleKey =
|
||||
| "custom_fields"
|
||||
| "currencies"
|
||||
| "team_settings"
|
||||
| "faq";
|
||||
| "faq"
|
||||
| "audit_logs"
|
||||
| "saved_views";
|
||||
|
||||
export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"customers",
|
||||
@@ -143,6 +145,8 @@ export const ORG_MODULE_KEYS: OrgModuleKey[] = [
|
||||
"currencies",
|
||||
"team_settings",
|
||||
"faq",
|
||||
"audit_logs",
|
||||
"saved_views",
|
||||
];
|
||||
|
||||
export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
@@ -170,6 +174,8 @@ export const ORG_MODULE_LABELS: Record<OrgModuleKey, string> = {
|
||||
currencies: "ارزها",
|
||||
team_settings: "تیم و تنظیمات سازمان",
|
||||
faq: "FAQ",
|
||||
audit_logs: "لاگ فعالیت کاربران",
|
||||
saved_views: "فیلترهای ذخیرهشده",
|
||||
};
|
||||
|
||||
export const ORG_MODULE_GROUPS: Array<{
|
||||
@@ -204,11 +210,18 @@ export const ORG_MODULE_GROUPS: Array<{
|
||||
"attachments",
|
||||
"loyalty",
|
||||
"reports",
|
||||
"saved_views",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "تنظیمات",
|
||||
keys: ["team_settings", "custom_fields", "currencies", "tahesab"],
|
||||
keys: [
|
||||
"team_settings",
|
||||
"custom_fields",
|
||||
"currencies",
|
||||
"tahesab",
|
||||
"audit_logs",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -279,7 +292,8 @@ export type RolePermissionKey =
|
||||
| "products"
|
||||
| "activities"
|
||||
| "notes"
|
||||
| "reports";
|
||||
| "reports"
|
||||
| "audit";
|
||||
|
||||
export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
key: RolePermissionKey;
|
||||
@@ -292,6 +306,7 @@ export const ROLE_PERMISSION_ITEMS: Array<{
|
||||
{ key: "activities", label: "فعالیتها" },
|
||||
{ key: "notes", label: "یادداشتها" },
|
||||
{ key: "reports", label: "گزارشها" },
|
||||
{ key: "audit", label: "لاگ فعالیت کاربران" },
|
||||
];
|
||||
|
||||
export interface OrgRoleMemberDto {
|
||||
@@ -582,6 +597,7 @@ export interface CompanyDto {
|
||||
website: string | null;
|
||||
address: string | null;
|
||||
notes: string | null;
|
||||
tags: string[];
|
||||
customFields: Record<string, unknown>;
|
||||
peopleCount: number;
|
||||
owner: UserSummaryDto | null;
|
||||
@@ -597,6 +613,7 @@ export interface CreateCompanyRequest {
|
||||
website?: string;
|
||||
address?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
customFields?: Record<string, unknown>;
|
||||
ownerId?: string | null;
|
||||
}
|
||||
@@ -633,6 +650,7 @@ export interface PersonDto {
|
||||
referrer: string | null;
|
||||
notes: string | null;
|
||||
mood: PersonMood;
|
||||
tags: string[];
|
||||
customFields: Record<string, unknown>;
|
||||
loyaltyPoints: number;
|
||||
loyaltyTier: LoyaltyTierSummaryDto | null;
|
||||
@@ -653,6 +671,7 @@ export interface CreatePersonRequest {
|
||||
referrer?: string;
|
||||
notes?: string;
|
||||
mood?: PersonMood;
|
||||
tags?: string[];
|
||||
companyId?: string | null;
|
||||
customFields?: Record<string, unknown>;
|
||||
ownerId?: string | null;
|
||||
@@ -845,6 +864,7 @@ export interface ActivityDto {
|
||||
reminderNotifyApp: boolean;
|
||||
reminderNotifySms: boolean;
|
||||
completedAt: string | null;
|
||||
tags: string[];
|
||||
person: PersonSummaryDto | null;
|
||||
company: CompanySummaryDto | null;
|
||||
deal: DealSummaryDto | null;
|
||||
@@ -864,6 +884,7 @@ export interface CreateActivityRequest {
|
||||
reminderAt?: string | null;
|
||||
reminderNotifyApp?: boolean;
|
||||
reminderNotifySms?: boolean;
|
||||
tags?: string[];
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
@@ -881,6 +902,7 @@ export interface UpdateActivityRequest {
|
||||
reminderAt?: string | null;
|
||||
reminderNotifyApp?: boolean;
|
||||
reminderNotifySms?: boolean;
|
||||
tags?: string[];
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
@@ -2021,6 +2043,7 @@ export interface ReportsDashboardDto {
|
||||
monthlyComparison: ReportsSeriesPointDto[];
|
||||
customerGrowth: ReportsSeriesPointDto[];
|
||||
sources: ReportsSliceDto[];
|
||||
lostReasons: ReportsSliceDto[];
|
||||
salesByCategory: ReportsSliceDto[];
|
||||
topProducts: ReportsTopProductDto[];
|
||||
customerStatus: ReportsSliceDto[];
|
||||
@@ -2125,10 +2148,12 @@ export interface ProjectCardDto {
|
||||
position: number;
|
||||
boardId: string;
|
||||
stageId: string;
|
||||
tags: string[];
|
||||
person: PersonSummaryDto | null;
|
||||
company: CompanySummaryDto | null;
|
||||
deal: { id: string; title: string } | null;
|
||||
owner: UserSummaryDto | null;
|
||||
assignees: UserSummaryDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2151,10 +2176,12 @@ export interface CreateProjectCardRequest {
|
||||
boardId?: string;
|
||||
stageId?: string;
|
||||
notes?: string;
|
||||
tags?: string[];
|
||||
personId?: string | null;
|
||||
companyId?: string | null;
|
||||
dealId?: string | null;
|
||||
ownerId?: string | null;
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export type UpdateProjectCardRequest = Partial<CreateProjectCardRequest>;
|
||||
@@ -2891,3 +2918,54 @@ export interface ResetPlatformUserPasswordRequest {
|
||||
export { brand, APP_NAME, APP_NAME_FA, brandSuggestionFa } from "./brand";
|
||||
export type { Brand } from "./brand";
|
||||
export const API_PREFIX = "api";
|
||||
|
||||
/* ——— لاگ فعالیت کاربران ——— */
|
||||
|
||||
export interface AuditLogDto {
|
||||
id: string;
|
||||
action: string;
|
||||
method: string;
|
||||
path: string;
|
||||
entityType: string | null;
|
||||
entityId: string | null;
|
||||
summary: string;
|
||||
actor: UserSummaryDto | null;
|
||||
ip: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AuditLogListResponse {
|
||||
items: AuditLogDto[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
/* ——— فیلترهای ذخیرهشده ——— */
|
||||
|
||||
export type SavedViewScope =
|
||||
| "deals"
|
||||
| "people"
|
||||
| "companies"
|
||||
| "activities"
|
||||
| "project_cards";
|
||||
|
||||
export interface SavedViewDto {
|
||||
id: string;
|
||||
name: string;
|
||||
scope: SavedViewScope;
|
||||
filters: Record<string, unknown>;
|
||||
isShared: boolean;
|
||||
owner: UserSummaryDto | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateSavedViewRequest {
|
||||
name: string;
|
||||
scope: SavedViewScope;
|
||||
filters: Record<string, unknown>;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateSavedViewRequest = Partial<CreateSavedViewRequest>;
|
||||
|
||||
Reference in New Issue
Block a user