first action update user pages .

2-update driver pages [fixed image display and notyfi]
3-update car pages [fixed image]
4-build tripe pages crud
5-build some role pages
6-build audit page
7-update ui compounants
8-update saidebar and topbar
9-cheange view to be ar view
10-cheange stractcher to be app,src
11-add validation layer by yup
12-add api image-proxy to broke image corse bloken
This commit is contained in:
m7amedez5511
2026-06-23 15:54:07 +03:00
parent c1b77f89cd
commit db64b79fe3
140 changed files with 9266 additions and 2449 deletions

View File

@@ -1,4 +1,4 @@
// app/api/auth/clear-cookie/route.ts
// Clears the HttpOnly auth cookie on logout.
// Only the server can delete an HttpOnly cookie.

View File

@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
const BACKEND_BASE = "https://logiapi.slash.sa";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ filename: string }> },
) {
const { filename } = await params;
try {
const upstream = await fetch(
`${BACKEND_BASE}/public/uploads/driver-photos/${filename}`,
{ cache: "no-store" },
);
if (!upstream.ok) {
return new NextResponse(null, { status: upstream.status });
}
const buffer = await upstream.arrayBuffer();
const contentType =
upstream.headers.get("Content-Type") ?? "image/jpeg";
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
},
});
} catch {
return new NextResponse(null, { status: 502 });
}
}

View File

@@ -10,51 +10,54 @@ async function proxy(
const path = params.path?.join("/") ?? "";
const targetUrl = `${BACKEND_BASE_URL}/${path}${request.nextUrl.search}`;
// Read the HttpOnly cookie server-side — this is the ONLY place the raw
// token value is accessible after the Issue 3 refactor.
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
const headers = new Headers();
// Forward all safe request headers.
request.headers.forEach((value, key) => {
if (key === "host" || key === "content-length" || key === "cookie") return;
headers.set(key, value);
});
// Inject the Authorization header using the server-side cookie value.
// The client never had access to this token value.
if (token) {
headers.set("Authorization", `Bearer ${decodeURIComponent(token)}`);
}
if (!headers.has("Content-Type")) {
// NOTE: do NOT force a default Content-Type here when there's no body —
// for multipart/form-data requests, the browser sets Content-Type itself
// (including the multipart boundary). We only need a fallback for bodied
// requests that didn't already specify one (e.g. raw JSON fetches).
const isBodyless = request.method === "GET" || request.method === "HEAD";
// CRITICAL FIX: read the body as raw bytes (ArrayBuffer), never as text.
// request.text() decodes the body as UTF-8, which corrupts any binary
// payload (images inside multipart/form-data) — invalid UTF-8 byte
// sequences get silently replaced, mangling the uploaded file before it
// ever reaches the backend. ArrayBuffer passes bytes through untouched,
// and works equally well for JSON bodies.
const body = isBodyless ? undefined : await request.arrayBuffer();
if (!isBodyless && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const isBodyless = request.method === "GET" || request.method === "HEAD";
const body = isBodyless ? undefined : await request.text();
const upstream = await fetch(targetUrl, {
method: request.method,
headers,
body,
cache: "no-store",
// @ts-expect-error - duplex is required by undici when streaming a body
// but isn't yet in the official RequestInit types in this Next.js version.
duplex: body ? "half" : undefined,
});
const contentType =
upstream.headers.get("content-type") || "application/json";
// Responses with these statuses MUST NOT carry a body (per the Fetch/HTTP
// spec). Constructing a NextResponse with a body alongside one of these
// statuses throws at runtime, which Next.js then surfaces as a 500 to the
// client — even though the upstream call itself succeeded. This is exactly
// what happens on DELETE endpoints that correctly return 204 No Content.
const isNoBodyStatus = [204, 205, 304].includes(upstream.status);
if (isNoBodyStatus) {
// Drain the upstream body (should be empty anyway) without parsing it.
await upstream.text().catch(() => null);
return new NextResponse(null, {
status: upstream.status,
@@ -62,6 +65,19 @@ async function proxy(
});
}
// Pass binary responses (e.g. images) through untouched too, just in case
// any proxied endpoint ever returns one.
if (!contentType.includes("application/json") && !contentType.includes("text")) {
const buffer = await upstream.arrayBuffer();
return new NextResponse(buffer, {
status: upstream.status,
headers: {
"content-type": contentType,
"cache-control": "no-store",
},
});
}
const responseBody = contentType.includes("application/json")
? await upstream.json().catch(() => null)
: await upstream.text();

View File

@@ -1,4 +1,4 @@
import Logo from "@/utils/logo";
import Logo from "@/src/utils/logo";
import Link from "next/link";
@@ -13,7 +13,7 @@ const navLinks = [
export default function Navbar() {
return (
<nav dir="rtl" className="w-full border-b border-slate-200 bg-white text-slate-900 shadow-sm">x
<nav dir="rtl" className="w-full border-b border-slate-200 bg-white text-slate-900 shadow-sm">
<div className="mx-auto flex h-14 max-w-7xl items-center gap-4 px-6 lg:px-8">
<a href="/" className="flex items-center gap-3 text-right">
<Logo />
@@ -33,8 +33,21 @@ export default function Navbar() {
))}
</div>
<div className="mr-auto flex items-center gap-2">
{/*
Was `mr-auto`. `mr-*` is a PHYSICAL Tailwind utility — it means
"margin-right" no matter what `dir` says, so it would have
pinned the login button to the visual left in both LTR and
RTL, fighting the rest of the nav. `ms-auto` ("margin-inline-
start: auto") pushes against the start edge instead, which is
right in RTL and left in LTR — it tracks `dir` automatically.
This is the single most common RTL bug: Tailwind's l/r-prefixed
utilities (ml, mr, pl, pr, left-, right-, text-left, text-right,
rounded-l, rounded-r, border-l, border-r) are all physical and
need their logical (s/e) equivalents — ms, me, ps, pe, start-,
end-, text-start, text-end, rounded-s, rounded-e, border-s,
border-e — anywhere direction should matter.
*/}
<div className="ms-auto flex items-center gap-2">
<Link href="/login" className="flex h-[34px] items-center rounded-lg bg-blue-600 px-3 text-[13px] font-bold text-white shadow-sm transition hover:bg-blue-700">
تسجيل الدخول
</Link>

View File

@@ -2,8 +2,8 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { getStoredUser } from "../../../lib/auth";
import Logo from "../../../utils/logo";
import { getStoredUser } from "@/src/lib/auth";
import Logo from "@/src/utils/logo";
const navSections = [
{
@@ -40,39 +40,110 @@ export function Sidebar() {
const permissions = user?.permissions ?? navSections.flatMap(s => s.items.map(i => i.permission));
return (
<aside
suppressHydrationWarning
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",
}}
>
<>
{/*
Full sidebar — visible lg+.
No `left`/`right` is set anywhere: placement comes purely from
document order + `dir="rtl"` on <html>. A flex row in RTL lays
its first child against the inline-start edge, which is
physically the RIGHT of the screen. Because <Sidebar /> is
still the first child in DashboardLayout, it now renders on
the right automatically — no positional CSS to flip.
*/}
<aside
suppressHydrationWarning
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",
}}
>
<SidebarContent
pathname={pathname}
permissions={permissions}
user={user}
compact={false}
/>
</aside>
{/*
Compact icon rail — md and below (replaces the old behavior of
hiding the sidebar entirely under `lg`). Fixed to the
inline-end... no: fixed to the RIGHT edge explicitly, because a
`position: fixed` element takes no part in flex flow and so
gets no automatic RTL placement from document order. This is
exactly the kind of element logical properties exist for:
`inset-inline-end: 0` means "right in RTL, left in LTR"
without a manual swap if direction ever toggles.
*/}
<aside
suppressHydrationWarning
className="hidden md:flex lg:hidden md:flex-col md:items-center"
style={{
position: "fixed",
insetBlockStart: 0,
insetBlockEnd: 0,
insetInlineEnd: 0,
width: "var(--sidebar-width-compact)",
background: "linear-gradient(175deg, #1E3A8A 0%, #1D4ED8 60%, #1565C0 100%)",
padding: "16px 0",
zIndex: 30,
overflowY: "auto",
}}
aria-label="التنقل المصغر"
>
<SidebarContent
pathname={pathname}
permissions={permissions}
user={user}
compact={true}
/>
</aside>
</>
);
}
function SidebarContent({
pathname,
permissions,
user,
compact,
}: {
pathname: string;
permissions: string[];
user: ReturnType<typeof getStoredUser>;
compact: boolean;
}) {
return (
<>
{/* Logo */}
<div style={{ marginBottom: 28 }}>
<div style={{ marginBottom: 28, display: "flex", justifyContent: compact ? "center" : "flex-start" }}>
<Logo white />
</div>
{/* Nav sections */}
<nav
style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0 }}
style={{ flex: 1, display: "flex", flexDirection: "column", gap: 0, width: "100%" }}
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>
{!compact && (
<p style={{
fontSize: 9,
letterSpacing: "0.14em",
textTransform: "uppercase",
color: "rgba(255,255,255,0.45)",
padding: "8px 8px 4px",
fontWeight: 600,
textAlign: "start",
}}>
{section.label}
</p>
)}
{section.items.map((item) => {
const allowed = item.permission === "read-dashboard"
@@ -86,11 +157,14 @@ export function Sidebar() {
<Link
key={item.href}
href={item.href}
title={compact ? item.label : undefined}
aria-label={item.label}
style={{
display: "flex",
alignItems: "center",
justifyContent: compact ? "center" : "flex-start",
gap: 9,
padding: "7px 8px",
padding: compact ? "10px" : "7px 8px",
borderRadius: 8,
fontSize: 13,
fontWeight: active ? 500 : 400,
@@ -99,14 +173,15 @@ export function Sidebar() {
textDecoration: "none",
transition: "var(--transition-base)",
marginBottom: 2,
textAlign: "start",
}}
>
<i
className={`ti ${item.icon}`}
aria-hidden="true"
style={{ fontSize: 16 }}
style={{ fontSize: compact ? 18 : 16, flexShrink: 0 }}
/>
{item.label}
{!compact && item.label}
</Link>
);
})}
@@ -114,20 +189,43 @@ export function Sidebar() {
))}
</nav>
{/* 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 style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2 }}>
{user?.role ?? "Signed in"}
</p>
</div>
</aside>
{/* User badge — collapses to an avatar dot in compact mode */}
{compact ? (
<div
suppressHydrationWarning
title={user?.name ?? user?.userName ?? "—"}
style={{
width: 36,
height: 36,
borderRadius: "var(--radius-full)",
background: "rgba(255,255,255,0.18)",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 600,
color: "#fff",
marginTop: 8,
}}
>
{(user?.name ?? user?.userName ?? "—").slice(0, 1)}
</div>
) : (
<div style={{
background: "rgba(255,255,255,0.12)",
borderRadius: 10,
padding: "10px 12px",
marginTop: 8,
width: "100%",
}}>
<p suppressHydrationWarning style={{ fontSize: 13, fontWeight: 500, color: "#fff", margin: 0, textAlign: "start" }}>
{user?.name ?? user?.userName ?? "—"}
</p>
<p suppressHydrationWarning style={{ fontSize: 11, color: "rgba(255,255,255,0.55)", marginTop: 2, textAlign: "start" }}>
{user?.role ?? "Signed in"}
</p>
</div>
)}
</>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { useRouter } from "next/navigation";
import { clearAuth, getStoredUser } from "../../../lib/auth";
import { clearAuth, getStoredUser } from "@/src/lib/auth";
export function Topbar() {
const router = useRouter();
@@ -24,14 +24,29 @@ export function Topbar() {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
/*
`justify-content: space-between` and `display: flex` are
direction-agnostic — they already flip with `dir` for free.
The title block (first child) lands at the inline-start edge
(right, in RTL) and the actions cluster (second child) lands
at inline-end (left). That matches the brief's "primary
actions easily accessible" requirement: in Arabic dashboards
the page title reads first at the right, and the role badge
+ logout sit together at the natural reading end — unchanged
from before, just now correctly positioned by `dir` instead
of by accident.
*/
}}
>
<div>
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0 }}>
<p style={{ fontSize: 14, fontWeight: 600, color: "var(--color-text-primary)", margin: 0, textAlign: "start" }}>
لوحة التحكم
</p>
<p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0 }}>
Operations dashboard
<p style={{ fontSize: 11, color: "var(--color-text-muted)", margin: 0, textAlign: "start" }}>
{/* English product label intentionally stays LTR-embedded so
"Operations dashboard" doesn't get its word order or
punctuation reversed by the surrounding RTL run. */}
<span className="ltr-embed">Operations dashboard</span>
</p>
</div>

View File

@@ -1,3 +1,222 @@
export default function AuditPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Audit log module is ready for compliance and activity review.</section>;
"use client";
import { useCallback, useEffect, useReducer, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth";
import { get } from "@/src/services/api";
// ── Types ──────────────────────────────────────────────────────────────────
interface AuditLog {
id: string;
action: string;
module?: string | null;
entityId?: string | null;
userId?: string | null;
userName?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
metadata?: Record<string, unknown> | null;
createdAt: string;
}
// ── State / Reducer ────────────────────────────────────────────────────────
interface State {
logs: AuditLog[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; logs: AuditLog[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "CLEAR_ERR" };
function reducer(s: State, a: Action): State {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, logs: a.logs, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── Action badge ───────────────────────────────────────────────────────────
const ACTION_COLORS: Record<string, { bg: string; color: string; border: string }> = {
CREATE: { bg: "#DCFCE7", color: "#166534", border: "#BBF7D0" },
UPDATE: { bg: "#EFF6FF", color: "#1D4ED8", border: "#BFDBFE" },
DELETE: { bg: "#FEF2F2", color: "#DC2626", border: "#FECACA" },
LOGIN: { bg: "#F5F3FF", color: "#5B21B6", border: "#DDD6FE" },
LOGOUT: { bg: "#F1F5F9", color: "#475569", border: "#E2E8F0" },
};
function ActionBadge({ action }: { action: string }) {
const upper = action?.toUpperCase() ?? "";
const key = Object.keys(ACTION_COLORS).find(k => upper.includes(k)) ?? "CREATE";
const cfg = ACTION_COLORS[key];
return (
<span style={{
display: "inline-flex", alignItems: "center",
borderRadius: "var(--radius-full)", border: `1px solid ${cfg.border}`,
background: cfg.bg, padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: cfg.color, whiteSpace: "nowrap",
}}>
{action}
</span>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function AuditPage() {
const [state, dispatch] = useReducer(reducer, {
logs: [], loading: true, total: 0, pages: 1, error: null,
});
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
const [module, setModule] = useState("");
const loadLogs = useCallback(async (p: number, q: string, mod: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const params = new URLSearchParams({ page: String(p), limit: "15" });
if (q) params.set("search", q);
if (mod) params.set("module", mod);
const res = await get<{
data: { data: AuditLog[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } };
}>(`audit?${params}`, token);
const payload = (res as unknown as { data: { data: AuditLog[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data;
dispatch({
type: "LOAD_OK",
logs: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل سجل التدقيق. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => { loadLogs(page, search, module); }, [page, search, module, loadLogs]);
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)",
};
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)" }}>{state.total}</strong> سجل
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}>
<input type="text" placeholder="بحث بالإجراء أو المستخدم…" value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} dir="rtl"
style={{ width: 220, height: 40, padding: "0 0.75rem", 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)" }} />
<select value={module} onChange={e => { setModule(e.target.value); setPage(1); }} dir="rtl"
style={{ height: 40, padding: "0 0.75rem", borderRadius: "var(--radius-lg)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, color: "var(--color-text-secondary)", outline: "none", fontFamily: "var(--font-sans)" }}>
<option value="">كل الوحدات</option>
{["User","Driver","Car","Trip","Order","Client","Role","Branch"].map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
</header>
{state.error && <Alert type="error" message={state.error} onClose={() => dispatch({ type: "CLEAR_ERR" })} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 1fr", ...thStyle }}>
<span>الإجراء</span>
<span>الوحدة</span>
<span>المستخدم</span>
<span>عنوان IP</span>
<span style={{ textAlign: "center" }}>الوقت</span>
</div>
{state.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>
) : state.logs.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 }}>
{state.logs.map((log, i) => (
<li key={log.id} style={{
display: "grid", gridTemplateColumns: "1.5fr 1fr 1fr 1.5fr 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>
<ActionBadge action={log.action} />
{log.entityId && (
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>
{log.entityId.slice(0, 12)}
</p>
)}
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{log.module ?? "—"}</span>
<span style={{ color: "var(--color-text-secondary)" }}>{log.userName ?? log.userId ?? "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-muted)" }}>{log.ipAddress ?? "—"}</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(log.createdAt).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" })}
</span>
</li>
))}
</ul>
)}
{state.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)" }}>{state.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(state.pages, p + 1)), disabled: page === state.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>
);
}

View File

@@ -1,3 +1,493 @@
export default function BranchesPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Branches module is ready for the location and hub management table.</section>;
"use client";
import { useCallback, useEffect, useReducer, useRef, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { getStoredToken } from "@/src/lib/auth";
import { get, post, put, del } from "@/src/services/api";
// ── Types ──────────────────────────────────────────────────────────────────
interface Branch {
id: string;
name: string;
address?: string | null;
phone?: string | null;
createdAt: string;
updatedAt: string;
}
interface BranchFormData {
name: string;
address: string;
phone: string;
}
// ── State / Reducer ────────────────────────────────────────────────────────
interface State {
branches: Branch[];
loading: boolean;
total: number;
pages: number;
error: string | null;
}
type Action =
| { type: "LOAD_START" }
| { type: "LOAD_OK"; branches: Branch[]; total: number; pages: number }
| { type: "LOAD_ERR"; error: string }
| { type: "ADD"; branch: Branch }
| { type: "UPDATE"; branch: Branch }
| { type: "DELETE"; id: string }
| { type: "CLEAR_ERR" };
function reducer(s: State, a: Action): State {
switch (a.type) {
case "LOAD_START": return { ...s, loading: true, error: null };
case "LOAD_OK": return { ...s, loading: false, branches: a.branches, total: a.total, pages: a.pages };
case "LOAD_ERR": return { ...s, loading: false, error: a.error };
case "ADD": return { ...s, branches: [a.branch, ...s.branches], total: s.total + 1 };
case "UPDATE": return { ...s, branches: s.branches.map(b => b.id === a.branch.id ? a.branch : b) };
case "DELETE": return { ...s, branches: s.branches.filter(b => b.id !== a.id), total: Math.max(0, s.total - 1) };
case "CLEAR_ERR": return { ...s, error: null };
default: return s;
}
}
// ── Branch Form Modal ──────────────────────────────────────────────────────
function BranchFormModal({
editBranch,
onClose,
onSubmit,
}: {
editBranch: Branch | null;
onClose: () => void;
onSubmit: (data: BranchFormData, isNew: boolean) => Promise<boolean>;
}) {
const isNew = editBranch === null;
const [form, setForm] = useState<BranchFormData>({
name: editBranch?.name ?? "",
address: editBranch?.address ?? "",
phone: editBranch?.phone ?? "",
});
const [errors, setErrors] = useState<Partial<BranchFormData>>({});
const [saving, setSaving] = useState(false);
const [apiErr, setApiErr] = useState("");
const nameRef = useRef<HTMLInputElement>(null);
useEffect(() => { nameRef.current?.focus(); }, []);
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
function validate(): boolean {
const e: Partial<BranchFormData> = {};
if (!form.name.trim()) e.name = "اسم الفرع مطلوب";
setErrors(e);
return Object.keys(e).length === 0;
}
async function handleSubmit(ev: React.FormEvent) {
ev.preventDefault();
if (!validate()) return;
setSaving(true);
setApiErr("");
const ok = await onSubmit(form, isNew);
setSaving(false);
if (ok) onClose();
else setApiErr("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
}
const inputBase: React.CSSProperties = {
width: "100%", height: 40, padding: "0 0.75rem",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", fontSize: 13,
color: "var(--color-text-primary)", outline: "none", fontFamily: "var(--font-sans)",
};
return (
<div
role="dialog" aria-modal="true"
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
style={{
position: "fixed", inset: 0, zIndex: 50,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}
>
<div onClick={e => e.stopPropagation()} style={{
width: "100%", maxWidth: 480,
background: "var(--color-surface)", borderRadius: "var(--radius-2xl)",
border: "1px solid var(--color-border)", boxShadow: "0 24px 64px rgba(0,0,0,.18)",
overflow: "hidden",
}}>
<div style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "1.25rem 1.5rem", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
{isNew ? "إضافة فرع" : "تعديل فرع"}
</p>
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{isNew ? "فرع جديد" : editBranch?.name}
</h2>
</div>
<button type="button" onClick={onClose} style={{
width: 34, height: 34, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", background: "var(--color-surface)",
cursor: "pointer", fontSize: 18, color: "var(--color-text-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>×</button>
</div>
<form onSubmit={handleSubmit} noValidate style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1rem" }}>
{apiErr && <Alert type="error" message={apiErr} onClose={() => setApiErr("")} />}
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
اسم الفرع *
<input ref={nameRef} style={{ ...inputBase, ...(errors.name ? { borderColor: "var(--color-danger)", background: "#FEF2F2" } : {}) }}
value={form.name} onChange={e => { setForm(p => ({ ...p, name: e.target.value })); setErrors(p => ({ ...p, name: undefined })); }}
placeholder="فرع الرياض" dir="rtl" />
{errors.name && <span style={{ fontSize: 11, color: "var(--color-danger)" }}>{errors.name}</span>}
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
العنوان (اختياري)
<input style={inputBase} value={form.address}
onChange={e => setForm(p => ({ ...p, address: e.target.value }))}
placeholder="شارع الملك فهد، الرياض" dir="rtl" />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 6, fontSize: 12, fontWeight: 600, color: "var(--color-text-secondary)" }}>
رقم الهاتف (اختياري)
<input style={inputBase} value={form.phone}
onChange={e => setForm(p => ({ ...p, phone: e.target.value }))}
placeholder="+966 11 xxx xxxx" dir="ltr" />
</label>
<div style={{ display: "flex", gap: "0.5rem", justifyContent: "flex-end", paddingTop: "0.5rem" }}>
<button type="button" onClick={onClose} disabled={saving}
style={{ height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: saving ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="submit" disabled={saving}
style={{ height: 40, padding: "0 1.5rem", borderRadius: "var(--radius-md)", border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: saving ? "not-allowed" : "pointer", display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)" }}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : isNew ? "إضافة الفرع" : "حفظ التغييرات"}
</button>
</div>
</form>
</div>
</div>
);
}
// ── Delete Modal ───────────────────────────────────────────────────────────
function BranchDeleteModal({ branch, deleting, onCancel, onConfirm }: {
branch: Branch; deleting: boolean; onCancel: () => void; onConfirm: () => void;
}) {
useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === "Escape") onCancel(); };
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onCancel]);
return (
<div role="alertdialog" aria-modal="true"
onClick={e => { if (e.target === e.currentTarget) onCancel(); }}
style={{
position: "fixed", inset: 0, zIndex: 60,
background: "rgba(15,23,42,0.55)", backdropFilter: "blur(4px)",
display: "flex", alignItems: "center", justifyContent: "center", padding: "1rem",
}}>
<div onClick={e => e.stopPropagation()} style={{
width: "100%", maxWidth: 420, background: "var(--color-surface)",
borderRadius: "var(--radius-xl)", border: "1px solid #FECACA",
boxShadow: "0 20px 48px rgba(0,0,0,.18)", padding: "2rem",
display: "flex", flexDirection: "column", gap: "1rem", textAlign: "center",
}}>
<div style={{ width: 52, height: 52, margin: "0 auto", borderRadius: "50%", background: "#FEF2F2", border: "1px solid #FECACA", display: "flex", alignItems: "center", justifyContent: "center" }}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</div>
<div>
<h2 style={{ fontSize: 17, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>حذف الفرع</h2>
<p style={{ marginTop: 8, fontSize: 13, color: "var(--color-text-muted)", lineHeight: 1.6 }}>
هل أنت متأكد من حذف فرع <strong style={{ color: "var(--color-text-primary)" }}>{branch.name}</strong>؟ لا يمكن التراجع عن هذا الإجراء.
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="button" onClick={onCancel} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)", background: "var(--color-surface)", fontSize: 13, fontWeight: 600, color: "var(--color-text-secondary)", cursor: deleting ? "not-allowed" : "pointer", fontFamily: "var(--font-sans)" }}>
إلغاء
</button>
<button type="button" onClick={onConfirm} disabled={deleting}
style={{ flex: 1, height: 40, borderRadius: "var(--radius-md)", border: "none", background: "#DC2626", fontSize: 13, fontWeight: 700, color: "#FFF", cursor: deleting ? "not-allowed" : "pointer", display: "flex", alignItems: "center", justifyContent: "center", gap: 8, fontFamily: "var(--font-sans)", opacity: deleting ? 0.7 : 1 }}>
{deleting && <Spinner size="sm" className="text-white" />}
{deleting ? "جارٍ الحذف…" : "تأكيد الحذف"}
</button>
</div>
</div>
</div>
);
}
// ── Toast ──────────────────────────────────────────────────────────────────
function Toast({ type, message }: { type: "success" | "error"; message: string }) {
const ok = type === "success";
return (
<div role="status" style={{
position: "fixed", bottom: 24, left: "50%", transform: "translateX(-50%)", zIndex: 9999,
display: "flex", alignItems: "center", gap: 10, padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)", background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF", fontSize: 13, fontWeight: 600, boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw", whiteSpace: "nowrap", fontFamily: "var(--font-sans)",
}}>
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
<span>{message}</span>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function BranchesPage() {
const [state, dispatch] = useReducer(reducer, {
branches: [], loading: true, total: 0, pages: 1, error: null,
});
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [formTarget, setFormTarget] = useState<Branch | null | false>(false);
const [deleteTarget, setDeleteTarget] = useState<Branch | null>(null);
const [deleting, setDeleting] = useState(false);
const [notification, setNotification] = useState<{ type: "success" | "error"; message: string } | null>(null);
const notify = useCallback((n: { type: "success" | "error"; message: string }) => {
setNotification(n);
setTimeout(() => setNotification(null), 4000);
}, []);
const loadBranches = useCallback(async (p: number, q: string) => {
dispatch({ type: "LOAD_START" });
try {
const token = getStoredToken();
const qs = `?page=${p}&limit=12${q ? `&search=${encodeURIComponent(q)}` : ""}`;
const res = await get<{ data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }>(`branches${qs}`, token);
const payload = (res as unknown as { data: { data: Branch[]; meta?: { total: number; pages: number }; pagination?: { total: number; pages: number } } }).data;
dispatch({
type: "LOAD_OK",
branches: payload.data ?? [],
total: payload.meta?.total ?? payload.pagination?.total ?? 0,
pages: payload.meta?.pages ?? payload.pagination?.pages ?? 1,
});
} catch {
dispatch({ type: "LOAD_ERR", error: "تعذّر تحميل الفروع. يرجى المحاولة مجدداً." });
}
}, []);
useEffect(() => { loadBranches(page, search); }, [page, search, loadBranches]);
const handleFormSubmit = useCallback(async (data: BranchFormData, isNew: boolean): Promise<boolean> => {
try {
const token = getStoredToken();
if (isNew) {
const res = await post<{ data: Branch }>("branches", data, token);
dispatch({ type: "ADD", branch: (res as unknown as { data: Branch }).data });
notify({ type: "success", message: "تم إضافة الفرع بنجاح." });
} else {
const res = await put<{ data: Branch }>(`branches/${(formTarget as Branch).id}`, data, token);
dispatch({ type: "UPDATE", branch: (res as unknown as { data: Branch }).data });
notify({ type: "success", message: "تم تحديث الفرع بنجاح." });
}
return true;
} catch (err) {
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حفظ الفرع." });
return false;
}
}, [formTarget, notify]);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const token = getStoredToken();
await del(`branches/${deleteTarget.id}`, token);
dispatch({ type: "DELETE", id: deleteTarget.id });
notify({ type: "success", message: "تم حذف الفرع بنجاح." });
setDeleteTarget(null);
} catch (err) {
notify({ type: "error", message: err instanceof Error ? err.message : "تعذّر حذف الفرع." });
} finally {
setDeleting(false);
}
}, [deleteTarget, notify]);
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)",
};
return (
<>
{notification && <Toast type={notification.type} message={notification.message} />}
{formTarget !== false && (
<BranchFormModal
editBranch={formTarget}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{deleteTarget && (
<BranchDeleteModal
branch={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
)}
<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)" }}>{state.total}</strong> فرع
</p>
</div>
<div style={{ display: "flex", gap: "0.5rem", alignItems: "center", flexWrap: "wrap" }}>
<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>
<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>
{state.error && <Alert type="error" message={state.error} onClose={() => dispatch({ type: "CLEAR_ERR" })} />}
{/* Table */}
<div style={cardStyle}>
<div dir="rtl" style={{ display: "grid", gridTemplateColumns: "2fr 2fr 1.5fr 1fr 100px", ...thStyle }}>
<span>الفرع</span>
<span>العنوان</span>
<span>الهاتف</span>
<span style={{ textAlign: "center" }}>تاريخ الإنشاء</span>
<span style={{ textAlign: "center" }}>إجراءات</span>
</div>
{state.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>
) : state.branches.length === 0 ? (
<div style={{ textAlign: "center", padding: "4rem 1rem" }}>
<p style={{ fontSize: 13, color: "var(--color-text-muted)" }}>
{search ? `لا توجد نتائج لـ "${search}"` : "لا توجد فروع لعرضها."}
</p>
{!search && (
<button type="button" onClick={() => setFormTarget(null)}
style={{ marginTop: 12, fontSize: 13, fontWeight: 600, color: "var(--color-brand-600)", background: "none", border: "none", cursor: "pointer", textDecoration: "underline" }}>
أضف أول فرع
</button>
)}
</div>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{state.branches.map((b, i) => (
<li key={b.id} style={{
display: "grid", gridTemplateColumns: "2fr 2fr 1.5fr 1fr 100px",
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 }}>{b.name}</p>
<p style={{ marginTop: 2, fontFamily: "var(--font-mono)", fontSize: 10, color: "var(--color-text-muted)" }}>{b.id.slice(0, 8)}</p>
</div>
<span style={{ color: "var(--color-text-secondary)" }}>{b.address ?? "—"}</span>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-text-secondary)" }}>{b.phone ?? "—"}</span>
<span style={{ textAlign: "center", fontSize: 11, color: "var(--color-text-muted)" }}>
{new Date(b.createdAt).toLocaleDateString("ar-SA", { year: "numeric", month: "short", day: "numeric" })}
</span>
<div style={{ display: "flex", justifyContent: "center", gap: 6 }}>
<button type="button" title="تعديل" onClick={() => setFormTarget(b)}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #BFDBFE", background: "#EFF6FF", color: "#1D4ED8", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button type="button" title="حذف" onClick={() => setDeleteTarget(b)}
style={{ width: 32, height: 32, borderRadius: "var(--radius-md)", border: "1px solid #FECACA", background: "#FEF2F2", color: "#DC2626", cursor: "pointer", display: "inline-flex", alignItems: "center", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</li>
))}
</ul>
)}
{state.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)" }}>{state.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(state.pages, p + 1)), disabled: page === state.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>
</>
);
}

View File

@@ -2,13 +2,13 @@
// app/(dashboard)/cars/page.tsx
import { useCallback, useState } from "react";
import { Spinner, Alert } from "@/Components/UI";
import { CarFormModal } from "@/Components/car/CarFormModal";
import { CarDetailPanel } from "@/Components/car/CarDetailPanel";
import { CarDeleteModal } from "@/Components/car/CarDeleteModal";
import { useCars, useCarMutations, useToast } from "@/hooks/useCars";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/types/car";
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/types/car";
import { Spinner, Alert } from "@/src/Components/UI";
import { CarFormModal } from "@/src/Components/car/CarFormModal";
import { CarDetailPanel } from "@/src/Components/car/CarDetailPanel";
import { CarDeleteModal } from "@/src/Components/car/CarDeleteModal";
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car";
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car";
// ── Toast ─────────────────────────────────────────────────────────────────────

View File

@@ -2,18 +2,18 @@
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Alert } from "@/Components/UI";
import { Toast } from "@/Components/Client/Toast";
import { useClientAddresses } from "@/hooks/useClientAddresses";
import { clientService } from "@/services/client.service";
import { getStoredToken } from "@/lib/auth";
import { Alert } from "@/src/Components/UI";
import { Toast } from "@/src/Components/Client/Toast";
import { useClientAddresses } from "@/src/hooks/useClientAddresses";
import { clientService } from "@/src/services/client.service";
import { getStoredToken } from "@/src/lib/auth";
import type {
Client,
ClientAddress,
ClientAddressFormData,
} from "@/types/client";
import { AddressFormModal } from "@/Components/Client/Addressformmodal";
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
} from "@/src/types/client";
import { AddressFormModal } from "@/src/Components/Client/Addressformmodal";
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
// ── Helpers ────────────────────────────────────────────────────────────────

View File

@@ -2,13 +2,13 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Alert } from "@/Components/UI";
import { Toast } from "@/Components/Client/Toast";
import { useClients } from "@/hooks/useClients";
import type { Client, ClientFormData } from "@/types/client";
import { ClientFormModal } from "@/Components/Client/Clientformmodal";
import { ClientTable } from "@/Components/Client/Clienttable";
import { DeleteConfirmModal } from "@/Components/Client/Deleteconfirmmodal";
import { Alert } from "@/src/Components/UI";
import { Toast } from "@/src/Components/Client/Toast";
import { useClients } from "@/src/hooks/useClients";
import type { Client, ClientFormData } from "@/src/types/client";
import { ClientFormModal } from "@/src/Components/Client/Clientformmodal";
import { ClientTable } from "@/src/Components/Client/Clienttable";
import { DeleteConfirmModal } from "@/src/Components/Client/Deleteconfirmmodal";
export default function ClientsPage() {
const router = useRouter();

View File

@@ -2,31 +2,22 @@
import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Spinner } from "@/Components/UI";
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
import { DriverReportPanel } from "@/Components/Driver_Report/driverReport";
import { Spinner } from "@/src/Components/UI";
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
import { driverService } from "@/services";
import { getStoredToken } from "@/lib/auth";
import type {
Driver,
CreateDriverPayload,
UpdateDriverPayload,
} from "@/types/driver";
import {
DRIVER_STATUS_MAP,
DRIVER_CARD_TYPE_MAP,
NATIONAL_ID_TYPE_MAP,
} from "@/types/driver";
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver";
import { DRIVER_STATUS_MAP, DRIVER_CARD_TYPE_MAP, NATIONAL_ID_TYPE_MAP } from "@/src/types/driver";
import { PhotoCard } from "@/src/Components/Driver/DriverPhotos";
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmtDate(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric",
month: "long",
day: "numeric",
year: "numeric", month: "long", day: "numeric",
});
}
@@ -37,40 +28,21 @@ function isExpiringSoon(iso?: string | null): boolean {
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
}}
>
<div
style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}
>
<div style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
}}>
<div style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: 0 }}>
{title}
</p>
</div>
@@ -79,123 +51,59 @@ function SectionCard({
);
}
function DetailRow({
label,
value,
mono = false,
warn = false,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
function DetailRow({ label, value, mono = false, warn = false }: {
label: string; value: string; mono?: boolean; warn?: boolean;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "0.55rem 0",
borderBottom: "1px solid var(--color-border)",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
{label}
</span>
<span
style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400,
maxWidth: "60%",
textAlign: "left",
wordBreak: "break-word",
}}
>
{warn && value !== "—" ? "⚠ " : ""}
{value}
<div style={{
display: "flex", justifyContent: "space-between", alignItems: "baseline",
padding: "0.55rem 0", borderBottom: "1px solid var(--color-border)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span>
<span style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400,
maxWidth: "60%", textAlign: "left", wordBreak: "break-word",
}}>
{warn && value !== "—" ? "⚠ " : ""}{value}
</span>
</div>
);
}
function PhotoCard({ url, label }: { url?: string | null; label: string }) {
const [imgError, setImgError] = useState(false);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--color-text-muted)" }}>
{label}
</span>
<div
style={{
width: "100%",
aspectRatio: "4/3",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{url && !imgError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={url}
alt={label}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setImgError(true)}
/>
) : (
<svg
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="var(--color-text-hint)"
strokeWidth="1.5"
>
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
)}
</div>
{url && !imgError && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: 11, color: "#2563EB", textDecoration: "underline", textAlign: "center" }}
>
عرض الصورة
</a>
)}
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DriverDetailPage() {
const params = useParams();
const router = useRouter();
const params = useParams();
const router = useRouter();
const driverId = params?.driverId as string;
const [driver, setDriver] = useState<Driver | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [driver, setDriver] = useState<Driver | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false);
// Reset avatar error when photo changes after an update
useEffect(() => {
console.log(driver?.photoUrl);
setAvatarError(false);
}, [driver?.photoUrl]);
// Toast shown after edit/delete actions on this page
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null);
// ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const showToast = useCallback((type: "success" | "error", message: string) => {
setToast({ type, message });
setTimeout(() => setToast(null), 4000);
}, []);
// ── Load driver ───────────────────────────────────────────────────────────
const loadDriver = useCallback(async () => {
if (!driverId) return;
@@ -212,27 +120,37 @@ export default function DriverDetailPage() {
}
}, [driverId]);
useEffect(() => {
loadDriver();
}, [loadDriver]);
useEffect(() => { loadDriver(); }, [loadDriver]);
// ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback(
async (
payload: CreateDriverPayload | UpdateDriverPayload,
_isNew: boolean,
): Promise<boolean> => {
async (payload: CreateDriverPayload | UpdateDriverPayload, _isNew: boolean): Promise<boolean> => {
if (!driver) return false;
try {
const token = getStoredToken();
await driverService.update(driver.id, payload as UpdateDriverPayload, token);
const typedPayload = payload as UpdateDriverPayload & {
photo?: File; nationalPhoto?: File; driverCardPhoto?: File;
};
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto;
if (hasFiles) {
// Use multipart — JSON.stringify converts File to {} which fails validation
await driverService.updateWithImages(driver.id, typedPayload, token);
} else {
await driverService.update(driver.id, typedPayload, token);
}
// Re-fetch to reflect updated status, name, photos, etc.
await loadDriver();
showToast("success", "تم تحديث بيانات السائق بنجاح.");
return true;
} catch {
} catch (err) {
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق.";
showToast("error", message);
return false;
}
},
[driver, loadDriver],
[driver, loadDriver, showToast],
);
// ── Delete confirm ────────────────────────────────────────────────────────
@@ -243,27 +161,19 @@ export default function DriverDetailPage() {
const token = getStoredToken();
await driverService.delete(driver.id, token);
router.push("/dashboard/drivers");
} catch {
} catch (err) {
const message = err instanceof Error ? err.message : "تعذّر حذف السائق.";
showToast("error", message);
setDeleting(false);
}
}, [driver, router]);
}, [driver, router, showToast]);
// ── Status config ─────────────────────────────────────────────────────────
const statusConfig = driver ? DRIVER_STATUS_MAP[driver.status] : null;
// ── Render: Loading ───────────────────────────────────────────────────────
if (loading) {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "6rem 0",
color: "var(--color-text-muted)",
}}
>
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "6rem 0", color: "var(--color-text-muted)" }}>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ التحميل</span>
</div>
@@ -273,37 +183,14 @@ export default function DriverDetailPage() {
// ── Render: Error ─────────────────────────────────────────────────────────
if (error || !driver) {
return (
<div
style={{
maxWidth: 480,
margin: "4rem auto",
borderRadius: "var(--radius-xl)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "1.5rem",
textAlign: "center",
}}
>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "السائق غير موجود"}
</p>
<button
type="button"
onClick={() => router.back()}
style={{
marginTop: "1rem",
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
<div style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> {error ?? "السائق غير موجود"}</p>
<button type="button" onClick={() => router.back()} style={{
marginTop: "1rem", height: 38, padding: "0 1.25rem",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)", cursor: "pointer", fontFamily: "var(--font-sans)",
}}>
رجوع
</button>
</div>
@@ -315,35 +202,32 @@ export default function DriverDetailPage() {
<>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Toast notification ── */}
{toast && (
<div style={{
borderRadius: "var(--radius-lg)",
border: `1px solid ${toast.type === "success" ? "#BBF7D0" : "#FECACA"}`,
background: toast.type === "success" ? "#DCFCE7" : "#FEF2F2",
padding: "0.75rem 1.25rem",
fontSize: 13,
fontWeight: 600,
color: toast.type === "success" ? "#166534" : "#DC2626",
}}>
{toast.type === "success" ? "✓ " : "⚠ "}{toast.message}
</div>
)}
{/* ── Page 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)",
}}
>
{/* Back link */}
<button
type="button"
onClick={() => router.back()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
marginBottom: "1rem",
fontFamily: "var(--font-sans)",
}}
>
<header style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)",
}}>
<button type="button" onClick={() => router.back()} style={{
display: "inline-flex", alignItems: "center", gap: 6,
fontSize: 12, fontWeight: 600, color: "var(--color-text-muted)",
background: "none", border: "none", cursor: "pointer", padding: 0,
marginBottom: "1rem", fontFamily: "var(--font-sans)",
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
@@ -351,68 +235,35 @@ export default function DriverDetailPage() {
</button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
{/* Avatar + name */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div
style={{
width: 72,
height: 72,
borderRadius: "50%",
overflow: "hidden",
flexShrink: 0,
border: "2px solid var(--color-brand-200)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<div style={{
width: 72, height: 72, borderRadius: "50%", overflow: "hidden", flexShrink: 0,
border: "2px solid var(--color-brand-200)", background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
{driver.photoUrl && !avatarError ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={driver.photoUrl}
alt={driver.name}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
onError={() => setAvatarError(true)}
/>
<img src={driver.photoUrl} alt={driver.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} onError={() => setAvatarError(true)} />
) : (
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>
{driver.name.charAt(0)}
</span>
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>{driver.name.charAt(0)}</span>
)}
</div>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
ملف السائق
</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{driver.name}
</h1>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>ملف السائق</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>{driver.name}</h1>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
{driver.userName && (
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
@{driver.userName}
</span>
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>@{driver.userName}</span>
)}
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
{driver.phone}
</span>
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>{driver.phone}</span>
{statusConfig && (
<span
style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<span style={{
borderRadius: "var(--radius-full)", border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg, padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: statusConfig.color,
display: "inline-flex", alignItems: "center", gap: 5,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</span>
@@ -421,45 +272,20 @@ export default function DriverDetailPage() {
</div>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button
type="button"
onClick={() => setDeleteOpen(true)}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13,
fontWeight: 700,
color: "#DC2626",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
<button type="button" onClick={() => setDeleteOpen(true)} style={{
height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA", background: "#FEF2F2",
fontSize: 13, fontWeight: 700, color: "#DC2626", cursor: "pointer", fontFamily: "var(--font-sans)",
}}>
حذف السائق
</button>
<button
type="button"
onClick={() => setEditOpen(true)}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<button type="button" onClick={() => setEditOpen(true)} style={{
height: 40, padding: "0 1.25rem", borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF", cursor: "pointer",
display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)",
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
@@ -471,103 +297,58 @@ export default function DriverDetailPage() {
</header>
{/* ── Content grid ── */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
}}
>
{/* Personal Info */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
<SectionCard title="البيانات الشخصية">
<DetailRow label="الاسم الكامل" value={driver.name} />
<DetailRow label="رقم الجوال" value={driver.phone} mono />
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
<DetailRow label="العنوان" value={driver.address ?? "—"} />
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
<DetailRow label="الاسم الكامل" value={driver.name} />
<DetailRow label="رقم الجوال" value={driver.phone} mono />
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
<DetailRow label="العنوان" value={driver.address ?? "—"} />
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
</SectionCard>
{/* ID & GOSI */}
<SectionCard title="الهوية والتأمينات">
<DetailRow
label="نوع الهوية"
value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"}
/>
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"} />
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono />
<DetailRow
label="انتهاء الهوية"
value={fmtDate(driver.nationalIdExpiry)}
warn={isExpiringSoon(driver.nationalIdExpiry)}
/>
<DetailRow label="انتهاء الهوية" value={fmtDate(driver.nationalIdExpiry)} warn={isExpiringSoon(driver.nationalIdExpiry)} />
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
</SectionCard>
{/* License */}
<SectionCard title="بيانات رخصة القيادة">
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono />
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
<DetailRow
label="انتهاء الرخصة"
value={fmtDate(driver.licenseExpiry)}
warn={isExpiringSoon(driver.licenseExpiry)}
/>
<DetailRow label="انتهاء الرخصة" value={fmtDate(driver.licenseExpiry)} warn={isExpiringSoon(driver.licenseExpiry)} />
</SectionCard>
{/* Driver Card */}
<SectionCard title="بطاقة السائق">
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono />
<DetailRow
label="نوع البطاقة"
value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"}
/>
<DetailRow
label="انتهاء البطاقة"
value={fmtDate(driver.driverCardExpiry)}
warn={isExpiringSoon(driver.driverCardExpiry)}
/>
<DetailRow label="نوع البطاقة" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"} />
<DetailRow label="انتهاء البطاقة" value={fmtDate(driver.driverCardExpiry)} warn={isExpiringSoon(driver.driverCardExpiry)} />
</SectionCard>
{/* System Info */}
<SectionCard title="معلومات النظام">
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono />
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
</SectionCard>
{/* Status History */}
{driver.statusHistory && driver.statusHistory.length > 0 && (
<SectionCard title="سجل الحالات">
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{driver.statusHistory.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
return (
<div
key={h.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
borderRadius: "var(--radius-md)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.5rem 0.875rem",
}}
>
<div key={h.id} style={{
display: "flex", justifyContent: "space-between", alignItems: "center",
borderRadius: "var(--radius-md)", border: `1px solid ${s.border}`,
background: s.bg, padding: "0.5rem 0.875rem",
}}>
<div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>
{s.label}
</span>
{h.reason && (
<span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}>
{h.reason}
</span>
)}
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span>
{h.reason && <span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}> {h.reason}</span>}
</div>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
{fmtDate(h.createdAt)}
</span>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span>
</div>
);
})}
@@ -576,31 +357,22 @@ export default function DriverDetailPage() {
)}
</div>
{/* ── Photos section (full width) ── */}
<SectionCard title="الصور والمستندات">
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" />
</div>
</SectionCard>
{/* ── Report section (full width) ── */}
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}
>
<div style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem",
}}>
<DriverReportPanel driverId={driver.id} />
</div>
</section>
{/* ── Edit modal ── */}
{editOpen && (
<DriverFormModal
editDriver={driver}
@@ -610,7 +382,6 @@ export default function DriverDetailPage() {
/>
)}
{/* ── Delete modal ── */}
{deleteOpen && (
<DriverDeleteModal
driver={driver}

View File

@@ -1,15 +1,12 @@
"use client";
import { useState, useCallback } from "react";
import { Alert, Spinner } from "@/Components/UI";
import { DriverFormModal } from "@/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/Components/Driver/DriverDeleteModal";
import { driverService } from "@/services";
import { getStoredToken } from "@/lib/auth";
import { useDrivers } from "@/hooks/useDriver";
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/types/driver";
import { DriverDetailPanel } from "@/Components/Driver/DriverDetailPanel";
import { Alert, Spinner } from "@/src/Components/UI";
import { DriverFormModal } from "@/src/Components/Driver/DriverFormModal";
import { DriverDeleteModal } from "@/src/Components/Driver/DriverDeleteModal";
import { useDrivers } from "@/src/hooks/useDriver";
import { CreateDriverPayload, Driver, DRIVER_STATUS_MAP, UpdateDriverPayload } from "@/src/types/driver";
import { DriverDetailPanel } from "@/src/Components/Driver/DriverDetailPanel";
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -46,13 +43,28 @@ const thStyle: React.CSSProperties = {
borderBottom: "1px solid var(--color-border)",
};
// Shared across header + rows — keep these in sync or columns will misalign.
const ROW_GRID_COLUMNS = "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr 0.8fr";
const iconBtnBase: React.CSSProperties = {
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
};
// ── Page Component ────────────────────────────────────────────────────────────
export default function DriversPage() {
const {
drivers, loading, error, total, pages, page,
search, setPage, handleSearch, clearError,
deleteDriver, notification, reload,
createDriver, updateDriver, deleteDriver,
notification, reload,
} = useDrivers();
// ── Panel / modal state ───────────────────────────────────────────────────
@@ -60,6 +72,8 @@ export default function DriversPage() {
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
const [deleting, setDeleting] = useState(false);
// Bumped after a successful edit to force the detail panel to re-fetch
const [panelRefreshKey, setPanelRefreshKey] = useState(0);
// ── Handlers ──────────────────────────────────────────────────────────────
const handleEdit = useCallback((driver: Driver) => {
@@ -77,34 +91,33 @@ export default function DriversPage() {
payload: CreateDriverPayload | UpdateDriverPayload,
isNew: boolean,
): Promise<boolean> => {
try {
const token = getStoredToken();
if (isNew) {
// Use multipart if there are file fields
const hasFiles =
(payload as CreateDriverPayload & { photo?: File }).photo ||
(payload as CreateDriverPayload & { nationalPhoto?: File }).nationalPhoto ||
(payload as CreateDriverPayload & { driverCardPhoto?: File }).driverCardPhoto;
let ok: boolean;
if (hasFiles) {
await driverService.createWithImages(
payload as Parameters<typeof driverService.createWithImages>[0],
token,
);
} else {
await driverService.create(payload as CreateDriverPayload, token);
}
} else {
const id = (formDriver as Driver).id;
await driverService.update(id, payload as UpdateDriverPayload, token);
}
reload();
return true;
} catch {
return false;
if (isNew) {
ok = await createDriver(
payload as CreateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
);
} else {
const id = (formDriver as Driver).id;
ok = await updateDriver(
id,
payload as UpdateDriverPayload & {
photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
},
);
// Re-fetch detail panel so updated status is visible immediately
if (ok && selectedDriverId) setPanelRefreshKey((k) => k + 1);
}
return ok;
},
[formDriver, reload],
[formDriver, createDriver, updateDriver, selectedDriverId],
);
const handleConfirmDelete = useCallback(async () => {
@@ -198,10 +211,7 @@ export default function DriversPage() {
{/* ── Notifications ── */}
{notification && (
<Alert
type={notification.type}
message={notification.message}
/>
<Alert type={notification.type} message={notification.message} />
)}
{error && (
<Alert type="error" message={error} onClose={clearError} />
@@ -213,7 +223,7 @@ export default function DriversPage() {
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
gridTemplateColumns: ROW_GRID_COLUMNS,
...thStyle,
}}
>
@@ -223,6 +233,7 @@ export default function DriversPage() {
<span>انتهاء الرخصة</span>
<span>انتهاء الهوية</span>
<span>الحالة</span>
<span>إجراءات</span>
</div>
{loading ? (
@@ -247,7 +258,7 @@ export default function DriversPage() {
onClick={() => setSelectedDriverId(d.id)}
style={{
display: "grid",
gridTemplateColumns: "2fr 1.2fr 1fr 1.1fr 1.1fr 1fr",
gridTemplateColumns: ROW_GRID_COLUMNS,
alignItems: "center", gap: "0.5rem",
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
@@ -259,29 +270,18 @@ export default function DriversPage() {
onMouseEnter={(e) => (e.currentTarget.style.background = "var(--color-surface-hover, #F8FAFC)")}
onMouseLeave={(e) => (e.currentTarget.style.background = i % 2 !== 0 ? "var(--color-surface-muted)" : "transparent")}
>
{/* Name + phone */}
<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>
{/* Branch */}
<span style={{ color: "var(--color-text-secondary)" }}>{d.branch?.name ?? "—"}</span>
{/* Nationality */}
<span style={{ color: "var(--color-text-secondary)" }}>{d.nationality ?? "—"}</span>
{/* License expiry */}
<span style={{ fontSize: 12, fontWeight: licWarn ? 600 : 400, color: licWarn ? "#D97706" : "var(--color-text-secondary)" }}>
{licWarn && "⚠ "}{fmtDate(d.licenseExpiry)}
</span>
{/* National ID expiry */}
<span style={{ fontSize: 12, fontWeight: idWarn ? 600 : 400, color: idWarn ? "#D97706" : "var(--color-text-secondary)" }}>
{idWarn && "⚠ "}{fmtDate((d as Driver & { nationalIdExpiry?: string }).nationalIdExpiry)}
</span>
{/* Status badge */}
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
borderRadius: "var(--radius-full)",
@@ -294,6 +294,50 @@ export default function DriversPage() {
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusCfg.dot, flexShrink: 0 }} />
{statusCfg.label}
</span>
{/* ── Inline row actions: edit / delete ──
stopPropagation is required on both buttons so the
click doesn't bubble up to the <li> onClick and
open the detail panel as well. */}
<div style={{ display: "flex", gap: "0.4rem" }}>
<button
type="button"
aria-label="تعديل السائق"
title="تعديل"
onClick={(e) => { e.stopPropagation(); handleEdit(d); }}
style={{
...iconBtnBase,
border: "1px solid var(--color-brand-200)",
background: "var(--color-brand-50, #EFF6FF)",
color: "var(--color-brand-600)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
type="button"
aria-label="حذف السائق"
title="حذف"
onClick={(e) => { e.stopPropagation(); handleDelete(d); }}
style={{
...iconBtnBase,
border: "1px solid #FECACA",
background: "#FEF2F2",
color: "#DC2626",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
</button>
</div>
</li>
);
})}
@@ -344,9 +388,10 @@ export default function DriversPage() {
</div>
</section>
{/* ── Detail panel ── */}
{/* ── Detail panel — key forces re-fetch after successful edit ── */}
{selectedDriverId && (
<DriverDetailPanel
key={`${selectedDriverId}-${panelRefreshKey}`}
driverId={selectedDriverId}
onClose={() => setSelectedDriverId(null)}
onEdit={handleEdit}

View File

@@ -11,8 +11,23 @@ export default function DashboardLayout({
className="flex min-h-screen"
style={{ background: "var(--color-surface-muted)" }}
>
{/*
Sidebar is still the first flex child. With dir="rtl" on <html>
(set in app/layout.tsx) this is enough to place the full
sidebar on the right with no positional CSS — see Sidebar.tsx.
*/}
<Sidebar />
<div className="flex min-w-0 flex-1 flex-col">
{/*
The compact icon rail (md and below) is `position: fixed`, so it
doesn't take up flex space. This wrapper reserves room for it
with `paddingInlineEnd`, which means "right padding in RTL,
left padding in LTR" — it never needs to be touched again if
direction changes.
*/}
<div
className="flex min-w-0 flex-1 flex-col md:[padding-inline-end:var(--sidebar-width-compact)] lg:[padding-inline-end:0]"
>
<Topbar />
<main className="flex-1 p-6">{children}</main>
</div>

View File

@@ -1,9 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { getStoredToken } from "@/lib/auth";
import { get } from "@/services/api";
import { Spinner, Alert } from "@/Components/UI";
import { getStoredToken } from "@/src/lib/auth";
import { get } from "@/src/services/api";
import { Spinner, Alert } from "@/src/Components/UI";
interface Order {
id: string;

View File

@@ -3,9 +3,9 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useMemo } from "react";
import { useDashboardSummary } from "../../hooks/useDashboardSummary";
import { clearAuth, getStoredUser } from "../../lib/auth";
import { Spinner, Alert } from "../../Components/UI";
import { useDashboardSummary } from "@/src/hooks/useDashboardSummary";
import { clearAuth, getStoredUser } from "@/src/lib/auth";
import { Spinner, Alert } from "@/src/Components/UI";
export default function DashboardPage() {
const router = useRouter();
@@ -48,15 +48,15 @@ export default function DashboardPage() {
backdropFilter: "blur(16px)",
}}
>
<p style={{ fontSize: 11, letterSpacing: "0.35em", textTransform: "uppercase", color: "#67E8F9" }}>
<p style={{ fontSize: 11, letterSpacing: "0.35em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>
لوحة العمليات
</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 style={{ fontSize: "clamp(1.5rem,3vw,2rem)", fontWeight: 600, color: "var(--color-text-dark-primary)", margin: 0, textAlign: "start" }}>
ذكاء الأسطول في لمحة سريعة
</h1>
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6 }}>
<p style={{ marginTop: "0.75rem", maxWidth: 680, color: "var(--color-text-dark-muted)", fontSize: 14, lineHeight: 1.6, textAlign: "start" }}>
عرض واضح لطلبات الأسطول وتنبيهات السلامة وتقدم الرحلات.
</p>
</div>
@@ -111,23 +111,28 @@ export default function DashboardPage() {
}}
>
<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)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
{item.label}
</p>
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>
<p style={{ fontSize: "2.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>
{item.value}
</p>
</article>
))}
</div>
{/* ── Active trips + alerts ── */}
{/* ── Active trips + alerts ──
Column order in the template below stays [trips, alerts] —
same reading order as before. In RTL this now renders
trips on the right (read first) and alerts on the left,
which is the correct mirror; no class change needed (see
grid + RTL note in the project report). */}
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<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>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#67E8F9", textAlign: "start" }}>الرحلات النشطة</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>تقدم الرحلات</h2>
</div>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(52,211,153,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#A7F3D0" }}>
مباشر
@@ -138,44 +143,54 @@ export default function DashboardPage() {
<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>
<p style={{ fontSize: 13, fontWeight: 600, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
<span className="ltr-embed">{trip.tripNumber}</span>
</p>
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>{trip.title}</p>
</div>
<span style={{ borderRadius: "var(--radius-full)", background: "rgba(103,232,249,0.10)", padding: "0.25rem 0.75rem", fontSize: 11, color: "#CFFAFE" }}>
{trip.progress}%
</span>
</div>
<div style={{ marginTop: "0.75rem", height: 6, borderRadius: 999, background: "rgba(255,255,255,0.08)" }}>
{/*
Progress fill: width % growing from the bar's
start edge is correct in both directions since
`width` itself is non-directional — the bar's
own inline-start (right, in RTL) is where the
fill begins, matching how the rest of the RTL
layout fills from the right. No change needed.
*/}
<div style={{ height: 6, borderRadius: 999, width: `${trip.progress}%`, background: "linear-gradient(90deg,#06B6D4,#34D399)" }} />
</div>
</div>
)) : (
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>لا توجد رحلات نشطة حالياً.</p>
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد رحلات نشطة حالياً.</p>
)}
</div>
</article>
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<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>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#FCD34D", textAlign: "start" }}>تنبيهات الالتزام</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>التجديدات القادمة</h2>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{(alerts?.expiringCars || []).slice(0, 3).map((item, i) => (
<div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}>
<div key={`car-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,191,36,0.20)", background: "rgba(251,191,36,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
{String((item as Record<string, unknown>).message || "Vehicle expiry alert")}
</div>
))}
{(alerts?.expiringDrivers || []).slice(0, 3).map((item, i) => (
<div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}>
<div key={`driver-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(251,113,133,0.20)", background: "rgba(244,63,94,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
{String((item as Record<string, unknown>).message || "Driver expiry alert")}
</div>
))}
{(alerts?.upcomingMaint || []).slice(0, 3).map((item, i) => (
<div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)" }}>
<div key={`maint-${i}`} style={{ borderRadius: "var(--radius-xl)", border: "1px solid rgba(103,232,249,0.20)", background: "rgba(103,232,249,0.06)", padding: "0.75rem 1rem", fontSize: 13, color: "var(--color-text-dark-primary)", textAlign: "start" }}>
{String((item as Record<string, unknown>).message || "Maintenance alert")}
</div>
))}
{!(alerts?.expiringCars?.length || alerts?.expiringDrivers?.length || alerts?.upcomingMaint?.length) && (
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)" }}>لا توجد تنبيهات حالياً.</p>
<p style={{ fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>لا توجد تنبيهات حالياً.</p>
)}
</div>
</article>
@@ -184,21 +199,29 @@ export default function DashboardPage() {
{/* ── 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>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#C4B5FD", textAlign: "start" }}>الأمان</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>لمحة الحساب</h2>
<div style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem" }}>
<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 style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
آخر تسجيل دخول: <span className="ltr-embed">{data?.accountSecurity?.lastLogin || "—"}</span>
</div>
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)" }}>
بيانات الجهاز: {data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}
<div style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 13, color: "var(--color-text-dark-muted)", textAlign: "start" }}>
{/*
JSON.stringify output is structurally LTR (braces,
colons, commas read left-to-right regardless of
locale). Wrapping it in .ltr-embed prevents the
surrounding RTL paragraph from reordering the
punctuation — without this, nested objects can
render with their braces visually swapped.
*/}
بيانات الجهاز: <span className="ltr-embed">{data?.accountSecurity?.requestMeta ? JSON.stringify(data.accountSecurity.requestMeta) : "—"}</span>
</div>
</div>
</article>
<article style={{ borderRadius: "var(--radius-2xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-card)", padding: "1.5rem", boxShadow: "var(--shadow-card)" }}>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7" }}>خريطة الخلفية</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem" }}>النقاط النهائية</h2>
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "#6EE7B7", textAlign: "start" }}>خريطة الخلفية</p>
<h2 style={{ fontSize: "1.25rem", fontWeight: 600, color: "var(--color-text-dark-primary)", marginTop: "0.5rem", textAlign: "start" }}>النقاط النهائية</h2>
<ul style={{ marginTop: "1.5rem", display: "flex", flexDirection: "column", gap: "0.75rem", listStyle: "none", padding: 0 }}>
{[
"/v1/dashboard/summary — نظرة عامة تشغيلية",
@@ -206,8 +229,18 @@ export default function DashboardPage() {
"/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 key={ep} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border-dark)", background: "var(--color-surface-dark-raised)", padding: "0.875rem 1rem", fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-dark-muted)", textAlign: "start" }}>
{/*
Each entry mixes an LTR API path with an Arabic
description. Splitting on the em-dash and
isolating only the path keeps the route string
from reading backwards while letting the Arabic
half flow naturally in the paragraph's own
direction.
*/}
<span className="ltr-embed">{ep.split(" — ")[0]}</span>
{" — "}
{ep.split(" — ")[1]}
</li>
))}
</ul>

View File

@@ -0,0 +1,238 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { Alert, Spinner } from "@/src/Components/UI";
import { RoleToast } from "@/src/Components/role/RoleToast";
import { useRoles } from "@/src/hooks/useRole";
import type { Role, Permission } from "@/src/services/role.service";
// ── Module label map ───────────────────────────────────────────────────────
const MODULE_LABELS: Record<string, string> = {
Role: "الأدوار", User: "المستخدمون", Branch: "الفروع",
Car: "المركبات", CarImage: "صور المركبات", Driver: "السائقون",
Order: "الطلبات", Trip: "الرحلات", Client: "العملاء",
Permission: "الصلاحيات", Audit: "سجل التدقيق",
Dashboard: "لوحة التحكم", Maintenance: "الصيانة",
};
const moduleLabel = (mod: string) => MODULE_LABELS[mod] ?? mod;
function groupByModule(permissions: Permission[]): Record<string, Permission[]> {
return permissions.reduce<Record<string, Permission[]>>((acc, p) => {
(acc[p.module || "أخرى"] ??= []).push(p);
return acc;
}, {});
}
// ── Permission Matrix ──────────────────────────────────────────────────────
function PermissionMatrix({
role,
allPermissions,
onSave,
}: {
role: Role;
allPermissions: Permission[];
onSave: (roleId: string, permIds: string[]) => Promise<boolean>;
}) {
const currentIds = role.permissions?.map(rp => rp.permission.id) ?? [];
const [selected, setSelected] = useState<Set<string>>(new Set(currentIds));
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const grouped = groupByModule(allPermissions);
const toggle = (id: string) => {
setSelected(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
setSaved(false);
};
const toggleModule = (mod: string) => {
const ids = (grouped[mod] ?? []).map(p => p.id);
const allChecked = ids.every(id => selected.has(id));
setSelected(prev => {
const next = new Set(prev);
allChecked ? ids.forEach(id => next.delete(id)) : ids.forEach(id => next.add(id));
return next;
});
setSaved(false);
};
const handleSave = async () => {
setSaving(true);
const ok = await onSave(role.id, [...selected]);
setSaving(false);
if (ok) setSaved(true);
};
return (
<div style={{
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", overflow: "hidden",
}}>
{/* Role header */}
<div style={{
padding: "1rem 1.5rem", borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "space-between",
}}>
<div>
<p style={{ fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>{role.name}</p>
{role.description && (
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}>{role.description}</p>
)}
<p style={{ fontSize: 11, color: "var(--color-text-hint)", marginTop: 4 }}>
{selected.size} من {allPermissions.length} صلاحية مفعّلة
</p>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
{saved && (
<span style={{ fontSize: 12, color: "#166534", fontWeight: 600 }}> تم الحفظ</span>
)}
<button type="button" onClick={handleSave} disabled={saving}
style={{
height: 36, padding: "0 1.25rem", borderRadius: "var(--radius-md)",
border: "none", background: saving ? "var(--color-brand-400)" : "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: saving ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", gap: 8, fontFamily: "var(--font-sans)",
}}>
{saving && <Spinner size="sm" className="text-white" />}
{saving ? "جارٍ الحفظ…" : "حفظ"}
</button>
</div>
</div>
{/* Module rows */}
<div style={{ padding: "0.75rem" }}>
{Object.entries(grouped).map(([mod, perms]) => {
const allChecked = perms.every(p => selected.has(p.id));
const someChecked = !allChecked && perms.some(p => selected.has(p.id));
return (
<div key={mod} style={{ marginBottom: "0.5rem" }}>
{/* Module header */}
<div style={{
display: "flex", alignItems: "center", gap: 10,
padding: "0.5rem 0.75rem",
background: "var(--color-surface-muted)",
borderRadius: "var(--radius-md)",
marginBottom: "0.375rem",
}}>
<input type="checkbox" checked={allChecked}
ref={el => { if (el) el.indeterminate = someChecked; }}
onChange={() => toggleModule(mod)}
style={{ width: 15, height: 15, cursor: "pointer", accentColor: "#2563EB" }} />
<span style={{ fontSize: 12, fontWeight: 700, color: "var(--color-text-primary)" }}>
{moduleLabel(mod)}
</span>
<span style={{ fontSize: 10, color: "var(--color-text-muted)", marginRight: "auto" }}>
{perms.filter(p => selected.has(p.id)).length}/{perms.length}
</span>
</div>
{/* Permissions */}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.375rem", paddingRight: "1.5rem" }}>
{perms.map(perm => {
const checked = selected.has(perm.id);
return (
<label key={perm.id} style={{
display: "inline-flex", alignItems: "center", gap: 6,
padding: "0.3rem 0.625rem",
borderRadius: "var(--radius-md)",
border: `1px solid ${checked ? "#BFDBFE" : "var(--color-border)"}`,
background: checked ? "#EFF6FF" : "var(--color-surface-muted)",
cursor: "pointer", userSelect: "none",
}}>
<input type="checkbox" checked={checked} onChange={() => toggle(perm.id)}
style={{ width: 13, height: 13, cursor: "pointer", accentColor: "#2563EB" }} />
<span style={{ fontSize: 11, fontWeight: 600, color: checked ? "#1D4ED8" : "var(--color-text-primary)" }}>
{perm.name}
</span>
</label>
);
})}
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function RolePermissionPage() {
const { roles, loading, error, permissions, clearError, updateRolePermissionsBulk, notification } = useRoles();
const [selectedRoleId, setSelectedRoleId] = useState<string>("");
const selectedRole = roles.find(r => r.id === selectedRoleId) ?? null;
const handleSave = useCallback(async (roleId: string, permIds: string[]): Promise<boolean> => {
return updateRolePermissionsBulk(roleId, permIds);
}, [updateRolePermissionsBulk]);
return (
<>
<RoleToast notification={notification} />
<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)" }}>
إدارة صلاحيات كل دور بشكل مجمَّع
</p>
</div>
<div style={{ minWidth: 240 }}>
<select value={selectedRoleId} onChange={e => setSelectedRoleId(e.target.value)} dir="rtl"
style={{ width: "100%", height: 40, padding: "0 0.75rem", 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)" }}>
<option value="">اختر دوراً لتعديل صلاحياته</option>
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</div>
</div>
</header>
{error && <Alert type="error" message={error} onClose={clearError} />}
{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>
) : !selectedRole ? (
<div style={{
borderRadius: "var(--radius-xl)", border: "2px dashed var(--color-border)",
background: "var(--color-surface)", padding: "4rem 2rem", textAlign: "center",
}}>
<p style={{ fontSize: 32, margin: 0 }}>🔐</p>
<p style={{ marginTop: 12, fontSize: 15, fontWeight: 700, color: "var(--color-text-primary)" }}>
اختر دوراً من القائمة أعلاه
</p>
<p style={{ marginTop: 4, fontSize: 13, color: "var(--color-text-muted)" }}>
ستظهر هنا صلاحيات الدور المحدد وبإمكانك تعديلها مباشرة.
</p>
</div>
) : (
<PermissionMatrix
key={selectedRole.id}
role={selectedRole}
allPermissions={permissions}
onSave={handleSave}
/>
)}
</section>
</>
);
}

View File

@@ -1,3 +1,143 @@
"use client";
import { useState } from "react";
import { Alert } from "@/src/Components/UI";
import { RoleTable } from "@/src/Components/role/RoleTable";
import { RoleFormModal } from "@/src/Components/role/RoleFormModal";
import { RoleDetailModal } from "@/src/Components/role/RoleDetailModal";
import { DeleteRoleModal } from "@/src/Components/role/DeleteRoleModal";
import { RoleToast } from "@/src/Components/role/RoleToast";
import { useRoles } from "@/src/hooks/useRole";
import type { Role, RoleFormData } from "@/src/services/role.service";
export default function RolesPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Roles and permissions module is ready for RBAC assignment and matrix views.</section>;
}
const {
roles, loading, total, pages, error,
permissions,
page, search,
setPage, handleSearch, clearError,
createRole, updateRole, deleteRole,
updateRolePermissionsBulk,
notification,
} = useRoles();
// false = closed | null = create mode | Role = edit mode
const [formTarget, setFormTarget] = useState<Role | null | false>(false);
const [viewTarget, setViewTarget] = useState<Role | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Role | null>(null);
const [deleting, setDeleting] = useState(false);
const handleFormSubmit = async (
data: RoleFormData,
currentPermIds: string[],
): Promise<boolean> => {
if (formTarget === null) {
return createRole(data);
}
return updateRole((formTarget as Role).id, data, currentPermIds);
};
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return;
setDeleting(true);
const ok = await deleteRole(deleteTarget.id);
setDeleting(false);
if (ok) setDeleteTarget(null);
};
const handleSavePermissions = async (
roleId: string,
permissionIds: string[],
): Promise<boolean> => {
return updateRolePermissionsBulk(roleId, permissionIds);
};
return (
<>
<RoleToast notification={notification} />
{formTarget !== false && (
<RoleFormModal
editRole={formTarget}
permissions={permissions}
onClose={() => setFormTarget(false)}
onSubmit={handleFormSubmit}
/>
)}
{viewTarget && (
<RoleDetailModal
role={viewTarget}
allPermissions={permissions}
onClose={() => setViewTarget(null)}
onSave={handleSavePermissions}
/>
)}
{deleteTarget && (
<DeleteRoleModal
role={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
)}
<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", alignItems: "center", flexWrap: "wrap" }}>
<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, color: "var(--color-text-primary)", 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} />}
<RoleTable
roles={roles}
loading={loading}
search={search}
page={page}
pages={pages}
onView={role => setViewTarget(role)}
onEdit={role => setFormTarget(role)}
onDelete={role => setDeleteTarget(role)}
onAddFirst={() => setFormTarget(null)}
onPageChange={setPage}
/>
</section>
</>
);
}

View File

@@ -1,19 +1,38 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Spinner } from "@/Components/UI";
import { TripFormModal } from "@/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/Components/Trip/Tripdeletemodal";
import { TripReportPanel } from "@/Components/Trip_Report/Tripreportpanel";
import { tripService } from "@/services/trip.service";
import { getStoredToken } from "@/lib/auth";
import type {
Trip,
CreateTripPayload,
UpdateTripPayload,
} from "@/types/trip";
import { TRIP_STATUS_MAP } from "@/types/trip";
import { Spinner } from "@/src/Components/UI";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
// ── API error extractor ────────────────────────────────────────────────────
// Same shape-walking logic as useTrip.ts — duplicated here on purpose so this
// page no longer depends on the list hook for a single piece of UI feedback.
function extractApiMessage(err: unknown, fallback: string): string {
if (typeof err === "string" && err.trim()) return err.trim();
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"];
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — ");
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"];
}
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -23,19 +42,26 @@ function fmtDate(iso?: string | null): string {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function fmtNumber(n?: number | string | null): string {
if (n == null || n === "") return "—";
return Number(n).toLocaleString("ar-SA");
function fmtDateTime(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("ar-SA", {
dateStyle: "medium",
timeStyle: "short",
});
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) {
function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div
style={{
@@ -46,19 +72,23 @@ function SectionCard({ title, children }: { title: string; children: React.React
boxShadow: "var(--shadow-card)",
}}
>
<div style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}>
<p style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}>
<div
style={{
padding: "0.875rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
}}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.25em",
textTransform: "uppercase",
color: "var(--color-text-hint)",
fontWeight: 700,
margin: 0,
}}
>
{title}
</p>
</div>
@@ -71,63 +101,70 @@ function DetailRow({
label,
value,
mono = false,
warn = false,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) {
return (
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "0.55rem 0",
borderBottom: "1px solid var(--color-border)",
}}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
padding: "0.55rem 0",
borderBottom: "1px solid var(--color-border)",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>
{label}
</span>
<span style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: "var(--color-text-primary)",
maxWidth: "60%",
textAlign: "left",
wordBreak: "break-word",
}}>
<span
style={{
fontSize: 13,
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)",
color: warn ? "#D97706" : "var(--color-text-primary)",
fontWeight: warn ? 600 : 400,
maxWidth: "60%",
textAlign: "left",
wordBreak: "break-word",
}}
>
{warn && value !== "—" ? "⚠ " : ""}
{value}
</span>
</div>
);
}
// ── Stat card (للأعداد) ───────────────────────────────────────────────────────
function StatCard({
label,
value,
color = "var(--color-brand-600)",
bg = "var(--color-brand-50, #EFF6FF)",
color,
}: {
label: string;
value: string;
color?: string;
bg?: string;
value: number | string;
color: string;
}) {
return (
<div style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: bg,
padding: "1rem 1.25rem",
display: "flex",
flexDirection: "column",
gap: 4,
}}>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)" }}>
<div
style={{
padding: "1rem 1.25rem",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
boxShadow: "var(--shadow-card)",
display: "flex",
flexDirection: "column",
gap: 4,
}}
>
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", letterSpacing: "0.05em" }}>
{label}
</span>
<span style={{ fontSize: 22, fontWeight: 700, color, fontFamily: "var(--font-mono)" }}>
<span style={{ fontSize: 22, fontWeight: 800, color, fontFamily: "var(--font-mono)" }}>
{value}
</span>
</div>
@@ -141,14 +178,31 @@ export default function TripDetailPage() {
const router = useRouter();
const tripId = params?.tripId as string;
const [trip, setTrip] = useState<Trip | null>(null);
const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deleting, setDeleting] = useState(false);
// ── Notifications (standalone — no longer borrowed from the list hook) ───
const [notification, setNotification] = useState<ToastNotification | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []);
const dismissNotification = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
}, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// ── Load trip ─────────────────────────────────────────────────────────────
const loadTrip = useCallback(async () => {
@@ -166,7 +220,9 @@ export default function TripDetailPage() {
}
}, [tripId]);
useEffect(() => { loadTrip(); }, [loadTrip]);
useEffect(() => {
loadTrip();
}, [loadTrip]);
// ── Edit submit ───────────────────────────────────────────────────────────
const handleEditSubmit = useCallback(
@@ -177,299 +233,317 @@ export default function TripDetailPage() {
if (!trip) return false;
try {
const token = getStoredToken();
await tripService.update(trip.id, payload as UpdateTripPayload, token);
await loadTrip();
const res = await tripService.update(trip.id, payload as UpdateTripPayload, token);
const updated = (res as unknown as { data: Trip }).data;
setTrip(updated);
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
return true;
} catch {
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") });
return false;
}
},
[trip, loadTrip],
[trip, notify],
);
// ── Delete ────────────────────────────────────────────────────────────────
// ── Delete confirm ────────────────────────────────────────────────────────
const handleConfirmDelete = useCallback(async () => {
if (!trip) return;
setDeleting(true);
try {
const token = getStoredToken();
await tripService.delete(trip.id, token);
router.back();
} catch {
router.push("/dashboard/trips");
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
setDeleting(false);
setDeleteOpen(false);
}
}, [trip, router]);
}, [trip, router, notify]);
// ── Status config ─────────────────────────────────────────────────────────
const statusConfig = trip ? TRIP_STATUS_MAP[trip.status] : null;
// ── Loading ───────────────────────────────────────────────────────────────
// ── Render: Loading ───────────────────────────────────────────────────────
if (loading) {
return (
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
<Spinner size="lg" />
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "6rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ التحميل</span>
</div>
);
}
// ── Error ─────────────────────────────────────────────────────────────────
// ── Render: Error ─────────────────────────────────────────────────────────
if (error || !trip) {
return (
<div style={{ padding: "2rem", textAlign: "center" }}>
<p style={{ fontSize: 14, color: "var(--color-danger)", marginBottom: "1rem" }}>
{error ?? "الرحلة غير موجودة."}
<div
style={{
maxWidth: 480,
margin: "4rem auto",
borderRadius: "var(--radius-xl)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "1.5rem",
textAlign: "center",
}}
>
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "الرحلة غير موجودة"}
</p>
<button
type="button"
onClick={() => router.back()}
style={{
height: 38, padding: "0 1.25rem",
marginTop: "1rem",
height: 38,
padding: "0 1.25rem",
borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600, cursor: "pointer",
fontSize: 13,
fontWeight: 600,
color: "var(--color-text-secondary)",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
العودة
رجوع
</button>
</div>
);
}
// ── Render: Trip detail ────────────────────────────────────────────────────
return (
<>
<section style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* ── Toast notification ── */}
<Toast notification={notification} onDismiss={dismissNotification} />
{/* ── Header ── */}
<header style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}>
{/* Back + trip info */}
<div style={{ display: "flex", alignItems: "center", gap: "1rem" }}>
<button
type="button"
onClick={() => router.back()}
style={{
width: 38, height: 38, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
display: "flex", alignItems: "center", justifyContent: "center",
cursor: "pointer", flexShrink: 0,
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}>
{/* Trip icon */}
<div style={{
width: 52, height: 52, borderRadius: "var(--radius-lg)",
background: "var(--color-brand-50, #EFF6FF)",
border: "1px solid var(--color-brand-200, #BFDBFE)",
display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0,
}}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#2563EB" strokeWidth="1.8">
<rect x="1" y="3" width="15" height="13" rx="2" />
<path d="M16 8h4l3 5v4h-7V8z" />
<circle cx="5.5" cy="18.5" r="2.5" />
<circle cx="18.5" cy="18.5" r="2.5" />
</svg>
</div>
{/* ── Page 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)",
}}
>
{/* Back link */}
<button
type="button"
onClick={() => router.back()}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 12,
fontWeight: 600,
color: "var(--color-text-muted)",
background: "none",
border: "none",
cursor: "pointer",
padding: 0,
marginBottom: "1rem",
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M19 12H5M12 5l-7 7 7 7" />
</svg>
العودة إلى قائمة الرحلات
</button>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<h1 style={{ fontSize: 18, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}>
{trip.title}
</h1>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
{trip.tripNumber}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}>
{/* Icon + title */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div
style={{
width: 72,
height: 72,
borderRadius: "50%",
overflow: "hidden",
flexShrink: 0,
border: "2px solid var(--color-brand-200)",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>
{trip.title.charAt(0)}
</span>
{statusConfig && (
<span style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</div>
<div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>
ملف الرحلة
</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>
{trip.title}
</h1>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}>
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>
#{trip.tripNumber}
</span>
)}
{trip.isDeleted && (
<span style={{
borderRadius: "var(--radius-full)",
border: "1px solid #FECACA",
background: "#FEF2F2",
padding: "0.2rem 0.625rem",
fontSize: 11, fontWeight: 700, color: "#DC2626",
}}>
محذوفة
</span>
)}
{statusConfig && (
<span
style={{
borderRadius: "var(--radius-full)",
border: `1px solid ${statusConfig.border}`,
background: statusConfig.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 700,
color: statusConfig.color,
display: "inline-flex",
alignItems: "center",
gap: 5,
}}
>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} />
{statusConfig.label}
</span>
)}
</div>
</div>
</div>
</div>
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button
type="button"
onClick={() => setDeleteOpen(true)}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13, fontWeight: 700, color: "#DC2626",
cursor: "pointer", fontFamily: "var(--font-sans)",
}}
>
حذف الرحلة
</button>
<button
type="button"
onClick={() => setEditOpen(true)}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل الرحلة
</button>
{/* Actions */}
<div style={{ display: "flex", gap: "0.75rem" }}>
<button
type="button"
onClick={() => setDeleteOpen(true)}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "1px solid #FECACA",
background: "#FEF2F2",
fontSize: 13,
fontWeight: 700,
color: "#DC2626",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}}
>
حذف الرحلة
</button>
<button
type="button"
onClick={() => setEditOpen(true)}
style={{
height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)",
border: "none",
background: "var(--color-brand-600)",
fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
تعديل الرحلة
</button>
</div>
</div>
</header>
{/* ── Stats row ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "1rem" }}>
<StatCard
label="المجمّع"
value={String(trip.collectedCount)}
color="#1E40AF"
bg="#EFF6FF"
/>
<StatCard
label="المُسلَّم"
value={String(trip.deliveredCount)}
color="#166534"
bg="#DCFCE7"
/>
<StatCard
label="المُرتجع"
value={String(trip.returnedCount)}
color="#92400E"
bg="#FFFBEB"
/>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "0.75rem" }}>
<StatCard label="المجمّع" value={trip.collectedCount} color="var(--color-brand-600)" />
<StatCard label="المُسلَّم" value={trip.deliveredCount} color="#16A34A" />
<StatCard label="المُرتجع" value={trip.returnedCount} color="#D97706" />
<StatCard
label="النقد المحصّل"
value={fmtNumber(trip.totalCashCollected)}
color="#581C87"
bg="#FAF5FF"
value={trip.totalCashCollected != null ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` : "—"}
color="#7C3AED"
/>
</div>
{/* ── Content grid ── */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
{/* Basic Info */}
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
}}
>
{/* Trip Info */}
<SectionCard title="بيانات الرحلة">
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
<DetailRow label="العنوان" value={trip.title} />
<DetailRow label="الحالة" value={statusConfig?.label ?? trip.status} />
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
<DetailRow label="وقت البدء" value={fmtDate(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmtDate(trip.endTime)} />
<DetailRow label="سبب الإنهاء" value={trip.endReason ?? "—"} />
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
{trip.endReason && <DetailRow label="سبب الإنهاء" value={trip.endReason} warn />}
</SectionCard>
{/* Driver */}
<SectionCard title="السائق">
{trip.driver ? (
<>
<DetailRow label="الاسم" value={trip.driver.name} />
<DetailRow label="الجوال" value={trip.driver.phone} mono />
<DetailRow label="البريد" value={trip.driver.email ?? "—"} />
<DetailRow label="رقم الرخصة" value={trip.driver.licenseNumber ?? "—"} mono />
<DetailRow label=قم البطاقة" value={trip.driver.driverCardNumber ?? "—"} mono />
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} />
</>
) : (
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسائق.</p>
)}
</SectionCard>
{trip.driver && (
<SectionCard title="بيانات السائق">
<DetailRow label="الاسم" value={trip.driver.name} />
<DetailRow label="الجوال" value={trip.driver.phone} mono />
<DetailRow label="اسم المستخدم" value={trip.driver.userName ?? "—"} mono />
<DetailRow label="البريد" value={trip.driver.email ?? "—"} />
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} />
<DetailRow label=خصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono />
<DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono />
<DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} mono />
</SectionCard>
)}
{/* Car */}
<SectionCard title="السيارة">
{trip.car ? (
<>
<DetailRow label="الماركة" value={trip.car.manufacturer} />
<DetailRow label="الموديل" value={trip.car.model} />
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
<DetailRow label="نوع اللوحة" value={trip.car.plateType ?? "—"} />
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
</>
) : (
<p style={{ fontSize: 13, color: "var(--color-text-muted)", margin: 0 }}>لا توجد بيانات للسيارة.</p>
)}
</SectionCard>
{trip.car && (
<SectionCard title="بيانات السيارة">
<DetailRow label="الشركة" value={trip.car.manufacturer} />
<DetailRow label="الموديل" value={trip.car.model} />
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} />
<DetailRow label="اللون" value={trip.car.color ?? "—"} />
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono />
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono />
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono />
</SectionCard>
)}
{/* System Info */}
<SectionCard title="معلومات النظام">
<DetailRow label="تاريخ الإضافة" value={fmtDate(trip.createdAt)} />
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
{trip.isDeleted && (
<DetailRow label="تاريخ الحذف" value={fmtDate(trip.deletedAt)} />
)}
</SectionCard>
</div>
{/* ── Notes (full width, if any) ── */}
{trip.notes && (
<SectionCard title="الملاحظات">
<p style={{ fontSize: 13, color: "var(--color-text-primary)", lineHeight: 1.7, margin: 0 }}>
{trip.notes}
</p>
</SectionCard>
)}
{/* ── Report section ── */}
<div style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}>
{/* ── Report section (full width) ── */}
<div
style={{
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}
>
<TripReportPanel tripId={trip.id} />
</div>

View File

@@ -1,3 +1,375 @@
export default function TripsPage() {
return <section className="rounded-3xl border border-white/10 bg-slate-900/80 p-6 text-slate-100 shadow-2xl shadow-slate-950/20">Trips module is ready for scheduling, status tracking, and manifest generation.</section>;
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useTrips } from "@/src/hooks/useTrip";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
import { TripDeleteModal } from "@/src/Components/Trip/Tripdeletemodal";
import { Spinner } from "@/src/Components/UI";
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
import type { Trip, TripStatus, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
// ── API error extractor ────────────────────────────────────────────────────
// Same logic as the detail page / useTrip.ts — kept local so create/update
// here can notify independently of the list hook's own notification state.
function extractApiMessage(err: unknown, fallback: string): string {
if (typeof err === "string" && err.trim()) return err.trim();
if (err && typeof err === "object") {
const e = err as Record<string, unknown>;
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"];
if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"];
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — ");
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"];
}
if (typeof e["message"] === "string" && e["message"].trim()) return e["message"];
}
return fallback;
}
// ── Status badge ─────────────────────────────────────────────────────────────
function StatusBadge({ status }: { status: TripStatus }) {
const s = TRIP_STATUS_MAP[status];
return (
<span style={{
display: "inline-flex", alignItems: "center", gap: 5,
padding: "3px 10px", borderRadius: 999,
fontSize: 11, fontWeight: 700, letterSpacing: "0.02em",
color: s.color, background: s.bg, border: `1px solid ${s.border}`,
}}>
<span style={{ width: 6, height: 6, borderRadius: "50%", background: s.dot, flexShrink: 0 }} />
{s.label}
</span>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default function TripsPage() {
const router = useRouter();
const {
trips, total, pages, loading, error,
page, setPage,
search, handleSearch,
status, handleStatusFilter,
reload,
} = useTrips();
// ── Notifications (standalone — fired explicitly by every action below) ──
const [notification, setNotification] = useState<ToastNotification | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(n);
timerRef.current = setTimeout(() => setNotification(null), 4000);
}, []);
const dismissNotification = useCallback(() => {
if (timerRef.current) clearTimeout(timerRef.current);
setNotification(null);
}, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
// Local input value is debounced before it reaches the hook's `search`,
// exactly like the table reloads only after the user stops typing.
const [searchInput, setSearchInput] = useState(search);
useEffect(() => {
const t = setTimeout(() => handleSearch(searchInput), 400);
return () => clearTimeout(t);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchInput]);
const [showForm, setShowForm] = useState(false);
const [editTrip, setEditTrip] = useState<Trip | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Trip | null>(null);
const [deleting, setDeleting] = useState(false);
// ── Create / update — calls tripService directly and notifies explicitly ──
const handleSubmit = async (
payload: CreateTripPayload | UpdateTripPayload,
isNew: boolean,
): Promise<boolean> => {
try {
const token = getStoredToken();
if (isNew) {
await tripService.create(payload as CreateTripPayload, token);
notify({ type: "success", message: "تم إضافة الرحلة بنجاح." });
} else {
await tripService.update(editTrip!.id, payload as UpdateTripPayload, token);
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
}
reload();
return true;
} catch (err) {
notify({
type: "error",
message: extractApiMessage(err, isNew ? "تعذّر إضافة الرحلة." : "تعذّر تحديث الرحلة."),
});
return false;
}
};
const handleDelete = async () => {
if (!deleteTarget) return;
setDeleting(true);
try {
const token = getStoredToken();
await tripService.delete(deleteTarget.id, token);
notify({ type: "success", message: "تم حذف الرحلة بنجاح." });
setDeleteTarget(null);
reload();
} catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") });
} finally {
setDeleting(false);
}
};
return (
<div dir="rtl" style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.25rem" }}>
{/* ── Header ── */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "0.75rem" }}>
<div>
<h1 style={{ fontSize: 20, fontWeight: 800, color: "var(--color-text-primary)", margin: 0 }}>الرحلات</h1>
<p style={{ margin: "2px 0 0", fontSize: 12, color: "var(--color-text-muted)" }}>
إدارة جدولة الرحلات وتتبع حالتها
</p>
</div>
<button
onClick={() => { setEditTrip(null); setShowForm(true); }}
style={{
height: 40, padding: "0 1.25rem",
borderRadius: "var(--radius-md)", border: "none",
background: "var(--color-brand-600)", color: "#FFF",
fontSize: 13, fontWeight: 700, cursor: "pointer",
display: "flex", alignItems: "center", gap: 8,
fontFamily: "var(--font-sans)",
}}
>
<svg width="16" height="16" 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>
{/* ── Filters ── */}
<div style={{
display: "flex", gap: "0.75rem", flexWrap: "wrap",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-lg)",
padding: "0.875rem 1rem",
}}>
<input
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
placeholder="بحث برقم الرحلة أو العنوان…"
dir="rtl"
style={{
flex: 1, minWidth: 200, height: 38, padding: "0 0.75rem",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", fontSize: 13,
color: "var(--color-text-primary)", fontFamily: "var(--font-sans)", outline: "none",
}}
/>
<select
value={status}
onChange={e => handleStatusFilter(e.target.value as TripStatus | "")}
dir="rtl"
style={{
height: 38, padding: "0 0.75rem", minWidth: 140,
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", fontSize: 13,
color: "var(--color-text-primary)", fontFamily: "var(--font-sans)",
cursor: "pointer", outline: "none",
}}
>
<option value="">كل الحالات</option>
<option value="Scheduled">مجدولة</option>
<option value="InProgress">جارية</option>
<option value="Completed">مكتملة</option>
<option value="Cancelled">ملغاة</option>
</select>
</div>
{/* ── Table card ── */}
<div style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
boxShadow: "0 1px 4px rgba(0,0,0,.06)",
}}>
{loading ? (
<div style={{ padding: "3rem", display: "flex", justifyContent: "center" }}>
<Spinner size="md" />
</div>
) : error ? (
<div style={{ padding: "2rem", textAlign: "center", color: "var(--color-danger)", fontSize: 13 }}>
{error}
</div>
) : trips.length === 0 ? (
<div style={{ padding: "3rem", textAlign: "center", color: "var(--color-text-muted)", fontSize: 13 }}>
لا توجد رحلات مطابقة
</div>
) : (
<div style={{ overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}>
<thead>
<tr style={{ background: "var(--color-surface-muted)", borderBottom: "1px solid var(--color-border)" }}>
{["رقم الرحلة", "العنوان", "السائق", "السيارة", "الحالة", "البدء", "الانتهاء", "إجراءات"].map(h => (
<th key={h} style={{
padding: "0.75rem 1rem", textAlign: "right", fontWeight: 700,
color: "var(--color-text-secondary)", fontSize: 11,
letterSpacing: "0.03em", whiteSpace: "nowrap",
}}>{h}</th>
))}
</tr>
</thead>
<tbody>
{trips.map((trip, i) => (
<tr
key={trip.id}
onClick={() => router.push(`/dashboard/trips/${trip.id}`)}
style={{
borderBottom: i < trips.length - 1 ? "1px solid var(--color-border)" : "none",
cursor: "pointer",
transition: "background 0.15s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "var(--color-surface-muted)")}
onMouseLeave={e => (e.currentTarget.style.background = "")}
>
<td style={{ padding: "0.875rem 1rem", whiteSpace: "nowrap" }}>
<span style={{ fontFamily: "var(--font-mono)", fontSize: 12, color: "var(--color-brand-600)", fontWeight: 700 }}>
{trip.tripNumber}
</span>
</td>
<td style={{ padding: "0.875rem 1rem", fontWeight: 600, color: "var(--color-text-primary)", maxWidth: 200 }}>
<span style={{ display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{trip.title}
</span>
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-secondary)", whiteSpace: "nowrap" }}>
{trip.driver?.name ?? "—"}
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-secondary)", whiteSpace: "nowrap" }}>
{trip.car ? `${trip.car.manufacturer} ${trip.car.model}` : "—"}
</td>
<td style={{ padding: "0.875rem 1rem" }}>
<StatusBadge status={trip.status} />
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
{trip.startTime ? new Date(trip.startTime).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" }) : "—"}
</td>
<td style={{ padding: "0.875rem 1rem", color: "var(--color-text-muted)", fontSize: 12, whiteSpace: "nowrap" }}>
{trip.endTime ? new Date(trip.endTime).toLocaleString("ar-SA", { dateStyle: "short", timeStyle: "short" }) : "—"}
</td>
<td style={{ padding: "0.875rem 1rem" }} onClick={e => e.stopPropagation()}>
<div style={{ display: "flex", gap: 6 }}>
<button
onClick={() => { setEditTrip(trip); setShowForm(true); }}
title="تعديل"
style={{
width: 30, height: 30, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)", background: "var(--color-surface)",
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
color: "var(--color-text-muted)",
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button
onClick={() => setDeleteTarget(trip)}
title="حذف"
style={{
width: 30, height: 30, borderRadius: "var(--radius-md)",
border: "1px solid #FECACA", background: "#FEF2F2",
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
color: "#DC2626",
}}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<polyline points="3 6 5 6 21 6" />
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6M14 11v6" />
</svg>
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ── Pagination ── */}
{pages > 1 && (
<div style={{
padding: "0.875rem 1rem",
borderTop: "1px solid var(--color-border)",
display: "flex", alignItems: "center", justifyContent: "space-between",
background: "var(--color-surface-muted)",
}}>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
{total} رحلة صفحة {page} من {pages}
</span>
<div style={{ display: "flex", gap: 6 }}>
{[...Array(pages)].map((_, idx) => (
<button
key={idx}
onClick={() => setPage(idx + 1)}
style={{
width: 32, height: 32, borderRadius: "var(--radius-md)",
border: "1px solid var(--color-border)",
background: page === idx + 1 ? "var(--color-brand-600)" : "var(--color-surface)",
color: page === idx + 1 ? "#FFF" : "var(--color-text-secondary)",
fontSize: 13, fontWeight: 600, cursor: "pointer",
}}
>
{idx + 1}
</button>
))}
</div>
</div>
)}
</div>
{/* ── Modals ── */}
{showForm && (
<TripFormModal
editTrip={editTrip}
onClose={() => setShowForm(false)}
onSubmit={handleSubmit}
/>
)}
{deleteTarget && (
<TripDeleteModal
trip={deleteTarget}
deleting={deleting}
onCancel={() => setDeleteTarget(null)}
onConfirm={handleDelete}
/>
)}
{/* ── Toast ── */}
<Toast notification={notification} onDismiss={dismissNotification} />
</div>
);
}

View File

@@ -1,14 +1,13 @@
"use client";
import { useState } from "react";
import { Alert } from "@/Components/UI";
import { UserTable } from "@/Components/User/UserTable";
import { UserFormModal } from "@/Components/User/UserFormModal";
import { UserDetailModal } from "@/Components/User/UserDetailModal";
import { DeleteConfirmModal } from "@/Components/User/DeleteConfirmModal";
import { Toast } from "@/Components/User/Toast";
import { useUsers } from "@/hooks/useUser";
import type { User, UserFormData } from "@/types/user";
import { Alert, Toast } from "@/src/Components/UI";
import { UserTable } from "@/src/Components/User/UserTable";
import { UserFormModal } from "@/src/Components/User/UserFormModal";
import { UserDetailModal } from "@/src/Components/User/UserDetailModal";
import { DeleteConfirmModal } from "@/src/Components/User/DeleteConfirmModal";
import { useUsers } from "@/src/hooks/useUser";
import type { User, UserFormData } from "@/src/types/user";
export default function UsersPage() {
// ── Modal state ─────────────────────────────────────────────────────────────
@@ -41,9 +40,9 @@ export default function UsersPage() {
const handleDeleteConfirm = async () => {
if (!deleteTarget || deleting) return;
setDeleting(true);
console.log(setDeleting(true))
const ok = await deleteUser(deleteTarget.id);
console.log(ok)
setDeleting(false);
if (ok) setDeleteTarget(null);
};

View File

@@ -1,12 +1,61 @@
import Link from "next/link";
export default function ForbiddenPage() {
return (
<main className="min-h-screen bg-[linear-gradient(135deg,#020617_0%,#111827_45%,#172554_100%)] text-slate-100 flex items-center justify-center px-6 py-10">
<section className="w-full max-w-xl rounded-3xl border border-rose-400/20 bg-slate-900/85 p-8 shadow-2xl shadow-slate-950/30 text-center">
<p className="text-sm uppercase tracking-[0.35em] text-rose-200">Access denied</p>
<h1 className="mt-4 text-3xl font-semibold text-white">You do not have permission to open this section.</h1>
<p className="mt-3 text-slate-300">Contact an administrator to request access to the required module.</p>
<a href="/dashboard" className="mt-6 inline-flex rounded-full bg-cyan-400 px-4 py-2 text-sm font-semibold text-slate-950 hover:bg-cyan-300">Return to dashboard</a>
</section>
<main
dir="rtl"
style={{
minHeight: "100vh",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
fontFamily: "var(--font-sans)",
}}
>
<div style={{ textAlign: "center", maxWidth: 480 }}>
<div style={{
width: 80, height: 80, margin: "0 auto",
borderRadius: "50%", background: "#FEF2F2",
border: "2px solid #FECACA",
display: "flex", alignItems: "center", justifyContent: "center",
}}>
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#DC2626" strokeWidth="1.5">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<p style={{
fontSize: "3rem", fontWeight: 800,
color: "#DC2626", lineHeight: 1,
margin: "1rem 0 0", fontFamily: "var(--font-mono)",
}}>403</p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", marginTop: "0.75rem" }}>
غير مصرح بالوصول
</h1>
<p style={{ fontSize: 14, color: "var(--color-text-muted)", marginTop: "0.75rem", lineHeight: 1.7 }}>
ليس لديك الصلاحية للوصول إلى هذه الصفحة. إذا كنت تعتقد أن هذا خطأ، تواصل مع مسؤول النظام.
</p>
<div style={{ marginTop: "2rem", display: "flex", gap: "0.75rem", justifyContent: "center" }}>
<Link href="/login"
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
textDecoration: "none",
display: "inline-flex", alignItems: "center",
}}>
تسجيل الدخول بحساب آخر
</Link>
</div>
</div>
</main>
);
}
}

View File

@@ -1,4 +1,4 @@
@import "../styles/tokens.css";
@import "../src/styles/tokens.css";
@import "tailwindcss";
@@ -12,7 +12,11 @@
html {
scroll-behavior: smooth;
font-size: 14px;
/* RTL is set per route-group layout, not globally */
/* `dir` is now set on <html> in app/layout.tsx (lang="ar" dir="rtl").
Tailwind v4 ships native `rtl:` / `ltr:` variants that key off this
attribute, so utility classes like `rtl:flex-row-reverse` work
without a plugin. Route groups no longer need to set `dir`
individually — see report Section 1. */
}
body {
@@ -85,7 +89,17 @@ body {
}
/* ── Focus ring utility ─────────────────────────────── */
/* Unchanged: box-shadow rings are direction-neutral. */
.focus-ring {
outline: none;
box-shadow: 0 0 0 2px var(--color-brand-600);
}
/* ── RTL icon mirroring ─────────────────────────────────
Directional glyphs (chevrons, arrows, "back") need to visually
point the opposite way in RTL even though the underlying icon
font glyph doesn't change. Apply .icon-flip-rtl to any <i>/<svg>
whose meaning is "forward"/"back" rather than purely decorative. */
[dir="rtl"] .icon-flip-rtl {
transform: scaleX(-1);
}

View File

@@ -1,32 +1,33 @@
// app/layout.tsx
// Root layout — single location for font loading and global metadata.
// No inline styles, no @import in CSS.
import type { Metadata } from "next";
import { Plus_Jakarta_Sans, IBM_Plex_Mono } from "next/font/google";
import "@tabler/icons-webfont/dist/tabler-icons.min.css";
import "./globals.css";
const jakartaSans = Plus_Jakarta_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-sans",
display: "swap",
});
const ibmPlexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "500"],
variable: "--font-mono",
display: "swap",
});
/**
* NOT SHOWN IN THE ORIGINAL FILE SET.
* This is the one file every RTL conversion ultimately hinges on:
* `dir="rtl"` and `lang="ar"` belong on the root <html> element so
* (a) the browser's native bidi algorithm kicks in everywhere,
* (b) Tailwind v4's `rtl:`/`ltr:` variants have something to key off,
* (c) form controls, scrollbars, and native widgets mirror correctly.
*
* If your real app/layout.tsx differs (providers, fonts, etc.), keep
* everything else as-is and only add the import below.
*
* ICON FONT: the project already uses Tabler Icons class names
* everywhere (ti ti-layout-dashboard, ti ti-car, ti ti-route, ...)
* but no stylesheet that defines those classes was ever loaded, so
* every icon in the sidebar was silently rendering as nothing.
* Self-hosted via `npm install @tabler/icons-webfont` rather than a
* CDN <link> — no external request at runtime, works behind
* corporate proxies / restrictive network policies, and Next bundles
* + fingerprints the CSS and font files automatically. Run
* `npm install @tabler/icons-webfont` if it isn't already a
* dependency, then this import resolves on its own.
*/
export const metadata: Metadata = {
title: {
default: "Slash.sa — Logistics Management",
template: "%s | Slash.sa",
},
description:
"Modern logistics client portal — manage deliveries, drivers, and fleet operations.",
title: "لوحة التحكم",
description: "نظام إدارة الأسطول والعمليات",
};
export default function RootLayout({
@@ -35,12 +36,8 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
// dir is set per route-group (ar = RTL, en = LTR) not globally
<html
lang="ar"
className={`${jakartaSans.variable} ${ibmPlexMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<html lang="ar" dir="rtl">
<body className="app-shell">{children}</body>
</html>
);
}

View File

@@ -3,11 +3,11 @@
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 { Button } from "../../Components/UI";
import { Input } from "../../Components/UI";
import { Alert } from "../../Components/UI";
import { loginUser } from "@/src/lib/auth";
import Logo from "@/src/utils/logo";
import { Button } from "@/src/Components/UI";
import { Input } from "@/src/Components/UI";
import { Alert } from "@/src/Components/UI";
export default function LoginPage() {
const router = useRouter();

View File

@@ -1,7 +1,82 @@
import React from 'react'
import Link from "next/link";
export default function page() {
export default function NotFound() {
return (
<div>page</div>
)
}
<main
dir="rtl"
style={{
minHeight: "100vh",
background: "var(--color-surface-muted)",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
fontFamily: "var(--font-sans)",
}}
>
<div style={{ textAlign: "center", maxWidth: 480 }}>
{/* Large 404 */}
<p style={{
fontSize: "6rem",
fontWeight: 800,
color: "var(--color-brand-600)",
lineHeight: 1,
margin: 0,
fontFamily: "var(--font-mono)",
}}>
404
</p>
<h1 style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
marginTop: "1rem",
}}>
الصفحة غير موجودة
</h1>
<p style={{
fontSize: 14,
color: "var(--color-text-muted)",
marginTop: "0.75rem",
lineHeight: 1.7,
}}>
الصفحة التي تبحث عنها غير موجودة أو ربما تم نقلها أو حذفها.
</p>
<div style={{ marginTop: "2rem", display: "flex", gap: "0.75rem", justifyContent: "center", flexWrap: "wrap" }}>
<Link href="/dashboard"
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF",
textDecoration: "none",
display: "inline-flex", alignItems: "center", gap: 8,
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
لوحة التحكم
</Link>
<Link href="/"
style={{
height: 40, padding: "0 1.5rem",
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface)",
fontSize: 13, fontWeight: 600,
color: "var(--color-text-secondary)",
textDecoration: "none",
display: "inline-flex", alignItems: "center",
}}>
الصفحة الرئيسية
</Link>
</div>
</div>
</main>
);
}

View File

@@ -1,6 +1,6 @@
import Link from "next/link";
import Navbar from "./components/layout/Navbar";
import Logo from "@/utils/logo";
import Logo from "@/src/utils/logo";
const stats = [
{ label: "الطلبات النشطة", value: "48", color: "text-blue-600" },