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:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user