2026-08-29 21:06:30 +03:30
|
|
|
|
<script setup lang="ts">
|
2026-11-03 16:51:51 +03:30
|
|
|
|
import {
|
|
|
|
|
|
computed,
|
|
|
|
|
|
onBeforeUnmount,
|
|
|
|
|
|
onMounted,
|
|
|
|
|
|
ref,
|
|
|
|
|
|
watch,
|
|
|
|
|
|
type Component,
|
|
|
|
|
|
} from 'vue';
|
2026-10-06 15:04:46 +03:30
|
|
|
|
import { matchesSearch } from '@/lib/search';
|
2026-10-05 11:56:49 +03:30
|
|
|
|
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import { RouterLink } from 'vue-router';
|
|
|
|
|
|
import axios from 'axios';
|
|
|
|
|
|
import { api } from '@/lib/api';
|
|
|
|
|
|
import { useAuthStore } from '@/stores/auth';
|
2026-09-05 18:38:11 +03:30
|
|
|
|
import { canReassignRecords } from '@/lib/roles';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import { formatMoney as formatMoneyLib } from '@/lib/money';
|
2026-09-05 18:38:11 +03:30
|
|
|
|
import {
|
|
|
|
|
|
formatJalaliNumeric,
|
|
|
|
|
|
formatLocalDateInput,
|
|
|
|
|
|
toFaDigits,
|
|
|
|
|
|
} from '@/lib/jalali';
|
2026-09-02 21:29:00 +03:30
|
|
|
|
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
2026-09-05 02:13:39 +03:30
|
|
|
|
import MoneyInput from '@/components/MoneyInput.vue';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
import { registerTableColumns, useTableColumns } from '@/lib/tableColumns';
|
|
|
|
|
|
import { Modal } from 'ant-design-vue';
|
|
|
|
|
|
import type { TableColumnsType } from 'ant-design-vue';
|
2026-09-12 01:33:07 +03:30
|
|
|
|
import {
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
import type {
|
|
|
|
|
|
CreateSalesGoalRequest,
|
|
|
|
|
|
OrgUserDto,
|
|
|
|
|
|
SalesForecastDto,
|
2026-09-05 18:38:11 +03:30
|
|
|
|
SalesForecastKpiDto,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
SalesGoalDto,
|
2026-09-05 18:38:11 +03:30
|
|
|
|
SalesGoalStatus,
|
|
|
|
|
|
SalesGoalType,
|
|
|
|
|
|
UpdateSalesGoalRequest,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} from '@mento/shared';
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
type StatusFilter = 'all' | SalesGoalStatus;
|
|
|
|
|
|
type MobTab = 'goals' | 'forecast';
|
|
|
|
|
|
type MobScreen = 'list' | 'form';
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
/** بریکپوینت موبایل نسخهٔ main (همان 900px تابع cancelForm) */
|
|
|
|
|
|
const MOBILE_MQ = '(max-width: 900px)';
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
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' },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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');
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const auth = useAuthStore();
|
|
|
|
|
|
const loading = ref(false);
|
|
|
|
|
|
const saving = ref(false);
|
|
|
|
|
|
const error = ref('');
|
|
|
|
|
|
const forecast = ref<SalesForecastDto | null>(null);
|
|
|
|
|
|
const users = ref<OrgUserDto[]>([]);
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
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);
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
/* لاجیک موبایل نسخهٔ main: لیست/فرم دو صفحهٔ جدا و تب اهداف/پیشبینی */
|
2026-09-05 18:38:11 +03:30
|
|
|
|
const mobTab = ref<MobTab>('goals');
|
|
|
|
|
|
const mobScreen = ref<MobScreen>('list');
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const isMobile = ref(false);
|
|
|
|
|
|
let mobileMq: MediaQueryList | null = null;
|
|
|
|
|
|
function syncMobile() {
|
|
|
|
|
|
isMobile.value = Boolean(mobileMq?.matches);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
const formOpen = ref(true);
|
|
|
|
|
|
const editingId = ref<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const title = ref('');
|
|
|
|
|
|
const goalType = ref<SalesGoalType>('sales');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const targetAmount = ref('');
|
2026-09-05 18:38:11 +03:30
|
|
|
|
const forecastAmount = ref('');
|
|
|
|
|
|
const probability = ref('80');
|
2026-08-29 21:06:30 +03:30
|
|
|
|
const notes = ref('');
|
|
|
|
|
|
const userId = ref('');
|
|
|
|
|
|
const periodStart = ref('');
|
|
|
|
|
|
const periodEnd = ref('');
|
|
|
|
|
|
|
|
|
|
|
|
const canManageTeamGoals = computed(() => canReassignRecords(auth.user?.role));
|
2026-09-05 18:38:11 +03:30
|
|
|
|
const currency = computed(() => forecast.value?.currency || 'IRR');
|
|
|
|
|
|
const isCountType = computed(
|
|
|
|
|
|
() =>
|
|
|
|
|
|
goalType.value === 'customer' ||
|
|
|
|
|
|
goalType.value === 'deal' ||
|
|
|
|
|
|
goalType.value === 'activity' ||
|
|
|
|
|
|
goalType.value === 'quality',
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const userOptions = computed(() => [
|
|
|
|
|
|
{ value: '', label: 'کل سازمان' },
|
|
|
|
|
|
...users.value.map((u) => ({ value: u.id, label: u.fullName })),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-06 15:04:46 +03:30
|
|
|
|
if (q.value.trim()) {
|
|
|
|
|
|
list = list.filter((g) => matchesSearch(q.value, [g.title]));
|
2026-09-05 18:38:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
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 ? 'ویرایش هدف' : 'افزودن هدف جدید',
|
|
|
|
|
|
);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
/** موبایل در صفحهٔ لیست فرم را نمیبیند؛ دسکتاپ سایدبار فرم همیشه باز است (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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
function monthBounds() {
|
|
|
|
|
|
const now = new Date();
|
2026-09-02 21:29:00 +03:30
|
|
|
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
|
|
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
2026-08-29 21:06:30 +03:30
|
|
|
|
return {
|
2026-09-02 21:29:00 +03:30
|
|
|
|
start: formatLocalDateInput(start),
|
|
|
|
|
|
end: formatLocalDateInput(end),
|
2026-08-29 21:06:30 +03:30
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
function resetPeriodDefaults() {
|
|
|
|
|
|
const b = monthBounds();
|
|
|
|
|
|
from.value = b.start;
|
|
|
|
|
|
to.value = b.end;
|
|
|
|
|
|
periodStart.value = b.start;
|
|
|
|
|
|
periodEnd.value = b.end;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
function formatMoney(amount: number) {
|
|
|
|
|
|
return formatMoneyLib(amount, currency.value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
const forecastChartData = computed(() => {
|
2026-09-12 01:33:07 +03:30
|
|
|
|
const f = forecast.value;
|
2026-11-03 16:51:51 +03:30
|
|
|
|
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,
|
|
|
|
|
|
}));
|
2026-09-12 01:33:07 +03:30
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
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 '—';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
try {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
return formatJalaliNumeric(new Date(iso));
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} catch {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
return '—';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function typeMeta(type: string) {
|
|
|
|
|
|
return GOAL_TYPES.find((t) => t.value === type) ?? GOAL_TYPES[0];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function goalTypeIcon(type: string): Component {
|
|
|
|
|
|
return GOAL_TYPE_ICONS[typeMeta(type).icon] ?? AimOutlined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
function statusLabel(status: SalesGoalStatus | string) {
|
|
|
|
|
|
if (status === 'completed') return 'تکمیل شده';
|
|
|
|
|
|
if (status === 'needs_attention') return 'نیاز به توجه';
|
|
|
|
|
|
return 'در حال انجام';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function statusColor(status: SalesGoalStatus | string) {
|
|
|
|
|
|
if (status === 'completed') return 'success';
|
|
|
|
|
|
if (status === 'needs_attention') return 'warning';
|
|
|
|
|
|
return 'processing';
|
2026-09-05 18:38:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function kpiIcon(key: string): Component {
|
|
|
|
|
|
if (key === 'customer') return TeamOutlined;
|
|
|
|
|
|
if (key === 'deal') return FileTextOutlined;
|
|
|
|
|
|
if (key === 'forecast') return FundOutlined;
|
|
|
|
|
|
return AimOutlined;
|
2026-09-05 18:38:11 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
2026-11-03 16:51:51 +03:30
|
|
|
|
if (isMobile.value) {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
formOpen.value = false;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function load() {
|
|
|
|
|
|
loading.value = true;
|
|
|
|
|
|
error.value = '';
|
2026-09-05 18:38:11 +03:30
|
|
|
|
closeMenus();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
try {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
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,
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
forecast.value = data;
|
|
|
|
|
|
if (canManageTeamGoals.value && !users.value.length) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await api.get<OrgUserDto[]>('/users');
|
|
|
|
|
|
users.value = res.data;
|
|
|
|
|
|
} catch {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
/* optional */
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'بارگذاری پیشبینی ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'بارگذاری پیشبینی ناموفق بود.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
async function saveGoal() {
|
|
|
|
|
|
if (!title.value.trim() || targetAmount.value === '') {
|
|
|
|
|
|
error.value = 'عنوان و مقدار هدف را وارد کنید.';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!periodStart.value || !periodEnd.value) {
|
|
|
|
|
|
error.value = 'بازه زمانی را وارد کنید.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
saving.value = true;
|
|
|
|
|
|
error.value = '';
|
|
|
|
|
|
try {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
const body: CreateSalesGoalRequest | UpdateSalesGoalRequest = {
|
2026-08-29 21:06:30 +03:30
|
|
|
|
title: title.value.trim(),
|
2026-09-05 18:38:11 +03:30
|
|
|
|
type: goalType.value,
|
|
|
|
|
|
periodStart: new Date(periodStart.value).toISOString(),
|
|
|
|
|
|
periodEnd: new Date(periodEnd.value + 'T23:59:59.000Z').toISOString(),
|
2026-08-29 21:06:30 +03:30
|
|
|
|
targetAmount: Number(targetAmount.value),
|
2026-09-05 18:38:11 +03:30
|
|
|
|
forecastAmount:
|
|
|
|
|
|
forecastAmount.value !== '' ? Number(forecastAmount.value) : null,
|
|
|
|
|
|
probability:
|
|
|
|
|
|
probability.value !== '' ? Number(probability.value) : null,
|
2026-08-29 21:06:30 +03:30
|
|
|
|
notes: notes.value || undefined,
|
|
|
|
|
|
userId: canManageTeamGoals.value
|
2026-09-05 18:38:11 +03:30
|
|
|
|
? userId.value || null
|
2026-08-29 21:06:30 +03:30
|
|
|
|
: undefined,
|
|
|
|
|
|
};
|
2026-09-05 18:38:11 +03:30
|
|
|
|
if (editingId.value) {
|
|
|
|
|
|
await api.patch(`/sales/goals/${editingId.value}`, body);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await api.post('/sales/goals', body);
|
|
|
|
|
|
}
|
|
|
|
|
|
cancelForm();
|
|
|
|
|
|
formOpen.value = true;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
await load();
|
|
|
|
|
|
} catch (e) {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
error.value = axios.isAxiosError(e)
|
|
|
|
|
|
? String(
|
|
|
|
|
|
(e.response?.data as { message?: string })?.message ??
|
|
|
|
|
|
'ذخیره هدف ناموفق بود.',
|
|
|
|
|
|
)
|
|
|
|
|
|
: 'ذخیره هدف ناموفق بود.';
|
2026-08-29 21:06:30 +03:30
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
function removeGoal(id: string) {
|
|
|
|
|
|
closeMenus();
|
|
|
|
|
|
Modal.confirm({
|
|
|
|
|
|
title: 'این هدف حذف شود؟',
|
|
|
|
|
|
okText: 'حذف',
|
|
|
|
|
|
cancelText: 'انصراف',
|
|
|
|
|
|
okButtonProps: { danger: true },
|
|
|
|
|
|
onOk: async () => {
|
2026-09-05 18:38:11 +03:30
|
|
|
|
try {
|
|
|
|
|
|
await api.delete(`/sales/goals/${id}`);
|
|
|
|
|
|
if (editingId.value === id) cancelForm();
|
|
|
|
|
|
await load();
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
error.value = 'حذف هدف ناموفق بود.';
|
|
|
|
|
|
}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-08-29 21:06:30 +03:30
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 18:38:11 +03:30
|
|
|
|
watch([statusFilter, q, pageSize], () => {
|
|
|
|
|
|
page.value = 1;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
watch([from, to], () => {
|
|
|
|
|
|
void load();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-08-29 21:06:30 +03:30
|
|
|
|
onMounted(() => {
|
2026-11-03 16:51:51 +03:30
|
|
|
|
mobileMq = window.matchMedia(MOBILE_MQ);
|
|
|
|
|
|
syncMobile();
|
|
|
|
|
|
mobileMq.addEventListener('change', syncMobile);
|
2026-09-05 18:38:11 +03:30
|
|
|
|
resetPeriodDefaults();
|
2026-08-29 21:06:30 +03:30
|
|
|
|
void load();
|
|
|
|
|
|
});
|
2026-11-03 16:51:51 +03:30
|
|
|
|
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
|
mobileMq?.removeEventListener('change', syncMobile);
|
|
|
|
|
|
mobileMq = null;
|
|
|
|
|
|
});
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
</div>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-flex>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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
|
2026-09-12 01:33:07 +03:30
|
|
|
|
/>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-row
|
|
|
|
|
|
v-if="!loading && !isMobileForm && !(isMobile && mobTab === 'forecast')"
|
|
|
|
|
|
:gutter="[16, 16]"
|
2026-09-05 18:38:11 +03:30
|
|
|
|
>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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 }">
|
2026-09-05 18:38:11 +03:30
|
|
|
|
{{ formatValue(kpi.current, kpi.unit) }}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
<MoneyInput v-if="!isCountType" v-model="targetAmount" :decimals="0" />
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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="مبلغ پیشبینی شده (ریال)">
|
2026-09-05 18:38:11 +03:30
|
|
|
|
<MoneyInput v-model="forecastAmount" :decimals="0" />
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره تغییرات' : 'ثبت هدف' }}
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-button>
|
|
|
|
|
|
<a-button block :disabled="saving" @click="cancelForm">انصراف</a-button>
|
|
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-form>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</a-affix>
|
|
|
|
|
|
</a-col>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" @click.stop>
|
|
|
|
|
|
<a-typography-text type="secondary">
|
|
|
|
|
|
{{ toFaDigits(filteredGoals.length) }} هدف
|
|
|
|
|
|
</a-typography-text>
|
2026-10-05 11:56:49 +03:30
|
|
|
|
<TableColumnsMenu table-key="sales-forecast" align="end" />
|
2026-11-03 16:51:51 +03:30
|
|
|
|
</a-flex>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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="هدفی یافت نشد." />
|
2026-09-22 11:02:08 +03:30
|
|
|
|
</template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
</template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 18:38:11 +03:30
|
|
|
|
</template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<template v-else-if="column.key === 'وضعیت'">
|
|
|
|
|
|
<a-tag :color="statusColor(record.status)" :bordered="false">
|
|
|
|
|
|
{{ statusLabel(record.status) }}
|
|
|
|
|
|
</a-tag>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
</template>
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<!-- خلاصه پیشبینی در main فقط تب «پیشبینی» موبایل است -->
|
|
|
|
|
|
<template v-if="isMobile && mobTab === 'forecast'">
|
|
|
|
|
|
<a-divider orientation="right">خلاصه پیشبینی</a-divider>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-09-05 02:29:41 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<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>
|
2026-08-29 21:06:30 +03:30
|
|
|
|
|
2026-11-03 16:51:51 +03:30
|
|
|
|
<!-- نوار پایین موبایل 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>
|