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

918 lines
21 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { toFaDigits } from '@/lib/jalali';
import type {
ActivityDto,
HomeDashboardDto,
PaginatedResponse,
PipelineBoardDto,
} from '@mento/shared';
const auth = useAuthStore();
const router = useRouter();
const loading = ref(true);
const home = ref<HomeDashboardDto | null>(null);
const activities = ref<ActivityDto[]>([]);
const board = ref<PipelineBoardDto | null>(null);
const askQuery = ref('');
const firstName = computed(() => {
const parts = auth.user?.fullName?.trim().split(/\s+/) ?? [];
return parts[0] ?? 'کاربر';
});
const greeting = computed(() => {
const hour = new Date().getHours();
if (hour < 12) return 'صبح بخیر';
if (hour < 17) return 'ظهر بخیر';
return 'عصر بخیر';
});
const kpiIcons: Record<string, string> = {
sales: '💰',
customers: '👤',
'open-deals': '💎',
followups: '📋',
};
const aiShortcuts = [
{ label: 'خلاصه مشتری شرکت آریانا را بده', to: '/contacts' },
{ label: 'متن پیگیری برای مشتری بفرست', to: '/notes' },
{ label: 'گزارش فروش این هفته را نشان بده', to: '/reports' },
{ label: 'معاملات در خطر را لیست کن', to: '/pipeline' },
];
const pipelineStages = computed(() => board.value?.stages.slice(0, 3) ?? []);
const trendPoints = computed(() => {
const trend = home.value?.salesTrend ?? [];
if (!trend.length) return '';
const max = Math.max(...trend.map((p) => p.amount), 1);
const w = 320;
const h = 120;
const pad = 12;
return trend
.map((p, i) => {
const x = pad + (i / Math.max(trend.length - 1, 1)) * (w - pad * 2);
const y = h - pad - (p.amount / max) * (h - pad * 2);
return `${x},${y}`;
})
.join(' ');
});
const trendArea = computed(() => {
const pts = trendPoints.value;
if (!pts) return '';
const first = pts.split(' ')[0]?.split(',');
const last = pts.split(' ').slice(-1)[0]?.split(',');
if (!first || !last) return '';
return `${pts} ${last[0]},132 ${first[0]},132`;
});
const donutSegments = computed(() => {
const sources = home.value?.sources ?? [];
if (!sources.length) return [];
let offset = 0;
const colors = ['#2eb29b', '#38bdf8', '#818cf8', '#fbbf24', '#fb7185', '#94a3b8'];
return sources.map((s, i) => {
const seg = { ...s, color: colors[i % colors.length], offset };
offset += s.percent;
return seg;
});
});
const typeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
function formatWhen(value: string | null) {
if (!value) return '';
try {
return new Intl.DateTimeFormat('fa-IR', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
} catch {
return value;
}
}
function activityStatus(act: ActivityDto) {
if (act.status === 'done') return { label: 'انجام شد', tone: 'done' };
if (act.dueAt && new Date(act.dueAt) < new Date()) {
return { label: 'عقب‌افتاده', tone: 'late' };
}
return { label: 'در انتظار پاسخ', tone: 'pending' };
}
function activityIcon(type: string) {
if (type === 'call') return '📞';
if (type === 'meeting') return '📅';
if (type === 'task') return '✅';
return '📝';
}
function trophy(rank: number) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return `#${toFaDigits(rank)}`;
}
function alertIcon(tone: string) {
if (tone === 'urgent') return '🔥';
if (tone === 'warning') return '⚠';
if (tone === 'success') return '📈';
return 'ℹ';
}
function formatKpiValue(key: string, value: number) {
if (key === 'sales') {
return formatMoney(value, home.value?.currency ?? 'IRR');
}
return toFaDigits(value);
}
function formatChange(value: number | null) {
if (value == null) return '';
const sign = value >= 0 ? '+' : '';
return `${sign}${toFaDigits(value)}٪`;
}
function submitAsk() {
const q = askQuery.value.trim();
if (!q) return;
void router.push({ name: 'notes', query: { q } });
}
onMounted(async () => {
loading.value = true;
try {
const [homeRes, actRes, boardRes] = await Promise.all([
api.get<HomeDashboardDto>('/reports/home'),
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { limit: 5, page: 1 },
}),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
home.value = homeRes.data;
activities.value = actRes.data.items;
board.value = boardRes.data;
} catch {
home.value = null;
} finally {
loading.value = false;
}
});
</script>
<template>
<section class="dashboard">
<header class="hero">
<div class="hero-main">
<div class="hero-greet">
<span class="hero-avatar">{{ firstName.slice(0, 1) }}</span>
<div>
<h1>{{ greeting }} {{ firstName }}</h1>
<p>منتو اینها رو برات پیدا کرده</p>
</div>
</div>
<div v-if="home?.alerts?.length" class="hero-chips">
<RouterLink
v-for="alert in home.alerts.slice(0, 3)"
:key="alert.key"
class="chip"
:class="`chip-${alert.tone}`"
:to="alert.link || '/'"
>
<span aria-hidden="true">{{ alertIcon(alert.tone) }}</span>
{{ alert.text }}
</RouterLink>
</div>
<form class="hero-ask" @submit.prevent="submitAsk">
<span class="ask-icon" aria-hidden="true">✨</span>
<input v-model="askQuery" type="search" placeholder="از منتو بپرس…" />
<kbd>⌘K</kbd>
</form>
</div>
<div class="hero-bot" aria-hidden="true">
<div class="bot-bubble">من دستیار فروش هوشمند سیستم هستم</div>
<div class="bot-head">
<span class="bot-eye" />
<span class="bot-eye" />
<span class="bot-badge">AI</span>
</div>
</div>
</header>
<p v-if="loading" class="muted center">در حال بارگذاری داشبورد…</p>
<template v-else>
<div class="kpi-row">
<article v-for="kpi in home?.kpis ?? []" :key="kpi.key" class="kpi-card">
<div class="kpi-icon">{{ kpiIcons[kpi.key] ?? '📊' }}</div>
<div>
<p class="kpi-label">{{ kpi.label }}</p>
<strong class="kpi-value">{{ formatKpiValue(kpi.key, kpi.value) }}</strong>
<span
v-if="kpi.changePercent != null"
class="kpi-change"
:class="{ down: kpi.changePercent < 0 }"
>
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
</span>
</div>
</article>
</div>
<div class="mid-grid">
<article class="panel chart-panel">
<header class="panel-head">
<h2>روند فروش</h2>
</header>
<div class="chart-wrap">
<svg viewBox="0 0 320 140" class="line-chart" aria-hidden="true">
<defs>
<linearGradient id="trendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2eb29b" stop-opacity="0.35" />
<stop offset="100%" stop-color="#2eb29b" stop-opacity="0" />
</linearGradient>
</defs>
<polygon v-if="trendArea" :points="trendArea" fill="url(#trendFill)" />
<polyline
v-if="trendPoints"
:points="trendPoints"
fill="none"
stroke="#2eb29b"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
<div class="chart-labels">
<span v-for="p in home?.salesTrend ?? []" :key="p.monthKey">{{ p.monthLabel }}</span>
</div>
</div>
</article>
<article class="panel donut-panel">
<header class="panel-head">
<h2>منابع مشتریان</h2>
</header>
<div class="donut-wrap">
<div class="donut-chart-box">
<svg viewBox="0 0 120 120" class="donut-chart" aria-hidden="true">
<circle cx="60" cy="60" r="42" fill="none" stroke="#eef2f7" stroke-width="16" />
<circle
v-for="seg in donutSegments"
:key="seg.label"
cx="60"
cy="60"
r="42"
fill="none"
:stroke="seg.color"
stroke-width="16"
stroke-linecap="butt"
:stroke-dasharray="`${seg.percent * 2.64} 264`"
:stroke-dashoffset="`${-seg.offset * 2.64}`"
transform="rotate(-90 60 60)"
/>
</svg>
<div class="donut-center">
<strong>{{ toFaDigits(home?.sources.reduce((a, s) => a + s.count, 0) ?? 0) }}</strong>
<span>مشتری</span>
</div>
</div>
<ul class="donut-legend">
<li v-for="seg in donutSegments" :key="seg.label">
<span class="dot" :style="{ background: seg.color }" />
{{ seg.label }}
<em>{{ toFaDigits(seg.percent) }}٪</em>
</li>
</ul>
</div>
</article>
<article class="panel ai-panel">
<header class="panel-head">
<h2>✨ دستیار هوشمند منتو</h2>
</header>
<p class="ai-lead">چه کمکی می‌تونم بکنم؟</p>
<div class="ai-shortcuts">
<RouterLink
v-for="item in aiShortcuts"
:key="item.label"
class="ai-chip"
:to="item.to"
>
{{ item.label }}
</RouterLink>
</div>
<form class="ai-input" @submit.prevent="submitAsk">
<input v-model="askQuery" type="search" placeholder="از منتو بپرس…" />
<button type="submit" aria-label="ارسال">➤</button>
</form>
</article>
</div>
<div class="bottom-grid">
<article class="panel">
<header class="panel-head">
<h2>فعالیت‌های اخیر</h2>
<RouterLink :to="{ name: 'activities' }">همه</RouterLink>
</header>
<ul v-if="activities.length" class="activity-list">
<li v-for="act in activities" :key="act.id">
<span class="act-icon">{{ activityIcon(act.type) }}</span>
<div class="act-body">
<strong>{{ act.title }}</strong>
<span>{{ typeLabel[act.type] ?? act.type }} · {{ formatWhen(act.dueAt || act.createdAt) }}</span>
</div>
<span class="status-pill" :class="activityStatus(act).tone">
{{ activityStatus(act).label }}
</span>
</li>
</ul>
<p v-else class="muted">فعالیتی ثبت نشده.</p>
</article>
<article class="panel pipeline-panel">
<header class="panel-head">
<h2>جریان فروش</h2>
<RouterLink :to="{ name: 'pipeline' }">کاریز کامل</RouterLink>
</header>
<div class="mini-pipeline">
<div v-for="stage in pipelineStages" :key="stage.id" class="pipe-col">
<header>
<strong>{{ stage.name }}</strong>
<span>{{ toFaDigits(stage.deals.length) }}</span>
</header>
<div class="pipe-cards">
<RouterLink
v-for="deal in stage.deals.slice(0, 3)"
:key="deal.id"
class="pipe-card"
:to="{ name: 'deal-detail', params: { id: deal.id } }"
>
<strong>{{ deal.company?.name || deal.person?.fullName || deal.title }}</strong>
<span v-if="deal.amount">{{ formatMoney(deal.amount, deal.currency) }}</span>
</RouterLink>
</div>
</div>
</div>
</article>
<article class="panel">
<header class="panel-head">
<h2>مشتریان برتر</h2>
</header>
<ul v-if="home?.topCustomers?.length" class="top-list">
<li v-for="row in home.topCustomers" :key="row.id">
<span class="rank">{{ trophy(row.rank) }}</span>
<div>
<strong>{{ row.name }}</strong>
<span>{{ toFaDigits(row.dealCount) }} معامله</span>
</div>
</li>
</ul>
<p v-else class="muted">هنوز داده کافی برای رتبه‌بندی نیست.</p>
</article>
</div>
</template>
</section>
</template>
<style scoped>
.dashboard {
display: grid;
gap: 1.25rem;
padding-bottom: 1rem;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: stretch;
padding: 1.35rem 1.5rem;
border-radius: 24px;
background: linear-gradient(135deg, #2eb29b 0%, #0f766e 45%, #1e3a5f 100%);
color: #fff;
box-shadow: 0 18px 40px rgba(15, 118, 110, 0.22);
overflow: hidden;
position: relative;
}
.hero-main {
display: grid;
gap: 1rem;
z-index: 1;
}
.hero-greet {
display: flex;
align-items: center;
gap: 0.85rem;
}
.hero-avatar {
width: 48px;
height: 48px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
display: grid;
place-items: center;
font-weight: 800;
font-size: 1.1rem;
}
.hero h1 {
margin: 0;
font-size: clamp(1.35rem, 2vw, 1.75rem);
}
.hero p {
margin: 0.25rem 0 0;
opacity: 0.88;
}
.hero-chips {
display: flex;
flex-wrap: wrap;
gap: 0.55rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.75rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 0.82rem;
text-decoration: none;
backdrop-filter: blur(4px);
}
.chip-warning {
background: rgba(251, 191, 36, 0.22);
}
.chip-urgent {
background: rgba(239, 68, 68, 0.24);
}
.chip-success {
background: rgba(34, 197, 94, 0.22);
}
.hero-ask {
display: flex;
align-items: center;
gap: 0.65rem;
background: rgba(255, 255, 255, 0.95);
color: var(--ink);
border-radius: 999px;
padding: 0.65rem 1rem;
max-width: 520px;
}
.hero-ask input {
border: 0;
background: transparent;
flex: 1;
outline: none;
}
.hero-ask kbd {
font-size: 0.72rem;
color: var(--muted);
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.15rem 0.4rem;
}
.hero-bot {
display: grid;
align-content: center;
justify-items: end;
gap: 0.65rem;
z-index: 1;
}
.bot-bubble {
max-width: 180px;
padding: 0.55rem 0.75rem;
border-radius: 14px 14px 4px 14px;
background: rgba(255, 255, 255, 0.16);
font-size: 0.82rem;
line-height: 1.45;
}
.bot-head {
width: 88px;
height: 88px;
border-radius: 22px;
background: linear-gradient(180deg, #dbeafe, #93c5fd);
position: relative;
box-shadow: 0 0 30px rgba(56, 189, 248, 0.45);
display: grid;
place-items: center;
}
.bot-eye {
position: absolute;
top: 34px;
width: 12px;
height: 12px;
border-radius: 999px;
background: #0ea5e9;
box-shadow: 0 0 12px #38bdf8;
}
.bot-eye:first-of-type {
right: 24px;
}
.bot-eye:last-of-type {
left: 24px;
}
.bot-badge {
position: absolute;
bottom: -6px;
background: #0ea5e9;
color: #fff;
font-size: 0.68rem;
font-weight: 800;
padding: 0.15rem 0.45rem;
border-radius: 999px;
}
.kpi-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
}
.kpi-card {
display: flex;
gap: 0.85rem;
align-items: flex-start;
background: #fff;
border-radius: 20px;
padding: 1.1rem 1.15rem;
box-shadow: var(--shadow);
border: 1px solid rgba(226, 232, 240, 0.9);
}
.kpi-icon {
width: 44px;
height: 44px;
border-radius: 14px;
background: var(--accent-soft);
display: grid;
place-items: center;
font-size: 1.2rem;
}
.kpi-label {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
.kpi-value {
display: block;
margin-top: 0.2rem;
font-size: 1.15rem;
}
.kpi-change {
display: block;
margin-top: 0.35rem;
color: #16a34a;
font-size: 0.78rem;
}
.kpi-change.down {
color: #dc2626;
}
.mid-grid {
display: grid;
grid-template-columns: 1.2fr 1fr 0.9fr;
gap: 1rem;
}
.bottom-grid {
display: grid;
grid-template-columns: 1fr 1.4fr 0.8fr;
gap: 1rem;
}
.panel {
background: #fff;
border-radius: 20px;
padding: 1.1rem 1.15rem;
box-shadow: var(--shadow);
border: 1px solid rgba(226, 232, 240, 0.9);
min-width: 0;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.85rem;
}
.panel-head h2 {
margin: 0;
font-size: 1rem;
}
.panel-head a {
color: var(--accent);
font-size: 0.85rem;
}
.chart-wrap {
display: grid;
gap: 0.5rem;
}
.line-chart {
width: 100%;
height: auto;
}
.chart-labels {
display: flex;
justify-content: space-between;
color: var(--muted);
font-size: 0.78rem;
}
.donut-wrap {
display: grid;
grid-template-columns: 120px 1fr;
gap: 0.85rem;
align-items: center;
}
.donut-chart-box {
position: relative;
width: 120px;
height: 120px;
}
.donut-chart {
width: 120px;
height: 120px;
}
.donut-center {
position: absolute;
inset: 0;
display: grid;
place-content: center;
text-align: center;
gap: 0.1rem;
pointer-events: none;
}
.donut-center strong {
font-size: 1rem;
}
.donut-center span {
font-size: 0.72rem;
color: var(--muted);
}
.donut-legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
font-size: 0.82rem;
}
.donut-legend li {
display: flex;
align-items: center;
gap: 0.4rem;
}
.donut-legend em {
margin-inline-start: auto;
font-style: normal;
color: var(--muted);
}
.dot {
width: 8px;
height: 8px;
border-radius: 999px;
}
.ai-lead {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 0.9rem;
}
.ai-shortcuts {
display: grid;
gap: 0.45rem;
margin-bottom: 0.85rem;
}
.ai-chip {
padding: 0.55rem 0.75rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
color: var(--ink);
font-size: 0.82rem;
text-decoration: none;
}
.ai-input {
display: flex;
gap: 0.45rem;
}
.ai-input input {
flex: 1;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.65rem 0.8rem;
}
.ai-input button {
width: 42px;
border: 0;
border-radius: 12px;
background: var(--accent);
color: #fff;
cursor: pointer;
}
.activity-list,
.top-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.65rem;
}
.activity-list li,
.top-list li {
display: flex;
align-items: center;
gap: 0.65rem;
}
.act-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: #f8fafc;
display: grid;
place-items: center;
}
.act-body {
flex: 1;
min-width: 0;
display: grid;
gap: 0.15rem;
}
.act-body strong,
.top-list strong {
font-size: 0.92rem;
}
.act-body span,
.top-list span {
color: var(--muted);
font-size: 0.78rem;
}
.status-pill {
font-size: 0.72rem;
padding: 0.2rem 0.5rem;
border-radius: 999px;
white-space: nowrap;
}
.status-pill.done {
background: #dcfce7;
color: #166534;
}
.status-pill.pending {
background: #dbeafe;
color: #1d4ed8;
}
.status-pill.late {
background: #fee2e2;
color: #b91c1c;
}
.mini-pipeline {
display: grid;
grid-template-columns: repeat(3, minmax(140px, 1fr));
gap: 0.75rem;
overflow-x: auto;
}
.pipe-col header {
display: flex;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.55rem;
font-size: 0.85rem;
}
.pipe-col header span {
color: var(--muted);
}
.pipe-cards {
display: grid;
gap: 0.45rem;
}
.pipe-card {
display: grid;
gap: 0.15rem;
padding: 0.65rem 0.75rem;
border-radius: 12px;
background: #f8fafc;
border: 1px solid var(--line);
text-decoration: none;
color: inherit;
font-size: 0.82rem;
}
.rank {
font-size: 1.2rem;
}
.muted {
color: var(--muted);
}
.center {
text-align: center;
}
@media (max-width: 1200px) {
.kpi-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mid-grid,
.bottom-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 700px) {
.hero {
grid-template-columns: 1fr;
}
.hero-bot {
display: none;
}
.kpi-row {
grid-template-columns: 1fr;
}
.mini-pipeline {
grid-template-columns: 1fr;
}
}
</style>