Add lead company logos and refine Leads stats cards.

Leads can upload/change/remove a logo shown in the list; stats use fixed stage labels with accurate non-archived counts.
This commit is contained in:
2026-09-27 21:17:42 +03:30
parent ec5c7639e3
commit 647503810d
7 changed files with 4389 additions and 3973 deletions
+12 -10
View File
@@ -9,15 +9,11 @@ const DEFAULT_STAGES: Array<{
isLost?: boolean;
isUnqualified?: boolean;
}> = [
{ name: 'جدید', color: 'sky' },
{ name: 'تماس اولیه', color: 'teal' },
{ name: 'عدم پاسخ', color: 'amber' },
{ name: 'پیگیری مجدد', color: 'amber' },
{ name: 'تماس موفق', color: 'mint' },
{ name: 'علاقه‌مند', color: 'violet' },
{ name: 'واجد شرایط', color: 'violet' },
{ name: 'مذاکره', color: 'peach' },
{ name: 'آماده خرید', color: 'rose' },
{ name: 'جدید', color: 'amber' },
{ name: 'در حال پیگیری', color: 'peach' },
{ name: 'عدم پاسخ', color: 'rose' },
{ name: 'مذاکره', color: 'mint' },
{ name: 'آماده خرید', color: 'violet' },
{ name: 'تبدیل‌شده', color: 'teal', isConverted: true },
{ name: 'از دست رفته', color: 'rose', isLost: true },
{ name: 'نامرتبط', color: 'amber', isUnqualified: true },
@@ -51,7 +47,13 @@ export class LeadStagesService {
const rows = await this.prisma.leadStage.findMany({
where: { organizationId },
orderBy: { position: 'asc' },
include: { _count: { select: { leads: true } } },
include: {
_count: {
select: {
leads: { where: { archivedAt: null } },
},
},
},
});
return rows.map((r) => this.toDto(r));
}
+27
View File
@@ -15,6 +15,7 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import ExcelJS from 'exceljs';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -295,6 +296,32 @@ export class LeadsController {
return this.leads.get(user, id);
}
@Get(':id/logo')
async logo(@CurrentUser() user: Actor, @Param('id') id: string) {
const file = await this.leads.openLogo(user, id);
return new StreamableFile(file.stream, { type: file.mimeType });
}
@Post(':id/logo')
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 },
}),
)
uploadLogo(
@CurrentUser() user: Actor,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.leads.saveLogo(user, id, file);
}
@Delete(':id/logo')
removeLogo(@CurrentUser() user: Actor, @Param('id') id: string) {
return this.leads.removeLogo(user, id);
}
@Patch(':id')
update(
@CurrentUser() user: Actor,
+76
View File
@@ -5,6 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as path from 'node:path';
import type {
ConvertLeadResult,
LeadBoardColumnDto,
@@ -20,8 +21,18 @@ import {
elevated,
} from '../common/record-scope';
import { emptyToNull, normalizePhone } from '../contacts/normalize';
import { LocalFileStorage } from '../storage/local-file.storage';
import { LeadStagesService } from './lead-stages.service';
const LOGO_MAX_BYTES = 2 * 1024 * 1024;
const LOGO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
function mobileKey(raw: string | null | undefined): string | null {
const n = normalizePhone(raw);
if (!n) return null;
@@ -57,6 +68,7 @@ export class LeadsService {
constructor(
private readonly prisma: PrismaService,
private readonly stages: LeadStagesService,
private readonly files: LocalFileStorage,
) {}
async list(
@@ -994,8 +1006,72 @@ export class LeadsService {
convertedAt: row.convertedAt?.toISOString() ?? null,
archivedAt: row.archivedAt?.toISOString() ?? null,
daysInStage,
hasLogo: Boolean(row.logoStorageKey),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
async saveLogo(
actor: Actor,
id: string,
file: Express.Multer.File | undefined,
): Promise<LeadDto> {
if (!file?.buffer?.length) {
throw new BadRequestException('فایل تصویر ارسال نشده');
}
if (file.size > LOGO_MAX_BYTES) {
throw new BadRequestException('حجم تصویر باید کمتر از ۲ مگابایت باشد');
}
const mime = (file.mimetype || '').toLowerCase();
const ext = LOGO_EXT[mime];
if (!ext) {
throw new BadRequestException('فقط تصویر JPG، PNG، WebP یا GIF مجاز است');
}
const existing = await this.findAccessible(actor, id);
const prevKey = existing.logoStorageKey;
const storageKey = path
.join(actor.organizationId, 'leads', id, `logo.${ext}`)
.replace(/\\/g, '/');
await this.files.save(storageKey, file.buffer);
if (prevKey && prevKey !== storageKey) {
await this.files.remove(prevKey);
}
const row = await this.prisma.lead.update({
where: { id },
data: { logoStorageKey: storageKey },
include: leadInclude,
});
await this.addEvent(actor, id, 'note', 'لوگو به‌روز شد', null);
return this.toDto(row);
}
async removeLogo(actor: Actor, id: string): Promise<LeadDto> {
const existing = await this.findAccessible(actor, id);
if (existing.logoStorageKey) {
await this.files.remove(existing.logoStorageKey);
}
const row = await this.prisma.lead.update({
where: { id },
data: { logoStorageKey: null },
include: leadInclude,
});
return this.toDto(row);
}
async openLogo(actor: Actor, id: string) {
const existing = await this.findAccessible(actor, id);
if (!existing.logoStorageKey) {
throw new NotFoundException('لوگو تنظیم نشده');
}
const storageKey = existing.logoStorageKey;
const ext = path.extname(storageKey).slice(1).toLowerCase();
const mime =
Object.entries(LOGO_EXT).find(([, value]) => value === ext)?.[0] ??
'image/jpeg';
return {
stream: this.files.openReadStream(storageKey),
mimeType: mime,
};
}
}