Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
201 lines
6.7 KiB
Vue
201 lines
6.7 KiB
Vue
<script setup lang="ts">
|
||
import { computed } from 'vue';
|
||
import { RouterLink } from 'vue-router';
|
||
import type { Component } from 'vue';
|
||
import {
|
||
AlertOutlined,
|
||
CheckCircleOutlined,
|
||
ExclamationCircleOutlined,
|
||
InfoCircleOutlined,
|
||
LeftOutlined,
|
||
} from '@ant-design/icons-vue';
|
||
import { toFaDigits } from '@/lib/jalali';
|
||
import type { HomeDashboardAlertDto } from '@mento/shared';
|
||
|
||
const props = defineProps<{
|
||
alerts: HomeDashboardAlertDto[];
|
||
}>();
|
||
|
||
type Tone = HomeDashboardAlertDto['tone'];
|
||
|
||
type AlertRow = {
|
||
key: string;
|
||
tone: Tone;
|
||
title: string;
|
||
subtitle?: string;
|
||
link: string;
|
||
};
|
||
|
||
const TONE_ORDER: Record<Tone, number> = {
|
||
urgent: 0,
|
||
warning: 1,
|
||
info: 2,
|
||
success: 3,
|
||
};
|
||
|
||
const rows = computed<AlertRow[]>(() =>
|
||
[...(props.alerts ?? [])]
|
||
.map((a) => {
|
||
const parts = a.text.split(/\s*[—\-–]\s*|\n/);
|
||
const title = parts[0]?.trim() || a.text;
|
||
const subtitle = parts.slice(1).join(' ').trim() || undefined;
|
||
return {
|
||
key: a.key,
|
||
tone: a.tone,
|
||
title,
|
||
subtitle,
|
||
link: a.link || '/',
|
||
};
|
||
})
|
||
.sort((a, b) => TONE_ORDER[a.tone] - TONE_ORDER[b.tone]),
|
||
);
|
||
|
||
const urgentCount = computed(() => rows.value.filter((r) => r.tone === 'urgent').length);
|
||
const warningCount = computed(() => rows.value.filter((r) => r.tone === 'warning').length);
|
||
|
||
function toneIcon(tone: Tone): Component {
|
||
if (tone === 'urgent') return ExclamationCircleOutlined;
|
||
if (tone === 'warning') return AlertOutlined;
|
||
if (tone === 'success') return CheckCircleOutlined;
|
||
return InfoCircleOutlined;
|
||
}
|
||
|
||
function toneLabel(tone: Tone) {
|
||
if (tone === 'urgent') return 'فوری';
|
||
if (tone === 'warning') return 'هشدار';
|
||
if (tone === 'success') return 'موفق';
|
||
return 'اطلاع';
|
||
}
|
||
|
||
function actionLabel(tone: Tone) {
|
||
if (tone === 'urgent') return 'اقدام';
|
||
if (tone === 'warning') return 'بررسی';
|
||
return 'مشاهده';
|
||
}
|
||
|
||
function rowClass(tone: Tone) {
|
||
if (tone === 'urgent') return 'border-rose-200/80 bg-rose-50/50 hover:bg-rose-50';
|
||
if (tone === 'warning') return 'border-amber-200/80 bg-amber-50/50 hover:bg-amber-50';
|
||
if (tone === 'success') return 'border-emerald-200/70 bg-emerald-50/40 hover:bg-emerald-50/80';
|
||
return 'border-slate-200/80 bg-slate-50/70 hover:bg-slate-100/80';
|
||
}
|
||
|
||
function barClass(tone: Tone) {
|
||
if (tone === 'urgent') return 'bg-rose-500';
|
||
if (tone === 'warning') return 'bg-amber-500';
|
||
if (tone === 'success') return 'bg-emerald-500';
|
||
return 'bg-blue-500';
|
||
}
|
||
|
||
function iconWrapClass(tone: Tone) {
|
||
if (tone === 'urgent') return 'bg-rose-100 text-rose-600';
|
||
if (tone === 'warning') return 'bg-amber-100 text-amber-600';
|
||
if (tone === 'success') return 'bg-emerald-100 text-emerald-600';
|
||
return 'bg-blue-100 text-blue-600';
|
||
}
|
||
|
||
function badgeClass(tone: Tone) {
|
||
if (tone === 'urgent') return 'bg-rose-100 text-rose-700';
|
||
if (tone === 'warning') return 'bg-amber-100 text-amber-700';
|
||
if (tone === 'success') return 'bg-emerald-100 text-emerald-700';
|
||
return 'bg-blue-100 text-blue-700';
|
||
}
|
||
|
||
function actionClass(tone: Tone) {
|
||
if (tone === 'urgent') return 'text-rose-600 group-hover:text-rose-700';
|
||
if (tone === 'warning') return 'text-amber-600 group-hover:text-amber-700';
|
||
if (tone === 'success') return 'text-emerald-600 group-hover:text-emerald-700';
|
||
return 'text-blue-600 group-hover:text-blue-700';
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section
|
||
v-if="rows.length"
|
||
class="bg-white rounded-2xl border border-slate-200/80 p-5 shadow-sm flex flex-col h-[330px]"
|
||
>
|
||
<header class="flex items-center justify-between gap-3 pb-3 mb-3 border-b border-slate-100 shrink-0">
|
||
<div class="flex items-center gap-2.5 min-w-0">
|
||
<span class="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
|
||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75" />
|
||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-rose-500" />
|
||
</span>
|
||
<div class="min-w-0">
|
||
<h3 class="m-0 font-bold text-slate-800 text-base leading-6">نیازمند توجه</h3>
|
||
<p class="m-0 mt-0.5 text-xs text-slate-400 truncate">
|
||
موارد با اولویت بالاتر اول آمدهاند
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-1.5 shrink-0">
|
||
<span
|
||
v-if="urgentCount"
|
||
class="text-[11px] font-bold bg-rose-50 text-rose-600 border border-rose-200/70 px-2 py-0.5 rounded-full"
|
||
>
|
||
{{ toFaDigits(urgentCount) }} فوری
|
||
</span>
|
||
<span
|
||
v-if="warningCount"
|
||
class="text-[11px] font-bold bg-amber-50 text-amber-700 border border-amber-200/70 px-2 py-0.5 rounded-full"
|
||
>
|
||
{{ toFaDigits(warningCount) }} هشدار
|
||
</span>
|
||
<span class="text-[11px] font-semibold text-slate-500 bg-slate-100 px-2 py-0.5 rounded-full">
|
||
{{ toFaDigits(rows.length) }} مورد
|
||
</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="flex-1 overflow-y-auto space-y-2 pe-1 min-h-0">
|
||
<RouterLink
|
||
v-for="item in rows"
|
||
:key="item.key"
|
||
:to="item.link"
|
||
class="group relative overflow-hidden rounded-xl border px-3 py-2.5 flex items-center gap-3 transition-all"
|
||
:class="rowClass(item.tone)"
|
||
>
|
||
<span
|
||
class="absolute inset-y-0 right-0 w-1 rounded-s-full"
|
||
:class="barClass(item.tone)"
|
||
aria-hidden="true"
|
||
/>
|
||
|
||
<span
|
||
class="w-8 h-8 rounded-lg flex items-center justify-center shrink-0 text-sm"
|
||
:class="iconWrapClass(item.tone)"
|
||
aria-hidden="true"
|
||
>
|
||
<component :is="toneIcon(item.tone)" />
|
||
</span>
|
||
|
||
<div class="min-w-0 flex-1">
|
||
<h4 class="m-0 font-bold text-[13px] text-slate-800 leading-5 truncate">
|
||
{{ item.title }}
|
||
</h4>
|
||
<div class="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<span
|
||
class="text-[10px] font-bold px-1.5 py-0.5 rounded shrink-0"
|
||
:class="badgeClass(item.tone)"
|
||
>
|
||
{{ toneLabel(item.tone) }}
|
||
</span>
|
||
<span v-if="item.subtitle" class="text-[11px] text-slate-500 truncate">
|
||
{{ item.subtitle }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
class="shrink-0 flex items-center gap-1 text-[11px] font-semibold"
|
||
:class="actionClass(item.tone)"
|
||
>
|
||
<span class="opacity-0 group-hover:opacity-100 transition-opacity hidden sm:inline">
|
||
{{ actionLabel(item.tone) }}
|
||
</span>
|
||
<LeftOutlined class="text-slate-400 group-hover:-translate-x-0.5 transition-transform" />
|
||
</div>
|
||
</RouterLink>
|
||
</div>
|
||
</section>
|
||
</template>
|