Files
LogisicsApp_Client/app/dashboard/trips/page.tsx
m7amedez5511 db64b79fe3 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
2026-06-23 15:54:07 +03:00

375 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}