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:
2026-09-26 14:59:48 +03:30
parent 35a618d0af
commit 6463275c3d
30 changed files with 1406 additions and 21 deletions
+5
View File
@@ -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
@@ -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 $$;
+51
View File
@@ -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,
+11
View File
@@ -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()
+4
View File
@@ -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(),
};
}
}
+10
View File
@@ -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,
+21
View File
@@ -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()
+5
View File
@@ -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,
+20
View File
@@ -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(),
};
+34
View File
@@ -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>
+9
View File
@@ -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: 'فیلدهای سفارشی',
+6
View File
@@ -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',
+21
View File
@@ -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>
+233
View File
@@ -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>
+22
View File
@@ -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"
+24
View File
@@ -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" />
+66 -3
View File
@@ -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;
+49
View File
@@ -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
+81 -3
View File
@@ -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>;