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,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>
);
}