Files
CRM_Montzar/apps/web/src/views/SalesForecastView.vue
T
MahdiandCursor 092767b6f2 Align older CRM pages with the Mento visual language.
Restyle list, form, settings, and secondary modules to match contacts/activities patterns with teal chrome, soft panels, and mobile layouts — without changing already-redesigned screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 02:29:41 +03:30

509 lines
12 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.
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
import { canReassignRecords, canViewAllRecords } from '@/lib/roles';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import { formatLocalDateInput } from '@/lib/jalali';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import MoneyInput from '@/components/MoneyInput.vue';
import type {
CreateSalesGoalRequest,
OrgUserDto,
SalesForecastDto,
SalesGoalDto,
} from '@mento/shared';
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 title = ref('هدف فروش ماه جاری');
const targetAmount = ref('');
const notes = ref('');
const userId = ref('');
const periodStart = ref('');
const periodEnd = ref('');
const canManageTeamGoals = computed(() => canReassignRecords(auth.user?.role));
const showAllHint = computed(() => canViewAllRecords(auth.user?.role));
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 formatMoney(amount: number) {
return formatMoneyLib(amount, forecast.value?.currency || 'IRR');
}
function formatDate(iso: string) {
try {
return new Intl.DateTimeFormat('fa-IR').format(new Date(iso));
} catch {
return iso.slice(0, 10);
}
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<SalesForecastDto>('/sales/forecast');
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) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری پیش‌بینی ناموفق بود.';
}
} finally {
loading.value = false;
}
}
async function createGoal() {
if (!title.value.trim() || !targetAmount.value) {
error.value = 'عنوان و مبلغ هدف را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const bounds = monthBounds();
const body: CreateSalesGoalRequest = {
title: title.value.trim(),
periodStart: new Date(periodStart.value || bounds.start).toISOString(),
periodEnd: new Date(
(periodEnd.value || bounds.end) + 'T23:59:59.000Z',
).toISOString(),
targetAmount: Number(targetAmount.value),
notes: notes.value || undefined,
userId: canManageTeamGoals.value
? userId.value === ''
? null
: userId.value
: undefined,
currency: 'IRR',
};
await api.post<SalesGoalDto>('/sales/goals', body);
targetAmount.value = '';
notes.value = '';
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg)
? msg.join('، ')
: (msg ?? 'ثبت هدف ناموفق بود');
} else {
error.value = 'ثبت هدف ناموفق بود';
}
} finally {
saving.value = false;
}
}
async function removeGoal(id: string) {
if (!confirm('این هدف حذف شود؟')) return;
await api.delete(`/sales/goals/${id}`);
await load();
}
onMounted(() => {
const b = monthBounds();
periodStart.value = b.start;
periodEnd.value = b.end;
void load();
});
</script>
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>هدف و پیش‌بینی</h1>
<p class="lead">
هدف ماهانه را بگذارید و تحقق، کاریز باز و پیش‌بینی ساده را ببینید.
<span v-if="!showAllHint"> اعداد بر اساس پرونده‌های شماست.</span>
</p>
</div>
<RouterLink class="btn ghost" :to="{ name: 'home' }">خانه</RouterLink>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<div v-if="forecast" class="metrics">
<article>
<p class="label">هدف دوره</p>
<strong>{{ formatMoney(forecast.targetAmount) }}</strong>
</article>
<article>
<p class="label">تحقق (برنده)</p>
<strong>{{ formatMoney(forecast.wonAmount) }}</strong>
<span class="muted">{{ forecast.wonDealsCount }} معامله</span>
</article>
<article>
<p class="label">کاریز باز</p>
<strong>{{ formatMoney(forecast.openPipelineAmount) }}</strong>
<span class="muted">{{ forecast.openDealsCount }} معامله</span>
</article>
<article class="accent">
<p class="label">پیش‌بینی ساده</p>
<strong>{{ formatMoney(forecast.forecastAmount) }}</strong>
<span class="muted">تحقق + ۴۰٪ کاریز باز</span>
</article>
</div>
<div v-if="forecast" class="progress-block">
<div class="progress-head">
<span>پیشرفت نسبت به هدف</span>
<strong>
{{
forecast.progressPercent == null
? '—'
: `${new Intl.NumberFormat('fa-IR').format(forecast.progressPercent)}٪`
}}
</strong>
</div>
<div class="bar">
<div
class="fill"
:style="{
width: `${Math.min(100, forecast.progressPercent ?? 0)}%`,
}"
/>
</div>
<p class="muted period">
{{ formatDate(forecast.periodStart) }}
تا
{{ formatDate(forecast.periodEnd) }}
</p>
</div>
<form class="form-card" @submit.prevent="createGoal">
<h2>هدف جدید</h2>
<div class="grid">
<div class="field wide">
<label>عنوان</label>
<input v-model="title" required maxlength="200" />
</div>
<div class="field">
<label>مبلغ هدف (ریال)</label>
<MoneyInput v-model="targetAmount" :decimals="0" required />
</div>
<div v-if="canManageTeamGoals" class="field">
<label>برای</label>
<select v-model="userId">
<option value="">کل سازمان</option>
<option v-for="u in users" :key="u.id" :value="u.id">
{{ u.fullName }}
</option>
</select>
</div>
<div class="field">
<label>از تاریخ</label>
<JalaliDateInput v-model="periodStart" required />
</div>
<div class="field">
<label>تا تاریخ</label>
<JalaliDateInput v-model="periodEnd" required />
</div>
<div class="field wide">
<label>یادداشت</label>
<input v-model="notes" maxlength="2000" />
</div>
</div>
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : 'ثبت هدف' }}
</button>
</form>
<div class="goals">
<h2>اهداف این دوره</h2>
<div v-if="forecast?.goals.length" class="goal-list">
<div v-for="g in forecast.goals" :key="g.id" class="goal-row">
<div>
<strong>{{ g.title }}</strong>
<p class="muted">
{{ formatMoney(g.targetAmount) }}
· {{ formatDate(g.periodStart) }} تا {{ formatDate(g.periodEnd) }}
<span v-if="g.user"> · {{ g.user.fullName }}</span>
<span v-else> · سازمانی</span>
</p>
</div>
<button class="btn ghost" type="button" @click="removeGoal(g.id)">
حذف
</button>
</div>
</div>
<p v-else class="empty">هنوز هدفی برای این دوره ثبت نشده.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 920px;
min-width: 0;
overflow-x: clip;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
h2 {
margin: 0 0 0.85rem;
font-size: 1.05rem;
color: #0f172a;
}
.lead,
.muted {
margin: 0;
color: #64748b;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
min-height: 42px;
padding: 0.55rem 1rem;
border-radius: 12px;
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
}
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.metrics {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.metrics article {
background: #fff;
border-radius: 18px;
padding: 0.95rem 1.05rem;
display: grid;
gap: 0.25rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.metrics .accent {
background: linear-gradient(135deg, #f0fdfa 0%, #fff 100%);
border: 1px solid #99f6e4;
}
.metrics .accent strong {
color: #0f766e;
}
.label {
margin: 0;
color: #64748b;
font-size: 0.85rem;
}
.progress-block {
display: grid;
gap: 0.5rem;
background: #fff;
border-radius: 18px;
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
}
.progress-head {
display: flex;
justify-content: space-between;
gap: 0.5rem;
color: #0f766e;
font-weight: 700;
}
.bar {
height: 10px;
border-radius: 999px;
background: #e2e8f0;
overflow: hidden;
}
.fill {
height: 100%;
background: linear-gradient(90deg, #14b8a6, #0f766e);
border-radius: 999px;
min-width: 0;
transition: width 0.3s ease;
}
.period {
font-size: 0.9rem;
}
.form-card,
.goals {
background: #fff;
border-radius: 18px;
padding: 1rem 1.05rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
min-width: 0;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.field {
display: grid;
gap: 0.3rem;
min-width: 0;
}
.field.wide {
grid-column: 1 / -1;
}
label {
color: #64748b;
font-size: 0.9rem;
font-weight: 700;
}
input,
select {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
width: 100%;
min-width: 0;
}
.goal-list {
display: grid;
gap: 0.55rem;
}
.goal-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 0.85rem;
border: 1px solid #f1f5f9;
border-radius: 14px;
background: #f8fafc;
min-width: 0;
}
.goal-row p {
margin: 0.2rem 0 0;
}
.empty,
.error {
margin: 0;
}
.error {
color: #b91c1c;
}
.empty {
color: #64748b;
}
@media (max-width: 900px) {
.metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.metrics,
.grid {
grid-template-columns: 1fr;
}
.page-head {
flex-direction: column;
}
.page-head .btn.ghost {
width: 100%;
}
.goal-row {
flex-direction: column;
align-items: stretch;
}
.goal-row .btn.ghost {
width: 100%;
}
}
</style>