Add simple report builder with metrics, dimensions, and user/team filters.

This commit is contained in:
2026-09-27 11:54:23 +03:30
parent a16c2f0a9b
commit 55a287563d
5 changed files with 935 additions and 11 deletions
+422 -9
View File
@@ -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;