338 lines
8.6 KiB
Vue
338 lines
8.6 KiB
Vue
<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>
|
|||
|
|
·
|
|||
|
|
<RouterLink to="/notes">یادداشتها ←</RouterLink>
|
|||
|
|
·
|
|||
|
|
<RouterLink to="/pipeline">کاریز فروش ←</RouterLink>
|
|||
|
|
·
|
|||
|
|
<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>
|