Files
CRM_Montzar/apps/web/src/views/SalesForecastView.vue
T
MahdiandCursor cb3a488b21 feat(web): continue Ant settings migration and tighten page chrome
Migrate remaining settings screens to Ant Design Vue, densify teams/roles UI, drop page-header subtitles project-wide, and polish notes cards plus contacts row actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-04 10:05:28 +03:30

909 lines
32 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
type Component,
} from 'vue';
import { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords } from '@/lib/roles';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import {
formatJalaliNumeric,
formatLocalDateInput,
toFaDigits,
} from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
import { Modal } from 'ant-design-vue';
import type { TableColumnsType } from 'ant-design-vue';
import {
Affix as AAffix,
Alert as AAlert,
Avatar as AAvatar,
Button as AButton,
Card as ACard,
Col as ACol,
Descriptions as ADescriptions,
DescriptionsItem as ADescriptionsItem,
Divider as ADivider,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
InputNumber as AInputNumber,
Menu as AMenu,
MenuItem as AMenuItem,
Pagination as APagination,
Progress as AProgress,
Row as ARow,
Segmented as ASegmented,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Table as ATable,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import {
AimOutlined,
ArrowRightOutlined,
CheckCircleOutlined,
CheckOutlined,
EditOutlined,
FileTextOutlined,
FundOutlined,
MoreOutlined,
PlusOutlined,
SearchOutlined,
StarOutlined,
TeamOutlined,
} from '@ant-design/icons-vue';
import type {
CreateSalesGoalRequest,
OrgUserDto,
SalesForecastDto,
SalesForecastKpiDto,
SalesGoalDto,
SalesGoalStatus,
SalesGoalType,
UpdateSalesGoalRequest,
} from '@mento/shared';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
type StatusFilter = 'all' | SalesGoalStatus;
type MobTab = 'goals' | 'forecast';
type MobScreen = 'list' | 'form';
/** بریک‌پوینت موبایل نسخهٔ main (همان 900px تابع cancelForm) */
const MOBILE_MQ = '(max-width: 900px)';
const GOAL_TYPES: { value: SalesGoalType; label: string; icon: 'target' | 'users' | 'chart' | 'briefcase' | 'check' | 'star' }[] = [
{ value: 'sales', label: 'فروش', icon: 'target' },
{ value: 'customer', label: 'مشتری', icon: 'users' },
{ value: 'revenue', label: 'درآمد', icon: 'chart' },
{ value: 'deal', label: 'معامله', icon: 'briefcase' },
{ value: 'activity', label: 'فعالیت', icon: 'check' },
{ value: 'quality', label: 'کیفیت', icon: 'star' },
];
const GOAL_TYPE_ICONS: Record<string, Component> = {
target: AimOutlined,
users: TeamOutlined,
chart: FundOutlined,
briefcase: FileTextOutlined,
check: CheckCircleOutlined,
star: StarOutlined,
};
const goalTypeOptions = GOAL_TYPES.map((t) => ({ value: t.value, label: t.label }));
const statusOptions = [
{ value: 'all', label: 'همه اهداف' },
{ value: 'in_progress', label: 'در حال انجام' },
{ value: 'completed', label: 'تکمیل شده' },
{ value: 'needs_attention', label: 'نیاز به توجه' },
];
const mobTabOptions = [
{ value: 'goals', label: 'لیست اهداف' },
{ value: 'forecast', label: 'پیش‌بینی' },
];
const FORECAST_COL_LABELS = [
'عنوان',
'نوع',
'مقدار هدف',
'مقدار فعلی',
'درصد تحقق',
'بازه زمانی',
'وضعیت',
'عملیات',
] as const;
registerTableColumns('sales-forecast', [...FORECAST_COL_LABELS]);
const tableCols = useTableColumns(() => 'sales-forecast');
const auth = useAuthStore();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const forecast = ref<SalesForecastDto | null>(null);
const users = ref<OrgUserDto[]>([]);
const q = ref('');
const statusFilter = ref<StatusFilter>('all');
const from = ref('');
const to = ref('');
const page = ref(1);
const pageSize = ref(10);
const menuId = ref<string | null>(null);
/* لاجیک موبایل نسخهٔ main: لیست/فرم دو صفحهٔ جدا و تب اهداف/پیش‌بینی */
const mobTab = ref<MobTab>('goals');
const mobScreen = ref<MobScreen>('list');
const isMobile = ref(false);
let mobileMq: MediaQueryList | null = null;
function syncMobile() {
isMobile.value = Boolean(mobileMq?.matches);
}
const formOpen = ref(true);
const editingId = ref<string | null>(null);
const title = ref('');
const goalType = ref<SalesGoalType>('sales');
const targetAmount = ref('');
const forecastAmount = ref('');
const probability = ref('80');
const notes = ref('');
const userId = ref('');
const periodStart = ref('');
const periodEnd = ref('');
const canManageTeamGoals = computed(() => canReassignRecords(auth.user?.role));
const currency = computed(() => forecast.value?.currency || 'IRR');
const isCountType = computed(
() =>
goalType.value === 'customer' ||
goalType.value === 'deal' ||
goalType.value === 'activity' ||
goalType.value === 'quality',
);
const userOptions = computed(() => [
{ value: '', label: 'کل سازمان' },
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
]);
const kpis = computed<SalesForecastKpiDto[]>(() => forecast.value?.kpis ?? []);
const filteredGoals = computed(() => {
let list = forecast.value?.goals ?? [];
if (statusFilter.value !== 'all') {
list = list.filter((g) => g.status === statusFilter.value);
}
if (q.value.trim()) {
list = list.filter((g) => matchesSearch(q.value, [g.title]));
}
return list;
});
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredGoals.value.length / pageSize.value)),
);
const pageItems = computed(() => {
const start = (page.value - 1) * pageSize.value;
return filteredGoals.value.slice(start, start + pageSize.value);
});
const pageFrom = computed(() =>
filteredGoals.value.length ? (page.value - 1) * pageSize.value + 1 : 0,
);
const formTitle = computed(() =>
editingId.value ? 'ویرایش هدف' : 'افزودن هدف جدید',
);
/** موبایل در صفحهٔ لیست فرم را نمی‌بیند؛ دسکتاپ سایدبار فرم همیشه باز است (main) */
const showForm = computed(
() => formOpen.value && !(isMobile.value && mobScreen.value === 'list'),
);
const isMobileForm = computed(
() => isMobile.value && mobScreen.value === 'form',
);
const columns = computed<TableColumnsType<SalesGoalDto>>(() => {
const all: TableColumnsType<SalesGoalDto> = [
{ title: '#', key: 'index', width: 56 },
{ title: 'عنوان', key: 'عنوان' },
{ title: 'نوع', key: 'نوع' },
{ title: 'مقدار هدف', key: 'مقدار هدف' },
{ title: 'مقدار فعلی', key: 'مقدار فعلی' },
{ title: 'درصد تحقق', key: 'درصد تحقق' },
{ title: 'بازه زمانی', key: 'بازه زمانی' },
{ title: 'وضعیت', key: 'وضعیت' },
{ title: 'عملیات', key: 'عملیات', fixed: 'left', width: 96 },
];
return all.filter((col) => {
const key = String(col.key ?? '');
if (key === 'index') return true;
if (!FORECAST_COL_LABELS.includes(key as (typeof FORECAST_COL_LABELS)[number])) {
return true;
}
return tableCols.isVisible(key);
});
});
function monthBounds() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
return {
start: formatLocalDateInput(start),
end: formatLocalDateInput(end),
};
}
function resetPeriodDefaults() {
const b = monthBounds();
from.value = b.start;
to.value = b.end;
periodStart.value = b.start;
periodEnd.value = b.end;
}
function formatMoney(amount: number) {
return formatMoneyLib(amount, currency.value);
}
const forecastChartData = computed(() => {
const f = forecast.value;
const rows = [
{ name: 'هدف دوره', value: f?.targetAmount || 0, color: '#3b82f6' },
{ name: 'تحقق‌یافته', value: f?.wonAmount || 0, color: '#10b981' },
{ name: 'معاملات باز', value: f?.openPipelineAmount || 0, color: '#f59e0b' },
{ name: 'پیش‌بینی', value: f?.forecastAmount || 0, color: '#14b8a6' },
];
const max = Math.max(0, ...rows.map((r) => r.value));
return rows.map((row) => ({
...row,
percent: max > 0 ? Math.round((row.value / max) * 100) : 0,
}));
});
function formatValue(amount: number, unit: 'money' | 'count', type?: string) {
if (unit === 'count' || type === 'customer' || type === 'deal' || type === 'activity') {
return `${toFaDigits(Math.round(amount))} ${type === 'customer' ? 'مشتری' : type === 'deal' ? 'معامله' : type === 'activity' ? 'فعالیت' : 'مورد'}`;
}
if (type === 'quality') return `${toFaDigits(Math.round(amount))}٪`;
return formatMoney(amount);
}
function formatDate(iso: string | null | undefined) {
if (!iso) return '—';
try {
return formatJalaliNumeric(new Date(iso));
} catch {
return '—';
}
}
function typeMeta(type: string) {
return GOAL_TYPES.find((t) => t.value === type) ?? GOAL_TYPES[0];
}
function goalTypeIcon(type: string): Component {
return GOAL_TYPE_ICONS[typeMeta(type).icon] ?? AimOutlined;
}
function statusLabel(status: SalesGoalStatus | string) {
if (status === 'completed') return 'تکمیل شده';
if (status === 'needs_attention') return 'نیاز به توجه';
return 'در حال انجام';
}
function statusColor(status: SalesGoalStatus | string) {
if (status === 'completed') return 'success';
if (status === 'needs_attention') return 'warning';
return 'processing';
}
function kpiIcon(key: string): Component {
if (key === 'customer') return TeamOutlined;
if (key === 'deal') return FileTextOutlined;
if (key === 'forecast') return FundOutlined;
return AimOutlined;
}
function closeMenus() {
menuId.value = null;
}
function resetForm() {
editingId.value = null;
title.value = '';
goalType.value = 'sales';
targetAmount.value = '';
forecastAmount.value = '';
probability.value = '80';
notes.value = '';
userId.value = '';
const b = monthBounds();
periodStart.value = from.value || b.start;
periodEnd.value = to.value || b.end;
}
function openCreate() {
resetForm();
formOpen.value = true;
mobScreen.value = 'form';
}
function openEdit(goal: SalesGoalDto) {
editingId.value = goal.id;
title.value = goal.title;
goalType.value = (GOAL_TYPES.some((t) => t.value === goal.type)
? goal.type
: 'sales') as SalesGoalType;
targetAmount.value = String(goal.targetAmount);
forecastAmount.value =
goal.forecastAmount != null ? String(goal.forecastAmount) : '';
probability.value =
goal.probability != null ? String(goal.probability) : '80';
notes.value = goal.notes ?? '';
userId.value = goal.user?.id ?? '';
periodStart.value = formatLocalDateInput(new Date(goal.periodStart));
periodEnd.value = formatLocalDateInput(new Date(goal.periodEnd));
formOpen.value = true;
mobScreen.value = 'form';
closeMenus();
}
function cancelForm() {
resetForm();
mobScreen.value = 'list';
if (isMobile.value) {
formOpen.value = false;
}
}
async function load() {
loading.value = true;
error.value = '';
closeMenus();
try {
const params: Record<string, string> = {};
if (from.value) params.from = from.value;
if (to.value) params.to = to.value;
const { data } = await api.get<SalesForecastDto>('/sales/forecast', {
params,
});
forecast.value = data;
if (canManageTeamGoals.value && !users.value.length) {
try {
const res = await api.get<OrgUserDto[]>('/users');
users.value = res.data;
} catch {
/* optional */
}
}
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'بارگذاری پیش‌بینی ناموفق بود.',
)
: 'بارگذاری پیش‌بینی ناموفق بود.';
} finally {
loading.value = false;
}
}
async function saveGoal() {
if (!title.value.trim() || targetAmount.value === '') {
error.value = 'عنوان و مقدار هدف را وارد کنید.';
return;
}
if (!periodStart.value || !periodEnd.value) {
error.value = 'بازه زمانی را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const body: CreateSalesGoalRequest | UpdateSalesGoalRequest = {
title: title.value.trim(),
type: goalType.value,
periodStart: new Date(periodStart.value).toISOString(),
periodEnd: new Date(periodEnd.value + 'T23:59:59.000Z').toISOString(),
targetAmount: Number(targetAmount.value),
forecastAmount:
forecastAmount.value !== '' ? Number(forecastAmount.value) : null,
probability:
probability.value !== '' ? Number(probability.value) : null,
notes: notes.value || undefined,
userId: canManageTeamGoals.value
? userId.value || null
: undefined,
};
if (editingId.value) {
await api.patch(`/sales/goals/${editingId.value}`, body);
} else {
await api.post('/sales/goals', body);
}
cancelForm();
formOpen.value = true;
await load();
} catch (e) {
error.value = axios.isAxiosError(e)
? String(
(e.response?.data as { message?: string })?.message ??
'ذخیره هدف ناموفق بود.',
)
: 'ذخیره هدف ناموفق بود.';
} finally {
saving.value = false;
}
}
function removeGoal(id: string) {
closeMenus();
Modal.confirm({
title: 'این هدف حذف شود؟',
okText: 'حذف',
cancelText: 'انصراف',
okButtonProps: { danger: true },
onOk: async () => {
try {
await api.delete(`/sales/goals/${id}`);
if (editingId.value === id) cancelForm();
await load();
} catch {
error.value = 'حذف هدف ناموفق بود.';
}
},
});
}
function onRowMenu({ key }: { key: string | number }, goal: SalesGoalDto) {
if (String(key) === 'edit') openEdit(goal);
else if (String(key) === 'remove') removeGoal(goal.id);
menuId.value = null;
}
watch([statusFilter, q, pageSize], () => {
page.value = 1;
});
watch([from, to], () => {
void load();
});
onMounted(() => {
mobileMq = window.matchMedia(MOBILE_MQ);
syncMobile();
mobileMq.addEventListener('change', syncMobile);
resetPeriodDefaults();
void load();
});
onBeforeUnmount(() => {
mobileMq?.removeEventListener('change', syncMobile);
mobileMq = null;
});
</script>
<template>
<div @click="closeMenus">
<a-flex vertical :gap="16">
<!-- صفحهٔ فرم موبایل (main): فقط هدر بازگشت + فرم -->
<a-flex v-if="isMobileForm" align="center" :gap="12">
<a-button aria-label="بازگشت" @click="cancelForm">
<template #icon><ArrowRightOutlined /></template>
</a-button>
<div>
<a-typography-title :level="4" :style="{ margin: 0 }">{{ formTitle }}</a-typography-title>
<a-typography-text type="secondary">اهداف ماهانه را تنظیم کنید</a-typography-text>
</div>
</a-flex>
<a-flex
v-else
justify="space-between"
align="center"
wrap="wrap"
:gap="12"
>
<div>
<a-typography-title :level="3" :style="{ margin: 0 }">هدف و پیش‌بینی</a-typography-title>
</div>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
افزودن هدف جدید
</a-button>
</a-flex>
<a-alert v-if="error" type="error" show-icon :message="error" />
<!-- تب‌های موبایل main: لیست اهداف / پیش‌بینی -->
<a-segmented
v-if="isMobile && !isMobileForm"
v-model:value="mobTab"
:options="mobTabOptions"
block
/>
<a-row
v-if="!loading && !isMobileForm && !(isMobile && mobTab === 'forecast')"
:gutter="[16, 16]"
>
<a-col v-for="kpi in kpis" :key="kpi.key" :xs="24" :sm="12" :lg="6">
<a-card :bordered="false" :style="{ height: '100%' }">
<a-flex align="center" :gap="12">
<a-progress
type="circle"
:percent="Math.max(0, Math.min(100, Math.round(kpi.percent || 0)))"
:size="64"
:format="(p?: number) => `${toFaDigits(String(p ?? 0))}٪`"
:style="{ flexShrink: 0 }"
/>
<a-flex vertical :gap="0" :style="{ flex: 1, minWidth: 0 }">
<a-typography-text type="secondary">{{ kpi.label }}</a-typography-text>
<a-typography-title :level="4" :style="{ margin: 0 }">
{{ formatValue(kpi.current, kpi.unit) }}
</a-typography-title>
<a-typography-text type="secondary">
از {{ formatValue(kpi.target, kpi.unit) }}
</a-typography-text>
</a-flex>
<a-avatar
:style="{
background: 'var(--ant-color-primary-bg)',
color: 'var(--ant-color-primary)',
flexShrink: 0,
}"
>
<template #icon><component :is="kpiIcon(kpi.key)" /></template>
</a-avatar>
</a-flex>
</a-card>
</a-col>
<a-col v-if="!kpis.length" :span="24">
<a-typography-text type="secondary">
برای این بازه هنوز KPIای محاسبه نشده است.
</a-typography-text>
</a-col>
</a-row>
<a-row :gutter="[24, 24]" align="top">
<a-col v-if="showForm" :xs="24" :lg="7" :xl="6">
<a-affix :offset-top="24">
<a-card :title="formTitle" :bordered="false">
<a-form layout="vertical" @finish="saveGoal">
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="عنوان" required>
<a-input
v-model:value="title"
:maxlength="200"
placeholder="هدف فروش ماه جاری"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="نوع هدف">
<a-select
v-model:value="goalType"
:options="goalTypeOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item :label="isCountType ? 'مقدار هدف' : 'مبلغ هدف (ریال)'" required>
<MoneyInput v-if="!isCountType" v-model="targetAmount" :decimals="0" />
<a-input-number
v-else
:value="targetAmount === '' ? undefined : Number(targetAmount)"
:min="0"
:step="1"
dir="ltr"
style="width: 100%"
@update:value="(v) => (targetAmount = v == null ? '' : String(v))"
/>
</a-form-item>
</a-col>
<a-col v-if="canManageTeamGoals" :span="24">
<a-form-item label="برای">
<a-select
v-model:value="userId"
:options="userOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="از تاریخ" required>
<JalaliDateInput v-model="periodStart" required style="width: 100%" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12">
<a-form-item label="تا تاریخ" required>
<JalaliDateInput v-model="periodEnd" required style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="یادداشت">
<a-textarea
v-model:value="notes"
:rows="3"
placeholder="یادداشت خود را وارد کنید..."
/>
</a-form-item>
</a-col>
</a-row>
<a-divider orientation="right">پیش‌بینی</a-divider>
<a-row :gutter="[16, 0]">
<a-col :span="24">
<a-form-item label="مبلغ پیش‌بینی شده (ریال)">
<MoneyInput v-model="forecastAmount" :decimals="0" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="احتمال تحقق (%)">
<a-input-number
:value="probability === '' ? undefined : Number(probability)"
:min="0"
:max="100"
:step="1"
dir="ltr"
style="width: 100%"
@update:value="(v) => (probability = v == null ? '' : String(v))"
/>
</a-form-item>
</a-col>
</a-row>
<a-flex vertical :gap="8">
<a-button type="primary" html-type="submit" block :loading="saving">
<template #icon><CheckOutlined /></template>
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره تغییرات' : 'ثبت هدف' }}
</a-button>
<a-button block :disabled="saving" @click="cancelForm">انصراف</a-button>
</a-flex>
</a-form>
</a-card>
</a-affix>
</a-col>
<a-col
v-if="!isMobileForm"
:xs="24"
:lg="showForm ? 17 : 24"
:xl="showForm ? 18 : 24"
>
<a-card :bordered="false">
<a-row :gutter="[16, 0]" @click.stop>
<a-col :xs="24" :md="12" :lg="8">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="جستجوی عنوان هدف..."
>
<template #prefix><SearchOutlined /></template>
</a-input>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12" :lg="8">
<a-form-item label="بازه زمانی">
<a-flex :gap="8" align="center">
<JalaliDateInput v-model="from" placeholder="از" style="width: 100%" />
<a-typography-text type="secondary">–</a-typography-text>
<JalaliDateInput v-model="to" placeholder="تا" style="width: 100%" />
</a-flex>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="وضعیت">
<a-segmented
v-model:value="statusFilter"
:options="statusOptions"
@click.stop
/>
</a-form-item>
</a-col>
</a-row>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" @click.stop>
<a-typography-text type="secondary">
{{ toFaDigits(filteredGoals.length) }} هدف
</a-typography-text>
<TableColumnsMenu table-key="sales-forecast" align="end" />
</a-flex>
<a-spin v-if="!isMobile || mobTab === 'goals'" :spinning="loading">
<a-table
:columns="columns"
:data-source="pageItems"
:pagination="false"
row-key="id"
:scroll="{ x: 'max-content' }"
:style="{ marginTop: '16px' }"
>
<template #emptyText>
<a-empty description="هدفی یافت نشد." />
</template>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ toFaDigits(pageFrom + index) }}
</template>
<template v-else-if="column.key === 'عنوان'">
<a-typography-text strong>{{ record.title }}</a-typography-text>
</template>
<template v-else-if="column.key === 'نوع'">
<a-space :size="8">
<component :is="goalTypeIcon(record.type)" />
<a-typography-text>{{ typeMeta(record.type).label }}</a-typography-text>
</a-space>
</template>
<template v-else-if="column.key === 'مقدار هدف'">
{{ formatValue(record.targetAmount, record.type === 'sales' || record.type === 'revenue' ? 'money' : 'count', record.type) }}
</template>
<template v-else-if="column.key === 'مقدار فعلی'">
{{ formatValue(record.currentAmount, record.type === 'sales' || record.type === 'revenue' ? 'money' : 'count', record.type) }}
</template>
<template v-else-if="column.key === 'درصد تحقق'">
<a-flex align="center" :gap="8">
<a-progress
:percent="Math.max(0, Math.min(100, Math.round(record.progressPercent || 0)))"
:show-info="false"
style="min-width: 5rem; flex: 1"
/>
<a-typography-text>
{{ toFaDigits(Math.round(record.progressPercent)) }}٪
</a-typography-text>
</a-flex>
</template>
<template v-else-if="column.key === 'بازه زمانی'">
<a-flex vertical :gap="0">
<a-typography-text>{{ formatDate(record.periodStart) }}</a-typography-text>
<a-typography-text type="secondary">
{{ formatDate(record.periodEnd) }}
</a-typography-text>
</a-flex>
</template>
<template v-else-if="column.key === 'وضعیت'">
<a-tag :color="statusColor(record.status)" :bordered="false">
{{ statusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'عملیات'">
<a-space :size="4" @click.stop>
<a-button type="text" aria-label="ویرایش" @click="openEdit(record as SalesGoalDto)">
<template #icon><EditOutlined /></template>
</a-button>
<a-dropdown
:open="menuId === record.id"
trigger="click"
@update:open="(v: boolean) => (menuId = v ? record.id : null)"
>
<a-button type="text" aria-label="عملیات">
<template #icon><MoreOutlined /></template>
</a-button>
<template #overlay>
<a-menu @click="(info) => onRowMenu(info, record as SalesGoalDto)">
<a-menu-item key="edit">ویرایش</a-menu-item>
<a-menu-item key="remove" danger>حذف</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space>
</template>
</template>
</a-table>
<a-flex
v-if="filteredGoals.length"
justify="flex-end"
:style="{ marginTop: '16px' }"
>
<a-pagination
v-model:current="page"
:total="filteredGoals.length"
:page-size="pageSize"
:show-total="
(total: number) =>
`${toFaDigits(total)} مورد · ${toFaDigits(pageCount)} صفحه`
"
/>
</a-flex>
</a-spin>
<!-- خلاصه پیش‌بینی در main فقط تب «پیش‌بینی» موبایل است -->
<template v-if="isMobile && mobTab === 'forecast'">
<a-divider orientation="right">خلاصه پیش‌بینی</a-divider>
<a-descriptions :column="1" bordered>
<a-descriptions-item label="هدف دوره">
{{ formatMoney(forecast?.targetAmount || 0) }}
</a-descriptions-item>
<a-descriptions-item label="تحقق (برنده)">
{{ formatMoney(forecast?.wonAmount || 0) }}
</a-descriptions-item>
<a-descriptions-item label="معاملات باز">
{{ formatMoney(forecast?.openPipelineAmount || 0) }}
</a-descriptions-item>
<a-descriptions-item label="پیش‌بینی درآمد">
{{ formatMoney(forecast?.forecastAmount || 0) }}
</a-descriptions-item>
<a-descriptions-item label="مشتریان جدید">
{{ toFaDigits(forecast?.newCustomersCount || 0) }}
</a-descriptions-item>
<a-descriptions-item label="معاملات جدید">
{{ toFaDigits(forecast?.newDealsCount || 0) }}
</a-descriptions-item>
</a-descriptions>
<a-flex vertical :gap="16" :style="{ marginTop: '16px' }">
<div v-for="row in forecastChartData" :key="row.name">
<a-flex justify="space-between" align="center" :gap="12">
<a-typography-text strong>{{ row.name }}</a-typography-text>
<a-typography-text>{{ formatMoney(row.value) }}</a-typography-text>
</a-flex>
<a-progress
:percent="row.percent"
:stroke-color="row.color"
:format="(p?: number) => `${toFaDigits(String(p ?? 0))}٪`"
/>
</div>
</a-flex>
</template>
</a-card>
</a-col>
</a-row>
<!-- نوار پایین موبایل main: گزارش / افزودن هدف / تحلیل -->
<a-affix
v-if="isMobile && !isMobileForm"
:offset-bottom="0"
>
<a-card :bordered="false">
<a-flex justify="space-between" align="center" :gap="12">
<RouterLink to="/reports">
<a-button type="text">
<template #icon><FundOutlined /></template>
گزارش
</a-button>
</RouterLink>
<a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template>
افزودن هدف جدید
</a-button>
<RouterLink to="/reports">
<a-button type="text">
<template #icon><FundOutlined /></template>
تحلیل
</a-button>
</RouterLink>
</a-flex>
</a-card>
</a-affix>
</a-flex>
</div>
</template>