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:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "logoStorageKey" TEXT;
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const selected = ref<Set<string>>(new Set());
|
||||
const sortOpen = ref(false);
|
||||
const rowMenuId = ref<string | null>(null);
|
||||
const logoMenuId = ref<string | null>(null);
|
||||
const logoUrls = ref<Record<string, string>>({});
|
||||
const logoBusyId = ref<string | null>(null);
|
||||
const logoFileInput = ref<HTMLInputElement | null>(null);
|
||||
const logoTargetId = ref<string | null>(null);
|
||||
const assignOpen = ref(false);
|
||||
const stageOpen = ref(false);
|
||||
const assignUserId = ref('');
|
||||
@@ -141,19 +196,51 @@ const sortLabels: Record<SortKey, string> = {
|
||||
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<string>) {
|
||||
const next: Record<string, string> = {};
|
||||
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<Blob>(`/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<LeadDto>(`/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<Blob>(`/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<string, string | undefined>) {
|
||||
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<LeadStatsDto>('/leads/stats')
|
||||
.catch(() => ({ data: stats.value }));
|
||||
@@ -762,7 +959,10 @@ onMounted(async () => {
|
||||
if (jobId) startImportPoll(jobId);
|
||||
});
|
||||
|
||||
onBeforeUnmount(stopImportPoll);
|
||||
onBeforeUnmount(() => {
|
||||
stopImportPoll();
|
||||
revokeLogoUrls();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -792,6 +992,13 @@ onBeforeUnmount(stopImportPoll);
|
||||
hidden
|
||||
@change="onImportFile"
|
||||
/>
|
||||
<input
|
||||
ref="logoFileInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif,.jpg,.jpeg,.png,.webp,.gif"
|
||||
hidden
|
||||
@change="onLogoFile"
|
||||
/>
|
||||
<PhoneContactsImport
|
||||
target="leads"
|
||||
:disabled="busy"
|
||||
@@ -822,41 +1029,43 @@ onBeforeUnmount(stopImportPoll);
|
||||
@click="setStageFilter('')"
|
||||
>
|
||||
<span class="stat-total-ico" aria-hidden="true">
|
||||
<UiIcon name="users" :size="18" />
|
||||
<UiIcon name="users" :size="20" />
|
||||
</span>
|
||||
<span class="stat-total-body">
|
||||
<span class="stat-label">همه لیدها</span>
|
||||
<strong class="stat-num">{{ formatCount(stats?.total ?? total) }}</strong>
|
||||
<strong class="stat-num">{{ formatCount(totalLeadsCount) }}</strong>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div v-if="pairStages.length" class="stat-pair" role="listitem">
|
||||
<div class="stat-pair" role="group" aria-label="وضعیتهای پیگیری">
|
||||
<button
|
||||
v-for="s in pairStages"
|
||||
:key="s.id"
|
||||
v-for="card in pairStatCards"
|
||||
:key="card.key"
|
||||
type="button"
|
||||
class="stat-cell"
|
||||
:class="{ on: stageId === s.id }"
|
||||
@click="setStageFilter(s.id)"
|
||||
:class="{ on: isStatActive(card), wide: card.key === 'در حال پیگیری' }"
|
||||
:disabled="!card.stageId"
|
||||
@click="selectStatCard(card)"
|
||||
>
|
||||
<i class="stat-dot" :data-tint="stageTint(s.color)" aria-hidden="true" />
|
||||
<span class="stat-label">{{ s.name }}</span>
|
||||
<strong class="stat-num">{{ formatCount(s.leadCount ?? 0) }}</strong>
|
||||
<i class="stat-dot" :data-tint="card.tint" aria-hidden="true" />
|
||||
<span class="stat-label">{{ card.label }}</span>
|
||||
<strong class="stat-num">{{ formatCount(card.count) }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-for="s in soloStages"
|
||||
:key="s.id"
|
||||
v-for="card in soloStatCards"
|
||||
:key="card.key"
|
||||
type="button"
|
||||
class="stat-cell alone"
|
||||
:class="{ on: stageId === s.id }"
|
||||
:class="{ on: isStatActive(card) }"
|
||||
role="listitem"
|
||||
@click="setStageFilter(s.id)"
|
||||
:disabled="!card.stageId"
|
||||
@click="selectStatCard(card)"
|
||||
>
|
||||
<i class="stat-dot" :data-tint="stageTint(s.color)" aria-hidden="true" />
|
||||
<span class="stat-label">{{ s.name }}</span>
|
||||
<strong class="stat-num">{{ formatCount(s.leadCount ?? 0) }}</strong>
|
||||
<i class="stat-dot" :data-tint="card.tint" aria-hidden="true" />
|
||||
<span class="stat-label">{{ card.label }}</span>
|
||||
<strong class="stat-num">{{ formatCount(card.count) }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1010,10 +1219,42 @@ onBeforeUnmount(stopImportPoll);
|
||||
class="row-click"
|
||||
@click="goDetail(lead.id)"
|
||||
>
|
||||
<td class="icon-col">
|
||||
<span class="row-ico" :data-tint="stageTintForLead(lead)">
|
||||
<UiIcon :name="rowIcon(lead)" :size="16" />
|
||||
</span>
|
||||
<td class="icon-col" @click.stop>
|
||||
<UiDropdownMenu
|
||||
:open="logoMenuId === lead.id"
|
||||
align="start"
|
||||
@update:open="(v) => (logoMenuId = v ? lead.id : null)"
|
||||
>
|
||||
<template #trigger="{ toggle }">
|
||||
<button
|
||||
type="button"
|
||||
class="row-ico"
|
||||
:class="{ busy: logoBusyId === lead.id }"
|
||||
:data-tint="stageTintForLead(lead)"
|
||||
:title="lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو'"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<img
|
||||
v-if="logoUrls[lead.id]"
|
||||
:src="logoUrls[lead.id]"
|
||||
:alt="lead.title"
|
||||
class="row-logo"
|
||||
/>
|
||||
<UiIcon v-else :name="rowIcon(lead)" :size="16" />
|
||||
</button>
|
||||
</template>
|
||||
<button type="button" @click="openLogoPicker(lead.id)">
|
||||
{{ lead.hasLogo ? 'تغییر لوگو' : 'افزودن لوگو' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="lead.hasLogo"
|
||||
type="button"
|
||||
class="danger-item"
|
||||
@click="removeLeadLogo(lead.id)"
|
||||
>
|
||||
حذف لوگو
|
||||
</button>
|
||||
</UiDropdownMenu>
|
||||
</td>
|
||||
<td>
|
||||
<strong class="name">{{ lead.title }}</strong>
|
||||
@@ -1313,37 +1554,44 @@ onBeforeUnmount(stopImportPoll);
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 0.75rem;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-total {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 9.5rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 14px;
|
||||
gap: 0.7rem;
|
||||
flex: 1.15 1 0;
|
||||
min-width: 8.5rem;
|
||||
min-height: 5.25rem;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 12px;
|
||||
background: #ecfdf5;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: start;
|
||||
color: #047857;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
box-shadow: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.stat-total:hover,
|
||||
.stat-total.on {
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.22);
|
||||
border-color: #86efac;
|
||||
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
|
||||
border-color: #6ee7b7;
|
||||
}
|
||||
|
||||
.stat-total-ico {
|
||||
width: 2.4rem;
|
||||
height: 2.4rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: #10b981;
|
||||
@@ -1351,52 +1599,75 @@ onBeforeUnmount(stopImportPoll);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.06);
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||
order: 0;
|
||||
}
|
||||
|
||||
.stat-total-body {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-total .stat-label {
|
||||
color: #059669;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-total .stat-num {
|
||||
color: #047857;
|
||||
font-size: 1.35rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.15;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-pair {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex: 1 1 14rem;
|
||||
min-width: 12rem;
|
||||
flex: 2.2 1 0;
|
||||
min-width: 14rem;
|
||||
min-height: 5.25rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell {
|
||||
flex: 1 1 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
box-shadow: none !important;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell.wide {
|
||||
flex: 1.45 1 0;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell + .stat-cell {
|
||||
border-inline-start: 1px solid #e5e7eb;
|
||||
border-inline-start: 1px solid #e5e7eb !important;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell:hover:not(:disabled) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.stat-pair .stat-cell.on {
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
|
||||
}
|
||||
|
||||
.stat-pair:has(.stat-cell.on) {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, #e5e7eb);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
@@ -1405,82 +1676,85 @@ onBeforeUnmount(stopImportPoll);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
min-width: 6.5rem;
|
||||
padding: 0.85rem 0.9rem 0.75rem;
|
||||
gap: 0.12rem;
|
||||
min-width: 5.5rem;
|
||||
min-height: 5.25rem;
|
||||
padding: 1.15rem 0.65rem 0.7rem;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 14px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: #0f172a;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
transition: box-shadow 0.15s, border-color 0.15s;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.stat-cell.alone {
|
||||
flex: 0 1 7.5rem;
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.stat-cell:hover,
|
||||
.stat-cell.on {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, #e5e7eb);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
.stat-cell:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stat-pair:has(.stat-cell.on) {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, #e5e7eb);
|
||||
.stat-cell.alone:hover:not(:disabled),
|
||||
.stat-cell.alone.on {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, #e5e7eb);
|
||||
box-shadow: 0 0 0 2px var(--accent-soft);
|
||||
}
|
||||
|
||||
.stat-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
position: absolute;
|
||||
top: 0.65rem;
|
||||
left: 0.7rem;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
margin-bottom: 0.15rem;
|
||||
background: #94a3b8;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, #94a3b8 18%, transparent);
|
||||
box-shadow: 0 0 0 4px rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.stat-dot[data-tint='mint'],
|
||||
.stat-dot[data-tint='teal'] {
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.18);
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
.stat-dot[data-tint='amber'] {
|
||||
background: #f59e0b;
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
|
||||
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.22);
|
||||
}
|
||||
.stat-dot[data-tint='peach'] {
|
||||
background: #f97316;
|
||||
box-shadow: 0 0 0 4px rgba(249, 115, 22, 0.18);
|
||||
background: #fbbf24;
|
||||
box-shadow: 0 0 0 4px rgba(251, 191, 36, 0.22);
|
||||
}
|
||||
.stat-dot[data-tint='sky'] {
|
||||
background: #0ea5e9;
|
||||
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.18);
|
||||
box-shadow: 0 0 0 4px rgba(14, 165, 233, 0.2);
|
||||
}
|
||||
.stat-dot[data-tint='violet'] {
|
||||
background: #8b5cf6;
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.18);
|
||||
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.22);
|
||||
}
|
||||
.stat-dot[data-tint='rose'] {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.18);
|
||||
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.stat-cell .stat-label {
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
color: #64748b;
|
||||
color: #334155;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.stat-cell .stat-num {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.3rem;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
@@ -1488,14 +1762,24 @@ onBeforeUnmount(stopImportPoll);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.stat-pair {
|
||||
flex: 1 1 100%;
|
||||
@media (max-width: 900px) {
|
||||
.stats {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-cell.alone,
|
||||
.stat-total {
|
||||
.stat-total,
|
||||
.stat-pair,
|
||||
.stat-cell.alone {
|
||||
flex: 1 1 calc(50% - 0.4rem);
|
||||
min-width: 9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.stat-total,
|
||||
.stat-pair,
|
||||
.stat-cell.alone {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1727,14 +2011,36 @@ onBeforeUnmount(stopImportPoll);
|
||||
}
|
||||
|
||||
.row-ico {
|
||||
width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
width: 2.35rem;
|
||||
height: 2.35rem;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.row-ico.busy {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.row-logo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.row-ico:has(.row-logo) {
|
||||
background: #fff;
|
||||
border: 1px solid var(--line, #e5e7eb);
|
||||
}
|
||||
|
||||
.row-ico[data-tint='amber'] {
|
||||
|
||||
+3870
-3869
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user