feat: migrate CRM screens to Ant Design Vue and expand home dashboard
Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardActivityTypeDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
items: HomeDashboardActivityTypeDto[];
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.items);
|
||||
const hasData = computed(() => rows.value.some((r) => r.count > 0));
|
||||
const total = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: { type: 'shadow' },
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (params) => {
|
||||
const row = Array.isArray(params) ? params[0] : params;
|
||||
const point = rows.value[row.dataIndex ?? 0];
|
||||
if (!point) return '';
|
||||
return `${point.label}<br/>${toFaDigits(point.count)} فعالیت باز`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: rows.value.map((r) => r.label),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
minInterval: 1,
|
||||
...(hasData.value ? {} : { max: 1 }),
|
||||
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: rows.value.map((r) => r.count),
|
||||
barMaxWidth: 36,
|
||||
itemStyle: {
|
||||
color: '#14b8a6',
|
||||
borderRadius: [8, 8, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#0f172a',
|
||||
fontWeight: 700,
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">ترکیب فعالیتهای باز</h3>
|
||||
<span v-if="hasData" class="text-xs text-slate-400">{{ toFaDigits(total) }} مورد</span>
|
||||
</div>
|
||||
<a-empty v-if="!hasData" description="فعالیت بازی نیست" />
|
||||
<div v-else class="h-56">
|
||||
<UiChart :option="option" height="224px" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -2,13 +2,25 @@
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { RobotOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Alert as AAlert,
|
||||
Button as AButton,
|
||||
Col as ACol,
|
||||
Form as AForm,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
Row as ARow,
|
||||
Select as ASelect,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import JalaliDateInput from '@/components/JalaliDateInput.vue';
|
||||
import { formatLocalDateTimeInput, rewriteGregorianDates } from '@/lib/jalali';
|
||||
import { UiButton, UiModal } from '@/components/ui';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
|
||||
import { brand, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
askQuery: string;
|
||||
@@ -26,11 +38,23 @@ const emit = defineEmits<{
|
||||
notice: [value: { error: string; done: string }];
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText, Paragraph: ATypographyParagraph } = Typography;
|
||||
|
||||
const AFormItem = AForm.Item;
|
||||
const ATextarea = AInput.TextArea;
|
||||
|
||||
const shortcuts = [
|
||||
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
|
||||
{ label: 'فعالیتهای عقبافتاده چیست؟', icon: 'calendar' as const },
|
||||
{ label: 'مهمترین پیگیری امروز چیست؟', icon: 'send' as const },
|
||||
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
|
||||
{ label: 'معاملات در خطر', prompt: 'معاملات باز و در خطر را بگو' },
|
||||
{ label: 'عقبافتادهها', prompt: 'فعالیتهای عقبافتاده چیست؟' },
|
||||
{ label: 'پیگیری امروز', prompt: 'مهمترین پیگیری امروز چیست؟' },
|
||||
{ label: 'خلاصه فروش', prompt: 'خلاصه وضعیت فروش را بده' },
|
||||
];
|
||||
|
||||
const activityTypeOptions = [
|
||||
{ value: 'call', label: 'تماس' },
|
||||
{ value: 'meeting', label: 'جلسه' },
|
||||
{ value: 'task', label: 'کار' },
|
||||
{ value: 'note', label: 'یادداشت' },
|
||||
];
|
||||
|
||||
function sourceTo(source: AskMentoSourceDto) {
|
||||
@@ -45,13 +69,6 @@ function pick(label: string) {
|
||||
emit('ask', label);
|
||||
}
|
||||
|
||||
const ACTIVITY_LABEL: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'کار',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
|
||||
const voiceBuf = ref('');
|
||||
const voiceBusy = ref(false);
|
||||
const voiceError = ref('');
|
||||
@@ -60,6 +77,13 @@ const draft = ref<VoiceCommandDraft | null>(null);
|
||||
const confirming = ref(false);
|
||||
const answerOpen = ref(false);
|
||||
|
||||
const draftOpen = computed({
|
||||
get: () => Boolean(draft.value && draft.value.intent !== 'none'),
|
||||
set: (open: boolean) => {
|
||||
if (!open) cancelDraft();
|
||||
},
|
||||
});
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (!axios.isAxiosError(e)) return fallback;
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
@@ -272,12 +296,36 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article id="home-ai-assistant" class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>
|
||||
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
|
||||
{{ brand.aiNameFa }}
|
||||
</h2>
|
||||
<section id="home-ai-assistant" class="bg-white rounded-2xl border border-slate-200/80 p-3 shadow-sm flex flex-col justify-between h-[330px] gap-3">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<span class="w-10 h-10 rounded-xl bg-emerald-600 text-white flex items-center justify-center shrink-0">
|
||||
<RobotOutlined />
|
||||
</span>
|
||||
<h3 class="font-bold text-slate-800 text-base truncate">{{ brand.aiNameFa }}</h3>
|
||||
</div>
|
||||
<a-tag color="success" class="!m-0 shrink-0">هوش مصنوعی</a-tag>
|
||||
</div>
|
||||
|
||||
<div class="bg-emerald-50/50 border border-emerald-200/60 rounded-xl p-3 text-xs text-slate-600 leading-relaxed">
|
||||
<template v-if="answer">{{ answer }}</template>
|
||||
<template v-else-if="error">{{ error }}</template>
|
||||
<template v-else>
|
||||
<strong class="text-slate-800">پیشنهاد هوشمند امروز:</strong>
|
||||
از پیشنهادهای سریع استفاده کنید یا پرسش خود را بنویسید.
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-50 border border-slate-200/90 rounded-xl p-1.5 flex items-center gap-2">
|
||||
<a-input
|
||||
:value="askQuery"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:bordered="false"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
class="flex-1"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="onTypedSubmit"
|
||||
/>
|
||||
<VoiceToTextButton
|
||||
variant="toolbar"
|
||||
hide-error
|
||||
@@ -286,322 +334,181 @@ watch(
|
||||
@recording="onVoiceRecording"
|
||||
@error="voiceError = $event"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<p class="ai-lead">چه کمکی میتونم بکنم؟</p>
|
||||
|
||||
<div class="ai-shortcuts">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
class="ai-chip"
|
||||
type="button"
|
||||
:disabled="asking"
|
||||
@click="pick(item.label)"
|
||||
>
|
||||
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="voiceBusy" class="ai-answer"><p>در حال فهمیدن دستور…</p></div>
|
||||
<div v-else-if="confirming" class="ai-answer"><p>در حال ثبت…</p></div>
|
||||
<div v-else-if="voiceDone" class="ai-answer"><p>{{ voiceDone }}</p></div>
|
||||
<p v-else-if="voiceError" class="ai-error">{{ voiceError }}</p>
|
||||
<p v-else-if="error" class="ai-error">{{ error }}</p>
|
||||
<div v-else-if="asking" class="ai-answer">
|
||||
<p>در حال پاسخ…</p>
|
||||
</div>
|
||||
|
||||
<form class="ai-input" @submit.prevent="onTypedSubmit">
|
||||
<input
|
||||
:value="askQuery"
|
||||
type="search"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
|
||||
<UiIcon name="send" :size="15" />
|
||||
</button>
|
||||
</form>
|
||||
:loading="asking || voiceBusy"
|
||||
aria-label="ارسال"
|
||||
@click="onTypedSubmit"
|
||||
>
|
||||
بپرس
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<UiModal
|
||||
:open="Boolean(draft && draft.intent !== 'none')"
|
||||
<div class="pt-3 border-t border-slate-100 flex items-center justify-between flex-wrap gap-2">
|
||||
<span class="text-[11px] font-semibold text-slate-400">پیشنهادهای سریع</span>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="item in shortcuts"
|
||||
:key="item.label"
|
||||
type="button"
|
||||
class="!text-[11px] !leading-4 !font-medium bg-emerald-50/70 text-emerald-700 border border-emerald-200/60 px-2 py-0.5 rounded-md disabled:opacity-50"
|
||||
:disabled="asking"
|
||||
@click="pick(item.prompt)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a-alert v-if="voiceBusy" type="info" show-icon message="در حال فهمیدن دستور…" />
|
||||
<a-alert v-else-if="confirming" type="info" show-icon message="در حال ثبت…" />
|
||||
<a-alert v-else-if="voiceDone" type="success" show-icon :message="voiceDone" />
|
||||
<a-alert v-else-if="voiceError" type="error" show-icon :message="voiceError" />
|
||||
</section>
|
||||
|
||||
<a-modal
|
||||
v-model:open="draftOpen"
|
||||
:title="draft?.intent === 'create_person' ? 'تأیید ساخت مشتری' : 'تأیید ثبت فعالیت'"
|
||||
@close="cancelDraft"
|
||||
destroy-on-close
|
||||
@cancel="cancelDraft"
|
||||
>
|
||||
<p v-if="draft" class="modal-lead">{{ rewriteGregorianDates(draft.summary) }}</p>
|
||||
<p class="edit-hint">قبل از تأیید میتوانید متن را اصلاح کنید.</p>
|
||||
<div v-if="draft?.person" class="modal-fields">
|
||||
<label>نام <input v-model="draft.person.firstName" @input="onPersonNameEdit" /></label>
|
||||
<label>نام خانوادگی <input v-model="draft.person.lastName" /></label>
|
||||
<label>موبایل <input v-model="draft.person.mobile" dir="ltr" /></label>
|
||||
<label>تلفن <input v-model="draft.person.phone" dir="ltr" /></label>
|
||||
<label>ایمیل <input v-model="draft.person.email" dir="ltr" /></label>
|
||||
<label>وبسایت <input v-model="draft.person.website" dir="ltr" /></label>
|
||||
<label>معرف <input v-model="draft.person.referrer" /></label>
|
||||
<label>سمت <input v-model="draft.person.position" /></label>
|
||||
<label>یادداشت <textarea v-model="draft.person.notes" rows="2" /></label>
|
||||
</div>
|
||||
<div v-else-if="draft?.activity" class="modal-fields">
|
||||
<label>عنوان <input v-model="draft.activity.title" @input="onActivityTitleEdit" /></label>
|
||||
<label>
|
||||
نوع
|
||||
<select v-model="draft.activity.type">
|
||||
<option v-for="(label, key) in ACTIVITY_LABEL" :key="key" :value="key">{{ label }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
زمان
|
||||
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
|
||||
</label>
|
||||
<label>
|
||||
مشتری
|
||||
<input v-model="draft.activity.personName" @input="onActivityPersonEdit" @change="rematchActivityPerson" />
|
||||
</label>
|
||||
<p v-if="draft.activity.personName && !draft.activity.personId" class="warn">
|
||||
در فهرست مشتریان پروندهای با این نام پیدا نشد. فعالیت ذخیره میشود، ولی داخل پروندهٔ آن شخص دیده نمیشود.
|
||||
</p>
|
||||
<label>توضیح <textarea v-model="draft.activity.description" rows="2" /></label>
|
||||
</div>
|
||||
<p v-if="draft?.missing.length" class="ai-error">ناقص است: {{ draft.missing.join('، ') }}</p>
|
||||
<template #footer>
|
||||
<UiButton variant="ghost" :disabled="confirming" @click="cancelDraft">انصراف</UiButton>
|
||||
<UiButton variant="primary" :disabled="!draft?.canConfirm || confirming" :loading="confirming" @click="confirmDraft">
|
||||
تأیید و انجام
|
||||
</UiButton>
|
||||
</template>
|
||||
</UiModal>
|
||||
<a-typography-paragraph v-if="draft">
|
||||
{{ rewriteGregorianDates(draft.summary) }}
|
||||
</a-typography-paragraph>
|
||||
<a-typography-text type="secondary">قبل از تأیید میتوانید متن را اصلاح کنید.</a-typography-text>
|
||||
|
||||
<UiModal :open="answerOpen && Boolean(answer)" title="پاسخ دستیار" @close="closeAnswer">
|
||||
<p class="modal-lead answer-body">{{ rewriteGregorianDates(answer || '') }}</p>
|
||||
<div v-if="sources?.length" class="sources">
|
||||
<a-form v-if="draft?.person" layout="vertical" style="margin-top: 16px">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام">
|
||||
<a-input v-model:value="draft.person.firstName" @update:value="onPersonNameEdit" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نام خانوادگی">
|
||||
<a-input v-model:value="draft.person.lastName" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="موبایل">
|
||||
<a-input v-model:value="draft.person.mobile" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="تلفن">
|
||||
<a-input v-model:value="draft.person.phone" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="ایمیل">
|
||||
<a-input v-model:value="draft.person.email" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="وبسایت">
|
||||
<a-input v-model:value="draft.person.website" dir="ltr" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="معرف">
|
||||
<a-input v-model:value="draft.person.referrer" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="سمت">
|
||||
<a-input v-model:value="draft.person.position" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="یادداشت">
|
||||
<a-textarea v-model:value="draft.person.notes" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-form v-else-if="draft?.activity" layout="vertical" style="margin-top: 16px">
|
||||
<a-row :gutter="[16, 0]">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="عنوان">
|
||||
<a-input v-model:value="draft.activity.title" @update:value="onActivityTitleEdit" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="نوع">
|
||||
<a-select
|
||||
v-model:value="draft.activity.type"
|
||||
:options="activityTypeOptions"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :xs="24" :md="12">
|
||||
<a-form-item label="زمان">
|
||||
<JalaliDateInput :model-value="dueLocal" mode="datetime" @update:model-value="onDueLocal" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="مشتری">
|
||||
<a-input
|
||||
v-model:value="draft.activity.personName"
|
||||
@update:value="onActivityPersonEdit"
|
||||
@blur="rematchActivityPerson"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-alert
|
||||
v-if="draft.activity.personName && !draft.activity.personId"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="در فهرست مشتریان پروندهای با این نام پیدا نشد. فعالیت ذخیره میشود، ولی داخل پروندهٔ آن شخص دیده نمیشود."
|
||||
/>
|
||||
</a-col>
|
||||
<a-col :span="24">
|
||||
<a-form-item label="توضیح">
|
||||
<a-textarea v-model:value="draft.activity.description" :rows="2" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
|
||||
<a-alert
|
||||
v-if="draft?.missing.length"
|
||||
type="error"
|
||||
show-icon
|
||||
:message="`ناقص است: ${draft.missing.join('، ')}`"
|
||||
style="margin-top: 12px"
|
||||
/>
|
||||
|
||||
<template #footer>
|
||||
<a-button :disabled="confirming" @click="cancelDraft">انصراف</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="!draft?.canConfirm || confirming"
|
||||
:loading="confirming"
|
||||
@click="confirmDraft"
|
||||
>
|
||||
تأیید و انجام
|
||||
</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<a-modal v-model:open="answerOpen" title="پاسخ دستیار" destroy-on-close @cancel="closeAnswer">
|
||||
<a-typography-paragraph style="white-space: pre-wrap">
|
||||
{{ rewriteGregorianDates(answer || '') }}
|
||||
</a-typography-paragraph>
|
||||
<a-space v-if="sources?.length" wrap>
|
||||
<RouterLink
|
||||
v-for="source in sources"
|
||||
:key="`${source.type}-${source.id}`"
|
||||
:to="sourceTo(source)"
|
||||
@click="closeAnswer"
|
||||
>
|
||||
{{ source.label }}
|
||||
<a-tag color="processing">{{ source.label }}</a-tag>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</a-space>
|
||||
<template #footer>
|
||||
<UiButton variant="primary" @click="closeAnswer">بستن</UiButton>
|
||||
<a-button type="primary" @click="closeAnswer">بستن</a-button>
|
||||
</template>
|
||||
</UiModal>
|
||||
</article>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1rem 0.95rem;
|
||||
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 {
|
||||
margin-bottom: 0.55rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.modal-lead {
|
||||
margin: 0 0 0.85rem;
|
||||
color: #334155;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.answer-body {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.edit-hint {
|
||||
margin: -0.35rem 0 0.75rem;
|
||||
color: #64748b;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.modal-fields {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.modal-fields label {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modal-fields input,
|
||||
.modal-fields select,
|
||||
.modal-fields textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 0.65rem;
|
||||
}
|
||||
|
||||
.modal-fields textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.warn {
|
||||
display: inline-block;
|
||||
margin-inline-start: 0.35rem;
|
||||
color: #b45309;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.92rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.spark {
|
||||
color: #eab308;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ai-lead {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
background: #d1fae5;
|
||||
color: #115e59;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
}
|
||||
|
||||
.ai-shortcuts {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.8rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
border: 1px solid #eef2f7;
|
||||
color: #334155;
|
||||
font-size: 0.76rem;
|
||||
text-align: start;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.ai-chip:hover:not(:disabled) {
|
||||
background: #f0fdfa;
|
||||
border-color: #99f6e4;
|
||||
}
|
||||
|
||||
.ai-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.chip-ico {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #14b8a6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-input {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.ai-input input {
|
||||
flex: 1;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.58rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ai-input button {
|
||||
width: 40px;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.ai-input button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ai-answer {
|
||||
background: #f0fdfa;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.ai-answer p {
|
||||
margin: 0;
|
||||
color: #134e4a;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.sources {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.sources a {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
color: #0f766e;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.5rem;
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
margin: 0 0 0.7rem;
|
||||
color: #b91c1c;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
AlertOutlined,
|
||||
CheckCircleOutlined,
|
||||
ExclamationCircleOutlined,
|
||||
InfoCircleOutlined,
|
||||
LeftOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardAlertDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
alerts: HomeDashboardAlertDto[];
|
||||
}>();
|
||||
|
||||
type Tone = HomeDashboardAlertDto['tone'];
|
||||
|
||||
type AlertRow = {
|
||||
key: string;
|
||||
tone: Tone;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
link: string;
|
||||
};
|
||||
|
||||
const TONE_ORDER: Record<Tone, number> = {
|
||||
urgent: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
success: 3,
|
||||
};
|
||||
|
||||
const rows = computed<AlertRow[]>(() =>
|
||||
[...(props.alerts ?? [])]
|
||||
.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;
|
||||
return {
|
||||
key: a.key,
|
||||
tone: a.tone,
|
||||
title,
|
||||
subtitle,
|
||||
link: a.link || '/',
|
||||
};
|
||||
})
|
||||
.sort((a, b) => TONE_ORDER[a.tone] - TONE_ORDER[b.tone]),
|
||||
);
|
||||
|
||||
const urgentCount = computed(() => rows.value.filter((r) => r.tone === 'urgent').length);
|
||||
const warningCount = computed(() => rows.value.filter((r) => r.tone === 'warning').length);
|
||||
|
||||
function toneIcon(tone: Tone): Component {
|
||||
if (tone === 'urgent') return ExclamationCircleOutlined;
|
||||
if (tone === 'warning') return AlertOutlined;
|
||||
if (tone === 'success') return CheckCircleOutlined;
|
||||
return InfoCircleOutlined;
|
||||
}
|
||||
|
||||
function toneLabel(tone: Tone) {
|
||||
if (tone === 'urgent') return 'فوری';
|
||||
if (tone === 'warning') return 'هشدار';
|
||||
if (tone === 'success') return 'موفق';
|
||||
return 'اطلاع';
|
||||
}
|
||||
|
||||
function actionLabel(tone: Tone) {
|
||||
if (tone === 'urgent') return 'اقدام';
|
||||
if (tone === 'warning') return 'بررسی';
|
||||
return 'مشاهده';
|
||||
}
|
||||
|
||||
function rowClass(tone: Tone) {
|
||||
if (tone === 'urgent') return 'border-rose-200/80 bg-rose-50/50 hover:bg-rose-50';
|
||||
if (tone === 'warning') return 'border-amber-200/80 bg-amber-50/50 hover:bg-amber-50';
|
||||
if (tone === 'success') return 'border-emerald-200/70 bg-emerald-50/40 hover:bg-emerald-50/80';
|
||||
return 'border-slate-200/80 bg-slate-50/70 hover:bg-slate-100/80';
|
||||
}
|
||||
|
||||
function barClass(tone: Tone) {
|
||||
if (tone === 'urgent') return 'bg-rose-500';
|
||||
if (tone === 'warning') return 'bg-amber-500';
|
||||
if (tone === 'success') return 'bg-emerald-500';
|
||||
return 'bg-blue-500';
|
||||
}
|
||||
|
||||
function iconWrapClass(tone: Tone) {
|
||||
if (tone === 'urgent') return 'bg-rose-100 text-rose-600';
|
||||
if (tone === 'warning') return 'bg-amber-100 text-amber-600';
|
||||
if (tone === 'success') return 'bg-emerald-100 text-emerald-600';
|
||||
return 'bg-blue-100 text-blue-600';
|
||||
}
|
||||
|
||||
function badgeClass(tone: Tone) {
|
||||
if (tone === 'urgent') return 'bg-rose-100 text-rose-700';
|
||||
if (tone === 'warning') return 'bg-amber-100 text-amber-700';
|
||||
if (tone === 'success') return 'bg-emerald-100 text-emerald-700';
|
||||
return 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
|
||||
function actionClass(tone: Tone) {
|
||||
if (tone === 'urgent') return 'text-rose-600 group-hover:text-rose-700';
|
||||
if (tone === 'warning') return 'text-amber-600 group-hover:text-amber-700';
|
||||
if (tone === 'success') return 'text-emerald-600 group-hover:text-emerald-700';
|
||||
return 'text-blue-600 group-hover:text-blue-700';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
v-if="rows.length"
|
||||
class="bg-white rounded-2xl border border-slate-200/80 p-5 shadow-sm flex flex-col h-[330px]"
|
||||
>
|
||||
<header class="flex items-center justify-between gap-3 pb-3 mb-3 border-b border-slate-100 shrink-0">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<span class="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75" />
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-rose-500" />
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<h3 class="m-0 font-bold text-slate-800 text-base leading-6">نیازمند توجه</h3>
|
||||
<p class="m-0 mt-0.5 text-xs text-slate-400 truncate">
|
||||
موارد با اولویت بالاتر اول آمدهاند
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span
|
||||
v-if="urgentCount"
|
||||
class="text-[11px] font-bold bg-rose-50 text-rose-600 border border-rose-200/70 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{{ toFaDigits(urgentCount) }} فوری
|
||||
</span>
|
||||
<span
|
||||
v-if="warningCount"
|
||||
class="text-[11px] font-bold bg-amber-50 text-amber-700 border border-amber-200/70 px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{{ toFaDigits(warningCount) }} هشدار
|
||||
</span>
|
||||
<span class="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2 py-0.5 rounded-full">
|
||||
{{ toFaDigits(rows.length) }} مورد
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto space-y-2 pe-1 min-h-0">
|
||||
<RouterLink
|
||||
v-for="item in rows"
|
||||
:key="item.key"
|
||||
:to="item.link"
|
||||
class="group relative overflow-hidden rounded-xl border px-3 py-2.5 flex items-center gap-3 transition-all"
|
||||
:class="rowClass(item.tone)"
|
||||
>
|
||||
<span
|
||||
class="absolute inset-y-0 right-0 w-1 rounded-s-full"
|
||||
:class="barClass(item.tone)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-sm"
|
||||
:class="iconWrapClass(item.tone)"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<component :is="toneIcon(item.tone)" />
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4 class="m-0 font-bold text-[13px] text-slate-800 leading-5 truncate">
|
||||
{{ item.title }}
|
||||
</h4>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
class="text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0"
|
||||
:class="badgeClass(item.tone)"
|
||||
>
|
||||
{{ toneLabel(item.tone) }}
|
||||
</span>
|
||||
<span v-if="item.subtitle" class="text-[11px] text-slate-500 truncate">
|
||||
{{ item.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="shrink-0 flex items-center gap-1 text-[11px] font-semibold"
|
||||
:class="actionClass(item.tone)"
|
||||
>
|
||||
<span class="opacity-0 group-hover:opacity-100 transition-opacity hidden sm:inline">
|
||||
{{ actionLabel(item.tone) }}
|
||||
</span>
|
||||
<LeftOutlined class="text-slate-400 group-hover:-translate-x-0.5 transition-transform" />
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,10 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { GiftOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Flex as AFlex,
|
||||
List as AList,
|
||||
Modal as AModal,
|
||||
Space as ASpace,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { UiModal } from '@/components/ui';
|
||||
import SmsComposeModal from '@/components/SmsComposeModal.vue';
|
||||
|
||||
type BirthdayItem = {
|
||||
@@ -28,6 +36,10 @@ type BirthdayMonth = {
|
||||
const data = ref<BirthdayMonth | null>(null);
|
||||
const listOpen = ref(false);
|
||||
const smsTarget = ref<BirthdayItem | null>(null);
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
const AListItem = AList.Item;
|
||||
const AListItemMeta = AList.Item.Meta;
|
||||
|
||||
const groups = computed(() => {
|
||||
const map = new Map<number, BirthdayItem[]>();
|
||||
@@ -54,63 +66,78 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="data" class="birthdays">
|
||||
<div class="head">
|
||||
<span class="ico"><UiIcon name="gift" :size="18" /></span>
|
||||
<div>
|
||||
<strong>تولدهای {{ data.monthName }}</strong>
|
||||
<p>{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند</p>
|
||||
</div>
|
||||
</div>
|
||||
<section v-if="data" class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm">
|
||||
<a-flex align="center" wrap="wrap" gap="middle" justify="space-between">
|
||||
<a-flex align="center" gap="middle">
|
||||
<a-typography-text type="secondary"><GiftOutlined /></a-typography-text>
|
||||
<div>
|
||||
<a-typography-text strong>تولدهای {{ data.monthName }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">
|
||||
{{ toFaDigits(data.total) }} مشتری این ماه تولد دارند
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</a-flex>
|
||||
|
||||
<div class="chips">
|
||||
<RouterLink
|
||||
v-for="person in data.preview"
|
||||
:key="person.id"
|
||||
class="chip"
|
||||
:title="person.fullName"
|
||||
:to="{ name: 'person-detail', params: { id: person.id } }"
|
||||
>
|
||||
<b>{{ toFaDigits(person.day) }}</b>
|
||||
<span>{{ person.fullName }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button type="button" class="more" @click="listOpen = true">
|
||||
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
|
||||
</button>
|
||||
|
||||
<UiModal :open="listOpen" :title="`تولدهای ${data.monthName}`" @close="listOpen = false">
|
||||
<div class="days">
|
||||
<section
|
||||
v-for="[day, people] in groups"
|
||||
:key="day"
|
||||
class="day"
|
||||
:class="{ past: day < data.today, today: day === data.today }"
|
||||
<a-space wrap>
|
||||
<RouterLink
|
||||
v-for="person in data.preview"
|
||||
:key="person.id"
|
||||
:to="{ name: 'person-detail', params: { id: person.id } }"
|
||||
>
|
||||
<h3>
|
||||
<b>{{ toFaDigits(day) }}</b>
|
||||
<span>{{ data.monthName }}</span>
|
||||
<em v-if="day < data.today">گذشته</em>
|
||||
<em v-else-if="day === data.today">امروز</em>
|
||||
</h3>
|
||||
<ul>
|
||||
<li v-for="person in people" :key="person.id">
|
||||
<RouterLink :to="{ name: 'person-detail', params: { id: person.id } }" @click="listOpen = false">
|
||||
{{ person.fullName }}
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="sms"
|
||||
:disabled="!person.mobile"
|
||||
@click="openSms(person)"
|
||||
>
|
||||
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</UiModal>
|
||||
<a-tag>
|
||||
{{ toFaDigits(person.day) }} — {{ person.fullName }}
|
||||
</a-tag>
|
||||
</RouterLink>
|
||||
<a-button type="link" @click="listOpen = true">
|
||||
{{ data.total > data.preview.length ? 'همه' : 'فهرست' }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-flex>
|
||||
|
||||
<a-modal
|
||||
v-model:open="listOpen"
|
||||
:title="`تولدهای ${data.monthName}`"
|
||||
:footer="null"
|
||||
destroy-on-close
|
||||
>
|
||||
<a-list item-layout="vertical" :data-source="groups">
|
||||
<template #renderItem="{ item }">
|
||||
<a-list-item>
|
||||
<template #actions>
|
||||
<a-tag v-if="item[0] < data!.today">گذشته</a-tag>
|
||||
<a-tag v-else-if="item[0] === data!.today" color="processing">امروز</a-tag>
|
||||
</template>
|
||||
<a-list-item-meta>
|
||||
<template #title>
|
||||
{{ toFaDigits(item[0]) }} {{ data!.monthName }}
|
||||
</template>
|
||||
</a-list-item-meta>
|
||||
<a-list :data-source="item[1]">
|
||||
<template #renderItem="{ item: person }">
|
||||
<a-list-item>
|
||||
<RouterLink
|
||||
:to="{ name: 'person-detail', params: { id: person.id } }"
|
||||
@click="listOpen = false"
|
||||
>
|
||||
{{ person.fullName }}
|
||||
</RouterLink>
|
||||
<template #actions>
|
||||
<a-button
|
||||
type="link"
|
||||
:disabled="!person.mobile"
|
||||
@click="openSms(person)"
|
||||
>
|
||||
{{ person.mobile ? 'پیامک تبریک' : 'بدون شماره' }}
|
||||
</a-button>
|
||||
</template>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-list-item>
|
||||
</template>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
|
||||
<SmsComposeModal
|
||||
nest
|
||||
@@ -126,289 +153,3 @@ onMounted(async () => {
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.birthdays {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.35), transparent 42%),
|
||||
linear-gradient(100deg, var(--accent-subtle) 0%, #fff 58%, #fff7f4 100%);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 22%, #fff);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 6px 18px rgba(15, 118, 110, 0.05);
|
||||
padding: 0.55rem 0.75rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ico {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
color: #e11d48;
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, #fff);
|
||||
}
|
||||
|
||||
.head strong {
|
||||
display: block;
|
||||
color: var(--accent-deep);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.head p {
|
||||
margin: 0.05rem 0 0;
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
gap: 0.35rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
max-width: 11.5rem;
|
||||
min-width: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 24%, #fff);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
color: #134e4a;
|
||||
padding: 0.22rem 0.55rem 0.22rem 0.28rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.chip b {
|
||||
flex-shrink: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-deep);
|
||||
background: var(--accent-soft);
|
||||
border-radius: 999px;
|
||||
padding: 0.12rem 0.38rem;
|
||||
}
|
||||
|
||||
.chip span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
padding: 0.32rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.more:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.days {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
max-height: 62vh;
|
||||
overflow: auto;
|
||||
padding-inline-end: 0.15rem;
|
||||
}
|
||||
|
||||
.day {
|
||||
background: #fff;
|
||||
border: 1px solid #e7eef2;
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.day h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.8rem;
|
||||
background: var(--accent-subtle);
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--accent) 16%, #fff);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.day h3 b {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 1.7rem;
|
||||
height: 1.7rem;
|
||||
padding: 0 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.day em {
|
||||
margin-inline-start: auto;
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
background: #fff;
|
||||
border-radius: 999px;
|
||||
padding: 0.1rem 0.45rem;
|
||||
}
|
||||
|
||||
.day.today h3 {
|
||||
background: color-mix(in srgb, var(--accent-soft) 70%, #fff);
|
||||
}
|
||||
|
||||
.day.today em {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.day.past {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.day ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.day li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.62rem 0.85rem;
|
||||
border-top: 1px solid #eef2f6;
|
||||
}
|
||||
|
||||
.day li:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.day a {
|
||||
flex: 1 1 auto;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.day a:hover {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
|
||||
.sms {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 35%, #fff);
|
||||
background: #fff;
|
||||
color: var(--accent-deep);
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.65rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sms:hover:not(:disabled) {
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.sms:disabled {
|
||||
border-color: #e2e8f0;
|
||||
color: #94a3b8;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.hint,
|
||||
.err,
|
||||
.ok {
|
||||
margin: 0.55rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.err {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.birthdays {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.45rem 0.6rem;
|
||||
}
|
||||
|
||||
.head {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.head p {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.more {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.chips {
|
||||
grid-column: 1 / -1;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.chips::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.day li {
|
||||
gap: 0.45rem;
|
||||
padding-inline: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSeriesPointDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
series: HomeDashboardSeriesPointDto[];
|
||||
}>();
|
||||
|
||||
const points = computed(() => props.series);
|
||||
const hasData = computed(() => points.value.some((p) => p.value > 0));
|
||||
const total = computed(() => points.value.reduce((a, p) => a + p.value, 0));
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (params) => {
|
||||
const row = Array.isArray(params) ? params[0] : params;
|
||||
const point = points.value[row.dataIndex ?? 0];
|
||||
if (!point) return '';
|
||||
return `${point.monthLabel}<br/><strong>${toFaDigits(point.value)}</strong> مشتری جدید`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: points.value.map((p) => p.monthLabel),
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
axisLabel: {
|
||||
color: '#94a3b8',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
fontSize: 11,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
min: 0,
|
||||
minInterval: 1,
|
||||
...(hasData.value ? {} : { max: 1 }),
|
||||
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: points.value.map((p) => p.value),
|
||||
barMaxWidth: 32,
|
||||
itemStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: '#34d399' },
|
||||
{ offset: 1, color: '#059669' },
|
||||
],
|
||||
},
|
||||
borderRadius: [8, 8, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'top',
|
||||
color: '#64748b',
|
||||
fontFamily: 'IRANSans, Tahoma, sans-serif',
|
||||
fontSize: 11,
|
||||
formatter: (p) => toFaDigits(Number(p.value ?? 0)),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'line',
|
||||
data: points.value.map((p) => p.value),
|
||||
smooth: 0.35,
|
||||
symbol: 'none',
|
||||
lineStyle: { color: '#0f766e', width: 2, opacity: 0.55 },
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">روند رشد مشتریان</h3>
|
||||
<span v-if="hasData" class="text-xs font-bold bg-emerald-50 text-emerald-700 px-2.5 py-1 rounded-lg">
|
||||
{{ toFaDigits(total) }} در ۱۲ ماه
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-56">
|
||||
<UiChart :option="option" height="224px" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSalesSummaryDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
summary: HomeDashboardSalesSummaryDto;
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const slices = computed(() => [
|
||||
{ name: 'باز', value: props.summary.openCount, color: '#3b82f6' },
|
||||
{ name: 'برنده', value: props.summary.wonCount, color: '#10b981' },
|
||||
{ name: 'باخته', value: props.summary.lostCount, color: '#f43f5e' },
|
||||
]);
|
||||
|
||||
const total = computed(() => slices.value.reduce((a, s) => a + s.value, 0));
|
||||
const hasData = computed(() => total.value > 0);
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
color: slices.value.map((s) => s.color),
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (p) => {
|
||||
const item = Array.isArray(p) ? p[0] : p;
|
||||
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪`;
|
||||
},
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['58%', '82%'],
|
||||
center: ['50%', '50%'],
|
||||
silent: !hasData.value,
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data: hasData.value
|
||||
? slices.value.map((s) => ({ name: s.name, value: s.value }))
|
||||
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="!m-0 !leading-6 font-bold text-slate-800 text-base">وضعیت معاملات</h3>
|
||||
<span class="text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-200/60 px-2.5 py-1 rounded-lg">
|
||||
نرخ برد {{ toFaDigits(summary.winRate) }}٪
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div class="w-full sm:w-[48%] sm:max-w-[240px] shrink-0">
|
||||
<div class="relative w-full aspect-square">
|
||||
<UiChart :option="option" height="100%" />
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-2xl font-black text-slate-800 leading-none">{{ toFaDigits(total) }}</span>
|
||||
<span class="text-xs text-slate-400 mt-1">معامله کل</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:flex-1 min-w-0 space-y-2.5">
|
||||
<div class="p-3 rounded-xl bg-slate-50/80 border border-slate-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-slate-700">باز</div>
|
||||
<div class="text-[11px] text-slate-400">در جریان</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-slate-800">{{ toFaDigits(summary.openCount) }}</div>
|
||||
<div class="text-xs text-slate-500">{{ formatAmount(summary.openAmount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-emerald-50/50 border border-emerald-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-emerald-900">برنده (۱۲ ماه)</div>
|
||||
<div class="text-[11px] text-emerald-600/80">تکمیل موفق</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-emerald-700">{{ toFaDigits(summary.wonCount) }}</div>
|
||||
<div class="text-xs text-emerald-600">{{ formatAmount(summary.wonAmount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 rounded-xl bg-rose-50/40 border border-rose-100 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-xs font-bold text-rose-900">باخته (۱۲ ماه)</div>
|
||||
<div class="text-[11px] text-rose-500">لغو یا عدم توافق</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-black text-rose-600">{{ toFaDigits(summary.lostCount) }}</div>
|
||||
<div class="text-xs text-rose-500">{{ formatAmount(summary.lostAmount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty, Progress as AProgress } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardFunnelStageDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
funnel: HomeDashboardFunnelStageDto[];
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.funnel);
|
||||
const hasData = computed(() => rows.value.some((r) => r.count > 0));
|
||||
const openTotal = computed(() => rows.value.reduce((a, r) => a + r.count, 0));
|
||||
|
||||
function share(count: number) {
|
||||
if (!openTotal.value) return 0;
|
||||
return Math.round((count / openTotal.value) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">قیف فروش باز</h3>
|
||||
<span v-if="hasData" class="text-xs font-semibold text-slate-500">
|
||||
{{ toFaDigits(openTotal) }} معامله باز
|
||||
</span>
|
||||
</div>
|
||||
<a-empty v-if="!hasData" description="معاملهٔ بازی در مسیر فروش نیست" />
|
||||
<div v-else class="space-y-4">
|
||||
<div v-for="row in rows" :key="row.stageId ?? row.stageName">
|
||||
<div class="flex items-center justify-between gap-2 mb-1">
|
||||
<span class="text-xs font-bold text-slate-700">{{ row.stageName }}</span>
|
||||
<span class="text-xs font-semibold text-slate-500">
|
||||
{{ toFaDigits(row.count) }} · {{ toFaDigits(share(row.count)) }}٪
|
||||
</span>
|
||||
</div>
|
||||
<a-progress :percent="share(row.count)" :show-info="false" stroke-color="#10b981" />
|
||||
<div class="text-[11px] text-slate-400">{{ formatAmount(row.amount) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
|
||||
import { brand, type HomeDashboardAlertDto } from '@mento/shared';
|
||||
import { SearchOutlined, SendOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Button as AButton,
|
||||
Input as AInput,
|
||||
Typography,
|
||||
} from 'ant-design-vue';
|
||||
import { brand } from '@mento/shared';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const props = defineProps<{
|
||||
greeting: string;
|
||||
firstName: string;
|
||||
userInitial: string;
|
||||
alerts: HomeDashboardAlertDto[];
|
||||
askQuery: string;
|
||||
asking?: boolean;
|
||||
askAnswer?: string;
|
||||
@@ -24,438 +26,218 @@ const emit = defineEmits<{
|
||||
}>();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const { Title: ATypographyTitle, Text: ATypographyText, Paragraph: ATypographyParagraph } =
|
||||
Typography;
|
||||
|
||||
type HeroChip = {
|
||||
key: string;
|
||||
tone: HomeDashboardAlertDto['tone'];
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
link: string;
|
||||
};
|
||||
|
||||
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;
|
||||
return {
|
||||
key: a.key,
|
||||
tone: a.tone,
|
||||
title,
|
||||
subtitle,
|
||||
link: a.link || '/',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
function alertIcon(tone: string) {
|
||||
if (tone === 'urgent') return 'fire' as const;
|
||||
if (tone === 'warning') return 'warning' as const;
|
||||
if (tone === 'success') return 'trend' as const;
|
||||
return 'sparkle' as const;
|
||||
}
|
||||
/** فقط عکس واقعی کاربر؛ پیشفرض عمومی را نشان نده */
|
||||
const hasCustomPhoto = computed(() => Boolean(auth.user?.hasAvatar));
|
||||
|
||||
const shortcutLabel = computed(() =>
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform)
|
||||
? '⌘K'
|
||||
: 'Ctrl+K',
|
||||
);
|
||||
|
||||
const statusMessage = computed(() => {
|
||||
if (props.asking) return props.statusText || 'در حال پاسخ…';
|
||||
if (props.askError) return props.askError;
|
||||
if (props.askAnswer) return props.askAnswer;
|
||||
return '';
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="hero">
|
||||
<div class="hero-surface" aria-hidden="true">
|
||||
<div class="hero-glow" />
|
||||
<div class="hero-mist" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="home-hero relative overflow-hidden rounded-2xl border border-emerald-300/50 shadow-sm">
|
||||
<!-- base gradient -->
|
||||
<div class="home-hero__gradient pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- geometric pattern -->
|
||||
<div class="home-hero__pattern pointer-events-none absolute inset-0" aria-hidden="true" />
|
||||
<!-- soft orbs -->
|
||||
<div
|
||||
class="pointer-events-none absolute -left-20 -top-24 h-52 w-52 rounded-full bg-emerald-400/20 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
class="pointer-events-none absolute -right-16 -bottom-20 h-48 w-48 rounded-full bg-teal-500/15 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="hero-body">
|
||||
<div class="hero-greet">
|
||||
<span class="hero-avatar">
|
||||
<img :src="auth.avatarSrc" :alt="firstName" width="72" height="72" />
|
||||
<i class="online" />
|
||||
</span>
|
||||
<div class="greet-text">
|
||||
<h1>{{ greeting }} {{ firstName }} 👋</h1>
|
||||
<p v-if="chips.length">{{ brand.nameFa }} اینا رو برات پیدا کرده</p>
|
||||
<p v-else>از {{ brand.nameFa }} بپرس یا کار امروز را شروع کن</p>
|
||||
<div
|
||||
class="relative flex flex-col lg:flex-row items-stretch lg:items-center justify-between gap-4 p-4 sm:p-5"
|
||||
>
|
||||
<div class="flex items-center gap-3.5 min-w-0">
|
||||
<div class="home-hero__identity relative shrink-0" :aria-label="firstName">
|
||||
<div class="home-hero__frame">
|
||||
<img
|
||||
v-if="hasCustomPhoto"
|
||||
:src="auth.avatarSrc"
|
||||
:alt="firstName"
|
||||
class="home-hero__photo"
|
||||
/>
|
||||
<span v-else class="home-hero__monogram" aria-hidden="true">
|
||||
{{ userInitial }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="home-hero__online" title="آنلاین" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="m-0 text-[11px] font-semibold tracking-wide text-emerald-700/80">
|
||||
{{ brand.nameFa }}
|
||||
</p>
|
||||
<a-typography-title
|
||||
:level="4"
|
||||
class="!m-0 !mt-0.5 !text-slate-800 !text-lg sm:!text-xl !font-extrabold !leading-7 truncate"
|
||||
>
|
||||
{{ greeting }}، {{ firstName }}
|
||||
</a-typography-title>
|
||||
<a-typography-text class="!text-slate-500 !text-sm !mt-0.5 block">
|
||||
از دادهها بپرسید یا کار امروز را از همینجا شروع کنید
|
||||
</a-typography-text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="chips.length" class="hero-chips">
|
||||
<RouterLink
|
||||
v-for="chip in chips"
|
||||
:key="chip.key"
|
||||
class="chip"
|
||||
:class="`chip-${chip.tone}`"
|
||||
:to="chip.link"
|
||||
<div
|
||||
class="flex items-center gap-2 w-full lg:max-w-xl bg-white border border-white/80 rounded-2xl px-3 py-2 shadow-md shadow-emerald-900/5 focus-within:border-emerald-300 focus-within:ring-2 focus-within:ring-emerald-200/70 transition"
|
||||
>
|
||||
<span class="chip-ico"><UiIcon :name="alertIcon(chip.tone)" :size="13" /></span>
|
||||
<span class="chip-text">
|
||||
<strong>{{ chip.title }}</strong>
|
||||
<small v-if="chip.subtitle">{{ chip.subtitle }}</small>
|
||||
<span
|
||||
class="w-8 h-8 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SearchOutlined />
|
||||
</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="hero-ask-wrap">
|
||||
<form class="hero-ask" @submit.prevent="emit('ask')">
|
||||
<UiIcon name="sparkle" :size="18" class="ask-ico" />
|
||||
<input
|
||||
<a-input
|
||||
id="home-hero-ask"
|
||||
:value="askQuery"
|
||||
type="search"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:disabled="asking"
|
||||
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
|
||||
:bordered="false"
|
||||
class="flex-1 !bg-transparent"
|
||||
@update:value="emit('update:askQuery', $event)"
|
||||
@press-enter="emit('ask')"
|
||||
/>
|
||||
<kbd>{{ shortcutLabel }}</kbd>
|
||||
<button type="submit" class="ask-send" aria-label="ارسال" :disabled="asking">
|
||||
<UiIcon name="send" :size="15" />
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="asking" class="hero-ask-status">{{ statusText || 'در حال پاسخ…' }}</p>
|
||||
<p v-else-if="askError" class="hero-ask-status err">{{ askError }}</p>
|
||||
<p v-else-if="askAnswer" class="hero-ask-status">{{ askAnswer }}</p>
|
||||
<kbd
|
||||
class="hidden sm:inline-flex items-center text-[11px] font-sans text-slate-400 bg-slate-50 px-2 py-1 rounded-lg border border-slate-200 shrink-0"
|
||||
>
|
||||
{{ shortcutLabel }}
|
||||
</kbd>
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="round"
|
||||
:disabled="asking"
|
||||
:loading="asking"
|
||||
aria-label="ارسال"
|
||||
@click="emit('ask')"
|
||||
>
|
||||
<template #icon><SendOutlined /></template>
|
||||
<span class="hidden sm:inline">بپرس</span>
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MentoAiAvatar class="hero-bot" :size="156" />
|
||||
</header>
|
||||
<div
|
||||
v-if="statusMessage"
|
||||
class="rounded-xl border px-3.5 py-2.5"
|
||||
:class="
|
||||
askError
|
||||
? 'border-rose-200 bg-rose-50/70'
|
||||
: asking
|
||||
? 'border-emerald-200/70 bg-emerald-50/50'
|
||||
: 'border-slate-200 bg-slate-50/80'
|
||||
"
|
||||
>
|
||||
<a-typography-paragraph
|
||||
:type="askError ? 'danger' : 'secondary'"
|
||||
:ellipsis="{ rows: 3 }"
|
||||
class="!mb-0 !text-sm"
|
||||
>
|
||||
{{ statusMessage }}
|
||||
</a-typography-paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
position: relative;
|
||||
display: grid;
|
||||
/* RTL: body سمت راست، ربات سمت چپ — فضای اضافه برای پاپآپ */
|
||||
grid-template-columns: minmax(0, 1fr) 210px;
|
||||
gap: 0.7rem 1rem;
|
||||
align-items: start;
|
||||
min-height: 0;
|
||||
padding: 0.8rem 1.5rem 0.9rem;
|
||||
border-radius: var(--radius);
|
||||
overflow: visible;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.hero-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
background-color: #134e4a;
|
||||
background-image:
|
||||
radial-gradient(ellipse 55% 90% at 8% 55%, rgba(103, 232, 249, 0.55) 0%, rgba(45, 212, 191, 0.22) 35%, transparent 68%),
|
||||
radial-gradient(ellipse 50% 80% at 92% 40%, rgba(153, 246, 228, 0.28) 0%, transparent 55%),
|
||||
radial-gradient(ellipse 70% 60% at 45% 30%, rgba(45, 212, 191, 0.18) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 50% at 60% 85%, rgba(15, 23, 42, 0.28) 0%, transparent 55%),
|
||||
.home-hero__gradient {
|
||||
background:
|
||||
linear-gradient(
|
||||
105deg,
|
||||
#0b3d3a 0%,
|
||||
#115e59 22%,
|
||||
#0f766e 48%,
|
||||
#134e4a 72%,
|
||||
#0f3f3c 100%
|
||||
115deg,
|
||||
#ecfdf5 0%,
|
||||
#d1fae5 28%,
|
||||
#ccfbf1 55%,
|
||||
#f0fdfa 78%,
|
||||
#ffffff 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 16px 36px rgba(15, 118, 110, 0.22),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.hero-glow {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border-radius: 999px;
|
||||
inset-inline-end: -70px;
|
||||
top: 40%;
|
||||
transform: translateY(-50%);
|
||||
background:
|
||||
radial-gradient(circle, rgba(165, 243, 252, 0.65) 0%, rgba(34, 211, 238, 0.28) 32%, transparent 68%);
|
||||
filter: blur(8px);
|
||||
pointer-events: none;
|
||||
.home-hero__pattern {
|
||||
opacity: 0.45;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(16, 185, 129, 0.28) 1px, transparent 0),
|
||||
linear-gradient(
|
||||
135deg,
|
||||
rgba(20, 184, 166, 0.08) 25%,
|
||||
transparent 25%,
|
||||
transparent 50%,
|
||||
rgba(20, 184, 166, 0.08) 50%,
|
||||
rgba(20, 184, 166, 0.08) 75%,
|
||||
transparent 75%,
|
||||
transparent
|
||||
);
|
||||
background-size:
|
||||
18px 18px,
|
||||
28px 28px;
|
||||
mask-image: linear-gradient(90deg, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.hero-mist {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(ellipse 40% 35% at 25% 20%, rgba(255, 255, 255, 0.14) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 35% 40% at 70% 75%, rgba(204, 251, 241, 0.12) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 50% 45% at 50% 50%, rgba(15, 118, 110, 0.35) 0%, transparent 75%);
|
||||
pointer-events: none;
|
||||
.home-hero__identity {
|
||||
width: 3.25rem;
|
||||
height: 3.25rem;
|
||||
}
|
||||
|
||||
.hero-body,
|
||||
.hero-bot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.hero-greet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.greet-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.hero-avatar img {
|
||||
.home-hero__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
padding: 2px;
|
||||
background: linear-gradient(145deg, #34d399 0%, #0d9488 48%, #059669 100%);
|
||||
box-shadow:
|
||||
0 0 0 3px rgba(255, 255, 255, 0.85),
|
||||
0 8px 18px -10px rgba(4, 120, 87, 0.55);
|
||||
}
|
||||
|
||||
.home-hero__photo,
|
||||
.home-hero__monogram {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: calc(1rem - 2px);
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 999px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.65);
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.online {
|
||||
.home-hero__monogram {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 800;
|
||||
color: #ecfdf5;
|
||||
background:
|
||||
radial-gradient(circle at 30% 25%, rgba(255, 255, 255, 0.22), transparent 45%),
|
||||
linear-gradient(160deg, #10b981 0%, #0f766e 100%);
|
||||
letter-spacing: -0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.home-hero__online {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
inset-inline-start: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
left: -1px;
|
||||
bottom: -1px;
|
||||
width: 0.85rem;
|
||||
height: 0.85rem;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
background: #10b981;
|
||||
border: 2px solid #fff;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.greet-text h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.35rem, 2vw, 1.75rem);
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.greet-text p {
|
||||
margin: 0.25rem 0 0;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-chips {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.4rem;
|
||||
max-width: min(580px, 100%);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.chip-text {
|
||||
display: grid;
|
||||
gap: 0.04rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chip-text strong {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-text small {
|
||||
font-size: 0.6rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
line-height: 1.25;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chip-ico {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: var(--radius);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chip-urgent .chip-ico {
|
||||
background: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.chip-warning .chip-ico {
|
||||
background: #fef3c7;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.chip-success .chip-ico,
|
||||
.chip-info .chip-ico {
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.hero-ask-wrap {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
max-width: min(520px, 100%);
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-ask {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
background: #fff;
|
||||
color: #0f172a;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 0.45rem 0.45rem 0.9rem;
|
||||
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.ask-ico {
|
||||
color: #14b8a6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-ask input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: 0.9rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-ask input::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.hero-ask kbd {
|
||||
font-size: 0.68rem;
|
||||
color: #94a3b8;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.14rem 0.38rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ask-send {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: #14b8a6;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ask-send:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.hero-ask-status {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.85rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.7;
|
||||
max-height: 7.5rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.hero-ask-status.err {
|
||||
background: rgba(185, 28, 28, 0.28);
|
||||
}
|
||||
|
||||
.hero-bot {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
/* در RTL ستون ربات سمت چپ است؛ end = لبه بیرونی چپ */
|
||||
justify-self: end;
|
||||
align-self: center;
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 0.35rem;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
}
|
||||
|
||||
.hero-chips {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: min(420px, 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-height: auto;
|
||||
padding-inline: 0.85rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-bot {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hero-ask kbd {
|
||||
display: none;
|
||||
}
|
||||
box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,125 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckCircleOutlined,
|
||||
FundOutlined,
|
||||
RiseOutlined,
|
||||
TeamOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import type { Component } from 'vue';
|
||||
import type { HomeDashboardKpiDto } from '@mento/shared';
|
||||
import { currencySuffix } from '@/lib/money';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
kpis: HomeDashboardKpiDto[];
|
||||
currency: string;
|
||||
formatValue: (key: string, value: number) => string;
|
||||
formatChange: (value: number | null) => string;
|
||||
}>();
|
||||
|
||||
const icons: Record<string, 'calendar' | 'users' | 'briefcase' | 'check'> = {
|
||||
sales: 'calendar',
|
||||
customers: 'users',
|
||||
'open-deals': 'briefcase',
|
||||
followups: 'check',
|
||||
function salesAmount(value: number) {
|
||||
return new Intl.NumberFormat('fa-IR').format(value);
|
||||
}
|
||||
|
||||
function salesUnit() {
|
||||
return currencySuffix(props.currency);
|
||||
}
|
||||
|
||||
const icons: Record<string, Component> = {
|
||||
sales: FundOutlined,
|
||||
customers: TeamOutlined,
|
||||
'open-deals': CalendarOutlined,
|
||||
followups: CheckCircleOutlined,
|
||||
'win-rate': RiseOutlined,
|
||||
overdue: WarningOutlined,
|
||||
};
|
||||
|
||||
const iconTones: Record<string, string> = {
|
||||
sales: '#14b8a6',
|
||||
customers: '#38bdf8',
|
||||
'open-deals': '#fb923c',
|
||||
followups: '#a78bfa',
|
||||
};
|
||||
function pillClass(kpi: HomeDashboardKpiDto) {
|
||||
if (kpi.key === 'overdue') return 'bg-rose-100/70 text-rose-600';
|
||||
if (kpi.changePercent == null) return 'text-slate-400';
|
||||
return kpi.changePercent < 0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kpi-row">
|
||||
<article v-for="kpi in kpis" :key="kpi.key" class="kpi-card">
|
||||
<div class="kpi-icon" :style="{ color: iconTones[kpi.key] ?? '#14b8a6' }">
|
||||
<UiIcon :name="icons[kpi.key] ?? 'chart'" :size="20" />
|
||||
</div>
|
||||
<div class="kpi-text">
|
||||
<p class="kpi-label">{{ kpi.label }}</p>
|
||||
<strong class="kpi-value">{{ formatValue(kpi.key, kpi.value) }}</strong>
|
||||
<section class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<div
|
||||
v-for="kpi in kpis"
|
||||
:key="kpi.key"
|
||||
class="rounded-2xl p-4 border shadow-sm flex flex-col justify-between transition duration-200 hover:-translate-y-0.5"
|
||||
:class="
|
||||
kpi.key === 'overdue'
|
||||
? 'border-rose-200/70 bg-gradient-to-b from-white to-rose-50/20'
|
||||
: 'bg-white border-slate-200/80'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<span class="text-xs font-semibold" :class="kpi.key === 'overdue' ? 'text-rose-700' : 'text-slate-500'">
|
||||
{{ kpi.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="kpi.changePercent != null"
|
||||
class="kpi-change"
|
||||
:class="{ down: kpi.changePercent < 0 }"
|
||||
class="w-8 h-8 rounded-xl flex items-center justify-center"
|
||||
:class="kpi.key === 'overdue' ? 'bg-rose-100/80 text-rose-600' : 'bg-emerald-50 text-emerald-600'"
|
||||
>
|
||||
<span class="arrow">{{ kpi.changePercent >= 0 ? '↑' : '↓' }}</span>
|
||||
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
|
||||
<component :is="icons[kpi.key] ?? FundOutlined" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
class="!font-bold !text-[24px] !leading-8 tracking-tight"
|
||||
:class="kpi.key === 'overdue' ? 'text-rose-600' : 'text-slate-800'"
|
||||
>
|
||||
<template v-if="kpi.key === 'sales'">
|
||||
<span>{{ salesAmount(kpi.value) }}</span><span class="text-[16px] leading-6 font-semibold ms-1.5">{{ salesUnit() }}</span>
|
||||
</template>
|
||||
<template v-else>{{ formatValue(kpi.key, kpi.value) }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-if="kpi.changePercent != null"
|
||||
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md"
|
||||
:class="pillClass(kpi)"
|
||||
>
|
||||
{{ formatChange(kpi.changePercent) }} {{ kpi.changeLabel }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="kpi.key === 'overdue'"
|
||||
class="mt-2.5 inline-flex text-[11px] font-semibold px-2 py-0.5 rounded-md bg-rose-100/70 text-rose-600"
|
||||
>
|
||||
نیازمند اقدام فوری
|
||||
</div>
|
||||
<div v-else class="mt-2.5 text-[11px] font-medium text-slate-400">
|
||||
{{ kpi.changeLabel || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kpi-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: flex;
|
||||
gap: 0.85rem;
|
||||
align-items: flex-start;
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1.1rem 1.15rem;
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
|
||||
border: 1px solid #eef2f7;
|
||||
}
|
||||
|
||||
.kpi-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.kpi-label {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.kpi-value {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 1.18rem;
|
||||
color: #0f172a;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.kpi-change {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-top: 0.45rem;
|
||||
padding: 0.18rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #dcfce7;
|
||||
color: #16a34a;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-change.down {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.kpi-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSliceDto } from '@mento/shared';
|
||||
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
reasons: HomeDashboardSliceDto[];
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.reasons);
|
||||
const hasData = computed(() => rows.value.some((r) => r.count > 0));
|
||||
const total = computed(() => rows.value.reduce((sum, row) => sum + row.count, 0));
|
||||
|
||||
function share(count: number) {
|
||||
if (!total.value) return 0;
|
||||
return Math.round((count / total.value) * 100);
|
||||
}
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
color: CHART_PALETTE,
|
||||
tooltip: {
|
||||
trigger: 'item',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (p) => {
|
||||
const item = Array.isArray(p) ? p[0] : p;
|
||||
const row = rows.value.find((r) => r.label === item.name);
|
||||
const amount =
|
||||
row?.amount != null ? `<br/>${props.formatAmount(row.amount)}` : '';
|
||||
return `${item.name}<br/>${toFaDigits(Number(item.value ?? 0))} — ${toFaDigits(item.percent ?? 0)}٪${amount}`;
|
||||
},
|
||||
},
|
||||
legend: { show: false },
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['58%', '78%'],
|
||||
center: ['50%', '50%'],
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
data: rows.value.map((r) => ({ name: r.label, value: r.count })),
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-bold text-slate-800 text-base">دلایل باخت</h3>
|
||||
<span v-if="hasData" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
|
||||
</div>
|
||||
<a-empty v-if="!hasData" description="معاملهٔ باختهای ثبت نشده" />
|
||||
<template v-else>
|
||||
<div class="relative w-36 h-36 mx-auto">
|
||||
<UiChart :option="option" height="144px" />
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
|
||||
<span class="text-[11px] text-slate-400">معامله باخته</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2 mt-3">
|
||||
<div
|
||||
v-for="(item, index) in rows"
|
||||
:key="item.label"
|
||||
class="rounded-xl border border-slate-200/70 bg-slate-50 px-2.5 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
|
||||
></span>
|
||||
<span class="text-[11px] text-slate-600 truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
<div class="text-sm font-black text-slate-800 mt-1">{{ toFaDigits(share(item.count)) }}٪</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,8 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import {
|
||||
FundOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { PipelineBoardDto } from '@mento/shared';
|
||||
import {
|
||||
PIPELINE_ACCENT,
|
||||
type PipelineAccent,
|
||||
} from '@/lib/pipeline-board';
|
||||
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -10,41 +19,100 @@ const props = defineProps<{
|
||||
formatMoney: (amount: number, currency: string) => string;
|
||||
}>();
|
||||
|
||||
type Card = { id: string; name: string; amount: string; when: string };
|
||||
type Col = { id: string; name: string; count: number; cards: Card[] };
|
||||
const ACCENT_CYCLE: PipelineAccent[] = ['green', 'blue', 'orange'];
|
||||
|
||||
function relativeWhen(iso: string | null | undefined) {
|
||||
if (!iso) return '';
|
||||
type Card = {
|
||||
id: string;
|
||||
name: string;
|
||||
initial: string;
|
||||
amount: string;
|
||||
when: string;
|
||||
isToday: boolean;
|
||||
};
|
||||
|
||||
type Col = {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
totalLabel: string;
|
||||
accent: PipelineAccent;
|
||||
cards: Card[];
|
||||
};
|
||||
|
||||
function relativeWhen(iso: string | null | undefined): { label: string; isToday: boolean } {
|
||||
if (!iso) return { label: '', isToday: false };
|
||||
const diff = Date.now() - new Date(iso).getTime();
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days <= 0) return 'امروز';
|
||||
if (days === 1) return 'دیروز';
|
||||
return `${toFaDigits(days)} روز پیش`;
|
||||
if (days <= 0) return { label: 'امروز', isToday: true };
|
||||
if (days === 1) return { label: 'دیروز', isToday: false };
|
||||
return { label: `${toFaDigits(days)} روز پیش`, isToday: false };
|
||||
}
|
||||
|
||||
function initialOf(name: string | undefined | null) {
|
||||
const t = (name ?? '').trim();
|
||||
return t ? t.slice(0, 1) : '؟';
|
||||
}
|
||||
|
||||
const cols = computed<Col[]>(() => {
|
||||
const stages = props.board?.stages ?? [];
|
||||
const hasDeals = stages.some((s) => s.deals.length);
|
||||
if (!hasDeals) return [];
|
||||
return stages.slice(0, 3).map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
count: stage.deals.length,
|
||||
cards: stage.deals.slice(0, 2).map((deal) => ({
|
||||
id: deal.id,
|
||||
name: deal.company?.name || deal.person?.fullName || deal.title,
|
||||
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
|
||||
when: relativeWhen(deal.updatedAt || deal.createdAt),
|
||||
})),
|
||||
}));
|
||||
|
||||
return stages.slice(0, 3).map((stage, index) => {
|
||||
const accent = ACCENT_CYCLE[index % ACCENT_CYCLE.length]!;
|
||||
const currency = stage.deals[0]?.currency ?? 'IRR';
|
||||
return {
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
count: stage.deals.length,
|
||||
totalLabel:
|
||||
stage.totalAmount > 0 ? props.formatMoney(stage.totalAmount, currency) : '',
|
||||
accent,
|
||||
cards: stage.deals.slice(0, 2).map((deal) => {
|
||||
const name = deal.company?.name || deal.person?.fullName || deal.title;
|
||||
const when = relativeWhen(deal.updatedAt || deal.createdAt);
|
||||
return {
|
||||
id: deal.id,
|
||||
name,
|
||||
initial: initialOf(name),
|
||||
amount: deal.amount ? props.formatMoney(deal.amount, deal.currency) : '',
|
||||
when: when.label,
|
||||
isToday: when.isToday,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
function accentOf(key: PipelineAccent) {
|
||||
return PIPELINE_ACCENT[key];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>جریان فروش</h2>
|
||||
<RouterLink :to="{ name: 'pipeline' }">نمایش همه</RouterLink>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-5 sm:p-6 shadow-sm">
|
||||
<header class="flex items-center justify-between gap-3 mb-5">
|
||||
<div class="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
class="w-9 h-9 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center shrink-0"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<FundOutlined />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="font-bold text-slate-800 text-base m-0">جریان فروش</h3>
|
||||
<p class="text-xs text-slate-400 m-0 mt-0.5">
|
||||
نمای سریع معاملات در مراحل کلیدی
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink
|
||||
:to="{ name: 'pipeline' }"
|
||||
class="shrink-0 inline-flex items-center gap-1 text-xs font-bold text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-3 py-1.5 rounded-xl transition-colors"
|
||||
>
|
||||
<span>نمایش همه</span>
|
||||
<LeftOutlined />
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
<HomePanelEmpty
|
||||
@@ -54,132 +122,85 @@ const cols = computed<Col[]>(() => {
|
||||
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>
|
||||
</header>
|
||||
<div class="pipe-cards">
|
||||
<RouterLink
|
||||
v-for="card in col.cards"
|
||||
:key="card.id"
|
||||
class="pipe-card"
|
||||
:to="{ name: 'deal-detail', params: { id: card.id } }"
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-5">
|
||||
<article
|
||||
v-for="col in cols"
|
||||
:key="col.id"
|
||||
class="rounded-2xl border border-slate-200/70 bg-slate-50/90 p-4 flex flex-col gap-3 min-w-0"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2 pb-2 border-b border-slate-200/80">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="w-2.5 h-2.5 rounded-full shrink-0"
|
||||
:style="{ background: accentOf(col.accent).solid }"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<h4 class="font-bold text-sm text-slate-700 m-0 truncate">{{ col.name }}</h4>
|
||||
</div>
|
||||
<p v-if="col.totalLabel" class="text-[11px] text-slate-400 m-0 mt-1 truncate">
|
||||
مجموع {{ col.totalLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="shrink-0 bg-white border border-slate-200 text-slate-700 text-xs font-bold px-2.5 py-0.5 rounded-full"
|
||||
>
|
||||
<span class="deal-mark">{{ card.name.slice(0, 1) }}</span>
|
||||
<div>
|
||||
<strong>{{ card.name }}</strong>
|
||||
<span v-if="card.amount">{{ card.amount }}</span>
|
||||
<em>{{ card.when }}</em>
|
||||
{{ toFaDigits(col.count) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 flex-1">
|
||||
<RouterLink
|
||||
v-for="item in col.cards"
|
||||
:key="item.id"
|
||||
:to="{ name: 'deal-detail', params: { id: item.id } }"
|
||||
class="group bg-white p-3 rounded-xl border border-slate-200/80 shadow-sm hover:border-emerald-400 hover:shadow transition-all flex items-center gap-3 min-w-0"
|
||||
>
|
||||
<span
|
||||
class="w-9 h-9 rounded-full font-bold text-xs flex items-center justify-center shrink-0 border"
|
||||
:style="{
|
||||
background: accentOf(col.accent).soft,
|
||||
color: accentOf(col.accent).text,
|
||||
borderColor: accentOf(col.accent).border,
|
||||
}"
|
||||
>
|
||||
{{ item.initial }}
|
||||
</span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-bold text-sm text-slate-800 truncate">{{ item.name }}</div>
|
||||
<div class="text-xs text-slate-500 mt-0.5 truncate">
|
||||
<template v-if="item.amount">
|
||||
<span class="font-medium">{{ item.amount }}</span>
|
||||
<span v-if="item.when" class="text-slate-300 mx-1">·</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="item.when"
|
||||
:class="item.isToday ? 'text-emerald-600 font-semibold' : 'text-slate-400'"
|
||||
>
|
||||
{{ item.when }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div
|
||||
v-if="!col.cards.length"
|
||||
class="border border-dashed border-slate-200 rounded-xl px-3 py-5 text-center text-xs text-slate-400"
|
||||
>
|
||||
معاملهای در این مرحله نیست
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
v-else-if="col.cards.length < 2"
|
||||
:to="{ name: 'deal-new', query: { stageId: col.id } }"
|
||||
class="mt-auto border border-dashed border-slate-200 hover:border-emerald-300 hover:bg-white rounded-xl px-3 py-3 text-center text-xs text-slate-400 hover:text-emerald-700 transition-colors inline-flex items-center justify-center gap-1"
|
||||
>
|
||||
<PlusOutlined />
|
||||
افزودن معامله
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
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;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #14b8a6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mini-pipeline {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
gap: 0.65rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.pipe-col > header {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.pipe-cards {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.pipe-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
border-radius: var(--radius);
|
||||
background: #f8fafc;
|
||||
border: 1px solid #eef2f7;
|
||||
color: inherit;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pipe-card div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pipe-card strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.pipe-card span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.pipe-card em {
|
||||
color: #cbd5e1;
|
||||
font-size: 0.66rem;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.deal-mark {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.mini-pipeline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router';
|
||||
import type { RouteLocationRaw } from 'vue-router';
|
||||
import { Button as AButton, Empty as AEmpty, Typography } from 'ant-design-vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -8,70 +9,19 @@ defineProps<{
|
||||
actionLabel: string;
|
||||
actionTo: RouteLocationRaw;
|
||||
}>();
|
||||
|
||||
const { Text: ATypographyText } = Typography;
|
||||
</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>
|
||||
<a-empty :description="null">
|
||||
<template #description>
|
||||
<a-typography-text strong>{{ title }}</a-typography-text>
|
||||
<br />
|
||||
<a-typography-text type="secondary">{{ description }}</a-typography-text>
|
||||
</template>
|
||||
<RouterLink :to="actionTo">
|
||||
<a-button type="primary">{{ actionLabel }}</a-button>
|
||||
</RouterLink>
|
||||
</a-empty>
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, type Component } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
CheckOutlined,
|
||||
MailOutlined,
|
||||
PhoneOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
import {
|
||||
Flex as AFlex,
|
||||
Tag as ATag,
|
||||
Timeline as ATimeline,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
|
||||
import type { ActivityDto } from '@mento/shared';
|
||||
|
||||
@@ -10,16 +22,21 @@ const props = defineProps<{
|
||||
formatWhen: (value: string | null) => string;
|
||||
}>();
|
||||
|
||||
const { token } = theme.useToken();
|
||||
const { Text: ATypographyText } = Typography;
|
||||
const ATimelineItem = ATimeline.Item;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
title: string;
|
||||
when: string;
|
||||
typeLabel: string;
|
||||
type: string;
|
||||
tone: 'done' | 'pending' | 'held' | 'late';
|
||||
label: string;
|
||||
};
|
||||
|
||||
const typeLabel: Record<string, string> = {
|
||||
const typeLabelMap: Record<string, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'وظیفه',
|
||||
@@ -31,23 +48,50 @@ function activityStatus(act: ActivityDto) {
|
||||
if (act.dueAt && new Date(act.dueAt) < new Date()) {
|
||||
return { label: 'عقبافتاده', tone: 'late' as const };
|
||||
}
|
||||
return { label: 'در انتظار پاسخ', tone: 'pending' as const };
|
||||
return { label: 'در انتظار', tone: 'pending' as const };
|
||||
}
|
||||
|
||||
function activityIcon(type: string) {
|
||||
if (type === 'call') return 'phone' as const;
|
||||
if (type === 'meeting') return 'calendar' as const;
|
||||
if (type === 'task') return 'check' as const;
|
||||
return 'mail' as const;
|
||||
function activityIcon(type: string): Component {
|
||||
if (type === 'call') return PhoneOutlined;
|
||||
if (type === 'meeting') return CalendarOutlined;
|
||||
if (type === 'task') return CheckOutlined;
|
||||
return MailOutlined;
|
||||
}
|
||||
|
||||
function tagColor(tone: Row['tone']) {
|
||||
if (tone === 'late') return 'error';
|
||||
if (tone === 'done' || tone === 'held') return 'success';
|
||||
return 'processing';
|
||||
}
|
||||
|
||||
function timelineColor(tone: Row['tone']) {
|
||||
if (tone === 'late') return 'red';
|
||||
if (tone === 'done' || tone === 'held') return 'green';
|
||||
return 'blue';
|
||||
}
|
||||
|
||||
function typeBg(type: string) {
|
||||
if (type === 'call') return token.value.colorInfoBg;
|
||||
if (type === 'meeting') return token.value.colorWarningBg;
|
||||
if (type === 'task') return token.value.colorSuccessBg;
|
||||
return token.value.colorFillAlter;
|
||||
}
|
||||
|
||||
function typeFg(type: string) {
|
||||
if (type === 'call') return token.value.colorInfo;
|
||||
if (type === 'meeting') return token.value.colorWarning;
|
||||
if (type === 'task') return token.value.colorSuccess;
|
||||
return token.value.colorTextSecondary;
|
||||
}
|
||||
|
||||
const rows = computed<Row[]>(() =>
|
||||
props.activities.slice(0, 3).map((act) => {
|
||||
props.activities.slice(0, 5).map((act) => {
|
||||
const st = activityStatus(act);
|
||||
return {
|
||||
id: act.id,
|
||||
title: act.title,
|
||||
when: `${typeLabel[act.type] ?? act.type} · ${props.formatWhen(act.dueAt || act.createdAt)}`,
|
||||
when: props.formatWhen(act.dueAt || act.createdAt),
|
||||
typeLabel: typeLabelMap[act.type] ?? act.type,
|
||||
type: act.type,
|
||||
tone: st.tone,
|
||||
label: st.label,
|
||||
@@ -57,11 +101,16 @@ const rows = computed<Row[]>(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>فعالیتهای اخیر</h2>
|
||||
<RouterLink :to="{ name: 'activities' }">مشاهده همه</RouterLink>
|
||||
</header>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-bold text-slate-800 text-base">فعالیتهای اخیر</h3>
|
||||
<a-tag v-if="rows.length" :bordered="false">{{ rows.length }}</a-tag>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'activities' }" class="text-sm font-bold text-emerald-600">
|
||||
مشاهده همه
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<HomePanelEmpty
|
||||
v-if="!rows.length"
|
||||
@@ -70,117 +119,44 @@ const rows = computed<Row[]>(() =>
|
||||
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" />
|
||||
</span>
|
||||
<div class="act-body">
|
||||
<strong>{{ act.title }}</strong>
|
||||
<span>{{ act.when }}</span>
|
||||
</div>
|
||||
<span class="status-pill" :class="act.tone">{{ act.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<a-timeline v-else>
|
||||
<a-timeline-item v-for="item in rows" :key="item.id" :color="timelineColor(item.tone)">
|
||||
<a-flex
|
||||
align="flex-start"
|
||||
justify="space-between"
|
||||
:gap="token.marginSM"
|
||||
:style="{
|
||||
padding: `${token.paddingXS}px ${token.paddingSM}px`,
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
background: token.colorFillAlter,
|
||||
minWidth: 0,
|
||||
}"
|
||||
>
|
||||
<a-flex :gap="token.marginSM" :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-flex
|
||||
align="center"
|
||||
justify="center"
|
||||
:style="{
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: `${token.borderRadius}px`,
|
||||
background: typeBg(item.type),
|
||||
color: typeFg(item.type),
|
||||
flexShrink: 0,
|
||||
}"
|
||||
>
|
||||
<component :is="activityIcon(item.type)" />
|
||||
</a-flex>
|
||||
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-typography-text strong ellipsis>{{ item.title }}</a-typography-text>
|
||||
<a-typography-text type="secondary" ellipsis>
|
||||
{{ item.typeLabel }} · {{ item.when }}
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
<a-tag :color="tagColor(item.tone)" style="flex-shrink: 0">{{ item.label }}</a-tag>
|
||||
</a-flex>
|
||||
</a-timeline-item>
|
||||
</a-timeline>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
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;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #14b8a6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.activity-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.act-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: #ecfdf8;
|
||||
color: #14b8a6;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.act-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
}
|
||||
|
||||
.act-body strong {
|
||||
font-size: 0.84rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.act-body span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
padding: 0.22rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill.done,
|
||||
.status-pill.held {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-pill.pending {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-pill.late {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { Segmented as ASegmented } from 'ant-design-vue';
|
||||
import type { HomeDashboardTrendPointDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
@@ -10,26 +11,57 @@ const props = defineProps<{
|
||||
formatAmount: (value: number) => string;
|
||||
}>();
|
||||
|
||||
const trend = computed(() => props.trend);
|
||||
const hasTrend = computed(() => trend.value.some((p) => p.amount > 0));
|
||||
const change = computed(() => props.changePercent);
|
||||
type PeriodMonths = 1 | 3 | 6 | 12;
|
||||
|
||||
const period = ref<PeriodMonths>(6);
|
||||
|
||||
const periodOptions = [
|
||||
{ value: 1, label: 'این ماه' },
|
||||
{ value: 3, label: '۳ ماه' },
|
||||
{ value: 6, label: '۶ ماه' },
|
||||
{ value: 12, label: '۱۲ ماه' },
|
||||
];
|
||||
|
||||
const filteredTrend = computed(() => {
|
||||
const all = props.trend;
|
||||
if (!all.length) return [];
|
||||
const n = period.value;
|
||||
return all.slice(-Math.min(n, all.length));
|
||||
});
|
||||
|
||||
const hasTrend = computed(() => filteredTrend.value.some((p) => p.amount > 0));
|
||||
|
||||
/** تغییر آخرین ماه نسبت به ماه قبلِ همان بازه */
|
||||
const periodChange = computed(() => {
|
||||
const rows = filteredTrend.value;
|
||||
if (rows.length < 2) return props.changePercent;
|
||||
const last = rows[rows.length - 1]!.amount;
|
||||
const prev = rows[rows.length - 2]!.amount;
|
||||
if (prev === 0) return last === 0 ? 0 : 100;
|
||||
return Math.round(((last - prev) / Math.abs(prev)) * 1000) / 10;
|
||||
});
|
||||
|
||||
const changeHint = computed(() => {
|
||||
if (period.value === 1) return 'نسبت به ماه قبل';
|
||||
return 'آخرین ماه نسبت به ماه قبل';
|
||||
});
|
||||
|
||||
const option = computed<UiChartOption>(() => ({
|
||||
animationDuration: 400,
|
||||
grid: { top: 28, right: 8, bottom: 28, left: 8, containLabel: true },
|
||||
animationDuration: 450,
|
||||
grid: { top: 36, right: 12, bottom: 28, left: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
textStyle: { fontFamily: 'IRANSans, Tahoma, sans-serif' },
|
||||
formatter: (params) => {
|
||||
const row = Array.isArray(params) ? params[0] : params;
|
||||
const point = trend.value[row.dataIndex ?? 0];
|
||||
const point = filteredTrend.value[row.dataIndex ?? 0];
|
||||
if (!point) return '';
|
||||
return `${point.monthLabel}<br/>${props.formatAmount(point.amount)}`;
|
||||
return `${point.monthLabel}<br/><strong>${props.formatAmount(point.amount)}</strong>`;
|
||||
},
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: trend.value.map((p) => p.monthLabel),
|
||||
data: filteredTrend.value.map((p) => p.monthLabel),
|
||||
boundaryGap: false,
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -43,18 +75,19 @@ const option = computed<UiChartOption>(() => ({
|
||||
type: 'value',
|
||||
min: 0,
|
||||
...(hasTrend.value ? {} : { max: 1 }),
|
||||
splitLine: { lineStyle: { color: '#eef2f7' } },
|
||||
splitLine: { lineStyle: { color: '#e5e7eb', type: 'dashed' } },
|
||||
axisLabel: { show: false },
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: trend.value.map((p) => p.amount),
|
||||
smooth: 0.35,
|
||||
name: 'فروش',
|
||||
data: filteredTrend.value.map((p) => p.amount),
|
||||
smooth: 0.4,
|
||||
symbol: 'circle',
|
||||
symbolSize: 8,
|
||||
itemStyle: { color: '#14b8a6', borderColor: '#fff', borderWidth: 2 },
|
||||
lineStyle: { color: '#14b8a6', width: 3 },
|
||||
symbolSize: period.value === 1 ? 10 : 8,
|
||||
itemStyle: { color: '#10b981', borderColor: '#fff', borderWidth: 2 },
|
||||
lineStyle: { color: '#10b981', width: 3 },
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
@@ -63,8 +96,8 @@ const option = computed<UiChartOption>(() => ({
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{ offset: 0, color: 'rgba(20, 184, 166, 0.28)' },
|
||||
{ offset: 1, color: 'rgba(20, 184, 166, 0)' },
|
||||
{ offset: 0, color: 'rgba(16, 185, 129, 0.28)' },
|
||||
{ offset: 1, color: 'rgba(16, 185, 129, 0)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -74,56 +107,22 @@ const option = computed<UiChartOption>(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>روند فروش</h2>
|
||||
<span v-if="hasTrend && change != null" class="mini-badge" :class="{ down: change < 0 }">
|
||||
{{ formatChange(change) }} نسبت به ماه قبل
|
||||
</span>
|
||||
</header>
|
||||
<UiChart :option="option" height="210px" />
|
||||
</article>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full flex flex-col">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<div>
|
||||
<h3 class="font-bold text-slate-800 text-base">روند فروش</h3>
|
||||
<span
|
||||
v-if="hasTrend && periodChange != null"
|
||||
class="mt-1 inline-flex text-xs font-bold px-2 py-0.5 rounded-md"
|
||||
:class="periodChange < 0 ? 'text-rose-600 bg-rose-50' : 'text-emerald-600 bg-emerald-50'"
|
||||
>
|
||||
{{ formatChange(periodChange) }} {{ changeHint }}
|
||||
</span>
|
||||
</div>
|
||||
<a-segmented v-model:value="period" :options="periodOptions" />
|
||||
</div>
|
||||
<div class="h-56">
|
||||
<UiChart :option="option" height="224px" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.mini-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
color: #16a34a;
|
||||
background: #dcfce7;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.18rem 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mini-badge.down {
|
||||
color: #dc2626;
|
||||
background: #fee2e2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Empty as AEmpty } from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardSourceDto } from '@mento/shared';
|
||||
import UiChart, { type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
import UiChart, { CHART_PALETTE, type UiChartOption } from '@/components/ui/UiChart.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
sources: HomeDashboardSourceDto[];
|
||||
}>();
|
||||
|
||||
const colors = ['#2563eb', '#14b8a6', '#22c55e', '#fb923c', '#94a3b8'];
|
||||
|
||||
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,
|
||||
color: CHART_PALETTE,
|
||||
tooltip: {
|
||||
show: total.value > 0,
|
||||
trigger: 'item',
|
||||
@@ -28,147 +27,51 @@ const option = computed<UiChartOption>(() => ({
|
||||
series: [
|
||||
{
|
||||
type: 'pie',
|
||||
radius: ['58%', '82%'],
|
||||
radius: ['55%', '78%'],
|
||||
center: ['50%', '50%'],
|
||||
silent: total.value === 0,
|
||||
avoidLabelOverlap: true,
|
||||
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 },
|
||||
label: { show: false },
|
||||
labelLine: { show: false },
|
||||
data:
|
||||
total.value > 0
|
||||
? rows.value.map((s) => ({ name: s.label, value: s.count }))
|
||||
: [{ name: '', value: 1, itemStyle: { color: '#e2e8f0' } }],
|
||||
: [{ name: '', value: 1, itemStyle: { color: '#e5e7eb' } }],
|
||||
},
|
||||
],
|
||||
}));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<h2>منابع مشتریان</h2>
|
||||
</header>
|
||||
|
||||
<div class="donut-wrap">
|
||||
<div class="donut-chart-box">
|
||||
<UiChart :option="option" height="132px" />
|
||||
<div class="donut-center">
|
||||
<strong>{{ toFaDigits(total) }}</strong>
|
||||
<span>مشتری</span>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-bold text-slate-800 text-base">منابع ورود مشتریان</h3>
|
||||
<span v-if="total" class="text-xs text-slate-400">۱۲ ماه اخیر</span>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div class="w-full sm:w-[48%] sm:max-w-[220px] shrink-0">
|
||||
<div class="relative w-full aspect-square">
|
||||
<UiChart :option="option" height="100%" />
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none">
|
||||
<span class="text-2xl font-black text-slate-800">{{ toFaDigits(total) }}</span>
|
||||
<span class="text-xs text-slate-400">مشتری</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:flex-1 min-w-0">
|
||||
<a-empty v-if="!total" description="منبعی ثبت نشده" />
|
||||
<div v-else class="divide-y divide-slate-100">
|
||||
<div v-for="(item, index) in rows" :key="item.label" class="flex items-center gap-2 py-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full shrink-0"
|
||||
:style="{ background: CHART_PALETTE[index % CHART_PALETTE.length] }"
|
||||
></span>
|
||||
<span class="flex-1 text-sm text-slate-700 truncate">{{ item.label }}</span>
|
||||
<span class="text-sm font-bold text-slate-800">{{ toFaDigits(item.percent) }}٪</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<em>{{ toFaDigits(row.percent) }}٪</em>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: var(--pad-modal);
|
||||
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 {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.donut-wrap {
|
||||
display: grid;
|
||||
grid-template-columns: 132px 1fr;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.donut-chart-box {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 132px;
|
||||
}
|
||||
|
||||
.donut-center {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
gap: 0.05rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.donut-center strong {
|
||||
font-size: 1.15rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.donut-center span {
|
||||
font-size: 0.7rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.donut-legend {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.48rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.donut-legend li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.lbl {
|
||||
flex: 1;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.legend-empty {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: #94a3b8;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.donut-wrap {
|
||||
grid-template-columns: 1fr;
|
||||
justify-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { TrophyOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
Avatar as AAvatar,
|
||||
Flex as AFlex,
|
||||
Progress as AProgress,
|
||||
Tag as ATag,
|
||||
Typography,
|
||||
theme,
|
||||
} from 'ant-design-vue';
|
||||
import { toFaDigits } from '@/lib/jalali';
|
||||
import type { HomeDashboardTopCustomerDto } from '@mento/shared';
|
||||
import HomePanelEmpty from '@/components/dashboard/HomePanelEmpty.vue';
|
||||
@@ -9,25 +18,61 @@ const props = defineProps<{
|
||||
customers: HomeDashboardTopCustomerDto[];
|
||||
}>();
|
||||
|
||||
const rows = computed(() => props.customers.slice(0, 3));
|
||||
const { token } = theme.useToken();
|
||||
const { Text: ATypographyText } = Typography;
|
||||
|
||||
function trophy(rank: number) {
|
||||
if (rank === 1) return '🥇';
|
||||
if (rank === 2) return '🥈';
|
||||
if (rank === 3) return '🥉';
|
||||
return `#${toFaDigits(rank)}`;
|
||||
const rows = computed(() => props.customers.slice(0, 5));
|
||||
const maxDeals = computed(() => Math.max(...rows.value.map((c) => c.dealCount), 1));
|
||||
|
||||
function rankStyle(rank: number) {
|
||||
if (rank === 1) {
|
||||
return {
|
||||
background: token.value.colorWarningBg,
|
||||
color: token.value.colorWarning,
|
||||
border: `1px solid ${token.value.colorWarningBorder}`,
|
||||
};
|
||||
}
|
||||
if (rank === 2) {
|
||||
return {
|
||||
background: token.value.colorFillSecondary,
|
||||
color: token.value.colorText,
|
||||
border: `1px solid ${token.value.colorBorder}`,
|
||||
};
|
||||
}
|
||||
if (rank === 3) {
|
||||
return {
|
||||
background: token.value.colorPrimaryBg,
|
||||
color: token.value.colorPrimary,
|
||||
border: `1px solid ${token.value.colorPrimaryBorder}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
background: token.value.colorFillAlter,
|
||||
color: token.value.colorTextSecondary,
|
||||
border: `1px solid ${token.value.colorBorderSecondary}`,
|
||||
};
|
||||
}
|
||||
|
||||
function progressPercent(count: number) {
|
||||
return Math.round((count / maxDeals.value) * 100);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article class="panel">
|
||||
<header class="panel-head">
|
||||
<div>
|
||||
<h2>مشتریان برتر</h2>
|
||||
<p class="hint">بر اساس معاملات برندهٔ ۵ ماه اخیر</p>
|
||||
<section class="bg-white rounded-2xl border border-slate-200/80 p-4 shadow-sm h-full w-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<TrophyOutlined class="text-amber-500" />
|
||||
<h3 class="font-bold text-slate-800 text-base">مشتریان برتر</h3>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'contacts' }">مشاهده همه</RouterLink>
|
||||
</header>
|
||||
<RouterLink :to="{ name: 'contacts' }" class="text-sm font-bold text-emerald-600">
|
||||
مشاهده همه
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<a-typography-text type="secondary" style="display: block; margin-bottom: 12px">
|
||||
بر اساس معاملات برندهٔ اخیر
|
||||
</a-typography-text>
|
||||
|
||||
<HomePanelEmpty
|
||||
v-if="!rows.length"
|
||||
@@ -36,99 +81,51 @@ function trophy(rank: number) {
|
||||
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>
|
||||
<strong>{{ row.name }}</strong>
|
||||
<span>{{ toFaDigits(row.dealCount) }} معامله</span>
|
||||
</div>
|
||||
<span class="rank">{{ trophy(row.rank) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
<a-flex v-else vertical :gap="token.marginSM">
|
||||
<a-flex
|
||||
v-for="item in rows"
|
||||
:key="item.id"
|
||||
align="center"
|
||||
:gap="token.marginSM"
|
||||
class="rounded-xl border"
|
||||
:class="item.rank === 1 ? 'bg-amber-50/50 border-amber-200/80' : 'bg-slate-50 border-slate-200/70'"
|
||||
:style="{ padding: `${token.paddingSM}px`, minWidth: 0 }"
|
||||
>
|
||||
<a-flex
|
||||
align="center"
|
||||
justify="center"
|
||||
:style="{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: '50%',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
...rankStyle(item.rank),
|
||||
}"
|
||||
>
|
||||
{{ toFaDigits(item.rank) }}
|
||||
</a-flex>
|
||||
<a-avatar :style="{ background: token.colorPrimary, flexShrink: 0 }">
|
||||
{{ item.name.slice(0, 1) }}
|
||||
</a-avatar>
|
||||
<a-flex vertical :style="{ minWidth: 0, flex: 1 }">
|
||||
<a-flex align="center" justify="space-between" :gap="token.marginXS">
|
||||
<a-typography-text strong ellipsis>{{ item.name }}</a-typography-text>
|
||||
<a-tag v-if="item.rank === 1" color="gold" style="flex-shrink: 0">رتبه یک</a-tag>
|
||||
</a-flex>
|
||||
<a-flex align="center" :gap="token.marginSM">
|
||||
<a-progress
|
||||
:percent="progressPercent(item.dealCount)"
|
||||
:show-info="false"
|
||||
:stroke-color="item.rank === 1 ? token.colorWarning : token.colorPrimary"
|
||||
style="flex: 1; margin: 0; line-height: 1"
|
||||
/>
|
||||
<a-typography-text type="secondary" style="flex-shrink: 0; white-space: nowrap">
|
||||
{{ toFaDigits(item.dealCount) }} معامله
|
||||
</a-typography-text>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</a-flex>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
padding: 1rem 1.05rem;
|
||||
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: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.85rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0.15rem 0 0;
|
||||
color: #94a3b8;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #14b8a6;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.top-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.top-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.cust-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: var(--radius);
|
||||
background: linear-gradient(145deg, #ccfbf1, #99f6e4);
|
||||
color: #0f766e;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.top-list div {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.top-list strong {
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.top-list span {
|
||||
color: #94a3b8;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.rank {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user