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

338 lines
8.6 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney as formatMoneyLib } from '@/lib/money';
import type {
ActivityDto,
ActivitySummaryDto,
HealthResponse,
OrganizationDto,
SalesForecastDto,
} from '@mento/shared';
const auth = useAuthStore();
const router = useRouter();
const org = ref<OrganizationDto | null>(null);
const health = ref<HealthResponse | null>(null);
const summary = ref<ActivitySummaryDto | null>(null);
const forecast = ref<SalesForecastDto | null>(null);
const orgError = ref('');
const apiError = ref('');
const typeLabel: Record<ActivityDto['type'], string> = {
call: 'تماس',
meeting: 'جلسه',
task: 'وظیفه',
note: 'یادداشت',
};
function formatDue(value: string | null) {
if (!value) return 'بدون موعد';
try {
return new Intl.DateTimeFormat('fa-IR', {
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
} catch {
return value;
}
}
function formatMoney(amount: number) {
return formatMoneyLib(amount, forecast.value?.currency || 'IRR');
}
async function complete(id: string) {
try {
await api.patch(`/activities/${id}/complete`);
const { data } = await api.get<ActivitySummaryDto>('/activities/summary');
summary.value = data;
} catch {
// ignore brief failure on home
}
}
onMounted(async () => {
try {
const { data } = await api.get<HealthResponse>('/health');
health.value = data;
} catch {
apiError.value = 'ارتباط با API برقرار نشد. سرویس Nest را چک کنید.';
}
try {
const { data } = await api.get<OrganizationDto>('/organizations/current');
org.value = data;
} catch (e) {
if (axios.isAxiosError(e) && e.response?.status === 401) {
auth.logout();
await router.replace({ name: 'login', query: { redirect: '/' } });
return;
}
orgError.value = apiError.value
? 'به‌خاطر قطع بودن API بارگذاری نشد.'
: 'بارگذاری سازمان ناموفق بود.';
}
try {
const { data } = await api.get<ActivitySummaryDto>('/activities/summary');
summary.value = data;
} catch {
// home still works without activities
}
try {
const { data } = await api.get<SalesForecastDto>('/sales/forecast');
forecast.value = data;
} catch {
// optional card
}
});
</script>
<template>
<section class="home">
<header>
<div>
<p class="eyebrow">خانه امروز</p>
<h1>سلام {{ auth.user?.fullName }}</h1>
<p class="lead">
کارهای امروز را ببینید و یادداشت‌های سریع را در Smart Notes ثبت کنید.
</p>
</div>
<div class="actions">
<RouterLink class="btn btn-ghost" :to="{ name: 'sales-forecast' }"
>هدف و پیش‌بینی</RouterLink
>
<RouterLink class="btn btn-ghost" :to="{ name: 'activities-calendar' }"
>تقویم</RouterLink
>
<RouterLink class="btn" :to="{ name: 'activity-new' }">فعالیت جدید</RouterLink>
</div>
</header>
<div class="grid">
<article>
<h2>سازمان</h2>
<p v-if="org">{{ org.name }} <span>({{ org.slug }})</span></p>
<p v-else-if="orgError" class="error">{{ orgError }}</p>
<p v-else class="muted">در حال بارگذاری…</p>
</article>
<article>
<h2>وضعیت API</h2>
<p v-if="health" class="ok">{{ health.status }} — {{ health.service }}</p>
<p v-else-if="apiError" class="error">{{ apiError }}</p>
<p v-else class="muted">در حال بررسی…</p>
</article>
<article v-if="forecast" class="wide forecast-card">
<div class="section-head">
<h2>فروش این ماه</h2>
<RouterLink :to="{ name: 'sales-forecast' }">جزئیات ←</RouterLink>
</div>
<div class="forecast-row">
<div>
<p class="muted">هدف</p>
<strong>{{ formatMoney(forecast.targetAmount) }}</strong>
</div>
<div>
<p class="muted">تحقق</p>
<strong>{{ formatMoney(forecast.wonAmount) }}</strong>
</div>
<div>
<p class="muted">پیش‌بینی</p>
<strong>{{ formatMoney(forecast.forecastAmount) }}</strong>
</div>
<div>
<p class="muted">پیشرفت</p>
<strong>
{{
forecast.progressPercent == null
? '—'
: `${new Intl.NumberFormat('fa-IR').format(forecast.progressPercent)}٪`
}}
</strong>
</div>
</div>
</article>
<article class="wide">
<div class="section-head">
<h2>عقب‌افتاده ({{ summary?.overdueCount ?? 0 }})</h2>
<RouterLink :to="{ name: 'activities', query: { filter: 'overdue' } }">همه ←</RouterLink>
</div>
<div v-if="summary?.overdue.length" class="activity-list">
<div v-for="item in summary.overdue.slice(0, 5)" :key="item.id" class="activity-row">
<div>
<strong>{{ item.title }}</strong>
<p class="muted">{{ typeLabel[item.type] }} · {{ item.person?.fullName || '—' }}</p>
</div>
<button class="btn btn-ghost" type="button" @click="complete(item.id)">انجام شد</button>
</div>
</div>
<p v-else class="muted">عقب‌افتاده‌ای نیست.</p>
</article>
<article class="wide">
<div class="section-head">
<h2>امروز ({{ summary?.todayCount ?? 0 }})</h2>
<RouterLink :to="{ name: 'activities' }">همه ←</RouterLink>
</div>
<div v-if="summary?.today.length" class="activity-list">
<div v-for="item in summary.today.slice(0, 6)" :key="item.id" class="activity-row">
<div>
<strong>{{ item.title }}</strong>
<p class="muted">
{{ typeLabel[item.type] }} · {{ formatDue(item.dueAt) }}
<span v-if="item.deal"> · {{ item.deal.title }}</span>
</p>
</div>
<button class="btn btn-ghost" type="button" @click="complete(item.id)">انجام شد</button>
</div>
</div>
<p v-else class="muted">برای امروز فعالیتی ثبت نشده.</p>
</article>
<article class="wide">
<h2>میانبر</h2>
<p>
<RouterLink to="/activities">فعالیت‌ها ←</RouterLink>
&nbsp;·&nbsp;
<RouterLink to="/notes">یادداشت‌ها ←</RouterLink>
&nbsp;·&nbsp;
<RouterLink to="/pipeline">کاریز فروش ←</RouterLink>
&nbsp;·&nbsp;
<RouterLink to="/contacts">اشخاص و شرکت‌ها ←</RouterLink>
</p>
</article>
</div>
</section>
</template>
<style scoped>
.home {
display: grid;
gap: 1.25rem;
}
header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.eyebrow {
margin: 0;
color: var(--accent);
font-weight: 700;
}
h1 {
margin: 0.2rem 0;
font-size: 2rem;
}
.lead,
.muted,
span {
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
article {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
padding: 1.1rem 1.2rem;
box-shadow: var(--shadow);
}
.wide {
grid-column: 1 / -1;
}
h2 {
margin: 0 0 0.6rem;
font-size: 1.05rem;
}
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.section-head h2 {
margin: 0;
}
.forecast-row {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.75rem;
}
.forecast-row p {
margin: 0 0 0.2rem;
font-size: 0.85rem;
}
.ok {
color: var(--accent);
font-weight: 600;
}
article a {
color: var(--accent);
font-weight: 600;
}
.activity-list {
display: grid;
gap: 0.65rem;
}
.activity-row {
display: flex;
justify-content: space-between;
gap: 0.8rem;
align-items: center;
padding: 0.7rem 0;
border-top: 1px solid var(--line);
}
.activity-row:first-child {
border-top: 0;
padding-top: 0;
}
.activity-row p {
margin: 0.2rem 0 0;
font-size: 0.9rem;
}
@media (max-width: 800px) {
.grid {
grid-template-columns: 1fr;
}
}
</style>