2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-09-27 11:11:33 +03:30
|
|
|
|
import { computed, onMounted, ref, watch } from 'vue';
|
|
|
|
|
|
import { useRoute, useRouter } from 'vue-router';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
2026-09-27 11:11:33 +03:30
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import { formatMoney } from '@/lib/money';
|
2026-09-05 17:04:54 +03:30
|
|
|
|
import {
|
|
|
|
|
|
dateToJalali,
|
|
|
|
|
|
formatJalaliNumeric,
|
|
|
|
|
|
formatLocalDateInput,
|
|
|
|
|
|
jalaliToDate,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
2026-09-05 17:04:54 +03:30
|
|
|
|
import UiIcon from '@/components/UiIcon.vue';
|
2026-09-12 01:33:07 +03:30
|
|
|
|
import {
|
|
|
|
|
|
UiButton,
|
|
|
|
|
|
UiCard,
|
2026-09-27 11:11:33 +03:30
|
|
|
|
UiField,
|
|
|
|
|
|
UiInput,
|
2026-09-27 10:39:09 +03:30
|
|
|
|
UiModal,
|
2026-09-12 01:33:07 +03:30
|
|
|
|
UiPageHeader,
|
|
|
|
|
|
UiChart,
|
|
|
|
|
|
CHART_PALETTE,
|
|
|
|
|
|
type UiChartOption,
|
|
|
|
|
|
} from '@/components/ui';
|
2026-09-27 10:39:09 +03:30
|
|
|
|
import type {
|
|
|
|
|
|
OrganizationDto,
|
2026-09-27 11:54:23 +03:30
|
|
|
|
OrgUserDto,
|
|
|
|
|
|
ReportsBuilderDimension,
|
|
|
|
|
|
ReportsBuilderMetric,
|
|
|
|
|
|
ReportsBuilderResultDto,
|
2026-09-27 10:39:09 +03:30
|
|
|
|
ReportsDashboardDto,
|
|
|
|
|
|
ReportsDrillKind,
|
|
|
|
|
|
ReportsDrilldownDto,
|
|
|
|
|
|
ReportsFunnelStageDto,
|
|
|
|
|
|
ReportsPeriodCompareRowDto,
|
2026-09-27 11:11:33 +03:30
|
|
|
|
ReportsViewFilters,
|
|
|
|
|
|
SavedViewDto,
|
2026-09-27 11:54:23 +03:30
|
|
|
|
TeamDto,
|
|
|
|
|
|
} from '@mento/shared';
|
|
|
|
|
|
import {
|
|
|
|
|
|
brand,
|
|
|
|
|
|
REPORTS_BUILDER_DIMENSION_LABELS,
|
|
|
|
|
|
REPORTS_BUILDER_DIMENSIONS,
|
|
|
|
|
|
REPORTS_BUILDER_METRIC_LABELS,
|
|
|
|
|
|
REPORTS_BUILDER_METRICS,
|
2026-09-27 10:39:09 +03:30
|
|
|
|
} from '@mento/shared';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
2026-09-27 11:54:23 +03:30
|
|
|
|
type PageMode = 'dashboard' | 'builder';
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
|
|
|
|
|
const PERIODS: Array<{ key: PeriodKey; label: string }> = [
|
|
|
|
|
|
{ key: 'today', label: 'امروز' },
|
|
|
|
|
|
{ key: 'week', label: 'هفته' },
|
|
|
|
|
|
{ key: 'month', label: 'ماه' },
|
|
|
|
|
|
{ key: 'quarter', label: 'سهماهه' },
|
|
|
|
|
|
{ key: 'year', label: 'سال' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
const PERIOD_KEYS = new Set<PeriodKey>([
|
|
|
|
|
|
'today',
|
|
|
|
|
|
'week',
|
|
|
|
|
|
'month',
|
|
|
|
|
|
'quarter',
|
|
|
|
|
|
'year',
|
|
|
|
|
|
'custom',
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
const router = useRouter();
|
2026-09-27 11:11:33 +03:30
|
|
|
|
const route = useRoute();
|
|
|
|
|
|
const auth = useAuthStore();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const data = ref<ReportsDashboardDto | null>(null);
|
|
|
|
|
|
const org = ref<OrganizationDto | null>(null);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
const period = ref<PeriodKey>('month');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const from = ref('');
|
|
|
|
|
|
const to = ref('');
|
2026-09-05 17:04:54 +03:30
|
|
|
|
const showCustomDates = ref(false);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
const drillOpen = ref(false);
|
|
|
|
|
|
const drillLoading = ref(false);
|
|
|
|
|
|
const drillError = ref('');
|
|
|
|
|
|
const drill = ref<ReportsDrilldownDto | null>(null);
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
const savedViews = ref<SavedViewDto[]>([]);
|
|
|
|
|
|
const activeViewId = ref<string | null>(null);
|
|
|
|
|
|
const viewsLoading = ref(false);
|
|
|
|
|
|
const viewsError = ref('');
|
|
|
|
|
|
const saveOpen = ref(false);
|
|
|
|
|
|
const saveName = ref('');
|
|
|
|
|
|
const saveShared = ref(true);
|
|
|
|
|
|
const saveBusy = ref(false);
|
|
|
|
|
|
const saveError = ref('');
|
|
|
|
|
|
const shareNote = ref('');
|
|
|
|
|
|
|
2026-09-27 11:54:23 +03:30
|
|
|
|
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);
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
const currency = computed(
|
2026-09-27 11:54:23 +03:30
|
|
|
|
() =>
|
|
|
|
|
|
builderResult.value?.currency ||
|
|
|
|
|
|
data.value?.currency ||
|
|
|
|
|
|
org.value?.baseCurrency ||
|
|
|
|
|
|
'IRR',
|
2026-09-05 17:04:54 +03:30
|
|
|
|
);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
const activeView = computed(
|
|
|
|
|
|
() => savedViews.value.find((v) => v.id === activeViewId.value) ?? null,
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const canManageActiveView = computed(() => {
|
|
|
|
|
|
const v = activeView.value;
|
|
|
|
|
|
if (!v) return false;
|
|
|
|
|
|
const role = auth.user?.role;
|
|
|
|
|
|
if (role === 'owner' || role === 'admin') return true;
|
|
|
|
|
|
return v.owner?.id === auth.user?.id;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
const funnelByPipeline = computed(() => {
|
|
|
|
|
|
const rows = data.value?.funnel ?? [];
|
|
|
|
|
|
const map = new Map<string, { name: string; stages: ReportsFunnelStageDto[] }>();
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
|
const key = row.pipelineId || row.pipelineName;
|
|
|
|
|
|
const cur = map.get(key) ?? { name: row.pipelineName, stages: [] };
|
|
|
|
|
|
cur.stages.push(row);
|
|
|
|
|
|
map.set(key, cur);
|
|
|
|
|
|
}
|
|
|
|
|
|
return [...map.values()];
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
function money(n: number | null | undefined) {
|
|
|
|
|
|
if (n == null || Number.isNaN(n)) return '—';
|
|
|
|
|
|
return formatMoney(n, currency.value);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
function num(n: number | null | undefined) {
|
|
|
|
|
|
if (n == null || Number.isNaN(n)) return '—';
|
|
|
|
|
|
return toFaDigits(Math.round(n).toLocaleString('en-US'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatKpi(value: number, format: 'money' | 'number') {
|
|
|
|
|
|
return format === 'money' ? money(value) : num(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatChange(p: number | null | undefined) {
|
|
|
|
|
|
if (p == null) return '—';
|
|
|
|
|
|
const sign = p > 0 ? '+' : '';
|
|
|
|
|
|
return `${sign}${toFaDigits(p)}٪`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
function formatCompareValue(
|
|
|
|
|
|
value: number,
|
|
|
|
|
|
format: ReportsPeriodCompareRowDto['format'],
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (format === 'money') return money(value);
|
|
|
|
|
|
if (format === 'percent') return `${toFaDigits(value)}٪`;
|
|
|
|
|
|
return num(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
function rangeLabel() {
|
|
|
|
|
|
if (!from.value || !to.value) return '—';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const a = formatJalaliNumeric(new Date(from.value));
|
|
|
|
|
|
const b = formatJalaliNumeric(new Date(to.value));
|
|
|
|
|
|
return `${a} – ${b}`;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return '—';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
async function openDrill(kind: ReportsDrillKind, id?: string) {
|
|
|
|
|
|
drillOpen.value = true;
|
|
|
|
|
|
drillLoading.value = true;
|
|
|
|
|
|
drillError.value = '';
|
|
|
|
|
|
drill.value = null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data: res } = await api.get<ReportsDrilldownDto>(
|
|
|
|
|
|
'/reports/drilldown',
|
|
|
|
|
|
{
|
|
|
|
|
|
params: {
|
|
|
|
|
|
kind,
|
|
|
|
|
|
id: id || undefined,
|
|
|
|
|
|
from: from.value || undefined,
|
|
|
|
|
|
to: to.value || undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
drill.value = res;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
drillError.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'بارگذاری فهرست ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بارگذاری فهرست ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
drillLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeDrill() {
|
|
|
|
|
|
drillOpen.value = false;
|
|
|
|
|
|
drill.value = null;
|
|
|
|
|
|
drillError.value = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openDeal(link: string) {
|
|
|
|
|
|
closeDrill();
|
|
|
|
|
|
void router.push(link);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
function applyPeriod(key: PeriodKey, opts?: { keepView?: boolean }) {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
period.value = key;
|
|
|
|
|
|
showCustomDates.value = key === 'custom';
|
2026-09-27 11:11:33 +03:30
|
|
|
|
if (!opts?.keepView) {
|
|
|
|
|
|
activeViewId.value = null;
|
|
|
|
|
|
shareNote.value = '';
|
|
|
|
|
|
syncViewQuery(null);
|
|
|
|
|
|
}
|
2026-09-05 17:04:54 +03:30
|
|
|
|
if (key === 'custom') return;
|
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
const j = dateToJalali(now);
|
|
|
|
|
|
to.value = formatLocalDateInput(now);
|
|
|
|
|
|
if (key === 'today') {
|
|
|
|
|
|
from.value = formatLocalDateInput(now);
|
|
|
|
|
|
} else if (key === 'week') {
|
|
|
|
|
|
const d = new Date(now);
|
|
|
|
|
|
d.setDate(d.getDate() - 6);
|
|
|
|
|
|
from.value = formatLocalDateInput(d);
|
|
|
|
|
|
} else if (key === 'month') {
|
|
|
|
|
|
from.value = formatLocalDateInput(jalaliToDate(j.jy, j.jm, 1));
|
|
|
|
|
|
} else if (key === 'quarter') {
|
|
|
|
|
|
let jm = j.jm - 2;
|
|
|
|
|
|
let jy = j.jy;
|
|
|
|
|
|
if (jm < 1) {
|
|
|
|
|
|
jm += 12;
|
|
|
|
|
|
jy -= 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
from.value = formatLocalDateInput(jalaliToDate(jy, jm, 1));
|
|
|
|
|
|
} else if (key === 'year') {
|
|
|
|
|
|
from.value = formatLocalDateInput(jalaliToDate(j.jy, 1, 1));
|
|
|
|
|
|
}
|
2026-09-27 11:54:23 +03:30
|
|
|
|
void load().then(() => {
|
|
|
|
|
|
if (pageMode.value === 'builder') void runBuilder();
|
|
|
|
|
|
});
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openCustom() {
|
|
|
|
|
|
period.value = 'custom';
|
|
|
|
|
|
showCustomDates.value = true;
|
2026-09-27 11:11:33 +03:30
|
|
|
|
activeViewId.value = null;
|
|
|
|
|
|
syncViewQuery(null);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
function currentFilters(): ReportsViewFilters {
|
|
|
|
|
|
return {
|
|
|
|
|
|
period: period.value,
|
|
|
|
|
|
from: from.value || undefined,
|
|
|
|
|
|
to: to.value || undefined,
|
2026-09-27 11:54:23 +03:30
|
|
|
|
mode: pageMode.value,
|
|
|
|
|
|
builder: {
|
|
|
|
|
|
metric: builderMetric.value,
|
|
|
|
|
|
dimension: builderDimension.value,
|
|
|
|
|
|
userId: builderUserId.value || undefined,
|
|
|
|
|
|
teamId: builderTeamId.value || undefined,
|
|
|
|
|
|
},
|
2026-09-27 11:11:33 +03:30
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseFilters(raw: Record<string, unknown>): ReportsViewFilters {
|
|
|
|
|
|
const periodRaw = String(raw.period ?? 'month');
|
|
|
|
|
|
const p = PERIOD_KEYS.has(periodRaw as PeriodKey)
|
|
|
|
|
|
? (periodRaw as PeriodKey)
|
|
|
|
|
|
: 'month';
|
2026-09-27 11:54:23 +03:30
|
|
|
|
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');
|
2026-09-27 11:11:33 +03:30
|
|
|
|
return {
|
|
|
|
|
|
period: p,
|
|
|
|
|
|
from: typeof raw.from === 'string' ? raw.from : undefined,
|
|
|
|
|
|
to: typeof raw.to === 'string' ? raw.to : undefined,
|
2026-09-27 11:54:23 +03:30
|
|
|
|
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,
|
|
|
|
|
|
},
|
2026-09-27 11:11:33 +03:30
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncViewQuery(viewId: string | null) {
|
|
|
|
|
|
const nextQuery = { ...route.query } as Record<string, string | string[]>;
|
|
|
|
|
|
if (viewId) nextQuery.view = viewId;
|
|
|
|
|
|
else delete nextQuery.view;
|
|
|
|
|
|
const cur = typeof route.query.view === 'string' ? route.query.view : null;
|
|
|
|
|
|
if (cur === viewId) return;
|
|
|
|
|
|
void router.replace({ query: nextQuery });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSavedViews() {
|
|
|
|
|
|
viewsLoading.value = true;
|
|
|
|
|
|
viewsError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data: rows } = await api.get<SavedViewDto[]>('/reports/views');
|
|
|
|
|
|
savedViews.value = rows;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
savedViews.value = [];
|
|
|
|
|
|
viewsError.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'بارگذاری نماهای ذخیرهشده ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بارگذاری نماهای ذخیرهشده ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
viewsLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyFilters(filters: ReportsViewFilters, keepView: boolean) {
|
2026-09-27 11:54:23 +03:30
|
|
|
|
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();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
const p = PERIOD_KEYS.has(filters.period as PeriodKey)
|
|
|
|
|
|
? (filters.period as PeriodKey)
|
|
|
|
|
|
: 'month';
|
|
|
|
|
|
if (p === 'custom') {
|
|
|
|
|
|
period.value = 'custom';
|
|
|
|
|
|
showCustomDates.value = true;
|
|
|
|
|
|
from.value = filters.from || formatLocalDateInput(new Date());
|
|
|
|
|
|
to.value = filters.to || formatLocalDateInput(new Date());
|
|
|
|
|
|
if (!keepView) {
|
|
|
|
|
|
activeViewId.value = null;
|
|
|
|
|
|
syncViewQuery(null);
|
|
|
|
|
|
}
|
2026-09-27 11:54:23 +03:30
|
|
|
|
void load().then(afterRange);
|
2026-09-27 11:11:33 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
applyPeriod(p, { keepView });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 11:54:23 +03:30
|
|
|
|
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,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
async function selectSavedView(id: string) {
|
|
|
|
|
|
if (!id) {
|
|
|
|
|
|
activeViewId.value = null;
|
|
|
|
|
|
syncViewQuery(null);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const { data: view } = await api.get<SavedViewDto>(`/reports/views/${id}`);
|
|
|
|
|
|
activeViewId.value = view.id;
|
|
|
|
|
|
syncViewQuery(view.id);
|
|
|
|
|
|
applyFilters(parseFilters(view.filters), true);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'باز کردن نما ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'باز کردن نما ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function openSaveModal() {
|
|
|
|
|
|
saveName.value = activeView.value?.name ?? '';
|
|
|
|
|
|
saveShared.value = activeView.value?.isShared ?? true;
|
|
|
|
|
|
saveError.value = '';
|
|
|
|
|
|
saveOpen.value = true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function saveCurrentView() {
|
|
|
|
|
|
const name = saveName.value.trim();
|
|
|
|
|
|
if (!name) {
|
|
|
|
|
|
saveError.value = 'نام نما را وارد کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
saveBusy.value = true;
|
|
|
|
|
|
saveError.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const payload = {
|
|
|
|
|
|
name,
|
|
|
|
|
|
filters: currentFilters() as unknown as Record<string, unknown>,
|
|
|
|
|
|
isShared: saveShared.value,
|
|
|
|
|
|
};
|
|
|
|
|
|
let saved: SavedViewDto;
|
|
|
|
|
|
if (activeViewId.value && canManageActiveView.value) {
|
|
|
|
|
|
const { data: row } = await api.patch<SavedViewDto>(
|
|
|
|
|
|
`/reports/views/${activeViewId.value}`,
|
|
|
|
|
|
payload,
|
|
|
|
|
|
);
|
|
|
|
|
|
saved = row;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const { data: row } = await api.post<SavedViewDto>(
|
|
|
|
|
|
'/reports/views',
|
|
|
|
|
|
payload,
|
|
|
|
|
|
);
|
|
|
|
|
|
saved = row;
|
|
|
|
|
|
}
|
|
|
|
|
|
saveOpen.value = false;
|
|
|
|
|
|
await loadSavedViews();
|
|
|
|
|
|
activeViewId.value = saved.id;
|
|
|
|
|
|
syncViewQuery(saved.id);
|
|
|
|
|
|
shareNote.value = saved.isShared
|
|
|
|
|
|
? 'نما ذخیره و برای سازمان قابل اشتراک شد.'
|
|
|
|
|
|
: 'نما ذخیره شد (فقط برای شما).';
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
saveError.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'ذخیره نما ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'ذخیره نما ناموفق بود.';
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
saveBusy.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function deleteActiveView() {
|
|
|
|
|
|
if (!activeViewId.value || !canManageActiveView.value) return;
|
|
|
|
|
|
if (!confirm('این نما حذف شود؟')) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/reports/views/${activeViewId.value}`);
|
|
|
|
|
|
activeViewId.value = null;
|
|
|
|
|
|
syncViewQuery(null);
|
|
|
|
|
|
await loadSavedViews();
|
|
|
|
|
|
shareNote.value = 'نما حذف شد.';
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'حذف نما ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'حذف نما ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function copyShareLink() {
|
|
|
|
|
|
shareNote.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
let view = activeView.value;
|
|
|
|
|
|
if (!view) {
|
|
|
|
|
|
openSaveModal();
|
|
|
|
|
|
saveShared.value = true;
|
|
|
|
|
|
shareNote.value = 'ابتدا نما را ذخیره کنید، بعد لینک را کپی کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!view.isShared && canManageActiveView.value) {
|
|
|
|
|
|
const { data: row } = await api.patch<SavedViewDto>(
|
|
|
|
|
|
`/reports/views/${view.id}`,
|
|
|
|
|
|
{ isShared: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
view = row;
|
|
|
|
|
|
await loadSavedViews();
|
|
|
|
|
|
activeViewId.value = row.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!view.isShared) {
|
|
|
|
|
|
shareNote.value = 'این نما اشتراکی نیست؛ فقط مالک میتواند اشتراک بگذارد.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
const path = router.resolve({
|
|
|
|
|
|
name: 'reports',
|
|
|
|
|
|
query: { view: view.id },
|
|
|
|
|
|
}).href;
|
|
|
|
|
|
const url = `${window.location.origin}${path}`;
|
|
|
|
|
|
await navigator.clipboard.writeText(url);
|
|
|
|
|
|
shareNote.value = 'لینک اشتراک کپی شد.';
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
shareNote.value = 'کپی لینک ناموفق بود.';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function bootstrap() {
|
2026-09-27 11:54:23 +03:30
|
|
|
|
await Promise.all([loadSavedViews(), loadFilterOptions()]);
|
2026-09-27 11:11:33 +03:30
|
|
|
|
const viewId =
|
|
|
|
|
|
typeof route.query.view === 'string' ? route.query.view.trim() : '';
|
|
|
|
|
|
if (viewId) {
|
|
|
|
|
|
await selectSavedView(viewId);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
applyPeriod('month');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
|
|
|
|
|
() => route.query.view,
|
|
|
|
|
|
(next, prev) => {
|
|
|
|
|
|
const id = typeof next === 'string' ? next : '';
|
|
|
|
|
|
const prevId = typeof prev === 'string' ? prev : '';
|
|
|
|
|
|
if (id && id !== prevId && id !== activeViewId.value) {
|
|
|
|
|
|
void selectSavedView(id);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
void bootstrap();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [dash, orgRes] = await Promise.all([
|
|
|
|
|
|
api.get<ReportsDashboardDto>('/reports/dashboard', {
|
|
|
|
|
|
params: {
|
|
|
|
|
|
from: from.value || undefined,
|
|
|
|
|
|
to: to.value || undefined,
|
|
|
|
|
|
},
|
|
|
|
|
|
}),
|
|
|
|
|
|
api.get<OrganizationDto>('/organizations/current'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
data.value = dash.data;
|
|
|
|
|
|
org.value = orgRes.data;
|
|
|
|
|
|
} catch (e) {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'بارگذاری گزارش ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بارگذاری گزارش ناموفق بود.';
|
|
|
|
|
|
data.value = null;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
function exportReport() {
|
2026-09-27 11:54:23 +03:30
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
if (!data.value) return;
|
|
|
|
|
|
const d = data.value;
|
|
|
|
|
|
const lines: string[] = [
|
2026-09-07 11:49:54 +03:30
|
|
|
|
`گزارشات ${brand.nameFa}`,
|
2026-09-05 17:04:54 +03:30
|
|
|
|
`بازه,${rangeLabel()}`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'شاخص,مقدار,تغییر٪',
|
|
|
|
|
|
...d.kpis.map(
|
|
|
|
|
|
(k) =>
|
|
|
|
|
|
`${k.label},${k.value},${k.changePercent ?? ''}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
'',
|
|
|
|
|
|
'روند فروش',
|
|
|
|
|
|
'برچسب,مبلغ',
|
|
|
|
|
|
...d.salesTrend.map((p) => `${p.label},${p.value}`),
|
|
|
|
|
|
'',
|
|
|
|
|
|
'منابع',
|
|
|
|
|
|
'منبع,تعداد,درصد',
|
|
|
|
|
|
...d.sources.map((s) => `${s.label},${s.count},${s.percent}`),
|
|
|
|
|
|
'',
|
2026-09-26 14:59:48 +03:30
|
|
|
|
'دلایل شکست معاملات',
|
|
|
|
|
|
'دلیل,تعداد,درصد,مبلغ',
|
|
|
|
|
|
...(d.lostReasons ?? []).map(
|
|
|
|
|
|
(s) => `${s.label},${s.count},${s.percent},${s.amount ?? ''}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
'',
|
2026-09-05 17:04:54 +03:30
|
|
|
|
'پرفروشها',
|
|
|
|
|
|
'نام,تعداد,مبلغ',
|
|
|
|
|
|
...d.topProducts.map((p) => `${p.name},${p.quantity},${p.amount}`),
|
|
|
|
|
|
'',
|
|
|
|
|
|
'تیم',
|
|
|
|
|
|
'نام,فعالیت,فروش',
|
|
|
|
|
|
...d.team.map(
|
|
|
|
|
|
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
|
|
|
|
|
|
),
|
2026-09-27 15:36:34 +03:30
|
|
|
|
'',
|
|
|
|
|
|
'نرخ تبدیل (ورودی به موفق)',
|
|
|
|
|
|
`کل,${d.conversionRate?.conversionPercent ?? ''}٪`,
|
|
|
|
|
|
`ورودی,${d.conversionRate?.incomingCount ?? 0}`,
|
|
|
|
|
|
`موفق,${d.conversionRate?.wonCount ?? 0}`,
|
|
|
|
|
|
'',
|
|
|
|
|
|
'نرخ تبدیل بر اساس زمان',
|
|
|
|
|
|
'بازه,ورودی,موفق,نرخ٪',
|
|
|
|
|
|
...(d.conversionRate?.byTime ?? []).map(
|
|
|
|
|
|
(r) =>
|
|
|
|
|
|
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
|
|
|
|
|
|
),
|
|
|
|
|
|
'',
|
|
|
|
|
|
'نرخ تبدیل بر اساس کاربر',
|
|
|
|
|
|
'کاربر,ورودی,موفق,نرخ٪',
|
|
|
|
|
|
...(d.conversionRate?.byUser ?? []).map(
|
|
|
|
|
|
(r) =>
|
|
|
|
|
|
`${r.label},${r.incomingCount},${r.wonCount},${r.conversionPercent}`,
|
|
|
|
|
|
),
|
2026-09-05 17:04:54 +03:30
|
|
|
|
];
|
|
|
|
|
|
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;
|
2026-09-07 11:49:54 +03:30
|
|
|
|
a.download = `${brand.filePrefix}-report-${from.value || 'from'}-${to.value || 'to'}.csv`;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
a.click();
|
|
|
|
|
|
URL.revokeObjectURL(url);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
/* ——— ECharts Options ——— */
|
|
|
|
|
|
const salesTrendOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const trend = data.value?.salesTrend ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params: any) => {
|
|
|
|
|
|
const item = Array.isArray(params) ? params[0] : params;
|
|
|
|
|
|
if (!item) return '';
|
|
|
|
|
|
return `${item.name}<br/><strong>${money(item.value)}</strong>`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: { left: '3%', right: '4%', bottom: '8%', top: '12%', containLabel: true },
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
boundaryGap: false,
|
|
|
|
|
|
data: trend.map((p) => p.label),
|
|
|
|
|
|
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: '#64748b',
|
|
|
|
|
|
fontFamily: 'IRANSans',
|
|
|
|
|
|
formatter: (v: number) => num(v),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'فروش',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
showSymbol: false,
|
|
|
|
|
|
symbolSize: 6,
|
|
|
|
|
|
itemStyle: { color: '#14b8a6' },
|
|
|
|
|
|
lineStyle: { width: 3, color: '#0d9488' },
|
|
|
|
|
|
areaStyle: {
|
|
|
|
|
|
color: {
|
|
|
|
|
|
type: 'linear',
|
|
|
|
|
|
x: 0,
|
|
|
|
|
|
y: 0,
|
|
|
|
|
|
x2: 0,
|
|
|
|
|
|
y2: 1,
|
|
|
|
|
|
colorStops: [
|
|
|
|
|
|
{ offset: 0, color: 'rgba(20, 184, 166, 0.35)' },
|
|
|
|
|
|
{ offset: 1, color: 'rgba(20, 184, 166, 0.01)' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
data: trend.map((p) => p.value),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
2026-09-05 17:04:54 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const sourcesOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const sources = data.value?.sources ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'item',
|
|
|
|
|
|
formatter: (p: any) => `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
bottom: '0%',
|
|
|
|
|
|
left: 'center',
|
|
|
|
|
|
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
|
|
|
|
|
|
},
|
|
|
|
|
|
color: CHART_PALETTE,
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'منابع مشتریان',
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['45%', '70%'],
|
|
|
|
|
|
center: ['50%', '42%'],
|
|
|
|
|
|
avoidLabelOverlap: true,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
data: sources.map((s) => ({ name: s.label, value: s.count })),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-26 14:59:48 +03:30
|
|
|
|
const lostReasonsOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const rows = data.value?.lostReasons ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'item',
|
|
|
|
|
|
formatter: (p: any) => {
|
|
|
|
|
|
const row = rows.find((r) => r.label === p.name);
|
|
|
|
|
|
const amountPart =
|
|
|
|
|
|
row?.amount != null ? `<br/>مبلغ: <strong>${money(row.amount)}</strong>` : '';
|
|
|
|
|
|
return `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)${amountPart}`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
bottom: '0%',
|
|
|
|
|
|
left: 'center',
|
|
|
|
|
|
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
|
|
|
|
|
|
},
|
|
|
|
|
|
color: CHART_PALETTE,
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'دلایل شکست',
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['45%', '70%'],
|
|
|
|
|
|
center: ['50%', '42%'],
|
|
|
|
|
|
avoidLabelOverlap: true,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
data: rows.map((s) => ({ name: s.label, value: s.count })),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const monthlyOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const rows = data.value?.monthlyComparison ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params: any) => {
|
|
|
|
|
|
const item = Array.isArray(params) ? params[0] : params;
|
|
|
|
|
|
if (!item) return '';
|
|
|
|
|
|
return `${item.name}<br/><strong>${money(item.value)}</strong>`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: { left: '3%', right: '4%', bottom: '8%', top: '12%', containLabel: true },
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: rows.map((r) => r.label),
|
|
|
|
|
|
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans', formatter: (v: number) => num(v) },
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'فروش ماهانه',
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
barWidth: '38%',
|
|
|
|
|
|
itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] },
|
|
|
|
|
|
data: rows.map((r) => r.value),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-27 15:36:34 +03:30
|
|
|
|
const conversionTimeOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const rows = data.value?.conversionRate?.byTime ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params: any) => {
|
|
|
|
|
|
const list = Array.isArray(params) ? params : [params];
|
|
|
|
|
|
const name = list[0]?.name ?? '';
|
|
|
|
|
|
const row = rows.find((r) => r.label === name);
|
|
|
|
|
|
if (!row) return name;
|
|
|
|
|
|
return `${name}<br/>نرخ تبدیل: <strong>${toFaDigits(row.conversionPercent)}٪</strong><br/>موفق: ${num(row.wonCount)} از ${num(row.incomingCount)}`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: { left: '3%', right: '4%', bottom: '8%', top: '14%', containLabel: true },
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
|
|
|
|
|
|
},
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: rows.map((r) => r.label),
|
|
|
|
|
|
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: [
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
name: 'تعداد',
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: '#64748b',
|
|
|
|
|
|
fontFamily: 'IRANSans',
|
|
|
|
|
|
formatter: (v: number) => num(v),
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
name: 'نرخ ٪',
|
|
|
|
|
|
min: 0,
|
|
|
|
|
|
max: 100,
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
splitLine: { show: false },
|
|
|
|
|
|
axisLabel: {
|
|
|
|
|
|
color: '#64748b',
|
|
|
|
|
|
fontFamily: 'IRANSans',
|
|
|
|
|
|
formatter: (v: number) => `${toFaDigits(v)}٪`,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'ورودی',
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
barMaxWidth: 28,
|
|
|
|
|
|
itemStyle: { color: '#94a3b8', borderRadius: [6, 6, 0, 0] },
|
|
|
|
|
|
data: rows.map((r) => r.incomingCount),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'موفق',
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
barMaxWidth: 28,
|
|
|
|
|
|
itemStyle: { color: '#14b8a6', borderRadius: [6, 6, 0, 0] },
|
|
|
|
|
|
data: rows.map((r) => r.wonCount),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'نرخ تبدیل',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
yAxisIndex: 1,
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
showSymbol: true,
|
|
|
|
|
|
symbolSize: 7,
|
|
|
|
|
|
itemStyle: { color: '#0f766e' },
|
|
|
|
|
|
lineStyle: { width: 2, color: '#0f766e' },
|
|
|
|
|
|
data: rows.map((r) => r.conversionPercent),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const topProductsOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const prods = (data.value?.topProducts ?? []).slice(0, 5).reverse();
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
axisPointer: { type: 'shadow' },
|
|
|
|
|
|
formatter: (params: any) => {
|
|
|
|
|
|
const item = Array.isArray(params) ? params[0] : params;
|
|
|
|
|
|
if (!item) return '';
|
|
|
|
|
|
return `${item.name}: <strong>${num(item.value)} عدد</strong>`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: { left: '3%', right: '8%', bottom: '4%', top: '4%', containLabel: true },
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
splitLine: { lineStyle: { color: '#f1f5f9' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans', formatter: (v: number) => num(v) },
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
data: prods.map((p) => p.name),
|
|
|
|
|
|
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
|
|
|
|
|
axisLabel: { color: '#334155', fontFamily: 'IRANSans' },
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'تعداد فروش',
|
|
|
|
|
|
type: 'bar',
|
|
|
|
|
|
barWidth: '45%',
|
|
|
|
|
|
itemStyle: { color: '#0f766e', borderRadius: [0, 6, 6, 0] },
|
|
|
|
|
|
data: prods.map((p) => p.quantity),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const categoryOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const cats = data.value?.salesByCategory ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'item',
|
|
|
|
|
|
formatter: (p: any) => `${p.name}: <strong>${money(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
bottom: '0%',
|
|
|
|
|
|
left: 'center',
|
|
|
|
|
|
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
|
|
|
|
|
|
},
|
|
|
|
|
|
color: CHART_PALETTE,
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'دستهبندی',
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['45%', '70%'],
|
|
|
|
|
|
center: ['50%', '42%'],
|
|
|
|
|
|
avoidLabelOverlap: true,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
data: cats.map((c) => ({ name: c.label, value: c.amount ?? 0 })),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const statusOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const st = data.value?.customerStatus ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'item',
|
|
|
|
|
|
formatter: (p: any) => `${p.name}: <strong>${num(p.value)}</strong> (${toFaDigits(p.percent)}٪)`,
|
|
|
|
|
|
},
|
|
|
|
|
|
legend: {
|
|
|
|
|
|
bottom: '0%',
|
|
|
|
|
|
left: 'center',
|
|
|
|
|
|
textStyle: { fontFamily: 'IRANSans', fontSize: 11, color: '#475569' },
|
|
|
|
|
|
},
|
|
|
|
|
|
color: ['#10b981', '#f59e0b', '#3b82f6', '#94a3b8', '#ef4444'],
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'وضعیت مشتریان',
|
|
|
|
|
|
type: 'pie',
|
|
|
|
|
|
radius: ['45%', '70%'],
|
|
|
|
|
|
center: ['50%', '42%'],
|
|
|
|
|
|
avoidLabelOverlap: true,
|
|
|
|
|
|
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
|
|
|
|
|
label: { show: false },
|
|
|
|
|
|
data: st.map((s) => ({ name: s.label, value: s.count })),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const growthOption = computed<UiChartOption>(() => {
|
|
|
|
|
|
const gr = data.value?.customerGrowth ?? [];
|
|
|
|
|
|
return {
|
|
|
|
|
|
tooltip: {
|
|
|
|
|
|
trigger: 'axis',
|
|
|
|
|
|
formatter: (params: any) => {
|
|
|
|
|
|
const item = Array.isArray(params) ? params[0] : params;
|
|
|
|
|
|
if (!item) return '';
|
|
|
|
|
|
return `${item.name}<br/>رشد: <strong>${num(item.value)} مشتری</strong>`;
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
grid: { left: '3%', right: '4%', bottom: '8%', top: '12%', containLabel: true },
|
|
|
|
|
|
xAxis: {
|
|
|
|
|
|
type: 'category',
|
|
|
|
|
|
boundaryGap: false,
|
|
|
|
|
|
data: gr.map((p) => p.label),
|
|
|
|
|
|
axisLine: { lineStyle: { color: '#cbd5e1' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans' },
|
|
|
|
|
|
},
|
|
|
|
|
|
yAxis: {
|
|
|
|
|
|
type: 'value',
|
|
|
|
|
|
axisLine: { show: false },
|
|
|
|
|
|
splitLine: { lineStyle: { color: '#f1f5f9', type: 'dashed' } },
|
|
|
|
|
|
axisLabel: { color: '#64748b', fontFamily: 'IRANSans', formatter: (v: number) => num(v) },
|
|
|
|
|
|
},
|
|
|
|
|
|
series: [
|
|
|
|
|
|
{
|
|
|
|
|
|
name: 'رشد مشتریان',
|
|
|
|
|
|
type: 'line',
|
|
|
|
|
|
smooth: true,
|
|
|
|
|
|
showSymbol: false,
|
|
|
|
|
|
symbolSize: 6,
|
|
|
|
|
|
itemStyle: { color: '#3b82f6' },
|
|
|
|
|
|
lineStyle: { width: 3, color: '#2563eb' },
|
|
|
|
|
|
areaStyle: {
|
|
|
|
|
|
color: {
|
|
|
|
|
|
type: 'linear',
|
|
|
|
|
|
x: 0,
|
|
|
|
|
|
y: 0,
|
|
|
|
|
|
x2: 0,
|
|
|
|
|
|
y2: 1,
|
|
|
|
|
|
colorStops: [
|
|
|
|
|
|
{ offset: 0, color: 'rgba(59, 130, 246, 0.3)' },
|
|
|
|
|
|
{ offset: 1, color: 'rgba(59, 130, 246, 0.01)' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
data: gr.map((p) => p.value),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
|
|
|
|
|
const activityMax = computed(() =>
|
|
|
|
|
|
Math.max(
|
|
|
|
|
|
...(data.value?.activities.byType.map((t) => t.count) ?? [0]),
|
|
|
|
|
|
1,
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<section class="crm-page">
|
|
|
|
|
|
<UiPageHeader
|
|
|
|
|
|
title="گزارشات و داشبورد"
|
|
|
|
|
|
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #actions>
|
2026-09-27 11:11:33 +03:30
|
|
|
|
<UiButton variant="secondary" @click="openSaveModal">
|
|
|
|
|
|
<UiIcon name="layers" :size="16" />
|
|
|
|
|
|
ذخیره نما
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
<UiButton variant="secondary" @click="copyShareLink">
|
|
|
|
|
|
<UiIcon name="send" :size="16" />
|
|
|
|
|
|
کپی لینک اشتراک
|
|
|
|
|
|
</UiButton>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiButton
|
|
|
|
|
|
variant="primary"
|
2026-09-27 11:54:23 +03:30
|
|
|
|
:disabled="pageMode === 'dashboard' ? !data : !builderResult"
|
2026-09-12 01:33:07 +03:30
|
|
|
|
@click="exportReport"
|
|
|
|
|
|
>
|
|
|
|
|
|
<UiIcon name="document" :size="16" />
|
|
|
|
|
|
خروجی اکسل / CSV
|
|
|
|
|
|
</UiButton>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiPageHeader>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-27 11:54:23 +03:30
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- Toolbar Filters -->
|
|
|
|
|
|
<div class="crm-toolbar filter-toolbar">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<div class="period-chips">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="p in PERIODS"
|
|
|
|
|
|
:key="p.key"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="chip"
|
|
|
|
|
|
:class="{ on: period === p.key }"
|
|
|
|
|
|
@click="applyPeriod(p.key)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ p.label }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<button type="button" class="range-btn" @click="openCustom">
|
|
|
|
|
|
<UiIcon name="calendar" :size="16" />
|
|
|
|
|
|
<span>{{ rangeLabel() }}</span>
|
|
|
|
|
|
</button>
|
2026-09-27 11:11:33 +03:30
|
|
|
|
|
|
|
|
|
|
<label class="view-select">
|
|
|
|
|
|
<span>نماهای ذخیرهشده</span>
|
|
|
|
|
|
<select
|
|
|
|
|
|
:value="activeViewId ?? ''"
|
|
|
|
|
|
:disabled="viewsLoading"
|
|
|
|
|
|
@change="
|
|
|
|
|
|
selectSavedView(($event.target as HTMLSelectElement).value)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
<option value="">پیشفرض</option>
|
|
|
|
|
|
<option v-for="v in savedViews" :key="v.id" :value="v.id">
|
|
|
|
|
|
{{ v.name }}{{ v.isShared ? ' (سازمانی)' : '' }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
|
|
<UiButton
|
|
|
|
|
|
v-if="activeViewId && canManageActiveView"
|
|
|
|
|
|
variant="secondary"
|
|
|
|
|
|
@click="deleteActiveView"
|
|
|
|
|
|
>
|
|
|
|
|
|
حذف نما
|
|
|
|
|
|
</UiButton>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
<p v-if="shareNote" class="ok-note">{{ shareNote }}</p>
|
|
|
|
|
|
<p v-if="viewsError" class="error">{{ viewsError }}</p>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- Custom date inputs -->
|
2026-09-27 11:11:33 +03:30
|
|
|
|
<form
|
|
|
|
|
|
v-if="showCustomDates"
|
|
|
|
|
|
class="custom-range"
|
|
|
|
|
|
@submit.prevent="
|
|
|
|
|
|
activeViewId = null;
|
|
|
|
|
|
syncViewQuery(null);
|
2026-09-27 11:54:23 +03:30
|
|
|
|
load().then(() => {
|
|
|
|
|
|
if (pageMode === 'builder') runBuilder();
|
|
|
|
|
|
});
|
2026-09-27 11:11:33 +03:30
|
|
|
|
"
|
|
|
|
|
|
>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
|
|
|
|
|
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
2026-09-27 11:54:23 +03:30
|
|
|
|
<UiButton variant="primary" type="submit" :disabled="loading || builderLoading">
|
|
|
|
|
|
اعمال
|
|
|
|
|
|
</UiButton>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
|
|
<p v-if="error" class="error">{{ error }}</p>
|
2026-09-27 11:54:23 +03:30
|
|
|
|
|
|
|
|
|
|
<!-- 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 -->
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div v-else-if="loading" class="loading-wrap">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<span>در حال بارگذاری گزارشات...</span>
|
|
|
|
|
|
</div>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-27 11:54:23 +03:30
|
|
|
|
<template v-else-if="pageMode === 'dashboard' && data">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- KPI row -->
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<div class="kpis">
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<article v-for="k in data.kpis" :key="k.key" class="kpi-card">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<div class="kpi-top">
|
|
|
|
|
|
<span class="kpi-ico">
|
|
|
|
|
|
<UiIcon
|
|
|
|
|
|
:name="
|
|
|
|
|
|
k.key === 'new_customers'
|
|
|
|
|
|
? 'users'
|
|
|
|
|
|
: k.key === 'invoice_count'
|
|
|
|
|
|
? 'document'
|
|
|
|
|
|
: k.key === 'sales_amount'
|
|
|
|
|
|
? 'trend'
|
|
|
|
|
|
: 'dollar'
|
|
|
|
|
|
"
|
|
|
|
|
|
:size="18"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="kpi-label">{{ k.label }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<strong class="kpi-val">{{ formatKpi(k.value, k.format) }}</strong>
|
|
|
|
|
|
<div class="kpi-change" :class="{ down: (k.changePercent ?? 0) < 0, flat: k.changePercent == null }">
|
|
|
|
|
|
<span>{{ formatChange(k.changePercent) }}</span>
|
|
|
|
|
|
<small>{{ k.changeLabel }}</small>
|
|
|
|
|
|
</div>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<!-- Deep analytics (BI phase 1) -->
|
|
|
|
|
|
<div class="deep-grid">
|
2026-10-06 20:51:50 +03:30
|
|
|
|
<UiCard class="chart-card" title="تبدیل مراحل مسیر فروش">
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<p class="deep-hint">
|
|
|
|
|
|
سهم هر مرحله از معاملات باز و نرخ عبور نسبت به مرحله قبل. برای دیدن پروندهها روی مرحله کلیک کنید.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div v-if="funnelByPipeline.length" class="funnel-wrap">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="pipe in funnelByPipeline"
|
|
|
|
|
|
:key="pipe.name"
|
|
|
|
|
|
class="funnel-pipe"
|
|
|
|
|
|
>
|
|
|
|
|
|
<h4 class="funnel-pipe-title">{{ pipe.name }}</h4>
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="s in pipe.stages"
|
|
|
|
|
|
:key="s.stageId"
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="funnel-row"
|
|
|
|
|
|
@click="openDrill('stage_open', s.stageId)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="funnel-row-top">
|
|
|
|
|
|
<strong>{{ s.stageName }}</strong>
|
|
|
|
|
|
<span>{{ num(s.count) }} · {{ money(s.amount) }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="funnel-bar-track">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="funnel-bar-fill"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
width: `${Math.max(s.shareOfPipelinePercent, s.count ? 4 : 0)}%`,
|
|
|
|
|
|
}"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="funnel-row-meta">
|
2026-10-06 20:51:50 +03:30
|
|
|
|
<small>سهم از مسیر: {{ toFaDigits(s.shareOfPipelinePercent) }}٪</small>
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<small v-if="s.conversionFromPrevPercent != null">
|
|
|
|
|
|
تبدیل از قبل: {{ toFaDigits(s.conversionFromPrevPercent) }}٪
|
|
|
|
|
|
</small>
|
2026-10-06 20:51:50 +03:30
|
|
|
|
<small v-else>شروع مسیر</small>
|
2026-09-27 10:39:09 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-10-06 20:51:50 +03:30
|
|
|
|
<p v-else class="empty-msg">معامله بازی در مسیر فروش نیست.</p>
|
2026-09-27 10:39:09 +03:30
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
|
|
|
|
|
<UiCard class="chart-card" title="مقایسه با دوره قبل">
|
|
|
|
|
|
<p class="deep-hint">
|
|
|
|
|
|
بازه فعلی در برابر دوره همطول قبلی. ردیفهای قابلکلیک فهرست معاملات را باز میکنند.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<table v-if="data.periodCompare?.length" class="compare-table">
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<th>شاخص</th>
|
|
|
|
|
|
<th>فعلی</th>
|
|
|
|
|
|
<th>قبل</th>
|
|
|
|
|
|
<th>تغییر</th>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
<tr
|
|
|
|
|
|
v-for="row in data.periodCompare"
|
|
|
|
|
|
:key="row.key"
|
|
|
|
|
|
:class="{ clickable: Boolean(row.drillKind) }"
|
|
|
|
|
|
@click="row.drillKind && openDrill(row.drillKind)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<td>{{ row.label }}</td>
|
|
|
|
|
|
<td>{{ formatCompareValue(row.current, row.format) }}</td>
|
|
|
|
|
|
<td>{{ formatCompareValue(row.previous, row.format) }}</td>
|
|
|
|
|
|
<td
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
up: (row.changePercent ?? 0) > 0,
|
|
|
|
|
|
down: (row.changePercent ?? 0) < 0,
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ formatChange(row.changePercent) }}
|
|
|
|
|
|
</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
<p v-else class="empty-msg">دادهای برای مقایسه نیست.</p>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-27 15:36:34 +03:30
|
|
|
|
<div class="deep-grid conversion-grid">
|
|
|
|
|
|
<UiCard class="chart-card span-2" title="نرخ تبدیل">
|
|
|
|
|
|
<p class="deep-hint">
|
|
|
|
|
|
از معاملات تازهثبتشده در این بازه، چند درصد تا الان به فروش موفق رسیدهاند
|
|
|
|
|
|
(متفاوت از «نرخ برد» که فقط معاملات بستهشده را میبیند).
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="data.conversionRate"
|
|
|
|
|
|
class="conversion-summary"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="conversion-stat">
|
|
|
|
|
|
<span>نرخ تبدیل</span>
|
|
|
|
|
|
<strong>
|
|
|
|
|
|
{{ toFaDigits(data.conversionRate.conversionPercent) }}٪
|
|
|
|
|
|
</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{{ formatChange(data.conversionRate.changePercent) }} نسبت به دوره قبل
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="conversion-stat">
|
|
|
|
|
|
<span>ورودی دوره</span>
|
|
|
|
|
|
<strong>{{ num(data.conversionRate.incomingCount) }}</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
دوره قبل {{ num(data.conversionRate.previousIncomingCount) }}
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="conversion-stat">
|
|
|
|
|
|
<span>موفق از همین ورودیها</span>
|
|
|
|
|
|
<strong>{{ num(data.conversionRate.wonCount) }}</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
دوره قبل {{ num(data.conversionRate.previousWonCount) }}
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.conversionRate?.byTime?.length"
|
|
|
|
|
|
:option="conversionTimeOption"
|
|
|
|
|
|
height="240px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">در این بازه معامله ورودی ثبت نشده است.</p>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
|
|
|
|
|
<UiCard class="chart-card" title="نرخ تبدیل بر اساس کاربر">
|
|
|
|
|
|
<p class="deep-hint">
|
|
|
|
|
|
کیفیت تبدیل فرصتهای هر فروشنده در همین بازه.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ul
|
|
|
|
|
|
v-if="data.conversionRate?.byUser?.length"
|
|
|
|
|
|
class="conversion-list"
|
|
|
|
|
|
>
|
|
|
|
|
|
<li
|
|
|
|
|
|
v-for="u in data.conversionRate.byUser.slice(0, 10)"
|
|
|
|
|
|
:key="u.key"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="conversion-list-top">
|
|
|
|
|
|
<strong>{{ u.label }}</strong>
|
|
|
|
|
|
<em>{{ toFaDigits(u.conversionPercent) }}٪</em>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="hb-track">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="hb-fill"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
width: `${Math.max(4, Math.min(100, u.conversionPercent))}%`,
|
|
|
|
|
|
}"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{{ num(u.wonCount) }} موفق از {{ num(u.incomingCount) }} ورودی
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p v-else class="empty-msg">داده کاربری نیست.</p>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
|
|
|
|
|
<UiCard class="chart-card" title="نرخ تبدیل بر اساس منبع">
|
|
|
|
|
|
<p class="deep-hint">
|
|
|
|
|
|
کدام شیوه آشنایی / منبع ورودی بهتر به فروش میرسد.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ul
|
|
|
|
|
|
v-if="data.conversionRate?.bySource?.length"
|
|
|
|
|
|
class="conversion-list"
|
|
|
|
|
|
>
|
|
|
|
|
|
<li
|
|
|
|
|
|
v-for="s in data.conversionRate.bySource.slice(0, 10)"
|
|
|
|
|
|
:key="s.key"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="conversion-list-top">
|
|
|
|
|
|
<strong>{{ s.label }}</strong>
|
|
|
|
|
|
<em>{{ toFaDigits(s.conversionPercent) }}٪</em>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="hb-track">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="hb-fill"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
width: `${Math.max(4, Math.min(100, s.conversionPercent))}%`,
|
|
|
|
|
|
}"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{{ num(s.wonCount) }} موفق از {{ num(s.incomingCount) }} ورودی
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<div class="charts-grid">
|
|
|
|
|
|
<!-- Sales trend (wide) -->
|
|
|
|
|
|
<UiCard class="chart-card span-2" title="روند فروش">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.salesTrend?.length"
|
|
|
|
|
|
:option="salesTrendOption"
|
|
|
|
|
|
height="260px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">دادهای برای روند فروش نیست.</p>
|
|
|
|
|
|
</UiCard>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- Customer sources -->
|
|
|
|
|
|
<UiCard class="chart-card" title="منابع مشتریان">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.sources?.length"
|
|
|
|
|
|
:option="sourcesOption"
|
2026-09-27 10:39:09 +03:30
|
|
|
|
height="220px"
|
2026-09-12 01:33:07 +03:30
|
|
|
|
/>
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<ul v-if="data.sources?.length" class="drill-links">
|
|
|
|
|
|
<li v-for="s in data.sources" :key="s.label">
|
|
|
|
|
|
<button type="button" @click="openDrill('source', s.label)">
|
|
|
|
|
|
{{ s.label }}
|
|
|
|
|
|
<span>{{ num(s.count) }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
|
|
|
|
|
|
</UiCard>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
2026-09-26 14:59:48 +03:30
|
|
|
|
<!-- Lost reasons -->
|
|
|
|
|
|
<UiCard class="chart-card" title="دلایل عدم موفقیت فروش">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.lostReasons?.length"
|
|
|
|
|
|
:option="lostReasonsOption"
|
2026-09-27 10:39:09 +03:30
|
|
|
|
height="220px"
|
2026-09-26 14:59:48 +03:30
|
|
|
|
/>
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<ul v-if="data.lostReasons?.length" class="drill-links">
|
|
|
|
|
|
<li v-for="s in data.lostReasons" :key="s.label">
|
|
|
|
|
|
<button type="button" @click="openDrill('lost_reason', s.label)">
|
|
|
|
|
|
{{ s.label }}
|
|
|
|
|
|
<span>{{ num(s.count) }}</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-09-26 14:59:48 +03:30
|
|
|
|
<p v-else class="empty-msg">معامله شکستخوردهای در این بازه نیست.</p>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<!-- Monthly comparison -->
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiCard class="chart-card" title="مقایسه فروش ماههای اخیر">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.monthlyComparison?.length"
|
|
|
|
|
|
:option="monthlyOption"
|
|
|
|
|
|
height="260px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">دادهای نیست.</p>
|
|
|
|
|
|
</UiCard>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
|
|
|
|
|
<!-- Top products -->
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiCard class="chart-card" title="پرفروشترین محصولات">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.topProducts?.length"
|
|
|
|
|
|
:option="topProductsOption"
|
|
|
|
|
|
height="260px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">فروش محصولی در این بازه نیست.</p>
|
|
|
|
|
|
</UiCard>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- Sales by category -->
|
|
|
|
|
|
<UiCard class="chart-card" title="فروش بر اساس دستهبندی">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.salesByCategory?.length"
|
|
|
|
|
|
:option="categoryOption"
|
|
|
|
|
|
height="260px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">دستهبندی فروشی نیست.</p>
|
|
|
|
|
|
</UiCard>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
|
|
|
|
|
<!-- Customer status -->
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<UiCard class="chart-card" title="وضعیت مشتریان">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.customerStatus?.length"
|
|
|
|
|
|
:option="statusOption"
|
|
|
|
|
|
height="260px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">مشتری ثبت نشده است.</p>
|
|
|
|
|
|
</UiCard>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- Customer growth -->
|
|
|
|
|
|
<UiCard class="chart-card" title="رشد مشتریان">
|
|
|
|
|
|
<UiChart
|
|
|
|
|
|
v-if="data.customerGrowth?.length"
|
|
|
|
|
|
:option="growthOption"
|
|
|
|
|
|
height="260px"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p v-else class="empty-msg">دادهای نیست.</p>
|
|
|
|
|
|
</UiCard>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Team performance -->
|
|
|
|
|
|
<UiCard class="chart-card" title="عملکرد تیم">
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<p class="deep-hint">برای دیدن معاملات برنده هر نفر کلیک کنید.</p>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<ul v-if="data.team?.length" class="team-list">
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<li v-for="u in data.team.slice(0, 8)" :key="u.userId">
|
2026-09-27 10:39:09 +03:30
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="team-row-btn"
|
|
|
|
|
|
@click="openDrill('team_won', u.userId)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="avatar">{{ u.fullName.slice(0, 1) }}</span>
|
|
|
|
|
|
<div class="team-meta">
|
|
|
|
|
|
<strong>{{ u.fullName }}</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{{ num(u.wonCount) }} معامله ·
|
|
|
|
|
|
{{ num(u.activitiesDone) }} فعالیت
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<em class="team-won">{{ money(u.wonAmount) }}</em>
|
|
|
|
|
|
</button>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<p v-else class="empty-msg">داده تیمی نیست.</p>
|
|
|
|
|
|
</UiCard>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- Activities stats -->
|
|
|
|
|
|
<UiCard class="chart-card" title="فعالیتها">
|
|
|
|
|
|
<div class="act-stats">
|
|
|
|
|
|
<div class="act-stat-box">
|
|
|
|
|
|
<span>باز</span>
|
|
|
|
|
|
<strong>{{ num(data.activities.open) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="act-stat-box done">
|
|
|
|
|
|
<span>انجامشده</span>
|
|
|
|
|
|
<strong>{{ num(data.activities.done) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="act-stat-box overdue">
|
|
|
|
|
|
<span>عقبافتاده</span>
|
|
|
|
|
|
<strong>{{ num(data.activities.overdue) }}</strong>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<ul v-if="data.activities.byType.length" class="act-bars">
|
2026-08-29 21:06:30 +03:30
|
|
|
|
<li v-for="t in data.activities.byType" :key="t.type">
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<span class="act-bar-label">{{ t.label }}</span>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
<div class="hb-track">
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="hb-fill"
|
|
|
|
|
|
:style="{
|
|
|
|
|
|
width: `${Math.max(6, Math.round((t.count / activityMax) * 100))}%`,
|
|
|
|
|
|
}"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<em>{{ num(t.count) }}</em>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<p v-else class="empty-msg">فعالیتی در بازه نیست.</p>
|
|
|
|
|
|
</UiCard>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<!-- AI Insights -->
|
|
|
|
|
|
<UiCard class="chart-card span-full" title="تحلیل هوشمند">
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<div class="insights-head">
|
|
|
|
|
|
<UiIcon name="sparkle" :size="18" class="sparkle-ico" />
|
|
|
|
|
|
<h3 class="card-title">تحلیل هوشمند و پیشبینی</h3>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
</div>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
</template>
|
|
|
|
|
|
<ul v-if="data.insights?.length" class="insight-list">
|
|
|
|
|
|
<li v-for="(line, i) in data.insights" :key="i">
|
|
|
|
|
|
<UiIcon name="check" :size="16" class="check-ico" />
|
|
|
|
|
|
<span>{{ line }}</span>
|
|
|
|
|
|
</li>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</ul>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
<p v-else class="empty-msg">با ثبت فروش و مشتری، تحلیلهای هوشمند به طور خودکار نمایش داده میشوند.</p>
|
|
|
|
|
|
<div class="suggest-box">
|
|
|
|
|
|
<UiIcon name="lightbulb" :size="18" />
|
|
|
|
|
|
<span>پیشنهاد: روی دستهها و منابع مشتری با نرخ رشد و حاشیه سود بالاتر تمرکز کنید.</span>
|
2026-09-05 17:04:54 +03:30
|
|
|
|
</div>
|
2026-09-12 01:33:07 +03:30
|
|
|
|
</UiCard>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2026-09-27 10:39:09 +03:30
|
|
|
|
|
|
|
|
|
|
<UiModal
|
|
|
|
|
|
:open="drillOpen"
|
|
|
|
|
|
:title="drill?.title || 'فهرست پروندهها'"
|
|
|
|
|
|
@close="closeDrill"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div v-if="drillLoading" class="loading-wrap">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<span>در حال بارگذاری...</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-else-if="drillError" class="error">{{ drillError }}</p>
|
|
|
|
|
|
<template v-else-if="drill">
|
|
|
|
|
|
<p class="deep-hint">
|
|
|
|
|
|
{{ toFaDigits(drill.total) }} مورد
|
|
|
|
|
|
<span v-if="drill.total > drill.items.length">
|
|
|
|
|
|
(نمایش {{ toFaDigits(drill.items.length) }} مورد اول)
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<ul v-if="drill.items.length" class="drill-list">
|
|
|
|
|
|
<li v-for="item in drill.items" :key="item.id">
|
|
|
|
|
|
<button type="button" class="drill-item" @click="openDeal(item.link)">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<strong>{{ item.title }}</strong>
|
|
|
|
|
|
<small>
|
|
|
|
|
|
{{ item.statusLabel }}
|
|
|
|
|
|
<template v-if="item.stageName"> · {{ item.stageName }}</template>
|
|
|
|
|
|
<template v-if="item.ownerName"> · {{ item.ownerName }}</template>
|
|
|
|
|
|
</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<em>{{ formatMoney(item.amount, item.currency || currency) }}</em>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p v-else class="empty-msg">موردی یافت نشد.</p>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
2026-09-27 11:11:33 +03:30
|
|
|
|
|
|
|
|
|
|
<UiModal
|
|
|
|
|
|
:open="saveOpen"
|
|
|
|
|
|
:title="activeViewId && canManageActiveView ? 'بهروزرسانی نما' : 'ذخیره نمای گزارش'"
|
|
|
|
|
|
@close="saveOpen = false"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="save-form">
|
|
|
|
|
|
<UiField label="نام نما" for-id="report-view-name">
|
|
|
|
|
|
<UiInput
|
|
|
|
|
|
id="report-view-name"
|
|
|
|
|
|
v-model="saveName"
|
|
|
|
|
|
placeholder="مثلاً فروش ماه جاری تیم"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</UiField>
|
|
|
|
|
|
<label class="share-check">
|
|
|
|
|
|
<input v-model="saveShared" type="checkbox" />
|
|
|
|
|
|
<span>اشتراک با اعضای سازمان (قابل باز شدن با لینک)</span>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<p v-if="saveError" class="error">{{ saveError }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<template #footer>
|
|
|
|
|
|
<UiButton variant="secondary" @click="saveOpen = false">انصراف</UiButton>
|
|
|
|
|
|
<UiButton :loading="saveBusy" @click="saveCurrentView">ذخیره</UiButton>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</UiModal>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</section>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-09-27 11:54:23 +03:30
|
|
|
|
.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 11:11:33 +03:30
|
|
|
|
.view-select {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
min-height: var(--control-h-md);
|
|
|
|
|
|
padding: 0 var(--sp-3);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.view-select select {
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
max-width: 220px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.ok-note {
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
margin: 0 0 var(--sp-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.save-form {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.share-check {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
.deep-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
gap: var(--sp-4);
|
|
|
|
|
|
margin-bottom: var(--sp-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.deep-hint {
|
|
|
|
|
|
margin: 0 0 var(--sp-3);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 15:36:34 +03:30
|
|
|
|
.conversion-summary {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
margin-bottom: var(--sp-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-stat {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
padding: 0.75rem 0.85rem;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
background: var(--surface-subtle, #f8fafc);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-stat span {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-stat strong {
|
|
|
|
|
|
font-size: 1.35rem;
|
|
|
|
|
|
color: var(--accent-deep, #0f766e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-stat small {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-list li {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.3rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-list-top {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
align-items: baseline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-list-top em {
|
|
|
|
|
|
font-style: normal;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--accent-deep, #0f766e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.conversion-list small {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-27 10:39:09 +03:30
|
|
|
|
.funnel-wrap {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: var(--sp-4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-pipe-title {
|
|
|
|
|
|
margin: 0 0 var(--sp-2);
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-row {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
text-align: start;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
padding: 0.65rem 0.75rem;
|
|
|
|
|
|
margin-bottom: 0.45rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-row:hover {
|
|
|
|
|
|
border-color: var(--accent, #0f766e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-row-top,
|
|
|
|
|
|
.funnel-row-meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-row-meta {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-bar-track {
|
|
|
|
|
|
height: 6px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
background: var(--surface-subtle, #f1f5f9);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.funnel-bar-fill {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: var(--accent, #0f766e);
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table th,
|
|
|
|
|
|
.compare-table td {
|
|
|
|
|
|
padding: 0.55rem 0.35rem;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
text-align: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table th {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-weight: var(--fw-semibold);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table tr.clickable {
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table tr.clickable:hover td {
|
|
|
|
|
|
background: var(--surface-subtle, #f8fafc);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table .up {
|
|
|
|
|
|
color: #0f766e;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.compare-table .down {
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-links {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: var(--sp-3) 0 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.35rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-links button {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: var(--surface-subtle, #f8fafc);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
padding: 0.45rem 0.6rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-links button:hover {
|
|
|
|
|
|
background: #eef2f7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.team-row-btn {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: auto 1fr auto;
|
|
|
|
|
|
gap: 0.65rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
padding: 0.35rem 0;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: start;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.team-row-btn:hover strong {
|
|
|
|
|
|
color: var(--accent-deep, #0f766e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 0.4rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-item {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
background: #fff;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
padding: 0.7rem 0.8rem;
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
text-align: start;
|
|
|
|
|
|
color: inherit;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-item:hover {
|
|
|
|
|
|
border-color: var(--accent, #0f766e);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-item small {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-top: 0.2rem;
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.drill-item em {
|
|
|
|
|
|
font-style: normal;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
|
.deep-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
2026-09-27 15:36:34 +03:30
|
|
|
|
|
|
|
|
|
|
.conversion-summary {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
2026-09-27 10:39:09 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-12 01:33:07 +03:30
|
|
|
|
.filter-toolbar {
|
2026-09-05 02:29:41 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-3);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
flex-wrap: wrap;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.period-chips {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: 2px;
|
|
|
|
|
|
background: var(--surface-subtle);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
padding: 3px;
|
|
|
|
|
|
border-radius: var(--radius);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.chip {
|
|
|
|
|
|
border: 0;
|
|
|
|
|
|
background: transparent;
|
|
|
|
|
|
padding: 0 var(--sp-3);
|
|
|
|
|
|
min-height: calc(var(--control-h-md) - 6px);
|
|
|
|
|
|
border-radius: calc(var(--radius) - 2px);
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
font-weight: var(--fw-semibold);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all var(--transition-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.chip.on {
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
color: var(--accent-deep);
|
|
|
|
|
|
box-shadow: var(--shadow-xs);
|
|
|
|
|
|
font-weight: var(--fw-bold);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.range-btn {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
min-height: var(--control-h-md);
|
|
|
|
|
|
padding: 0 var(--sp-3);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
font: inherit;
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
font-weight: var(--fw-medium);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.custom-range {
|
|
|
|
|
|
display: flex;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
padding: var(--sp-3);
|
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
flex-wrap: wrap;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.loading-wrap {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
padding: var(--sp-10);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: var(--fs-base);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* KPI Cards */
|
2026-08-29 21:06:30 +03:30
|
|
|
|
.kpis {
|
|
|
|
|
|
display: grid;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
|
|
|
|
gap: var(--sp-4);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.kpi-card {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
background: var(--surface);
|
2026-09-12 01:33:07 +03:30
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
padding: var(--sp-4);
|
|
|
|
|
|
box-shadow: var(--shadow-xs);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-2);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-top {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-2);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-ico {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
width: 34px;
|
|
|
|
|
|
height: 34px;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
background: var(--accent-soft);
|
|
|
|
|
|
color: var(--accent-deep);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.kpi-label {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
font-weight: var(--fw-medium);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-val {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
font-size: var(--fs-xl);
|
|
|
|
|
|
font-weight: var(--fw-extrabold);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
line-height: var(--lh-tight);
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-change {
|
|
|
|
|
|
display: flex;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
font-weight: var(--fw-semibold);
|
|
|
|
|
|
color: var(--success);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-change.down {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
color: var(--danger);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.kpi-change.flat {
|
|
|
|
|
|
color: var(--muted);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.kpi-change small {
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-weight: normal;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Charts Grid */
|
|
|
|
|
|
.charts-grid {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
display: grid;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
grid-template-columns: repeat(2, 1fr);
|
|
|
|
|
|
gap: var(--sp-4);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.chart-card.span-2 {
|
|
|
|
|
|
grid-column: span 2;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.chart-card.span-full {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
grid-column: 1 / -1;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.empty-msg {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
text-align: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
padding: var(--sp-6);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
font-size: var(--fs-sm);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
/* Team List */
|
|
|
|
|
|
.team-list {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-2);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.team-list li {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
gap: var(--sp-3);
|
|
|
|
|
|
padding: var(--sp-2) 0;
|
|
|
|
|
|
border-bottom: 1px solid var(--line-light);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.team-list li:last-child {
|
|
|
|
|
|
border-bottom: none;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
2026-09-05 17:04:54 +03:30
|
|
|
|
.avatar {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
width: 34px;
|
|
|
|
|
|
height: 34px;
|
|
|
|
|
|
border-radius: 50%;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
background: var(--accent-soft);
|
2026-09-12 01:33:07 +03:30
|
|
|
|
color: var(--accent-deep);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
display: grid;
|
|
|
|
|
|
place-items: center;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
font-weight: var(--fw-bold);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.team-meta {
|
|
|
|
|
|
flex: 1;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
display: flex;
|
2026-09-12 01:33:07 +03:30
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 2px;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.team-meta strong {
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.team-meta small {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.team-won {
|
|
|
|
|
|
font-style: normal;
|
|
|
|
|
|
font-weight: var(--fw-bold);
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--accent-deep);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
/* Activity Stats */
|
|
|
|
|
|
.act-stats {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, 1fr);
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
margin-bottom: var(--sp-4);
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.act-stat-box {
|
|
|
|
|
|
background: var(--surface-subtle);
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
padding: var(--sp-2);
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 2px;
|
2026-09-05 17:04:54 +03:30
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.act-stat-box span {
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
color: var(--muted);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.act-stat-box strong {
|
|
|
|
|
|
font-size: var(--fs-base);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.act-stat-box.done strong {
|
|
|
|
|
|
color: var(--success);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.act-stat-box.overdue strong {
|
|
|
|
|
|
color: var(--danger);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.act-bars {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.act-bars li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.act-bar-label {
|
|
|
|
|
|
flex: 0 0 70px;
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hb-track {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
height: 8px;
|
|
|
|
|
|
background: var(--surface-subtle);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hb-fill {
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
background: var(--accent);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Insights */
|
|
|
|
|
|
.insights-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sparkle-ico {
|
|
|
|
|
|
color: var(--accent);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-title {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
font-size: var(--fs-md);
|
|
|
|
|
|
font-weight: var(--fw-bold);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.insight-list {
|
|
|
|
|
|
list-style: none;
|
|
|
|
|
|
margin: 0 0 var(--sp-4);
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.insight-list li {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
font-size: var(--fs-sm);
|
|
|
|
|
|
color: var(--ink-secondary);
|
|
|
|
|
|
background: var(--surface-subtle);
|
|
|
|
|
|
padding: var(--sp-2) var(--sp-3);
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.check-ico {
|
|
|
|
|
|
color: var(--success);
|
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.suggest-box {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--sp-2);
|
|
|
|
|
|
padding: var(--sp-3);
|
|
|
|
|
|
background: var(--accent-subtle);
|
|
|
|
|
|
border: 1px solid color-mix(in srgb, var(--accent) 25%, transparent);
|
|
|
|
|
|
border-radius: var(--radius);
|
|
|
|
|
|
color: var(--accent-deep);
|
|
|
|
|
|
font-size: var(--fs-xs);
|
|
|
|
|
|
font-weight: var(--fw-medium);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
|
|
|
|
|
.charts-grid {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
2026-09-12 01:33:07 +03:30
|
|
|
|
|
|
|
|
|
|
.chart-card.span-2 {
|
2026-09-05 17:04:54 +03:30
|
|
|
|
grid-column: auto;
|
2026-09-05 02:29:41 +03:30
|
|
|
|
}
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
</style>
|