The trip files have been reorganized. Errors related to vehicle and driver editing have been resolved, archive display issues fixed, and model data display standardized across the project; order and trip-related issues have also been addressed.

This commit is contained in:
m7amedez5511
2026-07-22 14:19:32 +03:00
parent f1a521dd5a
commit b87d81100f
59 changed files with 2261 additions and 3261 deletions

View File

@@ -1,9 +1,18 @@
"use client";
// app/dashboard/trips/[tripId]/page.tsx
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
// CHANGE: loadTrip's catch block now uses the same extractApiMessage helper
// already defined in this file (and used by handleEditSubmit/handleConfirmDelete)
// instead of `err instanceof Error ? err.message : ...` — so backend
// validation messages surface correctly here too, not just on edit/delete.
import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { ConfirmDialog, Spinner } from "@/src/Components/UI";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
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";
@@ -205,42 +214,42 @@ export default function TripDetailPage() {
// ── 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(err instanceof Error ? err.message : "تعذّر تحميل بيانات الرحلة.");
} finally {
setLoading(false);
}
}, [tripId]);
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],
);
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 () => {

View File

@@ -1,110 +1,28 @@
"use client";
import { useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { Spinner, EmptyState, Badge, PageHeader } from "@/src/Components/UI";
import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth";
import type { Trip } from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import { ArchivedTripDetailModal } from "@/src/Components/Trip/archive/ArchivedtripDetailModal";
// app/dashboard/trips/archived/[tripId]/page.tsx
// CHANGE: rebuilt as a thin route wrapper around the shared
// ArchivedTripDetailModal instead of its own independent fetch/loading/error/
// render logic. This route now shows the exact same full detail view as the
// in-app archive browser (driver + car + counts + cash + notes + endReason,
// not just the handful of fields this page used to render on its own), and
// stays in sync automatically with any future changes to that component.
// `onClose` navigates back to the trips list (via router.push) instead of
// closing an in-place modal, since this route has no page behind it to
// reveal.
function fmtDateTime(iso?: string | null): string {
if (!iso) return "—";
return new Date(iso).toLocaleString("ar-SA", { dateStyle: "medium", timeStyle: "short" });
}
import { useParams, useRouter } from "next/navigation";
export default function ArchivedTripDetailPage() {
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);
useEffect(() => {
if (!tripId) return;
let cancelled = false;
(async () => {
setLoading(true);
setError(null);
try {
const token = getStoredToken();
const data = await tripService.getArchivedById(tripId, token);
if (!cancelled) setTrip(data);
} catch {
if (!cancelled) setError("لم يتم العثور على هذه الرحلة في الأرشيف.");
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [tripId]);
if (loading) {
return (
<div className="flex items-center justify-center gap-3 py-24 text-[var(--color-text-muted)]">
<Spinner size="sm" />
<span className="text-sm">جارٍ التحميل</span>
</div>
);
}
if (error || !trip) {
return (
<EmptyState
icon="🗄️"
title="الرحلة غير موجودة في الأرشيف"
description={error ?? "تعذّر العثور على رحلة بهذا المعرّف."}
action={
<button
onClick={() => router.push("/dashboard/trips")}
className="text-sm font-semibold text-[var(--color-brand-600)] underline"
>
العودة إلى قائمة الرحلات
</button>
}
/>
);
}
const statusConfig = TRIP_STATUS_MAP[trip.status];
return (
<section className="flex flex-col gap-6 p-6" dir="rtl">
<PageHeader
title={trip.title}
description={`رقم الرحلة: ${trip.tripNumber}`}
backHref="/dashboard/trips"
backLabel="الرحلات"
action={<Badge label="مؤرشفة" color="amber" />}
/>
<div className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 shadow-[var(--shadow-card)]">
<Badge label={statusConfig.label} color="slate" />
<dl className="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<dt className="text-[var(--color-text-muted)]">وقت البدء</dt>
<dd>{fmtDateTime(trip.startTime)}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">وقت الانتهاء</dt>
<dd>{fmtDateTime(trip.endTime)}</dd>
</div>
<div>
<dt className="text-[var(--color-text-muted)]">تاريخ الأرشفة</dt>
<dd>{fmtDateTime(trip.deletedAt)}</dd>
</div>
{trip.driver && (
<div>
<dt className="text-[var(--color-text-muted)]">السائق</dt>
<dd>{trip.driver.name}</dd>
</div>
)}
</dl>
</div>
</section>
<ArchivedTripDetailModal
tripId={tripId}
onClose={() => router.push("/dashboard/trips")}
/>
);
}

View File

@@ -1,164 +1,31 @@
"use client";
// app/dashboard/trips/page.tsx
// CHANGE: replaced the old hand-rolled inline table with <TripTable/>.
// CHANGE: TripFormModal now imported from the barrel (@/src/Components/Trip)
// instead of a direct path — avoids the Tripformmodal.tsx case-sensitivity
// mismatch that breaks on case-sensitive filesystems (Linux/Docker builds).
// CHANGE: removed the local ArchivedTripsModal wrapper (it duplicated what
// the new ArchivedTripModal already does — search bar + table + detail) and
// removed the ArchivedTripList import; now renders <ArchivedTripModal/>
// directly.
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTrips } from "@/src/hooks/useTrip";
import { TripFormModal } from "@/src/Components/Trip/Tripformmodal";
import { ArchivedTripList } from "@/src/Components/Trip/archive/ArchivedTripList";
import { Alert, Spinner, ArchiveButton, ConfirmDialog , Toast } from "@/src/Components/UI";
import type {
Trip,
TripStatus,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip";
import { TripFormModal, TripTable } from "@/src/Components/Trip";
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
import { Alert, ArchiveButton, ConfirmDialog, Toast } from "@/src/Components/UI";
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip";
import Header from "@/src/Components/UI/Header";
// ── Status badge — same visual pattern as Driver's status badge ─────────────
function StatusBadge({ status }: { status: TripStatus }) {
const s = TRIP_STATUS_MAP[status];
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
gap: 5,
borderRadius: "var(--radius-full)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.2rem 0.625rem",
fontSize: 11,
fontWeight: 600,
color: s.color,
width: "fit-content",
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: s.dot,
flexShrink: 0,
}}
/>
{s.label}
</span>
);
}
// ── Archived trips modal — unchanged wrapper, kept as-is ────────────────────
function ArchivedTripsModal({ onClose }: { onClose: () => void }) {
const router = useRouter();
useEffect(() => {
const h = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="archived-trips-title"
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
style={{
position: "fixed",
inset: 0,
zIndex: 70,
background: "rgba(15,23,42,0.6)",
backdropFilter: "blur(4px)",
display: "flex",
alignItems: "flex-start",
justifyContent: "center",
padding: "2rem 1rem",
overflowY: "auto",
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: "100%",
maxWidth: 920,
background: "var(--color-surface-sunken)",
borderRadius: "var(--radius-xl)",
border: "1px solid var(--color-border)",
boxShadow: "0 24px 64px rgba(0,0,0,.2)",
overflow: "hidden",
}}
>
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1.25rem 1.5rem",
borderBottom: "1px solid var(--color-border)",
background: "var(--color-surface)",
}}
>
<h2
id="archived-trips-title"
style={{
fontSize: 17,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
أرشيف الرحلات
</h2>
<button
type="button"
onClick={onClose}
aria-label="إغلاق"
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>
<div style={{ padding: "1.5rem" }}>
{/* Was pointing at the normal trip page — send it to the archived route instead */}
<ArchivedTripList
onView={(trip) =>
router.push(`/dashboard/trips/archived/${trip.id}`)
}
/>
</div>
</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function TripsPage() {
const router = useRouter();
// All list state + mutations now come from the hook, same as useDrivers
// is used on the Driver page. No direct tripService calls in this file.
// All list state + mutations come from the hook, same as useDrivers is
// used on the Driver page. No direct tripService calls in this file.
const {
trips,
total,
@@ -169,8 +36,6 @@ export default function TripsPage() {
setPage,
search,
handleSearch,
status,
handleStatusFilter,
createTrip,
updateTrip,
deleteTrip,
@@ -202,6 +67,19 @@ export default function TripsPage() {
setDeleteTarget(trip);
}, []);
// TripTable exposes a dedicated "view" action (icon button) instead of a
// row click — wired to the trip detail page, same destination the old
// row-click handler used to navigate to.
const handleView = useCallback(
(trip: Trip) => router.push(`/dashboard/trips/${trip.id}`),
[router],
);
const handleAdd = useCallback(() => {
setEditTrip(null);
setShowForm(true);
}, []);
// ── Create / update — delegates to the hook (hook fires the toast) ───────
const handleSubmit = async (
payload: CreateTripPayload | UpdateTripPayload,
@@ -233,298 +111,38 @@ export default function TripsPage() {
}}
>
{/* ── Header ── */}
<Header mainTitle="إدارة الرحلات"
title="الرحلات"
name="رحلة"
state={{ total }}
search={search}
setSearch={handleSearch}
module=""
setModule={() => {}}
setPage={setPage}
isAudit={false}
onAdd={() => { setEditTrip(null); setShowForm(true); }}/>
<Header
mainTitle="إدارة الرحلات"
title="الرحلات"
name="رحلة"
state={{ total }}
search={search}
setSearch={handleSearch}
module=""
setModule={() => {}}
setPage={setPage}
isAudit={false}
onAdd={handleAdd}
/>
{/* ── Error handling: Alert banner for list/API errors ──
Same pattern as the Driver page: a persistent banner tied to the
hook's `error` state, dismissible via `clearError`. */}
{error && <Alert type="error" message={error} onClose={clearError} />}
{/* ── Table ── */}
<div
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
borderRadius: "var(--radius-xl)",
overflow: "hidden",
boxShadow: "var(--shadow-card)",
}}
>
<div
dir="rtl"
style={{
display: "grid",
gridTemplateColumns: "1.5fr 2fr 1.3fr 1.3fr 1fr 1.1fr 1.1fr 0.8fr",
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)",
}}
>
<span>رقم الرحلة</span>
<span>العنوان</span>
<span>السائق</span>
<span>السيارة</span>
<span>الحالة</span>
<span>البدء</span>
<span>الانتهاء</span>
<span>إجراءات</span>
</div>
{loading ? (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "4rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 13 }}>جارٍ التحميل</span>
</div>
) : trips.length === 0 ? (
<p
style={{
textAlign: "center",
padding: "4rem 0",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
لا توجد رحلات مطابقة {search && `لـ "${search}"`}
</p>
) : (
<ul dir="rtl" style={{ listStyle: "none", margin: 0, padding: 0 }}>
{trips.map((trip, i) => (
<li
key={trip.id}
onClick={() => router.push(`/dashboard/trips/${trip.id}`)}
style={{
display: "grid",
gridTemplateColumns:
"1.5fr 2fr 1.3fr 1.3fr 1fr 1.1fr 1.1fr 0.8fr",
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,
cursor: "pointer",
transition: "background 0.15s",
}}
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")
}
>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 12,
color: "var(--color-brand-600)",
fontWeight: 700,
}}
>
{trip.tripNumber}
</span>
<span
style={{
fontWeight: 600,
color: "var(--color-text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{trip.title}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{trip.driver?.name ?? "—"}
</span>
<span style={{ color: "var(--color-text-secondary)" }}>
{trip.car
? `${trip.car.manufacturer} ${trip.car.model}`
: "—"}
</span>
<StatusBadge status={trip.status} />
<span
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
{trip.startTime
? new Date(trip.startTime).toLocaleString("ar-SA", {
dateStyle: "short",
timeStyle: "short",
})
: "—"}
</span>
<span
style={{ fontSize: 12, color: "var(--color-text-muted)" }}
>
{trip.endTime
? new Date(trip.endTime).toLocaleString("ar-SA", {
dateStyle: "short",
timeStyle: "short",
})
: "—"}
</span>
{/* Inline row actions — stopPropagation so the click does not
also trigger navigation to the detail page. */}
<div
style={{ display: "flex", gap: "0.4rem" }}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
aria-label="تعديل الرحلة"
title="تعديل"
onClick={() => handleEdit(trip)}
style={{
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
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={() => handleDelete(trip)}
style={{
width: 30,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "var(--radius-md)",
cursor: "pointer",
flexShrink: 0,
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" />
</svg>
</button>
</div>
</li>
))}
</ul>
)}
{/* ── Pagination — same pattern as Driver page ── */}
{pages > 1 && (
<div
dir="rtl"
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
borderTop: "1px solid var(--color-border)",
padding: "0.875rem 1.5rem",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
صفحة{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{page}
</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{pages}
</strong>
{" — "}
{total} رحلة
</span>
<div style={{ display: "flex", gap: "0.5rem" }}>
{[
{
label: "السابق",
action: () => setPage((p) => Math.max(1, p - 1)),
disabled: page === 1,
},
{
label: "التالي",
action: () => setPage((p) => Math.min(pages, p + 1)),
disabled: page === pages,
},
].map((btn) => (
<button
key={btn.label}
onClick={btn.action}
disabled={btn.disabled}
style={{
borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem",
fontSize: 12,
color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)",
}}
>
{btn.label}
</button>
))}
</div>
</div>
)}
</div>
<TripTable
trips={trips}
loading={loading}
search={search}
page={page}
pages={pages}
onEdit={handleEdit}
onDelete={handleDelete}
onView={handleView}
onAddFirst={handleAdd}
onPageChange={setPage}
/>
{/* ── Form modal ── */}
{showForm && (
@@ -549,10 +167,10 @@ export default function TripsPage() {
description={`هل أنت متأكد من حذف رحلة ${deleteTarget?.title ?? ""} (${deleteTarget?.tripNumber ?? ""})؟ لا يمكن التراجع عن هذا الإجراء.`}
/>
{/* ── Archive browser modal ── */}
{archiveOpen && (
<ArchivedTripsModal onClose={() => setArchiveOpen(false)} />
)}
{/* ── Archive browser modal — now the split ArchivedTripModal directly,
no local wrapper needed since it already ships its own search bar,
table, and detail-view wiring. ── */}
{archiveOpen && <ArchivedTripModal onClose={() => setArchiveOpen(false)} />}
{/* ── Floating archive button — same component Driver page uses ── */}
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />