710 lines
23 KiB
TypeScript
710 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { useParams, useRouter } from "next/navigation";
|
||
import { Button, ConfirmDialog, Spinner } from "@/src/Components/UI";
|
||
import { TripFormModal } from "@/src/Components/Trip";
|
||
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 ───────────────────────────────────────────────────────────────────
|
||
|
||
function fmtDate(iso?: string | null): string {
|
||
if (!iso) return "—";
|
||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
});
|
||
}
|
||
|
||
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;
|
||
}) {
|
||
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,
|
||
}}
|
||
>
|
||
{title}
|
||
</p>
|
||
</div>
|
||
<div style={{ padding: "1rem 1.5rem" }}>{children}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StatCard({
|
||
label,
|
||
value,
|
||
color,
|
||
}: {
|
||
label: string;
|
||
value: number | string;
|
||
color: string;
|
||
}) {
|
||
return (
|
||
<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: 800,
|
||
color,
|
||
fontFamily: "var(--font-mono)",
|
||
}}
|
||
>
|
||
{value}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||
|
||
export default function TripDetailPage() {
|
||
const params = useParams();
|
||
const router = useRouter();
|
||
const tripId = params?.tripId as string;
|
||
|
||
const [trip, setTrip] = useState<Trip | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// ── Modal state ───────────────────────────────────────────────────────────
|
||
const [editOpen, setEditOpen] = useState(false);
|
||
const [deleteOpen, setDeleteOpen] = 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 () => {
|
||
if (!tripId) return;
|
||
setLoading(true);
|
||
setError(null);
|
||
try {
|
||
const token = getStoredToken();
|
||
const data = await tripService.getById(tripId, token);
|
||
setTrip(data);
|
||
} catch (err: unknown) {
|
||
setError(extractApiMessage(err, "تعذّر تحميل بيانات الرحلة."));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [tripId]);
|
||
useEffect(() => {
|
||
queueMicrotask(loadTrip);
|
||
}, [loadTrip]);
|
||
|
||
// ── Edit submit ───────────────────────────────────────────────────────────
|
||
const handleEditSubmit = useCallback(
|
||
async (
|
||
payload: CreateTripPayload | UpdateTripPayload,
|
||
): Promise<boolean> => {
|
||
if (!trip) return false;
|
||
try {
|
||
const token = getStoredToken();
|
||
const updated = await tripService.update(
|
||
trip.id,
|
||
payload as UpdateTripPayload,
|
||
token,
|
||
);
|
||
setTrip(updated);
|
||
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
|
||
return true;
|
||
} catch (err) {
|
||
notify({
|
||
type: "error",
|
||
message: extractApiMessage(err, "تعذّر تحديث الرحلة."),
|
||
});
|
||
return false;
|
||
}
|
||
},
|
||
[trip, notify],
|
||
);
|
||
|
||
// ── Delete confirm ────────────────────────────────────────────────────────
|
||
const handleConfirmDelete = useCallback(async () => {
|
||
if (!trip) return;
|
||
setDeleting(true);
|
||
try {
|
||
const token = getStoredToken();
|
||
await tripService.delete(trip.id, token);
|
||
router.push("/dashboard/trips");
|
||
} catch (err) {
|
||
notify({
|
||
type: "error",
|
||
message: extractApiMessage(err, "تعذّر حذف الرحلة."),
|
||
});
|
||
setDeleting(false);
|
||
}
|
||
}, [trip, router, notify]);
|
||
|
||
// ── Status config ─────────────────────────────────────────────────────────
|
||
const statusConfig = trip ? TRIP_STATUS_MAP[trip.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)",
|
||
}}
|
||
>
|
||
<Spinner size="sm" className="text-blue-600" />
|
||
<span style={{ fontSize: 14 }}>جارٍ التحميل…</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Render: Error ─────────────────────────────────────────────────────────
|
||
if (error || !trip) {
|
||
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
|
||
variant="secondary"
|
||
size="sm"
|
||
onClick={() => router.back()}
|
||
style={{ marginTop: "1rem" }}
|
||
>
|
||
← رجوع
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Render: Trip detail ────────────────────────────────────────────────────
|
||
return (
|
||
<>
|
||
{/* ── Toast notification ── */}
|
||
<Toast notification={notification} onDismiss={dismissNotification} />
|
||
|
||
<section
|
||
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
|
||
>
|
||
{/* ── 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
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => router.back()}
|
||
style={{
|
||
height: "auto",
|
||
padding: 0,
|
||
background: "none",
|
||
marginBottom: "1rem",
|
||
}}
|
||
>
|
||
<i
|
||
className="ti ti-arrow-left"
|
||
style={{ fontSize: 14 }}
|
||
aria-hidden="true"
|
||
/>
|
||
العودة إلى قائمة الرحلات
|
||
</Button>
|
||
|
||
<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>
|
||
</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>
|
||
{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>
|
||
|
||
{/* 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)",
|
||
}}
|
||
>
|
||
<i
|
||
className="ti ti-edit"
|
||
style={{ fontSize: 14 }}
|
||
aria-hidden="true"
|
||
/>
|
||
تعديل الرحلة
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* ── Stats row ── */}
|
||
<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={
|
||
trip.totalCashCollected != null
|
||
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
|
||
: "—"
|
||
}
|
||
color="#7C3AED"
|
||
/>
|
||
</div>
|
||
|
||
{/* ── Content grid ── */}
|
||
<div
|
||
style={{
|
||
display: "grid",
|
||
gridTemplateColumns: "1fr 1fr",
|
||
gap: "1.5rem",
|
||
}}
|
||
>
|
||
{/* Trip Info */}
|
||
<SectionCard title="بيانات الرحلة">
|
||
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
|
||
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
|
||
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
|
||
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
|
||
{trip.endReason && (
|
||
<DetailRow label="سبب الإنهاء" value={trip.endReason} warn />
|
||
)}
|
||
</SectionCard>
|
||
|
||
{/* Driver */}
|
||
{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 */}
|
||
{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={trip.tripNumber} mono />
|
||
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
|
||
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
|
||
</SectionCard>
|
||
</div>
|
||
|
||
{/* ── 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>
|
||
</section>
|
||
|
||
{/* ── Edit modal ── */}
|
||
{editOpen && (
|
||
<TripFormModal
|
||
// CHANGE: added key — protects against stale form state if `trip`
|
||
// is refetched with a new object identity (e.g. after loadTrip()).
|
||
key={trip.id}
|
||
editTrip={trip}
|
||
onClose={() => setEditOpen(false)}
|
||
onSubmit={handleEditSubmit}
|
||
/>
|
||
)}
|
||
|
||
{/* ── Delete modal ── */}
|
||
<ConfirmDialog
|
||
open={deleteOpen}
|
||
loading={deleting}
|
||
onCancel={() => setDeleteOpen(false)}
|
||
onConfirm={handleConfirmDelete}
|
||
title="حذف الرحلة"
|
||
description={`هل أنت متأكد من حذف رحلة ${trip.title} (${trip.tripNumber})؟ لا يمكن التراجع عن هذا الإجراء.`}
|
||
/>
|
||
</>
|
||
);
|
||
}
|