From 647503810d52e439e21eae148c8f89b85701196f Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sun, 27 Sep 2026 21:17:42 +0330 Subject: [PATCH] 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. --- .../20260927200000_lead_logo/migration.sql | 2 + apps/api/prisma/schema.prisma | 2 + apps/api/src/leads/lead-stages.service.ts | 22 +- apps/api/src/leads/leads.controller.ts | 27 + apps/api/src/leads/leads.service.ts | 76 + apps/web/src/views/LeadsView.vue | 494 +- packages/shared/src/index.ts | 7739 +++++++++-------- 7 files changed, 4389 insertions(+), 3973 deletions(-) create mode 100644 apps/api/prisma/migrations/20260927200000_lead_logo/migration.sql diff --git a/apps/api/prisma/migrations/20260927200000_lead_logo/migration.sql b/apps/api/prisma/migrations/20260927200000_lead_logo/migration.sql new file mode 100644 index 0000000..738a59f --- /dev/null +++ b/apps/api/prisma/migrations/20260927200000_lead_logo/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "logoStorageKey" TEXT; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 1b03d3f..5e3a0a0 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -1967,6 +1967,8 @@ model Lead { trade String? source String? notes String? + /// لوگوی فروشگاه / شرکت + logoStorageKey String? estimatedValue Decimal? @db.Decimal(18, 2) stageId String stage LeadStage @relation(fields: [stageId], references: [id], onDelete: Restrict) diff --git a/apps/api/src/leads/lead-stages.service.ts b/apps/api/src/leads/lead-stages.service.ts index 963dad6..34ed70a 100644 --- a/apps/api/src/leads/lead-stages.service.ts +++ b/apps/api/src/leads/lead-stages.service.ts @@ -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)); } diff --git a/apps/api/src/leads/leads.controller.ts b/apps/api/src/leads/leads.controller.ts index 1a2a2a5..429590e 100644 --- a/apps/api/src/leads/leads.controller.ts +++ b/apps/api/src/leads/leads.controller.ts @@ -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, diff --git a/apps/api/src/leads/leads.service.ts b/apps/api/src/leads/leads.service.ts index cd2ff1f..7b3d4dc 100644 --- a/apps/api/src/leads/leads.service.ts +++ b/apps/api/src/leads/leads.service.ts @@ -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 = { + '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 { + 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 { + 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, + }; + } } diff --git a/apps/web/src/views/LeadsView.vue b/apps/web/src/views/LeadsView.vue index 135b940..f7b103a 100644 --- a/apps/web/src/views/LeadsView.vue +++ b/apps/web/src/views/LeadsView.vue @@ -42,6 +42,56 @@ type SortKey = 'updated' | 'newest' | 'follow_up' | 'last_contact'; type StageTint = 'mint' | 'amber' | 'peach' | 'sky' | 'violet' | 'rose' | 'teal'; type RowIcon = 'building' | 'bag' | 'briefcase' | 'users' | 'target'; +/** ردیف آمار صفحه لیدها — ترتیب و نام‌ها مطابق طرح */ +const LEAD_STAT_DEFS: Array<{ + label: string; + /** نام وضعیت‌های متناظر در دیتابیس */ + match: string[]; + tint: StageTint; + slot: 'pair' | 'solo'; +}> = [ + { + label: 'جدید', + match: ['جدید'], + tint: 'amber', + slot: 'pair', + }, + { + label: 'در حال پیگیری', + match: ['در حال پیگیری', 'پیگیری مجدد', 'تماس اولیه', 'تماس موفق'], + tint: 'peach', + slot: 'pair', + }, + { + label: 'مذاکره', + match: ['مذاکره'], + tint: 'mint', + slot: 'solo', + }, + { + label: 'آماده خرید', + match: ['آماده خرید'], + tint: 'violet', + slot: 'solo', + }, + { + label: 'عدم پاسخ', + match: ['عدم پاسخ'], + tint: 'rose', + slot: 'solo', + }, +]; + +type LeadStatCard = { + key: string; + label: string; + tint: StageTint; + count: number; + /** مرحله اصلی برای فیلتر */ + stageId: string; + slot: 'pair' | 'solo'; +}; + const route = useRoute(); const router = useRouter(); const auth = useAuthStore(); @@ -61,6 +111,11 @@ const sourceOptions = ref([]); const selected = ref>(new Set()); const sortOpen = ref(false); const rowMenuId = ref(null); +const logoMenuId = ref(null); +const logoUrls = ref>({}); +const logoBusyId = ref(null); +const logoFileInput = ref(null); +const logoTargetId = ref(null); const assignOpen = ref(false); const stageOpen = ref(false); const assignUserId = ref(''); @@ -141,19 +196,51 @@ const sortLabels: Record = { last_contact: 'آخرین تماس', }; -const stageCards = computed(() => { - const board = stages.value.filter( - (s) => s.showOnBoard && !s.isConverted && !s.isLost && !s.isUnqualified, - ); - return board.slice(0, 5); +const stageCards = computed((): LeadStatCard[] => { + return LEAD_STAT_DEFS.map((def) => { + const matched = stages.value.filter((s) => + def.match.some((name) => s.name.trim() === name), + ); + const count = matched.reduce((sum, s) => sum + (s.leadCount ?? 0), 0); + const primary = + matched.find((s) => s.name.trim() === def.match[0]) ?? matched[0]; + return { + key: def.label, + label: def.label, + tint: def.tint, + count, + stageId: primary?.id ?? '', + slot: def.slot, + }; + }); }); -const pairStages = computed(() => - stageCards.value.length >= 2 ? stageCards.value.slice(0, 2) : [], -); -const soloStages = computed(() => - stageCards.value.length >= 2 ? stageCards.value.slice(2) : stageCards.value, -); +const pairStatCards = computed(() => stageCards.value.filter((c) => c.slot === 'pair')); +const soloStatCards = computed(() => stageCards.value.filter((c) => c.slot === 'solo')); + +const totalLeadsCount = computed(() => { + if (stats.value?.total != null) return stats.value.total; + return stages.value.reduce((sum, s) => sum + (s.leadCount ?? 0), 0) || total.value; +}); + +function isStatActive(card: LeadStatCard) { + if (!card.stageId || !stageId.value) return false; + if (stageId.value === card.stageId) return true; + const def = LEAD_STAT_DEFS.find((d) => d.label === card.label); + if (!def) return false; + const matchedIds = stages.value + .filter((s) => def.match.some((name) => s.name.trim() === name)) + .map((s) => s.id); + return matchedIds.includes(stageId.value); +} + +function selectStatCard(card: LeadStatCard) { + if (!card.stageId) { + setStageFilter(''); + return; + } + setStageFilter(isStatActive(card) ? '' : card.stageId); +} const pageNumbers = computed(() => { const totalPages = pageCount.value; @@ -251,10 +338,119 @@ function lastActivity(lead: LeadDto) { function closePopups() { sortOpen.value = false; rowMenuId.value = null; + logoMenuId.value = null; assignOpen.value = false; stageOpen.value = false; } +function revokeLogoUrls(except?: Set) { + const next: Record = {}; + for (const [id, url] of Object.entries(logoUrls.value)) { + if (except?.has(id)) { + next[id] = url; + continue; + } + URL.revokeObjectURL(url); + } + logoUrls.value = next; +} + +async function loadLogos(items: LeadDto[]) { + const keep = new Set(items.filter((l) => l.hasLogo).map((l) => l.id)); + revokeLogoUrls(keep); + const pending = items.filter((l) => l.hasLogo && !logoUrls.value[l.id]); + await Promise.all( + pending.map(async (lead) => { + try { + const { data } = await api.get(`/leads/${lead.id}/logo`, { + responseType: 'blob', + }); + const url = URL.createObjectURL(data); + logoUrls.value = { ...logoUrls.value, [lead.id]: url }; + } catch { + /* بدون لوگو */ + } + }), + ); +} + +function openLogoPicker(leadId: string) { + logoMenuId.value = null; + logoTargetId.value = leadId; + logoFileInput.value?.click(); +} + +async function onLogoFile(ev: Event) { + const input = ev.target as HTMLInputElement; + const file = input.files?.[0]; + const leadId = logoTargetId.value; + input.value = ''; + logoTargetId.value = null; + if (!file || !leadId) return; + logoBusyId.value = leadId; + error.value = ''; + try { + const form = new FormData(); + form.append('file', file); + const { data: updated } = await api.post(`/leads/${leadId}/logo`, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + if (data.value) { + data.value = { + ...data.value, + items: data.value.items.map((l) => (l.id === leadId ? { ...l, hasLogo: true } : l)), + }; + } + const prev = logoUrls.value[leadId]; + if (prev) URL.revokeObjectURL(prev); + const { data: blob } = await api.get(`/leads/${leadId}/logo`, { + responseType: 'blob', + }); + logoUrls.value = { ...logoUrls.value, [leadId]: URL.createObjectURL(blob) }; + okMsg.value = `لوگوی «${updated.title}» ذخیره شد.`; + } catch (e) { + error.value = axios.isAxiosError(e) + ? apiErrorMessage(e, 'آپلود لوگو ناموفق بود.') + : 'آپلود لوگو ناموفق بود.'; + } finally { + logoBusyId.value = null; + } +} + +function apiErrorMessage(e: unknown, fallback: string) { + if (axios.isAxiosError(e)) { + const msg = (e.response?.data as { message?: string | string[] })?.message; + return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback); + } + return fallback; +} + +async function removeLeadLogo(leadId: string) { + logoMenuId.value = null; + if (!confirm('لوگوی این لید حذف شود؟')) return; + logoBusyId.value = leadId; + error.value = ''; + try { + await api.delete(`/leads/${leadId}/logo`); + const prev = logoUrls.value[leadId]; + if (prev) URL.revokeObjectURL(prev); + const next = { ...logoUrls.value }; + delete next[leadId]; + logoUrls.value = next; + if (data.value) { + data.value = { + ...data.value, + items: data.value.items.map((l) => (l.id === leadId ? { ...l, hasLogo: false } : l)), + }; + } + okMsg.value = 'لوگو حذف شد.'; + } catch { + error.value = 'حذف لوگو ناموفق بود.'; + } finally { + logoBusyId.value = null; + } +} + function patchQuery(next: Record) { const query = { ...route.query, ...next }; for (const key of Object.keys(query)) { @@ -352,6 +548,7 @@ async function load() { selected.value = new Set( [...selected.value].filter((id) => res.items.some((l) => l.id === id)), ); + void loadLogos(res.items); const { data: nextStats } = await api .get('/leads/stats') .catch(() => ({ data: stats.value })); @@ -762,7 +959,10 @@ onMounted(async () => { if (jobId) startImportPoll(jobId); }); -onBeforeUnmount(stopImportPoll); +onBeforeUnmount(() => { + stopImportPoll(); + revokeLogoUrls(); +});