build dashboard home page and dashboard layer
This commit is contained in:
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;
|
||||
@@ -38,4 +75,84 @@ export interface DashboardSummaryResponse {
|
||||
success: boolean;
|
||||
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