build dashboard home page and dashboard layer
This commit is contained in:
@@ -1,15 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect } from "react";
|
||||||
import { useDashboardSummary } from "@/src/hooks/useDashboardSummary";
|
|
||||||
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
||||||
import { Spinner, Alert } from "@/src/Components/UI";
|
import { Spinner, Alert } from "@/src/Components/UI";
|
||||||
|
import { useDashboardOverview } from "@/src/hooks/useDashboardOverview";
|
||||||
|
import {
|
||||||
|
KpiSection,
|
||||||
|
AlertsSection,
|
||||||
|
RecentActivityPanel,
|
||||||
|
TrendChartsPanel,
|
||||||
|
QuickAccessFooter,
|
||||||
|
} from "@/src/Components/Dashboard";
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data, error, loading } = useDashboardSummary();
|
const { data, error, loading } = useDashboardOverview();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUser = getStoredUser();
|
const storedUser = getStoredUser();
|
||||||
@@ -20,20 +26,8 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, [router]);
|
}, [router]);
|
||||||
|
|
||||||
const stats = data?.stats;
|
|
||||||
const alerts = data?.alerts;
|
|
||||||
const activeTrips = data?.activeTrips || [];
|
const activeTrips = data?.activeTrips || [];
|
||||||
|
|
||||||
const summaryCards = useMemo(
|
|
||||||
() => [
|
|
||||||
{ label: "العملاء", value: stats?.clients ?? 0, accent: "#06B6D4" },
|
|
||||||
{ label: "الطلبات", value: stats?.orders ?? 0, accent: "#A78BFA" },
|
|
||||||
{ label: "الرحلات", value: stats?.trips ?? 0, accent: "#34D399" },
|
|
||||||
{ label: "المركبات و السائقين ", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), accent: "#FBBF24" },
|
|
||||||
],
|
|
||||||
[stats],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
|
|
||||||
@@ -57,15 +51,14 @@ export default function DashboardPage() {
|
|||||||
ذكاء الأسطول في لمحة سريعة
|
ذكاء الأسطول في لمحة سريعة
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
|
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
|
||||||
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
|
عرض واضح لكل وحدة من وحدات المنصة، مع تنبيهات فورية واتجاهات الأداء.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* ── Loading ── */}
|
{/* ── Loading ── */}
|
||||||
{loading && (
|
{loading && !data && (
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, color: "var(--color-text-dark-muted)" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, color: "var(--color-text-dark-muted)" }}>
|
||||||
<Spinner size="sm" className="text-cyan-400" />
|
<Spinner size="sm" className="text-cyan-400" />
|
||||||
<span style={{ fontSize: 14 }}>جارٍ تحميل البيانات…</span>
|
<span style={{ fontSize: 14 }}>جارٍ تحميل البيانات…</span>
|
||||||
@@ -74,163 +67,93 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
{/* ── Error ── */}
|
{/* ── Error ── */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert type="error" message={error}
|
<Alert type="error" message={error} className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
||||||
className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && (
|
{/* ── KPI Section: total / active / pending per entity (9 cards) ── */}
|
||||||
<>
|
<KpiSection entities={data?.entities ?? []} loading={loading && !data} />
|
||||||
{/* ── Summary cards ── */}
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
{summaryCards.map((item) => (
|
|
||||||
<article
|
|
||||||
key={item.label}
|
|
||||||
style={{
|
|
||||||
borderRadius: "var(--radius-2xl)",
|
|
||||||
border: "1px solid var(--color-border-dark)",
|
|
||||||
background: "var(--color-surface-dark-card)",
|
|
||||||
padding: "1.25rem",
|
|
||||||
boxShadow: "var(--shadow-card)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ height: 3, borderRadius: 999, background: item.accent, marginBottom: "1rem" }} />
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
|
||||||
{item.label}
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
|
||||||
{item.value}
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Active trips + alerts ──
|
{/* ── Alerts Section: urgent cross-entity issues ── */}
|
||||||
Column order in the template below stays [trips, alerts] —
|
<AlertsSection alerts={data?.alerts ?? []} loading={loading && !data} />
|
||||||
same reading order as before. In RTL this now renders
|
|
||||||
trips on the right (read first) and alerts on the left,
|
{/* ── Insights Row: recent activity log + trend charts ── */}
|
||||||
which is the correct mirror; no class change needed (see
|
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||||
grid + RTL note in the project report). */}
|
<RecentActivityPanel logs={data?.recentActivity ?? []} loading={loading && !data} />
|
||||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
<TrendChartsPanel series={data?.trends ?? []} loading={loading && !data} />
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
|
{/* ── Quick access to every entity page (minimizes navigation clicks) ── */}
|
||||||
|
<QuickAccessFooter />
|
||||||
|
|
||||||
|
{/* ── Active trips (kept from the previous layout — live operational detail) ── */}
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
|
||||||
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
|
||||||
|
</div>
|
||||||
|
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#008000" }}>
|
||||||
|
مباشر
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
||||||
|
{activeTrips.length ? activeTrips.map((trip) => (
|
||||||
|
<div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
|
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
|
<span className="ltr-embed">{trip.tripNumber}</span>
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
|
||||||
</div>
|
</div>
|
||||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#008000" }}>
|
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
|
||||||
مباشر
|
{trip.progress}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
|
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
|
||||||
{activeTrips.length ? activeTrips.map((trip) => (
|
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
|
||||||
<div key={trip.id} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
|
||||||
<span className="ltr-embed">{trip.tripNumber}</span>
|
|
||||||
</p>
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
|
|
||||||
</div>
|
|
||||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
|
|
||||||
{trip.progress}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
|
|
||||||
{/*
|
|
||||||
Progress fill: width % growing from the bar's
|
|
||||||
start edge is correct in both directions since
|
|
||||||
`width` itself is non-directional — the bar's
|
|
||||||
own inline-start (right, in RTL) is where the
|
|
||||||
fill begins, matching how the rest of the RTL
|
|
||||||
layout fills from the right. No change needed.
|
|
||||||
*/}
|
|
||||||
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد رحلات نشطة حالياً.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
|
)) : (
|
||||||
|
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد رحلات نشطة حالياً.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
{/* ── Security + endpoints ── */}
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D", textAlign: "start" }}>تنبيهات الالتزام</p>
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>التجديدات القادمة</h2>
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>الأمان</p>
|
||||||
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>لمحة الحساب</h2>
|
||||||
<div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
|
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
||||||
</div>
|
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
|
||||||
))}
|
</div>
|
||||||
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
|
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
||||||
<div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
|
||||||
{String((item as Record<string, unknown>).message || "Driver expiry alert")}
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => (
|
|
||||||
<div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
|
||||||
{String((item as Record<string, unknown>).message || "Maintenance alert")}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{!(alerts?.expiringCars?.length || alerts?.expiringDrivers?.length || alerts?.upcomingMaint?.length) && (
|
|
||||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد تنبيهات حالياً.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</div>
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
{/* ── Security + endpoints ── */}
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>خريطة الخلفية</p>
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2>
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>الأمان</p>
|
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>لمحة الحساب</h2>
|
{[
|
||||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
"/v1/dashboard/overview — نظرة عامة تشغيلية",
|
||||||
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
"/v1/client — إدارة العملاء",
|
||||||
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
|
"/v1/orders — دورة شحن الطلبات",
|
||||||
</div>
|
"/v1/trip — تنظيم الرحلات",
|
||||||
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
].map((ep) => (
|
||||||
{/*
|
<li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
||||||
JSON.stringify output is structurally LTR (braces,
|
<span className="ltr-embed">{ep.split(" — ")[0]}</span>
|
||||||
colons, commas read left-to-right regardless of
|
{" — "}
|
||||||
locale). Wrapping it in .ltr-embed prevents the
|
{ep.split(" — ")[1]}
|
||||||
surrounding RTL paragraph from reordering the
|
</li>
|
||||||
punctuation — without this, nested objects can
|
))}
|
||||||
render with their braces visually swapped.
|
</ul>
|
||||||
*/}
|
</article>
|
||||||
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
|
||||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>خريطة الخلفية</p>
|
|
||||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2>
|
|
||||||
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
|
|
||||||
{[
|
|
||||||
"/v1/dashboard/summary — نظرة عامة تشغيلية",
|
|
||||||
"/v1/client — إدارة العملاء",
|
|
||||||
"/v1/orders — دورة شحن الطلبات",
|
|
||||||
"/v1/trip — تنظيم الرحلات",
|
|
||||||
].map((ep) => (
|
|
||||||
<li key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
|
|
||||||
{/*
|
|
||||||
Each entry mixes an LTR API path with an Arabic
|
|
||||||
description. Splitting on the em-dash and
|
|
||||||
isolating only the path keeps the route string
|
|
||||||
from reading backwards while letting the Arabic
|
|
||||||
half flow naturally in the paragraph's own
|
|
||||||
direction.
|
|
||||||
*/}
|
|
||||||
<span className="ltr-embed">{ep.split(" — ")[0]}</span>
|
|
||||||
{" — "}
|
|
||||||
{ep.split(" — ")[1]}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</article>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
90
src/Components/Dashboard/AlertsSection.tsx
Normal file
90
src/Components/Dashboard/AlertsSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EmptyState, Spinner } from "@/src/Components/UI";
|
||||||
|
import { ENTITY_KPI_CONFIG } from "@/src/types/dashboard";
|
||||||
|
import type { DashboardAlert } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
function fmtRelative(iso: string): string {
|
||||||
|
const diffMs = Date.now() - new Date(iso).getTime();
|
||||||
|
const mins = Math.floor(diffMs / 60000);
|
||||||
|
if (mins < 1) return "الآن";
|
||||||
|
if (mins < 60) return `منذ ${mins} د`;
|
||||||
|
const hours = Math.floor(mins / 60);
|
||||||
|
if (hours < 24) return `منذ ${hours} س`;
|
||||||
|
return `منذ ${Math.floor(hours / 24)} يوم`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertListItem({ alert }: { alert: DashboardAlert }) {
|
||||||
|
const entityCfg = ENTITY_KPI_CONFIG.find((e) => e.key === alert.entity);
|
||||||
|
const isCritical = alert.severity === "critical";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={entityCfg?.href ?? "/dashboard"}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 12,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-raised)",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
textDecoration: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 8, height: 8, borderRadius: "50%", flexShrink: 0,
|
||||||
|
background: isCritical ? "#F87171" : "#FBBF24",
|
||||||
|
boxShadow: isCritical ? "0 0 0 4px rgba(248,113,113,0.15)" : "0 0 0 4px rgba(251,191,36,0.15)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ flex: 1, fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
|
||||||
|
{alert.message}
|
||||||
|
</span>
|
||||||
|
{entityCfg && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10, fontWeight: 700, color: entityCfg.accent,
|
||||||
|
background: `${entityCfg.accent}1A`, border: `1px solid ${entityCfg.accent}40`,
|
||||||
|
borderRadius: "var(--radius-full)", padding: "0.15rem 0.55rem", whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{entityCfg.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-dark-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{fmtRelative(alert.createdAt)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AlertsSectionProps {
|
||||||
|
alerts: DashboardAlert[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AlertsSection({ alerts, loading }: AlertsSectionProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCA5A5", textAlign: "start" }}>
|
||||||
|
تنبيهات عاجلة
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
||||||
|
يحتاج انتباهك
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
|
||||||
|
{!loading && alerts.length === 0 && <EmptyState icon="✅" title="لا توجد تنبيهات حالياً" />}
|
||||||
|
{alerts.map((alert) => (
|
||||||
|
<AlertListItem key={alert.id} alert={alert} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/Components/Dashboard/KpiSection.tsx
Normal file
121
src/Components/Dashboard/KpiSection.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Spinner } from "@/src/Components/UI";
|
||||||
|
import type { EntityKpi } from "@/src/types/dashboard";
|
||||||
|
import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
function KpiAnomalyBadge({ anomaly }: { anomaly: EntityKpi["anomaly"] }) {
|
||||||
|
if (!anomaly) return null;
|
||||||
|
const isCritical = anomaly.severity === "critical";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
marginTop: "0.75rem",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: `1px solid ${isCritical ? "rgba(248,113,113,0.35)" : "rgba(251,191,36,0.35)"}`,
|
||||||
|
background: isCritical ? "rgba(248,113,113,0.08)" : "rgba(251,191,36,0.08)",
|
||||||
|
padding: "0.5rem 0.75rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ width: 6, height: 6, borderRadius: "50%", background: isCritical ? "#F87171" : "#FBBF24", flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 11, color: isCritical ? "#FCA5A5" : "#FDE68A", lineHeight: 1.5 }}>
|
||||||
|
{anomaly.message}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCardCounts({ total, active, pending }: { total: number; active: number; pending: number }) {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: "0.875rem" }}>
|
||||||
|
<p style={{ fontSize: "2rem", fontWeight: 700, color: "var(--color-text-dark-primary)", fontFamily: "var(--font-mono)", margin: 0, textAlign: "start" }}>
|
||||||
|
{total.toLocaleString("ar-SA")}
|
||||||
|
</p>
|
||||||
|
<div style={{ marginTop: "0.625rem", display: "flex", gap: "0.75rem" }}>
|
||||||
|
<div style={{ flex: 1, borderRadius: "var(--radius-md)", background: "rgba(52,211,153,0.08)", border: "1px solid rgba(52,211,153,0.2)", padding: "0.375rem 0.625rem" }}>
|
||||||
|
<p style={{ fontSize: 10, color: "#6EE7B7", margin: 0 }}>نشط</p>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 700, color: "#A7F3D0", margin: 0, fontFamily: "var(--font-mono)" }}>
|
||||||
|
{active.toLocaleString("ar-SA")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, borderRadius: "var(--radius-md)", background: "rgba(251,191,36,0.08)", border: "1px solid rgba(251,191,36,0.2)", padding: "0.375rem 0.625rem" }}>
|
||||||
|
<p style={{ fontSize: 10, color: "#FCD34D", margin: 0 }}>معلَّق</p>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 700, color: "#FDE68A", margin: 0, fontFamily: "var(--font-mono)" }}>
|
||||||
|
{pending.toLocaleString("ar-SA")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiCard({ entity }: { entity: EntityKpi }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={entity.href}
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-card)",
|
||||||
|
padding: "1.25rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
textDecoration: "none",
|
||||||
|
transition: "transform 150ms, box-shadow 150ms",
|
||||||
|
}}
|
||||||
|
className="hover:-translate-y-0.5 hover:shadow-[0_8px_24px_rgba(6,182,212,.12)]"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 40, height: 40, borderRadius: "var(--radius-lg)",
|
||||||
|
background: `${entity.accent}1A`, border: `1px solid ${entity.accent}40`,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`ti ti-${entity.icon}`} style={{ fontSize: 18, color: entity.accent }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0 }}>
|
||||||
|
{entity.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<i className="ti ti-chevron-left" style={{ fontSize: 14, color: "var(--color-text-dark-muted)" }} aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<KpiCardCounts total={entity.total} active={entity.active} pending={entity.pending} />
|
||||||
|
<KpiAnomalyBadge anomaly={entity.anomaly} />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiSectionProps {
|
||||||
|
entities: EntityKpi[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiSection({ entities, loading }: KpiSectionProps) {
|
||||||
|
// Before the overview resolves, render the static config as zeroed cards
|
||||||
|
// so the grid never collapses to nothing.
|
||||||
|
const list = entities.length > 0 ? entities : ENTITY_KPI_CONFIG.map((cfg) => ({ ...cfg, ...EMPTY_ENTITY_KPI }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: "0.75rem" }}>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", margin: 0 }}>
|
||||||
|
نظرة عامة
|
||||||
|
</p>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))" }}>
|
||||||
|
{list.map((entity) => (
|
||||||
|
<KpiCard key={entity.key} entity={entity} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/Components/Dashboard/QuickAccessFooter.tsx
Normal file
42
src/Components/Dashboard/QuickAccessFooter.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ENTITY_KPI_CONFIG } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
export function QuickAccessFooter() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderRadius: "var(--radius-2xl)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-card)",
|
||||||
|
padding: "1rem 1.25rem",
|
||||||
|
boxShadow: "var(--shadow-card)",
|
||||||
|
display: "flex",
|
||||||
|
gap: "0.625rem",
|
||||||
|
overflowX: "auto",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ENTITY_KPI_CONFIG.map((entity) => (
|
||||||
|
<Link
|
||||||
|
key={entity.key}
|
||||||
|
href={entity.href}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex", alignItems: "center", gap: 6, flexShrink: 0,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-raised)",
|
||||||
|
padding: "0.45rem 0.875rem",
|
||||||
|
fontSize: 12, fontWeight: 600,
|
||||||
|
color: "var(--color-text-dark-primary)",
|
||||||
|
textDecoration: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<i className={`ti ti-${entity.icon}`} style={{ fontSize: 14, color: entity.accent }} aria-hidden="true" />
|
||||||
|
{entity.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
src/Components/Dashboard/RecentActivityPanel.tsx
Normal file
71
src/Components/Dashboard/RecentActivityPanel.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { EmptyState, Spinner } from "@/src/Components/UI";
|
||||||
|
import { ActionBadge } from "@/src/Components/audit/AuditTable";
|
||||||
|
import type { AuditLog } from "@/src/types/audit";
|
||||||
|
|
||||||
|
function fmtDateTime(iso: string): string {
|
||||||
|
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActivityItem({ log }: { log: AuditLog }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10,
|
||||||
|
borderRadius: "var(--radius-lg)",
|
||||||
|
border: "1px solid var(--color-border-dark)",
|
||||||
|
background: "var(--color-surface-dark-raised)",
|
||||||
|
padding: "0.75rem 1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3" style={{ minWidth: 0 }}>
|
||||||
|
<ActionBadge action={log.action} />
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-dark-primary)", margin: 0, textAlign: "start" }}>
|
||||||
|
{log.userName ?? log.userId ?? "نظام"}{log.module ? ` — ${log.module}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-dark-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{fmtDateTime(log.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecentActivityPanelProps {
|
||||||
|
logs: AuditLog[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentActivityPanel({ logs, loading }: RecentActivityPanelProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>
|
||||||
|
سجل النشاط
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
||||||
|
آخر الأنشطة
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem", display: "flex", flexDirection: "column", gap: "0.625rem" }}>
|
||||||
|
{!loading && logs.length === 0 && <EmptyState icon="🕘" title="لا يوجد نشاط حديث" />}
|
||||||
|
{logs.slice(0, 8).map((log) => (
|
||||||
|
<ActivityItem key={log.id} log={log} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{logs.length > 0 && (
|
||||||
|
<Link href="/dashboard/audit" style={{ display: "inline-flex", alignItems: "center", gap: 6, marginTop: "1rem", fontSize: 12, fontWeight: 600, color: "#67E8F9", textDecoration: "none" }}>
|
||||||
|
عرض سجل التدقيق بالكامل
|
||||||
|
<i className="ti ti-arrow-left" style={{ fontSize: 12 }} aria-hidden="true" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
81
src/Components/Dashboard/TrendChartsPanel.tsx
Normal file
81
src/Components/Dashboard/TrendChartsPanel.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EmptyState, Spinner } from "@/src/Components/UI";
|
||||||
|
import type { TrendSeries } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
function MiniTrendChart({ series }: { series: TrendSeries }) {
|
||||||
|
const { points, deltaPercent, title } = series;
|
||||||
|
if (!points.length) return null;
|
||||||
|
|
||||||
|
const width = 300;
|
||||||
|
const height = 64;
|
||||||
|
const values = points.map((p) => p.value);
|
||||||
|
const max = Math.max(...values, 1);
|
||||||
|
const min = Math.min(...values, 0);
|
||||||
|
const range = max - min || 1;
|
||||||
|
|
||||||
|
const coords = points
|
||||||
|
.map((p, i) => {
|
||||||
|
const x = (i / (points.length - 1 || 1)) * width;
|
||||||
|
const y = height - ((p.value - min) / range) * height;
|
||||||
|
return `${x},${y}`;
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
const isUp = deltaPercent >= 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "1rem" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p style={{ fontSize: 12, color: "var(--color-text-dark-muted)", margin: 0 }}>{title}</p>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontWeight: 700,
|
||||||
|
color: isUp ? "#6EE7B7" : "#FCA5A5",
|
||||||
|
background: isUp ? "rgba(52,211,153,0.1)" : "rgba(248,113,113,0.1)",
|
||||||
|
border: `1px solid ${isUp ? "rgba(52,211,153,0.3)" : "rgba(248,113,113,0.3)"}`,
|
||||||
|
borderRadius: "var(--radius-full)",
|
||||||
|
padding: "0.1rem 0.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUp ? "▲" : "▼"} {Math.abs(deltaPercent).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} width="100%" height="96" preserveAspectRatio="none" style={{ marginTop: 8 }}>
|
||||||
|
<polyline points={coords} fill="none" stroke={isUp ? "#34D399" : "#F87171"} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrendChartsPanelProps {
|
||||||
|
series: TrendSeries[];
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrendChartsPanel({ series, loading }: TrendChartsPanelProps) {
|
||||||
|
return (
|
||||||
|
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>
|
||||||
|
الاتجاهات
|
||||||
|
</p>
|
||||||
|
<h2 style={{ fontSize: "1.1rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
|
||||||
|
أداء آخر ٧ أيام
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{loading && <Spinner size="sm" className="text-cyan-400" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: "1.25rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||||
|
{!loading && series.length === 0 && (
|
||||||
|
<EmptyState icon="📈" title="لا توجد بيانات اتجاه بعد" description="سيتم عرض الرسوم البيانية عند توفر بيانات كافية." />
|
||||||
|
)}
|
||||||
|
{series.map((s) => (
|
||||||
|
<MiniTrendChart key={s.key} series={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/Components/Dashboard/index.ts
Normal file
5
src/Components/Dashboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { KpiSection } from "./KpiSection";
|
||||||
|
export { AlertsSection } from "./AlertsSection";
|
||||||
|
export { RecentActivityPanel } from "./RecentActivityPanel";
|
||||||
|
export { TrendChartsPanel } from "./TrendChartsPanel";
|
||||||
|
export { QuickAccessFooter } from "./QuickAccessFooter";
|
||||||
40
src/hooks/useDashboardOverview.ts
Normal file
40
src/hooks/useDashboardOverview.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { dashboardService } from "@/src/services/dashboard.service";
|
||||||
|
import { getStoredToken } from "@/src/lib/auth";
|
||||||
|
import type { DashboardOverview } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
data: DashboardOverview | null;
|
||||||
|
error: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches the redesigned dashboard home overview (KPIs, alerts, trends,
|
||||||
|
* recent activity). Kept as its own hook alongside useDashboardSummary.ts
|
||||||
|
* rather than replacing it — same "additive, not destructive" approach
|
||||||
|
* used elsewhere when a new backend route isn't live yet.
|
||||||
|
*/
|
||||||
|
export function useDashboardOverview(): State {
|
||||||
|
const [state, setState] = useState<State>({ data: null, error: null, loading: true });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const token = getStoredToken(); // always null now — kept for the service signature
|
||||||
|
|
||||||
|
dashboardService
|
||||||
|
.getOverview(token)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setState({ data, error: null, loading: false });
|
||||||
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
if (!cancelled) setState({ data: null, error: err.message, loading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -1,7 +1,76 @@
|
|||||||
import { get } from "./api";
|
import { get } from "./api";
|
||||||
import type { DashboardSummaryResponse } from "@/src/types/dashboard";
|
import type {
|
||||||
|
DashboardSummaryResponse,
|
||||||
|
DashboardOverview,
|
||||||
|
EntityKpi,
|
||||||
|
DashboardAlert,
|
||||||
|
} from "@/src/types/dashboard";
|
||||||
|
import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard";
|
||||||
|
|
||||||
|
// Maps the stats we DO get from /dashboard/summary onto the entity cards.
|
||||||
|
// Entities with no matching stat (users, branches, roles, audit) stay at 0
|
||||||
|
// until the real /dashboard/overview endpoint exists.
|
||||||
|
function buildEntities(stats: DashboardSummaryResponse["data"]["stats"]): EntityKpi[] {
|
||||||
|
const totals: Partial<Record<EntityKpi["key"], number>> = {
|
||||||
|
clients: stats.clients,
|
||||||
|
orders: stats.orders,
|
||||||
|
trips: stats.trips,
|
||||||
|
cars: stats.cars,
|
||||||
|
drivers: stats.drivers,
|
||||||
|
};
|
||||||
|
|
||||||
|
return ENTITY_KPI_CONFIG.map((cfg) => ({
|
||||||
|
...cfg,
|
||||||
|
...EMPTY_ENTITY_KPI,
|
||||||
|
total: totals[cfg.key] ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flattens the 3 alert groups from /dashboard/summary into DashboardAlert[].
|
||||||
|
// AlertItem only guarantees `message`, so id/createdAt are synthesized here.
|
||||||
|
function buildAlerts(alerts: DashboardSummaryResponse["data"]["alerts"]): DashboardAlert[] {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const mapGroup = (
|
||||||
|
items: { message: string }[],
|
||||||
|
entity: DashboardAlert["entity"],
|
||||||
|
severity: DashboardAlert["severity"],
|
||||||
|
prefix: string
|
||||||
|
): DashboardAlert[] =>
|
||||||
|
items.map((item, i) => ({
|
||||||
|
id: `${prefix}-${i}`,
|
||||||
|
severity,
|
||||||
|
message: item.message,
|
||||||
|
entity,
|
||||||
|
createdAt: now,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...mapGroup(alerts.expiringCars, "cars", "warning", "expiring-car"),
|
||||||
|
...mapGroup(alerts.expiringDrivers, "drivers", "warning", "expiring-driver"),
|
||||||
|
...mapGroup(alerts.upcomingMaint, "cars", "critical", "upcoming-maint"),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
export const dashboardService = {
|
export const dashboardService = {
|
||||||
getSummary: (token: string) =>
|
getSummary: (token: string) =>
|
||||||
get<DashboardSummaryResponse>("dashboard/summary", token),
|
get<DashboardSummaryResponse>("dashboard/summary", token),
|
||||||
|
|
||||||
|
// NOTE: /dashboard/overview isn't exposed by the backend yet, so this
|
||||||
|
// composes the DashboardOverview shape client-side from /dashboard/summary,
|
||||||
|
// same "additive, not destructive" pattern used elsewhere (see
|
||||||
|
// useArchivedCars/useArchivedRoles) while the real route isn't live.
|
||||||
|
getOverview: async (token: string | null): Promise<DashboardOverview> => {
|
||||||
|
const res = await get<DashboardSummaryResponse>("dashboard/summary", token ?? "");
|
||||||
|
const { stats, alerts, activeTrips, accountSecurity } = res.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entities: buildEntities(stats),
|
||||||
|
alerts: buildAlerts(alerts),
|
||||||
|
trends: [], // no trend endpoint yet
|
||||||
|
recentActivity: [], // no audit-log endpoint composed yet
|
||||||
|
activeTrips,
|
||||||
|
accountSecurity,
|
||||||
|
};
|
||||||
|
},
|
||||||
};
|
};
|
||||||
@@ -6,6 +6,43 @@ export interface DashboardStats {
|
|||||||
drivers: number;
|
drivers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface ActiveTrip {
|
||||||
|
id: string;
|
||||||
|
tripNumber: string;
|
||||||
|
title: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountSecurity {
|
||||||
|
lastLogin: string | null;
|
||||||
|
requestMeta: Record<string, unknown> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummary {
|
||||||
|
stats: DashboardStats;
|
||||||
|
alerts: {
|
||||||
|
expiringCars: AlertItem[];
|
||||||
|
expiringDrivers: AlertItem[];
|
||||||
|
upcomingMaint: AlertItem[];
|
||||||
|
};
|
||||||
|
activeTrips: ActiveTrip[];
|
||||||
|
accountSecurity: AccountSecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardSummaryResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: DashboardSummary;
|
||||||
|
}
|
||||||
|
export interface DashboardStats {
|
||||||
|
clients: number;
|
||||||
|
orders: number;
|
||||||
|
trips: number;
|
||||||
|
cars: number;
|
||||||
|
drivers: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlertItem {
|
export interface AlertItem {
|
||||||
message: string;
|
message: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
@@ -38,4 +75,84 @@ export interface DashboardSummaryResponse {
|
|||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
data: DashboardSummary;
|
data: DashboardSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Overview (home page redesign) ────────────────────────────────────────
|
||||||
|
// NOTE: /dashboard/overview isn't exposed by the backend yet — see the
|
||||||
|
// redesign plan doc. dashboardService.getOverview() composes this shape
|
||||||
|
// client-side from endpoints we already call elsewhere, same pattern as
|
||||||
|
// useArchivedCars/useArchivedRoles aggregating client-side around a
|
||||||
|
// missing backend route.
|
||||||
|
|
||||||
|
import type { AuditLog } from "./audit";
|
||||||
|
|
||||||
|
export type DashboardEntityKey =
|
||||||
|
| "users" | "drivers" | "cars" | "trips" | "orders"
|
||||||
|
| "clients" | "branches" | "roles" | "audit";
|
||||||
|
|
||||||
|
export interface EntityKpi {
|
||||||
|
key: DashboardEntityKey;
|
||||||
|
label: string;
|
||||||
|
/** tabler icon suffix only, e.g. "car" -> rendered as `ti ti-car` */
|
||||||
|
icon: string;
|
||||||
|
/** hex used for the icon chip + accent strip */
|
||||||
|
accent: string;
|
||||||
|
href: string;
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
pending: number;
|
||||||
|
anomaly: {
|
||||||
|
severity: "warning" | "critical";
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_ENTITY_KPI: Pick<EntityKpi, "total" | "active" | "pending" | "anomaly"> = {
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
pending: 0,
|
||||||
|
anomaly: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DashboardAlert {
|
||||||
|
id: string;
|
||||||
|
severity: "warning" | "critical";
|
||||||
|
message: string;
|
||||||
|
entity: DashboardEntityKey;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendPoint {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendSeries {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
points: TrendPoint[];
|
||||||
|
deltaPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOverview {
|
||||||
|
entities: EntityKpi[];
|
||||||
|
alerts: DashboardAlert[];
|
||||||
|
trends: TrendSeries[];
|
||||||
|
recentActivity: AuditLog[];
|
||||||
|
activeTrips: ActiveTrip[];
|
||||||
|
accountSecurity: AccountSecurity;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static entity config — icon/label/href/accent defined exactly once,
|
||||||
|
// shared by KpiSection and QuickAccessFooter (per the redesign plan).
|
||||||
|
export const ENTITY_KPI_CONFIG: Array<Pick<EntityKpi, "key" | "label" | "icon" | "accent" | "href">> = [
|
||||||
|
{ key: "users", label: "المستخدمون", icon: "users", accent: "#818CF8", href: "/dashboard/users" },
|
||||||
|
{ key: "drivers", label: "السائقون", icon: "steering-wheel", accent: "#FBBF24", href: "/dashboard/drivers" },
|
||||||
|
{ key: "cars", label: "المركبات", icon: "car", accent: "#34D399", href: "/dashboard/cars" },
|
||||||
|
{ key: "trips", label: "الرحلات", icon: "route", accent: "#06B6D4", href: "/dashboard/trips" },
|
||||||
|
{ key: "orders", label: "الطلبات", icon: "package", accent: "#A78BFA", href: "/dashboard/orders" },
|
||||||
|
{ key: "clients", label: "العملاء", icon: "user-circle", accent: "#F472B6", href: "/dashboard/clients" },
|
||||||
|
{ key: "branches", label: "الفروع", icon: "building-store", accent: "#60A5FA", href: "/dashboard/branches" },
|
||||||
|
{ key: "roles", label: "الأدوار", icon: "shield-lock", accent: "#F87171", href: "/dashboard/roles" },
|
||||||
|
{ key: "audit", label: "التدقيق", icon: "report-money", accent: "#FDE68A", href: "/dashboard/audit" },
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user