Add saved report views with personal/org filters and in-org share links.
This commit is contained in:
@@ -1630,7 +1630,7 @@ model SavedView {
|
||||
ownerUserId String?
|
||||
owner User? @relation("SavedViewOwner", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||
name String
|
||||
/// deals | people | companies | activities | project_cards
|
||||
/// deals | people | companies | activities | project_cards | reports
|
||||
scope String
|
||||
filters Json @default("{}")
|
||||
isShared Boolean @default(false)
|
||||
|
||||
@@ -1,14 +1,63 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import { SavedViewsService } from '../saved-views/saved-views.service';
|
||||
import { ReportsDrilldownQueryDto, ReportsQueryDto } from './dto';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
class CreateReportViewDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsObject()
|
||||
filters!: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
class UpdateReportViewDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
filters?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
@Controller('reports')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
constructor(
|
||||
private readonly reportsService: ReportsService,
|
||||
private readonly savedViews: SavedViewsService,
|
||||
) {}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@CurrentUser() user: Actor, @Query() query: ReportsQueryDto) {
|
||||
@@ -35,4 +84,59 @@ export class ReportsController {
|
||||
home(@CurrentUser() user: Actor) {
|
||||
return this.reportsService.home(user);
|
||||
}
|
||||
|
||||
@Get('views')
|
||||
listViews(@CurrentUser() user: Actor) {
|
||||
return this.savedViews.list(user.organizationId, user.userId, 'reports');
|
||||
}
|
||||
|
||||
@Get('views/:id')
|
||||
getView(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
return this.savedViews.get(user.organizationId, user.userId, id, 'reports');
|
||||
}
|
||||
|
||||
@Post('views')
|
||||
createView(@CurrentUser() user: Actor, @Body() dto: CreateReportViewDto) {
|
||||
return this.savedViews.create(user.organizationId, user.userId, {
|
||||
name: dto.name,
|
||||
scope: 'reports',
|
||||
filters: dto.filters,
|
||||
isShared: dto.isShared,
|
||||
});
|
||||
}
|
||||
|
||||
@Patch('views/:id')
|
||||
updateView(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateReportViewDto,
|
||||
) {
|
||||
const isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||
return this.savedViews.update(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
{
|
||||
name: dto.name,
|
||||
filters: dto.filters,
|
||||
isShared: dto.isShared,
|
||||
scope: 'reports',
|
||||
},
|
||||
isAdmin,
|
||||
'reports',
|
||||
);
|
||||
}
|
||||
|
||||
@Delete('views/:id')
|
||||
async removeView(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
const isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||
await this.savedViews.remove(
|
||||
user.organizationId,
|
||||
user.userId,
|
||||
id,
|
||||
isAdmin,
|
||||
'reports',
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { SavedViewsModule } from '../saved-views/saved-views.module';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, SavedViewsModule],
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
import { SAVED_VIEW_SCOPES } from '@mento/shared';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
@@ -24,21 +25,13 @@ import { OrgModuleGuard } from '../billing/org-module.guard';
|
||||
import { RequireOrgModule } from '../billing/org-module.decorator';
|
||||
import { SavedViewsService } from './saved-views.service';
|
||||
|
||||
const SCOPES = [
|
||||
'deals',
|
||||
'people',
|
||||
'companies',
|
||||
'activities',
|
||||
'project_cards',
|
||||
] as const;
|
||||
|
||||
class CreateSavedViewDto {
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsIn(SCOPES)
|
||||
scope!: (typeof SCOPES)[number];
|
||||
@IsIn([...SAVED_VIEW_SCOPES])
|
||||
scope!: (typeof SAVED_VIEW_SCOPES)[number];
|
||||
|
||||
@IsObject()
|
||||
filters!: Record<string, unknown>;
|
||||
@@ -55,8 +48,8 @@ class UpdateSavedViewDto {
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(SCOPES)
|
||||
scope?: (typeof SCOPES)[number];
|
||||
@IsIn([...SAVED_VIEW_SCOPES])
|
||||
scope?: (typeof SAVED_VIEW_SCOPES)[number];
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
@@ -81,6 +74,14 @@ export class SavedViewsController {
|
||||
return this.savedViews.list(user.organizationId, user.userId, scope);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.savedViews.get(user.organizationId, user.userId, id);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@CurrentUser() user: { organizationId: string; userId: string },
|
||||
|
||||
@@ -9,17 +9,10 @@ import type {
|
||||
SavedViewScope,
|
||||
UpdateSavedViewRequest,
|
||||
} from '@mento/shared';
|
||||
import { SAVED_VIEW_SCOPES } from '@mento/shared';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const SCOPES: SavedViewScope[] = [
|
||||
'deals',
|
||||
'people',
|
||||
'companies',
|
||||
'activities',
|
||||
'project_cards',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class SavedViewsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -41,6 +34,25 @@ export class SavedViewsService {
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async get(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
scope?: string,
|
||||
): Promise<SavedViewDto> {
|
||||
const row = await this.prisma.savedView.findFirst({
|
||||
where: {
|
||||
id,
|
||||
organizationId,
|
||||
...(scope ? { scope } : {}),
|
||||
OR: [{ isShared: true }, { ownerUserId: userId }],
|
||||
},
|
||||
include: { owner: { select: { id: true, fullName: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('فیلتر پیدا نشد');
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
@@ -69,11 +81,15 @@ export class SavedViewsService {
|
||||
id: string,
|
||||
dto: UpdateSavedViewRequest,
|
||||
isAdmin: boolean,
|
||||
expectedScope?: string,
|
||||
): Promise<SavedViewDto> {
|
||||
const existing = await this.prisma.savedView.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
||||
if (expectedScope && existing.scope !== expectedScope) {
|
||||
throw new NotFoundException('فیلتر پیدا نشد');
|
||||
}
|
||||
if (!isAdmin && existing.ownerUserId !== userId) {
|
||||
throw new BadRequestException('فقط مالک فیلتر میتواند آن را ویرایش کند');
|
||||
}
|
||||
@@ -103,11 +119,15 @@ export class SavedViewsService {
|
||||
userId: string,
|
||||
id: string,
|
||||
isAdmin: boolean,
|
||||
expectedScope?: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.prisma.savedView.findFirst({
|
||||
where: { id, organizationId },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('فیلتر پیدا نشد');
|
||||
if (expectedScope && existing.scope !== expectedScope) {
|
||||
throw new NotFoundException('فیلتر پیدا نشد');
|
||||
}
|
||||
if (!isAdmin && existing.ownerUserId !== userId) {
|
||||
throw new BadRequestException('فقط مالک فیلتر میتواند آن را حذف کند');
|
||||
}
|
||||
@@ -115,7 +135,7 @@ export class SavedViewsService {
|
||||
}
|
||||
|
||||
private assertScope(scope: string): SavedViewScope {
|
||||
if (!SCOPES.includes(scope as SavedViewScope)) {
|
||||
if (!SAVED_VIEW_SCOPES.includes(scope as SavedViewScope)) {
|
||||
throw new BadRequestException('محدوده فیلتر معتبر نیست');
|
||||
}
|
||||
return scope as SavedViewScope;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -3051,7 +3051,25 @@ export type SavedViewScope =
|
||||
| "people"
|
||||
| "companies"
|
||||
| "activities"
|
||||
| "project_cards";
|
||||
| "project_cards"
|
||||
| "reports";
|
||||
|
||||
export const SAVED_VIEW_SCOPES: SavedViewScope[] = [
|
||||
"deals",
|
||||
"people",
|
||||
"companies",
|
||||
"activities",
|
||||
"project_cards",
|
||||
"reports",
|
||||
];
|
||||
|
||||
/** فیلترهای ذخیرهشدهٔ صفحه گزارشات */
|
||||
export interface ReportsViewFilters {
|
||||
/** today | week | month | quarter | year | custom */
|
||||
period: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export interface SavedViewDto {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user