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>
909 lines
32 KiB
Vue
909 lines
32 KiB
Vue
<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>
|