Files
imen_pishghaman/apps/admin/src/pages/DashboardPage.vue
T

342 lines
16 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.
This file contains Unicode characters that might be confused with other characters. 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 { type DashboardDto, MESSAGE_STATUS_LABELS } from "@imen/shared";
import { message } from "ant-design-vue";
import { computed, onMounted, ref } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import PageHeader from "@/components/PageHeader.vue";
import { useAuthStore } from "@/stores/auth";
import { activityLabel, faNumber, jalali } from "@/utils/format";
const auth = useAuthStore();
const data = ref<DashboardDto | null>(null);
const loading = ref(true);
const maxCategoryCount = computed(() => {
if (!data.value?.categoryStats?.length) return 1;
return Math.max(...data.value.categoryStats.map((c) => c.count), 1);
});
const maxTrendCount = computed(() => {
if (!data.value?.trend?.length) return 1;
return Math.max(...data.value.trend.map((t) => t.messages), 1);
});
const sectionPercent = computed(() => {
if (!data.value?.sectionsSummary?.total) return 100;
return Math.round((data.value.sectionsSummary.visible / data.value.sectionsSummary.total) * 100);
});
onMounted(async () => {
loading.value = true;
try {
data.value = await api<DashboardDto>("/admin/dashboard");
} catch (e) {
void message.error(errorMessage(e));
} finally {
loading.value = false;
}
});
</script>
<template>
<PageHeader title="داشبورد مدیریت" subtitle="نمای کلی آمار، نمودارها، پیام‌ها و فعالیت‌های اخیر سامانه">
<div class="flex items-center gap-2">
<RouterLink :to="{ name: 'sections' }">
<a-button>
<template #icon><VuesaxIcon name="row-vertical" :size="16" /></template>
مدیریت بخش‌ها
</a-button>
</RouterLink>
<RouterLink :to="{ name: 'product-new' }">
<a-button type="primary">
<template #icon><VuesaxIcon name="add" :size="16" /></template>
محصول جدید
</a-button>
</RouterLink>
</div>
</PageHeader>
<a-spin :spinning="loading">
<!-- 1. Top Statistics Row -->
<div class="grid grid-cols-2 gap-3 lg:gap-4 lg:grid-cols-5">
<RouterLink :to="{ name: 'products' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="کل محصولات" :value="faNumber(data?.products ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-orange-50 text-orange-600">
<VuesaxIcon name="bag-2" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
{{ faNumber((data?.products ?? 0) - (data?.draftProducts ?? 0)) }} محصول فعال روی سایت
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'products', query: { status: 'draft' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="پیش‌نویس‌ها" :value="faNumber(data?.draftProducts ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-amber-50 text-amber-600">
<VuesaxIcon name="document-text" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">نیاز به بازبینی و انتشار</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'categories' }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="دسته‌بندی‌ها" :value="faNumber(data?.categories ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-blue-50 text-blue-600">
<VuesaxIcon name="category" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">شاخه‌های تجهیزات ایمنی</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'messages', query: { status: 'NEW' } }" class="active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic
title="پیام‌های جدید"
:value="faNumber(data?.newMessages ?? 0)"
:value-style="data?.newMessages ? { color: '#ff5a00', fontWeight: 'bold' } : undefined"
/>
<div
class="flex size-10 items-center justify-center rounded-xl"
:class="data?.newMessages ? 'bg-orange-100 text-orange-600 animate-pulse' : 'bg-gray-100 text-gray-500'"
>
<VuesaxIcon name="sms" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">
از مجموع {{ faNumber(data?.messageBreakdown?.total ?? 0) }} پیام
</div>
</a-card>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="max-lg:col-span-2 active:scale-[0.98] transition-transform">
<a-card hoverable class="h-full" :body-style="{ padding: '14px' }">
<div class="flex items-center justify-between">
<a-statistic title="رسانه‌ها و تصاویر" :value="faNumber(data?.media ?? 0)" />
<div class="flex size-10 items-center justify-center rounded-xl bg-purple-50 text-purple-600">
<VuesaxIcon name="gallery" :size="20" />
</div>
</div>
<div class="mt-2 text-xs text-gray-400">تصاویر بهینه‌شده WebP</div>
</a-card>
</RouterLink>
</div>
<!-- 2. Quick Actions & Site Health Section -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Quick Action Buttons -->
<a-card title="دسترسی‌های سریع" class="lg:col-span-8">
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
<RouterLink :to="{ name: 'product-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-orange-200 hover:bg-orange-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-orange-600 shadow-sm">
<VuesaxIcon name="add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">افزودن محصول</span>
<span class="mt-1 text-[11px] text-gray-400">کالای ایمنی جدید</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'category-new' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-blue-200 hover:bg-blue-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-blue-600 shadow-sm">
<VuesaxIcon name="folder-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">دسته‌بندی جدید</span>
<span class="mt-1 text-[11px] text-gray-400">گروه محصولات</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'sections' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-emerald-200 hover:bg-emerald-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-emerald-600 shadow-sm">
<VuesaxIcon name="row-vertical" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">بخش‌های سایت</span>
<span class="mt-1 text-[11px] text-gray-400">نمایش یا مخفی‌سازی</span>
</div>
</RouterLink>
<RouterLink :to="{ name: 'media' }" class="block">
<div class="flex flex-col items-center justify-center rounded-xl border border-gray-100 bg-gray-50/70 p-4 text-center transition-all hover:border-purple-200 hover:bg-purple-50/50 hover:shadow-sm">
<div class="mb-2 flex size-11 items-center justify-center rounded-full bg-white text-purple-600 shadow-sm">
<VuesaxIcon name="gallery-add" :size="22" />
</div>
<span class="text-xs font-bold text-gray-800">آپلود رسانه</span>
<span class="mt-1 text-[11px] text-gray-400">تصاویر و اسناد</span>
</div>
</RouterLink>
</div>
</a-card>
<!-- Section Visibility Health Card -->
<a-card title="وضعیت بخش‌های صفحه اصلی" class="lg:col-span-4">
<template #extra>
<RouterLink :to="{ name: 'sections' }" class="text-xs text-orange-600 hover:underline">
مدیریت
</RouterLink>
</template>
<div class="flex items-center gap-4">
<a-progress
type="circle"
:percent="sectionPercent"
:size="72"
:stroke-color="{ '0%': '#ff8a3d', '100%': '#ff5a00' }"
/>
<div class="space-y-1.5 text-xs">
<div class="font-bold text-gray-800">
{{ faNumber(data?.sectionsSummary?.visible ?? 0) }} بخش از {{ faNumber(data?.sectionsSummary?.total ?? 12) }} فعال
</div>
<div class="text-gray-500">
<span v-if="(data?.sectionsSummary?.hidden ?? 0) === 0" class="text-emerald-600 font-medium">
تمامی بخش‌های سایت در حال نمایش هستند
</span>
<span v-else class="text-amber-600 font-medium">
{{ faNumber(data?.sectionsSummary?.hidden ?? 0) }} بخش غیرفعال / مخفی
</span>
</div>
<div class="text-[11px] text-gray-400">
تغییر وضعیت آنی از منوی بخش‌های سایت
</div>
</div>
</div>
</a-card>
</div>
<!-- 3. Charts Row (نمودارهای تحلیلی) -->
<div class="mt-6 grid gap-6 lg:grid-cols-12">
<!-- Chart 1: Products distribution per category -->
<a-card title="نمودار توزیع محصولات بر اساس دسته‌بندی" class="lg:col-span-7">
<div v-if="!data?.categoryStats?.length" class="py-8">
<a-empty description="داده‌ای موجود نیست" />
</div>
<div v-else class="space-y-3.5 pt-1">
<div v-for="cat in data.categoryStats" :key="cat.id" class="space-y-1">
<div class="flex items-center justify-between text-xs font-medium">
<span class="text-gray-700">{{ cat.name }}</span>
<span class="font-mono text-gray-500">{{ faNumber(cat.count) }} محصول</span>
</div>
<a-progress
:percent="Math.round((cat.count / maxCategoryCount) * 100)"
:show-info="false"
:stroke-color="{ '0%': '#ffd0b5', '100%': '#ff5a00' }"
size="small"
/>
</div>
</div>
</a-card>
<!-- Chart 2: Message Status Breakdown & Trend -->
<a-card title="تحلیل پیام‌های تماس دریافتی" class="lg:col-span-5">
<!-- Message Status Chips -->
<div class="grid grid-cols-3 gap-2 text-center">
<div class="rounded-lg bg-orange-50 p-2.5">
<div class="text-lg font-black text-orange-600">{{ faNumber(data?.messageBreakdown?.new ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">جدید</div>
</div>
<div class="rounded-lg bg-blue-50 p-2.5">
<div class="text-lg font-black text-blue-600">{{ faNumber(data?.messageBreakdown?.read ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">خوانده‌شده</div>
</div>
<div class="rounded-lg bg-gray-50 p-2.5">
<div class="text-lg font-black text-gray-600">{{ faNumber(data?.messageBreakdown?.archived ?? 0) }}</div>
<div class="text-[11px] font-medium text-gray-600">بایگانی</div>
</div>
</div>
<!-- 7-Day Trend Bar Chart -->
<div class="mt-5 border-t border-gray-100 pt-4">
<div class="mb-3 flex items-center justify-between text-xs">
<span class="font-bold text-gray-700">روند پیام‌ها در ۷ روز اخیر</span>
<span class="text-gray-400">نمودار روزانه</span>
</div>
<div class="flex h-28 items-end justify-between gap-1.5 sm:gap-2 pt-4">
<div
v-for="day in data?.trend ?? []"
:key="day.date"
class="flex flex-1 flex-col items-center gap-1"
>
<span v-if="day.messages > 0" class="text-[9px] font-bold text-orange-600 mb-0.5">
{{ faNumber(day.messages) }}
</span>
<span v-else class="text-[9px] text-transparent mb-0.5 select-none">-</span>
<a-tooltip :title="`${day.dayName}: ${faNumber(day.messages)} پیام`">
<div class="relative flex w-full justify-center">
<div
class="w-full max-w-[24px] rounded-t-md transition-all duration-300"
:class="day.messages > 0 ? 'bg-orange-500 hover:bg-orange-600' : 'bg-gray-100'"
:style="{
height: `${Math.max(Math.round((day.messages / maxTrendCount) * 72), 6)}px`,
}"
/>
</div>
</a-tooltip>
<span class="text-[10px] text-gray-400 truncate max-w-[36px]">{{ day.dayName }}</span>
</div>
</div>
</div>
</a-card>
</div>
<!-- 4. Tables / Activity Lists Row -->
<div class="mt-6 grid gap-6 lg:grid-cols-2">
<!-- Recent Messages -->
<a-card title="آخرین پیام‌های تماس">
<template #extra>
<RouterLink :to="{ name: 'messages' }" class="text-xs text-orange-600 hover:underline">
مشاهده همه ←
</RouterLink>
</template>
<a-empty v-if="!data?.recentMessages.length" description="پیامی ثبت نشده است" />
<a-list v-else :data-source="data.recentMessages" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="`${item.name} — ${item.org}`"
:description="item.message || item.phone"
/>
<div class="text-left text-xs text-gray-400">
<a-tag :color="item.status === 'NEW' ? 'orange' : item.status === 'READ' ? 'blue' : 'default'">
{{ MESSAGE_STATUS_LABELS[item.status as keyof typeof MESSAGE_STATUS_LABELS] }}
</a-tag>
<div class="mt-1 font-mono text-[11px]">{{ jalali(item.createdAt) }}</div>
</div>
</a-list-item>
</template>
</a-list>
</a-card>
<!-- Recent Audit Log / Activity -->
<a-card title="آخرین فعالیت‌ها و رویدادهای سیستم">
<a-empty v-if="!data?.recentActivity.length" description="فعالیتی ثبت نشده است" />
<a-timeline v-else class="!mt-2 max-h-[360px] overflow-y-auto pe-2">
<a-timeline-item
v-for="a in data.recentActivity"
:key="a.id"
:color="a.action === 'delete' ? 'red' : a.action === 'create' ? 'green' : 'blue'"
>
<div class="text-sm">
<b>{{ activityLabel(a.action, a.entity) }}</b>
<span v-if="a.summary" class="text-gray-600"> — {{ a.summary }}</span>
</div>
<div class="mt-0.5 text-xs text-gray-400">
{{ a.user ?? "سیستم" }} · {{ jalali(a.createdAt) }}
</div>
</a-timeline-item>
</a-timeline>
</a-card>
</div>
</a-spin>
</template>