Add saved report views with personal/org filters and in-org share links.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
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,
|
||||
@@ -16,6 +17,8 @@ import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
UiButton,
|
||||
UiCard,
|
||||
UiField,
|
||||
UiInput,
|
||||
UiModal,
|
||||
UiPageHeader,
|
||||
UiChart,
|
||||
@@ -29,6 +32,8 @@ import type {
|
||||
ReportsDrilldownDto,
|
||||
ReportsFunnelStageDto,
|
||||
ReportsPeriodCompareRowDto,
|
||||
ReportsViewFilters,
|
||||
SavedViewDto,
|
||||
} from '@mento/shared';
|
||||
import { brand } from '@mento/shared';
|
||||
|
||||
@@ -42,7 +47,18 @@ const PERIODS: Array<{ key: PeriodKey; label: string }> = [
|
||||
{ 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);
|
||||
@@ -57,10 +73,33 @@ 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 currency = computed(
|
||||
() => 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[] }>();
|
||||
@@ -154,9 +193,14 @@ function openDeal(link: string) {
|
||||
void router.push(link);
|
||||
}
|
||||
|
||||
function applyPeriod(key: PeriodKey) {
|
||||
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);
|
||||
@@ -186,8 +230,232 @@ function applyPeriod(key: PeriodKey) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
return {
|
||||
period: p,
|
||||
from: typeof raw.from === 'string' ? raw.from : undefined,
|
||||
to: typeof raw.to === 'string' ? raw.to : 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) {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
applyPeriod(p, { keepView });
|
||||
}
|
||||
|
||||
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() {
|
||||
await loadSavedViews();
|
||||
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 = '';
|
||||
@@ -571,10 +839,6 @@ const activityMax = computed(() =>
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
applyPeriod('month');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -584,6 +848,14 @@ onMounted(() => {
|
||||
lead="نمای جامع عملکرد فروش، مشتریان، فعالیتها و تیم"
|
||||
>
|
||||
<template #actions>
|
||||
<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="!data"
|
||||
@@ -614,10 +886,45 @@ onMounted(() => {
|
||||
<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="load">
|
||||
<form
|
||||
v-if="showCustomDates"
|
||||
class="custom-range"
|
||||
@submit.prevent="
|
||||
activeViewId = null;
|
||||
syncViewQuery(null);
|
||||
load();
|
||||
"
|
||||
>
|
||||
<JalaliDateInput v-model="from" placeholder="از تاریخ" />
|
||||
<JalaliDateInput v-model="to" placeholder="تا تاریخ" />
|
||||
<UiButton variant="primary" type="submit" :disabled="loading">اعمال</UiButton>
|
||||
@@ -954,10 +1261,75 @@ onMounted(() => {
|
||||
<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>
|
||||
.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));
|
||||
|
||||
Reference in New Issue
Block a user