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

918 lines
21 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 } 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>