solve problem tree rerender

This commit is contained in:
m7amedez5511
2026-08-19 18:00:55 +03:00
parent 55b03ad6f4
commit 2d05d3446c
11 changed files with 1441 additions and 479 deletions

View File

@@ -1,16 +1,33 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { Spinner, Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI"; import {
import { CarFormModal,CarDetailPanel } from "@/src/Components/car"; Spinner,
Alert,
ArchiveButton,
ConfirmDialog,
} from "@/src/Components/UI";
import { CarFormModal, CarDetailPanel } from "@/src/Components/car";
import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal"; import { ArchivedCarsModal } from "@/src/Components/car/archive/Archivedcarsmodal";
import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal"; import { CarMaintenanceFormModal } from "@/src/Components/Car_Maintanance/CarMaintananceFormModal";
import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars"; import { useCars, useCarMutations, useToast } from "@/src/hooks/useCars";
import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance"; import { useCarMaintenanceMutations } from "@/src/hooks/UseCarsMaintanance";
import { fmtDateShort, isExpiringSoon, STATUS_MAP, INS_MAP } from "@/src/types/car"; import {
import type { Car, CreateCarPayload, ToastMsg, UpdateCarPayload } from "@/src/types/car"; fmtDateShort,
import type { CreateMaintenancePayload, UpdateMaintenancePayload } from "@/src/types/carMaintanance"; isExpiringSoon,
STATUS_MAP,
INS_MAP,
} from "@/src/types/car";
import type {
Car,
CreateCarPayload,
ToastMsg,
UpdateCarPayload,
} from "@/src/types/car";
import type {
CreateMaintenancePayload,
UpdateMaintenancePayload,
} from "@/src/types/carMaintanance";
// ── Toast ───────────────────────────────────────────────────────────────────── // ── Toast ─────────────────────────────────────────────────────────────────────
@@ -18,21 +35,35 @@ function CarToast({ notification }: { notification: ToastMsg | null }) {
if (!notification) return null; if (!notification) return null;
const ok = notification.type === "success"; const ok = notification.type === "success";
return ( return (
<div role="status" aria-live="polite" style={{ <div
position: "fixed", bottom: 24, left: "50%", role="status"
transform: "translateX(-50%)", aria-live="polite"
zIndex: 9999, pointerEvents: "none", style={{
}}> position: "fixed",
<div style={{ bottom: 24,
display: "flex", alignItems: "center", gap: 10, left: "50%",
padding: "0.75rem 1.25rem", transform: "translateX(-50%)",
borderRadius: "var(--radius-full)", zIndex: 9999,
background: ok ? "#065F46" : "#7F1D1D", pointerEvents: "none",
color: "#FFF", fontSize: 13, fontWeight: 600, }}
boxShadow: "0 8px 32px rgba(0,0,0,.25)", >
maxWidth: "90vw", whiteSpace: "nowrap", <div
fontFamily: "var(--font-sans)", style={{
}}> display: "flex",
alignItems: "center",
gap: 10,
padding: "0.75rem 1.25rem",
borderRadius: "var(--radius-full)",
background: ok ? "#065F46" : "#7F1D1D",
color: "#FFF",
fontSize: 13,
fontWeight: 600,
boxShadow: "0 8px 32px rgba(0,0,0,.25)",
maxWidth: "90vw",
whiteSpace: "nowrap",
fontFamily: "var(--font-sans)",
}}
>
<span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span> <span style={{ fontSize: 16 }}>{ok ? "✓" : "⚠"}</span>
<span>{notification.message}</span> <span>{notification.message}</span>
</div> </div>
@@ -56,13 +87,14 @@ function CarCard({
onSendToMaintenance: (car: Car) => void; onSendToMaintenance: (car: Car) => void;
}) { }) {
const status = STATUS_MAP[car.currentStatus]; const status = STATUS_MAP[car.currentStatus];
const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null; const ins = car.insuranceStatus ? INS_MAP[car.insuranceStatus] : null;
const regWarn = isExpiringSoon(car.registrationExpiryDate); const regWarn = isExpiringSoon(car.registrationExpiryDate);
// Maintenance status can ONLY be set via this button's flow (POST // Maintenance status can ONLY be set via this button's flow (POST
// cars/:carId/maintenance flips it on the backend). Disable rather than // cars/:carId/maintenance flips it on the backend). Disable rather than
// hide it once the car is already in maintenance or inactive, and explain why. // hide it once the car is already in maintenance or inactive, and explain why.
const maintenanceDisabled = car.currentStatus === "InMaintenance" || car.currentStatus === "Inactive"; const maintenanceDisabled =
car.currentStatus === "InMaintenance" || car.currentStatus === "Inactive";
const maintenanceDisabledReason = const maintenanceDisabledReason =
car.currentStatus === "InMaintenance" car.currentStatus === "InMaintenance"
? "المركبة في الصيانة بالفعل" ? "المركبة في الصيانة بالفعل"
@@ -75,85 +107,206 @@ function CarCard({
onClick={onClick} onClick={onClick}
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={e => { if (e.key === "Enter" || e.key === " ") onClick(); }} onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick();
}}
aria-label={`${car.manufacturer} ${car.model}${car.plateLetters} ${car.plateNumber}`} aria-label={`${car.manufacturer} ${car.model}${car.plateLetters} ${car.plateNumber}`}
className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden shadow-[var(--shadow-card)] cursor-pointer outline-none transition-all duration-200 hover:shadow-[0_8px_24px_rgba(37,99,235,.12)] hover:-translate-y-0.5 focus-visible:shadow-[0_8px_24px_rgba(37,99,235,.12)] focus-visible:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-600)] focus-visible:ring-offset-2" className="rounded-[var(--radius-xl)] border border-[var(--color-border)] bg-[var(--color-surface)] overflow-hidden shadow-[var(--shadow-card)] cursor-pointer outline-none transition-all duration-200 hover:shadow-[0_8px_24px_rgba(37,99,235,.12)] hover:-translate-y-0.5 focus-visible:shadow-[0_8px_24px_rgba(37,99,235,.12)] focus-visible:-translate-y-0.5 focus-visible:ring-2 focus-visible:ring-[var(--color-brand-600)] focus-visible:ring-offset-2"
> >
{/* Colour accent bar by status */} {/* Colour accent bar by status */}
<div style={{ <div
height: 4, style={{
background: status.dot, height: 4,
borderRadius: "var(--radius-xl) var(--radius-xl) 0 0", background: status.dot,
}} /> borderRadius: "var(--radius-xl) var(--radius-xl) 0 0",
}}
/>
<div style={{ padding: "1.25rem" }}> <div style={{ padding: "1.25rem" }}>
{/* Manufacturer + model */} {/* Manufacturer + model */}
<div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 8 }}> <div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 8,
}}
>
<div> <div>
<p style={{ fontSize: 16, fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}> <p
style={{
fontSize: 16,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
{car.manufacturer} {car.model} {car.manufacturer} {car.model}
</p> </p>
<p style={{ fontSize: 12, color: "var(--color-text-muted)", marginTop: 2 }}> <p
{car.year}{car.color ? ` · ${car.color}` : ""} style={{
fontSize: 12,
color: "var(--color-text-muted)",
marginTop: 2,
}}
>
{car.year}
{car.color ? ` · ${car.color}` : ""}
</p> </p>
</div> </div>
{/* Status badge */} {/* Status badge */}
<span style={{ <span
display: "inline-flex", alignItems: "center", gap: 5, style={{
borderRadius: "var(--radius-full)", display: "inline-flex",
border: `1px solid ${status.border}`, alignItems: "center",
background: status.bg, gap: 5,
padding: "0.2rem 0.625rem", borderRadius: "var(--radius-full)",
fontSize: 11, fontWeight: 700, color: status.color, border: `1px solid ${status.border}`,
whiteSpace: "nowrap", flexShrink: 0, background: status.bg,
}}> padding: "0.2rem 0.625rem",
<span style={{ width: 6, height: 6, borderRadius: "50%", background: status.dot }} /> fontSize: 11,
fontWeight: 700,
color: status.color,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: status.dot,
}}
/>
{status.label} {status.label}
</span> </span>
</div> </div>
{/* Plate number */} {/* Plate number */}
<div style={{ <div
marginTop: "0.875rem", style={{
background: "var(--color-surface-muted)", marginTop: "0.875rem",
border: "1px solid var(--color-border)", background: "var(--color-surface-muted)",
borderRadius: "var(--radius-md)", border: "1px solid var(--color-border)",
padding: "0.5rem 0.75rem", borderRadius: "var(--radius-md)",
display: "flex", alignItems: "center", justifyContent: "space-between", padding: "0.5rem 0.75rem",
}}> display: "flex",
<span style={{ fontSize: 11, color: "var(--color-text-muted)", fontWeight: 600 }}>رقم اللوحة</span> alignItems: "center",
<span style={{ justifyContent: "space-between",
fontFamily: "var(--font-mono)", }}
fontSize: 14, fontWeight: 700, letterSpacing: "0.1em", >
color: "var(--color-brand-700)", <span
}}> style={{
fontSize: 11,
color: "var(--color-text-muted)",
fontWeight: 600,
}}
>
رقم اللوحة
</span>
<span
style={{
fontFamily: "var(--font-mono)",
fontSize: 14,
fontWeight: 700,
letterSpacing: "0.1em",
color: "var(--color-brand-700)",
}}
>
{car.plateLetters} {car.plateNumber} {car.plateLetters} {car.plateNumber}
</span> </span>
</div> </div>
{/* Key attributes grid */} {/* Key attributes grid */}
<div style={{ marginTop: "0.875rem", display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0.5rem" }}> <div
style={{
marginTop: "0.875rem",
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "0.5rem",
}}
>
<div style={{ fontSize: 11 }}> <div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الفرع</span> <span
<span style={{ color: "var(--color-text-primary)", marginTop: 2, display: "block" }}> style={{
color: "var(--color-text-muted)",
fontWeight: 600,
display: "block",
}}
>
الفرع
</span>
<span
style={{
color: "var(--color-text-primary)",
marginTop: 2,
display: "block",
}}
>
{car.branch?.name ?? "—"} {car.branch?.name ?? "—"}
</span> </span>
</div> </div>
<div style={{ fontSize: 11 }}> <div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>التأمين</span> <span
<span style={{ color: ins?.color ?? "var(--color-text-muted)", marginTop: 2, display: "block", fontWeight: 600 }}> style={{
color: "var(--color-text-muted)",
fontWeight: 600,
display: "block",
}}
>
التأمين
</span>
<span
style={{
color: ins?.color ?? "var(--color-text-muted)",
marginTop: 2,
display: "block",
fontWeight: 600,
}}
>
{ins?.label ?? "—"} {ins?.label ?? "—"}
</span> </span>
</div> </div>
<div style={{ fontSize: 11 }}> <div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>انتهاء الاستمارة</span> <span
<span style={{ color: regWarn ? "#D97706" : "var(--color-text-secondary)", marginTop: 2, display: "block", fontWeight: regWarn ? 600 : 400 }}> style={{
{regWarn && "⚠ "}{fmtDateShort(car.registrationExpiryDate)} color: "var(--color-text-muted)",
fontWeight: 600,
display: "block",
}}
>
انتهاء الاستمارة
</span>
<span
style={{
color: regWarn ? "#D97706" : "var(--color-text-secondary)",
marginTop: 2,
display: "block",
fontWeight: regWarn ? 600 : 400,
}}
>
{regWarn && "⚠ "}
{fmtDateShort(car.registrationExpiryDate)}
</span> </span>
</div> </div>
<div style={{ fontSize: 11 }}> <div style={{ fontSize: 11 }}>
<span style={{ color: "var(--color-text-muted)", fontWeight: 600, display: "block" }}>الطاقة</span> <span
<span style={{ color: "var(--color-text-secondary)", marginTop: 2, display: "block" }}> style={{
color: "var(--color-text-muted)",
fontWeight: 600,
display: "block",
}}
>
الطاقة
</span>
<span
style={{
color: "var(--color-text-secondary)",
marginTop: 2,
display: "block",
}}
>
{car.capacity != null ? car.capacity : "—"} {car.capacity != null ? car.capacity : "—"}
</span> </span>
</div> </div>
@@ -174,20 +327,38 @@ function CarCard({
height: 34, height: 34,
borderRadius: "var(--radius-md)", borderRadius: "var(--radius-md)",
border: `1px solid ${maintenanceDisabled ? "var(--color-border)" : "#FDE68A"}`, border: `1px solid ${maintenanceDisabled ? "var(--color-border)" : "#FDE68A"}`,
background: maintenanceDisabled ? "var(--color-surface-muted)" : "#FFFBEB", background: maintenanceDisabled
fontSize: 12, fontWeight: 700, ? "var(--color-surface-muted)"
: "#FFFBEB",
fontSize: 12,
fontWeight: 700,
color: maintenanceDisabled ? "var(--color-text-hint)" : "#854D0E", color: maintenanceDisabled ? "var(--color-text-hint)" : "#854D0E",
cursor: maintenanceDisabled ? "not-allowed" : "pointer", cursor: maintenanceDisabled ? "not-allowed" : "pointer",
display: "flex", alignItems: "center", justifyContent: "center", gap: 6, display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<i className="ti ti-tool" style={{ fontSize: 13 }} aria-hidden="true" /> <i
className="ti ti-tool"
style={{ fontSize: 13 }}
aria-hidden="true"
/>
إرسال للصيانة إرسال للصيانة
</button> </button>
{/* Footer CTA hint */} {/* Footer CTA hint */}
<p style={{ marginTop: "0.75rem", fontSize: 11, color: "var(--color-brand-600)", fontWeight: 600, textAlign: "left" }}> <p
style={{
marginTop: "0.75rem",
fontSize: 11,
color: "var(--color-brand-600)",
fontWeight: 600,
textAlign: "left",
}}
>
اضغط لعرض التفاصيل اضغط لعرض التفاصيل
</p> </p>
</div> </div>
@@ -198,14 +369,14 @@ function CarCard({
// ── Page ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function CarsPage() { export default function CarsPage() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Modal state // Modal state
const [detailId, setDetailId] = useState<string | null>(null); const [detailId, setDetailId] = useState<string | null>(null);
const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed const [formTarget, setFormTarget] = useState<Car | null | false>(false); // false = closed
const [deleteTarget, setDeleteTarget] = useState<Car | null>(null); const [deleteTarget, setDeleteTarget] = useState<Car | null>(null);
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] = useState<Car | null>(null); const [maintenanceTarget, setMaintenanceTarget] = useState<Car | null>(null);
const { toast, notify } = useToast(); const { toast, notify } = useToast();
@@ -214,14 +385,24 @@ export default function CarsPage() {
useCars(page, search); useCars(page, search);
// Need a stable ref to the current edit target for the mutation hook // Need a stable ref to the current edit target for the mutation hook
const getEditTarget = useCallback(() => const getEditTarget = useCallback(
formTarget instanceof Object && formTarget !== null ? formTarget as Car : null, () =>
[formTarget]); formTarget instanceof Object && formTarget !== null
? (formTarget as Car)
: null,
[formTarget],
);
const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMutations({ const { deleting, handleFormSubmit, handleDeleteConfirm } = useCarMutations({
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); }, onSuccess: (msg) => {
onError: (msg) => notify({ type: "error", message: msg }), notify({ type: "success", message: msg });
onDeleted: (id) => { removeCar(id); setDeleteTarget(null); }, loadCars();
},
onError: (msg) => notify({ type: "error", message: msg }),
onDeleted: (id) => {
removeCar(id);
setDeleteTarget(null);
},
getEditTarget, getEditTarget,
}); });
@@ -234,13 +415,17 @@ export default function CarsPage() {
// person just clicked. Errors surface inline inside the modal's own // person just clicked. Errors surface inline inside the modal's own
// apiError state (its default behavior), not through the outer toast — // apiError state (its default behavior), not through the outer toast —
// onError is intentionally a no-op here. // onError is intentionally a no-op here.
const { handleFormSubmit: handleMaintenanceSubmit } = useCarMaintenanceMutations({ const { handleFormSubmit: handleMaintenanceSubmit } =
carId: maintenanceTarget?.id ?? "", useCarMaintenanceMutations({
onSuccess: (msg) => { notify({ type: "success", message: msg }); loadCars(); }, carId: maintenanceTarget?.id ?? "",
onError: () => {}, onSuccess: (msg) => {
onDeleted: () => {}, notify({ type: "success", message: msg });
getEditTarget: () => null, loadCars();
}); },
onError: () => {},
onDeleted: () => {},
getEditTarget: () => null,
});
// ── Render ────────────────────────────────────────────────────────────────── // ── Render ──────────────────────────────────────────────────────────────────
return ( return (
@@ -251,18 +436,34 @@ export default function CarsPage() {
<CarDetailPanel <CarDetailPanel
carId={detailId} carId={detailId}
onClose={() => setDetailId(null)} onClose={() => setDetailId(null)}
onEdit={(car) => { setDetailId(null); setFormTarget(car); }} onEdit={(car) => {
onDelete={(car) => { setDetailId(null); setDeleteTarget(car); }} setDetailId(null);
setFormTarget(car);
}}
onDelete={(car) => {
setDetailId(null);
setDeleteTarget(car);
}}
/> />
)} )}
{formTarget !== false && ( {formTarget !== false && (
<CarFormModal <CarFormModal
// CHANGE: added key — same fix as UserFormModal/DriverFormModal,
// forces a fresh useForm instance (and fresh defaultValues) when
// switching between "new" and different edit targets.
key={formTarget === null ? "new" : formTarget.id}
editCar={formTarget} editCar={formTarget}
branches={[]} branches={[]}
onClose={() => setFormTarget(false)} onClose={() => setFormTarget(false)}
onSubmit={(payload: CreateCarPayload | UpdateCarPayload, isNew: boolean) => onSubmit={(
handleFormSubmit(payload, isNew).then((ok) => { if (ok) setFormTarget(false); return ok; }) payload: CreateCarPayload | UpdateCarPayload,
isNew: boolean,
) =>
handleFormSubmit(payload, isNew).then((ok) => {
if (ok) setFormTarget(false);
return ok;
})
} }
/> />
)} )}
@@ -281,9 +482,10 @@ export default function CarsPage() {
editRecord={null} editRecord={null}
carLabel={`${maintenanceTarget.manufacturer} ${maintenanceTarget.model}${maintenanceTarget.plateLetters} ${maintenanceTarget.plateNumber}`} carLabel={`${maintenanceTarget.manufacturer} ${maintenanceTarget.model}${maintenanceTarget.plateLetters} ${maintenanceTarget.plateNumber}`}
onClose={() => setMaintenanceTarget(null)} onClose={() => setMaintenanceTarget(null)}
onSubmit={(payload: CreateMaintenancePayload | UpdateMaintenancePayload, isNew: boolean) => onSubmit={(
handleMaintenanceSubmit(payload, isNew) payload: CreateMaintenancePayload | UpdateMaintenancePayload,
} isNew: boolean,
) => handleMaintenanceSubmit(payload, isNew)}
/> />
)} )}
@@ -291,50 +493,111 @@ export default function CarsPage() {
<ArchivedCarsModal onClose={() => setArchiveOpen(false)} /> <ArchivedCarsModal onClose={() => setArchiveOpen(false)} />
)} )}
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }} dir="rtl"> <section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
dir="rtl"
>
{/* ── Header ── */} {/* ── Header ── */}
<header style={{ <header
borderRadius: "var(--radius-xl)", style={{
border: "1px solid var(--color-border)", borderRadius: "var(--radius-xl)",
background: "var(--color-surface)", border: "1px solid var(--color-border)",
padding: "1.5rem 2rem", background: "var(--color-surface)",
boxShadow: "var(--shadow-card)", padding: "1.5rem 2rem",
}}> boxShadow: "var(--shadow-card)",
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}> }}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة الأسطول إدارة الأسطول
</p> </p>
<div style={{ marginTop: "0.75rem", display: "flex", flexWrap: "wrap", gap: "1rem", alignItems: "flex-end", justifyContent: "space-between" }}> <div
style={{
marginTop: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "1rem",
alignItems: "flex-end",
justifyContent: "space-between",
}}
>
<div> <div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}> <h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
المركبات المركبات
</h1> </h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}> <p
إجمالي <strong style={{ color: "var(--color-text-primary)" }}>{total}</strong> مركبة في الأسطول style={{
marginTop: "0.25rem",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{total}
</strong>{" "}
مركبة في الأسطول
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "center" }}> <div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
alignItems: "center",
}}
>
{/* Search */} {/* Search */}
<div style={{ position: "relative", width: 256 }}> <div style={{ position: "relative", width: 256 }}>
<i <i
className="ti ti-search" className="ti ti-search"
aria-hidden="true" aria-hidden="true"
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
fontSize: 16,
color: "var(--color-text-hint)",
pointerEvents: "none",
}}
/> />
<input <input
type="text" type="text"
placeholder="بحث بالماركة أو اللوحة..." placeholder="بحث بالماركة أو اللوحة..."
value={search} value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }} onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
dir="rtl" dir="rtl"
style={{ style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, width: "100%",
height: 40,
paddingRight: 36,
paddingLeft: 12,
borderRadius: "var(--radius-lg)", borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)", fontSize: 13,
outline: "none", fontFamily: "var(--font-sans)", color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
}} }}
/> />
</div> </div>
@@ -344,19 +607,28 @@ export default function CarsPage() {
type="button" type="button"
onClick={() => setFormTarget(null)} onClick={() => setFormTarget(null)}
style={{ style={{
height: 40, padding: "0 1.125rem", height: 40,
padding: "0 1.125rem",
borderRadius: "var(--radius-lg)", borderRadius: "var(--radius-lg)",
border: "none", border: "none",
background: "var(--color-brand-600)", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF", fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer", cursor: "pointer",
display: "inline-flex", alignItems: "center", gap: 7, display: "inline-flex",
alignItems: "center",
gap: 7,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
boxShadow: "0 1px 4px rgba(37,99,235,.35)", boxShadow: "0 1px 4px rgba(37,99,235,.35)",
whiteSpace: "nowrap", whiteSpace: "nowrap",
}} }}
> >
<i className="ti ti-plus" style={{ fontSize: 14 }} aria-hidden="true" /> <i
className="ti ti-plus"
style={{ fontSize: 14 }}
aria-hidden="true"
/>
إضافة مركبة إضافة مركبة
</button> </button>
</div> </div>
@@ -364,27 +636,54 @@ export default function CarsPage() {
</header> </header>
{/* Error alert */} {/* Error alert */}
{error && <Alert type="error" message={error} onClose={() => setError(null)} />} {error && (
<Alert type="error" message={error} onClose={() => setError(null)} />
)}
{/* ── Loading ── */} {/* ── Loading ── */}
{loading ? ( {loading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "5rem 0", color: "var(--color-text-muted)" }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 12,
padding: "5rem 0",
color: "var(--color-text-muted)",
}}
>
<Spinner size="md" className="text-blue-600" /> <Spinner size="md" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ تحميل المركبات</span> <span style={{ fontSize: 14 }}>جارٍ تحميل المركبات</span>
</div> </div>
) : cars.length === 0 ? ( ) : cars.length === 0 ? (
<div style={{ <div
borderRadius: "var(--radius-xl)", style={{
border: "2px dashed var(--color-border)", borderRadius: "var(--radius-xl)",
background: "var(--color-surface)", border: "2px dashed var(--color-border)",
padding: "5rem 2rem", background: "var(--color-surface)",
textAlign: "center", padding: "5rem 2rem",
}}> textAlign: "center",
}}
>
<div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div> <div style={{ fontSize: 52, marginBottom: 16 }}>🚗</div>
<p style={{ fontSize: 16, fontWeight: 600, color: "var(--color-text-primary)" }}> <p
{search ? `لا توجد مركبات تطابق "${search}"` : "لا توجد مركبات بعد"} style={{
fontSize: 16,
fontWeight: 600,
color: "var(--color-text-primary)",
}}
>
{search
? `لا توجد مركبات تطابق "${search}"`
: "لا توجد مركبات بعد"}
</p> </p>
<p style={{ fontSize: 13, color: "var(--color-text-muted)", marginTop: 6 }}> <p
style={{
fontSize: 13,
color: "var(--color-text-muted)",
marginTop: 6,
}}
>
اضغط على &quot;إضافة مركبة&quot; لإضافة أول مركبة في الأسطول. اضغط على &quot;إضافة مركبة&quot; لإضافة أول مركبة في الأسطول.
</p> </p>
{!search && ( {!search && (
@@ -392,11 +691,17 @@ export default function CarsPage() {
type="button" type="button"
onClick={() => setFormTarget(null)} onClick={() => setFormTarget(null)}
style={{ style={{
marginTop: 16, height: 40, padding: "0 1.5rem", marginTop: 16,
height: 40,
padding: "0 1.5rem",
borderRadius: "var(--radius-lg)", borderRadius: "var(--radius-lg)",
border: "none", background: "var(--color-brand-600)", border: "none",
fontSize: 13, fontWeight: 700, color: "#FFF", background: "var(--color-brand-600)",
cursor: "pointer", fontFamily: "var(--font-sans)", fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer",
fontFamily: "var(--font-sans)",
}} }}
> >
إضافة مركبة إضافة مركبة
@@ -405,11 +710,13 @@ export default function CarsPage() {
</div> </div>
) : ( ) : (
/* ── Card grid ── */ /* ── Card grid ── */
<div style={{ <div
display: "grid", style={{
gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))", display: "grid",
gap: "1rem", gridTemplateColumns: "repeat(auto-fill, minmax(300px, 1fr))",
}}> gap: "1rem",
}}
>
{/* PERF NOTE: Cards are rendered inline via .map() below. At the {/* PERF NOTE: Cards are rendered inline via .map() below. At the
current pagination size (10-12 items) this is not a bottleneck. current pagination size (10-12 items) this is not a bottleneck.
If page size increases (e.g. "show all" mode, a larger If page size increases (e.g. "show all" mode, a larger
@@ -431,24 +738,44 @@ export default function CarsPage() {
{/* ── Pagination ── */} {/* ── Pagination ── */}
{pages > 1 && ( {pages > 1 && (
<div style={{ <div
display: "flex", alignItems: "center", justifyContent: "space-between", style={{
borderRadius: "var(--radius-xl)", display: "flex",
border: "1px solid var(--color-border)", alignItems: "center",
background: "var(--color-surface)", justifyContent: "space-between",
padding: "0.875rem 1.5rem", borderRadius: "var(--radius-xl)",
boxShadow: "var(--shadow-card)", border: "1px solid var(--color-border)",
}}> background: "var(--color-surface)",
padding: "0.875rem 1.5rem",
boxShadow: "var(--shadow-card)",
}}
>
<span style={{ fontSize: 12, color: "var(--color-text-muted)" }}> <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> <strong style={{ color: "var(--color-text-primary)" }}>
{page}
</strong>{" "}
من{" "}
<strong style={{ color: "var(--color-text-primary)" }}>
{pages}
</strong>
{" · "} {" · "}
<span style={{ color: "var(--color-text-hint)" }}>{total} مركبة</span> <span style={{ color: "var(--color-text-hint)" }}>
{total} مركبة
</span>
</span> </span>
<div style={{ display: "flex", gap: "0.5rem" }}> <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 }, 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) => ( ].map((btn) => (
<button <button
key={btn.label} key={btn.label}
@@ -459,7 +786,8 @@ export default function CarsPage() {
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface-muted)", background: "var(--color-surface-muted)",
padding: "0.375rem 0.875rem", padding: "0.375rem 0.875rem",
fontSize: 12, color: "var(--color-text-secondary)", fontSize: 12,
color: "var(--color-text-secondary)",
cursor: btn.disabled ? "not-allowed" : "pointer", cursor: btn.disabled ? "not-allowed" : "pointer",
opacity: btn.disabled ? 0.4 : 1, opacity: btn.disabled ? 0.4 : 1,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
@@ -478,4 +806,4 @@ export default function CarsPage() {
<ArchiveButton onClick={() => setArchiveOpen(true)} /> <ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -3,40 +3,69 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { ConfirmDialog, Spinner, Button, Toast } from "@/src/Components/UI"; import { ConfirmDialog, Spinner, Button, Toast } from "@/src/Components/UI";
import { DriverFormModal , PhotoCard } from "@/src/Components/Driver"; import { DriverFormModal, PhotoCard } from "@/src/Components/Driver";
import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport"; import { DriverReportPanel } from "@/src/Components/Driver_Report/driverReport";
import type { Driver, CreateDriverPayload, UpdateDriverPayload } from "@/src/types/driver"; import type {
import { DRIVER_STATUS_MAP, DRIVER_CARD_TYPE_MAP, NATIONAL_ID_TYPE_MAP } from "@/src/types/driver"; Driver,
CreateDriverPayload,
UpdateDriverPayload,
} from "@/src/types/driver";
import {
DRIVER_STATUS_MAP,
DRIVER_CARD_TYPE_MAP,
NATIONAL_ID_TYPE_MAP,
} from "@/src/types/driver";
import { driverService } from "@/src/services"; import { driverService } from "@/src/services";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
function fmtDate(iso?: string | null): string { function fmtDate(iso?: string | null): string {
if (!iso) return "—"; if (!iso) return "—";
return new Date(iso).toLocaleDateString("ar-SA", { return new Date(iso).toLocaleDateString("ar-SA", {
year: "numeric", month: "long", day: "numeric", year: "numeric",
month: "long",
day: "numeric",
}); });
} }
function isExpiringSoon(iso?: string | null): boolean { function isExpiringSoon(iso?: string | null): boolean {
if (!iso) return false; if (!iso) return false;
return (new Date(iso).getTime() - Date.now()) <= 90 * 86_400_000; return new Date(iso).getTime() - Date.now() <= 90 * 86_400_000;
} }
function SectionCard({ title, children }: { title: string; children: React.ReactNode }) { function SectionCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return ( return (
<div style={{ <div
borderRadius: "var(--radius-xl)", style={{
border: "1px solid var(--color-border)", borderRadius: "var(--radius-xl)",
background: "var(--color-surface)", border: "1px solid var(--color-border)",
overflow: "hidden", background: "var(--color-surface)",
boxShadow: "var(--shadow-card)", overflow: "hidden",
}}> boxShadow: "var(--shadow-card)",
<div style={{ }}
padding: "0.875rem 1.5rem", >
borderBottom: "1px solid var(--color-border)", <div
background: "var(--color-surface-muted)", style={{
}}> padding: "0.875rem 1.5rem",
<p style={{ fontSize: 11, letterSpacing: "0.25em", textTransform: "uppercase", color: "var(--color-text-hint)", fontWeight: 700, margin: 0 }}> 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} {title}
</p> </p>
</div> </div>
@@ -45,50 +74,82 @@ function SectionCard({ title, children }: { title: string; children: React.React
); );
} }
function DetailRow({ label, value, mono = false, warn = false }: { function DetailRow({
label: string; value: string; mono?: boolean; warn?: boolean; label,
value,
mono = false,
warn = false,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) { }) {
return ( return (
<div style={{ <div
display: "flex", justifyContent: "space-between", alignItems: "baseline", style={{
padding: "0.55rem 0", borderBottom: "1px solid var(--color-border)", display: "flex",
}}> justifyContent: "space-between",
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}>{label}</span> alignItems: "baseline",
<span style={{ padding: "0.55rem 0",
fontSize: 13, borderBottom: "1px solid var(--color-border)",
fontFamily: mono ? "var(--font-mono)" : "var(--font-sans)", }}
color: warn ? "#D97706" : "var(--color-text-primary)", >
fontWeight: warn ? 600 : 400, <span
maxWidth: "60%", textAlign: "left", wordBreak: "break-word", style={{
}}> fontSize: 12,
{warn && value !== "—" ? "⚠ " : ""}{value} 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> </span>
</div> </div>
); );
} }
export default function DriverDetailPage() { export default function DriverDetailPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const driverId = params?.driverId as string; const driverId = params?.driverId as string;
const [driver, setDriver] = useState<Driver | null>(null); const [driver, setDriver] = useState<Driver | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [avatarError, setAvatarError] = useState(false); const [avatarError, setAvatarError] = useState(false);
useEffect(() => { useEffect(() => {
queueMicrotask(() => setAvatarError(false)); queueMicrotask(() => setAvatarError(false));
}, [driver?.photoUrl]); }, [driver?.photoUrl]);
const [toast, setToast] = useState<{ type: "success" | "error"; message: string } | null>(null); const [toast, setToast] = useState<{
type: "success" | "error";
message: string;
} | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const showToast = useCallback((type: "success" | "error", message: string) => { const showToast = useCallback(
setToast({ type, message }); (type: "success" | "error", message: string) => {
setTimeout(() => setToast(null), 4000); setToast({ type, message });
}, []); setTimeout(() => setToast(null), 4000);
},
[],
);
const loadDriver = useCallback(async () => { const loadDriver = useCallback(async () => {
if (!driverId) return; if (!driverId) return;
@@ -99,23 +160,34 @@ export default function DriverDetailPage() {
const data = await driverService.getById(driverId, token); const data = await driverService.getById(driverId, token);
setDriver(data); setDriver(data);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق."); setError(
err instanceof Error ? err.message : "تعذّر تحميل بيانات السائق.",
);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [driverId]); }, [driverId]);
useEffect(() => { queueMicrotask(loadDriver); }, [loadDriver]); useEffect(() => {
queueMicrotask(loadDriver);
}, [loadDriver]);
const handleEditSubmit = useCallback( const handleEditSubmit = useCallback(
async (payload: CreateDriverPayload | UpdateDriverPayload): Promise<boolean> => { async (
payload: CreateDriverPayload | UpdateDriverPayload,
): Promise<boolean> => {
if (!driver) return false; if (!driver) return false;
try { try {
const token = getStoredToken(); const token = getStoredToken();
const typedPayload = payload as UpdateDriverPayload & { const typedPayload = payload as UpdateDriverPayload & {
photo?: File; nationalPhoto?: File; driverCardPhoto?: File; photo?: File;
nationalPhoto?: File;
driverCardPhoto?: File;
}; };
const hasFiles = typedPayload.photo || typedPayload.nationalPhoto || typedPayload.driverCardPhoto; const hasFiles =
typedPayload.photo ||
typedPayload.nationalPhoto ||
typedPayload.driverCardPhoto;
if (hasFiles) { if (hasFiles) {
await driverService.updateWithImages(driver.id, typedPayload, token); await driverService.updateWithImages(driver.id, typedPayload, token);
@@ -127,7 +199,8 @@ export default function DriverDetailPage() {
showToast("success", "تم تحديث بيانات السائق بنجاح."); showToast("success", "تم تحديث بيانات السائق بنجاح.");
return true; return true;
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق."; const message =
err instanceof Error ? err.message : "تعذّر تحديث بيانات السائق.";
showToast("error", message); showToast("error", message);
return false; return false;
} }
@@ -153,7 +226,16 @@ export default function DriverDetailPage() {
if (loading) { if (loading) {
return ( return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 12, padding: "6rem 0", color: "var(--color-text-muted)" }}> <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" /> <Spinner size="sm" className="text-blue-600" />
<span style={{ fontSize: 14 }}>جارٍ التحميل</span> <span style={{ fontSize: 14 }}>جارٍ التحميل</span>
</div> </div>
@@ -162,9 +244,26 @@ export default function DriverDetailPage() {
if (error || !driver) { if (error || !driver) {
return ( return (
<div style={{ maxWidth: 480, margin: "4rem auto", borderRadius: "var(--radius-xl)", border: "1px solid #FECACA", background: "#FEF2F2", padding: "1.5rem", textAlign: "center" }}> <div
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> {error ?? "السائق غير موجود"}</p> style={{
<Button variant="secondary" size="sm" onClick={() => router.back()} className="mt-4"> 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()}
className="mt-4"
>
رجوع رجوع
</Button> </Button>
</div> </div>
@@ -173,50 +272,159 @@ export default function DriverDetailPage() {
return ( return (
<> <>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
<Toast notification={toast} onDismiss={() => setToast(null)} /> <Toast notification={toast} onDismiss={() => setToast(null)} />
<header style={{ <header
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", style={{
background: "var(--color-surface)", padding: "1.5rem 2rem", boxShadow: "var(--shadow-card)", borderRadius: "var(--radius-xl)",
}}> border: "1px solid var(--color-border)",
<Button variant="ghost" size="sm" onClick={() => router.back()} className="mb-4 !px-0"> background: "var(--color-surface)",
<i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" /> padding: "1.5rem 2rem",
boxShadow: "var(--shadow-card)",
}}
>
<Button
variant="ghost"
size="sm"
onClick={() => router.back()}
className="mb-4 !px-0"
>
<i
className="ti ti-arrow-left"
style={{ fontSize: 14 }}
aria-hidden="true"
/>
العودة إلى قائمة السائقين العودة إلى قائمة السائقين
</Button> </Button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 16 }}> <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div style={{ <div
width: 72, height: 72, borderRadius: "50%", overflow: "hidden", flexShrink: 0, style={{
border: "2px solid var(--color-brand-200)", background: "var(--color-surface-muted)", width: 72,
display: "flex", alignItems: "center", justifyContent: "center", 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",
}}
>
{driver.photoUrl && !avatarError ? ( {driver.photoUrl && !avatarError ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img src={driver.photoUrl} alt={driver.name} style={{ width: "100%", height: "100%", objectFit: "cover" }} onError={() => setAvatarError(true)} /> <img
src={driver.photoUrl}
alt={driver.name}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
}}
onError={() => setAvatarError(true)}
/>
) : ( ) : (
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}>{driver.name.charAt(0)}</span> <span
style={{
fontSize: 28,
fontWeight: 700,
color: "var(--color-brand-600)",
}}
>
{driver.name.charAt(0)}
</span>
)} )}
</div> </div>
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}>ملف السائق</p> <p
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}>{driver.name}</h1> style={{
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}> 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",
}}
>
{driver.name}
</h1>
<div
style={{
display: "flex",
alignItems: "center",
gap: 10,
marginTop: 6,
flexWrap: "wrap",
}}
>
{driver.userName && ( {driver.userName && (
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>@{driver.userName}</span> <span
style={{
fontSize: 12,
fontFamily: "var(--font-mono)",
color: "var(--color-text-muted)",
}}
>
@{driver.userName}
</span>
)} )}
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}>{driver.phone}</span> <span
style={{
fontSize: 12,
fontFamily: "var(--font-mono)",
color: "var(--color-text-muted)",
}}
>
{driver.phone}
</span>
{statusConfig && ( {statusConfig && (
<span style={{ <span
borderRadius: "var(--radius-full)", border: `1px solid ${statusConfig.border}`, style={{
background: statusConfig.bg, padding: "0.2rem 0.625rem", borderRadius: "var(--radius-full)",
fontSize: 11, fontWeight: 700, color: statusConfig.color, border: `1px solid ${statusConfig.border}`,
display: "inline-flex", alignItems: "center", gap: 5, background: statusConfig.bg,
}}> padding: "0.2rem 0.625rem",
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} /> 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} {statusConfig.label}
</span> </span>
)} )}
@@ -229,65 +437,153 @@ export default function DriverDetailPage() {
حذف السائق حذف السائق
</Button> </Button>
<Button variant="primary" onClick={() => setEditOpen(true)}> <Button variant="primary" onClick={() => setEditOpen(true)}>
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" /> <i
className="ti ti-edit"
style={{ fontSize: 14 }}
aria-hidden="true"
/>
تعديل السائق تعديل السائق
</Button> </Button>
</div> </div>
</div> </div>
</header> </header>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "1.5rem" }}> <div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
}}
>
<SectionCard title="البيانات الشخصية"> <SectionCard title="البيانات الشخصية">
<DetailRow label="الاسم الكامل" value={driver.name} /> <DetailRow label="الاسم الكامل" value={driver.name} />
<DetailRow label="رقم الجوال" value={driver.phone} mono /> <DetailRow label="رقم الجوال" value={driver.phone} mono />
<DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} /> <DetailRow label="البريد الإلكتروني" value={driver.email ?? "—"} />
<DetailRow label="العنوان" value={driver.address ?? "—"} /> <DetailRow label="العنوان" value={driver.address ?? "—"} />
<DetailRow label="الجنسية" value={driver.nationality ?? "—"} /> <DetailRow label="الجنسية" value={driver.nationality ?? "—"} />
<DetailRow label="الفرع" value={driver.branch?.name ?? "—"} /> <DetailRow label="الفرع" value={driver.branch?.name ?? "—"} />
<DetailRow label="نوع السائق" value={driver.driverType ?? "—"} /> <DetailRow label="نوع السائق" value={driver.driverType ?? "—"} />
</SectionCard> </SectionCard>
<SectionCard title="الهوية والتأمينات"> <SectionCard title="الهوية والتأمينات">
<DetailRow label="نوع الهوية" value={driver.nationalIdType ? NATIONAL_ID_TYPE_MAP[driver.nationalIdType] : "—"} /> <DetailRow
<DetailRow label="رقم الهوية" value={driver.nationalId ?? "—"} mono /> label="نوع الهوية"
<DetailRow label="انتهاء الهوية" value={fmtDate(driver.nationalIdExpiry)} warn={isExpiringSoon(driver.nationalIdExpiry)} /> value={
driver.nationalIdType
? NATIONAL_ID_TYPE_MAP[driver.nationalIdType]
: "—"
}
/>
<DetailRow
label="رقم الهوية"
value={driver.nationalId ?? "—"}
mono
/>
<DetailRow
label="انتهاء الهوية"
value={fmtDate(driver.nationalIdExpiry)}
warn={isExpiringSoon(driver.nationalIdExpiry)}
/>
<DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono /> <DetailRow label="رقم GOSI" value={driver.gosiNumber ?? "—"} mono />
</SectionCard> </SectionCard>
<SectionCard title="بيانات رخصة القيادة"> <SectionCard title="بيانات رخصة القيادة">
<DetailRow label="رقم الرخصة" value={driver.licenseNumber ?? "—"} mono /> <DetailRow
label="رقم الرخصة"
value={driver.licenseNumber ?? "—"}
mono
/>
<DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} /> <DetailRow label="نوع الرخصة" value={driver.licenseType ?? "—"} />
<DetailRow label="انتهاء الرخصة" value={fmtDate(driver.licenseExpiry)} warn={isExpiringSoon(driver.licenseExpiry)} /> <DetailRow
label="انتهاء الرخصة"
value={fmtDate(driver.licenseExpiry)}
warn={isExpiringSoon(driver.licenseExpiry)}
/>
</SectionCard> </SectionCard>
<SectionCard title="بطاقة السائق"> <SectionCard title="بطاقة السائق">
<DetailRow label="رقم البطاقة" value={driver.driverCardNumber ?? "—"} mono /> <DetailRow
<DetailRow label="نوع البطاقة" value={driver.driverCardType ? DRIVER_CARD_TYPE_MAP[driver.driverCardType] : "—"} /> label="رقم البطاقة"
<DetailRow label="انتهاء البطاقة" value={fmtDate(driver.driverCardExpiry)} warn={isExpiringSoon(driver.driverCardExpiry)} /> value={driver.driverCardNumber ?? "—"}
mono
/>
<DetailRow
label="نوع البطاقة"
value={
driver.driverCardType
? DRIVER_CARD_TYPE_MAP[driver.driverCardType]
: "—"
}
/>
<DetailRow
label="انتهاء البطاقة"
value={fmtDate(driver.driverCardExpiry)}
warn={isExpiringSoon(driver.driverCardExpiry)}
/>
</SectionCard> </SectionCard>
<SectionCard title="معلومات النظام"> <SectionCard title="معلومات النظام">
<DetailRow label="اسم المستخدم" value={driver.userName ?? "—"} mono /> <DetailRow
<DetailRow label="تاريخ الإضافة" value={fmtDate(driver.createdAt)} /> label="اسم المستخدم"
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} /> value={driver.userName ?? "—"}
mono
/>
<DetailRow
label="تاريخ الإضافة"
value={fmtDate(driver.createdAt)}
/>
<DetailRow label="آخر تحديث" value={fmtDate(driver.updatedAt)} />
</SectionCard> </SectionCard>
{driver.statusHistory && driver.statusHistory.length > 0 && ( {driver.statusHistory && driver.statusHistory.length > 0 && (
<SectionCard title="سجل الحالات"> <SectionCard title="سجل الحالات">
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}> <div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{driver.statusHistory.map((h) => { {driver.statusHistory.map((h) => {
const s = DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active; const s =
DRIVER_STATUS_MAP[h.status] ?? DRIVER_STATUS_MAP.Active;
return ( return (
<div key={h.id} style={{ <div
display: "flex", justifyContent: "space-between", alignItems: "center", key={h.id}
borderRadius: "var(--radius-md)", border: `1px solid ${s.border}`, style={{
background: s.bg, padding: "0.5rem 0.875rem", display: "flex",
}}> justifyContent: "space-between",
alignItems: "center",
borderRadius: "var(--radius-md)",
border: `1px solid ${s.border}`,
background: s.bg,
padding: "0.5rem 0.875rem",
}}
>
<div> <div>
<span style={{ fontSize: 12, fontWeight: 600, color: s.color }}>{s.label}</span> <span
{h.reason && <span style={{ fontSize: 11, color: "var(--color-text-muted)", marginRight: 8 }}> {h.reason}</span>} style={{
fontSize: 12,
fontWeight: 600,
color: s.color,
}}
>
{s.label}
</span>
{h.reason && (
<span
style={{
fontSize: 11,
color: "var(--color-text-muted)",
marginRight: 8,
}}
>
{h.reason}
</span>
)}
</div> </div>
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>{fmtDate(h.createdAt)}</span> <span
style={{
fontSize: 11,
color: "var(--color-text-muted)",
}}
>
{fmtDate(h.createdAt)}
</span>
</div> </div>
); );
})} })}
@@ -297,23 +593,42 @@ export default function DriverDetailPage() {
</div> </div>
<SectionCard title="الصور والمستندات"> <SectionCard title="الصور والمستندات">
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "1.25rem" }}> <div
<PhotoCard url={driver.photoUrl} label="صورة السائق" /> style={{
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" /> display: "grid",
<PhotoCard url={driver.driverCardPhotoUrl} label="صورة بطاقة السائق" /> gridTemplateColumns: "repeat(3, 1fr)",
gap: "1.25rem",
}}
>
<PhotoCard url={driver.photoUrl} label="صورة السائق" />
<PhotoCard url={driver.nationalPhotoUrl} label="صورة الهوية" />
<PhotoCard
url={driver.driverCardPhotoUrl}
label="صورة بطاقة السائق"
/>
</div> </div>
</SectionCard> </SectionCard>
<div style={{ <div
borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", style={{
background: "var(--color-surface)", boxShadow: "var(--shadow-card)", padding: "1.5rem", borderRadius: "var(--radius-xl)",
}}> border: "1px solid var(--color-border)",
background: "var(--color-surface)",
boxShadow: "var(--shadow-card)",
padding: "1.5rem",
}}
>
<DriverReportPanel driverId={driver.id} /> <DriverReportPanel driverId={driver.id} />
</div> </div>
</section> </section>
{editOpen && ( {editOpen && (
<DriverFormModal <DriverFormModal
// CHANGE: added key — this page only ever edits a single driver
// (driverId from the route), but the key still protects against a
// remount bug if driver data is refetched with a new object identity
// after loadDriver() runs.
key={driver.id}
editDriver={driver} editDriver={driver}
branches={[]} branches={[]}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
@@ -331,4 +646,4 @@ export default function DriverDetailPage() {
/> />
</> </>
); );
} }

View File

@@ -5,28 +5,42 @@ import { Alert, ArchiveButton, ConfirmDialog } from "@/src/Components/UI";
import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers"; import { ArchivedDrivers } from "@/src/Components/Driver/archive/ArchivedDrivers";
import { DriverTable } from "@/src/Components/Driver/DriverTable"; import { DriverTable } from "@/src/Components/Driver/DriverTable";
import { useDrivers } from "@/src/hooks/useDriver"; import { useDrivers } from "@/src/hooks/useDriver";
import { CreateDriverPayload, Driver, UpdateDriverPayload } from "@/src/types/driver"; import {
import { DriverDetailPanel, DriverFormModal , } from "@/src/Components/Driver"; CreateDriverPayload,
Driver,
UpdateDriverPayload,
} from "@/src/types/driver";
import { DriverDetailPanel, DriverFormModal } from "@/src/Components/Driver";
// ── Page Component ──────────────────────────────────────────────────────────── // ── Page Component ────────────────────────────────────────────────────────────
export default function DriversPage() { export default function DriversPage() {
const { const {
drivers, loading, error, total, pages, page, drivers,
search, setPage, handleSearch, clearError, loading,
createDriver, updateDriver, deleteDriver, error,
total,
pages,
page,
search,
setPage,
handleSearch,
clearError,
createDriver,
updateDriver,
deleteDriver,
notification, notification,
} = useDrivers(); } = useDrivers();
// ── Panel / modal state ─────────────────────────────────────────────────── // ── Panel / modal state ───────────────────────────────────────────────────
const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null); const [selectedDriverId, setSelectedDriverId] = useState<string | null>(null);
const [formDriver, setFormDriver] = useState<Driver | null | "new">(null); const [formDriver, setFormDriver] = useState<Driver | null | "new">(null);
const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null); const [deleteTarget, setDeleteTarget] = useState<Driver | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Bumped after a successful edit to force the detail panel to re-fetch // Bumped after a successful edit to force the detail panel to re-fetch
const [panelRefreshKey, setPanelRefreshKey] = useState(0); const [panelRefreshKey, setPanelRefreshKey] = useState(0);
// Archive browser modal open/closed // Archive browser modal open/closed
const [archiveOpen, setArchiveOpen] = useState(false); const [archiveOpen, setArchiveOpen] = useState(false);
// ── Handlers ────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────
const handleEdit = useCallback((driver: Driver) => { const handleEdit = useCallback((driver: Driver) => {
@@ -84,39 +98,95 @@ export default function DriversPage() {
// ── Render ──────────────────────────────────────────────────────────────── // ── Render ────────────────────────────────────────────────────────────────
return ( return (
<> <>
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* ── Header ── */} {/* ── Header ── */}
<header style={{ <header
borderRadius: "var(--radius-xl)", style={{
border: "1px solid var(--color-border)", borderRadius: "var(--radius-xl)",
background: "var(--color-surface)", border: "1px solid var(--color-border)",
padding: "1.5rem 2rem", background: "var(--color-surface)",
boxShadow: "var(--shadow-card)", padding: "1.5rem 2rem",
}}> boxShadow: "var(--shadow-card)",
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600 }}> }}
>
<p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
}}
>
إدارة الكوادر إدارة الكوادر
</p> </p>
<div style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "1rem" }}> <div
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}> style={{
marginTop: "0.75rem",
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-end",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}
>
<div> <div>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: 0 }}> <h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
}}
>
السائقون السائقون
</h1> </h1>
<p style={{ marginTop: "0.25rem", fontSize: 13, color: "var(--color-text-muted)" }}> <p
style={{
marginTop: "0.25rem",
fontSize: 13,
color: "var(--color-text-muted)",
}}
>
إجمالي{" "} إجمالي{" "}
<strong style={{ color: "var(--color-text-primary)" }}>{total}</strong>{" "} <strong style={{ color: "var(--color-text-primary)" }}>
{total}
</strong>{" "}
سائق مسجل سائق مسجل
</p> </p>
</div> </div>
<div style={{ display: "flex", gap: "0.75rem", alignItems: "center", flexWrap: "wrap" }}> <div
style={{
display: "flex",
gap: "0.75rem",
alignItems: "center",
flexWrap: "wrap",
}}
>
{/* Search */} {/* Search */}
<div style={{ position: "relative", width: 288 }}> <div style={{ position: "relative", width: 288 }}>
<i <i
className="ti ti-search" className="ti ti-search"
aria-hidden="true" aria-hidden="true"
style={{ position: "absolute", right: 12, top: "50%", transform: "translateY(-50%)", fontSize: 16, color: "var(--color-text-hint)", pointerEvents: "none" }} style={{
position: "absolute",
right: 12,
top: "50%",
transform: "translateY(-50%)",
fontSize: 16,
color: "var(--color-text-hint)",
pointerEvents: "none",
}}
/> />
<input <input
type="text" type="text"
@@ -125,12 +195,17 @@ export default function DriversPage() {
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
dir="rtl" dir="rtl"
style={{ style={{
width: "100%", height: 40, paddingRight: 36, paddingLeft: 12, width: "100%",
height: 40,
paddingRight: 36,
paddingLeft: 12,
borderRadius: "var(--radius-lg)", borderRadius: "var(--radius-lg)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
background: "var(--color-surface)", background: "var(--color-surface)",
fontSize: 13, color: "var(--color-text-primary)", fontSize: 13,
outline: "none", fontFamily: "var(--font-sans)", color: "var(--color-text-primary)",
outline: "none",
fontFamily: "var(--font-sans)",
}} }}
/> />
</div> </div>
@@ -140,18 +215,27 @@ export default function DriversPage() {
type="button" type="button"
onClick={() => setFormDriver("new")} onClick={() => setFormDriver("new")}
style={{ style={{
height: 40, padding: "0 1.25rem", height: 40,
padding: "0 1.25rem",
borderRadius: "var(--radius-lg)", borderRadius: "var(--radius-lg)",
border: "none", border: "none",
background: "var(--color-brand-600)", background: "var(--color-brand-600)",
fontSize: 13, fontWeight: 700, color: "#FFF", fontSize: 13,
fontWeight: 700,
color: "#FFF",
cursor: "pointer", cursor: "pointer",
display: "flex", alignItems: "center", gap: 8, display: "flex",
alignItems: "center",
gap: 8,
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
flexShrink: 0, flexShrink: 0,
}} }}
> >
<i className="ti ti-plus" style={{ fontSize: 15 }} aria-hidden="true" /> <i
className="ti ti-plus"
style={{ fontSize: 15 }}
aria-hidden="true"
/>
إضافة سائق إضافة سائق
</button> </button>
</div> </div>
@@ -163,9 +247,7 @@ export default function DriversPage() {
{notification && ( {notification && (
<Alert type={notification.type} message={notification.message} /> <Alert type={notification.type} message={notification.message} />
)} )}
{error && ( {error && <Alert type="error" message={error} onClose={clearError} />}
<Alert type="error" message={error} onClose={clearError} />
)}
{/* ── Table ── */} {/* ── Table ── */}
<DriverTable <DriverTable
@@ -195,6 +277,11 @@ export default function DriversPage() {
{/* ── Form modal ── */} {/* ── Form modal ── */}
{formDriver !== null && ( {formDriver !== null && (
<DriverFormModal <DriverFormModal
// CHANGE: added key — forces remount on switching between "new" and
// different edit targets, same fix applied to UserFormModal, so
// useForm's defaultValues (roleId/branchId/status/etc.) always match
// the correct driver instead of possibly reusing stale form state.
key={formDriver === "new" ? "new" : formDriver.id}
editDriver={formDriver === "new" ? null : formDriver} editDriver={formDriver === "new" ? null : formDriver}
branches={[]} branches={[]}
onClose={() => setFormDriver(null)} onClose={() => setFormDriver(null)}
@@ -213,12 +300,10 @@ export default function DriversPage() {
/> />
{/* ── Archive browser modal ── */} {/* ── Archive browser modal ── */}
{archiveOpen && ( {archiveOpen && <ArchivedDrivers onClose={() => setArchiveOpen(false)} />}
<ArchivedDrivers onClose={() => setArchiveOpen(false)} />
)}
{/* ── Floating button to open the archive browser ── */} {/* ── Floating button to open the archive browser ── */}
<ArchiveButton onClick={() => setArchiveOpen(true)} /> <ArchiveButton onClick={() => setArchiveOpen(true)} />
</> </>
); );
} }

View File

@@ -1,7 +1,5 @@
"use client"; "use client";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { Button, ConfirmDialog, Spinner } from "@/src/Components/UI"; import { Button, ConfirmDialog, Spinner } from "@/src/Components/UI";
@@ -9,7 +7,11 @@ import { TripFormModal } from "@/src/Components/Trip";
import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel"; import { TripReportPanel } from "@/src/Components/Trip_Report/Tripreportpanel";
import { tripService } from "@/src/services/trip.service"; import { tripService } from "@/src/services/trip.service";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip"; import type {
Trip,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
import { TRIP_STATUS_MAP } from "@/src/types/trip"; import { TRIP_STATUS_MAP } from "@/src/types/trip";
import { Toast, type ToastNotification } from "@/src/Components/UI/Toast"; import { Toast, type ToastNotification } from "@/src/Components/UI/Toast";
@@ -22,14 +24,20 @@ function extractApiMessage(err: unknown, fallback: string): string {
if (err && typeof err === "object") { if (err && typeof err === "object") {
const e = err as Record<string, unknown>; const e = err as Record<string, unknown>;
const responseData = (e["response"] as Record<string, unknown> | undefined)?.["data"]; const responseData = (
e["response"] as Record<string, unknown> | undefined
)?.["data"];
if (responseData && typeof responseData === "object") { if (responseData && typeof responseData === "object") {
const rd = responseData as Record<string, unknown>; const rd = responseData as Record<string, unknown>;
if (typeof rd["message"] === "string" && rd["message"].trim()) return rd["message"]; if (typeof rd["message"] === "string" && rd["message"].trim())
if (Array.isArray(rd["message"])) return (rd["message"] as string[]).join(" — "); return rd["message"];
if (typeof rd["error"] === "string" && rd["error"].trim()) return rd["error"]; 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"]; if (typeof e["message"] === "string" && e["message"].trim())
return e["message"];
} }
return fallback; return fallback;
@@ -119,7 +127,13 @@ function DetailRow({
borderBottom: "1px solid var(--color-border)", borderBottom: "1px solid var(--color-border)",
}} }}
> >
<span style={{ fontSize: 12, color: "var(--color-text-muted)", fontWeight: 600 }}> <span
style={{
fontSize: 12,
color: "var(--color-text-muted)",
fontWeight: 600,
}}
>
{label} {label}
</span> </span>
<span <span
@@ -162,10 +176,24 @@ function StatCard({
gap: 4, gap: 4,
}} }}
> >
<span style={{ fontSize: 11, fontWeight: 600, color: "var(--color-text-muted)", letterSpacing: "0.05em" }}> <span
style={{
fontSize: 11,
fontWeight: 600,
color: "var(--color-text-muted)",
letterSpacing: "0.05em",
}}
>
{label} {label}
</span> </span>
<span style={{ fontSize: 22, fontWeight: 800, color, fontFamily: "var(--font-mono)" }}> <span
style={{
fontSize: 22,
fontWeight: 800,
color,
fontFamily: "var(--font-mono)",
}}
>
{value} {value}
</span> </span>
</div> </div>
@@ -179,17 +207,19 @@ export default function TripDetailPage() {
const router = useRouter(); const router = useRouter();
const tripId = params?.tripId as string; const tripId = params?.tripId as string;
const [trip, setTrip] = useState<Trip | null>(null); const [trip, setTrip] = useState<Trip | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// ── Modal state ─────────────────────────────────────────────────────────── // ── Modal state ───────────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// ── Notifications (standalone — no longer borrowed from the list hook) ─── // ── Notifications (standalone — no longer borrowed from the list hook) ───
const [notification, setNotification] = useState<ToastNotification | null>(null); const [notification, setNotification] = useState<ToastNotification | null>(
null,
);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const notify = useCallback((n: ToastNotification) => { const notify = useCallback((n: ToastNotification) => {
@@ -203,7 +233,12 @@ export default function TripDetailPage() {
setNotification(null); setNotification(null);
}, []); }, []);
useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []); useEffect(
() => () => {
if (timerRef.current) clearTimeout(timerRef.current);
},
[],
);
// ── Load trip ───────────────────────────────────────────────────────────── // ── Load trip ─────────────────────────────────────────────────────────────
const loadTrip = useCallback(async () => { const loadTrip = useCallback(async () => {
@@ -232,12 +267,19 @@ export default function TripDetailPage() {
if (!trip) return false; if (!trip) return false;
try { try {
const token = getStoredToken(); const token = getStoredToken();
const updated = await tripService.update(trip.id, payload as UpdateTripPayload, token); const updated = await tripService.update(
trip.id,
payload as UpdateTripPayload,
token,
);
setTrip(updated); setTrip(updated);
notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." }); notify({ type: "success", message: "تم تحديث بيانات الرحلة بنجاح." });
return true; return true;
} catch (err) { } catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر تحديث الرحلة.") }); notify({
type: "error",
message: extractApiMessage(err, "تعذّر تحديث الرحلة."),
});
return false; return false;
} }
}, },
@@ -253,7 +295,10 @@ export default function TripDetailPage() {
await tripService.delete(trip.id, token); await tripService.delete(trip.id, token);
router.push("/dashboard/trips"); router.push("/dashboard/trips");
} catch (err) { } catch (err) {
notify({ type: "error", message: extractApiMessage(err, "تعذّر حذف الرحلة.") }); notify({
type: "error",
message: extractApiMessage(err, "تعذّر حذف الرحلة."),
});
setDeleting(false); setDeleting(false);
} }
}, [trip, router, notify]); }, [trip, router, notify]);
@@ -297,7 +342,12 @@ export default function TripDetailPage() {
<p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}> <p style={{ fontSize: 14, color: "#DC2626", fontWeight: 600 }}>
{error ?? "الرحلة غير موجودة"} {error ?? "الرحلة غير موجودة"}
</p> </p>
<Button variant="secondary" size="sm" onClick={() => router.back()} style={{ marginTop: "1rem" }}> <Button
variant="secondary"
size="sm"
onClick={() => router.back()}
style={{ marginTop: "1rem" }}
>
رجوع رجوع
</Button> </Button>
</div> </div>
@@ -310,8 +360,9 @@ export default function TripDetailPage() {
{/* ── Toast notification ── */} {/* ── Toast notification ── */}
<Toast notification={notification} onDismiss={dismissNotification} /> <Toast notification={notification} onDismiss={dismissNotification} />
<section style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}> <section
style={{ display: "flex", flexDirection: "column", gap: "1.5rem" }}
>
{/* ── Page header ── */} {/* ── Page header ── */}
<header <header
style={{ style={{
@@ -327,13 +378,30 @@ export default function TripDetailPage() {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => router.back()} onClick={() => router.back()}
style={{ height: "auto", padding: 0, background: "none", marginBottom: "1rem" }} style={{
height: "auto",
padding: 0,
background: "none",
marginBottom: "1rem",
}}
> >
<i className="ti ti-arrow-left" style={{ fontSize: 14 }} aria-hidden="true" /> <i
className="ti ti-arrow-left"
style={{ fontSize: 14 }}
aria-hidden="true"
/>
العودة إلى قائمة الرحلات العودة إلى قائمة الرحلات
</Button> </Button>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", flexWrap: "wrap", gap: "1rem" }}> <div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
gap: "1rem",
}}
>
{/* Icon + title */} {/* Icon + title */}
<div style={{ display: "flex", alignItems: "center", gap: 16 }}> <div style={{ display: "flex", alignItems: "center", gap: 16 }}>
<div <div
@@ -350,20 +418,56 @@ export default function TripDetailPage() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
<span style={{ fontSize: 28, fontWeight: 700, color: "var(--color-brand-600)" }}> <span
style={{
fontSize: 28,
fontWeight: 700,
color: "var(--color-brand-600)",
}}
>
{trip.title.charAt(0)} {trip.title.charAt(0)}
</span> </span>
</div> </div>
<div> <div>
<p style={{ fontSize: 11, letterSpacing: "0.3em", textTransform: "uppercase", color: "#2563EB", fontWeight: 600, margin: 0 }}> <p
style={{
fontSize: 11,
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "#2563EB",
fontWeight: 600,
margin: 0,
}}
>
ملف الرحلة ملف الرحلة
</p> </p>
<h1 style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--color-text-primary)", margin: "4px 0 0" }}> <h1
style={{
fontSize: "1.5rem",
fontWeight: 700,
color: "var(--color-text-primary)",
margin: "4px 0 0",
}}
>
{trip.title} {trip.title}
</h1> </h1>
<div style={{ display: "flex", alignItems: "center", gap: 10, marginTop: 6, flexWrap: "wrap" }}> <div
<span style={{ fontSize: 12, fontFamily: "var(--font-mono)", color: "var(--color-text-muted)" }}> 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} #{trip.tripNumber}
</span> </span>
{statusConfig && ( {statusConfig && (
@@ -381,7 +485,15 @@ export default function TripDetailPage() {
gap: 5, gap: 5,
}} }}
> >
<span style={{ width: 6, height: 6, borderRadius: "50%", background: statusConfig.dot, flexShrink: 0 }} /> <span
style={{
width: 6,
height: 6,
borderRadius: "50%",
background: statusConfig.dot,
flexShrink: 0,
}}
/>
{statusConfig.label} {statusConfig.label}
</span> </span>
)} )}
@@ -428,7 +540,11 @@ export default function TripDetailPage() {
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
}} }}
> >
<i className="ti ti-edit" style={{ fontSize: 14 }} aria-hidden="true" /> <i
className="ti ti-edit"
style={{ fontSize: 14 }}
aria-hidden="true"
/>
تعديل الرحلة تعديل الرحلة
</button> </button>
</div> </div>
@@ -436,13 +552,35 @@ export default function TripDetailPage() {
</header> </header>
{/* ── Stats row ── */} {/* ── Stats row ── */}
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(150px, 1fr))", gap: "0.75rem" }}> <div
<StatCard label="المجمّع" value={trip.collectedCount} color="var(--color-brand-600)" /> style={{
<StatCard label="المُسلَّم" value={trip.deliveredCount} color="#16A34A" /> display: "grid",
<StatCard label="المُرتجع" value={trip.returnedCount} color="#D97706" /> 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 <StatCard
label="النقد المحصّل" label="النقد المحصّل"
value={trip.totalCashCollected != null ? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س` : "—"} value={
trip.totalCashCollected != null
? `${Number(trip.totalCashCollected).toLocaleString("ar-SA")} ر.س`
: "—"
}
color="#7C3AED" color="#7C3AED"
/> />
</div> </div>
@@ -457,45 +595,77 @@ export default function TripDetailPage() {
> >
{/* Trip Info */} {/* Trip Info */}
<SectionCard title="بيانات الرحلة"> <SectionCard title="بيانات الرحلة">
<DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} /> <DetailRow label="وقت البدء" value={fmtDateTime(trip.startTime)} />
<DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} /> <DetailRow label="وقت الانتهاء" value={fmtDateTime(trip.endTime)} />
<DetailRow label="الفرع" value={trip.branch?.name ?? "—"} /> <DetailRow label="الفرع" value={trip.branch?.name ?? "—"} />
{trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />} {trip.notes && <DetailRow label="ملاحظات" value={trip.notes} />}
{trip.endReason && <DetailRow label="سبب الإنهاء" value={trip.endReason} warn />} {trip.endReason && (
<DetailRow label="سبب الإنهاء" value={trip.endReason} warn />
)}
</SectionCard> </SectionCard>
{/* Driver */} {/* Driver */}
{trip.driver && ( {trip.driver && (
<SectionCard title="بيانات السائق"> <SectionCard title="بيانات السائق">
<DetailRow label="الاسم" value={trip.driver.name} /> <DetailRow label="الاسم" value={trip.driver.name} />
<DetailRow label="الجوال" value={trip.driver.phone} mono /> <DetailRow label="الجوال" value={trip.driver.phone} mono />
<DetailRow label="اسم المستخدم" value={trip.driver.userName ?? "—"} mono /> <DetailRow
<DetailRow label="البريد" value={trip.driver.email ?? "—"} /> label="اسم المستخدم"
<DetailRow label="الجنسية" value={trip.driver.nationality ?? "—"} /> value={trip.driver.userName ?? "—"}
<DetailRow label="رخصة القيادة" value={trip.driver.licenseNumber ?? "—"} mono /> mono
<DetailRow label="بطاقة السائق" value={trip.driver.driverCardNumber ?? "—"} mono /> />
<DetailRow label="رقم GOSI" value={trip.driver.gosiNumber ?? "—"} 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> </SectionCard>
)} )}
{/* Car */} {/* Car */}
{trip.car && ( {trip.car && (
<SectionCard title="بيانات السيارة"> <SectionCard title="بيانات السيارة">
<DetailRow label="الشركة" value={trip.car.manufacturer} /> <DetailRow label="الشركة" value={trip.car.manufacturer} />
<DetailRow label="الموديل" value={trip.car.model} /> <DetailRow label="الموديل" value={trip.car.model} />
<DetailRow label="السنة" value={trip.car.year != null ? String(trip.car.year) : "—"} /> <DetailRow
<DetailRow label="اللون" value={trip.car.color ?? "—"} /> label="السنة"
<DetailRow label="رقم اللوحة" value={trip.car.plateNumber} mono /> value={trip.car.year != null ? String(trip.car.year) : "—"}
<DetailRow label="حروف اللوحة" value={trip.car.plateLetters ?? "—"} mono /> />
<DetailRow label="رقم التسجيل" value={trip.car.registrationNumber ?? "—"} mono /> <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> </SectionCard>
)} )}
{/* System Info */} {/* System Info */}
<SectionCard title="معلومات النظام"> <SectionCard title="معلومات النظام">
<DetailRow label="رقم الرحلة" value={trip.tripNumber} mono /> <DetailRow label="رقم الرحلة" value={trip.tripNumber} mono />
<DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} /> <DetailRow label="تاريخ الإنشاء" value={fmtDate(trip.createdAt)} />
<DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} /> <DetailRow label="آخر تحديث" value={fmtDate(trip.updatedAt)} />
</SectionCard> </SectionCard>
</div> </div>
@@ -511,12 +681,14 @@ export default function TripDetailPage() {
> >
<TripReportPanel tripId={trip.id} /> <TripReportPanel tripId={trip.id} />
</div> </div>
</section> </section>
{/* ── Edit modal ── */} {/* ── Edit modal ── */}
{editOpen && ( {editOpen && (
<TripFormModal <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} editTrip={trip}
onClose={() => setEditOpen(false)} onClose={() => setEditOpen(false)}
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
@@ -534,4 +706,4 @@ export default function TripDetailPage() {
/> />
</> </>
); );
} }

View File

@@ -15,8 +15,17 @@ import { useRouter } from "next/navigation";
import { useTrips } from "@/src/hooks/useTrip"; import { useTrips } from "@/src/hooks/useTrip";
import { TripFormModal, TripTable } from "@/src/Components/Trip"; import { TripFormModal, TripTable } from "@/src/Components/Trip";
import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal"; import { ArchivedTripModal } from "@/src/Components/Trip/archive/ArchivedTripModal";
import { Alert, ArchiveButton, ConfirmDialog, Toast } from "@/src/Components/UI"; import {
import type { Trip, CreateTripPayload, UpdateTripPayload } from "@/src/types/trip"; Alert,
ArchiveButton,
ConfirmDialog,
Toast,
} from "@/src/Components/UI";
import type {
Trip,
CreateTripPayload,
UpdateTripPayload,
} from "@/src/types/trip";
import Header from "@/src/Components/UI/Header"; import Header from "@/src/Components/UI/Header";
// ── Page ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
@@ -147,6 +156,9 @@ export default function TripsPage() {
{/* ── Form modal ── */} {/* ── Form modal ── */}
{showForm && ( {showForm && (
<TripFormModal <TripFormModal
// CHANGE: added key — ensures defaultValues reset correctly when
// switching between "add new trip" and editing different trips.
key={editTrip === null ? "new" : editTrip.id}
editTrip={editTrip} editTrip={editTrip}
onClose={() => setShowForm(false)} onClose={() => setShowForm(false)}
onSubmit={async (payload, isNew) => { onSubmit={async (payload, isNew) => {
@@ -170,7 +182,9 @@ export default function TripsPage() {
{/* ── Archive browser modal — now the split ArchivedTripModal directly, {/* ── Archive browser modal — now the split ArchivedTripModal directly,
no local wrapper needed since it already ships its own search bar, no local wrapper needed since it already ships its own search bar,
table, and detail-view wiring. ── */} table, and detail-view wiring. ── */}
{archiveOpen && <ArchivedTripModal onClose={() => setArchiveOpen(false)} />} {archiveOpen && (
<ArchivedTripModal onClose={() => setArchiveOpen(false)} />
)}
{/* ── Floating archive button — same component Driver page uses ── */} {/* ── Floating archive button — same component Driver page uses ── */}
<ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" /> <ArchiveButton onClick={() => setArchiveOpen(true)} label="الأرشيف" />
@@ -178,4 +192,4 @@ export default function TripsPage() {
<Toast notification={notification} onDismiss={dismissNotification} /> <Toast notification={notification} onDismiss={dismissNotification} />
</div> </div>
); );
} }

View File

@@ -17,13 +17,12 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
// ⚠️ اتصلح: الـ Navbar والـ footer كانوا برة <html>/<body> وده HTML غير صالح
<html lang="ar" dir="rtl"> <html lang="ar" dir="rtl">
<body className="app-shell" suppressHydrationWarning> <body className="app-shell" suppressHydrationWarning>
{/* CHANGE: mounted globally so every route (not just the dashboard // CHANGE: added — fixes hydration mismatch caused by browser extensions
form pages) gets protected from extension-injected attributes */}
<ExtensionAttributeCleanup /> <ExtensionAttributeCleanup />
{/* الناف بار بيظهر بس لو مفيش يوزر مسجل دخول */} // CHANGE: navbar is conditional based on route, so we can hide it on login/register pages
<ConditionalNavbar /> <ConditionalNavbar />
{children} {children}
<Footer /> <Footer />

View File

@@ -6,7 +6,11 @@ import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, FileInput, Input, Modal, Select } from "../UI"; import { Alert, Button, FileInput, Input, Modal, Select } from "../UI";
import { get } from "@/src/services/api"; import { get } from "@/src/services/api";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { createDriverSchema, updateDriverSchema } from "@/src/validations/driver.validator"; import {
createDriverSchema,
updateDriverSchema,
} from "@/src/validations/driver.validator";
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
import type { import type {
Driver, Driver,
CreateDriverPayload, CreateDriverPayload,
@@ -105,7 +109,9 @@ export function DriverFormModal({
// have structurally different required fields (union type yupResolver's // have structurally different required fields (union type yupResolver's
// single-schema signature won't accept), and yup's inferred type for // single-schema signature won't accept), and yup's inferred type for
// .optional() fields is structurally incompatible with DriverFormValues. // .optional() fields is structurally incompatible with DriverFormValues.
resolver: yupResolver((isNew ? createDriverSchema : updateDriverSchema) as any) as any, resolver: yupResolver(
(isNew ? createDriverSchema : updateDriverSchema) as any,
) as any,
defaultValues: { defaultValues: {
name: editDriver?.name ?? "", name: editDriver?.name ?? "",
phone: editDriver?.phone ?? "", phone: editDriver?.phone ?? "",
@@ -113,9 +119,12 @@ export function DriverFormModal({
address: editDriver?.address ?? "", address: editDriver?.address ?? "",
nationality: editDriver?.nationality ?? "", nationality: editDriver?.nationality ?? "",
nationalIdType: editDriver?.nationalIdType ?? "", nationalIdType: editDriver?.nationalIdType ?? "",
nationalId: (editDriver as Driver & { nationalId?: string })?.nationalId ?? "", nationalId:
(editDriver as Driver & { nationalId?: string })?.nationalId ?? "",
nationalIdExpiry: nationalIdExpiry:
(editDriver as Driver & { nationalIdExpiry?: string })?.nationalIdExpiry?.slice(0, 10) ?? "", (
editDriver as Driver & { nationalIdExpiry?: string }
)?.nationalIdExpiry?.slice(0, 10) ?? "",
gosiNumber: editDriver?.gosiNumber ?? "", gosiNumber: editDriver?.gosiNumber ?? "",
licenseNumber: editDriver?.licenseNumber ?? "", licenseNumber: editDriver?.licenseNumber ?? "",
licenseType: editDriver?.licenseType ?? "", licenseType: editDriver?.licenseType ?? "",
@@ -135,29 +144,30 @@ export function DriverFormModal({
// ── Local branches (auto-fetched if prop is empty) ───────────────────────── // ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp); const [branches, setBranches] = useState<Branch[]>(branchesProp);
useEffect(() => { useEffect(() => {
// defaultValues.branchId was applied before this list existed, so the
// <select> had no matching <option> yet and silently fell back to "" —
// reapply the saved id now that the option actually exists in the DOM.
const savedBranchId = (editDriver as Driver & { branchId?: string })?.branchId;
if (branchesProp.length > 0) { if (branchesProp.length > 0) {
queueMicrotask(() => { queueMicrotask(() => setBranches(branchesProp));
setBranches(branchesProp);
if (savedBranchId) setValue("branchId", savedBranchId);
});
return; return;
} }
const token = getStoredToken(); const token = getStoredToken();
get<{ data: { data: Branch[] } }>("branches?limit=100", token) get<{ data: { data: Branch[] } }>("branches?limit=100", token)
.then((res) => { .then((res) => {
const list = (res as unknown as { data: { data: Branch[] } }).data?.data ?? []; const list =
(res as unknown as { data: { data: Branch[] } }).data?.data ?? [];
setBranches(list); setBranches(list);
if (savedBranchId) setValue("branchId", savedBranchId);
}) })
.catch(() => { .catch(() => {
/* silently ignore */ /* silently ignore */
}); });
}, [branchesProp, editDriver, setValue]); }, [branchesProp]);
// CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
// calls above with useEditFormSync — same fix, shared implementation.
useEditFormSync(
setValue,
"branchId",
(editDriver as Driver & { branchId?: string })?.branchId,
branches,
);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");
@@ -171,20 +181,27 @@ export function DriverFormModal({
const submitHandler = useCallback( const submitHandler = useCallback(
async (data: DriverFormValues) => { async (data: DriverFormValues) => {
const payload: Record<string, unknown> = { name: data.name, phone: data.phone }; const payload: Record<string, unknown> = {
name: data.name,
phone: data.phone,
};
if (data.email) payload.email = data.email; if (data.email) payload.email = data.email;
if (data.address) payload.address = data.address; if (data.address) payload.address = data.address;
if (data.nationality) payload.nationality = data.nationality; if (data.nationality) payload.nationality = data.nationality;
if (data.nationalIdType) payload.nationalIdType = data.nationalIdType; if (data.nationalIdType) payload.nationalIdType = data.nationalIdType;
if (data.nationalId) payload.nationalId = data.nationalId; if (data.nationalId) payload.nationalId = data.nationalId;
if (data.nationalIdExpiry) payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry); if (data.nationalIdExpiry)
payload.nationalIdExpiry = toIsoDateTime(data.nationalIdExpiry);
if (data.gosiNumber) payload.gosiNumber = data.gosiNumber; if (data.gosiNumber) payload.gosiNumber = data.gosiNumber;
if (data.licenseNumber) payload.licenseNumber = data.licenseNumber; if (data.licenseNumber) payload.licenseNumber = data.licenseNumber;
if (data.licenseType) payload.licenseType = data.licenseType; if (data.licenseType) payload.licenseType = data.licenseType;
if (data.licenseExpiry) payload.licenseExpiry = toIsoDateTime(data.licenseExpiry); if (data.licenseExpiry)
if (data.driverCardNumber) payload.driverCardNumber = data.driverCardNumber; payload.licenseExpiry = toIsoDateTime(data.licenseExpiry);
if (data.driverCardNumber)
payload.driverCardNumber = data.driverCardNumber;
if (data.driverCardType) payload.driverCardType = data.driverCardType; if (data.driverCardType) payload.driverCardType = data.driverCardType;
if (data.driverCardExpiry) payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry); if (data.driverCardExpiry)
payload.driverCardExpiry = toIsoDateTime(data.driverCardExpiry);
if (data.driverType) payload.driverType = data.driverType; if (data.driverType) payload.driverType = data.driverType;
if (data.branchId) payload.branchId = data.branchId; if (data.branchId) payload.branchId = data.branchId;
if (!isNew) payload.status = data.status; if (!isNew) payload.status = data.status;
@@ -196,15 +213,23 @@ export function DriverFormModal({
setApiError(""); setApiError("");
try { try {
const ok = await onSubmit(payload as unknown as CreateDriverPayload, isNew); const ok = await onSubmit(
payload as unknown as CreateDriverPayload,
isNew,
);
if (ok) { if (ok) {
onClose(); onClose();
} else { } else {
setError("name", { message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً." }); setError("name", {
message: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.",
});
setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."); setApiError("حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.");
} }
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً."; const message =
err instanceof Error
? err.message
: "حدث خطأ غير متوقع. يرجى المحاولة لاحقاً.";
setError("name", { message }); setError("name", { message });
setApiError(message); setApiError(message);
} }
@@ -220,10 +245,15 @@ export function DriverFormModal({
onClose={onClose} onClose={onClose}
size="lg" size="lg"
subtitle={isNew ? "إضافة سائق" : "تعديل سائق"} subtitle={isNew ? "إضافة سائق" : "تعديل سائق"}
title={isNew ? "سائق جديد" : editDriver?.name ?? ""} title={isNew ? "سائق جديد" : (editDriver?.name ?? "")}
footer={ footer={
<> <>
<Button variant="secondary" type="button" onClick={onClose} disabled={isSubmitting}> <Button
variant="secondary"
type="button"
onClick={onClose}
disabled={isSubmitting}
>
إلغاء إلغاء
</Button> </Button>
<Button type="submit" form={FORM_ID} loading={isSubmitting}> <Button type="submit" form={FORM_ID} loading={isSubmitting}>
@@ -240,7 +270,11 @@ export function DriverFormModal({
className="flex flex-col gap-4" className="flex flex-col gap-4"
> >
{errors.name?.type === "manual" && ( {errors.name?.type === "manual" && (
<Alert type="error" message={errors.name.message ?? ""} onClose={() => setApiError("")} /> <Alert
type="error"
message={errors.name.message ?? ""}
onClose={() => setApiError("")}
/>
)} )}
{/* ── Section: Personal Info ── */} {/* ── Section: Personal Info ── */}
@@ -252,7 +286,9 @@ export function DriverFormModal({
placeholder="محمد عبدالله" placeholder="محمد عبدالله"
dir="rtl" dir="rtl"
autoComplete="off" autoComplete="off"
error={errors.name?.type !== "manual" ? errors.name?.message : undefined} error={
errors.name?.type !== "manual" ? errors.name?.message : undefined
}
{...register("name")} {...register("name")}
/> />
@@ -404,7 +440,9 @@ export function DriverFormModal({
> >
<option value="">اختر الفرع</option> <option value="">اختر الفرع</option>
{branches.map((b) => ( {branches.map((b) => (
<option key={b.id} value={b.id}>{b.name}</option> <option key={b.id} value={b.id}>
{b.name}
</option>
))} ))}
</Select> </Select>
@@ -429,7 +467,14 @@ export function DriverFormModal({
{/* ── Section: Photos ── */} {/* ── Section: Photos ── */}
<p style={sectionHeadingStyle}>الصور والمستندات</p> <p style={sectionHeadingStyle}>الصور والمستندات</p>
<p style={{ fontSize: 11, color: "var(--color-text-hint)", margin: "0.25rem 0 0", fontWeight: 400 }}> <p
style={{
fontSize: 11,
color: "var(--color-text-hint)",
margin: "0.25rem 0 0",
fontWeight: 400,
}}
>
جميع حقول الصور اختيارية جميع حقول الصور اختيارية
</p> </p>
@@ -440,25 +485,37 @@ export function DriverFormModal({
name="photo" name="photo"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FileInput label="صورة السائق" current={field.value} onChange={field.onChange} /> <FileInput
label="صورة السائق"
current={field.value}
onChange={field.onChange}
/>
)} )}
/> />
<Controller <Controller
name="nationalPhoto" name="nationalPhoto"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FileInput label="صورة الهوية" current={field.value} onChange={field.onChange} /> <FileInput
label="صورة الهوية"
current={field.value}
onChange={field.onChange}
/>
)} )}
/> />
<Controller <Controller
name="driverCardPhoto" name="driverCardPhoto"
control={control} control={control}
render={({ field }) => ( render={({ field }) => (
<FileInput label="صورة البطاقة" current={field.value} onChange={field.onChange} /> <FileInput
label="صورة البطاقة"
current={field.value}
onChange={field.onChange}
/>
)} )}
/> />
</div> </div>
</form> </form>
</Modal> </Modal>
); );
} }

View File

@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Modal, Select, Textarea } from "../UI"; import { Alert, Button, Input, Modal, Select, Textarea } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
import { import {
createTripSchema, createTripSchema,
updateTripSchema, updateTripSchema,
@@ -126,30 +127,25 @@ export function TripFormModal({
}, },
}); });
useEffect(() => { // ── Fetch dropdown options once on mount ────────────────────────────────────
const token = getStoredToken(); // CHANGE: split fetching from syncing — fetching stays a plain useEffect,
// syncing the saved id back into the form is now handled by useEditFormSync
// below, once per field, instead of being inlined into each .then().
useEffect(() => {
const token = getStoredToken();
driverService.getActiveOptions(token).then((list) => { driverService.getActiveOptions(token).then(setDrivers).catch(() => {});
setDrivers(list); carService.getActiveOptions(token).then(setCars).catch(() => {});
// defaultValues were applied before this list existed, so the <select> branchService.getOptions(token).then(setBranches).catch(() => {});
// had no matching <option> yet and silently fell back to "" — reapply }, []);
// the saved id now that the option actually exists in the DOM.
const savedDriverId = editTrip?.driverId ?? editTrip?.driver?.id;
if (savedDriverId) setValue("driverId", savedDriverId);
}).catch(() => {});
carService.getActiveOptions(token).then((list) => { // CHANGE: replaced the three manual `if (savedXId) setValue(...)` calls
setCars(list); // (previously inlined inside each .then() above) with useEditFormSync —
const savedCarId = editTrip?.carId ?? editTrip?.car?.id; // each hook call re-applies the saved id once its matching option list
if (savedCarId) setValue("carId", savedCarId); // has loaded, regardless of which fetch resolves first.
}).catch(() => {}); useEditFormSync(setValue, "driverId", editTrip?.driverId ?? editTrip?.driver?.id, drivers);
useEditFormSync(setValue, "carId", editTrip?.carId ?? editTrip?.car?.id, cars);
branchService.getOptions(token).then((list) => { useEditFormSync(setValue, "branchId", editTrip?.branchId ?? editTrip?.branch?.id, branches);
setBranches(list);
const savedBranchId = editTrip?.branchId ?? editTrip?.branch?.id;
if (savedBranchId) setValue("branchId", savedBranchId);
}).catch(() => {});
}, [editTrip, setValue]);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");

View File

@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup"; import { yupResolver } from "@hookform/resolvers/yup";
import { Alert, Button, Input, Modal, Select } from "../UI"; import { Alert, Button, Input, Modal, Select } from "../UI";
import { getStoredToken } from "@/src/lib/auth"; import { getStoredToken } from "@/src/lib/auth";
import { useEditFormSync } from "@/src/hooks/useEditFormSync";
import { import {
createCarSchema, createCarSchema,
updateCarSchema, updateCarSchema,
@@ -134,38 +135,31 @@ export function CarFormModal({
}, },
}); });
// ── Local branches (auto-fetched if prop is empty) ───────────────────────── // ── Local branches (auto-fetched if prop is empty) ─────────────────────────
const [branches, setBranches] = useState<Branch[]>(branchesProp); const [branches, setBranches] = useState<Branch[]>(branchesProp);
const loadBranches = useCallback(() => { const loadBranches = useCallback(() => {
// defaultValues.branchId was applied before this list existed, so the if (branchesProp.length > 0) {
// <select> had no matching <option> yet and silently fell back to "" — queueMicrotask(() => setBranches(branchesProp));
// reapply the saved id now that the option actually exists in the DOM. return;
const savedBranchId = editCar?.branch?.id; }
const token = getStoredToken();
branchService
.getOptions(token)
.then((list) => {
queueMicrotask(() => setBranches(list as unknown as Branch[]));
})
.catch(() => {
/* silently ignore */
});
}, [branchesProp]);
if (branchesProp.length > 0) { useEffect(() => {
queueMicrotask(() => { loadBranches();
setBranches(branchesProp); }, [loadBranches]);
if (savedBranchId) setValue("branchId", savedBranchId);
});
return;
}
const token = getStoredToken();
branchService
.getOptions(token)
.then((list) => {
queueMicrotask(() => {
setBranches(list as unknown as Branch[]);
if (savedBranchId) setValue("branchId", savedBranchId);
});
})
.catch(() => {
/* silently ignore */
});
}, [branchesProp, editCar, setValue]);
useEffect(() => { // CHANGE: replaced the manual `if (savedBranchId) setValue("branchId", ...)`
loadBranches(); // calls above with useEditFormSync.
}, [loadBranches]); useEditFormSync(setValue, "branchId", editCar?.branch?.id, branches);
const [apiError, setApiError] = useState(""); const [apiError, setApiError] = useState("");

View File

@@ -14,12 +14,14 @@ export function useCurrentUser() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
//1. get token from local storage
const token = getStoredToken(); const token = getStoredToken();
//2. call userService.getMe(token) to get the current user
const res = await userService.getMe(token); const res = await userService.getMe(token);
// ملاحظة: مبقاش بنعمل res.data ?? res هنا قبل التمرير. // console.log("useCurrentUser: got me", res);
// extractMeUser هي المسؤولة الوحيدة عن تفكيك شكل الـ response، //3. extract the user from the response using extractMeUser(res)
// فبنبعتلها الـ res زي ما هو عشان نتجنب تفكيك مزدوج.
const u = extractMeUser(res); const u = extractMeUser(res);
//4. if user is null, throw an error
if (!u) throw new Error("لا توجد بيانات"); if (!u) throw new Error("لا توجد بيانات");
setUser(u as UserMe); setUser(u as UserMe);
} catch { } catch {

View File

@@ -82,7 +82,7 @@ useEffect(() => {
}, [page, search, loadUsers]); }, [page, search, loadUsers]);
// create new user // create new user
const createUser = useCallback(async (data: UserFormData): Promise<boolean> => { const createUser = useCallback(async (data: UserFormData): Promise<boolean> => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const user = await userService.create(data as UserFormData & { password: string }, token); const user = await userService.create(data as UserFormData & { password: string }, token);
@@ -100,7 +100,7 @@ useEffect(() => {
try { try {
const token = getStoredToken(); const token = getStoredToken();
const res = await userService.update(id, data, token); const res = await userService.update(id, data, token);
dispatch({ type: "UPDATE", user: res?.data }); dispatch({ type: "UPDATE", user: res });
notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." }); notify({ type: "success", message: "تم تحديث بيانات المستخدم بنجاح." });
return true; return true;
} catch (err) { } catch (err) {