update dashboard home

This commit is contained in:
m7amedez5511
2026-08-30 16:18:54 +03:00
parent 0658e59d9b
commit 1e4d9c5b59
5 changed files with 102 additions and 30 deletions

View File

@@ -29,26 +29,18 @@ function KpiAnomalyBadge({ anomaly }: { anomaly: EntityKpi["anomaly"] }) {
); );
} }
function KpiCardCounts({ total, active, pending }: { total: number; active: number; pending: number }) { // Step 8: KpiCardCounts no longer takes/renders `active`/`pending` — it now
// only receives and displays `total`. The active/pending mini-boxes (and
// their inline styling) have been removed entirely; the total figure keeps
// its original styling/position exactly as before, so the card's overall
// layout is unchanged.
function KpiCardCounts({ total }: { total: number }) {
return ( return (
<div style={{ marginTop: "0.875rem" }}> <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" }}> <p style={{ fontSize: "2rem", fontWeight: 700, color: "var(--color-text-dark-primary)", fontFamily: "var(--font-mono)", margin: 0, textAlign: "start" }}>
{total.toLocaleString("ar-SA")} {total.toLocaleString("ar-SA")}
</p> </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> </div>
); );
} }
@@ -71,6 +63,7 @@ function KpiCard({ entity }: { entity: EntityKpi }) {
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div
style={{ style={{
width: 40, height: 40, borderRadius: "var(--radius-lg)", width: 40, height: 40, borderRadius: "var(--radius-lg)",
@@ -87,7 +80,9 @@ function KpiCard({ entity }: { entity: EntityKpi }) {
<i className="ti ti-chevron-left" style={{ fontSize: 14, color: "var(--color-text-dark-muted)" }} aria-hidden="true" /> <i className="ti ti-chevron-left" style={{ fontSize: 14, color: "var(--color-text-dark-muted)" }} aria-hidden="true" />
</div> </div>
<KpiCardCounts total={entity.total} active={entity.active} pending={entity.pending} /> {/* Step 9: only `total` is passed now — `active`/`pending` props removed
from this call site along with the corresponding fields on EntityKpi. */}
<KpiCardCounts total={entity.total} />
<KpiAnomalyBadge anomaly={entity.anomaly} /> <KpiAnomalyBadge anomaly={entity.anomaly} />
</Link> </Link>
); );
@@ -101,6 +96,7 @@ interface KpiSectionProps {
export function KpiSection({ entities, loading }: KpiSectionProps) { export function KpiSection({ entities, loading }: KpiSectionProps) {
// Before the overview resolves, render the static config as zeroed cards // Before the overview resolves, render the static config as zeroed cards
// so the grid never collapses to nothing. // so the grid never collapses to nothing.
const list = entities.length > 0 ? entities : ENTITY_KPI_CONFIG.map((cfg) => ({ ...cfg, ...EMPTY_ENTITY_KPI })); const list = entities.length > 0 ? entities : ENTITY_KPI_CONFIG.map((cfg) => ({ ...cfg, ...EMPTY_ENTITY_KPI }));
return ( return (

View File

@@ -1,5 +1,3 @@
// src/lib/locationHierarchy.ts
//
// Step 1: Pure lookup helpers over SAUDI_LOCATION_HIERARCHY. No side // Step 1: Pure lookup helpers over SAUDI_LOCATION_HIERARCHY. No side
// effects, no fetch — the dataset is static and imported directly, so // effects, no fetch — the dataset is static and imported directly, so
// these functions are safe to call from both client components and the // these functions are safe to call from both client components and the

View File

@@ -0,0 +1,35 @@
import { get } from "./api";
import type { AuditLog } from "@/src/types/audit";
// Step 1: New service, following the exact same shape/pattern as the other
// list services in this file's siblings (userService.getAll, branchService.
// getAll, roleService.getAll) — GET with page/limit, unwrap { data: { data,
// meta|pagination } } into a clean { items, total } result. No audit service
// previously existed even though src/types/audit.ts and AuditTable already
// consume AuditLog, so this fills that gap using the established contract.
export interface AuditListResponse {
success: boolean;
message: string;
responseAt: string;
data: {
data: AuditLog[];
meta?: { total: number; page: number; limit: number; totalPages: number };
pagination?: { total: number; page: number; pages: number };
};
}
function buildAuditQuery(page: number, limit: number): string {
return `?page=${page}&limit=${limit}`;
}
export const auditService = {
// The real backend endpoint is /api/v1/audit and it responds with
// { data: { data: [...], meta: { total, page, limit, totalPages } } }.
getAll: async (page = 1, limit = 1): Promise<{ items: AuditLog[]; total: number }> => {
const res = await get<AuditListResponse>(`audit${buildAuditQuery(page, limit)}`);
return {
items: res.data?.data ?? [],
total: res.data?.meta?.total ?? res.data?.pagination?.total ?? 0,
};
},
};

View File

@@ -6,17 +6,35 @@ import type {
DashboardAlert, DashboardAlert,
} from "@/src/types/dashboard"; } from "@/src/types/dashboard";
import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard"; import { ENTITY_KPI_CONFIG, EMPTY_ENTITY_KPI } from "@/src/types/dashboard";
// Step 3: Reuse the exact same services already used by UserTable,
// BranchTable, and RoleTable (via useUsers/useBranches/useRoles) instead of
// writing new fetch logic — this keeps the dashboard's data-fetching
// pattern identical to the rest of the app, per the task instructions.
import { userService } from "./user.service";
import { branchService } from "./branch.service";
import { roleService } from "./role.service";
import { auditService } from "./audit.service";
// Maps the stats we DO get from /dashboard/summary onto the entity cards. // Step 4: buildEntities now takes a second argument with the totals that
// Entities with no matching stat (users, branches, roles, audit) stay at 0 // don't come from /dashboard/summary (users, branches, roles, audit) and
// until the real /dashboard/overview endpoint exists. // merges them into the same totals map the clients/orders/trips/cars/
function buildEntities(stats: DashboardSummaryResponse["data"]["stats"]): EntityKpi[] { // drivers stats already flow through — no change to the entity list shape
// or ordering (ENTITY_KPI_CONFIG is untouched), so layout stays identical.
function buildEntities(
stats: DashboardSummaryResponse["data"]["stats"],
extraTotals: { users: number; branches: number; roles: number; audit: number },
): EntityKpi[] {
const totals: Partial<Record<EntityKpi["key"], number>> = { const totals: Partial<Record<EntityKpi["key"], number>> = {
clients: stats.clients, clients: stats.clients,
orders: stats.orders, orders: stats.orders,
trips: stats.trips, trips: stats.trips,
cars: stats.cars, cars: stats.cars,
drivers: stats.drivers, drivers: stats.drivers,
users: extraTotals.users,
branches: extraTotals.branches,
roles: extraTotals.roles,
audit: extraTotals.audit,
}; };
return ENTITY_KPI_CONFIG.map((cfg) => ({ return ENTITY_KPI_CONFIG.map((cfg) => ({
@@ -45,6 +63,7 @@ function buildAlerts(alerts: DashboardSummaryResponse["data"]["alerts"]): Dashbo
createdAt: now, createdAt: now,
})); }));
return [ return [
...mapGroup(alerts.expiringCars, "cars", "warning", "expiring-car"), ...mapGroup(alerts.expiringCars, "cars", "warning", "expiring-car"),
...mapGroup(alerts.expiringDrivers, "drivers", "warning", "expiring-driver"), ...mapGroup(alerts.expiringDrivers, "drivers", "warning", "expiring-driver"),
@@ -57,15 +76,37 @@ export const dashboardService = {
get<DashboardSummaryResponse>("dashboard/summary", token), get<DashboardSummaryResponse>("dashboard/summary", token),
// NOTE: /dashboard/overview isn't exposed by the backend yet, so this // NOTE: /dashboard/overview isn't exposed by the backend yet, so this
// composes the DashboardOverview shape client-side from /dashboard/summary, // composes the DashboardOverview shape client-side from /dashboard/summary
// same "additive, not destructive" pattern used elsewhere (see // plus the users/branches/roles/audit totals fetched below, same
// "additive, not destructive" pattern used elsewhere (see
// useArchivedCars/useArchivedRoles) while the real route isn't live. // useArchivedCars/useArchivedRoles) while the real route isn't live.
getOverview: async (token: string | null): Promise<DashboardOverview> => { getOverview: async (token: string | null): Promise<DashboardOverview> => {
const res = await get<DashboardSummaryResponse>("dashboard/summary", token ?? ""); const res = await get<DashboardSummaryResponse>("dashboard/summary", token ?? "");
const { stats, alerts, activeTrips, accountSecurity } = res.data; const { stats, alerts, activeTrips, accountSecurity } = res.data;
// Step 5: Fetch the four missing totals in parallel (mirrors the
// Promise.all-free-but-concurrent pattern already used in
// OrderFormModal, which loads clients + trips together for the same
// modal mount). page=1 + empty search is exactly what UserTable /
// BranchTable / RoleTable already send on first load — we just read
// back `.total` and discard the `.items` page since the KPI card only
// needs the count. Each call is defensively caught so one failing
// endpoint doesn't blank out the other three cards.
const [usersResult, branchesResult, rolesResult, auditResult] = await Promise.all([
userService.getAll(1, "", token).catch(() => ({ items: [], total: 0, pages: 1 })),
branchService.getAll(1, "", token).catch(() => ({ items: [], total: 0, pages: 1 })),
roleService.getAll(1, "", token).catch(() => ({ items: [], total: 0, pages: 1 })),
auditService.getAll(1, 1).catch(() => ({ items: [], total: 0 })),
]);
return { return {
entities: buildEntities(stats), entities: buildEntities(stats, {
users: usersResult.total,
branches: branchesResult.total,
roles: rolesResult.total,
audit: auditResult.total,
}),
alerts: buildAlerts(alerts), alerts: buildAlerts(alerts),
trends: [], // no trend endpoint yet trends: [], // no trend endpoint yet
recentActivity: [], // no audit-log endpoint composed yet recentActivity: [], // no audit-log endpoint composed yet

View File

@@ -90,6 +90,10 @@ export type DashboardEntityKey =
| "users" | "drivers" | "cars" | "trips" | "orders" | "users" | "drivers" | "cars" | "trips" | "orders"
| "clients" | "branches" | "roles" | "audit"; | "clients" | "branches" | "roles" | "audit";
// Step 6: Removed `active` and `pending` from EntityKpi — the stat cards
// (KpiSection.tsx) no longer break totals down into active/pending, they
// only display the total count, so the type no longer carries fields that
// are never rendered.
export interface EntityKpi { export interface EntityKpi {
key: DashboardEntityKey; key: DashboardEntityKey;
label: string; label: string;
@@ -99,18 +103,16 @@ export interface EntityKpi {
accent: string; accent: string;
href: string; href: string;
total: number; total: number;
active: number;
pending: number;
anomaly: { anomaly: {
severity: "warning" | "critical"; severity: "warning" | "critical";
message: string; message: string;
} | null; } | null;
} }
export const EMPTY_ENTITY_KPI: Pick<EntityKpi, "total" | "active" | "pending" | "anomaly"> = { // Step 7: EMPTY_ENTITY_KPI narrowed to match the trimmed EntityKpi shape —
// only `total` and `anomaly` are defaultable now.
export const EMPTY_ENTITY_KPI: Pick<EntityKpi, "total" | "anomaly"> = {
total: 0, total: 0,
active: 0,
pending: 0,
anomaly: null, anomaly: null,
}; };