build dashboard home page and dashboard layer
This commit is contained in:
@@ -1,15 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary } from "@/src/hooks/useDashboardSummary";
|
||||
import { useEffect } from "react";
|
||||
import { clearAuth, getStoredUser } from "@/src/lib/auth";
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const { data, error, loading } = useDashboardSummary();
|
||||
const { data, error, loading } = useDashboardOverview();
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = getStoredUser();
|
||||
@@ -20,20 +26,8 @@ export default function DashboardPage() {
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const stats = data?.stats;
|
||||
const alerts = data?.alerts;
|
||||
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 (
|
||||
<section className="flex flex-col gap-6">
|
||||
|
||||
@@ -57,15 +51,14 @@ export default function DashboardPage() {
|
||||
ذكاء الأسطول في لمحة سريعة
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
|
||||
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
|
||||
عرض واضح لكل وحدة من وحدات المنصة، مع تنبيهات فورية واتجاهات الأداء.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Loading ── */}
|
||||
{loading && (
|
||||
{loading && !data && (
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12, color: "var(--color-text-dark-muted)" }}>
|
||||
<Spinner size="sm" className="text-cyan-400" />
|
||||
<span style={{ fontSize: 14 }}>جارٍ تحميل البيانات…</span>
|
||||
@@ -74,43 +67,25 @@ export default function DashboardPage() {
|
||||
|
||||
{/* ── Error ── */}
|
||||
{error && (
|
||||
<Alert type="error" message={error}
|
||||
className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
||||
<Alert type="error" message={error} className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* ── 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>
|
||||
))}
|
||||
{/* ── KPI Section: total / active / pending per entity (9 cards) ── */}
|
||||
<KpiSection entities={data?.entities ?? []} loading={loading && !data} />
|
||||
|
||||
{/* ── Alerts Section: urgent cross-entity issues ── */}
|
||||
<AlertsSection alerts={data?.alerts ?? []} loading={loading && !data} />
|
||||
|
||||
{/* ── Insights Row: recent activity log + trend charts ── */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<RecentActivityPanel logs={data?.recentActivity ?? []} loading={loading && !data} />
|
||||
<TrendChartsPanel series={data?.trends ?? []} loading={loading && !data} />
|
||||
</div>
|
||||
|
||||
{/* ── Active trips + alerts ──
|
||||
Column order in the template below stays [trips, alerts] —
|
||||
same reading order as before. In RTL this now renders
|
||||
trips on the right (read first) and alerts on the left,
|
||||
which is the correct mirror; no class change needed (see
|
||||
grid + RTL note in the project report). */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
{/* ── 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>
|
||||
@@ -136,14 +111,6 @@ export default function DashboardPage() {
|
||||
</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>
|
||||
@@ -153,32 +120,6 @@ export default function DashboardPage() {
|
||||
</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: "#FCD34D", textAlign: "start" }}>تنبيهات الالتزام</p>
|
||||
<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" }}>
|
||||
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
|
||||
<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" }}>
|
||||
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
|
||||
</div>
|
||||
))}
|
||||
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
|
||||
<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" }}>
|
||||
{String((item as Record<string, unknown>).message || "Driver expiry alert")}
|
||||
</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>
|
||||
|
||||
{/* ── Security + endpoints ── */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<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)" }}>
|
||||
@@ -189,14 +130,6 @@ export default function DashboardPage() {
|
||||
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
|
||||
</div>
|
||||
<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" }}>
|
||||
{/*
|
||||
JSON.stringify output is structurally LTR (braces,
|
||||
colons, commas read left-to-right regardless of
|
||||
locale). Wrapping it in .ltr-embed prevents the
|
||||
surrounding RTL paragraph from reordering the
|
||||
punctuation — without this, nested objects can
|
||||
render with their braces visually swapped.
|
||||
*/}
|
||||
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,20 +140,12 @@ export default function DashboardPage() {
|
||||
<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/dashboard/overview — نظرة عامة تشغيلية",
|
||||
"/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]}
|
||||
@@ -229,8 +154,6 @@ export default function DashboardPage() {
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</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 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 = {
|
||||
getSummary: (token: string) =>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
message: string;
|
||||
[key: string]: unknown;
|
||||
@@ -39,3 +76,83 @@ export interface DashboardSummaryResponse {
|
||||
message: string;
|
||||
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