Require both the tahesab org module and at least one enabled accounting connection.
2613 lines
71 KiB
Vue
2613 lines
71 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, ref, watch } from 'vue';
|
||
import { useRoute, useRouter } from 'vue-router';
|
||
import axios from 'axios';
|
||
import { api } from '@/lib/api';
|
||
import { useAuthStore } from '@/stores/auth';
|
||
import { formatMoney } from '@/lib/money';
|
||
import {
|
||
dateToJalali,
|
||
formatJalaliNumeric,
|
||
formatLocalDateInput,
|
||
jalaliToDate,
|
||
toFaDigits,
|
||
} from '@/lib/jalali';
|
||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||
import UiIcon from '@/components/UiIcon.vue';
|
||
import {
|
||
UiButton,
|
||
UiCard,
|
||
UiField,
|
||
UiInput,
|
||
UiModal,
|
||
UiPageHeader,
|
||
UiChart,
|
||
CHART_PALETTE,
|
||
type UiChartOption,
|
||
} from '@/components/ui';
|
||
import type {
|
||
OrganizationDto,
|
||
OrgUserDto,
|
||
ReportsBuilderDimension,
|
||
ReportsBuilderMetric,
|
||
ReportsBuilderResultDto,
|
||
ReportsDashboardDto,
|
||
ReportsDrillKind,
|
||
ReportsDrilldownDto,
|
||
ReportsFunnelStageDto,
|
||
ReportsPeriodCompareRowDto,
|
||
ReportsViewFilters,
|
||
SavedViewDto,
|
||
TahesabConnectionDto,
|
||
TeamDto,
|
||
} from '@mento/shared';
|
||
import {
|
||
brand,
|
||
REPORTS_BUILDER_DIMENSION_LABELS,
|
||
REPORTS_BUILDER_DIMENSIONS,
|
||
REPORTS_BUILDER_METRIC_LABELS,
|
||
REPORTS_BUILDER_METRICS,
|
||
} from '@mento/shared';
|
||
|
||
type PeriodKey = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
||
type PageMode = 'dashboard' | 'builder';
|
||
|
||
const PERIODS: Array<{ key: PeriodKey; label: string }> = [
|
||
{ key: 'today', label: 'امروز' },
|
||
{ key: 'week', label: 'هفته' },
|
||
{ key: 'month', label: 'ماه' },
|
||
{ key: 'quarter', label: 'سهماهه' },
|
||
{ key: 'year', label: 'سال' },
|
||
];
|
||
|
||
const PERIOD_KEYS = new Set<PeriodKey>([
|
||
'today',
|
||
'week',
|
||
'month',
|
||
'quarter',
|
||
'year',
|
||
'custom',
|
||
]);
|
||
|
||
const router = useRouter();
|
||
const route = useRoute();
|
||
const auth = useAuthStore();
|
||
const loading = ref(false);
|
||
const error = ref('');
|
||
const data = ref<ReportsDashboardDto | null>(null);
|
||
const org = ref<OrganizationDto | null>(null);
|
||
const period = ref<PeriodKey>('month');
|
||
const from = ref('');
|
||
const to = ref('');
|
||
const showCustomDates = ref(false);
|
||
|
||
const drillOpen = ref(false);
|
||
const drillLoading = ref(false);
|
||
const drillError = ref('');
|
||
const drill = ref<ReportsDrilldownDto | null>(null);
|
||
|
||
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('');
|
||
|
||
const pageMode = ref<PageMode>('dashboard');
|
||
const builderMetric = ref<ReportsBuilderMetric>('won_amount');
|
||
const builderDimension = ref<ReportsBuilderDimension>('owner');
|
||
const builderUserId = ref('');
|
||
const builderTeamId = ref('');
|
||
const builderLoading = ref(false);
|
||
const builderError = ref('');
|
||
const builderResult = ref<ReportsBuilderResultDto | null>(null);
|
||
const orgUsers = ref<OrgUserDto[]>([]);
|
||
const orgTeams = ref<TeamDto[]>([]);
|
||
const teamsAvailable = ref(true);
|
||
/** ماژول تهحساب فعال + حداقل یک اتصال تنظیمشده */
|
||
const hasTahesabSetup = ref(false);
|
||
|
||
const showTahesabSalesReport = computed(
|
||
() =>
|
||
Boolean(auth.user?.enabledModules?.includes('tahesab')) &&
|
||
hasTahesabSetup.value,
|
||
);
|
||
|
||
const currency = computed(
|
||
() =>
|
||
builderResult.value?.currency ||
|
||
data.value?.currency ||
|
||
org.value?.baseCurrency ||
|
||
'IRR',
|
||
);
|
||
|
||
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;
|
||
});
|
||
|
||
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()];
|
||
});
|
||
|
||
function money(n: number | null | undefined) {
|
||
if (n == null || Number.isNaN(n)) return '—';
|
||
return formatMoney(n, currency.value);
|
||
}
|
||
|
||
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)}٪`;
|
||
}
|
||
|
||
function formatCompareValue(
|
||
value: number,
|
||
format: ReportsPeriodCompareRowDto['format'],
|
||
) {
|
||
if (format === 'money') return money(value);
|
||
if (format === 'percent') return `${toFaDigits(value)}٪`;
|
||
return num(value);
|
||
}
|
||
|
||
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 '—';
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function applyPeriod(key: PeriodKey, opts?: { keepView?: boolean }) {
|
||
period.value = key;
|
||
showCustomDates.value = key === 'custom';
|
||
if (!opts?.keepView) {
|
||
activeViewId.value = null;
|
||
shareNote.value = '';
|
||
syncViewQuery(null);
|
||
}
|
||
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));
|
||
}
|
||
void load().then(() => {
|
||
if (pageMode.value === 'builder') void runBuilder();
|
||
});
|
||
}
|
||
|
||
function openCustom() {
|
||
period.value = 'custom';
|
||
showCustomDates.value = true;
|
||
activeViewId.value = null;
|
||
syncViewQuery(null);
|
||
}
|
||
|
||
function currentFilters(): ReportsViewFilters {
|
||
return {
|
||
period: period.value,
|
||
from: from.value || undefined,
|
||
to: to.value || undefined,
|
||
mode: pageMode.value,
|
||
builder: {
|
||
metric: builderMetric.value,
|
||
dimension: builderDimension.value,
|
||
userId: builderUserId.value || undefined,
|
||
teamId: builderTeamId.value || undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
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';
|
||
const mode = raw.mode === 'builder' ? 'builder' : 'dashboard';
|
||
const builderRaw =
|
||
raw.builder && typeof raw.builder === 'object'
|
||
? (raw.builder as Record<string, unknown>)
|
||
: {};
|
||
const metric = String(builderRaw.metric ?? 'won_amount');
|
||
const dimension = String(builderRaw.dimension ?? 'owner');
|
||
return {
|
||
period: p,
|
||
from: typeof raw.from === 'string' ? raw.from : undefined,
|
||
to: typeof raw.to === 'string' ? raw.to : undefined,
|
||
mode,
|
||
builder: {
|
||
metric: REPORTS_BUILDER_METRICS.includes(metric as ReportsBuilderMetric)
|
||
? metric
|
||
: 'won_amount',
|
||
dimension: REPORTS_BUILDER_DIMENSIONS.includes(
|
||
dimension as ReportsBuilderDimension,
|
||
)
|
||
? dimension
|
||
: 'owner',
|
||
userId:
|
||
typeof builderRaw.userId === 'string' ? builderRaw.userId : undefined,
|
||
teamId:
|
||
typeof builderRaw.teamId === 'string' ? builderRaw.teamId : undefined,
|
||
},
|
||
};
|
||
}
|
||
|
||
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) {
|
||
pageMode.value = filters.mode === 'builder' ? 'builder' : 'dashboard';
|
||
if (filters.builder) {
|
||
const m = filters.builder.metric;
|
||
const d = filters.builder.dimension;
|
||
if (
|
||
m &&
|
||
REPORTS_BUILDER_METRICS.includes(m as ReportsBuilderMetric)
|
||
) {
|
||
builderMetric.value = m as ReportsBuilderMetric;
|
||
}
|
||
if (
|
||
d &&
|
||
REPORTS_BUILDER_DIMENSIONS.includes(d as ReportsBuilderDimension)
|
||
) {
|
||
builderDimension.value = d as ReportsBuilderDimension;
|
||
}
|
||
builderUserId.value = filters.builder.userId ?? '';
|
||
builderTeamId.value = filters.builder.teamId ?? '';
|
||
}
|
||
|
||
const afterRange = () => {
|
||
if (pageMode.value === 'builder') void runBuilder();
|
||
};
|
||
|
||
const p = PERIOD_KEYS.has(filters.period as PeriodKey)
|
||
? (filters.period as PeriodKey)
|
||
: 'month';
|
||
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);
|
||
}
|
||
void load().then(afterRange);
|
||
return;
|
||
}
|
||
applyPeriod(p, { keepView });
|
||
}
|
||
|
||
async function loadFilterOptions() {
|
||
try {
|
||
const { data: users } = await api.get<OrgUserDto[]>('/users');
|
||
orgUsers.value = users.filter((u) => u.isActive !== false);
|
||
} catch {
|
||
orgUsers.value = [];
|
||
}
|
||
try {
|
||
const { data: teams } = await api.get<TeamDto[]>('/teams');
|
||
orgTeams.value = teams;
|
||
teamsAvailable.value = true;
|
||
} catch (e) {
|
||
orgTeams.value = [];
|
||
teamsAvailable.value =
|
||
!(axios.isAxiosError(e) && e.response?.status === 403);
|
||
}
|
||
}
|
||
|
||
async function runBuilder() {
|
||
builderLoading.value = true;
|
||
builderError.value = '';
|
||
try {
|
||
const { data: res } = await api.post<ReportsBuilderResultDto>(
|
||
'/reports/builder',
|
||
{
|
||
metric: builderMetric.value,
|
||
dimension: builderDimension.value,
|
||
from: from.value || undefined,
|
||
to: to.value || undefined,
|
||
userId: builderUserId.value || undefined,
|
||
teamId: builderTeamId.value || undefined,
|
||
},
|
||
);
|
||
builderResult.value = res;
|
||
} catch (e) {
|
||
builderResult.value = null;
|
||
builderError.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string })?.message ??
|
||
'اجرای گزارشساز ناموفق بود.',
|
||
)
|
||
: 'اجرای گزارشساز ناموفق بود.';
|
||
} finally {
|
||
builderLoading.value = false;
|
||
}
|
||
}
|
||
|
||
function setPageMode(mode: PageMode) {
|
||
pageMode.value = mode;
|
||
if (mode === 'builder' && !builderResult.value) void runBuilder();
|
||
if (mode === 'dashboard' && !data.value) void load();
|
||
}
|
||
|
||
const builderChartOption = computed<UiChartOption>(() => {
|
||
const rows = (builderResult.value?.rows ?? []).slice(0, 12);
|
||
const isMoney = builderResult.value?.format === 'money';
|
||
return {
|
||
tooltip: {
|
||
trigger: 'axis',
|
||
formatter: (params: any) => {
|
||
const item = Array.isArray(params) ? params[0] : params;
|
||
if (!item) return '';
|
||
const val = isMoney ? money(item.value) : num(item.value);
|
||
return `${item.name}<br/><strong>${val}</strong>`;
|
||
},
|
||
},
|
||
grid: { left: 16, right: 16, top: 24, bottom: 48, containLabel: true },
|
||
xAxis: {
|
||
type: 'category',
|
||
data: rows.map((r) => r.label),
|
||
axisLabel: { rotate: rows.length > 5 ? 30 : 0, fontSize: 11 },
|
||
},
|
||
yAxis: { type: 'value' },
|
||
series: [
|
||
{
|
||
type: 'bar',
|
||
data: rows.map((r) => r.value),
|
||
itemStyle: { color: CHART_PALETTE[0] },
|
||
barMaxWidth: 42,
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
async function selectSavedView(id: string) {
|
||
if (!id) {
|
||
activeViewId.value = null;
|
||
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 loadTahesabSetup() {
|
||
if (!auth.user?.enabledModules?.includes('tahesab')) {
|
||
hasTahesabSetup.value = false;
|
||
return;
|
||
}
|
||
try {
|
||
const { data } = await api.get<TahesabConnectionDto[]>(
|
||
'/integrations/tahesab/connections/public',
|
||
);
|
||
hasTahesabSetup.value = (data ?? []).some((c) => c.enabled);
|
||
} catch {
|
||
hasTahesabSetup.value = false;
|
||
}
|
||
}
|
||
|
||
async function bootstrap() {
|
||
await Promise.all([
|
||
loadSavedViews(),
|
||
loadFilterOptions(),
|
||
loadTahesabSetup(),
|
||
]);
|
||
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();
|
||
});
|
||
|
||
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) {
|
||
error.value = axios.isAxiosError(e)
|
||
? String(
|
||
(e.response?.data as { message?: string })?.message ??
|
||
'بارگذاری گزارش ناموفق بود.',
|
||
)
|
||
: 'بارگذاری گزارش ناموفق بود.';
|
||
data.value = null;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function exportReport() {
|
||
if (pageMode.value === 'builder') {
|
||
if (!builderResult.value) return;
|
||
const b = builderResult.value;
|
||
const lines: string[] = [
|
||
`گزارشساز ${brand.nameFa}`,
|
||
`بازه,${rangeLabel()}`,
|
||
`متریک,${b.metricLabel}`,
|
||
`بعد,${b.dimensionLabel}`,
|
||
`جمع,${b.total}`,
|
||
'',
|
||
`${b.dimensionLabel},${b.metricLabel}`,
|
||
...b.rows.map((r) => `${r.label},${r.value}`),
|
||
];
|
||
const blob = new Blob(['\ufeff' + lines.join('\n')], {
|
||
type: 'text/csv;charset=utf-8',
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${brand.filePrefix}-builder-${from.value || 'from'}-${to.value || 'to'}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
return;
|
||
}
|
||
|
||
if (!data.value) return;
|
||
const d = data.value;
|
||
const lines: string[] = [
|
||
`گزارشات ${brand.nameFa}`,
|
||
`بازه,${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}`),
|
||
'',
|
||
'دلایل شکست معاملات',
|
||
'دلیل,تعداد,درصد,مبلغ',
|
||
...(d.lostReasons ?? []).map(
|
||
(s) => `${s.label},${s.count},${s.percent},${s.amount ?? ''}`,
|
||
),
|
||
'',
|
||
'پرفروشها',
|
||
'نام,تعداد,مبلغ',
|
||
...d.topProducts.map((p) => `${p.name},${p.quantity},${p.amount}`),
|
||
'',
|
||
'تیم',
|
||
'نام,فعالیت,فروش',
|
||
...d.team.map(
|
||
(t) => `${t.fullName},${t.activitiesDone},${t.wonAmount}`,
|
||
),
|
||
'',
|
||
'نرخ تبدیل (ورودی به موفق)',
|
||
`کل,${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}`,
|
||
),
|
||
];
|
||
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}-report-${from.value || 'from'}-${to.value || 'to'}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
/* ——— 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),
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
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 })),
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
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 })),
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
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),
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
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),
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
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),
|
||
},
|
||
],
|
||
};
|
||
});
|
||
|
||
const activityMax = computed(() =>
|
||
Math.max(
|
||
...(data.value?.activities.byType.map((t) => t.count) ?? [0]),
|
||
1,
|
||
),
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<section class="crm-page">
|
||
<UiPageHeader
|
||
title="گزارشات و داشبورد"
|
||
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
||
>
|
||
<template #actions>
|
||
<UiButton
|
||
v-if="showTahesabSalesReport"
|
||
variant="secondary"
|
||
@click="router.push({ name: 'tahesab-sales-report' })"
|
||
>
|
||
<UiIcon name="chart" :size="16" />
|
||
فروش تهحساب
|
||
</UiButton>
|
||
<UiButton variant="secondary" @click="openSaveModal">
|
||
<UiIcon name="layers" :size="16" />
|
||
ذخیره نما
|
||
</UiButton>
|
||
<UiButton variant="secondary" @click="copyShareLink">
|
||
<UiIcon name="send" :size="16" />
|
||
کپی لینک اشتراک
|
||
</UiButton>
|
||
<UiButton
|
||
variant="primary"
|
||
:disabled="pageMode === 'dashboard' ? !data : !builderResult"
|
||
@click="exportReport"
|
||
>
|
||
<UiIcon name="document" :size="16" />
|
||
خروجی اکسل / CSV
|
||
</UiButton>
|
||
</template>
|
||
</UiPageHeader>
|
||
|
||
<div class="mode-tabs">
|
||
<button
|
||
type="button"
|
||
class="mode-tab"
|
||
:class="{ on: pageMode === 'dashboard' }"
|
||
@click="setPageMode('dashboard')"
|
||
>
|
||
داشبورد
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="mode-tab"
|
||
:class="{ on: pageMode === 'builder' }"
|
||
@click="setPageMode('builder')"
|
||
>
|
||
گزارشساز
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Toolbar Filters -->
|
||
<div class="crm-toolbar filter-toolbar">
|
||
<div class="period-chips">
|
||
<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>
|
||
|
||
<button type="button" class="range-btn" @click="openCustom">
|
||
<UiIcon name="calendar" :size="16" />
|
||
<span>{{ rangeLabel() }}</span>
|
||
</button>
|
||
|
||
<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>
|
||
</div>
|
||
|
||
<p v-if="shareNote" class="ok-note">{{ shareNote }}</p>
|
||
<p v-if="viewsError" class="error">{{ viewsError }}</p>
|
||
|
||
<!-- Custom date inputs -->
|
||
<form
|
||
v-if="showCustomDates"
|
||
class="custom-range"
|
||
@submit.prevent="
|
||
activeViewId = null;
|
||
syncViewQuery(null);
|
||
load().then(() => {
|
||
if (pageMode === 'builder') runBuilder();
|
||
});
|
||
"
|
||
>
|
||
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
||
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
||
<UiButton variant="primary" type="submit" :disabled="loading || builderLoading">
|
||
اعمال
|
||
</UiButton>
|
||
</form>
|
||
|
||
<p v-if="error" class="error">{{ error }}</p>
|
||
|
||
<!-- Builder mode -->
|
||
<template v-if="pageMode === 'builder'">
|
||
<UiCard class="builder-card" title="گزارشساز ساده">
|
||
<p class="deep-hint">
|
||
متریک و بُعد را انتخاب کنید؛ میتوانید بازه بالا و فیلتر کاربر/تیم را هم اعمال کنید.
|
||
</p>
|
||
<div class="builder-form">
|
||
<UiField label="متریک">
|
||
<select v-model="builderMetric" class="builder-select">
|
||
<option
|
||
v-for="m in REPORTS_BUILDER_METRICS"
|
||
:key="m"
|
||
:value="m"
|
||
>
|
||
{{ REPORTS_BUILDER_METRIC_LABELS[m] }}
|
||
</option>
|
||
</select>
|
||
</UiField>
|
||
<UiField label="گروهبندی (بُعد)">
|
||
<select v-model="builderDimension" class="builder-select">
|
||
<option
|
||
v-for="d in REPORTS_BUILDER_DIMENSIONS"
|
||
:key="d"
|
||
:value="d"
|
||
>
|
||
{{ REPORTS_BUILDER_DIMENSION_LABELS[d] }}
|
||
</option>
|
||
</select>
|
||
</UiField>
|
||
<UiField label="کاربر">
|
||
<select v-model="builderUserId" class="builder-select">
|
||
<option value="">همه کاربران مجاز</option>
|
||
<option v-for="u in orgUsers" :key="u.id" :value="u.id">
|
||
{{ u.fullName }}
|
||
</option>
|
||
</select>
|
||
</UiField>
|
||
<UiField label="تیم">
|
||
<select
|
||
v-model="builderTeamId"
|
||
class="builder-select"
|
||
:disabled="!teamsAvailable"
|
||
>
|
||
<option value="">همه تیمها</option>
|
||
<option v-for="t in orgTeams" :key="t.id" :value="t.id">
|
||
{{ t.name }}
|
||
</option>
|
||
</select>
|
||
</UiField>
|
||
<div class="builder-actions">
|
||
<UiButton :loading="builderLoading" @click="runBuilder">
|
||
اجرای گزارش
|
||
</UiButton>
|
||
</div>
|
||
</div>
|
||
<p v-if="!teamsAvailable" class="deep-hint">
|
||
ماژول تیمها برای این محیط فعال نیست؛ فیلتر تیم در دسترس نیست.
|
||
</p>
|
||
<p v-if="builderError" class="error">{{ builderError }}</p>
|
||
</UiCard>
|
||
|
||
<div v-if="builderLoading" class="loading-wrap">
|
||
<div class="spinner"></div>
|
||
<span>در حال ساخت گزارش...</span>
|
||
</div>
|
||
|
||
<template v-else-if="builderResult">
|
||
<div class="builder-kpi">
|
||
<article class="kpi-card">
|
||
<div class="kpi-top">
|
||
<span class="kpi-label">{{ builderResult.metricLabel }}</span>
|
||
</div>
|
||
<strong class="kpi-val">
|
||
{{
|
||
builderResult.format === 'money'
|
||
? money(builderResult.total)
|
||
: num(builderResult.total)
|
||
}}
|
||
</strong>
|
||
<div class="kpi-change flat">
|
||
<small>{{ builderResult.dimensionLabel }}</small>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div class="charts-grid">
|
||
<UiCard class="chart-card span-2" title="نمودار">
|
||
<UiChart
|
||
v-if="builderResult.rows.length"
|
||
:option="builderChartOption"
|
||
height="300px"
|
||
/>
|
||
<p v-else class="empty-msg">دادهای برای نمایش نیست.</p>
|
||
</UiCard>
|
||
<UiCard class="chart-card" title="جدول">
|
||
<table v-if="builderResult.rows.length" class="compare-table">
|
||
<thead>
|
||
<tr>
|
||
<th>{{ builderResult.dimensionLabel }}</th>
|
||
<th>{{ builderResult.metricLabel }}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="row in builderResult.rows" :key="row.key">
|
||
<td>{{ row.label }}</td>
|
||
<td>
|
||
{{
|
||
builderResult.format === 'money'
|
||
? money(row.value)
|
||
: num(row.value)
|
||
}}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="empty-msg">ردیفی نیست.</p>
|
||
</UiCard>
|
||
</div>
|
||
</template>
|
||
</template>
|
||
|
||
<!-- Dashboard mode -->
|
||
<div v-else-if="loading" class="loading-wrap">
|
||
<div class="spinner"></div>
|
||
<span>در حال بارگذاری گزارشات...</span>
|
||
</div>
|
||
|
||
<template v-else-if="pageMode === 'dashboard' && data">
|
||
<!-- KPI row -->
|
||
<div class="kpis">
|
||
<article v-for="k in data.kpis" :key="k.key" class="kpi-card">
|
||
<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>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- Deep analytics (BI phase 1) -->
|
||
<div class="deep-grid">
|
||
<UiCard class="chart-card" title="تبدیل مراحل مسیر فروش">
|
||
<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">
|
||
<small>سهم از مسیر: {{ toFaDigits(s.shareOfPipelinePercent) }}٪</small>
|
||
<small v-if="s.conversionFromPrevPercent != null">
|
||
تبدیل از قبل: {{ toFaDigits(s.conversionFromPrevPercent) }}٪
|
||
</small>
|
||
<small v-else>شروع مسیر</small>
|
||
</div>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<p v-else class="empty-msg">معامله بازی در مسیر فروش نیست.</p>
|
||
</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>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
|
||
<!-- Customer sources -->
|
||
<UiCard class="chart-card" title="منابع مشتریان">
|
||
<UiChart
|
||
v-if="data.sources?.length"
|
||
:option="sourcesOption"
|
||
height="220px"
|
||
/>
|
||
<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>
|
||
<p v-else class="empty-msg">منبعی ثبت نشده است.</p>
|
||
</UiCard>
|
||
|
||
<!-- Lost reasons -->
|
||
<UiCard class="chart-card" title="دلایل عدم موفقیت فروش">
|
||
<UiChart
|
||
v-if="data.lostReasons?.length"
|
||
:option="lostReasonsOption"
|
||
height="220px"
|
||
/>
|
||
<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>
|
||
<p v-else class="empty-msg">معامله شکستخوردهای در این بازه نیست.</p>
|
||
</UiCard>
|
||
|
||
<!-- Monthly comparison -->
|
||
<UiCard class="chart-card" title="مقایسه فروش ماههای اخیر">
|
||
<UiChart
|
||
v-if="data.monthlyComparison?.length"
|
||
:option="monthlyOption"
|
||
height="260px"
|
||
/>
|
||
<p v-else class="empty-msg">دادهای نیست.</p>
|
||
</UiCard>
|
||
|
||
<!-- Top products -->
|
||
<UiCard class="chart-card" title="پرفروشترین محصولات">
|
||
<UiChart
|
||
v-if="data.topProducts?.length"
|
||
:option="topProductsOption"
|
||
height="260px"
|
||
/>
|
||
<p v-else class="empty-msg">فروش محصولی در این بازه نیست.</p>
|
||
</UiCard>
|
||
|
||
<!-- 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>
|
||
|
||
<!-- Customer status -->
|
||
<UiCard class="chart-card" title="وضعیت مشتریان">
|
||
<UiChart
|
||
v-if="data.customerStatus?.length"
|
||
:option="statusOption"
|
||
height="260px"
|
||
/>
|
||
<p v-else class="empty-msg">مشتری ثبت نشده است.</p>
|
||
</UiCard>
|
||
|
||
<!-- 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="عملکرد تیم">
|
||
<p class="deep-hint">برای دیدن معاملات برنده هر نفر کلیک کنید.</p>
|
||
<ul v-if="data.team?.length" class="team-list">
|
||
<li v-for="u in data.team.slice(0, 8)" :key="u.userId">
|
||
<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>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="empty-msg">داده تیمی نیست.</p>
|
||
</UiCard>
|
||
|
||
<!-- 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>
|
||
<ul v-if="data.activities.byType.length" class="act-bars">
|
||
<li v-for="t in data.activities.byType" :key="t.type">
|
||
<span class="act-bar-label">{{ t.label }}</span>
|
||
<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>
|
||
</li>
|
||
</ul>
|
||
<p v-else class="empty-msg">فعالیتی در بازه نیست.</p>
|
||
</UiCard>
|
||
|
||
<!-- 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>
|
||
</div>
|
||
</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>
|
||
</ul>
|
||
<p v-else class="empty-msg">با ثبت فروش و مشتری، تحلیلهای هوشمند به طور خودکار نمایش داده میشوند.</p>
|
||
<div class="suggest-box">
|
||
<UiIcon name="lightbulb" :size="18" />
|
||
<span>پیشنهاد: روی دستهها و منابع مشتری با نرخ رشد و حاشیه سود بالاتر تمرکز کنید.</span>
|
||
</div>
|
||
</UiCard>
|
||
</div>
|
||
</template>
|
||
|
||
<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>
|
||
|
||
<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>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.mode-tabs {
|
||
display: inline-flex;
|
||
gap: 2px;
|
||
background: var(--surface-subtle);
|
||
border: 1px solid var(--line);
|
||
padding: 3px;
|
||
border-radius: var(--radius);
|
||
margin-bottom: var(--sp-3);
|
||
}
|
||
|
||
.mode-tab {
|
||
border: 0;
|
||
background: transparent;
|
||
padding: 0 var(--sp-4);
|
||
min-height: calc(var(--control-h-md) - 6px);
|
||
border-radius: calc(var(--radius) - 2px);
|
||
font: inherit;
|
||
font-size: var(--fs-sm);
|
||
font-weight: var(--fw-semibold);
|
||
color: var(--muted);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.mode-tab.on {
|
||
background: var(--surface);
|
||
color: var(--accent-deep);
|
||
box-shadow: var(--shadow-xs);
|
||
font-weight: var(--fw-bold);
|
||
}
|
||
|
||
.builder-card {
|
||
margin-bottom: var(--sp-4);
|
||
}
|
||
|
||
.builder-form {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
|
||
gap: var(--sp-3);
|
||
align-items: end;
|
||
}
|
||
|
||
.builder-select {
|
||
width: 100%;
|
||
font: inherit;
|
||
min-height: var(--control-h-md);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 0.4rem 0.55rem;
|
||
background: #fff;
|
||
}
|
||
|
||
.builder-actions {
|
||
display: flex;
|
||
align-items: end;
|
||
}
|
||
|
||
.builder-kpi {
|
||
display: grid;
|
||
grid-template-columns: minmax(220px, 320px);
|
||
gap: var(--sp-3);
|
||
margin-bottom: var(--sp-4);
|
||
}
|
||
|
||
@media (max-width: 1000px) {
|
||
.builder-form {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 640px) {
|
||
.builder-form {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.view-select {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
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);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.conversion-summary {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
.filter-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-3);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.period-chips {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
background: var(--surface-subtle);
|
||
border: 1px solid var(--line);
|
||
padding: 3px;
|
||
border-radius: var(--radius);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.custom-range {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-3);
|
||
padding: var(--sp-3);
|
||
background: var(--surface);
|
||
border: 1px solid var(--line);
|
||
border-radius: var(--radius);
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.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 */
|
||
.kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
.kpi-card {
|
||
background: var(--surface);
|
||
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);
|
||
}
|
||
|
||
.kpi-top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.kpi-ico {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--accent-soft);
|
||
color: var(--accent-deep);
|
||
}
|
||
|
||
.kpi-label {
|
||
font-size: var(--fs-xs);
|
||
font-weight: var(--fw-medium);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.kpi-val {
|
||
font-size: var(--fs-xl);
|
||
font-weight: var(--fw-extrabold);
|
||
color: var(--ink);
|
||
line-height: var(--lh-tight);
|
||
}
|
||
|
||
.kpi-change {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-2);
|
||
font-size: var(--fs-xs);
|
||
font-weight: var(--fw-semibold);
|
||
color: var(--success);
|
||
}
|
||
|
||
.kpi-change.down {
|
||
color: var(--danger);
|
||
}
|
||
|
||
.kpi-change.flat {
|
||
color: var(--muted);
|
||
}
|
||
|
||
.kpi-change small {
|
||
color: var(--muted);
|
||
font-weight: normal;
|
||
}
|
||
|
||
/* Charts Grid */
|
||
.charts-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, 1fr);
|
||
gap: var(--sp-4);
|
||
}
|
||
|
||
.chart-card.span-2 {
|
||
grid-column: span 2;
|
||
}
|
||
|
||
.chart-card.span-full {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.empty-msg {
|
||
text-align: center;
|
||
padding: var(--sp-6);
|
||
color: var(--muted);
|
||
font-size: var(--fs-sm);
|
||
}
|
||
|
||
/* Team List */
|
||
.team-list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--sp-2);
|
||
}
|
||
|
||
.team-list li {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--sp-3);
|
||
padding: var(--sp-2) 0;
|
||
border-bottom: 1px solid var(--line-light);
|
||
}
|
||
|
||
.team-list li:last-child {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.avatar {
|
||
width: 34px;
|
||
height: 34px;
|
||
border-radius: 50%;
|
||
background: var(--accent-soft);
|
||
color: var(--accent-deep);
|
||
display: grid;
|
||
place-items: center;
|
||
font-weight: var(--fw-bold);
|
||
font-size: var(--fs-xs);
|
||
}
|
||
|
||
.team-meta {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.team-meta strong {
|
||
font-size: var(--fs-sm);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.team-meta small {
|
||
font-size: var(--fs-xs);
|
||
color: var(--muted);
|
||
}
|
||
|
||
.team-won {
|
||
font-style: normal;
|
||
font-weight: var(--fw-bold);
|
||
font-size: var(--fs-sm);
|
||
color: var(--accent-deep);
|
||
}
|
||
|
||
/* Activity Stats */
|
||
.act-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: var(--sp-2);
|
||
margin-bottom: var(--sp-4);
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.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 {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.chart-card.span-2 {
|
||
grid-column: auto;
|
||
}
|
||
}
|
||
</style>
|