finsh user,car,driver pages also add validator layer to app build trip page
This commit is contained in:
498
app/dashboard/trips/[tripId]/page.tsx
Normal file
498
app/dashboard/trips/[tripId]/page.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, 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";
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString("ar-SA", {
|
||||
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");
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: 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: "var(--color-text-primary)",
|
||||
maxWidth: "60%",
|
||||
textAlign: "left",
|
||||
wordBreak: "break-word",
|
||||
}}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card (للأعداد) ───────────────────────────────────────────────────────
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
color = "var(--color-brand-600)",
|
||||
bg = "var(--color-brand-50, #EFF6FF)",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
bg?: 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)" }}>
|
||||
{label}
|
||||
</span>
|
||||
<span style={{ fontSize: 22, fontWeight: 700, 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);
|
||||
|
||||
// ── Load trip ─────────────────────────────────────────────────────────────
|
||||
const loadTrip = useCallback(async () => {
|
||||
if (!tripId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
const res = await tripService.getById(tripId, token);
|
||||
setTrip((res as unknown as { data: Trip }).data);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tripId]);
|
||||
|
||||
useEffect(() => { loadTrip(); }, [loadTrip]);
|
||||
|
||||
// ── Edit submit ───────────────────────────────────────────────────────────
|
||||
const handleEditSubmit = useCallback(
|
||||
async (
|
||||
payload: CreateTripPayload | UpdateTripPayload,
|
||||
_isNew: boolean,
|
||||
): Promise<boolean> => {
|
||||
if (!trip) return false;
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
await tripService.update(trip.id, payload as UpdateTripPayload, token);
|
||||
await loadTrip();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[trip, loadTrip],
|
||||
);
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!trip) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
const token = getStoredToken();
|
||||
await tripService.delete(trip.id, token);
|
||||
router.back();
|
||||
} catch {
|
||||
setDeleting(false);
|
||||
setDeleteOpen(false);
|
||||
}
|
||||
}, [trip, router]);
|
||||
|
||||
const statusConfig = trip ? TRIP_STATUS_MAP[trip.status] : null;
|
||||
|
||||
// ── Loading ───────────────────────────────────────────────────────────────
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "4rem" }}>
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────
|
||||
if (error || !trip) {
|
||||
return (
|
||||
<div style={{ padding: "2rem", textAlign: "center" }}>
|
||||
<p style={{ fontSize: 14, color: "var(--color-danger)", marginBottom: "1rem" }}>
|
||||
{error ?? "الرحلة غير موجودة."}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
style={{
|
||||
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",
|
||||
fontFamily: "var(--font-sans)",
|
||||
}}
|
||||
>
|
||||
← العودة
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section style={{ padding: "1.5rem", display: "flex", flexDirection: "column", gap: "1.5rem" }}>
|
||||
|
||||
{/* ── 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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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}
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
</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"
|
||||
/>
|
||||
<StatCard
|
||||
label="النقد المحصّل"
|
||||
value={fmtNumber(trip.totalCashCollected)}
|
||||
color="#581C87"
|
||||
bg="#FAF5FF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Content grid ── */}
|
||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}>
|
||||
|
||||
{/* Basic Info */}
|
||||
<SectionCard title="بيانات الرحلة">
|
||||
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
|
||||
<DetailRow label="العنوان" value={trip.title} />
|
||||
<DetailRow label="الحالة" value={statusConfig?.label ?? trip.status} />
|
||||
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
|
||||
<DetailRow label="وقت البدء" value={fmtDate(trip.startTime)} />
|
||||
<DetailRow label="وقت الانتهاء" value={fmtDate(trip.endTime)} />
|
||||
<DetailRow label="سبب الإنهاء" value={trip.endReason ?? "—"} />
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* System Info */}
|
||||
<SectionCard title="معلومات النظام">
|
||||
<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",
|
||||
}}>
|
||||
<TripReportPanel tripId={trip.id} />
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
{/* ── Edit modal ── */}
|
||||
{editOpen && (
|
||||
<TripFormModal
|
||||
editTrip={trip}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Delete modal ── */}
|
||||
{deleteOpen && (
|
||||
<TripDeleteModal
|
||||
trip={trip}
|
||||
deleting={deleting}
|
||||
onCancel={() => setDeleteOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user