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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user