Edit dashboard appearance

This commit is contained in:
2026-09-21 17:55:05 +03:30
parent d7d82102af
commit 1adc217375
9 changed files with 169 additions and 130 deletions
+7 -33
View File
@@ -32,31 +32,8 @@ type HeroChip = {
link: string;
};
const defaultChips: HeroChip[] = [
{
key: 'demo-urgent',
tone: 'urgent',
title: 'شرکت آماده تصمیم‌گیریه',
subtitle: 'امروز تماس بگیر',
link: '/pipeline',
},
{
key: 'demo-warning',
tone: 'warning',
title: '۳ معامله بیشتر از ۵ روزه',
subtitle: 'پیگیری نشدن',
link: '/pipeline',
},
{
key: 'demo-success',
tone: 'success',
title: 'این ماه از تارگت جلوتری',
link: '/sales/forecast',
},
];
const chips = computed<HeroChip[]>(() => {
const fromApi = props.alerts.slice(0, 3).map((a) => {
const chips = computed<HeroChip[]>(() =>
props.alerts.slice(0, 3).map((a) => {
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
const title = parts[0]?.trim() || a.text;
const subtitle = parts.slice(1).join(' ').trim() || undefined;
@@ -67,12 +44,8 @@ const chips = computed<HeroChip[]>(() => {
subtitle,
link: a.link || '/',
};
});
if (fromApi.length >= 3) return fromApi;
const usedTones = new Set(fromApi.map((c) => c.tone));
const fillers = defaultChips.filter((c) => !usedTones.has(c.tone));
return [...fromApi, ...fillers].slice(0, 3);
});
}),
);
function alertIcon(tone: string) {
if (tone === 'urgent') return 'fire' as const;
@@ -103,11 +76,12 @@ const shortcutLabel = computed(() =>
</span>
<div class="greet-text">
<h1>{{ greeting }} {{ firstName }} 👋</h1>
<p>{{ brand.nameFa }} اینا رو برات پیدا کرده</p>
<p v-if="chips.length">{{ brand.nameFa }} اینا رو برات پیدا کرده</p>
<p v-else>از {{ brand.nameFa }} بپرس یا کار امروز را شروع کن</p>
</div>
</div>
<div class="hero-chips">
<div v-if="chips.length" class="hero-chips">
<RouterLink
v-for="chip in chips"
:key="chip.key"
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { toFaDigits } from '@/lib/jalali';
import type { PipelineBoardDto } from '@mento/shared';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
const props = defineProps<{
board: PipelineBoardDto | null;
@@ -12,33 +13,6 @@ const props = defineProps<{
type Card = { id: string; name: string; amount: string; when: string };
type Col = { id: string; name: string; count: number; cards: Card[] };
const demoCols: Col[] = [
{
id: 'p1',
name: 'پیشنهاد قیمت',
count: 4,
cards: [{ id: 'a', name: 'شرکت سایپا', amount: '۳۴۰٬۰۰۰٬۰۰۰ تومان', when: 'امروز' }],
},
{
id: 'p2',
name: 'نیازسنجی',
count: 6,
cards: [
{ id: 'b', name: 'آریا تجارت', amount: '۳۶۰٬۰۰۰٬۰۰۰ تومان', when: 'دیروز' },
{ id: 'c', name: 'فناوران نوین', amount: '۱۸۰٬۰۰۰٬۰۰۰ تومان', when: 'دیروز' },
],
},
{
id: 'p3',
name: 'سرنخ جدید',
count: 8,
cards: [
{ id: 'd', name: 'شرکت نیکان', amount: '۸۰٬۰۰۰٬۰۰۰ تومان', when: 'امروز' },
{ id: 'e', name: 'گروه پارسیان', amount: '۱۲۰٬۰۰۰٬۰۰۰ تومان', when: 'دیروز' },
],
},
];
function relativeWhen(iso: string | null | undefined) {
if (!iso) return '';
const diff = Date.now() - new Date(iso).getTime();
@@ -49,10 +23,10 @@ function relativeWhen(iso: string | null | undefined) {
}
const cols = computed<Col[]>(() => {
const stages = props.board?.stages.slice(0, 3) ?? [];
const stages = props.board?.stages ?? [];
const hasDeals = stages.some((s) => s.deals.length);
if (!stages.length || !hasDeals) return demoCols;
return stages.map((stage) => ({
if (!hasDeals) return [];
return stages.slice(0, 3).map((stage) => ({
id: stage.id,
name: stage.name,
count: stage.deals.length,
@@ -73,7 +47,14 @@ const cols = computed<Col[]>(() => {
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink>
</header>
<div class="mini-pipeline">
<HomePanelEmpty
v-if="!cols.length"
title="هنوز معامله‌ای در کاریز نیست"
description="اولین معامله را بسازید تا جریان فروش اینجا دیده شود."
action-label="معامله جدید"
:action-to="{ name: 'deal-new' }"
/>
<div v-else class="mini-pipeline">
<div v-for="col in cols" :key="col.id" class="pipe-col">
<header>
<strong>{{ col.name }} ({{ toFaDigits(col.count) }})</strong>
@@ -83,7 +64,7 @@ const cols = computed<Col[]>(() => {
v-for="card in col.cards"
:key="card.id"
class="pipe-card"
:to="card.id.length > 4 ? { name: 'deal-detail', params: { id: card.id } } : { name: 'pipeline' }"
:to="{ name: 'deal-detail', params: { id: card.id } }"
>
<span class="deal-mark">{{ card.name.slice(0, 1) }}</span>
<div>
@@ -106,6 +87,8 @@ const cols = computed<Col[]>(() => {
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
defineProps<{
title: string;
description: string;
actionLabel: string;
actionTo: RouteLocationRaw;
}>();
</script>
<template>
<div class="empty-card">
<div class="art" aria-hidden="true">
<svg viewBox="0 0 168 148" fill="none">
<rect x="38" y="18" width="92" height="112" rx="16" fill="#E7F6F3" />
<rect x="50" y="32" width="68" height="84" rx="10" fill="#fff" />
<path d="M62 52h28" stroke="#14b8a6" stroke-width="4" stroke-linecap="round" />
<path d="M62 68h36" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<path d="M62 84h22" stroke="#99f6e4" stroke-width="4" stroke-linecap="round" />
<circle cx="118" cy="112" r="20" fill="#14b8a6" />
<path d="M118 104v16M110 112h16" stroke="#fff" stroke-width="3" stroke-linecap="round" />
</svg>
</div>
<strong>{{ title }}</strong>
<p>{{ description }}</p>
<RouterLink class="cta" :to="actionTo">{{ actionLabel }}</RouterLink>
</div>
</template>
<style scoped>
.empty-card {
flex: 1;
min-height: 0;
display: grid;
justify-items: center;
align-content: center;
text-align: center;
gap: 0.45rem;
padding: 0.4rem 0.2rem 0.15rem;
}
.art svg {
width: 92px;
height: auto;
display: block;
}
strong {
font-size: 0.88rem;
color: #0f172a;
}
p {
margin: 0;
max-width: 16rem;
color: #94a3b8;
font-size: 0.75rem;
line-height: 1.7;
}
.cta {
margin-top: 0.25rem;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 36px;
padding: 0 0.9rem;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
}
</style>
@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
import type { ActivityDto } from '@mento/shared';
const props = defineProps<{
@@ -18,33 +19,6 @@ type Row = {
label: string;
};
const demoRows: Row[] = [
{
id: 'd1',
title: 'تماس با شرکت آریا',
when: 'امروز، ۱۰:۴۵',
type: 'call',
tone: 'done',
label: 'انجام شد',
},
{
id: 'd2',
title: 'ارسال پیشنهاد قیمت به پارسیان',
when: 'دیروز، ۱۴:۴۰',
type: 'mail',
tone: 'pending',
label: 'در انتظار پاسخ',
},
{
id: 'd3',
title: 'جلسه با مشتری جدید',
when: 'پریروز، ۱۱:۰۰',
type: 'meeting',
tone: 'held',
label: 'برگزار شد',
},
];
const typeLabel: Record<string, string> = {
call: 'تماس',
meeting: 'جلسه',
@@ -67,9 +41,8 @@ function activityIcon(type: string) {
return 'mail' as const;
}
const rows = computed<Row[]>(() => {
if (!props.activities.length) return demoRows;
return props.activities.slice(0, 3).map((act) => {
const rows = computed<Row[]>(() =>
props.activities.slice(0, 3).map((act) => {
const st = activityStatus(act);
return {
id: act.id,
@@ -79,8 +52,8 @@ const rows = computed<Row[]>(() => {
tone: st.tone,
label: st.label,
};
});
});
}),
);
</script>
<template>
@@ -90,7 +63,14 @@ const rows = computed<Row[]>(() => {
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink>
</header>
<ul class="activity-list">
<HomePanelEmpty
v-if="!rows.length"
title="فعالیتی در این فهرست نیست"
description="فیلتر را عوض کنید، یا یک فعالیت جدید بسازید."
action-label="فعالیت جدید"
:action-to="{ name: 'activity-new' }"
/>
<ul v-else class="activity-list">
<li v-for="act in rows" :key="act.id">
<span class="act-icon">
<UiIcon :name="activityIcon(act.type)" :size="16" />
@@ -113,6 +93,8 @@ const rows = computed<Row[]>(() => {
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
@@ -10,16 +10,9 @@ const props = defineProps<{
formatAmount: (value: number) => string;
}>();
const demoTrend: HomeDashboardTrendPointDto[] = [
{ monthKey: '01', monthLabel: 'فروردین', amount: 980_000_000 },
{ monthKey: '02', monthLabel: 'اردیبهشت', amount: 1_250_000_000 },
{ monthKey: '03', monthLabel: 'خرداد', amount: 1_480_000_000 },
{ monthKey: '04', monthLabel: 'تیر', amount: 1_860_000_000 },
{ monthKey: '05', monthLabel: 'مرداد', amount: 2_450_000_000 },
];
const trend = computed(() => (props.trend.length ? props.trend : demoTrend));
const change = computed(() => props.changePercent ?? 14);
const trend = computed(() => props.trend);
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
const change = computed(() => props.changePercent);
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
@@ -48,6 +41,8 @@ const option = computed<UiChartOption>(() => ({
},
yAxis: {
type: 'value',
min: 0,
...(hasTrend.value ? {} : { max: 1 }),
splitLine: { lineStyle: { color: '#eef2f7' } },
axisLabel: { show: false },
},
@@ -82,7 +77,7 @@ const option = computed<UiChartOption>(() => ({
<article class="panel">
<header class="panel-head">
<h2>روند فروش</h2>
<span class="mini-badge" :class="{ down: change < 0 }">
<span v-if="hasTrend && change != null" class="mini-badge" :class="{ down: change < 0 }">
{{ formatChange(change) }} نسبت به ماه قبل
</span>
</header>
@@ -8,23 +8,16 @@ const props = defineProps<{
sources: HomeDashboardSourceDto[];
}>();
const demoSources: HomeDashboardSourceDto[] = [
{ label: 'معرفی دوستان', count: 44, percent: 34 },
{ label: 'وب‌سایت', count: 33, percent: 26 },
{ label: 'شبکه‌های اجتماعی', count: 23, percent: 18 },
{ label: 'تبلیغات', count: 18, percent: 14 },
{ label: 'سایر', count: 10, percent: 8 },
];
const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8'];
const rows = computed(() => (props.sources.length ? props.sources : demoSources));
const rows = computed(() => props.sources);
const total = computed(() => rows.value.reduce((a, s) => a + s.count, 0));
const option = computed<UiChartOption>(() => ({
animationDuration: 400,
color: colors,
tooltip: {
show: total.value > 0,
trigger: 'item',
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
formatter: (p) => {
@@ -37,10 +30,14 @@ const option = computed<UiChartOption>(() => ({
type: 'pie',
radius: ['58%', '82%'],
center: ['50%', '50%'],
silent: total.value === 0,
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
data: rows.value.map((s) => ({ name: s.label, value: s.count })),
data:
total.value > 0
? rows.value.map((s) => ({ name: s.label, value: s.count }))
: [{ name: '', value: 1, itemStyle: { color: '#e2e8f0' } }],
},
],
}));
@@ -61,7 +58,8 @@ const option = computed<UiChartOption>(() => ({
</div>
</div>
<ul class="donut-legend">
<p v-if="!total" class="legend-empty">منبعی ثبت نشده</p>
<ul v-else class="donut-legend">
<li v-for="(row, i) in rows" :key="row.label">
<span class="dot" :style="{ background: colors[i % colors.length] }" />
<span class="lbl">{{ row.label }}</span>
@@ -80,6 +78,9 @@ const option = computed<UiChartOption>(() => ({
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid var(--line);
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
}
.panel-head {
@@ -145,6 +146,12 @@ h2 {
color: #475569;
}
.legend-empty {
margin: 0;
color: #94a3b8;
font-size: 0.8rem;
}
em {
font-style: normal;
color: #94a3b8;
@@ -3,18 +3,13 @@ import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { toFaDigits } from '@/lib/jalali';
import type { HomeDashboardTopCustomerDto } from '@mento/shared';
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
const props = defineProps<{
customers: HomeDashboardTopCustomerDto[];
}>();
const demo: HomeDashboardTopCustomerDto[] = [
{ id: '1', name: 'شرکت آریا', dealCount: 8, rank: 1 },
{ id: '2', name: 'گروه پارسیان', dealCount: 4, rank: 2 },
{ id: '3', name: 'فناوران نوین', dealCount: 3, rank: 3 },
];
const rows = computed(() => (props.customers.length ? props.customers.slice(0, 3) : demo));
const rows = computed(() => props.customers.slice(0, 3));
function trophy(rank: number) {
if (rank === 1) return '🥇';
@@ -27,11 +22,21 @@ function trophy(rank: number) {
<template>
<article class="panel">
<header class="panel-head">
<h2>مشتریان برتر</h2>
<div>
<h2>مشتریان برتر</h2>
<p class="hint">بر اساس معاملات برندهٔ ۵ ماه اخیر</p>
</div>
<RouterLink :to="{ name: 'contacts' }">مشاهده همه</RouterLink>
</header>
<ul class="top-list">
<HomePanelEmpty
v-if="!rows.length"
title="هنوز مشتری برتری نیست"
description="با بستن معاملات، مشتریان برتر اینجا دیده می‌شوند."
action-label="مشتری جدید"
:action-to="{ name: 'person-new' }"
/>
<ul v-else class="top-list">
<li v-for="row in rows" :key="row.id">
<span class="cust-avatar">{{ row.name.slice(0, 1) }}</span>
<div>
@@ -52,13 +57,16 @@ function trophy(rank: number) {
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.panel-head {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 0.85rem;
gap: 0.5rem;
}
h2 {
@@ -66,6 +74,12 @@ h2 {
font-size: 0.95rem;
}
.hint {
margin: 0.15rem 0 0;
color: #94a3b8;
font-size: 0.7rem;
}
a {
color: #14b8a6;
font-size: 0.8rem;
+6
View File
@@ -204,6 +204,12 @@ textarea {
border: 1px solid var(--line);
}
.btn.ghost {
background: #fff;
color: var(--ink);
border: 1px solid var(--line);
}
.btn-danger {
background: transparent;
color: var(--danger);
+1
View File
@@ -1418,6 +1418,7 @@ tbody tr + tr td {
.btn.ghost {
background: #fff;
border-color: #e2e8f0;
color: #334155;
}
.pager {