cheng dashbord data and ui
This commit is contained in:
@@ -3,22 +3,35 @@ import { AdminDashboardService } from './admin-dashboard.service';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* GET get-analytics -> views/DashboardECommerce.vue (wholesale)
|
||||
* GET get-analytics-tak -> views/DashboardAnalytics.vue (retail)
|
||||
* Legacy shipped two dashboards behind two rids:
|
||||
* GET get-analytics -> views/DashboardECommerce.vue (wholesale), dashboard.omde
|
||||
* GET get-analytics-tak -> views/DashboardAnalytics.vue (retail), dashboard.tak
|
||||
*
|
||||
* They are now one screen ("پیشخوان") behind one rid (dashboard.view), so this
|
||||
* is one endpoint returning the union of both plus the new chart aggregates.
|
||||
* `omde()`/`tak()` stay as thin aliases to the old paths so nothing that still
|
||||
* calls get-analytics / get-analytics-tak (e.g. a cached branch build) 404s.
|
||||
*/
|
||||
@Controller('admin/dashboard')
|
||||
export class AdminDashboardController {
|
||||
constructor(private readonly dashboard: AdminDashboardService) {}
|
||||
|
||||
@Get('overview')
|
||||
@RequirePermission('dashboard.view')
|
||||
overview() {
|
||||
return this.dashboard.overview();
|
||||
}
|
||||
|
||||
/** @deprecated superseded by GET overview; kept as an alias. */
|
||||
@Get('omde')
|
||||
@RequirePermission('dashboard.omde')
|
||||
@RequirePermission('dashboard.view')
|
||||
omde() {
|
||||
return this.dashboard.omdeAnalytics();
|
||||
}
|
||||
|
||||
/** @deprecated superseded by GET overview; kept as an alias. */
|
||||
@Get('tak')
|
||||
@RequirePermission('dashboard.tak')
|
||||
@RequirePermission('dashboard.view')
|
||||
tak() {
|
||||
return this.dashboard.takAnalytics();
|
||||
}
|
||||
|
||||
@@ -15,6 +15,11 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
* - "سفارشات هفته جاری": this week's orders
|
||||
*
|
||||
* So this returns exactly those figures rather than the template's demo series.
|
||||
*
|
||||
* The two dashboards (عمده/تک) were merged into one screen behind one rid
|
||||
* (dashboard.view) — see `overview()`, which returns the union of both legacy
|
||||
* payloads (nothing dropped) plus new chart aggregates built from data that
|
||||
* already exists (order channel, status, GoldPriceQuote, customer debt).
|
||||
*/
|
||||
@Injectable()
|
||||
export class AdminDashboardService {
|
||||
@@ -31,6 +36,138 @@ export class AdminDashboardService {
|
||||
status: { in: [OrderStatus.DELIVERED_TO_AGENT, OrderStatus.DELIVERED_TO_CUSTOMER] },
|
||||
};
|
||||
|
||||
/** The single merged dashboard endpoint (`GET admin/dashboard/overview`). */
|
||||
async overview() {
|
||||
const since30 = this.daysAgo(30);
|
||||
const since7 = this.daysAgo(7);
|
||||
|
||||
const [
|
||||
omde,
|
||||
tak,
|
||||
ordersLast30,
|
||||
statusGroups,
|
||||
channelGroups,
|
||||
topProductRows,
|
||||
latestGoldQuote,
|
||||
debtAgg,
|
||||
] = await Promise.all([
|
||||
this.omdeAnalytics(),
|
||||
this.takAnalytics(),
|
||||
|
||||
// روند سفارشها در ۳۰ روز گذشته — one row per order, bucketed by day below.
|
||||
this.prisma.order.findMany({
|
||||
where: { createdAt: { gte: since30 } },
|
||||
select: { createdAt: true, channel: true },
|
||||
}),
|
||||
|
||||
// دونات وضعیت سفارشات (۳۰ روز گذشته)
|
||||
this.prisma.order.groupBy({
|
||||
by: ['status'],
|
||||
where: { createdAt: { gte: since30 } },
|
||||
_count: true,
|
||||
}),
|
||||
|
||||
// تفکیک عمده/تک (۳۰ روز گذشته)
|
||||
this.prisma.order.groupBy({
|
||||
by: ['channel'],
|
||||
where: { createdAt: { gte: since30 } },
|
||||
_count: true,
|
||||
_sum: { totalGoldWeightGrams: true },
|
||||
}),
|
||||
|
||||
// محصولات پرفروش (۳۰ روز گذشته) — by shipped weight, top 6.
|
||||
this.prisma.orderItem.findMany({
|
||||
where: { order: { createdAt: { gte: since30 } } },
|
||||
select: {
|
||||
quantity: true,
|
||||
computedWeightGrams: true,
|
||||
productVariant: { select: { product: { select: { id: true, title: true } } } },
|
||||
},
|
||||
}),
|
||||
|
||||
// نرخ فعلی طلا — legacy's goldPrice widget (18 عیار / 750‰).
|
||||
this.prisma.goldPriceQuote.findFirst({
|
||||
where: { karatPerMille: 750 },
|
||||
orderBy: { quotedAt: 'desc' },
|
||||
}),
|
||||
|
||||
// مجموع بدهی مشتریان (گرم طلا) — cached balance, see Customer.goldDebtGrams.
|
||||
this.prisma.customer.aggregate({ _sum: { goldDebtGrams: true } }),
|
||||
]);
|
||||
|
||||
// Bucket the 30-day order list into day-strings client-side: Prisma has no
|
||||
// portable "group by day" across the sqlite/postgres split this project
|
||||
// supports, and 30 days of order rows is tiny.
|
||||
const trendByDay = new Map<string, { total: number; retail: number; wholesale: number }>();
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
trendByDay.set(this.dayKey(this.daysAgo(i)), { total: 0, retail: 0, wholesale: 0 });
|
||||
}
|
||||
for (const o of ordersLast30) {
|
||||
const key = this.dayKey(o.createdAt);
|
||||
const bucket = trendByDay.get(key);
|
||||
if (!bucket) continue; // safety net; every key in range was pre-seeded above
|
||||
bucket.total += 1;
|
||||
if (o.channel === 'WHOLESALE') bucket.wholesale += 1;
|
||||
else bucket.retail += 1;
|
||||
}
|
||||
const ordersTrend = [...trendByDay.entries()].map(([date, v]) => ({ date, ...v }));
|
||||
|
||||
const statusLabels: Record<OrderStatus, string> = {
|
||||
SUSPENDED: 'معلق (سقف اعتبار)',
|
||||
PLACED: 'ثبت شده',
|
||||
ADMIN_APPROVED: 'تایید مدیر',
|
||||
SOURCING: 'در حال تامین',
|
||||
PREPARING: 'در حال آمادهسازی',
|
||||
DELIVERED_TO_AGENT: 'تحویل به نماینده',
|
||||
DELIVERED_TO_CUSTOMER: 'تحویل به مشتری',
|
||||
CANCELLED: 'لغو شده',
|
||||
};
|
||||
const statusBreakdown = statusGroups.map((g) => ({
|
||||
status: g.status,
|
||||
label: statusLabels[g.status],
|
||||
count: g._count,
|
||||
}));
|
||||
|
||||
const channelLabels: Record<string, string> = { RETAIL: 'تک فروشی', WHOLESALE: 'عمده فروشی' };
|
||||
const channelBreakdown = channelGroups.map((g) => ({
|
||||
channel: g.channel,
|
||||
label: channelLabels[g.channel] ?? g.channel,
|
||||
count: g._count,
|
||||
grams: g._sum.totalGoldWeightGrams ?? new Prisma.Decimal(0),
|
||||
}));
|
||||
|
||||
const productTotals = new Map<string, { title: string; weight: Prisma.Decimal; qty: number }>();
|
||||
for (const it of topProductRows) {
|
||||
const p = it.productVariant.product;
|
||||
const row = productTotals.get(p.id) ?? { title: p.title, weight: new Prisma.Decimal(0), qty: 0 };
|
||||
row.weight = row.weight.add(it.computedWeightGrams.mul(it.quantity));
|
||||
row.qty += it.quantity;
|
||||
productTotals.set(p.id, row);
|
||||
}
|
||||
const topProducts = [...productTotals.values()]
|
||||
.sort((a, b) => Number(b.weight) - Number(a.weight))
|
||||
.slice(0, 6);
|
||||
|
||||
return {
|
||||
// ── everything the two legacy dashboards returned, unchanged ──────
|
||||
...omde,
|
||||
takLastOrder: tak.lastOrder,
|
||||
|
||||
// ── new chart data ─────────────────────────────────────────────
|
||||
ordersTrend,
|
||||
statusBreakdown,
|
||||
channelBreakdown,
|
||||
topProducts,
|
||||
goldPricePerGram: latestGoldQuote?.pricePerGramRial ?? null,
|
||||
goldPriceQuotedAt: latestGoldQuote?.quotedAt ?? null,
|
||||
totalCustomerDebtGrams: debtAgg._sum.goldDebtGrams ?? new Prisma.Decimal(0),
|
||||
};
|
||||
}
|
||||
|
||||
private dayKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** Legacy `GET get-analytics` — the wholesale dashboard. */
|
||||
async omdeAnalytics() {
|
||||
const since30 = this.daysAgo(30);
|
||||
|
||||
@@ -45,8 +45,7 @@ export const ALL_RIDS = [
|
||||
'config.wallet',
|
||||
'custom.config.statics.slider',
|
||||
'custom.config.statics.tags.banner.ids',
|
||||
'dashboard.omde',
|
||||
'dashboard.tak',
|
||||
'dashboard.view',
|
||||
'document.create',
|
||||
'document.list',
|
||||
'document.list.confirm',
|
||||
|
||||
Reference in New Issue
Block a user