342 lines
16 KiB
Vue
342 lines
16 KiB
Vue
<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>
|