From 7862b6f3741b6dfa55260b98596a798ccb38567b Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 5 Sep 2026 17:04:54 +0330 Subject: [PATCH] Redesign reports dashboard with full API-backed widgets. Co-authored-by: Cursor --- apps/api/src/reports/reports.service.ts | 495 +++++++++- apps/web/src/views/ReportsView.vue | 1188 +++++++++++++++++------ packages/shared/src/index.ts | 67 +- 3 files changed, 1433 insertions(+), 317 deletions(-) diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 2b1b83e..4241e7d 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -9,7 +9,7 @@ import { ownerWhere, ticketAccessWhere, } from '../common/record-scope'; -import { decimalToBase, loadOrgRates } from '../common/currency'; +import { decimalToBase, loadOrgRates, toBaseAmount } from '../common/currency'; import { addJalaliMonths, dateToJalali, @@ -29,24 +29,43 @@ export class ReportsService { const { start, end } = resolveRange(opts.from, opts.to); const orgId = actor.organizationId; const dealScope = ownerWhere(actor); + const invoiceScope = ownerWhere(actor); const activityScope = assigneeWhere(actor); const ticketScope = ticketAccessWhere(actor); + const personScope = elevated(actor.role) ? {} : { ownerId: actor.userId }; + + const durationMs = end.getTime() - start.getTime(); + const prevEnd = new Date(start.getTime() - 1); + const prevStart = new Date(prevEnd.getTime() - durationMs); + + const now = new Date(); + const { jy, jm } = dateToJalali(now); + const sixAgo = addJalaliMonths(jy, jm, -5); + const sixMonthStart = startOfJalaliMonth(sixAgo.jy, sixAgo.jm); + const dealEarliest = new Date( + Math.min(sixMonthStart.getTime(), prevStart.getTime(), start.getTime()), + ); const rates = await loadOrgRates(this.prisma, orgId); - const [deals, stages, activities, tickets, peopleCreated, users] = + const invoiceStatuses: Array<'issued' | 'partially_paid' | 'paid'> = [ + 'issued', + 'partially_paid', + 'paid', + ]; + + const [deals, stages, activities, tickets, people, users, invoices] = await Promise.all([ this.prisma.deal.findMany({ where: { organizationId: orgId, ...dealScope, OR: [ - { createdAt: { gte: start, lte: end } }, + { status: 'open' }, { status: { in: ['won', 'lost'] }, - updatedAt: { gte: start, lte: end }, + updatedAt: { gte: dealEarliest }, }, - { status: 'open' }, ], }, select: { @@ -56,8 +75,18 @@ export class ReportsService { currency: true, stageId: true, ownerId: true, + source: true, updatedAt: true, createdAt: true, + lineItems: { + select: { + name: true, + quantity: true, + unitPrice: true, + currency: true, + product: { select: { name: true, category: true } }, + }, + }, }, }), this.prisma.pipelineStage.findMany({ @@ -92,19 +121,78 @@ export class ReportsService { csatScore: true, }, }), - this.prisma.person.count({ + this.prisma.person.findMany({ where: { organizationId: orgId, - createdAt: { gte: start, lte: end }, - ...(elevated(actor.role) ? {} : { ownerId: actor.userId }), + ...personScope, }, + select: { mood: true, createdAt: true }, }), this.prisma.user.findMany({ where: { organizationId: orgId }, select: { id: true, fullName: true }, }), + this.prisma.invoice.findMany({ + where: { + organizationId: orgId, + ...invoiceScope, + status: { in: invoiceStatuses }, + OR: [ + { issuedAt: { gte: prevStart, lte: end } }, + { createdAt: { gte: prevStart, lte: end } }, + ], + }, + select: { + issuedAt: true, + createdAt: true, + lineItems: { + select: { + name: true, + quantity: true, + unitPrice: true, + currency: true, + product: { select: { name: true, category: true } }, + }, + }, + }, + }), ]); + const invoiceInRange = (inv: { + issuedAt: Date | null; + createdAt: Date; + }, from: Date, to: Date) => { + const anchor = inv.issuedAt ?? inv.createdAt; + return anchor >= from && anchor <= to; + }; + + const invoicesCur = invoices.filter((inv) => + invoiceInRange(inv, start, end), + ); + const invoicesPrev = invoices.filter((inv) => + invoiceInRange(inv, prevStart, prevEnd), + ); + + const sumInvoiceLines = ( + rows: typeof invoices, + ): { count: number; amount: number } => { + let amount = 0; + for (const inv of rows) { + for (const line of inv.lineItems) { + const lineTotal = Number(line.quantity) * Number(line.unitPrice); + amount += toBaseAmount(lineTotal, line.currency, rates); + } + } + return { count: rows.length, amount }; + }; + + const invCur = sumInvoiceLines(invoicesCur); + const invPrev = sumInvoiceLines(invoicesPrev); + const invoiceCount = invCur.count; + const invoiceAmount = Math.round(invCur.amount); + const invoiceCountPrev = invPrev.count; + const invoiceAmountPrev = Math.round(invPrev.amount); + const openDeals = deals.filter((d) => d.status === 'open'); const wonInPeriod = deals.filter( (d) => @@ -118,6 +206,12 @@ export class ReportsService { d.updatedAt >= start && d.updatedAt <= end, ); + const wonPrev = deals.filter( + (d) => + d.status === 'won' && + d.updatedAt >= prevStart && + d.updatedAt <= prevEnd, + ); const sumBase = ( rows: Array<{ amount: Prisma.Decimal | null; currency: string }>, @@ -127,9 +221,302 @@ export class ReportsService { 0, ); + const wonAmount = Math.round(sumBase(wonInPeriod)); + const wonAmountPrev = Math.round(sumBase(wonPrev)); const closedCount = wonInPeriod.length + lostInPeriod.length; const winRate = closedCount ? wonInPeriod.length / closedCount : 0; + const peopleCreated = people.filter( + (p) => p.createdAt >= start && p.createdAt <= end, + ).length; + const peopleCreatedPrev = people.filter( + (p) => p.createdAt >= prevStart && p.createdAt <= prevEnd, + ).length; + + const salesAmount = invoiceAmount > 0 ? invoiceAmount : wonAmount; + const salesAmountPrev = + invoiceAmountPrev > 0 ? invoiceAmountPrev : wonAmountPrev; + const avgInvoice = + invoiceCount > 0 ? Math.round(salesAmount / invoiceCount) : 0; + const avgInvoicePrev = + invoiceCountPrev > 0 + ? Math.round(salesAmountPrev / invoiceCountPrev) + : 0; + const avgSaleDenom = Math.max(invoiceCount || wonInPeriod.length, 1); + const avgSale = Math.round(salesAmount / avgSaleDenom); + const avgSalePrev = Math.round( + salesAmountPrev / + Math.max(invoiceCountPrev || wonPrev.length, 1), + ); + + const changeLabel = 'نسبت به دوره قبل'; + const kpis: ReportsDashboardDto['kpis'] = [ + { + key: 'new_customers', + label: 'مشتریان جدید', + value: peopleCreated, + format: 'number', + changePercent: pctChange(peopleCreated, peopleCreatedPrev), + changeLabel, + }, + { + key: 'invoice_count', + label: 'تعداد فاکتور', + value: invoiceCount, + format: 'number', + changePercent: pctChange(invoiceCount, invoiceCountPrev), + changeLabel, + }, + { + key: 'sales_amount', + label: 'مبلغ فروش', + value: salesAmount, + format: 'money', + changePercent: pctChange(salesAmount, salesAmountPrev), + changeLabel, + }, + { + key: 'avg_invoice', + label: 'میانگین فاکتور', + value: avgInvoice, + format: 'money', + changePercent: pctChange(avgInvoice, avgInvoicePrev), + changeLabel, + }, + { + key: 'avg_sale', + label: 'میانگین فروش', + value: avgSale, + format: 'money', + changePercent: pctChange(avgSale, avgSalePrev), + changeLabel, + }, + ]; + + const spanDays = + Math.ceil((end.getTime() - start.getTime()) / 86_400_000) || 1; + const salesTrend: ReportsDashboardDto['salesTrend'] = []; + if (spanDays <= 45) { + for (let i = 0; i < spanDays; i += 1) { + const dayStart = startOfDay( + new Date(start.getFullYear(), start.getMonth(), start.getDate() + i), + ); + const dayEnd = endOfDay(dayStart); + if (dayStart > end) break; + const value = Math.round( + sumBase( + wonInPeriod.filter( + (d) => d.updatedAt >= dayStart && d.updatedAt <= dayEnd, + ), + ), + ); + const key = `${dayStart.getFullYear()}-${String(dayStart.getMonth() + 1).padStart(2, '0')}-${String(dayStart.getDate()).padStart(2, '0')}`; + const j = dateToJalali(dayStart); + salesTrend.push({ + key, + label: `${j.jd} ${jalaliMonthName(j.jm)}`, + value, + }); + } + } else { + const buckets = new Map< + string, + { label: string; start: Date; end: Date; value: number } + >(); + for (const d of wonInPeriod) { + const j = dateToJalali(d.updatedAt); + const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`; + let bucket = buckets.get(key); + if (!bucket) { + bucket = { + label: `${jalaliMonthName(j.jm)} ${j.jy}`, + start: startOfJalaliMonth(j.jy, j.jm), + end: endOfJalaliMonth(j.jy, j.jm), + value: 0, + }; + buckets.set(key, bucket); + } + bucket.value += decimalToBase(d.amount, d.currency, rates); + } + const cursor = dateToJalali(start); + let cy = cursor.jy; + let cm = cursor.jm; + const endJ = dateToJalali(end); + while (cy < endJ.jy || (cy === endJ.jy && cm <= endJ.jm)) { + const key = `${cy}-${String(cm).padStart(2, '0')}`; + const existing = buckets.get(key); + salesTrend.push({ + key, + label: `${jalaliMonthName(cm)} ${cy}`, + value: Math.round(existing?.value ?? 0), + }); + const next = addJalaliMonths(cy, cm, 1); + cy = next.jy; + cm = next.jm; + } + } + + const monthlyComparison: ReportsDashboardDto['monthlyComparison'] = []; + const customerGrowth: ReportsDashboardDto['customerGrowth'] = []; + for (let i = 5; i >= 0; i -= 1) { + const m = addJalaliMonths(jy, jm, -i); + const mStart = startOfJalaliMonth(m.jy, m.jm); + const mEnd = endOfJalaliMonth(m.jy, m.jm); + const key = `${m.jy}-${String(m.jm).padStart(2, '0')}`; + const label = jalaliMonthName(m.jm); + monthlyComparison.push({ + key, + label, + value: Math.round( + sumBase( + deals.filter( + (d) => + d.status === 'won' && + d.updatedAt >= mStart && + d.updatedAt <= mEnd, + ), + ), + ), + }); + customerGrowth.push({ + key, + label, + value: people.filter( + (p) => p.createdAt >= mStart && p.createdAt <= mEnd, + ).length, + }); + } + + const sourceMap = new Map(); + for (const d of wonInPeriod) { + const label = normalizeSource(d.source); + sourceMap.set(label, (sourceMap.get(label) ?? 0) + 1); + } + const sourceTotal = + [...sourceMap.values()].reduce((a, b) => a + b, 0) || 1; + const sources: ReportsDashboardDto['sources'] = [...sourceMap.entries()] + .map(([label, count]) => ({ + label, + count, + percent: Math.round((count / sourceTotal) * 1000) / 10, + })) + .sort((a, b) => b.count - a.count); + + type LineAgg = { + name: string; + category: string; + quantity: number; + amount: number; + }; + const collectLines = ( + lines: Array<{ + name: string; + quantity: Prisma.Decimal; + unitPrice: Prisma.Decimal; + currency: string; + product: { name: string; category: string | null } | null; + }>, + ): LineAgg[] => + lines.map((line) => { + const qty = Number(line.quantity); + const amount = toBaseAmount( + qty * Number(line.unitPrice), + line.currency, + rates, + ); + return { + name: line.product?.name?.trim() || line.name, + category: line.product?.category?.trim() || 'سایر', + quantity: qty, + amount, + }; + }); + + const invoiceProductLines: LineAgg[] = []; + for (const inv of invoicesCur) { + invoiceProductLines.push(...collectLines(inv.lineItems)); + } + + let productLines = invoiceProductLines; + if (!productLines.length) { + productLines = []; + for (const d of wonInPeriod) { + productLines.push(...collectLines(d.lineItems)); + } + } + + const categoryMap = new Map(); + for (const line of invoiceProductLines) { + const cur = categoryMap.get(line.category) ?? { count: 0, amount: 0 }; + cur.count += 1; + cur.amount += line.amount; + categoryMap.set(line.category, cur); + } + const categoryTotal = + [...categoryMap.values()].reduce((a, b) => a + b.amount, 0) || 1; + const salesByCategory: ReportsDashboardDto['salesByCategory'] = [ + ...categoryMap.entries(), + ] + .map(([label, row]) => ({ + label, + count: row.count, + amount: Math.round(row.amount), + percent: Math.round((row.amount / categoryTotal) * 1000) / 10, + })) + .sort((a, b) => (b.amount ?? 0) - (a.amount ?? 0)); + + const productMap = new Map< + string, + { name: string; quantity: number; amount: number } + >(); + for (const line of productLines) { + const cur = productMap.get(line.name) ?? { + name: line.name, + quantity: 0, + amount: 0, + }; + cur.quantity += line.quantity; + cur.amount += line.amount; + productMap.set(line.name, cur); + } + const topProducts: ReportsDashboardDto['topProducts'] = [ + ...productMap.values(), + ] + .map((row) => ({ + name: row.name, + quantity: Math.round(row.quantity * 100) / 100, + amount: Math.round(row.amount), + })) + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + let statusNew = 0; + let statusActive = 0; + let statusPotential = 0; + let statusInactive = 0; + for (const p of people) { + const isNew = p.createdAt >= start && p.createdAt <= end; + if (isNew) { + statusNew += 1; + } else if (p.mood === 'happy' || p.mood === 'special') { + statusActive += 1; + } else if (p.mood === 'newcomer') { + statusPotential += 1; + } else { + statusInactive += 1; + } + } + const statusTotal = people.length || 1; + const customerStatus: ReportsDashboardDto['customerStatus'] = [ + { label: 'جدید', count: statusNew }, + { label: 'فعال', count: statusActive }, + { label: 'بالقوه', count: statusPotential }, + { label: 'غیرفعال', count: statusInactive }, + ].map((row) => ({ + ...row, + percent: Math.round((row.count / statusTotal) * 1000) / 10, + })); + const openByStage = new Map(); for (const d of openDeals) { const cur = openByStage.get(d.stageId) ?? { count: 0, amount: 0 }; @@ -149,7 +536,6 @@ export class ReportsService { }; }); - const now = new Date(); const activityOpen = activities.filter((a) => a.status === 'open').length; const activityDone = activities.filter((a) => a.status === 'done').length; const activityOverdue = activities.filter( @@ -203,26 +589,74 @@ export class ReportsService { ? csatScores.reduce((a, b) => a + b, 0) / csatScores.length : null; + const insights: string[] = []; + const hasData = + peopleCreated > 0 || + wonAmount > 0 || + invoiceAmount > 0 || + invoiceCount > 0 || + wonInPeriod.length > 0; + if (hasData) { + const salesChange = pctChange(salesAmount, salesAmountPrev); + if (salesChange != null) { + if (salesChange > 0) { + insights.push( + `مبلغ فروش نسبت به دوره قبل ${salesChange}٪ افزایش داشته است.`, + ); + } else if (salesChange < 0) { + insights.push( + `مبلغ فروش نسبت به دوره قبل ${Math.abs(salesChange)}٪ کاهش داشته است.`, + ); + } else { + insights.push('مبلغ فروش نسبت به دوره قبل تغییری نداشته است.'); + } + } + if (peopleCreated > 0) { + insights.push( + `${peopleCreated} مشتری جدید در این بازه ثبت شده است.`, + ); + } + if (topProducts[0]) { + insights.push( + `پرفروش‌ترین محصول «${topProducts[0].name}» با مبلغ ${topProducts[0].amount.toLocaleString('fa-IR')} است.`, + ); + } + if (wonInPeriod.length + lostInPeriod.length > 0) { + insights.push( + `نرخ برد معاملات در این دوره ${Math.round(winRate * 1000) / 10}٪ بوده است.`, + ); + } + if (invoiceCount > 0) { + insights.push( + `${invoiceCount} فاکتور صادر/پرداخت‌شده با مجموع ${invoiceAmount.toLocaleString('fa-IR')} ثبت شده است.`, + ); + } + if (sources[0] && sources[0].count > 0) { + insights.push( + `بیشترین منبع معاملات برنده «${sources[0].label}» بوده است.`, + ); + } + } + return { from: start.toISOString(), to: end.toISOString(), currency: rates.baseCurrency, - sales: { - openCount: openDeals.length, - wonCount: wonInPeriod.length, - lostCount: lostInPeriod.length, - openAmount: Math.round(sumBase(openDeals)), - wonAmount: Math.round(sumBase(wonInPeriod)), - lostAmount: Math.round(sumBase(lostInPeriod)), - winRate: Math.round(winRate * 1000) / 10, - }, - funnel, + kpis, + salesTrend, + monthlyComparison, + customerGrowth, + sources, + salesByCategory, + topProducts, + customerStatus, activities: { open: activityOpen, done: activityDone, overdue: activityOverdue, byType: [...byTypeMap.entries()].map(([type, count]) => ({ type, + label: activityTypeLabel(type), count, })), }, @@ -242,6 +676,17 @@ export class ReportsService { elevated(actor.role) || u.wonCount > 0 || u.activitiesDone > 0, ) .sort((a, b) => b.wonAmount - a.wonAmount), + insights: insights.slice(0, 5), + sales: { + openCount: openDeals.length, + wonCount: wonInPeriod.length, + lostCount: lostInPeriod.length, + openAmount: Math.round(sumBase(openDeals)), + wonAmount, + lostAmount: Math.round(sumBase(lostInPeriod)), + winRate: Math.round(winRate * 1000) / 10, + }, + funnel, tickets: { open: ticketOpen, resolved: ticketResolved, @@ -251,6 +696,8 @@ export class ReportsService { csatCount: csatScores.length, }, peopleCreated, + invoiceCount, + invoiceAmount, }; } @@ -604,3 +1051,13 @@ function normalizeSource(raw: string | null | undefined): string { }; return map[value.toLowerCase()] ?? value; } + +function activityTypeLabel(type: string): string { + const map: Record = { + call: 'تماس', + meeting: 'جلسه', + task: 'وظیفه', + note: 'یادداشت', + }; + return map[type] ?? type; +} diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index f4af333..6b4104c 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -1,36 +1,108 @@ @@ -70,143 +298,349 @@ onMounted(() => {

گزارشات و داشبورد

-

نمای یک‌صفحه‌ای فروش، فعالیت، تیم و پشتیبانی.

+

نمای عملکرد فروش، مشتریان، فعالیت‌ها و تیم

+
-
- - +
+
+ +
+ + +
+ + + + - - پرتال مشتری (/p/{{ org.slug }}) -

{{ error }}

در حال بارگذاری…

@@ -216,224 +650,408 @@ onMounted(() => { .page { display: grid; gap: 1rem; - min-width: 0; - overflow-x: clip; } - .page-head { display: flex; + flex-wrap: wrap; justify-content: space-between; - gap: 1rem; + gap: 0.75rem; align-items: flex-start; } - -.head-copy { - min-width: 0; -} - h1 { - margin: 0 0 0.3rem; - font-size: clamp(1.45rem, 2.5vw, 1.85rem); - font-weight: 800; -} - -h2 { - margin: 0 0 0.85rem; - font-size: 1.05rem; - color: #0f172a; -} - -.lead, -.muted { margin: 0; - color: #64748b; + font-size: 1.45rem; } - -.toolbar { - display: flex; - flex-wrap: wrap; - gap: 0.65rem; - align-items: end; - background: #fff; - border-radius: 18px; - padding: 0.95rem 1rem; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); +.lead { + margin: 0.25rem 0 0; + color: var(--muted); + font-size: 0.92rem; } - -.field { - display: grid; - gap: 0.3rem; - font-size: 0.85rem; - color: #64748b; - min-width: 0; -} - -.field span { - font-weight: 700; -} - -.toolbar :deep(.trigger) { - min-width: 11.5rem; -} - .btn { + border: 0; + border-radius: 12px; + padding: 0.6rem 0.95rem; + font-weight: 700; + cursor: pointer; display: inline-flex; align-items: center; - justify-content: center; - min-height: 42px; - padding: 0.55rem 1rem; - border-radius: 12px; - font-weight: 700; - border: 1px solid transparent; - cursor: pointer; - font: inherit; + gap: 0.35rem; } - .btn.primary { - background: #14b8a6; + background: var(--accent); color: #fff; - box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24); } - -.btn.primary:disabled { - opacity: 0.6; - cursor: not-allowed; +.filters { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + align-items: center; } - -.portal-link { - margin-inline-start: auto; - color: #0f766e; - font-size: 0.9rem; - font-weight: 700; - text-decoration: none; - align-self: center; +.period-chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; } - -.portal-link:hover { - color: #14b8a6; +.chip { + border: 1px solid var(--line); + background: var(--surface); + border-radius: 999px; + padding: 0.4rem 0.75rem; + cursor: pointer; + font-weight: 600; + color: #475569; +} +.chip.on { + background: var(--accent); + color: #fff; + border-color: transparent; +} +.range-btn { + border: 1px solid var(--line); + background: var(--surface); + border-radius: 12px; + padding: 0.45rem 0.75rem; + display: inline-flex; + align-items: center; + gap: 0.4rem; + cursor: pointer; + color: #334155; + font-weight: 600; +} +.custom-range { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: end; } - .kpis { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.75rem; } - -.kpis article { - background: #fff; - border-radius: 18px; - padding: 1rem 1.05rem; +.kpi { + background: var(--surface); + border: 1px solid #eef2f7; + border-radius: 16px; + padding: 0.85rem 0.95rem; + box-shadow: 0 8px 20px rgba(15, 23, 42, 0.03); display: grid; - gap: 0.25rem; - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); - min-width: 0; + gap: 0.35rem; } - -.kpis article.accent { - background: linear-gradient(135deg, #f0fdfa 0%, #fff 100%); - border: 1px solid #99f6e4; +.kpi-top { + display: flex; + align-items: center; + gap: 0.45rem; + color: var(--muted); + font-size: 0.82rem; } - -.kpis span, -.kpis small { - color: #64748b; - font-size: 0.85rem; +.kpi-ico { + width: 32px; + height: 32px; + border-radius: 10px; + background: var(--accent-soft); + color: var(--accent); + display: grid; + place-items: center; } - -.kpis strong { +.kpi-val { font-size: 1.15rem; color: #0f172a; } - -.kpis .accent strong { - color: #0f766e; +.kpi-change { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + align-items: baseline; + color: #16a34a; + font-size: 0.78rem; + font-weight: 700; +} +.kpi-change.down { + color: #dc2626; +} +.kpi-change.flat { + color: #94a3b8; +} +.kpi-change small { + font-weight: 500; + color: var(--muted); } - .grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0.85rem; } - .panel { - background: #fff; + background: var(--surface); + border: 1px solid #eef2f7; border-radius: 18px; - padding: 1rem 1.05rem; + padding: 1rem; box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); min-width: 0; } - -.table-wrap { - overflow-x: auto; - min-width: 0; +.panel.wide { + grid-column: 1 / -1; } - -table { +.panel-head { + margin-bottom: 0.75rem; +} +.panel-head h2 { + margin: 0; + font-size: 0.95rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; +} +.chart-wrap { + display: grid; + gap: 0.25rem; +} +.chart { width: 100%; - border-collapse: collapse; - font-size: 0.92rem; - min-width: 0; + height: auto; } - -th, -td { - text-align: start; - padding: 0.55rem 0.35rem; - border-bottom: 1px solid #f1f5f9; +.x-labels { + display: flex; + justify-content: space-between; + color: #94a3b8; + font-size: 0.72rem; + gap: 0.25rem; + overflow: hidden; } - -th { - font-size: 0.78rem; +.x-labels span { + white-space: nowrap; +} +.donut-wrap { + display: grid; + grid-template-columns: 132px 1fr; + gap: 0.75rem; + align-items: center; +} +.donut-box { + position: relative; + width: 132px; + height: 132px; +} +.donut { + width: 132px; + height: 132px; +} +.donut-center { + position: absolute; + inset: 0; + display: grid; + place-content: center; + text-align: center; + pointer-events: none; +} +.donut-center strong { + font-size: 1.1rem; +} +.donut-center strong.sm { + font-size: 0.72rem; + max-width: 90px; + line-height: 1.3; +} +.donut-center span { + font-size: 0.7rem; + color: #94a3b8; +} +.legend { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.4rem; + font-size: 0.8rem; +} +.legend li { + display: flex; + align-items: center; + gap: 0.4rem; +} +.legend i { + width: 8px; + height: 8px; + border-radius: 50%; +} +.legend span { + flex: 1; + color: #475569; +} +.legend em { + font-style: normal; color: #94a3b8; font-weight: 700; } - -.stats { +.vbars { + display: flex; + align-items: end; + justify-content: space-between; + gap: 0.35rem; + min-height: 150px; + padding-top: 0.5rem; +} +.vbar { + flex: 1; + display: grid; + justify-items: center; + gap: 0.35rem; +} +.vbar-col { + width: 70%; + max-width: 36px; + border-radius: 10px 10px 4px 4px; + background: linear-gradient(180deg, #2dd4bf, #0d9488); +} +.vbar span { + font-size: 0.7rem; + color: #94a3b8; +} +.hbars, +.act-bars, +.team, +.act-stats, +.insight-list { + list-style: none; margin: 0; padding: 0; - list-style: none; display: grid; - gap: 0.4rem; + gap: 0.55rem; +} +.hbars li, +.act-bars li { + display: grid; + grid-template-columns: 1fr 2fr auto; + gap: 0.5rem; + align-items: center; + font-size: 0.85rem; +} +.hb-name { + color: #334155; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hb-track { + height: 8px; + background: #f1f5f9; + border-radius: 999px; + overflow: hidden; +} +.hb-fill { + height: 100%; + border-radius: 999px; + background: var(--accent); +} +.hbars em, +.act-bars em { + font-style: normal; + font-weight: 700; + color: #64748b; + font-size: 0.8rem; +} +.team li { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 0.65rem; + align-items: center; +} +.avatar { + width: 36px; + height: 36px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + display: grid; + place-items: center; + font-weight: 800; +} +.team-meta strong { + display: block; + font-size: 0.88rem; +} +.team-meta small { + color: var(--muted); +} +.team em { + font-style: normal; + font-weight: 700; + font-size: 0.82rem; +} +.act-stats { + grid-template-columns: repeat(3, 1fr); + margin-bottom: 0.5rem; +} +.act-stats li { + background: #f8fafc; + border-radius: 12px; + padding: 0.55rem; + display: grid; + gap: 0.2rem; +} +.act-stats span { + font-size: 0.75rem; + color: var(--muted); +} +.insights .insight-list li { + padding: 0.55rem 0.7rem; + background: #f8fafc; + border-radius: 10px; + font-size: 0.88rem; + line-height: 1.6; color: #334155; } - +.suggest { + margin-top: 0.75rem; + display: flex; + gap: 0.45rem; + align-items: flex-start; + color: #0f766e; + background: var(--accent-soft); + border-radius: 12px; + padding: 0.65rem 0.75rem; + font-size: 0.85rem; +} +.empty, +.muted { + color: var(--muted); + font-size: 0.9rem; +} .error { - margin: 0; color: #b91c1c; } - -@media (max-width: 900px) { +.mob-only { + display: none; +} +@media (max-width: 1100px) { + .kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} +@media (max-width: 800px) { .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .grid { grid-template-columns: 1fr; } - - .portal-link { - margin-inline-start: 0; - width: 100%; + .panel.wide { + grid-column: auto; } -} - -@media (max-width: 700px) { - .kpis { + .donut-wrap { grid-template-columns: 1fr; + justify-items: center; } - - .toolbar { - flex-direction: column; - align-items: stretch; + .desk-only { + display: none; } - - .field :deep(.trigger) { - width: 100%; - min-width: 0; + .mob-only { + display: inline-flex; } - - .btn.primary { - width: 100%; + .hbars li, + .act-bars li { + grid-template-columns: 1fr; } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f049191..1f99c23 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1301,10 +1301,62 @@ export interface PortalTicketDto { /* ——— گزارشات و داشبورد ——— */ +export interface ReportsKpiDto { + key: string; + label: string; + value: number; + format: "money" | "number"; + changePercent: number | null; + changeLabel: string; +} + +export interface ReportsSeriesPointDto { + key: string; + label: string; + value: number; +} + +export interface ReportsSliceDto { + label: string; + count: number; + percent: number; + amount?: number; +} + +export interface ReportsTopProductDto { + name: string; + quantity: number; + amount: number; +} + +export interface ReportsTeamRowDto { + userId: string; + fullName: string; + wonAmount: number; + wonCount: number; + activitiesDone: number; +} + export interface ReportsDashboardDto { from: string; to: string; currency: string; + kpis: ReportsKpiDto[]; + salesTrend: ReportsSeriesPointDto[]; + monthlyComparison: ReportsSeriesPointDto[]; + customerGrowth: ReportsSeriesPointDto[]; + sources: ReportsSliceDto[]; + salesByCategory: ReportsSliceDto[]; + topProducts: ReportsTopProductDto[]; + customerStatus: ReportsSliceDto[]; + activities: { + open: number; + done: number; + overdue: number; + byType: Array<{ type: string; label: string; count: number }>; + }; + team: ReportsTeamRowDto[]; + insights: string[]; sales: { openCount: number; wonCount: number; @@ -1321,19 +1373,6 @@ export interface ReportsDashboardDto { count: number; amount: number; }>; - activities: { - open: number; - done: number; - overdue: number; - byType: Array<{ type: string; count: number }>; - }; - team: Array<{ - userId: string; - fullName: string; - wonAmount: number; - wonCount: number; - activitiesDone: number; - }>; tickets: { open: number; resolved: number; @@ -1343,6 +1382,8 @@ export interface ReportsDashboardDto { csatCount: number; }; peopleCreated: number; + invoiceCount: number; + invoiceAmount: number; } export interface HomeDashboardKpiDto {