diff --git a/apps/api/src/reports/dto.ts b/apps/api/src/reports/dto.ts index 7c61140..a7b28eb 100644 --- a/apps/api/src/reports/dto.ts +++ b/apps/api/src/reports/dto.ts @@ -1,5 +1,9 @@ import { IsIn, IsOptional, IsString } from 'class-validator'; -import { REPORTS_DRILL_KINDS } from '@mento/shared'; +import { + REPORTS_BUILDER_DIMENSIONS, + REPORTS_BUILDER_METRICS, + REPORTS_DRILL_KINDS, +} from '@mento/shared'; export class ReportsQueryDto { @IsOptional() @@ -27,3 +31,27 @@ export class ReportsDrilldownQueryDto { @IsString() to?: string; } + +export class ReportsBuilderQueryDto { + @IsIn([...REPORTS_BUILDER_METRICS]) + metric!: (typeof REPORTS_BUILDER_METRICS)[number]; + + @IsIn([...REPORTS_BUILDER_DIMENSIONS]) + dimension!: (typeof REPORTS_BUILDER_DIMENSIONS)[number]; + + @IsOptional() + @IsString() + from?: string; + + @IsOptional() + @IsString() + to?: string; + + @IsOptional() + @IsString() + userId?: string; + + @IsOptional() + @IsString() + teamId?: string; +} diff --git a/apps/api/src/reports/reports.controller.ts b/apps/api/src/reports/reports.controller.ts index a55f8ec..d622da8 100644 --- a/apps/api/src/reports/reports.controller.ts +++ b/apps/api/src/reports/reports.controller.ts @@ -20,7 +20,7 @@ import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { SavedViewsService } from '../saved-views/saved-views.service'; -import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto'; +import { ReportsBuilderQueryDto, ReportsDrilldownQueryDto, ReportsQueryDto } from './dto'; import { ReportsService } from './reports.service'; class CreateReportViewDto { @@ -80,6 +80,21 @@ export class ReportsController { }); } + @Post('builder') + builder( + @CurrentUser() user: Actor, + @Body() dto: ReportsBuilderQueryDto, + ) { + return this.reportsService.builder(user, { + metric: dto.metric, + dimension: dto.dimension, + from: dto.from, + to: dto.to, + userId: dto.userId, + teamId: dto.teamId, + }); + } + @Get('home') home(@CurrentUser() user: Actor) { return this.reportsService.home(user); diff --git a/apps/api/src/reports/reports.service.ts b/apps/api/src/reports/reports.service.ts index 8cb96f6..e12b084 100644 --- a/apps/api/src/reports/reports.service.ts +++ b/apps/api/src/reports/reports.service.ts @@ -1,12 +1,21 @@ import { Injectable, BadRequestException } from '@nestjs/common'; import type { HomeDashboardDto, + ReportsBuilderDimension, + ReportsBuilderMetric, + ReportsBuilderQuery, + ReportsBuilderResultDto, + ReportsBuilderRowDto, ReportsDashboardDto, ReportsDrillKind, ReportsDrilldownDto, ReportsFunnelStageDto, ReportsPeriodCompareRowDto, } from '@mento/shared'; +import { + REPORTS_BUILDER_DIMENSION_LABELS, + REPORTS_BUILDER_METRIC_LABELS, +} from '@mento/shared'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { @@ -1016,6 +1025,332 @@ export class ReportsService { }; } + async builder( + actor: Actor, + query: ReportsBuilderQuery, + ): Promise { + const { start, end } = resolveRange(query.from, query.to); + const orgId = actor.organizationId; + const rates = await loadOrgRates(this.prisma, orgId); + const metric = query.metric; + let dimension = query.dimension; + + const dealMetrics = new Set([ + 'won_amount', + 'won_count', + 'lost_count', + 'open_count', + 'open_amount', + ]); + const invoiceMetrics = new Set([ + 'invoice_amount', + 'invoice_count', + ]); + + if ( + !dealMetrics.has(metric) && + (dimension === 'source' || + dimension === 'stage' || + dimension === 'status') + ) { + dimension = 'none'; + } + + const format: 'money' | 'number' = + metric === 'won_amount' || + metric === 'open_amount' || + metric === 'invoice_amount' + ? 'money' + : 'number'; + + const ownerFilterIds = await this.resolveBuilderOwnerIds(actor, { + userId: query.userId, + teamId: query.teamId, + }); + if (ownerFilterIds && ownerFilterIds.length === 0) { + return emptyBuilderResult(metric, dimension, start, end, rates.baseCurrency, format); + } + + const dealOwnerWhere = + ownerFilterIds == null + ? ownerWhere(actor) + : { ownerId: { in: ownerFilterIds } }; + const activityAssigneeWhere = + ownerFilterIds == null + ? assigneeWhere(actor) + : { assigneeId: { in: ownerFilterIds } }; + const personOwnerWhere = + ownerFilterIds == null + ? elevated(actor.role) + ? {} + : ownerWhere(actor) + : { ownerId: { in: ownerFilterIds } }; + + const buckets = new Map(); + const add = (key: string, label: string, value: number) => { + const cur = buckets.get(key) ?? { label, value: 0 }; + cur.value += value; + buckets.set(key, cur); + }; + + const userName = async (userId: string | null | undefined) => { + if (!userId) return 'بدون مسئول'; + const u = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { fullName: true }, + }); + return u?.fullName?.trim() || 'بدون مسئول'; + }; + + let teamByUser = new Map(); + if (dimension === 'team' || query.teamId) { + teamByUser = await this.loadUserTeams(orgId); + } + + const dimKey = async (opts: { + ownerId?: string | null; + source?: string | null; + stageId?: string | null; + stageName?: string | null; + status?: string | null; + at: Date; + }): Promise<{ key: string; label: string }> => { + if (dimension === 'none') return { key: 'total', label: 'جمع کل' }; + if (dimension === 'owner') { + const id = opts.ownerId || 'none'; + return { key: id, label: await userName(opts.ownerId) }; + } + if (dimension === 'team') { + const t = opts.ownerId ? teamByUser.get(opts.ownerId) : undefined; + return t + ? { key: t.id, label: t.name } + : { key: 'none', label: 'بدون تیم' }; + } + if (dimension === 'source') { + const label = normalizeSource(opts.source); + return { key: label, label }; + } + if (dimension === 'stage') { + const id = opts.stageId || 'none'; + return { key: id, label: opts.stageName || 'بدون مرحله' }; + } + if (dimension === 'status') { + const st = opts.status || 'open'; + return { key: st, label: dealStatusLabel(st) }; + } + // month + const j = dateToJalali(opts.at); + const key = `${j.jy}-${String(j.jm).padStart(2, '0')}`; + return { key, label: `${jalaliMonthName(j.jm)} ${j.jy}` }; + }; + + if (dealMetrics.has(metric)) { + let statusFilter: 'won' | 'lost' | 'open'; + if (metric === 'won_amount' || metric === 'won_count') statusFilter = 'won'; + else if (metric === 'lost_count') statusFilter = 'lost'; + else statusFilter = 'open'; + + const deals = await this.prisma.deal.findMany({ + where: { + organizationId: orgId, + ...dealOwnerWhere, + status: statusFilter, + ...(statusFilter === 'open' + ? { createdAt: { lte: end } } + : { updatedAt: { gte: start, lte: end } }), + }, + select: { + amount: true, + currency: true, + ownerId: true, + source: true, + status: true, + stageId: true, + stage: { select: { name: true } }, + updatedAt: true, + createdAt: true, + }, + }); + + for (const d of deals) { + const at = statusFilter === 'open' ? d.createdAt : d.updatedAt; + const dim = await dimKey({ + ownerId: d.ownerId, + source: d.source, + stageId: d.stageId, + stageName: d.stage?.name, + status: d.status, + at, + }); + const amount = decimalToBase(d.amount, d.currency, rates); + const value = + metric === 'won_amount' || metric === 'open_amount' ? amount : 1; + add(dim.key, dim.label, value); + } + } else if (metric === 'activities_done') { + const rows = await this.prisma.activity.findMany({ + where: { + organizationId: orgId, + ...activityAssigneeWhere, + status: 'done', + createdAt: { gte: start, lte: end }, + }, + select: { assigneeId: true, createdAt: true }, + }); + for (const a of rows) { + const dim = await dimKey({ + ownerId: a.assigneeId, + at: a.createdAt, + }); + add(dim.key, dim.label, 1); + } + } else if (metric === 'people_created') { + const rows = await this.prisma.person.findMany({ + where: { + organizationId: orgId, + ...personOwnerWhere, + createdAt: { gte: start, lte: end }, + }, + select: { ownerId: true, createdAt: true }, + }); + for (const p of rows) { + const dim = await dimKey({ + ownerId: p.ownerId, + at: p.createdAt, + }); + add(dim.key, dim.label, 1); + } + } else if (invoiceMetrics.has(metric)) { + const invoices = await this.prisma.invoice.findMany({ + where: { + organizationId: orgId, + ...dealOwnerWhere, + status: { in: ['issued', 'partially_paid', 'paid'] }, + OR: [ + { issuedAt: { gte: start, lte: end } }, + { issuedAt: null, createdAt: { gte: start, lte: end } }, + ], + }, + select: { + ownerId: true, + issuedAt: true, + createdAt: true, + lineItems: { + select: { quantity: true, unitPrice: true, currency: true }, + }, + }, + }); + for (const inv of invoices) { + const at = inv.issuedAt ?? inv.createdAt; + const dim = await dimKey({ ownerId: inv.ownerId, at }); + if (metric === 'invoice_count') { + add(dim.key, dim.label, 1); + } else { + let amount = 0; + for (const line of inv.lineItems) { + amount += toBaseAmount( + Number(line.quantity) * Number(line.unitPrice), + line.currency, + rates, + ); + } + add(dim.key, dim.label, amount); + } + } + } + + const rows: ReportsBuilderRowDto[] = [...buckets.entries()] + .map(([key, row]) => ({ + key, + label: row.label, + value: Math.round(row.value * (format === 'money' ? 1 : 1)), + })) + .sort((a, b) => b.value - a.value); + + if (format === 'money') { + for (const r of rows) r.value = Math.round(r.value); + } + + const total = rows.reduce((a, b) => a + b.value, 0); + + return { + metric, + metricLabel: REPORTS_BUILDER_METRIC_LABELS[metric], + dimension, + dimensionLabel: REPORTS_BUILDER_DIMENSION_LABELS[dimension], + from: start.toISOString(), + to: end.toISOString(), + currency: rates.baseCurrency, + format, + total: Math.round(total), + rows: dimension === 'none' && rows.length === 0 + ? [{ key: 'total', label: 'جمع کل', value: 0 }] + : dimension === 'none' + ? [{ key: 'total', label: 'جمع کل', value: Math.round(total) }] + : rows, + }; + } + + private async resolveBuilderOwnerIds( + actor: Actor, + opts: { userId?: string; teamId?: string }, + ): Promise { + const scope = ownerWhere(actor); + let allowed: string[] | null = null; + if ('ownerId' in scope && typeof scope.ownerId === 'string') { + allowed = [scope.ownerId]; + } else if ( + 'ownerId' in scope && + scope.ownerId && + typeof scope.ownerId === 'object' && + 'in' in scope.ownerId + ) { + allowed = [...scope.ownerId.in]; + } + + let ids: string[] | null = null; + + if (opts.teamId?.trim()) { + const members = await this.prisma.teamMember.findMany({ + where: { + teamId: opts.teamId.trim(), + team: { organizationId: actor.organizationId }, + }, + select: { userId: true }, + }); + ids = members.map((m) => m.userId); + } + + if (opts.userId?.trim()) { + const uid = opts.userId.trim(); + ids = ids == null ? [uid] : ids.filter((id) => id === uid); + } + + if (ids == null) return allowed; + if (allowed == null) return [...new Set(ids)]; + return ids.filter((id) => allowed.includes(id)); + } + + private async loadUserTeams( + organizationId: string, + ): Promise> { + const members = await this.prisma.teamMember.findMany({ + where: { team: { organizationId } }, + select: { + userId: true, + team: { select: { id: true, name: true } }, + }, + orderBy: { team: { name: 'asc' } }, + }); + const map = new Map(); + for (const m of members) { + if (!map.has(m.userId)) { + map.set(m.userId, { id: m.team.id, name: m.team.name }); + } + } + return map; + } + async home(actor: Actor): Promise { const orgId = actor.organizationId; const dealScope = ownerWhere(actor); @@ -1418,6 +1753,31 @@ function toDrillItem( }; } +function emptyBuilderResult( + metric: ReportsBuilderMetric, + dimension: ReportsBuilderDimension, + start: Date, + end: Date, + currency: string, + format: 'money' | 'number', +): ReportsBuilderResultDto { + return { + metric, + metricLabel: REPORTS_BUILDER_METRIC_LABELS[metric], + dimension, + dimensionLabel: REPORTS_BUILDER_DIMENSION_LABELS[dimension], + from: start.toISOString(), + to: end.toISOString(), + currency, + format, + total: 0, + rows: + dimension === 'none' + ? [{ key: 'total', label: 'جمع کل', value: 0 }] + : [], + }; +} + function activityTypeLabel(type: string): string { const map: Record = { call: 'تماس', diff --git a/apps/web/src/views/ReportsView.vue b/apps/web/src/views/ReportsView.vue index de355b9..2461dc9 100644 --- a/apps/web/src/views/ReportsView.vue +++ b/apps/web/src/views/ReportsView.vue @@ -27,6 +27,10 @@ import { } from '@/components/ui'; import type { OrganizationDto, + OrgUserDto, + ReportsBuilderDimension, + ReportsBuilderMetric, + ReportsBuilderResultDto, ReportsDashboardDto, ReportsDrillKind, ReportsDrilldownDto, @@ -34,10 +38,18 @@ import type { ReportsPeriodCompareRowDto, ReportsViewFilters, SavedViewDto, + TeamDto, +} from '@mento/shared'; +import { + brand, + REPORTS_BUILDER_DIMENSION_LABELS, + REPORTS_BUILDER_DIMENSIONS, + REPORTS_BUILDER_METRIC_LABELS, + REPORTS_BUILDER_METRICS, } from '@mento/shared'; -import { brand } from '@mento/shared'; type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom'; +type PageMode = 'dashboard' | 'builder'; const PERIODS: Array<{ key: PeriodKey; label: string }> = [ { key: 'today', label: 'امروز' }, @@ -84,8 +96,24 @@ const saveBusy = ref(false); const saveError = ref(''); const shareNote = ref(''); +const pageMode = ref('dashboard'); +const builderMetric = ref('won_amount'); +const builderDimension = ref('owner'); +const builderUserId = ref(''); +const builderTeamId = ref(''); +const builderLoading = ref(false); +const builderError = ref(''); +const builderResult = ref(null); +const orgUsers = ref([]); +const orgTeams = ref([]); +const teamsAvailable = ref(true); + const currency = computed( - () => data.value?.currency || org.value?.baseCurrency || 'IRR', + () => + builderResult.value?.currency || + data.value?.currency || + org.value?.baseCurrency || + 'IRR', ); const activeView = computed( @@ -224,7 +252,9 @@ function applyPeriod(key: PeriodKey, opts?: { keepView?: boolean }) { } else if (key === 'year') { from.value = formatLocalDateInput(jalaliToDate(j.jy, 1, 1)); } - void load(); + void load().then(() => { + if (pageMode.value === 'builder') void runBuilder(); + }); } function openCustom() { @@ -239,6 +269,13 @@ function currentFilters(): ReportsViewFilters { period: period.value, from: from.value || undefined, to: to.value || undefined, + mode: pageMode.value, + builder: { + metric: builderMetric.value, + dimension: builderDimension.value, + userId: builderUserId.value || undefined, + teamId: builderTeamId.value || undefined, + }, }; } @@ -247,10 +284,32 @@ function parseFilters(raw: Record): ReportsViewFilters { const p = PERIOD_KEYS.has(periodRaw as PeriodKey) ? (periodRaw as PeriodKey) : 'month'; + const mode = raw.mode === 'builder' ? 'builder' : 'dashboard'; + const builderRaw = + raw.builder && typeof raw.builder === 'object' + ? (raw.builder as Record) + : {}; + const metric = String(builderRaw.metric ?? 'won_amount'); + const dimension = String(builderRaw.dimension ?? 'owner'); return { period: p, from: typeof raw.from === 'string' ? raw.from : undefined, to: typeof raw.to === 'string' ? raw.to : undefined, + mode, + builder: { + metric: REPORTS_BUILDER_METRICS.includes(metric as ReportsBuilderMetric) + ? metric + : 'won_amount', + dimension: REPORTS_BUILDER_DIMENSIONS.includes( + dimension as ReportsBuilderDimension, + ) + ? dimension + : 'owner', + userId: + typeof builderRaw.userId === 'string' ? builderRaw.userId : undefined, + teamId: + typeof builderRaw.teamId === 'string' ? builderRaw.teamId : undefined, + }, }; } @@ -283,6 +342,30 @@ async function loadSavedViews() { } function applyFilters(filters: ReportsViewFilters, keepView: boolean) { + pageMode.value = filters.mode === 'builder' ? 'builder' : 'dashboard'; + if (filters.builder) { + const m = filters.builder.metric; + const d = filters.builder.dimension; + if ( + m && + REPORTS_BUILDER_METRICS.includes(m as ReportsBuilderMetric) + ) { + builderMetric.value = m as ReportsBuilderMetric; + } + if ( + d && + REPORTS_BUILDER_DIMENSIONS.includes(d as ReportsBuilderDimension) + ) { + builderDimension.value = d as ReportsBuilderDimension; + } + builderUserId.value = filters.builder.userId ?? ''; + builderTeamId.value = filters.builder.teamId ?? ''; + } + + const afterRange = () => { + if (pageMode.value === 'builder') void runBuilder(); + }; + const p = PERIOD_KEYS.has(filters.period as PeriodKey) ? (filters.period as PeriodKey) : 'month'; @@ -295,12 +378,96 @@ function applyFilters(filters: ReportsViewFilters, keepView: boolean) { activeViewId.value = null; syncViewQuery(null); } - void load(); + void load().then(afterRange); return; } applyPeriod(p, { keepView }); } +async function loadFilterOptions() { + try { + const { data: users } = await api.get('/users'); + orgUsers.value = users.filter((u) => u.isActive !== false); + } catch { + orgUsers.value = []; + } + try { + const { data: teams } = await api.get('/teams'); + orgTeams.value = teams; + teamsAvailable.value = true; + } catch (e) { + orgTeams.value = []; + teamsAvailable.value = + !(axios.isAxiosError(e) && e.response?.status === 403); + } +} + +async function runBuilder() { + builderLoading.value = true; + builderError.value = ''; + try { + const { data: res } = await api.post( + '/reports/builder', + { + metric: builderMetric.value, + dimension: builderDimension.value, + from: from.value || undefined, + to: to.value || undefined, + userId: builderUserId.value || undefined, + teamId: builderTeamId.value || undefined, + }, + ); + builderResult.value = res; + } catch (e) { + builderResult.value = null; + builderError.value = axios.isAxiosError(e) + ? String( + (e.response?.data as { message?: string })?.message ?? + 'اجرای گزارش‌ساز ناموفق بود.', + ) + : 'اجرای گزارش‌ساز ناموفق بود.'; + } finally { + builderLoading.value = false; + } +} + +function setPageMode(mode: PageMode) { + pageMode.value = mode; + if (mode === 'builder' && !builderResult.value) void runBuilder(); + if (mode === 'dashboard' && !data.value) void load(); +} + +const builderChartOption = computed(() => { + const rows = (builderResult.value?.rows ?? []).slice(0, 12); + const isMoney = builderResult.value?.format === 'money'; + return { + tooltip: { + trigger: 'axis', + formatter: (params: any) => { + const item = Array.isArray(params) ? params[0] : params; + if (!item) return ''; + const val = isMoney ? money(item.value) : num(item.value); + return `${item.name}
${val}`; + }, + }, + grid: { left: 16, right: 16, top: 24, bottom: 48, containLabel: true }, + xAxis: { + type: 'category', + data: rows.map((r) => r.label), + axisLabel: { rotate: rows.length > 5 ? 30 : 0, fontSize: 11 }, + }, + yAxis: { type: 'value' }, + series: [ + { + type: 'bar', + data: rows.map((r) => r.value), + itemStyle: { color: CHART_PALETTE[0] }, + barMaxWidth: 42, + }, + ], + }; +}); + async function selectSavedView(id: string) { if (!id) { activeViewId.value = null; @@ -431,7 +598,7 @@ async function copyShareLink() { } async function bootstrap() { - await loadSavedViews(); + await Promise.all([loadSavedViews(), loadFilterOptions()]); const viewId = typeof route.query.view === 'string' ? route.query.view.trim() : ''; if (viewId) { @@ -485,6 +652,31 @@ async function load() { } function exportReport() { + if (pageMode.value === 'builder') { + if (!builderResult.value) return; + const b = builderResult.value; + const lines: string[] = [ + `گزارش‌ساز ${brand.nameFa}`, + `بازه,${rangeLabel()}`, + `متریک,${b.metricLabel}`, + `بعد,${b.dimensionLabel}`, + `جمع,${b.total}`, + '', + `${b.dimensionLabel},${b.metricLabel}`, + ...b.rows.map((r) => `${r.label},${r.value}`), + ]; + const blob = new Blob(['\ufeff' + lines.join('\n')], { + type: 'text/csv;charset=utf-8', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${brand.filePrefix}-builder-${from.value || 'from'}-${to.value || 'to'}.csv`; + a.click(); + URL.revokeObjectURL(url); + return; + } + if (!data.value) return; const d = data.value; const lines: string[] = [ @@ -858,7 +1050,7 @@ const activityMax = computed(() => @@ -867,6 +1059,25 @@ const activityMax = computed(() => +
+ + +
+
@@ -922,21 +1133,148 @@ const activityMax = computed(() => @submit.prevent=" activeViewId = null; syncViewQuery(null); - load(); + load().then(() => { + if (pageMode === 'builder') runBuilder(); + }); " > - اعمال + + اعمال +

{{ error }}

+ + + + +
در حال بارگذاری گزارشات...
-