Files
crm/apps/web/src/views/SalesForecastView.vue
T

940 lines
39 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, onMounted, ref, watch } from '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 UiIcon from '@/components/UiIcon.vue';
import type {
CreateSalesGoalRequest,
OrgUserDto,
SalesForecastDto,
SalesForecastKpiDto,
SalesGoalDto,
SalesGoalStatus,
SalesGoalType,
UpdateSalesGoalRequest,
} from '@mento/shared';
type StatusFilter = 'all' | SalesGoalStatus;
type MobTab = 'goals' | 'forecast';
type MobScreen = 'list' | 'form';
const PAGE_SIZES = [10, 20, 50];
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 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);
const mobTab = ref<MobTab>('goals');
const mobScreen = ref<MobScreen>('list');
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 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);
}
const query = q.value.trim().toLowerCase();
if (query) {
list = list.filter((g) => g.title.toLowerCase().includes(query));
}
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 pageTo = computed(() =>
Math.min(page.value * pageSize.value, filteredGoals.value.length),
);
const formTitle = computed(() =>
editingId.value ? 'ویرایش هدف' : 'افزودن هدف جدید',
);
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);
}
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 statusLabel(status: SalesGoalStatus | string) {
if (status === 'completed') return 'تکمیل شده';
if (status === 'needs_attention') return 'نیاز به توجه';
return 'در حال انجام';
}
function kpiIcon(key: string): 'target' | 'users' | 'briefcase' | 'chart' {
if (key === 'customer') return 'users';
if (key === 'deal') return 'briefcase';
if (key === 'forecast') return 'chart';
return 'target';
}
function ringStyle(percent: number) {
const p = Math.max(0, Math.min(100, percent || 0));
return {
background: `conic-gradient(#0d9488 ${p * 3.6}deg, #e2e8f0 0deg)`,
};
}
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 (window.matchMedia('(max-width: 900px)').matches) {
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;
}
}
async function removeGoal(id: string) {
if (!confirm('این هدف حذف شود؟')) return;
try {
await api.delete(`/sales/goals/${id}`);
if (editingId.value === id) cancelForm();
await load();
} catch {
error.value = 'حذف هدف ناموفق بود.';
}
closeMenus();
}
watch([statusFilter, q, pageSize], () => {
page.value = 1;
});
watch([from, to], () => {
void load();
});
onMounted(() => {
resetPeriodDefaults();
void load();
});
</script>
<template>
<section class="page" @click="closeMenus">
<!-- Mobile form screen -->
<div v-if="mobScreen === 'form'" class="mob-form-screen mob-only">
<header class="mob-head">
<button type="button" class="icon-btn" aria-label="بازگشت" @click="cancelForm">
<UiIcon name="chevron" :size="18" class="back-ico" />
</button>
<div class="mob-head-copy">
<h1>{{ formTitle }}</h1>
<p>اهداف ماهانه را تنظیم کنید</p>
</div>
<span class="doc-badge"><UiIcon name="document" :size="18" /><UiIcon name="plus" :size="10" class="plus" /></span>
</header>
<form class="side-form" @submit.prevent="saveGoal">
<label class="field">
<span>عنوان <em>*</em></span>
<div class="control">
<UiIcon name="document" :size="16" />
<input v-model="title" required maxlength="200" placeholder="هدف فروش ماه جاری" />
</div>
</label>
<label class="field">
<span>نوع هدف</span>
<div class="control">
<UiIcon :name="typeMeta(goalType).icon" :size="16" />
<select v-model="goalType">
<option v-for="t in GOAL_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
</label>
<label class="field">
<span>{{ isCountType ? 'مقدار هدف *' : 'مبلغ هدف (ریال) *' }}</span>
<div class="control">
<UiIcon name="dollar" :size="16" />
<MoneyInput v-if="!isCountType" v-model="targetAmount" :decimals="0" />
<input v-else v-model="targetAmount" type="number" min="0" step="1" dir="ltr" required />
</div>
</label>
<label v-if="canManageTeamGoals" class="field">
<span>برای</span>
<div class="control">
<UiIcon name="users" :size="16" />
<select v-model="userId">
<option value="">کل سازمان</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
</div>
</label>
<div class="row-2">
<label class="field"><span>از تاریخ <em>*</em></span><JalaliDateInput v-model="periodStart" required /></label>
<label class="field"><span>تا تاریخ <em>*</em></span><JalaliDateInput v-model="periodEnd" required /></label>
</div>
<label class="field">
<span>یادداشت</span>
<div class="control area">
<UiIcon name="document" :size="16" />
<textarea v-model="notes" rows="3" placeholder="یادداشت خود را وارد کنید..." />
</div>
</label>
<div class="forecast-block">
<h3><UiIcon name="chart" :size="16" /> پیش‌بینی</h3>
<label class="field">
<span>مبلغ پیش‌بینی شده (ریال)</span>
<div class="control">
<UiIcon name="trend" :size="16" />
<MoneyInput v-model="forecastAmount" :decimals="0" />
</div>
</label>
<label class="field">
<span>احتمال تحقق (%)</span>
<div class="control">
<UiIcon name="trend" :size="16" />
<input v-model="probability" type="number" min="0" max="100" step="1" dir="ltr" />
</div>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn primary full" type="submit" :disabled="saving">
<UiIcon name="check-plain" :size="16" />
{{ saving ? '…' : 'ثبت هدف' }}
</button>
<button class="btn ghost full" type="button" @click="cancelForm">انصراف</button>
</form>
</div>
<template v-else>
<header class="page-head">
<div class="title-row">
<span class="title-ico"><UiIcon name="target" :size="22" /></span>
<div>
<h1>هدف و پیش‌بینی</h1>
<p class="lead">اهداف ماهانه را تنظیم و پیش‌بینی‌های خود را مشاهده نمایید</p>
</div>
</div>
</header>
<div class="mob-tabs mob-only">
<button type="button" :class="{ on: mobTab === 'goals' }" @click="mobTab = 'goals'">لیست اهداف</button>
<button type="button" :class="{ on: mobTab === 'forecast' }" @click="mobTab = 'forecast'">پیش‌بینی</button>
</div>
<!-- KPI cards -->
<div
v-if="!loading"
class="kpi-row"
:class="{ 'mob-forecast-hide': mobTab === 'forecast' }"
>
<article v-for="kpi in kpis" :key="kpi.key" class="kpi-card">
<div class="ring" :style="ringStyle(kpi.percent)">
<span>{{ toFaDigits(Math.round(kpi.percent)) }}٪</span>
</div>
<div class="kpi-meta">
<strong>{{ kpi.label }}</strong>
<p>
{{ formatValue(kpi.current, kpi.unit) }}
<small>از {{ formatValue(kpi.target, kpi.unit) }}</small>
</p>
</div>
<span class="kpi-ico"><UiIcon :name="kpiIcon(kpi.key)" :size="18" /></span>
</article>
<p v-if="!kpis.length" class="muted kpi-empty">برای این بازه هنوز KPIای محاسبه نشده است.</p>
</div>
<div class="desk-layout">
<aside class="sidebar desk-only" :class="{ collapsed: !formOpen }">
<header class="side-head">
<span class="side-ico"><UiIcon name="plus" :size="16" /></span>
<h2>{{ formTitle }}</h2>
</header>
<form class="side-form" @submit.prevent="saveGoal">
<label class="field">
<span>عنوان <em>*</em></span>
<div class="control">
<UiIcon name="document" :size="16" />
<input v-model="title" required maxlength="200" placeholder="هدف فروش ماه جاری" />
</div>
</label>
<label class="field">
<span>نوع هدف</span>
<div class="control">
<UiIcon :name="typeMeta(goalType).icon" :size="16" />
<select v-model="goalType">
<option v-for="t in GOAL_TYPES" :key="t.value" :value="t.value">{{ t.label }}</option>
</select>
</div>
</label>
<label class="field">
<span>{{ isCountType ? 'مقدار هدف *' : 'مبلغ هدف (ریال) *' }}</span>
<div class="control">
<UiIcon name="dollar" :size="16" />
<MoneyInput v-if="!isCountType" v-model="targetAmount" :decimals="0" />
<input v-else v-model="targetAmount" type="number" min="0" step="1" dir="ltr" required />
</div>
</label>
<label v-if="canManageTeamGoals" class="field">
<span>برای</span>
<div class="control">
<UiIcon name="users" :size="16" />
<select v-model="userId">
<option value="">کل سازمان</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.fullName }}</option>
</select>
</div>
</label>
<div class="row-2">
<label class="field"><span>از تاریخ</span><JalaliDateInput v-model="periodStart" /></label>
<label class="field"><span>تا تاریخ</span><JalaliDateInput v-model="periodEnd" /></label>
</div>
<label class="field">
<span>یادداشت</span>
<div class="control area">
<UiIcon name="document" :size="16" />
<textarea v-model="notes" rows="3" placeholder="یادداشت خود را وارد کنید..." />
</div>
</label>
<div class="forecast-block">
<h3><UiIcon name="chart" :size="16" /> پیش‌بینی</h3>
<label class="field">
<span>مبلغ پیش‌بینی شده (ریال)</span>
<div class="control">
<UiIcon name="trend" :size="16" />
<MoneyInput v-model="forecastAmount" :decimals="0" />
</div>
</label>
<label class="field">
<span>احتمال تحقق (%)</span>
<div class="control">
<UiIcon name="trend" :size="16" />
<input v-model="probability" type="number" min="0" max="100" step="1" dir="ltr" />
</div>
</label>
</div>
<button class="btn primary full" type="submit" :disabled="saving">
<UiIcon name="check-plain" :size="16" />
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره تغییرات' : 'ثبت هدف' }}
</button>
<button class="btn ghost full" type="button" @click="cancelForm">انصراف</button>
</form>
</aside>
<main class="main">
<div class="toolbar">
<label class="search">
<UiIcon name="search" :size="16" />
<input v-model="q" type="search" placeholder="جستجوی عنوان هدف..." />
</label>
<div class="status-tabs desk-only">
<button type="button" :class="{ on: statusFilter === 'all' }" @click="statusFilter = 'all'">همه اهداف</button>
<button type="button" :class="{ on: statusFilter === 'in_progress' }" @click="statusFilter = 'in_progress'">در حال انجام</button>
<button type="button" :class="{ on: statusFilter === 'completed' }" @click="statusFilter = 'completed'">تکمیل شده</button>
<button type="button" class="warn" :class="{ on: statusFilter === 'needs_attention' }" @click="statusFilter = 'needs_attention'">نیاز به توجه</button>
</div>
<div class="date-range desk-only">
<UiIcon name="calendar" :size="16" />
<JalaliDateInput v-model="from" placeholder="از" />
<span>–</span>
<JalaliDateInput v-model="to" placeholder="تا" />
</div>
<label class="mob-filter mob-only">
<select v-model="statusFilter">
<option value="all">همه اهداف</option>
<option value="in_progress">در حال انجام</option>
<option value="completed">تکمیل شده</option>
<option value="needs_attention">نیاز به توجه</option>
</select>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<template v-else>
<!-- Desktop table -->
<div class="table-wrap desk-only">
<table>
<thead>
<tr>
<th>#</th>
<th>عنوان</th>
<th>نوع</th>
<th>مقدار هدف</th>
<th>مقدار فعلی</th>
<th>درصد تحقق</th>
<th>بازه زمانی</th>
<th>وضعیت</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="(g, idx) in pageItems" :key="g.id">
<td>{{ toFaDigits(pageFrom + idx) }}</td>
<td><strong>{{ g.title }}</strong></td>
<td>
<span class="type-cell">
<UiIcon :name="typeMeta(g.type).icon" :size="14" />
{{ typeMeta(g.type).label }}
</span>
</td>
<td>{{ formatValue(g.targetAmount, g.type === 'sales' || g.type === 'revenue' ? 'money' : 'count', g.type) }}</td>
<td>{{ formatValue(g.currentAmount, g.type === 'sales' || g.type === 'revenue' ? 'money' : 'count', g.type) }}</td>
<td>
<div class="bar-wrap">
<div class="bar"><i :style="{ width: Math.min(100, g.progressPercent) + '%' }" /></div>
<span>{{ toFaDigits(Math.round(g.progressPercent)) }}٪</span>
</div>
</td>
<td class="dates">
<span>{{ formatDate(g.periodStart) }}</span>
<span>{{ formatDate(g.periodEnd) }}</span>
</td>
<td><span class="status" :class="g.status">{{ statusLabel(g.status) }}</span></td>
<td class="ops" @click.stop>
<button type="button" class="icon-btn sm" title="ویرایش" @click="openEdit(g)">
<UiIcon name="pencil" :size="15" />
</button>
<button type="button" class="icon-btn sm" @click="menuId = menuId === g.id ? null : g.id">
<UiIcon name="dots-v" :size="15" />
</button>
<div v-if="menuId === g.id" class="menu">
<button type="button" @click="openEdit(g)">ویرایش</button>
<button type="button" class="danger" @click="removeGoal(g.id)">حذف</button>
</div>
</td>
</tr>
</tbody>
</table>
<p v-if="!pageItems.length" class="empty">هدفی یافت نشد.</p>
</div>
<!-- Mobile goal cards -->
<div class="mob-goals mob-only" v-show="mobTab === 'goals'">
<div class="mob-sec-head">
<h2>اهداف من <em>{{ toFaDigits(filteredGoals.length) }} مورد</em></h2>
<button type="button" class="fab-mini" aria-label="افزودن" @click="openCreate">
<UiIcon name="plus" :size="18" />
</button>
</div>
<article v-for="g in filteredGoals" :key="g.id" class="goal-card" @click.stop>
<button type="button" class="dots" @click="menuId = menuId === g.id ? null : g.id">
<UiIcon name="dots-v" :size="16" />
</button>
<div v-if="menuId === g.id" class="menu left">
<button type="button" @click="openEdit(g)">ویرایش</button>
<button type="button" class="danger" @click="removeGoal(g.id)">حذف</button>
</div>
<div class="goal-body">
<div>
<strong>{{ g.title }}</strong>
<span class="muted">{{ typeMeta(g.type).label }}</span>
<p>{{ formatValue(g.targetAmount, g.type === 'sales' || g.type === 'revenue' ? 'money' : 'count', g.type) }}</p>
<small class="muted">{{ formatDate(g.periodStart) }} تا {{ formatDate(g.periodEnd) }}</small>
</div>
<div class="ring sm" :style="ringStyle(g.progressPercent)">
<span>{{ toFaDigits(Math.round(g.progressPercent)) }}٪</span>
</div>
</div>
<span class="status" :class="g.status">
<UiIcon name="calendar" :size="12" />
{{ statusLabel(g.status) }}
</span>
</article>
<p v-if="!filteredGoals.length" class="empty">هدفی یافت نشد.</p>
</div>
<!-- Mobile forecast tab -->
<div class="mob-forecast mob-only" v-show="mobTab === 'forecast'">
<article class="forecast-summary card">
<h3>خلاصه پیش‌بینی</h3>
<div class="sum-row"><span>هدف دوره</span><strong>{{ formatMoney(forecast?.targetAmount || 0) }}</strong></div>
<div class="sum-row"><span>تحقق (برنده)</span><strong>{{ formatMoney(forecast?.wonAmount || 0) }}</strong></div>
<div class="sum-row"><span>کاریز باز</span><strong>{{ formatMoney(forecast?.openPipelineAmount || 0) }}</strong></div>
<div class="sum-row payable"><span>پیش‌بینی درآمد</span><strong>{{ formatMoney(forecast?.forecastAmount || 0) }}</strong></div>
<div class="sum-row"><span>مشتریان جدید</span><strong>{{ toFaDigits(forecast?.newCustomersCount || 0) }}</strong></div>
<div class="sum-row"><span>معاملات جدید</span><strong>{{ toFaDigits(forecast?.newDealsCount || 0) }}</strong></div>
</article>
</div>
<footer v-if="filteredGoals.length && mobTab !== 'forecast'" class="pager desk-only">
<div class="pager-meta">
<p class="muted">
نمایش {{ toFaDigits(pageFrom) }} تا {{ toFaDigits(pageTo) }} از
{{ toFaDigits(filteredGoals.length) }} مورد
</p>
<label class="page-size">
نمایش در هر صفحه:
<select v-model.number="pageSize">
<option v-for="n in PAGE_SIZES" :key="n" :value="n">{{ toFaDigits(n) }}</option>
</select>
</label>
</div>
<div class="pages">
<button type="button" class="page-btn" :disabled="page <= 1" @click="page -= 1">‹</button>
<button
v-for="n in pageCount"
:key="n"
type="button"
class="page-btn"
:class="{ on: page === n }"
@click="page = n"
>
{{ toFaDigits(n) }}
</button>
<button type="button" class="page-btn" :disabled="page >= pageCount" @click="page += 1">›</button>
</div>
</footer>
</template>
</main>
</div>
<div class="mob-bottom mob-only">
<RouterLink class="side-link" to="/reports"><UiIcon name="chart" :size="18" /><span>گزارش</span></RouterLink>
<button type="button" class="fab" @click="openCreate">افزودن هدف جدید +</button>
<RouterLink class="side-link" to="/reports"><UiIcon name="trend" :size="18" /><span>تحلیل</span></RouterLink>
</div>
</template>
</section>
</template>
<style scoped>
.page {
--teal: #0d9488;
--teal-soft: #f0fdfa;
display: grid;
gap: 1rem;
padding-bottom: 5.5rem;
min-width: 0;
}
.page-head { display: flex; justify-content: space-between; gap: 1rem; }
.title-row { display: flex; gap: 0.75rem; align-items: flex-start; }
.title-ico, .side-ico, .doc-badge, .kpi-ico {
width: 42px; height: 42px; border-radius: 12px; display: grid; place-items: center;
background: var(--teal-soft); color: var(--teal); flex-shrink: 0; position: relative;
}
.doc-badge .plus {
position: absolute; bottom: 4px; left: 4px; background: var(--teal); color: #fff;
border-radius: 4px; padding: 1px;
}
h1 { margin: 0; font-size: 1.4rem; font-weight: 800; }
.lead, .mob-head-copy p { margin: 0.2rem 0 0; color: var(--muted); font-size: 0.88rem; }
.kpi-row {
display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.75rem;
}
.kpi-card {
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 0.9rem;
display: grid; grid-template-columns: auto 1fr auto; gap: 0.7rem; align-items: center;
box-shadow: 0 8px 20px rgba(15,23,42,.04);
}
.ring {
width: 64px; height: 64px; border-radius: 50%; display: grid; place-items: center;
position: relative;
}
.ring::before {
content: ''; position: absolute; inset: 7px; border-radius: 50%; background: #fff;
}
.ring span {
position: relative; z-index: 1; font-size: 0.78rem; font-weight: 800; color: var(--teal);
}
.ring.sm { width: 56px; height: 56px; }
.ring.sm::before { inset: 6px; }
.ring.sm span { font-size: 0.72rem; }
.kpi-meta strong { display: block; font-size: 0.88rem; }
.kpi-meta p { margin: 0.25rem 0 0; font-size: 0.78rem; font-weight: 700; }
.kpi-meta small { display: block; color: var(--muted); font-weight: 500; margin-top: 0.1rem; }
.kpi-empty { grid-column: 1 / -1; }
.desk-layout {
display: grid; grid-template-columns: minmax(280px, 320px) minmax(0, 1fr); gap: 1rem; align-items: start;
}
.sidebar, .main, .card, .table-wrap, .goal-card, .forecast-summary {
background: #fff; border: 1px solid #eef2f7; border-radius: 16px;
box-shadow: 0 8px 20px rgba(15,23,42,.04);
}
.sidebar { padding: 1rem; display: grid; gap: 0.85rem; position: sticky; top: 0.75rem; }
.side-head { display: flex; align-items: center; gap: 0.55rem; }
.side-head h2 { margin: 0; font-size: 1.05rem; }
.side-form { display: grid; gap: 0.75rem; }
.field { display: grid; gap: 0.3rem; }
.field > span { font-size: 0.82rem; color: var(--muted); font-weight: 600; }
.field em { color: #ef4444; font-style: normal; }
.control { position: relative; display: flex; align-items: center; }
.control :deep(.ico) {
position: absolute; inset-inline-start: 0.7rem; color: #94a3b8; pointer-events: none; z-index: 1;
}
.control input, .control select, .control textarea, .search input, .mob-filter select {
width: 100%; border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h);
padding: 0 0.8rem; background: #fff; font: inherit; color: inherit; box-sizing: border-box;
}
.control input, .control select, .control textarea { padding-inline-start: 2.2rem; }
.control.area textarea { min-height: 84px; resize: vertical; }
.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.forecast-block {
border-top: 1px dashed #e2e8f0; padding-top: 0.75rem; display: grid; gap: 0.65rem;
}
.forecast-block h3 {
margin: 0; display: flex; align-items: center; gap: 0.35rem; font-size: 0.95rem; color: var(--teal);
}
.btn {
border: 0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 1rem; font-weight: 800; box-sizing: border-box;
display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; cursor: pointer;
}
.btn.primary { background: var(--teal); color: #fff; }
.btn.ghost { background: #fff; border: 1px solid #e2e8f0; color: #334155; }
.btn.full { width: 100%; }
.main { padding: 0.9rem; display: grid; gap: 0.85rem; min-width: 0; }
.toolbar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.search {
flex: 1 1 180px; display: flex; align-items: center; gap: 0.4rem;
border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.7rem; color: #94a3b8; background: #fff; box-sizing: border-box;
}
.search input { border: 0; outline: 0; background: transparent; color: var(--text); }
.status-tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.status-tabs button {
border: 0; background: #f1f5f9; color: #64748b; border-radius: 999px; padding: 0.4rem 0.75rem;
font-weight: 700; cursor: pointer;
}
.status-tabs button.on { background: var(--teal); color: #fff; }
.status-tabs button.warn.on { background: #f59e0b; }
.date-range {
display: inline-flex; align-items: center; gap: 0.3rem; border: 1px solid #e2e8f0;
border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h);
padding: 0 0.5rem; color: #94a3b8; background: #fff; box-sizing: border-box;
}
.date-range :deep(button), .date-range :deep(input) { border: 0; background: transparent; min-height: 34px; }
.table-wrap { overflow: auto; }
table { width: 100%; border-collapse: collapse; min-width: 920px; }
th, td { padding: 0.75rem 0.55rem; border-bottom: 1px solid #eef2f7; text-align: right; font-size: 0.88rem; vertical-align: middle; }
th { background: #f8fafc; color: #64748b; font-weight: 700; white-space: nowrap; }
.type-cell { display: inline-flex; align-items: center; gap: 0.3rem; color: #475569; }
.bar-wrap { display: grid; grid-template-columns: 1fr auto; gap: 0.4rem; align-items: center; min-width: 110px; }
.bar { height: 8px; border-radius: 999px; background: #e2e8f0; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--teal); border-radius: inherit; }
.dates { display: grid; gap: 0.15rem; font-size: 0.8rem; color: #64748b; }
.status {
display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.2rem 0.55rem;
border-radius: 999px; font-size: 0.75rem; font-weight: 700; white-space: nowrap;
}
.status.in_progress { background: #ccfbf1; color: #0f766e; }
.status.completed { background: #dcfce7; color: #15803d; }
.status.needs_attention { background: #ffedd5; color: #c2410c; }
.ops { position: relative; white-space: nowrap; }
.icon-btn {
width: 34px; height: 34px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff;
display: inline-flex; align-items: center; justify-content: center; cursor: pointer; color: #64748b;
}
.icon-btn.sm { width: 30px; height: 30px; }
.menu {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 120px;
background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; box-shadow: 0 10px 24px rgba(15,23,42,.12);
padding: 0.3rem; display: grid;
}
.menu.left { right: 0; left: auto; }
.menu button {
border: 0; background: transparent; text-align: right; padding: 0.5rem 0.65rem; border-radius: 8px;
cursor: pointer; font: inherit;
}
.menu button:hover { background: var(--teal-soft); color: var(--teal); }
.menu button.danger { color: #b91c1c; }
.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 0.75rem; align-items: center; }
.pager-meta { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: center; }
.page-size { display: inline-flex; gap: 0.35rem; align-items: center; color: var(--muted); font-size: 0.85rem; }
.page-size select { border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.25rem 0.45rem; }
.pages { display: flex; gap: 0.25rem; }
.page-btn {
min-width: 32px; height: 32px; border-radius: 10px; border: 1px solid #e2e8f0; background: #fff; cursor: pointer;
}
.page-btn.on { background: var(--teal); color: #fff; border-color: transparent; }
.mob-tabs, .mob-goals, .mob-forecast, .mob-bottom, .mob-only, .mob-form-screen { display: none; }
.mob-sec-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.55rem; }
.mob-sec-head h2 { margin: 0; font-size: 1rem; display: flex; align-items: center; gap: 0.45rem; }
.mob-sec-head em {
font-style: normal; font-size: 0.75rem; background: var(--teal-soft); color: var(--teal);
border-radius: 999px; padding: 0.15rem 0.5rem;
}
.fab-mini {
width: 36px; height: 36px; border-radius: 999px; border: 0; background: var(--teal); color: #fff;
display: grid; place-items: center; cursor: pointer;
}
.goal-card { padding: 0.9rem; display: grid; gap: 0.65rem; position: relative; }
.goal-card .dots {
position: absolute; top: 0.6rem; left: 0.5rem; border: 0; background: transparent; color: #94a3b8; cursor: pointer;
}
.goal-body { display: grid; grid-template-columns: 1fr auto; gap: 0.65rem; align-items: center; }
.goal-body strong { display: block; }
.goal-body .muted, .goal-body small { display: block; font-size: 0.8rem; }
.goal-body p { margin: 0.25rem 0; font-weight: 800; font-size: 0.9rem; }
.sum-row {
display: flex; justify-content: space-between; gap: 0.5rem; padding: 0.45rem 0;
border-bottom: 1px solid #f1f5f9; font-size: 0.9rem;
}
.sum-row.payable { color: var(--teal); font-weight: 800; border-bottom: 0; }
.forecast-summary { padding: 1rem; }
.mob-bottom {
position: fixed; left: 0; right: 0; bottom: 0; z-index: 40;
display: none; grid-template-columns: auto 1fr auto; gap: 0.5rem; align-items: center;
padding: 0.65rem 0.85rem calc(0.65rem + env(safe-area-inset-bottom));
background: #fff; border-top: 1px solid #eef2f7; box-shadow: 0 -8px 24px rgba(15,23,42,.06);
}
.fab {
border: 0; border-radius: 10px; min-height: 36px; background: var(--teal); color: #fff; font-weight: 800;
padding: 0 1rem; cursor: pointer;
}
.side-link {
display: grid; justify-items: center; gap: 0.15rem; text-decoration: none; color: #64748b;
font-size: 0.72rem; font-weight: 700; min-width: 48px;
}
.mob-form-screen { display: none; gap: 1rem; }
.mob-head {
display: grid; grid-template-columns: auto 1fr auto; gap: 0.55rem; align-items: center;
}
.mob-head-copy { text-align: center; }
.mob-head-copy h1 { font-size: 1.1rem; }
.back-ico { transform: rotate(180deg); }
.muted, .empty { color: var(--muted); font-size: 0.9rem; }
.error { color: #b91c1c; font-size: 0.9rem; }
@media (max-width: 1100px) {
.kpi-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
.desk-only { display: none !important; }
.mob-only, .mob-tabs, .mob-goals, .mob-forecast, .mob-bottom { display: flex; }
.mob-goals, .mob-forecast, .mob-form-screen { display: grid; }
.mob-forecast-hide { display: none !important; }
.desk-layout { grid-template-columns: 1fr; }
.kpi-row { grid-template-columns: 1fr; }
.mob-tabs {
display: grid; grid-template-columns: 1fr 1fr; gap: 0.35rem; padding: 0.28rem;
background: #f1f5f9; border-radius: var(--radius);
}
.mob-tabs button {
border: 0; border-radius: 8px; min-height: var(--control-h); height: var(--control-h); background: transparent; color: #64748b;
font-weight: 800; cursor: pointer;
}
.mob-tabs button.on { background: var(--teal); color: #fff; }
.mob-filter { flex: 1; }
.toolbar { flex-wrap: nowrap; }
}
</style>