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
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Lead" ADD COLUMN IF NOT EXISTS "logoStorageKey" TEXT;
+2
View File
@@ -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)
+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,
};
}
}
+400 -94
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff