Add simple report builder with metrics, dimensions, and user/team filters.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ReportsBuilderResultDto> {
|
||||
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<ReportsBuilderMetric>([
|
||||
'won_amount',
|
||||
'won_count',
|
||||
'lost_count',
|
||||
'open_count',
|
||||
'open_amount',
|
||||
]);
|
||||
const invoiceMetrics = new Set<ReportsBuilderMetric>([
|
||||
'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<string, { label: string; value: number }>();
|
||||
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<string, { id: string; name: string }>();
|
||||
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<string[] | null> {
|
||||
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<Map<string, { id: string; name: string }>> {
|
||||
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<string, { id: string; name: string }>();
|
||||
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<HomeDashboardDto> {
|
||||
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<string, string> = {
|
||||
call: 'تماس',
|
||||
|
||||
@@ -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<PageMode>('dashboard');
|
||||
const builderMetric = ref<ReportsBuilderMetric>('won_amount');
|
||||
const builderDimension = ref<ReportsBuilderDimension>('owner');
|
||||
const builderUserId = ref('');
|
||||
const builderTeamId = ref('');
|
||||
const builderLoading = ref(false);
|
||||
const builderError = ref('');
|
||||
const builderResult = ref<ReportsBuilderResultDto | null>(null);
|
||||
const orgUsers = ref<OrgUserDto[]>([]);
|
||||
const orgTeams = ref<TeamDto[]>([]);
|
||||
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<string, unknown>): 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<string, unknown>)
|
||||
: {};
|
||||
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<OrgUserDto[]>('/users');
|
||||
orgUsers.value = users.filter((u) => u.isActive !== false);
|
||||
} catch {
|
||||
orgUsers.value = [];
|
||||
}
|
||||
try {
|
||||
const { data: teams } = await api.get<TeamDto[]>('/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<ReportsBuilderResultDto>(
|
||||
'/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<UiChartOption>(() => {
|
||||
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}<br/><strong>${val}</strong>`;
|
||||
},
|
||||
},
|
||||
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(() =>
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="primary"
|
||||
:disabled="!data"
|
||||
:disabled="pageMode === 'dashboard' ? !data : !builderResult"
|
||||
@click="exportReport"
|
||||
>
|
||||
<UiIcon name="document" :size="16" />
|
||||
@@ -867,6 +1059,25 @@ const activityMax = computed(() =>
|
||||
</template>
|
||||
</UiPageHeader>
|
||||
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ on: pageMode === 'dashboard' }"
|
||||
@click="setPageMode('dashboard')"
|
||||
>
|
||||
داشبورد
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-tab"
|
||||
:class="{ on: pageMode === 'builder' }"
|
||||
@click="setPageMode('builder')"
|
||||
>
|
||||
گزارشساز
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar Filters -->
|
||||
<div class="crm-toolbar filter-toolbar">
|
||||
<div class="period-chips">
|
||||
@@ -922,21 +1133,148 @@ const activityMax = computed(() =>
|
||||
@submit.prevent="
|
||||
activeViewId = null;
|
||||
syncViewQuery(null);
|
||||
load();
|
||||
load().then(() => {
|
||||
if (pageMode === 'builder') runBuilder();
|
||||
});
|
||||
"
|
||||
>
|
||||
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
||||
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
||||
<UiButton variant="primary" type="submit" :disabled="loading">اعمال</UiButton>
|
||||
<UiButton variant="primary" type="submit" :disabled="loading || builderLoading">
|
||||
اعمال
|
||||
</UiButton>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
|
||||
<!-- Builder mode -->
|
||||
<template v-if="pageMode === 'builder'">
|
||||
<UiCard class="builder-card" title="گزارشساز ساده">
|
||||
<p class="deep-hint">
|
||||
متریک و بُعد را انتخاب کنید؛ میتوانید بازه بالا و فیلتر کاربر/تیم را هم اعمال کنید.
|
||||
</p>
|
||||
<div class="builder-form">
|
||||
<UiField label="متریک">
|
||||
<select v-model="builderMetric" class="builder-select">
|
||||
<option
|
||||
v-for="m in REPORTS_BUILDER_METRICS"
|
||||
:key="m"
|
||||
:value="m"
|
||||
>
|
||||
{{ REPORTS_BUILDER_METRIC_LABELS[m] }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="گروهبندی (بُعد)">
|
||||
<select v-model="builderDimension" class="builder-select">
|
||||
<option
|
||||
v-for="d in REPORTS_BUILDER_DIMENSIONS"
|
||||
:key="d"
|
||||
:value="d"
|
||||
>
|
||||
{{ REPORTS_BUILDER_DIMENSION_LABELS[d] }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="کاربر">
|
||||
<select v-model="builderUserId" class="builder-select">
|
||||
<option value="">همه کاربران مجاز</option>
|
||||
<option v-for="u in orgUsers" :key="u.id" :value="u.id">
|
||||
{{ u.fullName }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<UiField label="تیم">
|
||||
<select
|
||||
v-model="builderTeamId"
|
||||
class="builder-select"
|
||||
:disabled="!teamsAvailable"
|
||||
>
|
||||
<option value="">همه تیمها</option>
|
||||
<option v-for="t in orgTeams" :key="t.id" :value="t.id">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
</UiField>
|
||||
<div class="builder-actions">
|
||||
<UiButton :loading="builderLoading" @click="runBuilder">
|
||||
اجرای گزارش
|
||||
</UiButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!teamsAvailable" class="deep-hint">
|
||||
ماژول تیمها برای این محیط فعال نیست؛ فیلتر تیم در دسترس نیست.
|
||||
</p>
|
||||
<p v-if="builderError" class="error">{{ builderError }}</p>
|
||||
</UiCard>
|
||||
|
||||
<div v-if="builderLoading" class="loading-wrap">
|
||||
<div class="spinner"></div>
|
||||
<span>در حال ساخت گزارش...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="builderResult">
|
||||
<div class="builder-kpi">
|
||||
<article class="kpi-card">
|
||||
<div class="kpi-top">
|
||||
<span class="kpi-label">{{ builderResult.metricLabel }}</span>
|
||||
</div>
|
||||
<strong class="kpi-val">
|
||||
{{
|
||||
builderResult.format === 'money'
|
||||
? money(builderResult.total)
|
||||
: num(builderResult.total)
|
||||
}}
|
||||
</strong>
|
||||
<div class="kpi-change flat">
|
||||
<small>{{ builderResult.dimensionLabel }}</small>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="charts-grid">
|
||||
<UiCard class="chart-card span-2" title="نمودار">
|
||||
<UiChart
|
||||
v-if="builderResult.rows.length"
|
||||
:option="builderChartOption"
|
||||
height="300px"
|
||||
/>
|
||||
<p v-else class="empty-msg">دادهای برای نمایش نیست.</p>
|
||||
</UiCard>
|
||||
<UiCard class="chart-card" title="جدول">
|
||||
<table v-if="builderResult.rows.length" class="compare-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ builderResult.dimensionLabel }}</th>
|
||||
<th>{{ builderResult.metricLabel }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in builderResult.rows" :key="row.key">
|
||||
<td>{{ row.label }}</td>
|
||||
<td>
|
||||
{{
|
||||
builderResult.format === 'money'
|
||||
? money(row.value)
|
||||
: num(row.value)
|
||||
}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-msg">ردیفی نیست.</p>
|
||||
</UiCard>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Dashboard mode -->
|
||||
<div v-else-if="loading" class="loading-wrap">
|
||||
<div class="spinner"></div>
|
||||
<span>در حال بارگذاری گزارشات...</span>
|
||||
</div>
|
||||
|
||||
<template v-else-if="data">
|
||||
<template v-else-if="pageMode === 'dashboard' && data">
|
||||
<!-- KPI row -->
|
||||
<div class="kpis">
|
||||
<article v-for="k in data.kpis" :key="k.key" class="kpi-card">
|
||||
@@ -1290,6 +1628,81 @@ const activityMax = computed(() =>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mode-tabs {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--line);
|
||||
padding: 3px;
|
||||
border-radius: var(--radius);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0 var(--sp-4);
|
||||
min-height: calc(var(--control-h-md) - 6px);
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
font: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: var(--fw-semibold);
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mode-tab.on {
|
||||
background: var(--surface);
|
||||
color: var(--accent-deep);
|
||||
box-shadow: var(--shadow-xs);
|
||||
font-weight: var(--fw-bold);
|
||||
}
|
||||
|
||||
.builder-card {
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.builder-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||||
gap: var(--sp-3);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.builder-select {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
min-height: var(--control-h-md);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
padding: 0.4rem 0.55rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.builder-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.builder-kpi {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 320px);
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.builder-form {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.builder-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.view-select {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -3069,6 +3069,114 @@ export interface ReportsViewFilters {
|
||||
period: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
/** dashboard | builder */
|
||||
mode?: string;
|
||||
builder?: {
|
||||
metric?: string;
|
||||
dimension?: string;
|
||||
userId?: string;
|
||||
teamId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** متریکهای گزارشساز ساده */
|
||||
export type ReportsBuilderMetric =
|
||||
| "won_amount"
|
||||
| "won_count"
|
||||
| "lost_count"
|
||||
| "open_count"
|
||||
| "open_amount"
|
||||
| "activities_done"
|
||||
| "people_created"
|
||||
| "invoice_amount"
|
||||
| "invoice_count";
|
||||
|
||||
export const REPORTS_BUILDER_METRICS: ReportsBuilderMetric[] = [
|
||||
"won_amount",
|
||||
"won_count",
|
||||
"lost_count",
|
||||
"open_count",
|
||||
"open_amount",
|
||||
"activities_done",
|
||||
"people_created",
|
||||
"invoice_amount",
|
||||
"invoice_count",
|
||||
];
|
||||
|
||||
export const REPORTS_BUILDER_METRIC_LABELS: Record<
|
||||
ReportsBuilderMetric,
|
||||
string
|
||||
> = {
|
||||
won_amount: "مبلغ معاملات برنده",
|
||||
won_count: "تعداد معاملات برنده",
|
||||
lost_count: "تعداد معاملات باخته",
|
||||
open_count: "تعداد معاملات باز",
|
||||
open_amount: "مبلغ معاملات باز",
|
||||
activities_done: "فعالیت انجامشده",
|
||||
people_created: "مشتریان جدید",
|
||||
invoice_amount: "مبلغ فاکتور",
|
||||
invoice_count: "تعداد فاکتور",
|
||||
};
|
||||
|
||||
/** ابعاد گروهبندی گزارشساز */
|
||||
export type ReportsBuilderDimension =
|
||||
| "none"
|
||||
| "owner"
|
||||
| "team"
|
||||
| "source"
|
||||
| "stage"
|
||||
| "month"
|
||||
| "status";
|
||||
|
||||
export const REPORTS_BUILDER_DIMENSIONS: ReportsBuilderDimension[] = [
|
||||
"none",
|
||||
"owner",
|
||||
"team",
|
||||
"source",
|
||||
"stage",
|
||||
"month",
|
||||
"status",
|
||||
];
|
||||
|
||||
export const REPORTS_BUILDER_DIMENSION_LABELS: Record<
|
||||
ReportsBuilderDimension,
|
||||
string
|
||||
> = {
|
||||
none: "بدون گروهبندی (جمع کل)",
|
||||
owner: "کاربر / مسئول",
|
||||
team: "تیم",
|
||||
source: "منبع",
|
||||
stage: "مرحله کاریز",
|
||||
month: "ماه",
|
||||
status: "وضعیت معامله",
|
||||
};
|
||||
|
||||
export interface ReportsBuilderQuery {
|
||||
metric: ReportsBuilderMetric;
|
||||
dimension: ReportsBuilderDimension;
|
||||
from?: string;
|
||||
to?: string;
|
||||
userId?: string;
|
||||
teamId?: string;
|
||||
}
|
||||
|
||||
export interface ReportsBuilderRowDto {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ReportsBuilderResultDto {
|
||||
metric: ReportsBuilderMetric;
|
||||
metricLabel: string;
|
||||
dimension: ReportsBuilderDimension;
|
||||
dimensionLabel: string;
|
||||
from: string;
|
||||
to: string;
|
||||
currency: string;
|
||||
format: "money" | "number";
|
||||
total: number;
|
||||
rows: ReportsBuilderRowDto[];
|
||||
}
|
||||
|
||||
export interface SavedViewDto {
|
||||
|
||||
Reference in New Issue
Block a user