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

268 lines
7.4 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
import { formatJalaliDateTime, rewriteGregorianDates, toFaDigits } from '@/lib/jalali';
import HomeHero from '@/components/dashboard/HomeHero.vue';
import HomeKpiRow from '@/components/dashboard/HomeKpiRow.vue';
import HomeBirthdayStrip from '@/components/dashboard/HomeBirthdayStrip.vue';
import HomeSalesTrend from '@/components/dashboard/HomeSalesTrend.vue';
import HomeSourcesChart from '@/components/dashboard/HomeSourcesChart.vue';
import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue';
import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue';
import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue';
import HomeTopCustomers from '@/components/dashboard/HomeTopCustomers.vue';
import axios from 'axios';
import type {
ActivityDto,
AskMentoResponse,
AskMentoSourceDto,
HomeDashboardDto,
PaginatedResponse,
PipelineBoardDto,
} from '@mento/shared';
const auth = useAuthStore();
const loading = ref(true);
const home = ref<HomeDashboardDto | null>(null);
const activities = ref<ActivityDto[]>([]);
const board = ref<PipelineBoardDto | null>(null);
const askQuery = ref('');
const asking = ref(false);
const assistantBusy = ref(false);
const assistantRef = ref<{ submitFromInput: () => Promise<void> } | null>(null);
const askAnswer = ref('');
const askError = ref('');
const askSources = ref<AskMentoSourceDto[]>([]);
const firstName = computed(() => {
const parts = auth.user?.fullName?.trim().split(/\s+/) ?? [];
return parts[0] ?? 'کاربر';
});
const userInitial = computed(() => firstName.value.slice(0, 1));
const greeting = computed(() => {
const hour = new Date().getHours();
if (hour < 12) return 'صبح بخیر';
if (hour < 17) return 'ظهر بخیر';
return 'عصر بخیر';
});
function formatWhen(value: string | null) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return formatJalaliDateTime(d);
}
function formatKpiValue(key: string, value: number) {
if (key === 'sales') {
return formatMoney(value, home.value?.currency ?? 'IRR');
}
return toFaDigits(value);
}
function formatChange(value: number | null) {
if (value == null) return '';
const sign = value >= 0 ? '+' : '';
return `${sign}${toFaDigits(value)}٪`;
}
function formatTrendAmount(value: number) {
return formatMoney(value, home.value?.currency ?? 'IRR');
}
function clearAsk() {
askAnswer.value = '';
askError.value = '';
askSources.value = [];
}
function onHeroAsk() {
if (!assistantRef.value) {
askError.value = 'داشبورد هنوز آماده نیست. چند لحظه بعد دوباره بفرستید.';
return;
}
void assistantRef.value.submitFromInput();
}
function onAssistantNotice(payload: { error: string; done: string }) {
if (payload.done) {
askAnswer.value = payload.done;
askError.value = '';
return;
}
if (payload.error) {
askError.value = payload.error;
askAnswer.value = '';
return;
}
if (!asking.value) askError.value = '';
}
async function submitAsk(preset?: string) {
const q = (preset || askQuery.value).trim();
if (!q || asking.value) return;
if (preset) askQuery.value = preset;
asking.value = true;
askError.value = '';
askAnswer.value = '';
askSources.value = [];
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
askAnswer.value = rewriteGregorianDates(data.answer);
askSources.value = data.sources ?? [];
await nextTick();
document
.getElementById('home-ai-assistant')
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
askError.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'پرسش ناموفق بود');
} else {
askError.value = 'پرسش ناموفق بود';
}
} finally {
asking.value = false;
}
}
function onAskHotkey(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return;
const tag = (e.target as HTMLElement | null)?.tagName;
if (tag === 'TEXTAREA' || (tag === 'INPUT' && (e.target as HTMLInputElement).id !== 'home-hero-ask')) {
return;
}
e.preventDefault();
document.getElementById('home-hero-ask')?.focus();
}
onMounted(async () => {
window.addEventListener('keydown', onAskHotkey);
loading.value = true;
try {
const [homeRes, actRes, boardRes] = await Promise.all([
api.get<HomeDashboardDto>('/reports/home'),
api.get<PaginatedResponse<ActivityDto>>('/activities', {
params: { limit: 5, page: 1 },
}),
api.get<PipelineBoardDto>('/pipelines/board'),
]);
home.value = homeRes.data;
activities.value = actRes.data.items;
board.value = boardRes.data;
} catch {
home.value = null;
} finally {
loading.value = false;
}
});
onUnmounted(() => {
window.removeEventListener('keydown', onAskHotkey);
});
</script>
<template>
<section class="dashboard">
<HomeHero
:greeting="greeting"
:first-name="firstName"
:user-initial="userInitial"
:alerts="home?.alerts ?? []"
:asking="asking || assistantBusy"
:ask-answer="askAnswer"
:ask-error="askError"
:status-text="assistantBusy && !asking ? 'در حال فهمیدن دستور…' : ''"
v-model:ask-query="askQuery"
@ask="onHeroAsk"
/>
<p v-if="loading" class="muted center">در حال بارگذاری داشبورد…</p>
<template v-else>
<HomeKpiRow
:kpis="home?.kpis ?? []"
:currency="home?.currency ?? 'IRR'"
:format-value="formatKpiValue"
:format-change="formatChange"
/>
<HomeBirthdayStrip />
<div class="mid-grid">
<HomeSalesTrend
:trend="home?.salesTrend ?? []"
:change-percent="home?.kpis?.[0]?.changePercent ?? null"
:format-change="formatChange"
:format-amount="formatTrendAmount"
/>
<HomeSourcesChart :sources="home?.sources ?? []" />
<HomeAiAssistant
id="home-ai-assistant"
ref="assistantRef"
v-model:ask-query="askQuery"
:asking="asking"
:answer="askAnswer"
:error="askError"
:sources="askSources"
@ask="submitAsk"
@clear="clearAsk"
@busy="assistantBusy = $event"
@notice="onAssistantNotice"
/>
</div>
<div class="bottom-grid">
<HomeRecentActivities :activities="activities" :format-when="formatWhen" />
<HomeMiniPipeline :board="board" :format-money="formatMoney" />
<HomeTopCustomers :customers="home?.topCustomers ?? []" />
</div>
</template>
</section>
</template>
<style scoped>
.dashboard {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.15rem;
min-width: 0;
}
.dashboard > * {
min-width: 0;
}
.mid-grid {
display: grid;
grid-template-columns: 1.25fr 1fr 0.88fr;
gap: 0.8rem;
align-items: stretch;
}
.bottom-grid {
display: grid;
grid-template-columns: 1fr 1.35fr 0.78fr;
gap: 0.8rem;
}
.muted {
color: #94a3b8;
}
.center {
text-align: center;
}
@media (max-width: 1200px) {
.mid-grid,
.bottom-grid {
grid-template-columns: 1fr;
}
}
</style>