Edit dashboard appearance
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1418,6 +1418,7 @@ tbody tr + tr td {
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
border-color: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.pager {
|
||||
|
||||
Reference in New Issue
Block a user