create user pages crud also update pages ui build order and car ,driver first page interfase create model layer to componants
This commit is contained in:
@@ -1,25 +1,25 @@
|
||||
"use client";
|
||||
// ─────────────────────────────────────────────
|
||||
// page.tsx
|
||||
// نقطة الدخول — تحقق من الجلسة وتوجيه المستخدم
|
||||
// ─────────────────────────────────────────────
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { loadSession, type ClientSession } from "@/utils/helperFun";
|
||||
import RegisterStep from "../../Components/Client/client";
|
||||
import DashboardStep from "../../Components/Order/order";
|
||||
import Logo from "@/utils/logo";
|
||||
import { loadSession, type ClientSession } from "@/lib/session";
|
||||
import RegisterStep from "../..//Components/Client/client";
|
||||
import DashboardStep from "../../Components/Order/order";
|
||||
import AddressStep from "../../Components/Client_Adress/clientAdress";
|
||||
import Logo from "../../utils/logo";
|
||||
import { Spinner } from "../../Components/UI";
|
||||
|
||||
type Step = "bootstrap" | "register" | "dashboard";
|
||||
type Step = "bootstrap" | "register" | "address" | "dashboard";
|
||||
|
||||
/* شريط التنقل */
|
||||
function Navbar({ session }: { session: ClientSession | null }) {
|
||||
return (
|
||||
<nav className="bg-white border-b border-[#E5E7EB] flex items-center justify-between px-5 h-14 sticky top-0 z-30">
|
||||
<nav
|
||||
aria-label="Client portal navigation"
|
||||
className="bg-white border-b border-[#E5E7EB] flex items-center justify-between px-5 h-14 sticky top-0 z-30"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{session?.name && (
|
||||
<span className="text-[12px] text-[#6B7280] hidden sm:block">{session.name}</span>
|
||||
)}
|
||||
<div className="w-8 h-8 rounded-full bg-[#EBF3FF] flex items-center justify-center">
|
||||
<div aria-hidden="true" className="w-8 h-8 rounded-full bg-[#EBF3FF] flex items-center justify-center">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#1A73E8" strokeWidth="2">
|
||||
<circle cx="12" cy="8" r="4"/>
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/>
|
||||
@@ -31,35 +31,26 @@ function Navbar({ session }: { session: ClientSession | null }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* شاشة التحميل */
|
||||
function BootstrapScreen() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-3">
|
||||
<div className="w-10 h-10 rounded-full border-4 border-[#1A73E8] border-t-transparent animate-spin" />
|
||||
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل بيانات الجلسة…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ClientOrderPage() {
|
||||
const [step, setStep] = useState<Step>("bootstrap");
|
||||
const [session, setSession] = useState<ClientSession | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = loadSession();
|
||||
|
||||
if (saved?.id && saved.name) {
|
||||
// user has valid session → dashboard
|
||||
setSession(saved);
|
||||
setStep("dashboard");
|
||||
} else {
|
||||
// new or invalid session → register
|
||||
setStep("register");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRegistered = useCallback((s: ClientSession) => {
|
||||
setSession(s);
|
||||
setStep("address");
|
||||
}, []);
|
||||
|
||||
const handleAddressComplete = useCallback(() => {
|
||||
setStep("dashboard");
|
||||
}, []);
|
||||
|
||||
@@ -68,18 +59,28 @@ export default function ClientOrderPage() {
|
||||
className="min-h-screen bg-[#ECEEF2]"
|
||||
dir="rtl"
|
||||
lang="ar"
|
||||
style={{ fontFamily: "'Cairo', 'IBM Plex Sans Arabic', Tahoma, sans-serif" }}
|
||||
style={{ fontFamily: "var(--font-sans)" }}
|
||||
>
|
||||
<Navbar session={session} />
|
||||
|
||||
<div className="max-w-lg mx-auto px-4 py-8">
|
||||
<div className="bg-white border border-[#E5E7EB] rounded-2xl p-6 shadow-sm">
|
||||
{step === "bootstrap" && <BootstrapScreen />}
|
||||
|
||||
{step === "bootstrap" && (
|
||||
<div className="flex flex-col items-center justify-center py-10 gap-3" role="status">
|
||||
<Spinner size="lg" />
|
||||
<p className="text-[13px] text-[#6B7280]">جارٍ تحميل بيانات الجلسة…</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "register" && (
|
||||
<RegisterStep onSuccess={handleRegistered} />
|
||||
)}
|
||||
|
||||
{step === "address" && session && (
|
||||
<AddressStep session={session} onSuccess={handleAddressComplete} />
|
||||
)}
|
||||
|
||||
{step === "dashboard" && session && (
|
||||
<DashboardStep session={session} />
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,417 @@
|
||||
export default function CarsPage() {
|
||||
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Cars module is ready for the fleet management dashboard.</section>;
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStoredToken } from "../../lib/auth";
|
||||
import { get } from "../../services/api";
|
||||
import { Spinner, Alert } from "../../Components/UI";
|
||||
|
||||
interface Car {
|
||||
id: string;
|
||||
manufacturer: string;
|
||||
model: string;
|
||||
year: number;
|
||||
color?: string;
|
||||
plateNumber: string;
|
||||
plateLetters: string;
|
||||
currentStatus: "Active" | "InMaintenance" | "InTrip" | "Inactive";
|
||||
insuranceStatus?: "Valid" | "Expired" | "NotInsured";
|
||||
insuranceExpiryDate?: string;
|
||||
registrationExpiryDate?: string;
|
||||
branch?: { name: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
data: Car[];
|
||||
pagination: { total: number; page: number; pages: number };
|
||||
meta?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<
|
||||
Car["currentStatus"],
|
||||
{ label: string; color: string; bg: string; border: string }
|
||||
> = {
|
||||
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
|
||||
InMaintenance: {
|
||||
label: "صيانة",
|
||||
color: "#854D0E",
|
||||
bg: "#FFFBEB",
|
||||
border: "#FDE68A",
|
||||
},
|
||||
InTrip: {
|
||||
label: "في رحلة",
|
||||
color: "#1E40AF",
|
||||
bg: "#EFF6FF",
|
||||
border: "#BFDBFE",
|
||||
},
|
||||
Inactive: {
|
||||
label: "غير نشط",
|
||||
color: "#64748B",
|
||||
bg: "#F1F5F9",
|
||||
border: "#E2E8F0",
|
||||
},
|
||||
};
|
||||
|
||||
const INS_MAP: Record<string, { label: string; color: string }> = {
|
||||
Valid: { label: "سارٍ", color: "#16A34A" },
|
||||
Expired: { label: "منتهي", color: "#DC2626" },
|
||||
NotInsured: { label: "غير مؤمَّن", color: "#D97706" },
|
||||
};
|
||||
|
||||
function fmtDate(iso?: string) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-muted)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
export default function CarsPage() {
|
||||
const [cars, setCars] = useState<Car[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
get<ApiResponse>(`cars${query}`, token)
|
||||
.then((res) => {
|
||||
const payload = res.data ?? res;
|
||||
setCars(payload.data ?? []);
|
||||
setTotal(payload.meta?.total ?? 0);
|
||||
setPages(payload.meta?.pages ?? 1);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search]);
|
||||
|
||||
return (
|
||||
<section
|
||||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||||
>
|
||||
{/* Header */}
|
||||
<header
|
||||
style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
padding: "1.5rem 2rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
letterSpacing: "0.3em",
|
||||
textTransform: "uppercase",
|
||||
color: "#2563EB",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
إدارة الأسطول
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: "1.5rem",
|
||||
fontWeight: 700,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
السيارات
|
||||
</h1>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "0.25rem",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
إجمالي{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||
{total}
|
||||
</strong>{" "}
|
||||
مركبة في الأسطول
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ position: "relative", width: "100%", maxWidth: 288 }}>
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 12,
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
width: 16,
|
||||
height: 16,
|
||||
color: "var(--color-text-hint)",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="بحث بالماركة أو اللوحة..."
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
dir="rtl"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 40,
|
||||
paddingRight: 36,
|
||||
paddingLeft: 12,
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-primary)",
|
||||
outline: "none",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={() => setError(null)} />
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div style={cardStyle}>
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 1fr",
|
||||
...thStyle,
|
||||
}}
|
||||
>
|
||||
<span>السيارة</span>
|
||||
<span>اللوحة</span>
|
||||
<span>الفرع</span>
|
||||
<span>الحالة</span>
|
||||
<span>التأمين</span>
|
||||
<span style={{ textAlign: "center" }}>انتهاء الاستمارة</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 12,
|
||||
padding: "4rem 0",
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : cars.length === 0 ? (
|
||||
<p
|
||||
style={{
|
||||
textAlign: "center",
|
||||
padding: "4rem 0",
|
||||
fontSize: 13,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
لا توجد نتائج {search && `لـ "${search}"`}
|
||||
</p>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{cars.map((c, i) => {
|
||||
const status = STATUS_MAP[c.currentStatus];
|
||||
const ins = c.insuranceStatus ? INS_MAP[c.insuranceStatus] : null;
|
||||
return (
|
||||
<li
|
||||
key={c.id}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.5fr 1fr 1fr 1fr 1fr",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background:
|
||||
i % 2 !== 0
|
||||
? "var(--color-surface-muted)"
|
||||
: "transparent",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{c.manufacturer} {c.model}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{c.year}
|
||||
{c.color ? ` · ${c.color}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.08em",
|
||||
color: "#2563EB",
|
||||
}}
|
||||
>
|
||||
{c.plateLetters} {c.plateNumber}
|
||||
</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>
|
||||
{c.branch?.name ?? "—"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${status.border}`,
|
||||
background: status.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
color: status.color,
|
||||
width: "fit-content",
|
||||
}}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: ins?.color ?? "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{ins?.label ?? "—"}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
textAlign: "center",
|
||||
fontSize: 11,
|
||||
color: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{fmtDate(c.registrationExpiryDate)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div
|
||||
dir="rtl"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
padding: "0.875rem 1.5rem",
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||
{page}
|
||||
</strong>{" "}
|
||||
من{" "}
|
||||
<strong style={{ color: "var(--color-text-primary)" }}>
|
||||
{pages}
|
||||
</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{
|
||||
label: "السابق",
|
||||
action: () => setPage((p) => Math.max(1, p - 1)),
|
||||
disabled: page === 1,
|
||||
},
|
||||
{
|
||||
label: "التالي",
|
||||
action: () => setPage((p) => Math.min(pages, p + 1)),
|
||||
disabled: page === pages,
|
||||
},
|
||||
].map((btn) => (
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={btn.action}
|
||||
disabled={btn.disabled}
|
||||
style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
padding: "0.375rem 0.875rem",
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-secondary)",
|
||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||
opacity: btn.disabled ? 0.4 : 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,130 @@
|
||||
// File: app/components/layout/Sidebar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
// FIX: Import getStoredUser so the sidebar reads the authenticated user's
|
||||
// name and role from localStorage instead of showing hardcoded placeholders.
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { getStoredUser } from "../../../lib/auth";
|
||||
import Logo from "../../../utils/logo";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", permission: "read-dashboard" },
|
||||
{ href: "/users", label: "Users", permission: "read-user" },
|
||||
{ href: "/cars", label: "Cars", permission: "read-car" },
|
||||
{ href: "/drivers", label: "Drivers", permission: "read-driver" },
|
||||
{ href: "/clients", label: "Clients", permission: "read-client" },
|
||||
{ href: "/orders", label: "Orders", permission: "read-order" },
|
||||
{ href: "/trips", label: "Trips", permission: "read-trip" },
|
||||
{ href: "/branches", label: "Branches", permission: "read-branch" },
|
||||
{ href: "/roles", label: "Roles", permission: "read-role" },
|
||||
{ href: "/audit", label: "Audit", permission: "read-audit" },
|
||||
const navSections = [
|
||||
{
|
||||
label: "الرئيسية",
|
||||
items: [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: "ti-layout-dashboard", permission: "read-dashboard" },
|
||||
{ href: "/users", label: "Users", icon: "ti-users", permission: "read-user" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "الأسطول",
|
||||
items: [
|
||||
{ href: "/cars", label: "Cars", icon: "ti-car", permission: "read-car" },
|
||||
{ href: "/drivers", label: "Drivers", icon: "ti-steering-wheel",permission: "read-driver" },
|
||||
{ href: "/trips", label: "Trips", icon: "ti-route", permission: "read-trip" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "العمليات",
|
||||
items: [
|
||||
{ href: "/orders", label: "Orders", icon: "ti-package", permission: "read-order" },
|
||||
{ href: "/clients", label: "Clients", icon: "ti-users-group", permission: "read-client" },
|
||||
{ href: "/branches", label: "Branches", icon: "ti-building", permission: "read-branch" },
|
||||
{ href: "/roles", label: "Roles", icon: "ti-shield", permission: "read-role" },
|
||||
{ href: "/audit", label: "Audit", icon: "ti-clipboard-list", permission: "read-audit" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
// FIX: Read the stored user on render. suppressHydrationWarning handles
|
||||
// the SSR/CSR mismatch since localStorage is browser-only.
|
||||
const user = getStoredUser();
|
||||
const permissions = user?.permissions ?? navItems.map((i) => i.permission);
|
||||
const pathname = usePathname();
|
||||
const user = getStoredUser();
|
||||
const permissions = user?.permissions ?? navSections.flatMap(s => s.items.map(i => i.permission));
|
||||
|
||||
return (
|
||||
<aside
|
||||
suppressHydrationWarning
|
||||
className="hidden w-72 shrink-0 border-r border-white/10 bg-slate-950/80 p-6 lg:flex lg:flex-col"
|
||||
className="hidden lg:flex lg:flex-col"
|
||||
style={{
|
||||
width: "var(--sidebar-width)",
|
||||
flexShrink: 0,
|
||||
background: "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)",
|
||||
minHeight: "100vh",
|
||||
padding: "20px 12px",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">Logistics</p>
|
||||
<h2 className="mt-3 text-xl font-semibold text-white">Ops Console</h2>
|
||||
<p className="mt-2 text-sm text-slate-300">
|
||||
Fleet, clients, orders, and compliance in one place.
|
||||
</p>
|
||||
{/* Logo */}
|
||||
<div style={{ marginBottom: 28 }}>
|
||||
<Logo white />
|
||||
</div>
|
||||
|
||||
<nav className="mt-8 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
// FIX: Check the real user permissions array fetched from stored auth.
|
||||
// Dashboard is always visible regardless of permissions.
|
||||
const hasPermission =
|
||||
item.permission === "read-dashboard" ||
|
||||
permissions.includes(item.permission);
|
||||
if (!hasPermission) return null;
|
||||
{/* Nav sections */}
|
||||
<nav
|
||||
style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0 }}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{navSections.map((section) => (
|
||||
<div key={section.label} style={{ marginBottom: 8 }}>
|
||||
<p style={{
|
||||
fontSize: 9,
|
||||
letterSpacing: "0.14em",
|
||||
textTransform: "uppercase",
|
||||
color: "rgba(255,255,255,0.45)",
|
||||
padding: "8px 8px 4px",
|
||||
fontWeight: 600,
|
||||
}}>
|
||||
{section.label}
|
||||
</p>
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="flex items-center justify-between rounded-2xl border border-white/8 bg-slate-900/70 px-4 py-3 text-sm text-slate-200 transition hover:border-cyan-400/30 hover:bg-slate-800"
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span className="text-xs text-slate-400">→</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
{section.items.map((item) => {
|
||||
const allowed = item.permission === "read-dashboard"
|
||||
|| permissions.includes(item.permission);
|
||||
if (!allowed) return null;
|
||||
|
||||
const active = pathname === item.href
|
||||
|| (item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 9,
|
||||
padding: "7px 8px",
|
||||
borderRadius: 8,
|
||||
fontSize: 13,
|
||||
fontWeight: active ? 500 : 400,
|
||||
color: active ? "#FFFFFF" : "rgba(255,255,255,0.55)",
|
||||
background: active ? "rgba(255,255,255,0.15)" : "transparent",
|
||||
textDecoration: "none",
|
||||
transition: "var(--transition-base)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
<i
|
||||
className={`ti ${item.icon}`}
|
||||
aria-hidden="true"
|
||||
style={{ fontSize: 16 }}
|
||||
/>
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="mt-auto rounded-3xl border border-emerald-400/20 bg-emerald-400/10 p-4 text-sm text-emerald-50">
|
||||
<p className="text-xs uppercase tracking-[0.25em] text-emerald-100">Authenticated</p>
|
||||
{/* FIX: Show the real user name and role from the stored auth session,
|
||||
replacing the previous hardcoded "Operations user" / "Signed in" text. */}
|
||||
<p suppressHydrationWarning className="mt-2 font-semibold">
|
||||
{/* User badge */}
|
||||
<div style={{
|
||||
background: "rgba(255,255,255,0.12)",
|
||||
borderRadius: 10,
|
||||
padding: "10px 12px",
|
||||
marginTop: 8,
|
||||
}}>
|
||||
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0 }}>
|
||||
{user?.name ?? user?.userName ?? "—"}
|
||||
</p>
|
||||
<p suppressHydrationWarning className="text-xs text-emerald-100/90">
|
||||
{user?.role ? `Role: ${user.role}` : "Signed in"}
|
||||
<p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2 }}>
|
||||
{user?.role ?? "Signed in"}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -1,68 +1,72 @@
|
||||
// File: app/components/layout/Topbar.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
// FIX: Import both clearAuth and getStoredUser so the topbar can show the
|
||||
// authenticated user's role and handle logout properly.
|
||||
import { clearAuth, getStoredUser } from "../../../lib/auth";
|
||||
|
||||
export function Topbar() {
|
||||
const router = useRouter();
|
||||
|
||||
function handleLogout() {
|
||||
// FIX: clearAuth already existed and is correct — it removes both the
|
||||
// token and user from localStorage, fully ending the session.
|
||||
clearAuth();
|
||||
router.replace("/login");
|
||||
}
|
||||
|
||||
// FIX: Read the stored user to display their real role in the header
|
||||
// instead of the hardcoded "Manager" placeholder.
|
||||
const user = getStoredUser();
|
||||
// Capitalize the role label for display (e.g. "admin" → "Admin")
|
||||
const roleLabel = user?.role
|
||||
? user.role.charAt(0).toUpperCase() + user.role.slice(1)
|
||||
: "—";
|
||||
|
||||
return (
|
||||
<header
|
||||
suppressHydrationWarning
|
||||
className="border-b border-white/10 bg-slate-950/70 px-4 py-4 lg:px-6"
|
||||
style={{
|
||||
height: "var(--topbar-height)",
|
||||
background: "#FFFFFF",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
padding: "0 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.35em] text-cyan-200">
|
||||
Operations dashboard
|
||||
</p>
|
||||
<h1 className="text-xl font-semibold text-white lg:text-2xl">
|
||||
Logistics delivery management
|
||||
</h1>
|
||||
<div>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
لوحة التحكم
|
||||
</p>
|
||||
<p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0 }}>
|
||||
Operations dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--color-text-secondary)",
|
||||
background: "var(--color-surface-muted)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "4px 12px",
|
||||
}}
|
||||
>
|
||||
{user?.role ?? "—"}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 hover:bg-slate-800"
|
||||
>
|
||||
Overview
|
||||
</Link>
|
||||
{/* FIX: Replace the hardcoded "Manager" string with the actual
|
||||
authenticated user's role read from localStorage. */}
|
||||
<div
|
||||
suppressHydrationWarning
|
||||
className="hidden rounded-full border border-white/10 bg-slate-900/80 px-4 py-2 text-sm text-slate-200 md:block"
|
||||
>
|
||||
{roleLabel}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="rounded-full bg-rose-500/10 px-4 py-2 text-sm text-rose-100 hover:bg-rose-500/20"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "#DC2626",
|
||||
background: "#FEF2F2",
|
||||
border: "1px solid #FECACA",
|
||||
borderRadius: "var(--radius-full)",
|
||||
padding: "5px 14px",
|
||||
cursor: "pointer",
|
||||
transition: "var(--transition-base)",
|
||||
}}
|
||||
>
|
||||
تسجيل الخروج
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Sidebar } from "../components/layout/Sidebar";
|
||||
import { Topbar } from "../components/layout/Topbar";
|
||||
import { Topbar } from "../components/layout/Topbar";
|
||||
|
||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top,#0f172a_0%,#111827_45%,#020617_100%)] text-slate-100">
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 p-4 lg:p-6">{children}</main>
|
||||
</div>
|
||||
<div className="flex min-h-screen" style={{ background: "var(--color-surface-muted)" }}>
|
||||
<Sidebar />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,160 +2,219 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { getDashboardSummary, type DashboardSummaryResponse } from "../../lib/api";
|
||||
import { clearAuth, getStoredToken, getStoredUser } from "../../lib/auth";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useDashboardSummary } from "../../hooks/useDashboardSummary";
|
||||
import { clearAuth, getStoredUser } from "../../lib/auth";
|
||||
import { Spinner, Alert } from "../../Components/UI";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [data, setData] = useState<DashboardSummaryResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { data, error, loading } = useDashboardSummary();
|
||||
|
||||
useEffect(() => {
|
||||
if (!getStoredToken()) {
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
// FIX: Implement role-based access control by checking the authenticated user's role from localStorage. Only users with "user" or "admin" roles can access the dashboard. If the role is missing or not allowed, clear the auth state and redirect to login.
|
||||
const storedUser = getStoredUser(); // add this import at top of file
|
||||
const storedUser = getStoredUser();
|
||||
const role = typeof storedUser?.role === "string" ? storedUser.role : "";
|
||||
if (!role || ["driver", "سائق"].includes(role)) {
|
||||
clearAuth(); // add this import at top of file
|
||||
clearAuth();
|
||||
router.replace("/login");
|
||||
return;
|
||||
}
|
||||
let mounted = true;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const summary = await getDashboardSummary();
|
||||
if (mounted) setData(summary);
|
||||
} catch (err) {
|
||||
if (mounted) setError(err instanceof Error ? err.message : "Unable to load dashboard data.");
|
||||
} finally {
|
||||
if (mounted) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [router]);
|
||||
|
||||
const stats = data?.data?.stats;
|
||||
const alerts = data?.data?.alerts;
|
||||
const activeTrips = data?.data?.activeTrips || [];
|
||||
const stats = data?.stats;
|
||||
const alerts = data?.alerts;
|
||||
const activeTrips = data?.activeTrips || [];
|
||||
|
||||
const summaryCards = useMemo(
|
||||
() => [
|
||||
{ label: "العملاء", value: stats?.clients ?? 0, tone: "from-cyan-500 to-sky-600" },
|
||||
{ label: "الطلبات", value: stats?.orders ?? 0, tone: "from-violet-500 to-fuchsia-600" },
|
||||
{ label: "الرحلات", value: stats?.trips ?? 0, tone: "from-emerald-500 to-green-600" },
|
||||
{ label: "المركبات", value: (stats?.cars ?? 0) + (stats?.drivers ?? 0), tone: "from-amber-400 to-orange-500" },
|
||||
{ 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 (
|
||||
<main className="min-h-screen bg-[radial-gradient(circle_at_top,#0f172a_0%,#111827_45%,#020617_100%)] text-slate-100">
|
||||
<section className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-6 py-10 lg:px-8">
|
||||
<header className="rounded-3xl border border-white/10 bg-white/6 p-8 shadow-2xl shadow-slate-950/30 backdrop-blur-xl">
|
||||
<p className="text-sm uppercase tracking-[0.35em] text-cyan-200">لوحة العمليات</p>
|
||||
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-white lg:text-4xl">ذكاء الأسطول في لمحة سريعة</h1>
|
||||
<p className="mt-3 max-w-3xl text-slate-300">هذه الشاشة متوافقة مع نقطة ملخص لوحة الإدارة في الخلفية وتوفر عرضًا واضحًا لطلب الأسطول، وتنبيهات السلامة، وتقدم الرحلات.</p>
|
||||
</div>
|
||||
<Link href="/" className="inline-flex items-center rounded-full border border-cyan-400/30 bg-cyan-400/10 px-4 py-2 text-sm text-cyan-100 transition hover:bg-cyan-400/20">العودة إلى بوابة العميل</Link>
|
||||
<section className="flex flex-col gap-6">
|
||||
|
||||
{/* ── Header ── */}
|
||||
<header
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.04)",
|
||||
border: "1px solid var(--color-border-dark)",
|
||||
borderRadius: "var(--radius-2xl)",
|
||||
padding: "2rem",
|
||||
boxShadow: "var(--shadow-overlay)",
|
||||
backdropFilter: "blur(16px)",
|
||||
}}
|
||||
>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.35em", textTransform: "uppercase", color: "#67E8F9" }}>
|
||||
لوحة العمليات
|
||||
</p>
|
||||
<div className="mt-4 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0 }}>
|
||||
ذكاء الأسطول في لمحة سريعة
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6 }}>
|
||||
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
<Link
|
||||
href="/"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: "1px solid rgba(103,232,249,0.30)",
|
||||
background: "rgba(103,232,249,0.08)",
|
||||
padding: "0.5rem 1rem",
|
||||
fontSize: 13,
|
||||
color: "#CFFAFE",
|
||||
textDecoration: "none",
|
||||
whiteSpace: "nowrap",
|
||||
transition: "var(--transition-base)",
|
||||
}}
|
||||
>
|
||||
العودة إلى بوابة العميل
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <p className="text-slate-300">جارٍ تحميل مقاييس الخلفية…</p>}
|
||||
{error && <p className="rounded-2xl border border-rose-400/30 bg-rose-500/10 p-4 text-rose-100">{error}</p>}
|
||||
{/* ── Loading ── */}
|
||||
{loading && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
<section className="grid gap-6 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((item) => (
|
||||
<article key={item.label} className="rounded-3xl border border-white/10 bg-slate-900/80 p-5 shadow-xl shadow-slate-950/20">
|
||||
<div className={`h-2 rounded-full bg-linear-to-r ${item.tone}`} />
|
||||
<p className="mt-4 text-sm uppercase tracking-[0.25em] text-slate-400">{item.label}</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-white">{item.value}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
{/* ── Error ── */}
|
||||
{error && (
|
||||
<Alert type="error" message={error}
|
||||
className="border-rose-400/30 bg-rose-500/10 text-rose-100" />
|
||||
)}
|
||||
|
||||
<section className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-cyan-200">الرحلات النشطة</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">تقدم الرحلات</h2>
|
||||
</div>
|
||||
<span className="rounded-full bg-emerald-400/10 px-3 py-1 text-xs text-emerald-200">مباشر من /dashboard/summary</span>
|
||||
{!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)" }}>
|
||||
{item.label}
|
||||
</p>
|
||||
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>
|
||||
{item.value}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Active trips + alerts ── */}
|
||||
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
|
||||
<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" }}>الرحلات النشطة</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>تقدم الرحلات</h2>
|
||||
</div>
|
||||
<div className="mt-6 space-y-4">
|
||||
{activeTrips.length ? activeTrips.map((trip) => (
|
||||
<div key={trip.id} className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-white">{trip.tripNumber}</p>
|
||||
<p className="text-sm text-slate-300">{trip.title}</p>
|
||||
</div>
|
||||
<span className="rounded-full bg-cyan-400/10 px-3 py-1 text-xs text-cyan-100">{trip.progress}%</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 rounded-full bg-slate-700">
|
||||
<div className="h-2 rounded-full bg-linear-to-r from-cyan-400 to-emerald-400" style={{ width: `${trip.progress}%` }} />
|
||||
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}>
|
||||
مباشر
|
||||
</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>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)" }}>{trip.tripNumber}</p>
|
||||
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>{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>
|
||||
)) : <p className="text-sm text-slate-300">لا توجد رحلات في-progress حالياً.</p>}
|
||||
</div>
|
||||
</article>
|
||||
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
|
||||
<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)" }}>لا توجد رحلات نشطة حالياً.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-amber-200">تنبيهات الالتزام</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">التجديدات القادمة</h2>
|
||||
<div className="mt-6 space-y-4 text-sm text-slate-200">
|
||||
{(alerts?.expiringCars || []).slice(0, 3).map((item, index) => (
|
||||
<div key={`car-${index}`} className="rounded-2xl border border-amber-400/20 bg-amber-400/8 p-4">{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}</div>
|
||||
))}
|
||||
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, index) => (
|
||||
<div key={`driver-${index}`} className="rounded-2xl border border-rose-400/20 bg-rose-500/8 p-4">{String((item as Record<string, unknown>).message || "Driver expiry alert")}</div>
|
||||
))}
|
||||
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, index) => (
|
||||
<div key={`maint-${index}`} className="rounded-2xl border border-cyan-400/20 bg-cyan-400/8 p-4">{String((item as Record<string, unknown>).message || "Maintenance alert")}</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<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" }}>تنبيهات الالتزام</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>التجديدات القادمة</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)" }}>
|
||||
{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)" }}>
|
||||
{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)" }}>
|
||||
{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)" }}>لا توجد تنبيهات حالياً.</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section className="grid gap-6 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-violet-200">الأمان</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">لمحة الحساب</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-800/80 p-4 text-sm text-slate-200">آخر تسجيل دخول: {data?.data?.accountSecurity?.lastLogin || "لم يتم العثور على حدث تسجيل دخول"}</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-800/80 p-4 text-sm text-slate-200">بيانات الجهاز: {data?.data?.accountSecurity?.requestMeta ? JSON.stringify(data.data.accountSecurity.requestMeta) : "غير متاح"}</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)" }}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD" }}>الأمان</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>لمحة الحساب</h2>
|
||||
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
||||
<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)" }}>
|
||||
آخر تسجيل دخول: {data?.accountSecurity?.lastLogin || "—"}
|
||||
</div>
|
||||
</article>
|
||||
<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)" }}>
|
||||
بيانات الجهاز: {data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 shadow-xl shadow-slate-950/20">
|
||||
<p className="text-sm uppercase tracking-[0.25em] text-emerald-200">خريطة الخلفية</p>
|
||||
<h2 className="mt-2 text-xl font-semibold text-white">النقاط النهائية المستندة إلى Swagger</h2>
|
||||
<ul className="mt-6 grid gap-3 text-sm text-slate-200">
|
||||
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/dashboard/summary — نظرة عامة تشغيلية</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/client — إدارة العملاء</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/orders — دورة شحن الطلبات</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-slate-800/80 p-4">/v1/trip — تنظيم الرحلات</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
<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" }}>خريطة الخلفية</p>
|
||||
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>النقاط النهائية</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)" }}>
|
||||
{ep}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,247 @@
|
||||
export default function DriversPage() {
|
||||
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Drivers module is ready for the workforce and report tools.</section>;
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStoredToken } from "../../lib/auth";
|
||||
import { get, post, put, del } from "../../services/api";
|
||||
import { Spinner, Alert } from "../../Components/UI";
|
||||
|
||||
interface Driver {
|
||||
id: string;
|
||||
name: string;
|
||||
userName?: string;
|
||||
phone: string;
|
||||
nationality?: string;
|
||||
licenseNumber?: string;
|
||||
licenseExpiry?: string;
|
||||
nationalIdExpiry?: string;
|
||||
status: "Active" | "Inactive" | "InTrip" | "Suspended";
|
||||
isActive: boolean;
|
||||
branch?: { name: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
data: Driver[];
|
||||
pagination: { total: number; page: number; pages: number };
|
||||
meta?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<Driver["status"], { label: string; color: string; bg: string; border: string }> = {
|
||||
Active: { label: "نشط", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
|
||||
InTrip: { label: "في رحلة", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
|
||||
Inactive: { label: "غير نشط", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
|
||||
Suspended: { label: "موقوف", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA" },
|
||||
};
|
||||
|
||||
function fmtDate(iso?: string) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function expirySoon(iso?: string) {
|
||||
if (!iso) return false;
|
||||
return (new Date(iso).getTime() - Date.now()) / 86_400_000 <= 90;
|
||||
}
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-muted)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
export default function DriversPage() {
|
||||
const [drivers, setDrivers] = useState<Driver[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const query = `?page=${page}&limit=10${search ? `&search=${encodeURIComponent(search)}` : ""}`;
|
||||
get<ApiResponse>(`driver${query}`, token)
|
||||
.then((res) => {
|
||||
const payload = res.data ?? res;
|
||||
setDrivers(payload.data ?? []);
|
||||
setTotal(payload.meta?.total ?? 0);
|
||||
setPages(payload.meta?.pages ?? 1);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search]);
|
||||
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
padding: "1.5rem 2rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||
إدارة الكوادر
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
السائقون
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> سائق مسجل
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ position: "relative", width: "100%", maxWidth: 288 }}>
|
||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }} dir="rtl"
|
||||
style={{
|
||||
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, color: "var(--color-text-primary)",
|
||||
outline: "none", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{/* Table */}
|
||||
<div style={cardStyle}>
|
||||
<div dir="rtl" style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 1fr",
|
||||
...thStyle,
|
||||
}}>
|
||||
<span>السائق</span>
|
||||
<span>الفرع</span>
|
||||
<span>الجنسية</span>
|
||||
<span>انتهاء الرخصة</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>انتهاء الهوية</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : drivers.length === 0 ? (
|
||||
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
لا توجد نتائج {search && `لـ "${search}"`}
|
||||
</p>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{drivers.map((d, i) => {
|
||||
const status = STATUS_MAP[d.status];
|
||||
const licWarn = expirySoon(d.licenseExpiry);
|
||||
const idWarn = expirySoon(d.nationalIdExpiry);
|
||||
return (
|
||||
<li key={d.id} style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "2fr 1.2fr 1fr 1fr 1fr 1fr",
|
||||
alignItems: "center", gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>{d.name}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>{d.phone}</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 600,
|
||||
color: licWarn ? "#D97706" : "var(--color-text-secondary)",
|
||||
}}>
|
||||
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
|
||||
</span>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center",
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${status.border}`,
|
||||
background: status.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 600, color: status.color,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{
|
||||
textAlign: "center", fontSize: 12, fontWeight: 600,
|
||||
color: idWarn ? "#D97706" : "var(--color-text-secondary)",
|
||||
}}>
|
||||
{idWarn && "⚠ "}{fmtDate(d.nationalIdExpiry)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
|
||||
style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
padding: "0.375rem 0.875rem",
|
||||
fontSize: 12, color: "var(--color-text-secondary)",
|
||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||
opacity: btn.disabled ? 0.4 : 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@import "../styles/tokens.css";
|
||||
@import "tailwindcss";
|
||||
|
||||
|
||||
/* ── Base resets ────────────────────────────────────── */
|
||||
*,
|
||||
*::before,
|
||||
@@ -27,11 +28,60 @@ body {
|
||||
/* ── Tailwind theme bridge ──────────────────────────── */
|
||||
/* Expose CSS variables as Tailwind color utilities. */
|
||||
@theme inline {
|
||||
--color-background: var(--color-surface-sunken);
|
||||
--color-foreground: var(--color-text-primary);
|
||||
--color-brand: var(--color-brand-600);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-primary-*: initial;
|
||||
--color-sand-*: initial;
|
||||
--color-gold-*: initial;
|
||||
--color-slate-*: initial;
|
||||
|
||||
--color-primary-50: var(--color-primary-50);
|
||||
--color-primary-100: var(--color-primary-100);
|
||||
--color-primary-200: var(--color-primary-200);
|
||||
--color-primary-300: var(--color-primary-300);
|
||||
--color-primary-400: var(--color-primary-400);
|
||||
--color-primary-500: var(--color-primary-500);
|
||||
--color-primary-600: var(--color-primary-600);
|
||||
--color-primary-700: var(--color-primary-700);
|
||||
--color-primary-800: var(--color-primary-800);
|
||||
--color-primary-900: var(--color-primary-900);
|
||||
|
||||
--color-sand-50: var(--color-sand-50);
|
||||
--color-sand-100: var(--color-sand-100);
|
||||
--color-sand-200: var(--color-sand-200);
|
||||
--color-sand-300: var(--color-sand-300);
|
||||
--color-sand-400: var(--color-sand-400);
|
||||
--color-sand-500: var(--color-sand-500);
|
||||
--color-sand-600: var(--color-sand-600);
|
||||
--color-sand-700: var(--color-sand-700);
|
||||
--color-sand-800: var(--color-sand-800);
|
||||
--color-sand-900: var(--color-sand-900);
|
||||
|
||||
--color-gold-50: var(--color-gold-50);
|
||||
--color-gold-100: var(--color-gold-100);
|
||||
--color-gold-200: var(--color-gold-200);
|
||||
--color-gold-300: var(--color-gold-300);
|
||||
--color-gold-400: var(--color-gold-400);
|
||||
--color-gold-500: var(--color-gold-500);
|
||||
--color-gold-600: var(--color-gold-600);
|
||||
--color-gold-700: var(--color-gold-700);
|
||||
--color-gold-800: var(--color-gold-800);
|
||||
--color-gold-900: var(--color-gold-900);
|
||||
|
||||
--color-slate-50: var(--color-slate-50);
|
||||
--color-slate-100: var(--color-slate-100);
|
||||
--color-slate-200: var(--color-slate-200);
|
||||
--color-slate-300: var(--color-slate-300);
|
||||
--color-slate-400: var(--color-slate-400);
|
||||
--color-slate-500: var(--color-slate-500);
|
||||
--color-slate-600: var(--color-slate-600);
|
||||
--color-slate-700: var(--color-slate-700);
|
||||
--color-slate-800: var(--color-slate-800);
|
||||
--color-slate-900: var(--color-slate-900);
|
||||
|
||||
--color-background: var(--color-sand-50);
|
||||
--color-foreground: var(--color-slate-900);
|
||||
--color-brand: var(--color-primary-500);
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
}
|
||||
|
||||
/* ── Focus ring utility ─────────────────────────────── */
|
||||
|
||||
@@ -4,38 +4,32 @@ import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { loginUser } from "../../lib/auth";
|
||||
import Logo from "@/utils/logo";
|
||||
import { Spinner } from "@/Components/UI";
|
||||
import Logo from "../../utils/logo";
|
||||
import { Button } from "../../Components/UI";
|
||||
import { Input } from "../../Components/UI";
|
||||
import { Alert } from "../../Components/UI";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [identity, setIdentity] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await loginUser(identity, password);
|
||||
|
||||
window.location.href = "/dashboard"; // Use full page reload to ensure all client state is reset after login
|
||||
console.log("Login successful", { identity });
|
||||
window.location.href = "/dashboard";
|
||||
} catch (err) {
|
||||
// FIX: Surface specific error messages from auth.ts (invalid credentials,
|
||||
// role denial, network failure) directly to the user.
|
||||
if (err instanceof Error) {
|
||||
// Network/fetch failure signals
|
||||
if (
|
||||
err.message.includes("fetch") ||
|
||||
err.message.includes("network") ||
|
||||
err.message.includes("Failed to fetch")
|
||||
) {
|
||||
setError(
|
||||
"Unable to connect. Please check your internet connection and try again later.",
|
||||
);
|
||||
const msg = err.message.toLowerCase();
|
||||
if (msg.includes("fetch") || msg.includes("network") || msg.includes("failed to fetch")) {
|
||||
setError("Unable to connect. Please check your internet connection and try again later.");
|
||||
} else {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -50,45 +44,37 @@ export default function LoginPage() {
|
||||
return (
|
||||
<main className="min-h-screen bg-slate-100 text-slate-900">
|
||||
<section className="grid min-h-screen w-full lg:grid-cols-[1fr_1fr]">
|
||||
|
||||
{/* ── Left panel ─────────────────────────────────── */}
|
||||
<aside className="relative overflow-hidden bg-linear-to-br from-blue-900 via-blue-600 to-blue-700 px-8 py-10 text-white lg:px-12 lg:py-14">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
|
||||
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.18) 1px, transparent 1px)",
|
||||
backgroundSize: "40px 40px",
|
||||
}}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<Logo />
|
||||
<Logo white={true} />
|
||||
<h1 className="mt-10 max-w-md text-[36px] font-bold leading-tight tracking-[-0.5px]">
|
||||
اللوجستيات ببساطة، والتسليم بثقة.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-md text-[15px] leading-7 text-white/75">
|
||||
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب
|
||||
من خلال تسجيل دخول آمن واحد.
|
||||
راقب عمليات التسليم، وقم بتنظيم السائقين، وحافظ على رؤية كل طلب من خلال تسجيل دخول آمن واحد.
|
||||
</p>
|
||||
<div className="mt-8 space-y-3">
|
||||
{[
|
||||
[
|
||||
"التنسيق اللحظي",
|
||||
"تتبع كل طلب من الاستلام إلى التسليم في مكان واحد.",
|
||||
],
|
||||
[
|
||||
"وصول آمن",
|
||||
"الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة.",
|
||||
],
|
||||
[
|
||||
"عمليات سريعة",
|
||||
"استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات.",
|
||||
],
|
||||
["التنسيق اللحظي", "تتبع كل طلب من الاستلام إلى التسليم في مكان واحد."],
|
||||
["وصول آمن", "الجلسات المعتمدة على الصلاحيات تبقي مسارات العميل والإدارة منفصلة."],
|
||||
["عمليات سريعة", "استخدم لوحة الإدارة لمراجعة الحالة والتنبيهات وحركة الرحلات."],
|
||||
].map(([title, desc]) => (
|
||||
<article
|
||||
key={title}
|
||||
className="flex items-start gap-3 rounded-[10px] border border-white/15 bg-white/10 p-3"
|
||||
>
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm">
|
||||
<div aria-hidden="true" className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-white/15 text-sm">
|
||||
•
|
||||
</div>
|
||||
<div>
|
||||
@@ -99,71 +85,61 @@ export default function LoginPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[12px] text-white/50">
|
||||
مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.
|
||||
</p>
|
||||
<p className="text-[12px] text-white/50">مصمم لفرق العمليات التي تحتاج إلى الوضوح والسرعة والثقة.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ── Right panel ────────────────────────────────── */}
|
||||
<section className="flex items-center justify-center bg-slate-50 px-6 py-10 lg:px-8">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full max-w-100 rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm"
|
||||
className="w-full max-w-[400px] rounded-[14px] border border-slate-200 bg-white p-8 shadow-sm"
|
||||
noValidate
|
||||
>
|
||||
<p className="text-[28px] font-bold text-slate-900">
|
||||
مرحبًا بعودتك
|
||||
</p>
|
||||
<p className="mt-1 text-[14px] text-slate-500">
|
||||
سجل الدخول لإدارة عمليات التسليم
|
||||
</p>
|
||||
<p className="text-[28px] font-bold text-slate-900">مرحبًا بعودتك</p>
|
||||
<p className="mt-1 text-[14px] text-slate-500">سجل الدخول لإدارة عمليات التسليم</p>
|
||||
|
||||
<label className="mt-6 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500">
|
||||
البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم
|
||||
<input
|
||||
<div className="mt-6 flex flex-col gap-4">
|
||||
<Input
|
||||
label="البريد الإلكتروني أو رقم الهاتف أو اسم المستخدم"
|
||||
autoComplete="username"
|
||||
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
|
||||
value={identity}
|
||||
onChange={(event) => setIdentity(event.target.value)}
|
||||
onChange={(e) => setIdentity(e.target.value)}
|
||||
placeholder="name@company.com / 05xxxxxxxx / username"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="mt-4 block text-[11px] font-bold uppercase tracking-[0.5px] text-slate-500">
|
||||
كلمة المرور
|
||||
<input
|
||||
<Input
|
||||
label="كلمة المرور"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="mt-2 h-11 w-full rounded-[9px] border border-slate-200 bg-white px-3 text-[14px] text-slate-900 outline-none transition focus:border-blue-500 focus:ring-4 focus:ring-blue-100"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-[12px] text-blue-600 hover:underline"
|
||||
>
|
||||
<Link href="/forgot-password" className="text-[12px] text-blue-600 hover:underline">
|
||||
هل نسيت كلمة المرور؟
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="mt-4 rounded-[9px] border border-red-200 bg-red-50 p-3 text-[13px] text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{error && (
|
||||
<div className="mt-4">
|
||||
<Alert type="error" message={error} onClose={() => setError(null)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-6 inline-flex h-11.5 w-full items-center justify-center rounded-[9px] bg-blue-600 text-[15px] font-semibold text-white transition hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
loading={loading}
|
||||
fullWidth
|
||||
className="mt-6 h-11"
|
||||
>
|
||||
{loading ? <Spinner /> : "تسجيل الدخول"}
|
||||
</button>
|
||||
{loading ? "جاري تسجيل الدخول…" : "تسجيل الدخول"}
|
||||
</Button>
|
||||
|
||||
<p className="mt-4 text-center text-[13px] text-slate-500">
|
||||
ليس لديك حساب؟{" "}
|
||||
@@ -176,4 +152,4 @@ export default function LoginPage() {
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,281 @@
|
||||
export default function OrdersPage() {
|
||||
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Orders module is ready for the order lifecycle and transfer workflows.</section>;
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { getStoredToken } from "../../lib/auth";
|
||||
import { get } from "../../services/api";
|
||||
import { Spinner, Alert } from "../../Components/UI";
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
shipmentNumber: string;
|
||||
recipientName: string;
|
||||
recipientPhone: string;
|
||||
currentStatus: "Created" | "Assigned" | "InTransit" | "Delivered" | "Returned" | "Cancelled";
|
||||
totalPrice?: number;
|
||||
paymentMethod?: string;
|
||||
paymentStatus?: "Pending" | "Paid" | "Failed" | "Refunded";
|
||||
client?: { name: string };
|
||||
trip?: { tripNumber: string };
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
data: {
|
||||
data: Order[];
|
||||
pagination: { total: number; page: number; pages: number };
|
||||
meta?: { total: number; pages: number };
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<Order["currentStatus"], { label: string; color: string; bg: string; border: string }> = {
|
||||
Created: { label: "تم الإنشاء", color: "#1E40AF", bg: "#EFF6FF", border: "#BFDBFE" },
|
||||
Assigned: { label: "مُعيَّن", color: "#5B21B6", bg: "#F5F3FF", border: "#DDD6FE" },
|
||||
InTransit: { label: "قيد التوصيل", color: "#854D0E", bg: "#FFFBEB", border: "#FDE68A" },
|
||||
Delivered: { label: "تم التسليم", color: "#166534", bg: "#DCFCE7", border: "#BBF7D0" },
|
||||
Returned: { label: "مُرتجع", color: "#991B1B", bg: "#FEF2F2", border: "#FECACA" },
|
||||
Cancelled: { label: "ملغي", color: "#64748B", bg: "#F1F5F9", border: "#E2E8F0" },
|
||||
};
|
||||
|
||||
const PAY_MAP: Record<string, { label: string; color: string }> = {
|
||||
Pending: { label: "معلَّق", color: "#D97706" },
|
||||
Paid: { label: "مدفوع", color: "#16A34A" },
|
||||
Failed: { label: "فشل", color: "#DC2626" },
|
||||
Refunded: { label: "مُسترجع", color: "#64748B" },
|
||||
};
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
overflow: "hidden",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
};
|
||||
|
||||
const thStyle: React.CSSProperties = {
|
||||
padding: "0.75rem 1.5rem",
|
||||
fontSize: 11,
|
||||
fontWeight: 700,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.2em",
|
||||
color: "var(--color-text-muted)",
|
||||
background: "var(--color-surface-muted)",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const params = new URLSearchParams({ page: String(page), limit: "10" });
|
||||
if (search) params.set("search", search);
|
||||
if (statusFilter) params.set("currentStatus", statusFilter);
|
||||
get<ApiResponse>(`orders?${params}`, token)
|
||||
.then((res) => {
|
||||
const payload = res.data ?? res;
|
||||
setOrders(payload.data ?? []);
|
||||
setTotal(payload.meta?.total ?? 0);
|
||||
setPages(payload.meta?.pages ?? 1);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, [page, search, statusFilter]);
|
||||
|
||||
return (
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* Header */}
|
||||
<header style={{
|
||||
borderRadius: "var(--radius-xl)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
padding: "1.5rem 2rem",
|
||||
boxShadow: "var(--shadow-card)",
|
||||
}}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||
إدارة الشحنات
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
الطلبات
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> طلب
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
||||
{/* Status filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
dir="rtl"
|
||||
style={{
|
||||
height: 40, borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, color: "var(--color-text-secondary)",
|
||||
padding: "0 0.75rem", outline: "none",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
<option value="">كل الحالات</option>
|
||||
{Object.entries(STATUS_MAP).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Search */}
|
||||
<div style={{ position: "relative", width: 256 }}>
|
||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
|
||||
</svg>
|
||||
<input type="text" placeholder="رقم الشحنة أو المستلم..." value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }} dir="rtl"
|
||||
style={{
|
||||
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12,
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)",
|
||||
fontSize: 13, color: "var(--color-text-primary)",
|
||||
outline: "none", fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <Alert type="error" message={error} onClose={() => setError(null)} />}
|
||||
|
||||
{/* Table */}
|
||||
<div style={cardStyle}>
|
||||
<div dir="rtl" style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1.8fr 1.5fr 1.2fr 1fr 1fr 1fr",
|
||||
...thStyle,
|
||||
}}>
|
||||
<span>رقم الشحنة</span>
|
||||
<span>المستلم</span>
|
||||
<span>العميل</span>
|
||||
<span>المبلغ</span>
|
||||
<span>الحالة</span>
|
||||
<span style={{ textAlign: "center" }}>التاريخ</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "4rem 0", color: "var(--color-text-muted)" }}>
|
||||
<Spinner size="sm" className="text-blue-600" />
|
||||
<span style={{ fontSize: 13 }}>جارٍ التحميل…</span>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<p style={{ textAlign: "center", padding: "4rem 0", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
لا توجد نتائج
|
||||
</p>
|
||||
) : (
|
||||
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{orders.map((o, i) => {
|
||||
const status = STATUS_MAP[o.currentStatus];
|
||||
const pay = o.paymentStatus ? PAY_MAP[o.paymentStatus] : null;
|
||||
return (
|
||||
<li key={o.id} style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1.8fr 1.5fr 1.2fr 1fr 1fr 1fr",
|
||||
alignItems: "center", gap: "0.5rem",
|
||||
padding: "0.875rem 1.5rem",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent",
|
||||
fontSize: 13,
|
||||
}}>
|
||||
<div>
|
||||
<p style={{ fontFamily: "var(--font-mono)", fontSize: 12, fontWeight: 700, color: "#2563EB", margin: 0 }}>
|
||||
{o.shipmentNumber}
|
||||
</p>
|
||||
{o.trip && (
|
||||
<p style={{ marginTop: 2, fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
رحلة: {o.trip.tripNumber}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p style={{ color: "var(--color-text-primary)", margin: 0 }}>{o.recipientName}</p>
|
||||
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{o.recipientPhone}
|
||||
</p>
|
||||
</div>
|
||||
<span style={{ color: "var(--color-text-secondary)" }}>{o.client?.name ?? "—"}</span>
|
||||
<div>
|
||||
<p style={{ fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
{o.totalPrice != null ? `${o.totalPrice.toFixed(2)} ر.س` : "—"}
|
||||
</p>
|
||||
{pay && (
|
||||
<p style={{ marginTop: 2, fontSize: 11, fontWeight: 600, color: pay.color }}>
|
||||
{pay.label}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span style={{
|
||||
display: "inline-flex", alignItems: "center",
|
||||
borderRadius: "var(--radius-full)",
|
||||
border: `1px solid ${status.border}`,
|
||||
background: status.bg,
|
||||
padding: "0.2rem 0.625rem",
|
||||
fontSize: 11, fontWeight: 600, color: status.color,
|
||||
width: "fit-content",
|
||||
}}>
|
||||
{status.label}
|
||||
</span>
|
||||
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||
{new Date(o.createdAt).toLocaleDateString("ar-SA", {
|
||||
year: "numeric", month: "short", day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{pages > 1 && (
|
||||
<div dir="rtl" style={{
|
||||
display: "flex", alignItems: "center", justifyContent: "space-between",
|
||||
borderTop: "1px solid var(--color-border)", padding: "0.875rem 1.5rem",
|
||||
}}>
|
||||
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||
صفحة <strong style={{ color: "var(--color-text-primary)" }}>{page}</strong> من <strong style={{ color: "var(--color-text-primary)" }}>{pages}</strong>
|
||||
</span>
|
||||
<div style={{ display: "flex", gap: "0.5rem" }}>
|
||||
{[
|
||||
{ label: "السابق", action: () => setPage(p => Math.max(1, p - 1)), disabled: page === 1 },
|
||||
{ label: "التالي", action: () => setPage(p => Math.min(pages, p + 1)), disabled: page === pages },
|
||||
].map(btn => (
|
||||
<button key={btn.label} onClick={btn.action} disabled={btn.disabled}
|
||||
style={{
|
||||
borderRadius: "var(--radius-lg)",
|
||||
border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface-muted)",
|
||||
padding: "0.375rem 0.875rem",
|
||||
fontSize: 12, color: "var(--color-text-secondary)",
|
||||
cursor: btn.disabled ? "not-allowed" : "pointer",
|
||||
opacity: btn.disabled ? 0.4 : 1,
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}>
|
||||
{btn.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,137 @@
|
||||
// صفحة إدارة المستخدمين — تستورد فقط من الطبقات الأخرى
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Alert } from "../../Components/UI";
|
||||
import { UserTable } from "../../Components/User/UserTable";
|
||||
import { UserFormModal } from "../../Components/User/UserFormModal";
|
||||
import { DeleteConfirmModal } from "../../Components/User/DeleteConfirmModal";
|
||||
import { Toast } from "../../Components/User/Toast";
|
||||
import { useUsers } from "../../hooks/useUser";
|
||||
import type { User, UserFormData } from "../../types/user";
|
||||
|
||||
export default function UsersPage() {
|
||||
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Users module is ready for the full CRUD UI.</section>;
|
||||
}
|
||||
// ── الحالة المحلية للمودالات فقط ─────────────────────────────────────────
|
||||
// false = مغلق، null = وضع الإضافة، User = وضع التعديل
|
||||
const [formTarget, setFormTarget] = useState<User | null | false>(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// ── كل منطق البيانات والحالة من الهوك ────────────────────────────────────
|
||||
const {
|
||||
users, loading, total, pages, error,
|
||||
roles, branches,
|
||||
page, search,
|
||||
setPage, handleSearch, clearError,
|
||||
createUser, updateUser, deleteUser,
|
||||
notification,
|
||||
} = useUsers();
|
||||
|
||||
// ── معالج تقديم النموذج (إضافة أو تعديل) ────────────────────────────────
|
||||
const handleFormSubmit = async (data: UserFormData, isNew: boolean): Promise<boolean> => {
|
||||
if (isNew) return createUser(data);
|
||||
return updateUser((formTarget as User).id, data);
|
||||
};
|
||||
|
||||
// ── معالج تأكيد الحذف ────────────────────────────────────────────────────
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
const ok = await deleteUser(deleteTarget.id);
|
||||
setDeleting(false);
|
||||
if (ok) setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ── Toast لعرض رسائل النجاح والخطأ ── */}
|
||||
<Toast notification={notification} />
|
||||
|
||||
{/* ── مودال الإضافة / التعديل ── */}
|
||||
{formTarget !== false && (
|
||||
<UserFormModal
|
||||
editUser={formTarget}
|
||||
roles={roles}
|
||||
branches={branches}
|
||||
onClose={() => setFormTarget(false)}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── مودال تأكيد الحذف ── */}
|
||||
{deleteTarget && (
|
||||
<DeleteConfirmModal
|
||||
user={deleteTarget}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── رأس الصفحة ── */}
|
||||
<header style={{
|
||||
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
|
||||
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
|
||||
}}>
|
||||
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}>
|
||||
إدارة الفريق
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
|
||||
المستخدمون
|
||||
</h1>
|
||||
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}>
|
||||
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> مستخدم
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* أدوات البحث وإضافة مستخدم */}
|
||||
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
|
||||
{/* حقل البحث */}
|
||||
<div style={{ position: "relative", width: 256 }}>
|
||||
<svg style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", width: 16, height: 16, color: "var(--color-text-hint)", pointerEvents: "none" }}
|
||||
fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
|
||||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input type="text" placeholder="بحث بالاسم أو الهاتف..." value={search}
|
||||
onChange={e => handleSearch(e.target.value)} dir="rtl"
|
||||
style={{ width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, outline: "none", fontFamily: "var(--font-sans)" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* زر إضافة مستخدم */}
|
||||
<button type="button" onClick={() => setFormTarget(null)}
|
||||
style={{ height: 40, padding: "0 1.125rem", borderRadius: "var(--radius-lg)", border: "none", background: "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer", display: "inline-flex", alignItems: "center", gap: 7, fontFamily: "var(--font-sans)", boxShadow: "0 1px 4px rgba(37,99,235,.35)", whiteSpace: "nowrap" }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
إضافة مستخدم
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── رسالة الخطأ العامة (فشل التحميل) ── */}
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={clearError} />
|
||||
)}
|
||||
|
||||
{/* ── جدول المستخدمين ── */}
|
||||
<UserTable
|
||||
users={users}
|
||||
loading={loading}
|
||||
search={search}
|
||||
page={page}
|
||||
pages={pages}
|
||||
onEdit={user => setFormTarget(user)}
|
||||
onDelete={user => setDeleteTarget(user)}
|
||||
onAddFirst={() => setFormTarget(null)}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user